@animalabs/agent-framework 0.11.0 → 0.13.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 (160) hide show
  1. package/dist/src/agent.d.ts +11 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +60 -4
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +72 -1
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +873 -123
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +7 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +15 -1
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/kv-unified-wire.d.ts +16 -0
  14. package/dist/src/kv-unified-wire.d.ts.map +1 -0
  15. package/dist/src/kv-unified-wire.js +2 -0
  16. package/dist/src/kv-unified-wire.js.map +1 -0
  17. package/dist/src/mcpl/channel-registry.d.ts +84 -1
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +188 -5
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  22. package/dist/src/mcpl/hook-orchestrator.js +28 -2
  23. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  24. package/dist/src/mcpl/push-handler.d.ts +6 -1
  25. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  26. package/dist/src/mcpl/push-handler.js +29 -2
  27. package/dist/src/mcpl/push-handler.js.map +1 -1
  28. package/dist/src/mcpl/reference-fetcher.d.ts +59 -0
  29. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -0
  30. package/dist/src/mcpl/reference-fetcher.js +240 -0
  31. package/dist/src/mcpl/reference-fetcher.js.map +1 -0
  32. package/dist/src/mcpl/references.d.ts +125 -0
  33. package/dist/src/mcpl/references.d.ts.map +1 -0
  34. package/dist/src/mcpl/references.js +262 -0
  35. package/dist/src/mcpl/references.js.map +1 -0
  36. package/dist/src/mcpl/types.d.ts +32 -0
  37. package/dist/src/mcpl/types.d.ts.map +1 -1
  38. package/dist/src/mcpl/types.js.map +1 -1
  39. package/dist/src/modules/index.d.ts +2 -1
  40. package/dist/src/modules/index.d.ts.map +1 -1
  41. package/dist/src/modules/index.js +1 -1
  42. package/dist/src/modules/index.js.map +1 -1
  43. package/dist/src/modules/workspace/index.d.ts +125 -2
  44. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  45. package/dist/src/modules/workspace/index.js +354 -58
  46. package/dist/src/modules/workspace/index.js.map +1 -1
  47. package/dist/src/modules/workspace/types.d.ts +7 -0
  48. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  49. package/dist/src/modules/workspace/types.js.map +1 -1
  50. package/dist/src/tool-result-history.d.ts.map +1 -1
  51. package/dist/src/tool-result-history.js +14 -0
  52. package/dist/src/tool-result-history.js.map +1 -1
  53. package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
  54. package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
  55. package/dist/src/tool-wrapper-prose-guard.js +37 -0
  56. package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
  57. package/dist/src/tune-out/coordinator.d.ts +144 -0
  58. package/dist/src/tune-out/coordinator.d.ts.map +1 -0
  59. package/dist/src/tune-out/coordinator.js +444 -0
  60. package/dist/src/tune-out/coordinator.js.map +1 -0
  61. package/dist/src/tune-out/tools.d.ts +43 -0
  62. package/dist/src/tune-out/tools.d.ts.map +1 -0
  63. package/dist/src/tune-out/tools.js +93 -0
  64. package/dist/src/tune-out/tools.js.map +1 -0
  65. package/dist/src/types/agent.d.ts +15 -0
  66. package/dist/src/types/agent.d.ts.map +1 -1
  67. package/dist/src/types/framework.d.ts +6 -0
  68. package/dist/src/types/framework.d.ts.map +1 -1
  69. package/dist/src/usage-accounting.d.ts +5 -0
  70. package/dist/src/usage-accounting.d.ts.map +1 -0
  71. package/dist/src/usage-accounting.js +11 -0
  72. package/dist/src/usage-accounting.js.map +1 -0
  73. package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
  74. package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
  75. package/dist/test/channel-incoming-targeting.test.js +81 -0
  76. package/dist/test/channel-incoming-targeting.test.js.map +1 -0
  77. package/dist/test/conversation-routing.test.js +39 -0
  78. package/dist/test/conversation-routing.test.js.map +1 -1
  79. package/dist/test/kv-unified-wiring.test.d.ts +2 -0
  80. package/dist/test/kv-unified-wiring.test.d.ts.map +1 -0
  81. package/dist/test/kv-unified-wiring.test.js +56 -0
  82. package/dist/test/kv-unified-wiring.test.js.map +1 -0
  83. package/dist/test/mcpl-push-convert.test.d.ts +2 -0
  84. package/dist/test/mcpl-push-convert.test.d.ts.map +1 -0
  85. package/dist/test/mcpl-push-convert.test.js +42 -0
  86. package/dist/test/mcpl-push-convert.test.js.map +1 -0
  87. package/dist/test/mcpl-reference-fetcher.test.d.ts +2 -0
  88. package/dist/test/mcpl-reference-fetcher.test.d.ts.map +1 -0
  89. package/dist/test/mcpl-reference-fetcher.test.js +199 -0
  90. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -0
  91. package/dist/test/per-agent-delivery.test.d.ts +2 -0
  92. package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
  93. package/dist/test/per-agent-delivery.test.js +108 -0
  94. package/dist/test/per-agent-delivery.test.js.map +1 -0
  95. package/dist/test/present-while-acting.test.js +64 -0
  96. package/dist/test/present-while-acting.test.js.map +1 -1
  97. package/dist/test/tool-result-history.test.js +78 -0
  98. package/dist/test/tool-result-history.test.js.map +1 -1
  99. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
  100. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
  101. package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
  102. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
  103. package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
  104. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
  105. package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
  106. package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
  107. package/dist/test/tune-out-e2e.test.d.ts +2 -0
  108. package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
  109. package/dist/test/tune-out-e2e.test.js +142 -0
  110. package/dist/test/tune-out-e2e.test.js.map +1 -0
  111. package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
  112. package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
  113. package/dist/test/tune-out-gate-composition.test.js +221 -0
  114. package/dist/test/tune-out-gate-composition.test.js.map +1 -0
  115. package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
  116. package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
  117. package/dist/test/tune-out-lifecycle.test.js +121 -0
  118. package/dist/test/tune-out-lifecycle.test.js.map +1 -0
  119. package/dist/test/turn-trigger-provenance.test.d.ts +2 -0
  120. package/dist/test/turn-trigger-provenance.test.d.ts.map +1 -0
  121. package/dist/test/turn-trigger-provenance.test.js +85 -0
  122. package/dist/test/turn-trigger-provenance.test.js.map +1 -0
  123. package/dist/test/usage-accounting.test.d.ts +2 -0
  124. package/dist/test/usage-accounting.test.d.ts.map +1 -0
  125. package/dist/test/usage-accounting.test.js +13 -0
  126. package/dist/test/usage-accounting.test.js.map +1 -0
  127. package/dist/test/workspace-branch-continuation.test.d.ts +15 -0
  128. package/dist/test/workspace-branch-continuation.test.d.ts.map +1 -0
  129. package/dist/test/workspace-branch-continuation.test.js +183 -0
  130. package/dist/test/workspace-branch-continuation.test.js.map +1 -0
  131. package/dist/test/workspace-read-file-from-disk.test.d.ts +17 -0
  132. package/dist/test/workspace-read-file-from-disk.test.d.ts.map +1 -0
  133. package/dist/test/workspace-read-file-from-disk.test.js +174 -0
  134. package/dist/test/workspace-read-file-from-disk.test.js.map +1 -0
  135. package/dist/test/workspace-read-image.test.js +8 -0
  136. package/dist/test/workspace-read-image.test.js.map +1 -1
  137. package/dist/test/workspace-restore-order.test.js +1 -1
  138. package/dist/test/workspace-restore-order.test.js.map +1 -1
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +2 -2
  141. package/src/agent.ts +77 -4
  142. package/src/framework.ts +953 -128
  143. package/src/gate/event-gate.ts +28 -3
  144. package/src/kv-unified-wire.ts +12 -0
  145. package/src/mcpl/channel-registry.ts +264 -8
  146. package/src/mcpl/hook-orchestrator.ts +29 -2
  147. package/src/mcpl/push-handler.ts +30 -2
  148. package/src/mcpl/reference-fetcher.ts +240 -0
  149. package/src/mcpl/references.ts +292 -0
  150. package/src/mcpl/types.ts +29 -0
  151. package/src/modules/index.ts +2 -1
  152. package/src/modules/workspace/index.ts +425 -66
  153. package/src/modules/workspace/types.ts +7 -0
  154. package/src/tool-result-history.ts +11 -0
  155. package/src/tool-wrapper-prose-guard.ts +38 -0
  156. package/src/tune-out/coordinator.ts +548 -0
  157. package/src/tune-out/tools.ts +129 -0
  158. package/src/types/agent.ts +16 -0
  159. package/src/types/framework.ts +7 -0
  160. package/src/usage-accounting.ts +8 -0
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { constants as fsConstants } from 'node:fs';
9
+ import type { Stats } from 'node:fs';
9
10
  import { open, readFile, stat, access, writeFile, unlink, mkdir, lstat, realpath } from 'node:fs/promises';
10
11
  import { join, resolve, relative, dirname, sep } from 'node:path';
11
12
  import type { JsStore } from '@animalabs/chronicle';
@@ -78,6 +79,82 @@ class WorkspaceImageReadError extends Error {
78
79
  }
79
80
  }
80
81
 
82
+ /**
83
+ * Why a workspace-owned filesystem read was refused. Distinguishes the cases a
84
+ * peer needs to react to differently: a path that never resolved inside the
85
+ * mount (`unknown_mount`, `traversal`), a mount whose root is gone
86
+ * (`mount_unavailable`), an ordinary missing file (`not_found`), a directory
87
+ * (`directory`), a symlink refused by the mount's `followSymlinks` policy
88
+ * (`symlink`), a symlink whose canonical target lands outside the canonical
89
+ * mount root (`escape`), and a file swapped underneath the read (`changed`).
90
+ */
91
+ export type WorkspaceReadErrorCode =
92
+ | 'unknown_mount'
93
+ | 'traversal'
94
+ | 'mount_unavailable'
95
+ | 'not_found'
96
+ | 'directory'
97
+ | 'symlink'
98
+ | 'escape'
99
+ | 'changed';
100
+
101
+ /** Where in the open sequence a `WorkspaceReadError` originated (diagnostics). */
102
+ export type WorkspaceReadStage =
103
+ | 'parse'
104
+ | 'lstat'
105
+ | 'realpath_root'
106
+ | 'open'
107
+ | 'fstat'
108
+ | 'post_lstat'
109
+ | 'realpath'
110
+ | 'stat'
111
+ | 'read';
112
+
113
+ /**
114
+ * Thrown by `WorkspaceModule.readFileFromDisk()`. Messages carry the
115
+ * mount-prefixed path only — never absolute filesystem paths — so they can be
116
+ * surfaced to the agent or logged without leaking host layout.
117
+ */
118
+ export class WorkspaceReadError extends Error {
119
+ constructor(
120
+ readonly code: WorkspaceReadErrorCode,
121
+ message: string,
122
+ readonly stage: WorkspaceReadStage,
123
+ /** Underlying errno code (e.g. 'ENOENT', 'ELOOP') when a syscall failed. */
124
+ readonly errno?: string,
125
+ ) {
126
+ super(message);
127
+ this.name = 'WorkspaceReadError';
128
+ }
129
+ }
130
+
131
+ /** Successful `readFileFromDisk()` result. */
132
+ export interface WorkspaceDiskReadResult {
133
+ /** File bytes — the whole file, or its first `maxBytes` when `truncated`. */
134
+ bytes: Buffer;
135
+ /** Full on-disk size in bytes, regardless of truncation. */
136
+ size: number;
137
+ /** True when `maxBytes` was set and the file was larger; `bytes` is a prefix. */
138
+ truncated: boolean;
139
+ /** mtime of the file actually read (for change-detection caches). */
140
+ mtimeMs: number;
141
+ /** Canonical (realpath) location of the file read — inside the canonical mount root by construction. */
142
+ realPath: string;
143
+ /** Mount the path resolved into. */
144
+ mount: string;
145
+ }
146
+
147
+ export interface ReadFileFromDiskOptions {
148
+ /** Read at most this many bytes (a bounded prefix read — the rest of the file is never loaded). */
149
+ maxBytes?: number;
150
+ }
151
+
152
+ function errnoOf(err: unknown): string | undefined {
153
+ return typeof err === 'object' && err !== null && 'code' in err && typeof (err as { code?: unknown }).code === 'string'
154
+ ? (err as { code: string }).code
155
+ : undefined;
156
+ }
157
+
81
158
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
82
159
  const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
83
160
  const GIF87A_SIGNATURE = Buffer.from('GIF87a', 'ascii');
@@ -292,9 +369,13 @@ function validateJpeg(bytes: Buffer, mountPrefixedPath: string): void {
292
369
  offset += 1;
293
370
 
294
371
  if (marker === JPEG_EOI) {
295
- if (!sawSof || !sawSos || offset !== bytes.length) {
372
+ if (!sawSof || !sawSos) {
296
373
  invalidImage('JPEG', mountPrefixedPath);
297
374
  }
375
+ // Bytes after EOI are tolerated: hardware encoders (e.g. Raspberry Pi
376
+ // camera stills) pad each frame to a 4-byte boundary with NULs after
377
+ // the EOI marker, and every mainstream decoder stops at EOI. The image
378
+ // proper (SOI..EOI) has been fully validated by this point.
298
379
  return;
299
380
  }
300
381
  if (marker === JPEG_TEM || (marker >= 0xd0 && marker <= 0xd7)) {
@@ -637,6 +718,95 @@ export class WorkspaceModule implements Module {
637
718
  }
638
719
  }
639
720
  this.savedState = null;
721
+
722
+ // Absorb out-of-band branch switches (e.g. an offline repair that left
723
+ // the store on a child branch): if the pinned branch is a strict ancestor
724
+ // of the current branch and disk state is fully contained in the current
725
+ // branch's history, re-pin. Without this, every materialize refuses
726
+ // forever after a repair, even though nothing on disk can be clobbered.
727
+ this.healBranchPins();
728
+ }
729
+
730
+ /**
731
+ * True when everything last materialized to disk for a mount is part of the
732
+ * CURRENT branch's history — i.e. the current branch is a linear
733
+ * continuation of the pinned branch as of the pinned sequence.
734
+ *
735
+ * Walks the current branch's parent chain. Safe iff the pinned branch is on
736
+ * the chain AND every fork point along the way is at or after `pinnedSeq`
737
+ * (a fork before `pinnedSeq` means disk holds records the current branch
738
+ * never had — genuine divergence). A missing/GC'd intermediate branch or a
739
+ * child without fork metadata is treated as unsafe: ancestry can't be
740
+ * proven, so the guard stays closed and `force` is the escape hatch.
741
+ */
742
+ private isLinearContinuation(
743
+ store: JsStore,
744
+ pinnedBranchId: string,
745
+ pinnedSeq: number,
746
+ ): boolean {
747
+ const current = store.currentBranch();
748
+ if (current.id === pinnedBranchId) return true;
749
+ const byId = new Map(store.listBranches().map((b) => [b.id, b]));
750
+ const visited = new Set<string>();
751
+ let cursor = byId.get(current.id);
752
+ while (cursor && !visited.has(cursor.id)) {
753
+ visited.add(cursor.id);
754
+ if (cursor.id === pinnedBranchId) return true;
755
+ if (cursor.parentId === undefined || cursor.branchPoint === undefined) return false;
756
+ if (cursor.branchPoint < pinnedSeq) return false;
757
+ cursor = byId.get(cursor.parentId);
758
+ }
759
+ return false;
760
+ }
761
+
762
+ /**
763
+ * Why materializing this mount is blocked on the current branch, or null if
764
+ * it isn't. Single source of truth for the materialize guard AND the
765
+ * `canMaterialize` status field, so status can never report `true` for a
766
+ * mount that materialize would refuse (the pre-fix defect: status computed
767
+ * per-mount id-equality while the guard checked ALL mounts).
768
+ */
769
+ private mountMaterializeBlockReason(store: JsStore, mount: MountState): string | null {
770
+ if (this.config.materializeOnlyActiveBranch === false) return null;
771
+ if (!mount.lastMaterializedBranchId) return null;
772
+ const current = store.currentBranch();
773
+ if (mount.lastMaterializedBranchId === current.id) return null;
774
+ if (this.isLinearContinuation(store, mount.lastMaterializedBranchId, mount.lastMaterializedSeq)) {
775
+ return null;
776
+ }
777
+ const pinned = store.listBranches().find((b) => b.id === mount.lastMaterializedBranchId);
778
+ const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
779
+ return (
780
+ `current branch "${current.name}" has diverged from the branch last materialized to disk ` +
781
+ `(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
782
+ `never had. Pass force: true to overwrite disk from the current branch if it is canonical.`
783
+ );
784
+ }
785
+
786
+ /**
787
+ * Re-pin mounts whose last-materialized branch is a proven ancestor of the
788
+ * current branch. Runs after persisted state is applied on restart, so an
789
+ * out-of-band branch switch (offline repair/treatment branches) heals at
790
+ * boot instead of wedging materialize until manual surgery.
791
+ */
792
+ private healBranchPins(): void {
793
+ const store = this.store;
794
+ if (!store) return;
795
+ const current = store.currentBranch();
796
+ for (const mount of this.mounts.values()) {
797
+ if (
798
+ mount.lastMaterializedBranchId &&
799
+ mount.lastMaterializedBranchId !== current.id &&
800
+ this.isLinearContinuation(store, mount.lastMaterializedBranchId, mount.lastMaterializedSeq)
801
+ ) {
802
+ console.warn(
803
+ `[workspace] Mount "${mount.config.name}": branch pin ${mount.lastMaterializedBranchId} → ` +
804
+ `${current.id} ("${current.name}") — current branch linearly continues the last ` +
805
+ `materialized branch (out-of-band switch, e.g. repair); disk state is preserved history.`,
806
+ );
807
+ mount.lastMaterializedBranchId = current.id;
808
+ }
809
+ }
640
810
  }
641
811
 
642
812
  /**
@@ -877,6 +1047,7 @@ export class WorkspaceModule implements Module {
877
1047
  properties: {
878
1048
  path: { type: 'string', description: 'Specific path to materialize (optional — defaults to all changed)' },
879
1049
  mount: { type: 'string', description: 'Specific mount (optional)' },
1050
+ force: { type: 'boolean', description: 'Materialize even if the current branch has diverged from the branch last written to disk (default false). Only needed for genuine divergence — descendant branches pass automatically.' },
880
1051
  },
881
1052
  },
882
1053
  },
@@ -994,8 +1165,17 @@ export class WorkspaceModule implements Module {
994
1165
  /**
995
1166
  * Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
996
1167
  * absolute filesystem path. Returns null if the mount is unknown or the
997
- * resolved path escapes the mount root (path-traversal guard). Public API
998
- * for peer modules that need to read workspace files directly.
1168
+ * resolved path escapes the mount root.
1169
+ *
1170
+ * **Lexical containment only.** The guard reasons about `..` segments in the
1171
+ * path string; it knows nothing about what is on disk. A symlink inside the
1172
+ * mount that targets an outside file passes this check, and a caller that
1173
+ * then does `fs.readFile(path)` reads the outside content. Use the returned
1174
+ * path for resolution-only purposes (write targets, nonexistent paths,
1175
+ * display); for reading file content, use {@link readFileFromDisk}, which
1176
+ * enforces the mount's `followSymlinks` policy and canonical containment.
1177
+ *
1178
+ * @deprecated for direct filesystem reads — call `readFileFromDisk()`.
999
1179
  */
1000
1180
  resolveAbsolutePath(mountPrefixedPath: string): string | null {
1001
1181
  try {
@@ -1189,82 +1369,107 @@ export class WorkspaceModule implements Module {
1189
1369
  return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
1190
1370
  }
1191
1371
 
1192
- private async readImageFromFilesystem(
1372
+ /**
1373
+ * Open a mount-relative file for reading with the mount boundary enforced on
1374
+ * the *filesystem*, not just the path string:
1375
+ *
1376
+ * 1. `lstat` the lexical path — refuse directories; refuse a final-component
1377
+ * symlink when the mount does not `followSymlinks`.
1378
+ * 2. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
1379
+ * holds against a symlink swapped in between lstat and open; where it
1380
+ * doesn't (Windows), re-`lstat` after opening instead.
1381
+ * 3. `realpath` both the mount root and the opened path and require canonical
1382
+ * containment — this is what catches an *intermediate* symlinked
1383
+ * directory escaping the mount, and a followed symlink whose target lands
1384
+ * outside, on every platform (junctions included).
1385
+ * 4. Confirm the descriptor and the canonical path are the same inode.
1386
+ *
1387
+ * `inspect` runs right after the descriptor is stat'd and before the
1388
+ * containment work, so callers can impose size policy in the same order the
1389
+ * image reader always has. The returned handle is the caller's to close.
1390
+ */
1391
+ private async openContainedFile(
1193
1392
  mount: MountState,
1194
1393
  relativePath: string,
1195
1394
  mountPrefixedPath: string,
1196
- maxSize: number,
1197
- ): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType }> {
1395
+ inspect?: (fileStat: Stats) => void,
1396
+ ): Promise<{ handle: Awaited<ReturnType<typeof open>>; fileStat: Stats; realFilePath: string }> {
1198
1397
  const lexicalPath = resolve(mount.config.path, relativePath);
1199
- const useNoFollow = !mount.config.followSymlinks && typeof fsConstants.O_NOFOLLOW === 'number';
1398
+ const follow = mount.config.followSymlinks === true;
1399
+ const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number';
1200
1400
 
1201
1401
  let fileInfo: Awaited<ReturnType<typeof lstat>>;
1202
1402
  try {
1203
1403
  fileInfo = await lstat(lexicalPath);
1204
1404
  } catch (err) {
1205
- if (isErrnoCode(err, 'ENOENT')) {
1206
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
1405
+ const code = errnoOf(err);
1406
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
1407
+ throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'lstat', code);
1207
1408
  }
1208
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1409
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'lstat', code);
1209
1410
  }
1210
-
1211
1411
  if (fileInfo.isDirectory()) {
1212
- throw new WorkspaceImageReadError('directory', `Path is a directory: ${mountPrefixedPath}`);
1412
+ throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'lstat');
1213
1413
  }
1214
- if (fileInfo.isSymbolicLink() && !mount.config.followSymlinks) {
1215
- throw new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`);
1414
+ if (fileInfo.isSymbolicLink() && !follow) {
1415
+ throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat');
1216
1416
  }
1217
1417
 
1218
1418
  let realMountRoot: string;
1219
1419
  try {
1220
1420
  realMountRoot = await realpath(mount.config.path);
1221
- } catch {
1222
- throw new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
1421
+ } catch (err) {
1422
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err));
1223
1423
  }
1224
1424
 
1225
- let handle: Awaited<ReturnType<typeof open>> | null = null;
1425
+ let handle: Awaited<ReturnType<typeof open>>;
1226
1426
  try {
1227
1427
  handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
1228
1428
  } catch (err) {
1229
- if (isErrnoCode(err, 'ENOENT')) {
1230
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
1429
+ const code = errnoOf(err);
1430
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
1431
+ throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'open', code);
1231
1432
  }
1232
- if (!mount.config.followSymlinks && isErrnoCode(err, 'ELOOP')) {
1233
- throw new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`);
1433
+ if (!follow && code === 'ELOOP') {
1434
+ throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'open', code);
1234
1435
  }
1235
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1436
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'open', code);
1236
1437
  }
1237
1438
 
1238
1439
  try {
1239
- const fileStat = await handle.stat();
1440
+ let fileStat: Stats;
1441
+ try {
1442
+ fileStat = await handle.stat();
1443
+ } catch (err) {
1444
+ const code = errnoOf(err);
1445
+ throw new WorkspaceReadError(
1446
+ 'not_found',
1447
+ code === 'ENOENT' ? `File not found: ${mountPrefixedPath}` : `Unable to read file: ${mountPrefixedPath}`,
1448
+ 'fstat',
1449
+ code,
1450
+ );
1451
+ }
1240
1452
  if (fileStat.isDirectory()) {
1241
- throw new WorkspaceImageReadError('directory', `Path is a directory: ${mountPrefixedPath}`);
1453
+ throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
1242
1454
  }
1243
1455
  if (!fileStat.isFile()) {
1244
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
1245
- }
1246
- if (fileStat.size === 0) {
1247
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1248
- }
1249
- if (fileStat.size > maxSize) {
1250
- throw new WorkspaceImageReadError(
1251
- 'too_large',
1252
- `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
1253
- );
1456
+ throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat');
1254
1457
  }
1458
+ inspect?.(fileStat);
1255
1459
 
1256
- if (!mount.config.followSymlinks && !useNoFollow) {
1460
+ if (!follow && !useNoFollow) {
1257
1461
  let postOpenInfo: Awaited<ReturnType<typeof lstat>>;
1258
1462
  try {
1259
1463
  postOpenInfo = await lstat(lexicalPath);
1260
1464
  } catch (err) {
1261
- if (isErrnoCode(err, 'ENOENT')) {
1262
- throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
1465
+ const code = errnoOf(err);
1466
+ if (code === 'ENOENT') {
1467
+ throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'post_lstat', code);
1263
1468
  }
1264
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1469
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'post_lstat', code);
1265
1470
  }
1266
1471
  if (postOpenInfo.isSymbolicLink()) {
1267
- throw new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`);
1472
+ throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'post_lstat');
1268
1473
  }
1269
1474
  }
1270
1475
 
@@ -1272,29 +1477,162 @@ export class WorkspaceModule implements Module {
1272
1477
  try {
1273
1478
  realFilePath = await realpath(lexicalPath);
1274
1479
  } catch (err) {
1275
- if (isErrnoCode(err, 'ENOENT')) {
1276
- throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
1480
+ const code = errnoOf(err);
1481
+ if (code === 'ENOENT') {
1482
+ throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'realpath', code);
1277
1483
  }
1278
- throw new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${mountPrefixedPath}`);
1484
+ throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
1279
1485
  }
1280
1486
 
1281
1487
  if (!isContainedPath(realMountRoot, realFilePath)) {
1282
- throw new WorkspaceImageReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`);
1488
+ throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
1283
1489
  }
1284
1490
 
1285
- let pathStat: Awaited<ReturnType<typeof stat>>;
1491
+ let pathStat: Stats;
1286
1492
  try {
1287
1493
  pathStat = await stat(realFilePath);
1288
1494
  } catch (err) {
1289
- if (isErrnoCode(err, 'ENOENT')) {
1290
- throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
1495
+ const code = errnoOf(err);
1496
+ if (code === 'ENOENT') {
1497
+ throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat', code);
1291
1498
  }
1292
- throw new WorkspaceImageReadError('not_found', `Unable to stat image file: ${mountPrefixedPath}`);
1499
+ throw new WorkspaceReadError('not_found', `Unable to stat file: ${mountPrefixedPath}`, 'stat', code);
1293
1500
  }
1294
1501
  if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
1295
- throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
1502
+ throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat');
1296
1503
  }
1297
1504
 
1505
+ return { handle, fileStat, realFilePath };
1506
+ } catch (err) {
1507
+ await handle.close();
1508
+ throw err;
1509
+ }
1510
+ }
1511
+
1512
+ /**
1513
+ * Read a workspace file from disk with the mount boundary enforced on the
1514
+ * filesystem (see {@link openContainedFile}). Public API for peer modules
1515
+ * that read mounted files outside the Chronicle tree — the safe replacement
1516
+ * for `resolveAbsolutePath()` + `fs.readFile()`, whose lexical-only guard let
1517
+ * an in-mount symlink smuggle outside content into context (agent-framework
1518
+ * #129, found via connectome-host #101).
1519
+ *
1520
+ * - Honors the mount's `followSymlinks` policy (default: refuse).
1521
+ * - With symlinks allowed, the canonical target must stay beneath the
1522
+ * canonical mount root; a sibling-prefix root (`/mount-other`) does not count.
1523
+ * - `maxBytes` performs a bounded prefix read: at most `maxBytes` bytes are
1524
+ * ever loaded, and `truncated` reports that the file was larger.
1525
+ *
1526
+ * Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
1527
+ * mount, lexical traversal, unavailable mount, missing file, directory,
1528
+ * policy-denied symlink, outside-mount target, and file-changed-during-read.
1529
+ */
1530
+ async readFileFromDisk(
1531
+ mountPrefixedPath: string,
1532
+ options: ReadFileFromDiskOptions = {},
1533
+ ): Promise<WorkspaceDiskReadResult> {
1534
+ if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
1535
+ throw new RangeError('readFileFromDisk: maxBytes must be a non-negative integer');
1536
+ }
1537
+ let mount: MountState;
1538
+ let relativePath: string;
1539
+ try {
1540
+ ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1541
+ } catch (err) {
1542
+ const message = err instanceof Error ? err.message : String(err);
1543
+ const code: WorkspaceReadErrorCode = message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
1544
+ throw new WorkspaceReadError(code, message, 'parse');
1545
+ }
1546
+ if (!relativePath) {
1547
+ throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
1548
+ }
1549
+
1550
+ const { handle, fileStat, realFilePath } = await this.openContainedFile(mount, relativePath, mountPrefixedPath);
1551
+ try {
1552
+ const size = fileStat.size;
1553
+ const cap = options.maxBytes;
1554
+ let bytes: Buffer;
1555
+ let truncated = false;
1556
+ if (cap !== undefined && size > cap) {
1557
+ truncated = true;
1558
+ bytes = Buffer.alloc(cap);
1559
+ let offset = 0;
1560
+ while (offset < cap) {
1561
+ const { bytesRead } = await handle.read(bytes, offset, cap - offset, offset);
1562
+ if (bytesRead === 0) break;
1563
+ offset += bytesRead;
1564
+ }
1565
+ if (offset < cap) bytes = bytes.subarray(0, offset);
1566
+ } else {
1567
+ bytes = await handle.readFile();
1568
+ }
1569
+ return { bytes, size, truncated, mtimeMs: fileStat.mtimeMs, realPath: realFilePath, mount: mount.config.name };
1570
+ } catch (err) {
1571
+ if (err instanceof WorkspaceReadError) throw err;
1572
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
1573
+ } finally {
1574
+ await handle.close();
1575
+ }
1576
+ }
1577
+
1578
+ /** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
1579
+ private imageErrorFromRead(err: WorkspaceReadError, mount: MountState, mountPrefixedPath: string): WorkspaceImageReadError {
1580
+ const p = mountPrefixedPath;
1581
+ switch (err.code) {
1582
+ case 'mount_unavailable':
1583
+ return new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
1584
+ case 'directory':
1585
+ return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
1586
+ case 'symlink':
1587
+ return new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${p}`);
1588
+ case 'escape':
1589
+ return new WorkspaceImageReadError('escape', `Symlink escape detected: ${p}`);
1590
+ case 'changed':
1591
+ return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
1592
+ case 'not_found':
1593
+ default:
1594
+ if (err.message.startsWith('File not found')) {
1595
+ return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
1596
+ }
1597
+ if (err.stage === 'realpath') {
1598
+ return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
1599
+ }
1600
+ if (err.stage === 'stat') {
1601
+ return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
1602
+ }
1603
+ return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
1604
+ }
1605
+ }
1606
+
1607
+ private async readImageFromFilesystem(
1608
+ mount: MountState,
1609
+ relativePath: string,
1610
+ mountPrefixedPath: string,
1611
+ maxSize: number,
1612
+ ): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType }> {
1613
+ let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
1614
+ try {
1615
+ opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
1616
+ if (fileStat.size === 0) {
1617
+ throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1618
+ }
1619
+ if (fileStat.size > maxSize) {
1620
+ throw new WorkspaceImageReadError(
1621
+ 'too_large',
1622
+ `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
1623
+ );
1624
+ }
1625
+ });
1626
+ } catch (err) {
1627
+ if (err instanceof WorkspaceImageReadError) throw err;
1628
+ if (err instanceof WorkspaceReadError) throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
1629
+ if (isErrnoCode(err, 'ENOENT')) {
1630
+ throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
1631
+ }
1632
+ throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1633
+ }
1634
+ const { handle } = opened;
1635
+ try {
1298
1636
  const bytes = await handle.readFile();
1299
1637
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
1300
1638
  } catch (err) {
@@ -1304,7 +1642,7 @@ export class WorkspaceModule implements Module {
1304
1642
  }
1305
1643
  throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1306
1644
  } finally {
1307
- await handle?.close();
1645
+ await handle.close();
1308
1646
  }
1309
1647
  }
1310
1648
 
@@ -1863,7 +2201,7 @@ export class WorkspaceModule implements Module {
1863
2201
  initialSyncDone: mount.initialSyncDone,
1864
2202
  currentBranch: currentBranch.name,
1865
2203
  lastMaterializedBranch: mount.lastMaterializedBranchId,
1866
- canMaterialize: !mount.lastMaterializedBranchId || mount.lastMaterializedBranchId === currentBranch.id,
2204
+ canMaterialize: this.mountMaterializeBlockReason(store, mount) === null,
1867
2205
  };
1868
2206
  }
1869
2207
 
@@ -1873,20 +2211,6 @@ export class WorkspaceModule implements Module {
1873
2211
  private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
1874
2212
  const store = this.getStore();
1875
2213
 
1876
- // Guard: only materialize on the active branch
1877
- if (this.config.materializeOnlyActiveBranch !== false) {
1878
- const currentBranch = store.currentBranch();
1879
- for (const mount of this.mounts.values()) {
1880
- if (mount.lastMaterializedBranchId && mount.lastMaterializedBranchId !== currentBranch.id) {
1881
- return {
1882
- success: false,
1883
- error: `Cannot materialize: current branch "${currentBranch.name}" differs from last materialized branch. Switch back or set materializeOnlyActiveBranch: false.`,
1884
- isError: true,
1885
- };
1886
- }
1887
- }
1888
- }
1889
-
1890
2214
  const allWritten: Array<{ mount: string; path: string }> = [];
1891
2215
 
1892
2216
  let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
@@ -1902,6 +2226,36 @@ export class WorkspaceModule implements Module {
1902
2226
  .map(([name, mount]) => ({ name, mount }));
1903
2227
  }
1904
2228
 
2229
+ // Branch guard, scoped to the mounts actually being materialized: a
2230
+ // linear continuation (current branch descends from the pinned branch at
2231
+ // or after the pinned seq) passes; genuine divergence refuses unless
2232
+ // force. One mount's stale pin must never block another mount.
2233
+ const blocked: Array<{ mount: string; reason: string }> = [];
2234
+ for (const { name, mount } of mountsToMaterialize) {
2235
+ const reason = this.mountMaterializeBlockReason(store, mount);
2236
+ if (!reason) continue;
2237
+ if (input.force) {
2238
+ // Disk reflects another line of history, so an incremental diff from
2239
+ // the pinned seq is meaningless — reset tracking and re-materialize
2240
+ // the full tree, exactly like materializeMount() after a deliberate
2241
+ // branch switch.
2242
+ mount.lastMaterializedSeq = 0;
2243
+ mount.lastMaterializedBranchId = null;
2244
+ } else {
2245
+ blocked.push({ mount: name, reason });
2246
+ }
2247
+ }
2248
+ mountsToMaterialize = mountsToMaterialize.filter(
2249
+ ({ name }) => !blocked.some((b) => b.mount === name),
2250
+ );
2251
+ if (mountsToMaterialize.length === 0 && blocked.length > 0) {
2252
+ return {
2253
+ success: false,
2254
+ error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
2255
+ isError: true,
2256
+ };
2257
+ }
2258
+
1905
2259
  for (const { name, mount } of mountsToMaterialize) {
1906
2260
 
1907
2261
  let paths: string[] | undefined;
@@ -1919,8 +2273,12 @@ export class WorkspaceModule implements Module {
1919
2273
  allWritten.push({ mount: name, path: p });
1920
2274
  }
1921
2275
 
1922
- // Track which branch we materialized on
1923
- if (written.length > 0) {
2276
+ // Track which branch we materialized on. Re-pin on a clean empty
2277
+ // materialize too (previously-pinned mount, nothing pending): disk
2278
+ // already reflects the current branch's tree, and leaving the old pin
2279
+ // would keep force required forever after a cross-branch materialize
2280
+ // that happened to write nothing.
2281
+ if (written.length > 0 || mount.lastMaterializedBranchId !== null) {
1924
2282
  mount.lastMaterializedBranchId = store.currentBranch().id;
1925
2283
  }
1926
2284
  }
@@ -1930,6 +2288,7 @@ export class WorkspaceModule implements Module {
1930
2288
  data: {
1931
2289
  materialized: allWritten,
1932
2290
  count: allWritten.length,
2291
+ ...(blocked.length > 0 ? { skipped: blocked } : {}),
1933
2292
  },
1934
2293
  };
1935
2294
  }
@@ -200,6 +200,13 @@ export interface MaterializeInput {
200
200
  path?: string;
201
201
  /** Specific mount (optional — defaults to all read-write mounts) */
202
202
  mount?: string;
203
+ /**
204
+ * Materialize even when the current branch has genuinely diverged from the
205
+ * branch last materialized to disk (default: false). Not needed for linear
206
+ * continuations (child branches forked at or after the last materialized
207
+ * point) — those pass the guard automatically.
208
+ */
209
+ force?: boolean;
203
210
  }
204
211
 
205
212
  export interface SyncInput {