@deepseek-ai/dsh-api-workspace-files 0.1.5-alpha.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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +154 -0
- package/README.zh.md +154 -0
- package/lib/client.js +522 -0
- package/lib/index.js +551 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +853 -0
- package/lib/typert.remote-client.d.ts +38 -0
- package/lib/typert.remote-client.js +287 -0
- package/lib/types/changes.d.ts +26 -0
- package/lib/types/changes.js +109 -0
- package/lib/types/client/change-feed.d.ts +105 -0
- package/lib/types/client/change-feed.js +296 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.js +26 -0
- package/lib/types/client/provider.d.ts +45 -0
- package/lib/types/client/provider.js +129 -0
- package/lib/types/client/remote.d.ts +44 -0
- package/lib/types/client/remote.js +2 -0
- package/lib/types/client/types.d.ts +65 -0
- package/lib/types/client/types.js +2 -0
- package/lib/types/index.d.ts +143 -0
- package/lib/types/index.js +368 -0
- package/lib/types/types.d.ts +163 -0
- package/lib/types/types.js +17 -0
- package/package.json +86 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
3
|
+
import { Deque } from "@deepseek-ai/dsh-deque";
|
|
4
|
+
//#region lib/types/changes.js
|
|
5
|
+
/**
|
|
6
|
+
* Producer of the `changes` stream: every `fs/observed` emission whose target
|
|
7
|
+
* lies inside a generation's workspace root becomes one frame of that
|
|
8
|
+
* generation. Observations are emitted by tools after their own filesystem
|
|
9
|
+
* operation, so the feed covers Agent writes only; the OS is not watched.
|
|
10
|
+
* Each generation acknowledges its observation queue and resolved workspace
|
|
11
|
+
* root with `ready` before emitting any queued or live changes.
|
|
12
|
+
*/
|
|
13
|
+
/** Owns `fs/observed` observation and every open `changes` generation. */
|
|
14
|
+
var WorkspaceChangeFeed = class {
|
|
15
|
+
ctx;
|
|
16
|
+
followers = /* @__PURE__ */ new Set();
|
|
17
|
+
/** @param ctx - Host context carrying the filesystem the observations come from. */
|
|
18
|
+
constructor(ctx) {
|
|
19
|
+
this.ctx = ctx;
|
|
20
|
+
ctx.on("fs/observed", (target, observation) => {
|
|
21
|
+
for (const follower of this.followers) follower.push([target, observation]);
|
|
22
|
+
});
|
|
23
|
+
ctx.effect(() => () => {
|
|
24
|
+
for (const follower of this.followers) follower.close();
|
|
25
|
+
this.followers.clear();
|
|
26
|
+
}, "workspace-files.changes");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Open one generation reporting observations inside `workspaceRoot`.
|
|
30
|
+
* @param workspaceRoot - the session's workspace root path.
|
|
31
|
+
* @param signal - generation cancellation.
|
|
32
|
+
* @returns `ready` after observation is active and the root resolves, then
|
|
33
|
+
* observations made after the generation was first pulled, in emission order.
|
|
34
|
+
*/
|
|
35
|
+
async *follow(workspaceRoot, signal) {
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
const follower = new ChangeFollower();
|
|
38
|
+
this.followers.add(follower);
|
|
39
|
+
try {
|
|
40
|
+
const root = await this.ctx.fs.resolve(workspaceRoot, { signal }).catch((error) => {
|
|
41
|
+
if (signal.aborted) return void 0;
|
|
42
|
+
throw error;
|
|
43
|
+
});
|
|
44
|
+
if (root === void 0 || signal.aborted || follower.isClosed) return;
|
|
45
|
+
yield { kind: "ready" };
|
|
46
|
+
for await (const [target, observation] of follower.read(signal)) {
|
|
47
|
+
if (!this.ctx.fs.contains(root, target)) continue;
|
|
48
|
+
const absolutePath = this.ctx.fs.processPath(target);
|
|
49
|
+
yield {
|
|
50
|
+
kind: "change",
|
|
51
|
+
change: observation.kind === "present" ? {
|
|
52
|
+
absolutePath,
|
|
53
|
+
version: observation.version
|
|
54
|
+
} : {
|
|
55
|
+
absolutePath,
|
|
56
|
+
absent: true
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
this.followers.delete(follower);
|
|
62
|
+
follower.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
/** One generation's queue: observations wait here until its consumer pulls them. */
|
|
67
|
+
var ChangeFollower = class {
|
|
68
|
+
queue = new Deque();
|
|
69
|
+
wake;
|
|
70
|
+
closed = false;
|
|
71
|
+
/** Whether the generation was closed while its workspace root resolved. */
|
|
72
|
+
get isClosed() {
|
|
73
|
+
return this.closed;
|
|
74
|
+
}
|
|
75
|
+
push(observed) {
|
|
76
|
+
this.queue.pushBack(observed);
|
|
77
|
+
this.wake?.();
|
|
78
|
+
}
|
|
79
|
+
close() {
|
|
80
|
+
this.closed = true;
|
|
81
|
+
this.wake?.();
|
|
82
|
+
}
|
|
83
|
+
/** Drain until closed or aborted; anything still queued then is dropped with the generation. */
|
|
84
|
+
async *read(signal) {
|
|
85
|
+
const abort = () => {
|
|
86
|
+
this.close();
|
|
87
|
+
};
|
|
88
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
89
|
+
if (signal.aborted) abort();
|
|
90
|
+
try {
|
|
91
|
+
while (!this.closed) {
|
|
92
|
+
const observed = this.queue.popFront();
|
|
93
|
+
if (observed !== void 0) {
|
|
94
|
+
yield observed;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
this.wake = resolve;
|
|
99
|
+
});
|
|
100
|
+
this.wake = void 0;
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
signal.removeEventListener("abort", abort);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region lib/types/index.js
|
|
109
|
+
/**
|
|
110
|
+
* Workspace file service: paged text reads, byte-window reads, stats, directory
|
|
111
|
+
* listings, and the agent-write change feed inside one session's workspace
|
|
112
|
+
* root, exposed as the `workspaceFiles` Remote namespace.
|
|
113
|
+
*
|
|
114
|
+
* Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend
|
|
115
|
+
* fences writes and edits only, and says so. Every constraint this service
|
|
116
|
+
* needs is therefore its own, and there are four:
|
|
117
|
+
*
|
|
118
|
+
* 1. The path is authorized by containment in the session's workspace root.
|
|
119
|
+
* 2. Containment is decided by {@link FileSystem.contains}, never by comparing
|
|
120
|
+
* path strings: `resolve` realpaths, so a prefix test cannot see a symlink
|
|
121
|
+
* that leaves the root. `lstat` rejects a link before that follow happens.
|
|
122
|
+
* 3. Every cap is validated Config, changeable per deployment. A page is cut by
|
|
123
|
+
* lines and refused, not shortened, when its bytes exceed the byte cap; a
|
|
124
|
+
* listing is cut by entries and says so.
|
|
125
|
+
* 4. Failures are one `RemoteError` per reason, declared in `./types`.
|
|
126
|
+
*
|
|
127
|
+
* A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
|
|
128
|
+
* goes, so the file is read only up to the first character past the page and
|
|
129
|
+
* never held whole in memory; the NUL scan runs on the page itself.
|
|
130
|
+
*
|
|
131
|
+
* This is NOT modelled on `session.openWorkspacePath`. That endpoint hands a
|
|
132
|
+
* path to the local opener and leaves the effect on the machine; this one sends
|
|
133
|
+
* file content across the wire, which is a different level of exposure.
|
|
134
|
+
*/
|
|
135
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
136
|
+
var useValue = arguments.length > 2;
|
|
137
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
138
|
+
return useValue ? value : void 0;
|
|
139
|
+
};
|
|
140
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
141
|
+
function accept(f) {
|
|
142
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
143
|
+
return f;
|
|
144
|
+
}
|
|
145
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
146
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
147
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
148
|
+
var _, done = false;
|
|
149
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
150
|
+
var context = {};
|
|
151
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
152
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
153
|
+
context.addInitializer = function(f) {
|
|
154
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
155
|
+
extraInitializers.push(accept(f || null));
|
|
156
|
+
};
|
|
157
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
158
|
+
get: descriptor.get,
|
|
159
|
+
set: descriptor.set
|
|
160
|
+
} : descriptor[key], context);
|
|
161
|
+
if (kind === "accessor") {
|
|
162
|
+
if (result === void 0) continue;
|
|
163
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
164
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
165
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
166
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
167
|
+
} else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
|
|
168
|
+
else descriptor[key] = _;
|
|
169
|
+
}
|
|
170
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
171
|
+
done = true;
|
|
172
|
+
};
|
|
173
|
+
/** The byte text never carries: its presence marks a page as binary. */
|
|
174
|
+
const NUL = String.fromCharCode(0);
|
|
175
|
+
/** Refuse anything the wire schema admits as a number but a window cannot use: only safe integers index a file. */
|
|
176
|
+
function integerAtLeast(value, min, name) {
|
|
177
|
+
if (!Number.isSafeInteger(value) || value < min) throw new RemoteError("gateway/bad-request", `${name} must be a safe integer of at least ${min}`, {});
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Cut lines `offset` through `offset + limit - 1` from decoded chunks, stopping
|
|
182
|
+
* at the first character past the page so the rest of the file is never read.
|
|
183
|
+
* Lines before the page are counted, not kept, and the page is refused the
|
|
184
|
+
* moment its bytes exceed `maxBytes`, so one giant line cannot grow memory past
|
|
185
|
+
* the cap either.
|
|
186
|
+
*/
|
|
187
|
+
async function cutPage(chunks, offset, limit, maxBytes, path) {
|
|
188
|
+
const last = offset + limit - 1;
|
|
189
|
+
const lines = [];
|
|
190
|
+
let current = "";
|
|
191
|
+
let bytes = 0;
|
|
192
|
+
let lineNumber = 1;
|
|
193
|
+
const admit = (size) => {
|
|
194
|
+
bytes += size;
|
|
195
|
+
if (bytes > maxBytes) throw new RemoteError("workspace-file/too-large", `lines ${offset}-${last} of "${path}" exceed the ${maxBytes} byte cap`, {
|
|
196
|
+
path,
|
|
197
|
+
limit: maxBytes
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
const complete = () => {
|
|
201
|
+
if (lines.length > 0) admit(1);
|
|
202
|
+
lines.push(current);
|
|
203
|
+
current = "";
|
|
204
|
+
};
|
|
205
|
+
for await (const chunk of chunks) {
|
|
206
|
+
let position = 0;
|
|
207
|
+
while (position < chunk.length) {
|
|
208
|
+
if (lineNumber > last) return {
|
|
209
|
+
text: lines.join("\n"),
|
|
210
|
+
lines: lines.length,
|
|
211
|
+
eof: false
|
|
212
|
+
};
|
|
213
|
+
const newline = chunk.indexOf("\n", position);
|
|
214
|
+
const segment = newline === -1 ? chunk.slice(position) : chunk.slice(position, newline);
|
|
215
|
+
if (lineNumber >= offset) {
|
|
216
|
+
admit(Buffer.byteLength(segment, "utf8"));
|
|
217
|
+
current += segment;
|
|
218
|
+
}
|
|
219
|
+
if (newline === -1) break;
|
|
220
|
+
if (lineNumber >= offset) complete();
|
|
221
|
+
lineNumber += 1;
|
|
222
|
+
position = newline + 1;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (current.length > 0) complete();
|
|
226
|
+
return {
|
|
227
|
+
text: lines.join("\n"),
|
|
228
|
+
lines: lines.length,
|
|
229
|
+
eof: true
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Workspace path of `target` relative to `root`, derived from the two canonical
|
|
234
|
+
* `file:` URIs so the answer is `/`-joined on every platform. Empty for the root.
|
|
235
|
+
*/
|
|
236
|
+
function workspacePathOf(rootUrl, targetUrl) {
|
|
237
|
+
const root = new URL(rootUrl).pathname.replace(/\/+$/, "");
|
|
238
|
+
const target = new URL(targetUrl).pathname;
|
|
239
|
+
if (target === root) return "";
|
|
240
|
+
return target.slice(root.length + 1).split("/").map(decodeURIComponent).join("/");
|
|
241
|
+
}
|
|
242
|
+
/** Strip the resolved child target: the wire carries names and metadata only. */
|
|
243
|
+
function directoryEntry(child) {
|
|
244
|
+
return {
|
|
245
|
+
name: child.name,
|
|
246
|
+
type: child.type,
|
|
247
|
+
...child.size === void 0 ? {} : { size: child.size }
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** Host Remote service over the composed filesystem, confined to one workspace. */
|
|
251
|
+
let WorkspaceFiles = (() => {
|
|
252
|
+
let _classSuper = TypertRemoteService;
|
|
253
|
+
let _instanceExtraInitializers = [];
|
|
254
|
+
let _read_decorators;
|
|
255
|
+
let _readBytes_decorators;
|
|
256
|
+
let _stat_decorators;
|
|
257
|
+
let _list_decorators;
|
|
258
|
+
let _changes_decorators;
|
|
259
|
+
return class WorkspaceFiles extends _classSuper {
|
|
260
|
+
static {
|
|
261
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
262
|
+
_read_decorators = [Remote];
|
|
263
|
+
_readBytes_decorators = [Remote];
|
|
264
|
+
_stat_decorators = [Remote];
|
|
265
|
+
_list_decorators = [Remote];
|
|
266
|
+
_changes_decorators = [Remote({ mode: "stream" })];
|
|
267
|
+
__esDecorate(this, null, _read_decorators, {
|
|
268
|
+
kind: "method",
|
|
269
|
+
name: "read",
|
|
270
|
+
static: false,
|
|
271
|
+
private: false,
|
|
272
|
+
access: {
|
|
273
|
+
has: (obj) => "read" in obj,
|
|
274
|
+
get: (obj) => obj.read
|
|
275
|
+
},
|
|
276
|
+
metadata: _metadata
|
|
277
|
+
}, null, _instanceExtraInitializers);
|
|
278
|
+
__esDecorate(this, null, _readBytes_decorators, {
|
|
279
|
+
kind: "method",
|
|
280
|
+
name: "readBytes",
|
|
281
|
+
static: false,
|
|
282
|
+
private: false,
|
|
283
|
+
access: {
|
|
284
|
+
has: (obj) => "readBytes" in obj,
|
|
285
|
+
get: (obj) => obj.readBytes
|
|
286
|
+
},
|
|
287
|
+
metadata: _metadata
|
|
288
|
+
}, null, _instanceExtraInitializers);
|
|
289
|
+
__esDecorate(this, null, _stat_decorators, {
|
|
290
|
+
kind: "method",
|
|
291
|
+
name: "stat",
|
|
292
|
+
static: false,
|
|
293
|
+
private: false,
|
|
294
|
+
access: {
|
|
295
|
+
has: (obj) => "stat" in obj,
|
|
296
|
+
get: (obj) => obj.stat
|
|
297
|
+
},
|
|
298
|
+
metadata: _metadata
|
|
299
|
+
}, null, _instanceExtraInitializers);
|
|
300
|
+
__esDecorate(this, null, _list_decorators, {
|
|
301
|
+
kind: "method",
|
|
302
|
+
name: "list",
|
|
303
|
+
static: false,
|
|
304
|
+
private: false,
|
|
305
|
+
access: {
|
|
306
|
+
has: (obj) => "list" in obj,
|
|
307
|
+
get: (obj) => obj.list
|
|
308
|
+
},
|
|
309
|
+
metadata: _metadata
|
|
310
|
+
}, null, _instanceExtraInitializers);
|
|
311
|
+
__esDecorate(this, null, _changes_decorators, {
|
|
312
|
+
kind: "method",
|
|
313
|
+
name: "changes",
|
|
314
|
+
static: false,
|
|
315
|
+
private: false,
|
|
316
|
+
access: {
|
|
317
|
+
has: (obj) => "changes" in obj,
|
|
318
|
+
get: (obj) => obj.changes
|
|
319
|
+
},
|
|
320
|
+
metadata: _metadata
|
|
321
|
+
}, null, _instanceExtraInitializers);
|
|
322
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
323
|
+
enumerable: true,
|
|
324
|
+
configurable: true,
|
|
325
|
+
writable: true,
|
|
326
|
+
value: _metadata
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
config = __runInitializers(this, _instanceExtraInitializers);
|
|
330
|
+
static inject = [
|
|
331
|
+
"fs",
|
|
332
|
+
"sandboxPolicy",
|
|
333
|
+
"typert"
|
|
334
|
+
];
|
|
335
|
+
static Config = z.object({
|
|
336
|
+
maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024),
|
|
337
|
+
maxLines: z.number().step(1).min(1).default(5e3),
|
|
338
|
+
maxEntries: z.number().step(1).min(1).default(2e3)
|
|
339
|
+
});
|
|
340
|
+
feed;
|
|
341
|
+
/**
|
|
342
|
+
* @param ctx - Host context carrying the filesystem and the sandbox policy.
|
|
343
|
+
* @param config - deployment caps on one page or one listing.
|
|
344
|
+
*/
|
|
345
|
+
constructor(ctx, config) {
|
|
346
|
+
super(ctx, "workspaceFiles");
|
|
347
|
+
this.config = config;
|
|
348
|
+
this.feed = new WorkspaceChangeFeed(ctx);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Read one page of lines from a UTF-8 text file inside the Agent's workspace.
|
|
352
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
353
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
354
|
+
* @param range - the line window; omitted fields take the page defaults.
|
|
355
|
+
* @param signal - caller cancellation.
|
|
356
|
+
* @returns the page, the file's version at the stat before it, and whether it reaches the last line.
|
|
357
|
+
*/
|
|
358
|
+
async read(agent, path, range, signal) {
|
|
359
|
+
const { offset, limit } = this.resolvePage(range);
|
|
360
|
+
const { target, info } = await this.locateFile(agent, path, signal);
|
|
361
|
+
const page = await this.cutPage(target, offset, limit, signal, path);
|
|
362
|
+
if (page.text.includes(NUL)) throw new RemoteError("workspace-file/not-text", `"${path}" contains NUL bytes`, { path });
|
|
363
|
+
return {
|
|
364
|
+
...this.statOf(target, info),
|
|
365
|
+
offset,
|
|
366
|
+
text: page.text,
|
|
367
|
+
lines: page.lines,
|
|
368
|
+
eof: page.eof
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Read one byte window of a regular file inside the Agent's workspace: raw
|
|
373
|
+
* bytes, no text decoding and no binary rejection.
|
|
374
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
375
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
376
|
+
* @param range - the byte window; omitted fields take the window defaults.
|
|
377
|
+
* @param signal - caller cancellation.
|
|
378
|
+
* @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
|
|
379
|
+
*/
|
|
380
|
+
async readBytes(agent, path, range, signal) {
|
|
381
|
+
const { offset, length } = this.resolveWindow(range, path);
|
|
382
|
+
const { target, info } = await this.locateFile(agent, path, signal);
|
|
383
|
+
const data = await this.ctx.fs.readByteRange(target, {
|
|
384
|
+
offset,
|
|
385
|
+
length
|
|
386
|
+
}, signal);
|
|
387
|
+
const eof = info.size === void 0 ? data.length < length : offset + data.length >= info.size;
|
|
388
|
+
return {
|
|
389
|
+
...this.statOf(target, info),
|
|
390
|
+
offset,
|
|
391
|
+
data: Buffer.from(data).toString("base64"),
|
|
392
|
+
eof
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Report one regular file's identity, version, and size without its content.
|
|
397
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
398
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
399
|
+
* @param signal - caller cancellation.
|
|
400
|
+
* @returns the file's absolute path, current version, and byte size.
|
|
401
|
+
*/
|
|
402
|
+
async stat(agent, path, signal) {
|
|
403
|
+
const { target, info } = await this.locateFile(agent, path, signal);
|
|
404
|
+
return this.statOf(target, info);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* List the direct children of one directory inside the Agent's workspace.
|
|
408
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
409
|
+
* @param path - workspace path, absolute or relative to the workspace root.
|
|
410
|
+
* @param signal - caller cancellation.
|
|
411
|
+
* @returns the directory's children in the backend's stable name order, bounded by the entry cap.
|
|
412
|
+
*/
|
|
413
|
+
async list(agent, path, signal) {
|
|
414
|
+
const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal);
|
|
415
|
+
if (entry.type !== "directory") throw new RemoteError("workspace-file/not-directory", `"${path}" is a ${entry.type}`, {
|
|
416
|
+
path,
|
|
417
|
+
kind: entry.type
|
|
418
|
+
});
|
|
419
|
+
const target = await this.confine(root, workspaceRoot, path, signal);
|
|
420
|
+
const children = await this.ctx.fs.listDir(target, signal);
|
|
421
|
+
return {
|
|
422
|
+
path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)),
|
|
423
|
+
entries: children.slice(0, this.config.maxEntries).map(directoryEntry),
|
|
424
|
+
truncated: children.length > this.config.maxEntries
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Stream every `fs/observed` observation of a file inside the Agent's
|
|
429
|
+
* workspace. Only Agent filesystem operations report here; the OS is not
|
|
430
|
+
* watched.
|
|
431
|
+
* @param agent - target Agent resolved from the Session identity on the wire.
|
|
432
|
+
* @param signal - generation cancellation.
|
|
433
|
+
* @returns `ready` once the Host observation queue is active and the workspace
|
|
434
|
+
* root is resolved, then queued and live observations in emission order.
|
|
435
|
+
*/
|
|
436
|
+
changes(agent, signal) {
|
|
437
|
+
return this.feed.follow(this.workspaceRootOf(agent), signal);
|
|
438
|
+
}
|
|
439
|
+
/** Apply the page defaults and caps here, so the request never carries them implicitly. */
|
|
440
|
+
resolvePage(range) {
|
|
441
|
+
const offset = range.offset === void 0 ? 1 : integerAtLeast(range.offset, 1, "offset");
|
|
442
|
+
const limit = range.limit === void 0 ? this.config.maxLines : integerAtLeast(range.limit, 1, "limit");
|
|
443
|
+
if (limit > this.config.maxLines) throw new RemoteError("gateway/bad-request", `limit must be at most ${this.config.maxLines}`, {});
|
|
444
|
+
return {
|
|
445
|
+
offset,
|
|
446
|
+
limit
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/** Apply the byte-window defaults and cap; a window above the cap is refused, not shortened. */
|
|
450
|
+
resolveWindow(range, path) {
|
|
451
|
+
const offset = range.offset === void 0 ? 0 : integerAtLeast(range.offset, 0, "offset");
|
|
452
|
+
const length = range.length === void 0 ? this.config.maxBytes : integerAtLeast(range.length, 1, "length");
|
|
453
|
+
if (offset + length > Number.MAX_SAFE_INTEGER) throw new RemoteError("gateway/bad-request", "offset plus length must stay a safe integer", {});
|
|
454
|
+
if (length > this.config.maxBytes) throw new RemoteError("workspace-file/too-large", `${length} bytes of "${path}" exceed the ${this.config.maxBytes} byte cap`, {
|
|
455
|
+
path,
|
|
456
|
+
limit: this.config.maxBytes
|
|
457
|
+
});
|
|
458
|
+
return {
|
|
459
|
+
offset,
|
|
460
|
+
length
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* The workspace root comes from the policy, not from the backend's own cwd
|
|
465
|
+
* default: the `minimal` preset shadows the host provider with a bare
|
|
466
|
+
* `fs-local` whose cwd differs, and resolving explicitly makes the answer
|
|
467
|
+
* the same whichever instance answers.
|
|
468
|
+
*/
|
|
469
|
+
workspaceRootOf(agent) {
|
|
470
|
+
return this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Gates 1 and 2 up to the point where the path's own type is known. The
|
|
474
|
+
* path is inspected before containment is decided, so a caller learns whether
|
|
475
|
+
* an outside path exists and what kind it is before `outside-workspace`
|
|
476
|
+
* refuses it; the caller is the Session's own owner, who can read the Host
|
|
477
|
+
* through the Agent anyway, and the accepted cost buys one `lstat` gate for
|
|
478
|
+
* every method instead of two resolution orders.
|
|
479
|
+
*/
|
|
480
|
+
async inspect(agent, path, signal) {
|
|
481
|
+
if (path.length === 0) throw new RemoteError("gateway/bad-request", "path is required", {});
|
|
482
|
+
const workspaceRoot = this.workspaceRootOf(agent);
|
|
483
|
+
const root = await this.ctx.fs.resolve(workspaceRoot, { signal });
|
|
484
|
+
const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal);
|
|
485
|
+
if (entry === void 0) throw new RemoteError("workspace-file/not-found", `no entry at "${path}"`, { path });
|
|
486
|
+
return {
|
|
487
|
+
root,
|
|
488
|
+
workspaceRoot,
|
|
489
|
+
entry
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/** Resolve an inspected path and refuse it unless the workspace contains it. */
|
|
493
|
+
async confine(root, workspaceRoot, path, signal) {
|
|
494
|
+
const target = await this.ctx.fs.resolve(path, {
|
|
495
|
+
cwd: workspaceRoot,
|
|
496
|
+
signal
|
|
497
|
+
});
|
|
498
|
+
if (!this.ctx.fs.contains(root, target)) throw new RemoteError("workspace-file/outside-workspace", `"${path}" is outside the workspace`, { path });
|
|
499
|
+
return target;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* All gates for a regular file, ending in the one stat that names its version
|
|
503
|
+
* and size. The stat re-checks what `lstat` saw: the file may have gone or
|
|
504
|
+
* changed kind in between.
|
|
505
|
+
*/
|
|
506
|
+
async locateFile(agent, path, signal) {
|
|
507
|
+
const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal);
|
|
508
|
+
if (entry.type !== "file") throw new RemoteError("workspace-file/not-regular-file", `"${path}" is a ${entry.type}`, {
|
|
509
|
+
path,
|
|
510
|
+
kind: entry.type
|
|
511
|
+
});
|
|
512
|
+
const target = await this.confine(root, workspaceRoot, path, signal);
|
|
513
|
+
const info = await this.ctx.fs.stat(target, signal);
|
|
514
|
+
if (info === void 0) throw new RemoteError("workspace-file/not-found", `no entry at "${path}"`, { path });
|
|
515
|
+
if (info.type !== "file") throw new RemoteError("workspace-file/not-regular-file", `"${path}" is a ${info.type}`, {
|
|
516
|
+
path,
|
|
517
|
+
kind: info.type
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
target,
|
|
521
|
+
info
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
statOf(target, info) {
|
|
525
|
+
return {
|
|
526
|
+
absolutePath: this.ctx.fs.processPath(target),
|
|
527
|
+
version: info.version,
|
|
528
|
+
...info.size === void 0 ? {} : { bytes: info.size }
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/** Stream the file as text and cut the page, classifying the backend's non-text refusal. */
|
|
532
|
+
async cutPage(target, offset, limit, signal, path) {
|
|
533
|
+
try {
|
|
534
|
+
return await cutPage(await this.ctx.fs.streamText(target, signal), offset, limit, this.config.maxBytes, path);
|
|
535
|
+
} catch (error) {
|
|
536
|
+
if (isNotTextRefusal(error)) throw new RemoteError("workspace-file/not-text", `"${path}" is not UTF-8 text`, { path }, { cause: error });
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
})();
|
|
542
|
+
/**
|
|
543
|
+
* The backend's non-text refusal, recognized by its code alone: the error class
|
|
544
|
+
* belongs to whichever `dsh-fs` instance the provider loaded, so no class
|
|
545
|
+
* identity is shared across the package boundary.
|
|
546
|
+
*/
|
|
547
|
+
function isNotTextRefusal(error) {
|
|
548
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "FS_NOT_TEXT";
|
|
549
|
+
}
|
|
550
|
+
//#endregion
|
|
551
|
+
export { WorkspaceFiles, WorkspaceFiles as default };
|