@animalabs/agent-framework 0.22.4 → 0.23.1

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 (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -4
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -8,8 +8,8 @@
8
8
  import { isUtf8 } from 'node:buffer';
9
9
  import { constants as fsConstants } from 'node:fs';
10
10
  import type { Stats } from 'node:fs';
11
- import { open, readFile, readdir, stat, lstat, realpath } from 'node:fs/promises';
12
- import { isAbsolute, join, resolve, relative, sep } from 'node:path';
11
+ import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
12
+ import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
13
13
  import type { JsStore } from '@animalabs/chronicle';
14
14
  import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
15
15
  import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
@@ -40,7 +40,7 @@ import { MountWatcher, type FsChange } from './watcher.js';
40
40
  import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
41
41
  import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
42
42
  import { BranchIntents } from './branch-intent.js';
43
- import { IGNORED, OTHER_MOUNT, checkDir, filesystemTrustsCtime, inOtherMount, locateRoot, passedOverAt, readPrefix } from './observe.js';
43
+ import { IGNORED, NONBLOCK, OTHER_MOUNT, ROOT_REPLACED, ROOT_UNAVAILABLE, checkDir, checkRoot, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, leadsInto, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedAtReal, nestedHolder, passedOverAt, readPrefix, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js';
44
44
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
45
45
  import {
46
46
  type MountRuntime,
@@ -101,6 +101,8 @@ class WorkspaceImageReadError extends Error {
101
101
  | 'not_found'
102
102
  | 'directory'
103
103
  | 'symlink'
104
+ | 'parent_symlink'
105
+ | 'other_mount'
104
106
  | 'escape'
105
107
  | 'changed'
106
108
  | 'empty'
@@ -119,13 +121,23 @@ class WorkspaceImageReadError extends Error {
119
121
  /**
120
122
  * Why a workspace-owned filesystem read was refused. Distinguishes the cases a
121
123
  * peer needs to react to differently: a path that never resolved inside the
122
- * mount (`unknown_mount`, `traversal`), a mount whose root is gone
123
- * (`mount_unavailable`), an ordinary missing file (`not_found`), a directory
124
- * (`directory`), a symlink refused by the mount's `followSymlinks` policy
125
- * (`symlink`), a symlink whose canonical target lands outside the canonical
126
- * mount root (`escape`), a file swapped underneath the read (`changed`), and a
127
- * path in another mount's directory nested in the one named (`other_mount`),
128
- * whose message names the mount that holds it and the path to use there.
124
+ * mount (`unknown_mount`, `traversal`), a mount whose root is gone, isn't a
125
+ * directory, or isn't the directory the workspace was using
126
+ * (`mount_unavailable`: with errno ENOENT or ENOTDIR when no directory is
127
+ * there at all, with none for another directory), an ordinary missing file
128
+ * or one that isn't a regular file, such as a FIFO (`not_found`), a directory
129
+ * (`directory`), a file that is itself a symlink, refused by the mount's
130
+ * `followSymlinks` policy (`symlink`), a symlink among the path's directories
131
+ * on a mount that doesn't follow symlinks, leading to a directory or a file
132
+ * inside the mount, or nowhere (`parent_symlink`), whose message names the
133
+ * link and, where it leads somewhere, its target, a symlink whose canonical
134
+ * target lands outside the canonical mount root, or one among the path's
135
+ * directories leading out of it (`escape`), a file swapped underneath the read
136
+ * (`changed`), and a path in another mount's directory nested in the one
137
+ * named, or reached through a symlink among the path's directories leading
138
+ * there (`other_mount`), whose message names the mount that holds it and the
139
+ * path to use there. A `mount_unavailable` for a root whose realpath or stat
140
+ * fails otherwise carries that errno, such as EACCES.
129
141
  */
130
142
  export type WorkspaceReadErrorCode =
131
143
  | 'unknown_mount'
@@ -135,12 +147,16 @@ export type WorkspaceReadErrorCode =
135
147
  | 'not_found'
136
148
  | 'directory'
137
149
  | 'symlink'
150
+ | 'parent_symlink'
138
151
  | 'escape'
139
152
  | 'changed';
140
153
 
141
154
  /** Where in the open sequence a `WorkspaceReadError` originated (diagnostics). */
142
155
  export type WorkspaceReadStage =
143
156
  | 'parse'
157
+ /** The mount root's check: a directory, the one the workspace was using. */
158
+ | 'root'
159
+ | 'readlink'
144
160
  | 'lstat'
145
161
  | 'realpath_root'
146
162
  | 'open'
@@ -156,8 +172,8 @@ export type WorkspaceReadStage =
156
172
  * use there.
157
173
  */
158
174
  class NestedMountError extends Error {
159
- constructor(path: string, mount: string, holder: string, use: string) {
160
- super(`"${path}" lies in mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
175
+ constructor(path: string, mount: string, holder: string, use: string, how: 'lies in' | 'leads into' = 'lies in') {
176
+ super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
161
177
  }
162
178
  }
163
179
 
@@ -221,6 +237,20 @@ function errnoOf(err: unknown): string | undefined {
221
237
  : undefined;
222
238
  }
223
239
 
240
+ /**
241
+ * What is at a mount root's path now, followed as the root is: for a
242
+ * directory, undefined; for anything else, ENOTDIR; and where the path
243
+ * can't be followed to anything, the errno its stat failed with, ENOENT
244
+ * when nothing is there.
245
+ */
246
+ async function rootAbsence(root: string): Promise<string | undefined> {
247
+ try {
248
+ return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
249
+ } catch (err) {
250
+ return errnoOf(err);
251
+ }
252
+ }
253
+
224
254
  /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
225
255
  function imageTooLarge(maxSize: number, mountPrefixedPath: string): WorkspaceImageReadError {
226
256
  return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
@@ -253,6 +283,96 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean {
253
283
  return buffer.length > 0 && prefix.subarray(0, buffer.length).equals(buffer);
254
284
  }
255
285
 
286
+ /**
287
+ * A disk reader's refusal for what linkOnTheWay found on a path's way: the
288
+ * link's reason, as the observations give it, after the path; or, for a
289
+ * directory the walk couldn't ask, a failure to read, coded `not_found` with
290
+ * the readlink's errno, as every failed lookup is. A link's code follows
291
+ * where it leads, so a caller choosing advice by code gets advice that fits:
292
+ * `escape` for a link leading outside the mount, the code a path resolving
293
+ * outside it gets too; `other_mount` for one into a nested mount's directory,
294
+ * the code that mount's own paths get; and `parent_symlink` for any other,
295
+ * which `symlink`, a file that is itself a link, leaves apart.
296
+ */
297
+ async function linkReadError(
298
+ mount: { config: { name: string; path: string } },
299
+ found: LinkFound,
300
+ mountPrefixedPath: string,
301
+ nested: readonly { dir: string; name: string }[],
302
+ ): Promise<WorkspaceReadError> {
303
+ if (!('link' in found)) {
304
+ return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
305
+ }
306
+ const rootReal = await realpath(mount.config.path).catch(() => resolve(mount.config.path));
307
+ const leads = await linkLeads(mount.config.path, rootReal, found.link);
308
+ const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested)}`;
309
+ const code = leads.kind === 'outside' ? 'escape'
310
+ : leads.kind === 'inside' && nestedHolder(leads.target, nested) ? 'other_mount'
311
+ : 'parent_symlink';
312
+ return new WorkspaceReadError(code, reason, 'readlink');
313
+ }
314
+
315
+ /**
316
+ * `to` beneath `from`, mount-relative and `/`-separated, whatever the
317
+ * platform's separator; undefined where it isn't beneath it. Outside,
318
+ * `relative` gives a path that climbs with a `..` segment, or an absolute one
319
+ * across Windows drives. A name that only begins with two dots, such as
320
+ * `..cache`, is inside.
321
+ */
322
+ function nestedDir(from: string, to: string): string | undefined {
323
+ const rel = relative(from, to);
324
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return undefined;
325
+ return rel.split(sep).join('/');
326
+ }
327
+
328
+ /** A mount nested in another, as the outer mount's otherMounts holds it. */
329
+ interface NestedMountEntry {
330
+ name: string;
331
+ /**
332
+ * Its real root, as realBeneath places it once a start has read it, and
333
+ * its root as resolved until then. None where it's the outer mount's own
334
+ * or holds it (NestedMount).
335
+ */
336
+ real?: string;
337
+ /** Its directory as configured and as the real roots place it. */
338
+ fromRoots: Array<{ dir: string; root: string }>;
339
+ /** Those, and each as disk spells it where that differs. */
340
+ spellings: Array<{ dir: string; root: string }>;
341
+ }
342
+
343
+ /** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
344
+ const LINKS_FOLLOWED = 40;
345
+
346
+ /**
347
+ * An absolute path, a mount root's or one beneath it, as realpath resolves
348
+ * it, with the links on its way and in it taken out, read without holding
349
+ * the process. Where nothing is there yet, a link leads where its target
350
+ * would be, and anything else lies beneath its nearest existing directory's
351
+ * real path, with the rest as given. A link's target is read from the
352
+ * link's directory as it is on disk, and nothing is resolved lexically on
353
+ * the way, so a `..` is taken from where the links before it lead, as the
354
+ * kernel takes it. Where realpath fails for any other reason, such as a
355
+ * loop or a lack of permission, the whole path as path.resolve reads it.
356
+ */
357
+ async function realBeneath(given: string, links = 0): Promise<string> {
358
+ // A trailing separator, as a link to a directory is often written, names
359
+ // the same place, but readlink would then follow the link it names.
360
+ const { root } = parse(given);
361
+ let absolute = given;
362
+ while (absolute.length > root.length && (absolute.endsWith('/') || absolute.endsWith(sep))) absolute = absolute.slice(0, -1);
363
+ try {
364
+ return await realpath(absolute);
365
+ } catch (err) {
366
+ const code = errnoOf(err);
367
+ const parent = dirname(absolute);
368
+ if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links >= LINKS_FOLLOWED || parent === absolute) return resolve(absolute);
369
+ const place = await realBeneath(parent, links);
370
+ const target = await readlink(absolute).catch(() => null);
371
+ if (target !== null) return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1);
372
+ return join(place, basename(absolute));
373
+ }
374
+ }
375
+
256
376
  function isContainedPath(root: string, candidate: string): boolean {
257
377
  if (candidate === root) return true;
258
378
  const normalizedRoot = root.endsWith(sep) ? root : root + sep;
@@ -676,6 +796,8 @@ export class WorkspaceModule implements Module {
676
796
  private store: JsStore | null = null;
677
797
  /** Set once stop has drained and saved, cleared by start (running()). */
678
798
  private stopped = false;
799
+ /** Stops begun, so a start that read disk meanwhile can tell that one came. */
800
+ private stopsBegun = 0;
679
801
  private config: WorkspaceConfig;
680
802
  private mounts = new Map<string, MountState>();
681
803
  private watchers = new Map<string, MountWatcher>();
@@ -691,10 +813,19 @@ export class WorkspaceModule implements Module {
691
813
  /** Per canonical root: the device of its filesystem when that maintains ctime, else null. */
692
814
  private ctimeTrust = new Map<string, string | null>();
693
815
  /**
694
- * Each mount's directories that are other mounts nested inside it:
695
- * mount-relative, `/`-separated, with each mount's name and resolved root.
816
+ * Each mount's directories that are other mounts nested inside it, with
817
+ * each mount's name and real root, in each spelling that names the
818
+ * directory: as configured; as the real roots place it, where that differs
819
+ * (`fromRoots`); and as disk spells each of those where that differs.
820
+ * Each spelling is mount-relative and `/`-separated, with its root beneath
821
+ * the mount's resolved root. The constructor nests by configured path,
822
+ * with each root as resolved standing for its real one, and each start
823
+ * reads it all again with the roots placed where disk has them
824
+ * (readNesting).
696
825
  */
697
- private otherMounts = new Map<string, Array<{ dir: string; name: string; root: string }>>();
826
+ private otherMounts = new Map<string, NestedMountEntry[]>();
827
+ /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
828
+ private rootFoldsCase = new Map<string, boolean>();
698
829
  /**
699
830
  * Whether a file's mode is observed, taken in and applied (MountRuntime's
700
831
  * `modesObserved`): not on Windows, which reports no POSIX modes. A test
@@ -723,27 +854,22 @@ export class WorkspaceModule implements Module {
723
854
  // named or not, and in its watcher (OTHER_MOUNT). It's kept apart from
724
855
  // the outer mount's ignore list, which a sync that names a path sets
725
856
  // aside for what it names (#276), so no sync of the outer mount takes in
726
- // the inner one's files.
857
+ // the inner one's files. Here by configured path, before any path is
858
+ // parsed; each start places the roots where disk has them too
859
+ // (readNesting).
727
860
  for (const outer of config.mounts) {
728
861
  for (const inner of config.mounts) {
729
862
  if (outer === inner) continue;
730
863
  const outerPath = resolve(outer.path);
731
864
  const innerPath = resolve(inner.path);
732
- const rel = relative(outerPath, innerPath);
733
- // Outside the outer mount, `relative` gives a path that climbs with a
734
- // `..` segment, or an absolute one across Windows drives. A name that
735
- // only begins with two dots, such as `..cache`, is inside it.
736
- if (rel && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)) {
737
- // inner is nested under outer: passed over as exactly that
738
- // directory, read against `/`-separated paths, whatever the
739
- // platform's.
740
- const relPath = rel.split(sep).join('/');
741
- this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { dir: relPath, name: inner.name, root: innerPath }]);
742
- console.warn(
743
- `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
744
- `at ${relPath}/ — passing over it as another mount's directory`,
745
- );
746
- }
865
+ const dir = nestedDir(outerPath, innerPath);
866
+ if (dir === undefined) continue;
867
+ const configured = [{ dir, root: innerPath }];
868
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
869
+ console.warn(
870
+ `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
871
+ `at ${dir}/ — passing over it as another mount's directory`,
872
+ );
747
873
  }
748
874
  }
749
875
  this.config = config;
@@ -830,6 +956,25 @@ export class WorkspaceModule implements Module {
830
956
  return this.agreement;
831
957
  }
832
958
 
959
+ /**
960
+ * A mount as observation reads it, with the root identity the workspace
961
+ * recorded for it, if any: what a pass reads beneath (runtime), with
962
+ * rootIdentity's, and what a disk read checks its root against (readRoot),
963
+ * with rootIdentityForRead's.
964
+ */
965
+ private viewOf(mount: MountState, rootIdentity: MountView['rootIdentity']): MountView {
966
+ return {
967
+ root: resolve(mount.config.path),
968
+ name: mount.config.name,
969
+ rootIdentity,
970
+ followSymlinks: mount.config.followSymlinks === true,
971
+ maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
972
+ ignore: mount.config.ignore ?? [],
973
+ others: this.othersOf(mount.config.name),
974
+ nested: this.nestedOf(mount.config.name),
975
+ };
976
+ }
977
+
833
978
  /** The view reconciliation needs of a mount, refreshed per pass. */
834
979
  private async runtime(mount: MountState): Promise<MountRuntime> {
835
980
  const root = resolve(mount.config.path);
@@ -842,14 +987,7 @@ export class WorkspaceModule implements Module {
842
987
  }
843
988
  return {
844
989
  name: mount.config.name,
845
- view: {
846
- root,
847
- rootIdentity: this.agreementOrThrow().rootIdentity(mount.config.name),
848
- followSymlinks: mount.config.followSymlinks === true,
849
- maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
850
- ignore: mount.config.ignore ?? [],
851
- others: this.othersOf(mount.config.name),
852
- },
990
+ view: this.viewOf(mount, this.agreementOrThrow().rootIdentity(mount.config.name)),
853
991
  rootReal,
854
992
  ctimeTrustedDev: trustedDev,
855
993
  treeStateId: mount.treeStateId,
@@ -862,14 +1000,14 @@ export class WorkspaceModule implements Module {
862
1000
  }
863
1001
 
864
1002
  /**
865
- * Where a search's walk starts: `base`, its path as given, then each
866
- * segment of `beneath` while disk lists it, in the directory reached so
867
- * far, as a directory with exactly that spelling. The walk names what it
868
- * finds from its start's spelling, and a case-insensitive disk would let
869
- * it go on under a spelling disk doesn't list, taking files in under that
870
- * spelling beside their own. So the start stops before a segment spelled
871
- * any other way, not there, or not a directory, and before a link, which
872
- * a walk of the whole mount doesn't go through either. It lists only
1003
+ * Where a search's walk starts: `base`, its path as parsePath spells it,
1004
+ * then each segment of `beneath` while disk lists it, in the directory
1005
+ * reached so far, as a directory with exactly that spelling. The walk names
1006
+ * what it finds from its start's spelling, and a case-insensitive disk
1007
+ * would let it go on under a spelling disk doesn't list, taking files in
1008
+ * under that spelling beside their own. So the start stops before a segment
1009
+ * spelled any other way, not there, or not a directory, and before a link,
1010
+ * which a walk of the whole mount doesn't go through either. It lists only
873
1011
  * directories it has checked lie inside the mount, and none the mount
874
1012
  * passes over by design, which the walk then answers for.
875
1013
  */
@@ -917,6 +1055,20 @@ export class WorkspaceModule implements Module {
917
1055
  }
918
1056
 
919
1057
  async start(ctx: ModuleContext): Promise<void> {
1058
+ // Which mounts are nested where, with the roots placed where disk has
1059
+ // them and each nested mount's directory as disk spells it, and whether
1060
+ // each mount's root folds case, read where disk has it, before this start
1061
+ // sets anything, so a restart takes no call and walks or watches nothing
1062
+ // before they're known. A stop begun meanwhile stands, and this start then
1063
+ // sets nothing.
1064
+ const stops = this.stopsBegun;
1065
+ const { nesting, reals } = await this.readNesting();
1066
+ const rootFoldsCase = new Map<string, boolean>();
1067
+ for (const mount of this.config.mounts) rootFoldsCase.set(mount.name, await foldsCase(reals.get(mount.name)!));
1068
+ if (this.stopsBegun !== stops) return;
1069
+ this.otherMounts = nesting;
1070
+ this.rootFoldsCase = rootFoldsCase;
1071
+
920
1072
  this.ctx = ctx;
921
1073
  this.stopped = false;
922
1074
 
@@ -1168,6 +1320,7 @@ export class WorkspaceModule implements Module {
1168
1320
  }
1169
1321
 
1170
1322
  async stop(): Promise<void> {
1323
+ this.stopsBegun++;
1171
1324
  // No new watcher-driven passes, then let every pass and write finish
1172
1325
  // before the store goes away — including any queued behind the ones in
1173
1326
  // flight (a tool call, a scan continuing past its deadline): drained
@@ -1178,11 +1331,13 @@ export class WorkspaceModule implements Module {
1178
1331
  for (const watcher of stopping) {
1179
1332
  await watcher.stop();
1180
1333
  }
1334
+ // A call under way can await disk (parsePath) before it takes its turn,
1335
+ // so the calls are waited for as well as the turns.
1181
1336
  for (;;) {
1182
1337
  const tails = [...this.mountTurns.values()];
1183
- await Promise.all(tails);
1338
+ await Promise.allSettled([...this.callsUnderWay, ...tails]);
1184
1339
  const now = [...this.mountTurns.values()];
1185
- if (now.length === tails.length && now.every((tail, i) => tail === tails[i])) break;
1340
+ if (this.callsUnderWay.size === 0 && now.length === tails.length && now.every((tail, i) => tail === tails[i])) break;
1186
1341
  }
1187
1342
  // Nothing is queued now, and nothing more is taken: what follows saves
1188
1343
  // the pins and watermarks as the last work left them.
@@ -1460,6 +1615,25 @@ export class WorkspaceModule implements Module {
1460
1615
 
1461
1616
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
1462
1617
  if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
1618
+ return this.underWay(this.runToolCall(call));
1619
+ }
1620
+
1621
+ /**
1622
+ * Tool calls, and the public reads and writes that take a mount's turn,
1623
+ * under way. Each can await disk (parsePath) before it takes its turn, so
1624
+ * stop waits for these as well as for the turns.
1625
+ */
1626
+ private callsUnderWay = new Set<Promise<unknown>>();
1627
+
1628
+ /** `work`, recorded as under way until it settles (callsUnderWay). */
1629
+ private underWay<T>(work: Promise<T>): Promise<T> {
1630
+ this.callsUnderWay.add(work);
1631
+ const done = (): void => { this.callsUnderWay.delete(work); };
1632
+ work.then(done, done);
1633
+ return work;
1634
+ }
1635
+
1636
+ private async runToolCall(call: ToolCall): Promise<ToolResult> {
1463
1637
  try {
1464
1638
  // Every handler reads its input as its schema describes it; a call
1465
1639
  // that sends something else is refused here, before anything is read.
@@ -1583,7 +1757,10 @@ export class WorkspaceModule implements Module {
1583
1757
  */
1584
1758
  resolveAbsolutePath(mountPrefixedPath: string): string | null {
1585
1759
  try {
1586
- const { mount, relativePath } = this.parseFilePath(mountPrefixedPath);
1760
+ // Synchronous, so the path as given: it reaches the same file, and
1761
+ // the nested-mount check matches it as configured or as disk spells
1762
+ // the nested mount's directory (readNesting).
1763
+ const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath });
1587
1764
  return resolve(mount.config.path, relativePath);
1588
1765
  } catch {
1589
1766
  return null;
@@ -1618,10 +1795,19 @@ export class WorkspaceModule implements Module {
1618
1795
  options: { branch?: string } = {},
1619
1796
  ): Promise<ToolResult> {
1620
1797
  if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
1798
+ return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options));
1799
+ }
1800
+
1801
+ private async writeBinaryUnderWay(
1802
+ mountPrefixedPath: string,
1803
+ data: Buffer,
1804
+ mimeType: string,
1805
+ options: { branch?: string },
1806
+ ): Promise<ToolResult> {
1621
1807
  let mount: MountState;
1622
1808
  let relativePath: string;
1623
1809
  try {
1624
- ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1810
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1625
1811
  } catch (error) {
1626
1812
  return {
1627
1813
  success: false,
@@ -1682,10 +1868,16 @@ export class WorkspaceModule implements Module {
1682
1868
  mountPrefixedPath: string,
1683
1869
  ): Promise<{ data: Buffer } | { error: string }> {
1684
1870
  if (!this.running()) return { error: NOT_RUNNING };
1871
+ return this.underWay(this.readBinaryUnderWay(mountPrefixedPath));
1872
+ }
1873
+
1874
+ private async readBinaryUnderWay(
1875
+ mountPrefixedPath: string,
1876
+ ): Promise<{ data: Buffer } | { error: string }> {
1685
1877
  let mount: MountState;
1686
1878
  let relativePath: string;
1687
1879
  try {
1688
- ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1880
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1689
1881
  } catch (error) {
1690
1882
  return { error: error instanceof Error ? error.message : String(error) };
1691
1883
  }
@@ -1712,8 +1904,16 @@ export class WorkspaceModule implements Module {
1712
1904
  * permanent cost to send something once is the wrong trade.
1713
1905
  *
1714
1906
  * Same mount boundary and traversal guard as every other path here: only
1715
- * declared mounts, nothing above their root. Deliberately does NOT sync,
1716
- * register, or hash anything — it reads and returns.
1907
+ * declared mounts, nothing above their root, and the file reached as
1908
+ * readFileFromDisk reaches it (openContainedFile), so the mount's
1909
+ * `followSymlinks` holds for the file and the directories on its way, and
1910
+ * what it reaches stays canonically inside the mount. Deliberately does NOT
1911
+ * sync, register, or hash anything — it reads and returns.
1912
+ *
1913
+ * `maxBytes` refuses a larger file as "File too large", having loaded at
1914
+ * most `maxBytes` bytes and one more: the reads decide where the file ends,
1915
+ * not the size fstat gave at the open, so the bound holds for a file that
1916
+ * grows while it's read.
1717
1917
  */
1718
1918
  async readBinaryFromDisk(
1719
1919
  mountPrefixedPath: string,
@@ -1722,28 +1922,129 @@ export class WorkspaceModule implements Module {
1722
1922
  let mount: MountState;
1723
1923
  let relativePath: string;
1724
1924
  try {
1725
- ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1925
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1726
1926
  } catch (error) {
1727
1927
  return { error: error instanceof Error ? error.message : String(error) };
1728
1928
  }
1729
1929
  const absolutePath = resolve(mount.config.path, relativePath);
1930
+ const maxBytes = opts.maxBytes;
1931
+ let tooLarge: string | undefined;
1730
1932
  try {
1731
- const info = await stat(absolutePath);
1732
- if (!info.isFile()) return { error: `Not a file: ${mountPrefixedPath}` };
1733
- if (opts.maxBytes !== undefined && info.size > opts.maxBytes) {
1734
- return { error: `File too large: ${info.size} > ${opts.maxBytes} bytes` };
1933
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
1934
+ if (maxBytes !== undefined && fileStat.size > maxBytes) {
1935
+ tooLarge = `File too large: ${fileStat.size} > ${maxBytes} bytes`;
1936
+ throw new Error(tooLarge);
1937
+ }
1938
+ });
1939
+ try {
1940
+ if (maxBytes === undefined) return { data: await handle.readFile(), absolutePath };
1941
+ // No more than maxBytes is loaded: a file grown past it since the
1942
+ // fstat above is refused here, as one already over it was there. Its
1943
+ // size is an fstat's after the read, and at least one more than the
1944
+ // bytes kept, so the refusal's size is always past maxBytes.
1945
+ const { bytes, more } = await readPrefix(handle, maxBytes, fileStat.size);
1946
+ if (more) return { error: `File too large: ${Math.max((await handle.stat()).size, bytes.length + 1)} > ${maxBytes} bytes` };
1947
+ return { data: bytes, absolutePath };
1948
+ } finally {
1949
+ await handle.close();
1735
1950
  }
1736
- return { data: await readFile(absolutePath), absolutePath };
1737
1951
  } catch (error) {
1738
- const code = (error as { code?: string }).code;
1739
- if (code === 'ENOENT') return { error: `File not found on disk: ${mountPrefixedPath}` };
1952
+ if (tooLarge !== undefined) return { error: tooLarge };
1953
+ // A directory, or a file that isn't regular (the reader's refusal of
1954
+ // one carries no errno), and a missing file keep this reader's words.
1955
+ if (error instanceof WorkspaceReadError && (error.code === 'directory' || (error.code === 'not_found' && error.errno === undefined))) {
1956
+ return { error: `Not a file: ${mountPrefixedPath}` };
1957
+ }
1958
+ if (error instanceof WorkspaceReadError && error.code === 'not_found' && (error.errno === 'ENOENT' || error.errno === 'ENOTDIR')) {
1959
+ return { error: `File not found on disk: ${mountPrefixedPath}` };
1960
+ }
1740
1961
  return { error: error instanceof Error ? error.message : String(error) };
1741
1962
  }
1742
1963
  }
1743
1964
 
1744
- /** The directories of the mounts nested inside a mount, mount-relative. */
1965
+ /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
1745
1966
  private othersOf(mountName: string): string[] {
1746
- return (this.otherMounts.get(mountName) ?? []).map((o) => o.dir);
1967
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
1968
+ }
1969
+
1970
+ /**
1971
+ * Which mounts are nested in which, read afresh at each start. A mount is
1972
+ * nested in another where its configured path lies beneath the other's
1973
+ * root, as configured or as it lies on disk, whatever links the path
1974
+ * reaches that root through, or where its real root lies beneath the
1975
+ * other's. Each root, and each directory above a configured path, is
1976
+ * placed by realBeneath, awaited, so a root on a share that stops
1977
+ * answering holds this start and not the process, and a root moved while
1978
+ * the module was stopped is placed where it is now. A mount whose real
1979
+ * root is the other's own, or holds it, is nested by its configured path
1980
+ * alone, with no real root, since through it it would claim the other's
1981
+ * every path.
1982
+ *
1983
+ * Each way it lies there is a spelling of its directory (`fromRoots`), and
1984
+ * so is the spelling disk lists each of those by, where that differs. On a
1985
+ * disk that folds names, the outer mount's walks name the directory as
1986
+ * disk does, and so does a path parsePath has respelled, while a path given
1987
+ * as configured still names it as configured (resolveAbsolutePath). A
1988
+ * directory not on disk keeps the spellings the roots give alone. Each
1989
+ * mount's real root comes back beside the nesting (`reals`), for what
1990
+ * start reads there.
1991
+ */
1992
+ private async readNesting(): Promise<{ nesting: Map<string, NestedMountEntry[]>; reals: Map<string, string> }> {
1993
+ const reals = new Map<string, string>();
1994
+ // Each directory above a mount's configured path, with where it lies on
1995
+ // disk, so a path that reaches another mount's root through a link, as
1996
+ // one under macOS's /var reaches /private/var, is still read beneath it.
1997
+ const above = new Map<string, Array<{ dir: string; real: string }>>();
1998
+ for (const mount of this.config.mounts) {
1999
+ const given = resolve(mount.path);
2000
+ reals.set(mount.name, await realBeneath(given));
2001
+ const dirs: Array<{ dir: string; real: string }> = [];
2002
+ for (let dir = dirname(given); ; dir = dirname(dir)) {
2003
+ dirs.push({ dir, real: await realBeneath(dir) });
2004
+ if (dirname(dir) === dir) break;
2005
+ }
2006
+ above.set(mount.name, dirs);
2007
+ }
2008
+ const nesting = new Map<string, NestedMountEntry[]>();
2009
+ for (const outer of this.config.mounts) {
2010
+ const outerPath = resolve(outer.path);
2011
+ const outerReal = reals.get(outer.name)!;
2012
+ for (const inner of this.config.mounts) {
2013
+ if (outer === inner) continue;
2014
+ const real = reals.get(inner.name)!;
2015
+ const innerPath = resolve(inner.path);
2016
+ const configured = nestedDir(outerPath, innerPath);
2017
+ const fromRoots: Array<{ dir: string; root: string }> = [];
2018
+ // Its directory as configured, read against this mount's root as
2019
+ // configured and from each directory above it that is this mount's
2020
+ // root on disk, and its real root against this mount's.
2021
+ const throughRoot = above.get(inner.name)!.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
2022
+ for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
2023
+ if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
2024
+ }
2025
+ if (fromRoots.length === 0) continue;
2026
+ const spellings = [...fromRoots];
2027
+ for (const found of fromRoots) {
2028
+ const dir = await diskSpelling(outerPath, found.dir);
2029
+ if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
2030
+ }
2031
+ const claims = !isContainedPath(real, outerReal);
2032
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
2033
+ const placed = fromRoots.filter((s) => s.dir !== configured);
2034
+ if (placed.length > 0) {
2035
+ console.warn(
2036
+ `[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
2037
+ `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`,
2038
+ );
2039
+ }
2040
+ }
2041
+ }
2042
+ return { nesting, reals };
2043
+ }
2044
+
2045
+ /** The mounts nested in `mountName`, by directory, name and real root where it has one (MountView's `nested`), in each spelling known. */
2046
+ private nestedOf(mountName: string): NestedMount[] {
2047
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real })));
1747
2048
  }
1748
2049
 
1749
2050
  /**
@@ -1758,7 +2059,7 @@ export class WorkspaceModule implements Module {
1758
2059
  if (!mount) return undefined;
1759
2060
  const resolved = resolve(mount.config.path, relativePath);
1760
2061
  const holder = (this.otherMounts.get(mountName) ?? [])
1761
- .filter((o) => isContainedPath(o.root, resolved))
2062
+ .flatMap((o) => o.spellings.filter((s) => isContainedPath(s.root, resolved)).map((s) => ({ name: o.name, root: s.root })))
1762
2063
  .sort((a, b) => b.root.length - a.root.length)[0];
1763
2064
  if (!holder) return undefined;
1764
2065
  const rest = relative(holder.root, resolved).split(sep).join('/');
@@ -1785,17 +2086,98 @@ export class WorkspaceModule implements Module {
1785
2086
  * observe it and its pushes don't write it, so its file operations refuse
1786
2087
  * it, naming the innermost mount that holds it and the path to use there.
1787
2088
  */
1788
- private parseFilePath(path: string): { mount: MountState; relativePath: string } {
1789
- const parsed = this.parsePath(path);
2089
+ private async parseFilePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> {
2090
+ const parsed = this.refuseNested(await this.parsePath(path));
2091
+ // A path can also reach a nested mount's directory through a symlink on
2092
+ // its way. No push of this mount writes it there and no read reads it,
2093
+ // so it's refused here, before anything is stored, and the checks where
2094
+ // disk is acted on refuse it again.
2095
+ const refusal = await this.leadsIntoNested(parsed.mount, parsed.relativePath, parsed.path);
2096
+ if (refusal) throw refusal;
2097
+ return parsed;
2098
+ }
2099
+
2100
+ /**
2101
+ * The refusal for a path of `mount` that reaches a nested mount's directory
2102
+ * through a symlink on its way (nestedAtReal), or null where it doesn't. On
2103
+ * a mount that doesn't follow the link, the refusal is the link's own
2104
+ * (linkWords), as the readers and pushes give it. A mount with nothing
2105
+ * nested has nothing to find, so it pays nothing.
2106
+ */
2107
+ private async leadsIntoNested(mount: MountState, relativePath: string, path: string): Promise<Error | null> {
2108
+ const nested = this.nestedOf(mount.config.name);
2109
+ if (nested.length === 0) return null;
2110
+ const holder = nestedAtReal(await realBeneath(resolve(mount.config.path, relativePath)), nested);
2111
+ if (!holder) return null;
2112
+ if (mount.config.followSymlinks !== true) {
2113
+ const found = await linkOnTheWay({ root: mount.config.path, followSymlinks: false }, relativePath);
2114
+ if (found !== null) return await linkReadError(mount, found, path, nested);
2115
+ }
2116
+ return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
2117
+ }
2118
+
2119
+ /** A parsed path, refused if it lies in a nested mount's directory (parseFilePath). */
2120
+ private refuseNested<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P): P {
1790
2121
  const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath);
1791
- if (holder) throw new NestedMountError(path, parsed.mount.config.name, holder.name, holder.use);
2122
+ if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
1792
2123
  return parsed;
1793
2124
  }
1794
2125
 
1795
2126
  /**
1796
- * Parse a mount-prefixed path into (mountName, relativePath).
2127
+ * Parse a mount-prefixed path into its mount and mount-relative path, as
2128
+ * disk spells it (diskSpelling), with `path` the mount-prefixed path so
2129
+ * spelled. On a disk that folds names, a path spelled otherwise reaches
2130
+ * disk's file, so every tool takes it by disk's name: one name per file,
2131
+ * and the names its answers give are disk's, except that a path that names
2132
+ * a workspace entry exactly, or does once its directories take disk's
2133
+ * spelling, keeps the entry's spelling. A path the workspace records an
2134
+ * intent at, such as a deletion disk hasn't been given yet, counts as an
2135
+ * entry here, so the calls that settle it reach it. The workspace's
2136
+ * entries (entriesOf) stand in for disk where it has nothing yet, so a
2137
+ * draft has one name too. Neither applies to a path that disk's spelling
2138
+ * puts in a nested mount's directory.
2139
+ * A handler that must read something at the moment it's asked, as
2140
+ * handleSync reads the branch a path sync adopts on, reads it before
2141
+ * calling this, since this awaits disk. The entries are read when the
2142
+ * step needs them, some after those awaits, from the branch current then.
2143
+ */
2144
+ private async parsePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> {
2145
+ const given = this.parsePathAsGiven(path);
2146
+ const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath, this.entriesOf(given.mount));
2147
+ return {
2148
+ mount: given.mount,
2149
+ relativePath,
2150
+ path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,
2151
+ };
2152
+ }
2153
+
2154
+ /**
2155
+ * A mount's entries on the current branch, and the paths it records an
2156
+ * intent at there, as diskSpelling reads them when it does, whether its
2157
+ * root folds case as the last start read it (false before the first), and
2158
+ * which nested mount's directory holds a path, by holderOf, as the
2159
+ * nested-mount checks read it. diskSpelling keeps disk's spelling of a
2160
+ * path that lies in a nested mount's directory as disk spells it, so the
2161
+ * checks refuse at least what they would refuse without the entries.
2162
+ */
2163
+ private entriesOf(mount: MountState): WorkspaceEntries {
2164
+ const store = this.getStore();
2165
+ const intents = this.intents.get(mount.config.name)!;
2166
+ return {
2167
+ // An intent recorded at a path, such as a deletion disk hasn't been
2168
+ // given yet or a conflict, is the workspace's own name for the file
2169
+ // there, as an entry is, so the calls that settle it reach it.
2170
+ has: (path) => store.treeGet(mount.treeStateId, path) !== null || store.treeGet(intents.treeStateId, path) !== null,
2171
+ under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path),
2172
+ holder: (path) => this.holderOf(mount.config.name, path)?.name,
2173
+ rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
2174
+ };
2175
+ }
2176
+
2177
+ /**
2178
+ * Parse a mount-prefixed path into (mountName, relativePath), as spelled.
1797
2179
  */
1798
- private parsePath(path: string): { mount: MountState; relativePath: string } {
2180
+ private parsePathAsGiven(path: string): { mount: MountState; relativePath: string } {
1799
2181
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
1800
2182
  // store would keep one name and disk another.
1801
2183
  if (LONE_SURROGATE.test(path)) {
@@ -1875,35 +2257,137 @@ export class WorkspaceModule implements Module {
1875
2257
  return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
1876
2258
  }
1877
2259
 
2260
+ /**
2261
+ * The mount root a disk read reads beneath: resolved, then checked as
2262
+ * observation checks it (checkRoot), so the read and the evidence agree on
2263
+ * which directory the mount is. Once the workspace has recorded the root's
2264
+ * identity, a root that resolves to any other directory is refused, in
2265
+ * ROOT_REPLACED's words; with none recorded yet, it need only be a
2266
+ * directory there. A refusal where no directory is at the root's path
2267
+ * carries the errno that gives (rootAbsence: ENOENT when nothing is there
2268
+ * and ENOTDIR when something else is, which a caller can read as no file,
2269
+ * or the errno the root's stat failed with otherwise, such as EACCES). A
2270
+ * root whose realpath fails carries realpath's errno, and one that is
2271
+ * another directory carries none. The identity is rootIdentityForRead's,
2272
+ * which answers while the evidence awaits reopening; where even it can't
2273
+ * be read, as when the evidence was never read back whole, the read is
2274
+ * refused too, with an errno only where no directory is at the root's path.
2275
+ */
2276
+ private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
2277
+ const name = mount.config.name;
2278
+ const root = resolve(mount.config.path);
2279
+ let real: string;
2280
+ try {
2281
+ real = await realpath(root);
2282
+ } catch (err) {
2283
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
2284
+ }
2285
+ let rootIdentity: MountView['rootIdentity'];
2286
+ try {
2287
+ rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
2288
+ } catch (err) {
2289
+ // The evidence can't be read back, so neither can the check be made.
2290
+ // Where no directory is at the root's path, its errno still says so.
2291
+ throw new WorkspaceReadError('mount_unavailable',
2292
+ `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
2293
+ }
2294
+ const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
2295
+ if ('dir' in checked) return { real, dir: checked.dir };
2296
+ // Another directory is no absence. For any other refusal, what is at the
2297
+ // root's path now decides: nothing, something that isn't a directory, or
2298
+ // a directory the path has come to name since it was resolved (no errno).
2299
+ const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
2300
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
2301
+ }
2302
+
2303
+ /**
2304
+ * The refusal for a root that stopped being the directory a read checked,
2305
+ * with the code and errno readRoot gives what is at its path now (its stage
2306
+ * and words are this check's): gone, or not a directory, with ENOENT or
2307
+ * ENOTDIR, so a caller reads a root gone mid-read as one gone before it; or
2308
+ * another directory, with none.
2309
+ */
2310
+ private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
2311
+ const errno = await rootAbsence(resolve(mount.config.path));
2312
+ const reason = errno === undefined ? ROOT_REPLACED : ROOT_UNAVAILABLE;
2313
+ return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
2314
+ }
2315
+
1878
2316
  /**
1879
2317
  * Open a mount-relative file for reading with the mount boundary enforced on
1880
2318
  * the *filesystem*, not just the path string:
1881
2319
  *
1882
- * 1. `lstat` the lexical path — refuse directories; refuse a final-component
1883
- * symlink when the mount does not `followSymlinks`.
1884
- * 2. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
2320
+ * 0. Check the root (readRoot) before anything beneath it: under a root
2321
+ * that isn't the mount's directory, no path says anything about the
2322
+ * mount's files, an absence included.
2323
+ * 1. On a mount that does not `followSymlinks`, refuse a path with a symlink
2324
+ * among the directories on its way beneath the mount's root, wherever it
2325
+ * leads (linkOnTheWay), before anything resolves it: as `escape` for a
2326
+ * link leading outside the mount, as `other_mount` for one into a nested
2327
+ * mount's directory, and as `parent_symlink` for any other.
2328
+ * 2. `lstat` the lexical path — refuse directories; refuse a final-component
2329
+ * symlink when the mount does not `followSymlinks`; refuse anything else
2330
+ * that isn't a regular file, such as a FIFO, without opening it.
2331
+ * 3. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
1885
2332
  * holds against a symlink swapped in between lstat and open; where it
1886
- * doesn't (Windows), re-`lstat` after opening instead.
1887
- * 3. `realpath` both the mount root and the opened path and require canonical
1888
- * containment — this is what catches an *intermediate* symlinked
1889
- * directory escaping the mount, and a followed symlink whose target lands
1890
- * outside, on every platform (junctions included).
1891
- * 4. Confirm the descriptor and the canonical path are the same inode.
2333
+ * doesn't (Windows), re-`lstat` after opening instead. `O_NONBLOCK`, so a
2334
+ * FIFO swapped in meanwhile doesn't wait for a writer.
2335
+ * 4. `realpath` the opened path. On a mount that does not `followSymlinks`,
2336
+ * refuse it again for a symlinked directory on its way, which catches one
2337
+ * swapped in since step 1 (linkRefusal). Then require canonical
2338
+ * containment in the root step 0 resolved — this is what catches an
2339
+ * *intermediate* symlinked directory escaping the mount, and a followed
2340
+ * symlink whose target lands outside, on every platform (junctions
2341
+ * included).
2342
+ * 5. Confirm the descriptor and the canonical path are the same inode, and
2343
+ * the root still the directory step 0 checked.
1892
2344
  *
1893
- * `inspect` runs right after the descriptor is stat'd and before the
1894
- * containment work, so callers can impose size policy in the same order the
1895
- * image reader always has. The returned handle is the caller's to close.
2345
+ * Whatever steps 1 to 5 refuse, a missing file included, is said of the
2346
+ * mount's files only while the root is still that directory. If it isn't,
2347
+ * the read is refused with the code and errno step 0 gives what is at the
2348
+ * root's path then (rootMoved), as step 5's own check of the root does.
2349
+ *
2350
+ * `inspect` runs last, after the boundary's checks, so a caller's own check
2351
+ * of the file, such as a size limit, never answers for a file the boundary
2352
+ * refuses. The returned handle is the caller's to close.
1896
2353
  */
1897
2354
  private async openContainedFile(
1898
2355
  mount: MountState,
1899
2356
  relativePath: string,
1900
2357
  mountPrefixedPath: string,
1901
2358
  inspect?: (fileStat: Stats) => void,
2359
+ ): Promise<{ handle: Awaited<ReturnType<typeof open>>; fileStat: Stats; realFilePath: string }> {
2360
+ const root = await this.readRoot(mount);
2361
+ try {
2362
+ return await this.openBeneathRoot(mount, root, relativePath, mountPrefixedPath, inspect);
2363
+ } catch (err) {
2364
+ if (err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && !(await root.dir.stillHere())) {
2365
+ throw await this.rootMoved(mount);
2366
+ }
2367
+ throw err;
2368
+ }
2369
+ }
2370
+
2371
+ /** Steps 1 to 5 of openContainedFile, beneath the root that step 0 checked. */
2372
+ private async openBeneathRoot(
2373
+ mount: MountState,
2374
+ { real: realMountRoot, dir: rootDir }: { real: string; dir: CheckedDir },
2375
+ relativePath: string,
2376
+ mountPrefixedPath: string,
2377
+ inspect?: (fileStat: Stats) => void,
1902
2378
  ): Promise<{ handle: Awaited<ReturnType<typeof open>>; fileStat: Stats; realFilePath: string }> {
1903
2379
  const lexicalPath = resolve(mount.config.path, relativePath);
1904
2380
  const follow = mount.config.followSymlinks === true;
1905
2381
  const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number';
1906
2382
 
2383
+ // Before anything resolves the path, so a link's own answer comes first: a
2384
+ // mount that doesn't follow symlinks goes through none among the
2385
+ // directories on the way, wherever it leads. Asked again of the path's
2386
+ // resolution after the open, below.
2387
+ const linkView = { root: mount.config.path, followSymlinks: follow };
2388
+ const onTheWay = await linkOnTheWay(linkView, relativePath, 'parents');
2389
+ if (onTheWay !== null) throw await linkReadError(mount, onTheWay, mountPrefixedPath, this.nestedOf(mount.config.name));
2390
+
1907
2391
  let fileInfo: Awaited<ReturnType<typeof lstat>>;
1908
2392
  try {
1909
2393
  fileInfo = await lstat(lexicalPath);
@@ -1920,17 +2404,16 @@ export class WorkspaceModule implements Module {
1920
2404
  if (fileInfo.isSymbolicLink() && !follow) {
1921
2405
  throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat');
1922
2406
  }
1923
-
1924
- let realMountRoot: string;
1925
- try {
1926
- realMountRoot = await realpath(mount.config.path);
1927
- } catch (err) {
1928
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err));
2407
+ if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
2408
+ // A FIFO, socket or device is never opened.
2409
+ throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
1929
2410
  }
1930
2411
 
1931
2412
  let handle: Awaited<ReturnType<typeof open>>;
1932
2413
  try {
1933
- handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
2414
+ // O_NONBLOCK: a FIFO swapped in since the lstat opens at once instead of
2415
+ // waiting for a writer, and the regular-file check below refuses it.
2416
+ handle = await open(lexicalPath, fsConstants.O_RDONLY | NONBLOCK | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
1934
2417
  } catch (err) {
1935
2418
  const code = errnoOf(err);
1936
2419
  if (code === 'ENOENT' || code === 'ENOTDIR') {
@@ -1959,9 +2442,8 @@ export class WorkspaceModule implements Module {
1959
2442
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
1960
2443
  }
1961
2444
  if (!fileStat.isFile()) {
1962
- throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat');
2445
+ throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
1963
2446
  }
1964
- inspect?.(fileStat);
1965
2447
 
1966
2448
  if (!follow && !useNoFollow) {
1967
2449
  let postOpenInfo: Awaited<ReturnType<typeof lstat>>;
@@ -1990,9 +2472,16 @@ export class WorkspaceModule implements Module {
1990
2472
  throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
1991
2473
  }
1992
2474
 
2475
+ // A directory swapped for a link since the walk above is caught here, by
2476
+ // what the path resolved to after the open.
2477
+ const resolvedThrough = await linkRefusal(linkView, realMountRoot, relativePath, realFilePath);
2478
+ if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, this.nestedOf(mount.config.name));
2479
+
1993
2480
  if (!isContainedPath(realMountRoot, realFilePath)) {
1994
2481
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
1995
2482
  }
2483
+ const holder = nestedAtReal(realFilePath, this.nestedOf(mount.config.name));
2484
+ if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
1996
2485
 
1997
2486
  let pathStat: Stats;
1998
2487
  try {
@@ -2007,7 +2496,16 @@ export class WorkspaceModule implements Module {
2007
2496
  if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
2008
2497
  throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat');
2009
2498
  }
2010
-
2499
+ // Containment compares paths, so it can't see another directory put at
2500
+ // the root's own path since step 0 (a drive unmounted mid-read): the
2501
+ // root is asked again, as an effect asks it (effects.ts moved()). This
2502
+ // sees the root as it is now, so a root replaced and put back before it
2503
+ // isn't seen (observe.ts's header says why no check can be sure).
2504
+ if (!(await rootDir.stillHere())) throw await this.rootMoved(mount);
2505
+
2506
+ // The caller's checks of the file, such as a size limit, come after the
2507
+ // boundary's, so they never answer for a file the boundary refuses.
2508
+ inspect?.(fileStat);
2011
2509
  return { handle, fileStat, realFilePath };
2012
2510
  } catch (err) {
2013
2511
  await handle.close();
@@ -2031,11 +2529,20 @@ export class WorkspaceModule implements Module {
2031
2529
  * (`truncated`). The reads decide where the file ends, not the size fstat
2032
2530
  * gave at the open, so the bound holds for a file that grows while it's
2033
2531
  * read.
2532
+ * - Once the workspace has recorded the mount root's identity, with the
2533
+ * first evidence it gathers there, a root that is another directory is
2534
+ * an unavailable mount: a replaced directory, a link swapped in for it,
2535
+ * or an unmounted drive's mountpoint. In any mount, recorded or not, a
2536
+ * root that stops being the directory the read checked until the file
2537
+ * has been opened and checked is unavailable too: with ENOENT or
2538
+ * ENOTDIR where no directory is there, as before the read, and with no
2539
+ * errno for another directory. The file's bytes are then read through
2540
+ * the descriptor that was checked.
2034
2541
  *
2035
2542
  * Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
2036
2543
  * mount, lexical traversal, a path in another mount's directory nested in
2037
- * the one named (`other_mount`), unavailable mount, missing file,
2038
- * directory, policy-denied symlink, outside-mount target, and
2544
+ * the one named (`other_mount`), unavailable mount, missing or non-regular
2545
+ * file, directory, policy-denied symlink, outside-mount target, and
2039
2546
  * file-changed-during-read.
2040
2547
  */
2041
2548
  async readFileFromDisk(
@@ -2048,8 +2555,10 @@ export class WorkspaceModule implements Module {
2048
2555
  let mount: MountState;
2049
2556
  let relativePath: string;
2050
2557
  try {
2051
- ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
2558
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
2052
2559
  } catch (err) {
2560
+ // A link's refusal (parseFilePath) carries its own code.
2561
+ if (err instanceof WorkspaceReadError) throw err;
2053
2562
  const message = err instanceof Error ? err.message : String(err);
2054
2563
  const code: WorkspaceReadErrorCode = err instanceof NestedMountError ? 'other_mount'
2055
2564
  : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
@@ -2086,17 +2595,39 @@ export class WorkspaceModule implements Module {
2086
2595
  const p = mountPrefixedPath;
2087
2596
  switch (err.code) {
2088
2597
  case 'mount_unavailable':
2089
- return new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
2598
+ // With why, where the root's check says, such as a directory that
2599
+ // isn't the mount's.
2600
+ return new WorkspaceImageReadError('mount_unavailable', err.message);
2090
2601
  case 'directory':
2091
2602
  return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
2092
2603
  case 'symlink':
2093
- return new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${p}`);
2604
+ // The core reader's own words for a link at the path: "Symlinks are not
2605
+ // allowed: <path>".
2606
+ return new WorkspaceImageReadError('symlink', err.message);
2607
+ case 'parent_symlink':
2608
+ // The core reader's own words for a link on its way: which link and
2609
+ // where it leads, with the setting for a link to a directory.
2610
+ return new WorkspaceImageReadError('parent_symlink', err.message);
2611
+ case 'other_mount':
2612
+ // The core reader's own words for a path a link takes into a nested
2613
+ // mount's directory: that mount and the path to use there. (#460's
2614
+ // own other_mount, a path in that directory, is refused at parse,
2615
+ // and so is a link found there before the read.)
2616
+ return new WorkspaceImageReadError('other_mount', err.message);
2094
2617
  case 'escape':
2095
- return new WorkspaceImageReadError('escape', `Symlink escape detected: ${p}`);
2618
+ // The core reader's own words: "Symlink escape detected: <path>" for a
2619
+ // followed link that resolves outside the mount, and for a link on the
2620
+ // way that leads outside a mount that doesn't follow links, which link.
2621
+ return new WorkspaceImageReadError('escape', err.message);
2096
2622
  case 'changed':
2097
2623
  return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
2098
2624
  case 'not_found':
2099
2625
  default:
2626
+ // The core reader's own words for a file that isn't regular, and for
2627
+ // a directory on the way it couldn't ask about.
2628
+ if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
2629
+ return new WorkspaceImageReadError('not_found', err.message);
2630
+ }
2100
2631
  if (err.message.startsWith('File not found')) {
2101
2632
  return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
2102
2633
  }
@@ -2160,8 +2691,10 @@ export class WorkspaceModule implements Module {
2160
2691
 
2161
2692
  /**
2162
2693
  * Commit a tool's write or delete to the workspace tree, with the branch
2163
- * intent it implies (store origin for a path disk never agreed with; a
2164
- * tombstone for a deletion), and push it to disk when the mount opts in via
2694
+ * intent it implies: store origin for a path disk never agreed with; for a
2695
+ * deletion, a tombstone, unless the evidence holds no agreed disk copy (P
2696
+ * absent, or no P and store origin), when it clears the path's intent and
2697
+ * pushes nothing. It pushes the change to disk when the mount opts in via
2165
2698
  * `autoMaterialize` — required for cross-agent pipelines, since another
2166
2699
  * agent's watcher on the same directory only sees real filesystem events.
2167
2700
  * The push follows materialize's rule: a disk copy that changed since it
@@ -2171,22 +2704,25 @@ export class WorkspaceModule implements Module {
2171
2704
  * change reached the workspace but not disk (`refused`): the
2172
2705
  * autoMaterialize contract is that disk is the source of truth for
2173
2706
  * downstream agents. And, apart from it, why a write whose bytes reached
2174
- * disk left the file's mode as disk had it (`modeKept`). The caller holds
2175
- * the mount's turn and has settled the path first (settleBeforeMutation, or
2176
- * a pass over the path, as an edit makes).
2707
+ * disk left the file's mode as disk had it (`modeKept`), and, for a
2708
+ * deletion that cleared a conflict naming a disk file the workspace never
2709
+ * took in, that nothing was unlinked (`note`). The caller holds the mount's
2710
+ * turn and has settled the path first (settleBeforeMutation, or a pass over
2711
+ * the path, as an edit makes).
2177
2712
  */
2178
2713
  private async commitToolChange(
2179
2714
  mount: MountState,
2180
2715
  relativePath: string,
2181
2716
  change: { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string },
2182
2717
  onCommitted?: () => void,
2183
- ): Promise<{ refused: string | null; modeKept: string | null }> {
2718
+ ): Promise<{ refused: string | null; modeKept: string | null; note: string | null }> {
2184
2719
  const store = this.getStore();
2185
2720
  const intents = this.intents.get(mount.config.name)!;
2186
2721
  // The mutation's precondition, enforced here, synchronously with the
2187
2722
  // commit: whatever settleBeforeMutation's pass could or couldn't settle.
2188
2723
  settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
2189
- const known = this.agreementOrThrow().get(mount.config.name, relativePath) !== undefined;
2724
+ const evidence = this.agreementOrThrow().get(mount.config.name, relativePath);
2725
+ const known = evidence !== undefined;
2190
2726
  if (change.kind === 'set') {
2191
2727
  store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
2192
2728
  // A later intent update or disk push can throw; the tree entry is
@@ -2205,20 +2741,49 @@ export class WorkspaceModule implements Module {
2205
2741
  } else {
2206
2742
  store.treeRemove(mount.treeStateId, relativePath);
2207
2743
  onCommitted?.();
2744
+ // A deletion whose evidence holds no agreed disk copy (disk last agreed
2745
+ // it had nothing here, or has never agreed with this entry, which the
2746
+ // workspace created) puts the store back at the agreement. Nothing on
2747
+ // disk is the workspace's to unlink, so it pushes nothing, and it
2748
+ // clears the path's whole intent: no tombstone, so it needs no proof of
2749
+ // absence, which a path disk can't show, such as one beneath a symlink
2750
+ // the mount doesn't follow, would never give; and no conflict, which
2751
+ // has nothing left to be about once the workspace has given up its
2752
+ // side. Whatever disk holds there is a sync's to observe, a file the
2753
+ // workspace never saw included. Any other deletion keeps its
2754
+ // tombstone, which guards it: a copy disk agreed with, a push of ours
2755
+ // that may have landed (pending or interrupted), or an entry from
2756
+ // before the evidence.
2757
+ let agreed = true;
2758
+ let note: string | null = null;
2208
2759
  intents.update(relativePath, (cur) => {
2209
- const next = { ...cur, tombstone: { hash: change.previousHash } };
2210
- delete next.origin;
2211
- return next;
2760
+ agreed = !(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'));
2761
+ if (agreed) {
2762
+ const next = { ...cur, tombstone: { hash: change.previousHash } };
2763
+ delete next.origin;
2764
+ return next;
2765
+ }
2766
+ // A conflict it clears may name a file disk held there, which the
2767
+ // workspace never took in: the answer says that file wasn't unlinked,
2768
+ // where the deletion alone would read as having removed it. The
2769
+ // deletion doesn't look at disk, so a file nothing recorded gets no
2770
+ // note.
2771
+ if (cur.conflict?.disk) {
2772
+ note = `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${relativePath} ` +
2773
+ `that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`;
2774
+ }
2775
+ return {};
2212
2776
  });
2777
+ if (!agreed) return { refused: null, modeKept: null, note };
2213
2778
  }
2214
- if (!this.pushesOnCommit(mount)) return { refused: null, modeKept: null };
2779
+ if (!this.pushesOnCommit(mount)) return { refused: null, modeKept: null, note: null };
2215
2780
 
2216
2781
  const pushed = await this.pushUnlocked(mount, [relativePath], {
2217
2782
  applyDeletions: change.kind === 'remove',
2218
2783
  branchId: store.currentBranch().id,
2219
2784
  });
2220
2785
  const refused = pushed.skipped.find((s) => s.path === relativePath);
2221
- if (!refused) return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null };
2786
+ if (!refused) return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null, note: null };
2222
2787
  this.ctx?.pushEvent({
2223
2788
  type: 'workspace:materialize-failed',
2224
2789
  mount: mount.config.name,
@@ -2226,7 +2791,7 @@ export class WorkspaceModule implements Module {
2226
2791
  op: change.kind === 'set' ? 'write' : 'delete',
2227
2792
  error: refused.reason,
2228
2793
  } as ProcessEvent);
2229
- return { refused: refused.reason, modeKept: null };
2794
+ return { refused: refused.reason, modeKept: null, note: null };
2230
2795
  }
2231
2796
 
2232
2797
  // ==========================================================================
@@ -2300,7 +2865,8 @@ export class WorkspaceModule implements Module {
2300
2865
  }
2301
2866
  let content = generatedContent;
2302
2867
  if (content === undefined) {
2303
- const { mount, relativePath } = this.parseFilePath(input.path);
2868
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2869
+ input = { ...input, path };
2304
2870
  const store = this.getStore();
2305
2871
  const found = await this.withMount(mount, async (): Promise<{ text: string } | { error: string }> => {
2306
2872
  const report = await this.ensureSynced(mount, relativePath);
@@ -2388,7 +2954,9 @@ export class WorkspaceModule implements Module {
2388
2954
  let mount: MountState;
2389
2955
  let relativePath: string;
2390
2956
  try {
2391
- ({ mount, relativePath } = this.parseFilePath(input.path));
2957
+ let path: string;
2958
+ ({ mount, relativePath, path } = await this.parseFilePath(input.path));
2959
+ input = { ...input, path };
2392
2960
  } catch (err) {
2393
2961
  return {
2394
2962
  success: false,
@@ -2460,7 +3028,8 @@ export class WorkspaceModule implements Module {
2460
3028
  if (LONE_SURROGATE.test(input.content)) {
2461
3029
  return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
2462
3030
  }
2463
- const { mount, relativePath } = this.parseFilePath(input.path);
3031
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
3032
+ input = { ...input, path };
2464
3033
  if (mount.config.mode === 'read-only') {
2465
3034
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2466
3035
  }
@@ -2505,7 +3074,8 @@ export class WorkspaceModule implements Module {
2505
3074
  return { success: false, isError: true, error: `The edit did nothing: "${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` };
2506
3075
  }
2507
3076
  }
2508
- const { mount, relativePath } = this.parseFilePath(input.path);
3077
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
3078
+ input = { ...input, path };
2509
3079
  if (mount.config.mode === 'read-only') {
2510
3080
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2511
3081
  }
@@ -2649,7 +3219,8 @@ export class WorkspaceModule implements Module {
2649
3219
  }
2650
3220
 
2651
3221
  private async handleDelete(input: DeleteInput): Promise<ToolResult> {
2652
- const { mount, relativePath } = this.parseFilePath(input.path);
3222
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
3223
+ input = { ...input, path };
2653
3224
  if (mount.config.mode === 'read-only') {
2654
3225
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2655
3226
  }
@@ -2665,13 +3236,17 @@ export class WorkspaceModule implements Module {
2665
3236
  return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2666
3237
  }
2667
3238
 
2668
- const { refused } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
3239
+ const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
2669
3240
  () => { committed = true; });
2670
3241
  if (refused !== null) return storedButUnfinished('delete', input.path, { refused });
2671
3242
 
2672
3243
  // Its whole receipt is what it changed: kept in the stub if the guard
2673
3244
  // withholds the result (agent-framework #277).
2674
- return { success: true, data: { path: input.path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
3245
+ return {
3246
+ success: true,
3247
+ data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
3248
+ keepWhenWithheld: ['path', 'deleted', 'note'],
3249
+ };
2675
3250
  });
2676
3251
  } catch (error) {
2677
3252
  if (!committed) throw error;
@@ -2692,7 +3267,8 @@ export class WorkspaceModule implements Module {
2692
3267
  return { success: true, data: { mounts } };
2693
3268
  }
2694
3269
 
2695
- const { mount, relativePath } = this.parsePath(input.path);
3270
+ const { mount, relativePath, path } = await this.parsePath(input.path);
3271
+ input = { ...input, path };
2696
3272
  const recursive = input.recursive === true;
2697
3273
 
2698
3274
  // Every listing checks the paths it shows against disk first, under the
@@ -2745,7 +3321,7 @@ export class WorkspaceModule implements Module {
2745
3321
 
2746
3322
  // Search across mounts
2747
3323
  const mountsToSearch = input.path
2748
- ? [this.parsePath(input.path)]
3324
+ ? [await this.parsePath(input.path)]
2749
3325
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2750
3326
 
2751
3327
  // Every match lies beneath the pattern's literal directory, so the walk
@@ -2797,7 +3373,7 @@ export class WorkspaceModule implements Module {
2797
3373
  const contextAfter = input.contextAfter ?? 0;
2798
3374
 
2799
3375
  const mountsToSearch = input.path
2800
- ? [this.parsePath(input.path)]
3376
+ ? [await this.parsePath(input.path)]
2801
3377
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2802
3378
 
2803
3379
  type GrepMatch = { line: number; text: string; context?: string[] };
@@ -2848,12 +3424,29 @@ export class WorkspaceModule implements Module {
2848
3424
  // on: one selected in between would label its content with the other
2849
3425
  // branch's states. So a pass whose branch is no longer selected runs
2850
3426
  // again, and a branch that keeps changing leaves the scope unsearched.
3427
+ //
3428
+ // A copy held at a path that now leads into a nested mount's directory
3429
+ // through a link is that mount's file too: read refuses it
3430
+ // (parseFilePath), so grep searches none of it and says why. The pass
3431
+ // can't check such a path's disk, so only an unverified one can be
3432
+ // one. Asking awaits disk, so it's done before the branch is
3433
+ // compared, and a branch selected meanwhile runs the pass again.
2851
3434
  let pass: PassResult | null = null;
3435
+ let refused = new Map<string, string>();
2852
3436
  for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
2853
3437
  const next = await this.passUnlocked(mount, fileScope
2854
3438
  ? { kind: 'paths', paths: [relativePath] }
2855
3439
  : { kind: 'dir', dir: start, recursive: true });
2856
- if (next.branchId === undefined || next.branchId === store.currentBranch().id) pass = next;
3440
+ const asked = new Map<string, string>();
3441
+ for (const report of next.reports.values()) {
3442
+ if (report.state !== 'unverified') continue;
3443
+ const refusal = await this.leadsIntoNested(mount, report.path, `${mount.config.name}/${report.path}`);
3444
+ if (refusal) asked.set(report.path, refusal.message);
3445
+ }
3446
+ if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
3447
+ pass = next;
3448
+ refused = asked;
3449
+ }
2857
3450
  }
2858
3451
  if (pass === null) {
2859
3452
  incomplete.push({
@@ -2883,6 +3476,11 @@ export class WorkspaceModule implements Module {
2883
3476
  // none of this mount's copy of it, as read refuses it. Beneath a
2884
3477
  // scope it's passed over by design, like the directory.
2885
3478
  if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
3479
+ const refusal = refused.get(report.path);
3480
+ if (refusal !== undefined) {
3481
+ skipped.push({ file, reason: refusal });
3482
+ continue;
3483
+ }
2886
3484
  // The workspace's version, labelled with the path's state.
2887
3485
  const entry = store.treeGet(mount.treeStateId, report.path);
2888
3486
  const blob = entry ? store.getBlob(entry.blobHash) : null;
@@ -3062,7 +3660,7 @@ export class WorkspaceModule implements Module {
3062
3660
  const modesKept: Array<{ mount: string; path: string; reason: string }> = [];
3063
3661
 
3064
3662
  let explicit: { mount: MountState; relativePath: string } | null = null;
3065
- if (input.path) explicit = this.parsePath(input.path);
3663
+ if (input.path) explicit = await this.parsePath(input.path);
3066
3664
 
3067
3665
  let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
3068
3666
  if (explicit) {
@@ -3248,7 +3846,7 @@ export class WorkspaceModule implements Module {
3248
3846
  // A path names its own mount; otherwise the given mount, or every mount.
3249
3847
  let targets: Array<{ name: string; mount: MountState; relativePath: string }>;
3250
3848
  if (input.path) {
3251
- const { mount, relativePath } = this.parsePath(input.path);
3849
+ const { mount, relativePath } = await this.parsePath(input.path);
3252
3850
  targets = [{ name: mount.config.name, mount, relativePath }];
3253
3851
  } else if (input.mount) {
3254
3852
  const m = this.mounts.get(input.mount);