@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.
- package/README.md +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -4
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- 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,
|
|
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
|
-
*
|
|
124
|
-
* (`
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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}"
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|
866
|
-
* segment of `beneath` while disk lists it, in the directory
|
|
867
|
-
* far, as a directory with exactly that spelling. The walk names
|
|
868
|
-
* finds from its start's spelling, and a case-insensitive disk
|
|
869
|
-
* it go on under a spelling disk doesn't list, taking files in
|
|
870
|
-
* spelling beside their own. So the start stops before a segment
|
|
871
|
-
* any other way, not there, or not a directory, and before a link,
|
|
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.
|
|
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
|
-
|
|
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
|
|
1716
|
-
*
|
|
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
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
-
|
|
1739
|
-
|
|
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) ?? []).
|
|
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
|
-
.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
1883
|
-
*
|
|
1884
|
-
*
|
|
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
|
-
*
|
|
1888
|
-
*
|
|
1889
|
-
*
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
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
|
-
*
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
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
|
-
|
|
1925
|
-
|
|
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
|
-
|
|
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', `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2164
|
-
*
|
|
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`)
|
|
2175
|
-
*
|
|
2176
|
-
*
|
|
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
|
|
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
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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);
|