@deepseek-ai/dsh-api-workspace-files 0.1.5-alpha.1 → 0.1.5-alpha.2

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/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { posix, win32 } from "node:path";
1
2
  import z from "@deepseek-ai/schemastery";
2
3
  import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
3
4
  import { Deque } from "@deepseek-ai/dsh-deque";
@@ -5,8 +6,8 @@ import { Deque } from "@deepseek-ai/dsh-deque";
5
6
  /**
6
7
  * Producer of the `changes` stream: every `fs/observed` emission whose target
7
8
  * 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.
9
+ * generation. Instrumented filesystem operations emit these observations; the
10
+ * operating system is not watched.
10
11
  * Each generation acknowledges its observation queue and resolved workspace
11
12
  * root with `ready` before emitting any queued or live changes.
12
13
  */
@@ -107,22 +108,16 @@ var ChangeFollower = class {
107
108
  //#endregion
108
109
  //#region lib/types/index.js
109
110
  /**
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.
111
+ * Workspace file service: read-only file previews, workspace directory
112
+ * listings, and the filesystem-observation change feed, exposed as
113
+ * `workspaceFiles`.
113
114
  *
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`.
115
+ * File reads follow the composed filesystem's read access, including paths
116
+ * outside the workspace. The selected Session header supplies the base for
117
+ * relative paths, with the sandbox policy root as its no-cwd fallback, not a
118
+ * read-containment restriction. Directory listings and change observations
119
+ * remain workspace-scoped. File-kind checks and configured read caps apply to
120
+ * every preview; this service exposes no mutations.
126
121
  *
127
122
  * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
128
123
  * goes, so the file is read only up to the first character past the page and
@@ -247,12 +242,14 @@ function directoryEntry(child) {
247
242
  ...child.size === void 0 ? {} : { size: child.size }
248
243
  };
249
244
  }
250
- /** Host Remote service over the composed filesystem, confined to one workspace. */
245
+ /** Host Remote file reads and workspace directory observations over the composed filesystem. */
251
246
  let WorkspaceFiles = (() => {
252
247
  let _classSuper = TypertRemoteService;
253
248
  let _instanceExtraInitializers = [];
254
249
  let _read_decorators;
255
250
  let _readBytes_decorators;
251
+ let _readAll_decorators;
252
+ let _readRelated_decorators;
256
253
  let _stat_decorators;
257
254
  let _list_decorators;
258
255
  let _changes_decorators;
@@ -261,6 +258,8 @@ let WorkspaceFiles = (() => {
261
258
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
262
259
  _read_decorators = [Remote];
263
260
  _readBytes_decorators = [Remote];
261
+ _readAll_decorators = [Remote];
262
+ _readRelated_decorators = [Remote];
264
263
  _stat_decorators = [Remote];
265
264
  _list_decorators = [Remote];
266
265
  _changes_decorators = [Remote({ mode: "stream" })];
@@ -286,6 +285,28 @@ let WorkspaceFiles = (() => {
286
285
  },
287
286
  metadata: _metadata
288
287
  }, null, _instanceExtraInitializers);
288
+ __esDecorate(this, null, _readAll_decorators, {
289
+ kind: "method",
290
+ name: "readAll",
291
+ static: false,
292
+ private: false,
293
+ access: {
294
+ has: (obj) => "readAll" in obj,
295
+ get: (obj) => obj.readAll
296
+ },
297
+ metadata: _metadata
298
+ }, null, _instanceExtraInitializers);
299
+ __esDecorate(this, null, _readRelated_decorators, {
300
+ kind: "method",
301
+ name: "readRelated",
302
+ static: false,
303
+ private: false,
304
+ access: {
305
+ has: (obj) => "readRelated" in obj,
306
+ get: (obj) => obj.readRelated
307
+ },
308
+ metadata: _metadata
309
+ }, null, _instanceExtraInitializers);
289
310
  __esDecorate(this, null, _stat_decorators, {
290
311
  kind: "method",
291
312
  name: "stat",
@@ -330,10 +351,12 @@ let WorkspaceFiles = (() => {
330
351
  static inject = [
331
352
  "fs",
332
353
  "sandboxPolicy",
354
+ "sessions",
333
355
  "typert"
334
356
  ];
335
357
  static Config = z.object({
336
358
  maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024),
359
+ maxFileBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER - 1).default(32 * 1024 * 1024),
337
360
  maxLines: z.number().step(1).min(1).default(5e3),
338
361
  maxEntries: z.number().step(1).min(1).default(2e3)
339
362
  });
@@ -346,18 +369,36 @@ let WorkspaceFiles = (() => {
346
369
  super(ctx, "workspaceFiles");
347
370
  this.config = config;
348
371
  this.feed = new WorkspaceChangeFeed(ctx);
372
+ ctx.inject(["sessions", "typert"], (scope) => {
373
+ scope.typert.lookups.register("workspaceFileScope", {
374
+ parameter: "workspaceFileScope",
375
+ wire: "workspaceFileScopeId",
376
+ hostTypeSymbol: "@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope",
377
+ wireTypeSymbol: "@deepseek-ai/dsh-session/types#SessionId",
378
+ resolve: async (sessionId) => {
379
+ const live = scope.sessions.get(sessionId)?.header;
380
+ const stored = live === void 0 ? await scope.get("sessionPersistence")?.stat(sessionId) : void 0;
381
+ const header = live ?? stored?.header;
382
+ if (header === void 0) return void 0;
383
+ return {
384
+ sessionId,
385
+ workspaceRoot: header.cwd ?? scope.sandboxPolicy.workspaceRoot
386
+ };
387
+ }
388
+ });
389
+ });
349
390
  }
350
391
  /**
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.
392
+ * Read one page of lines from a UTF-8 file readable by the filesystem backend.
393
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
394
+ * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
354
395
  * @param range - the line window; omitted fields take the page defaults.
355
396
  * @param signal - caller cancellation.
356
397
  * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
357
398
  */
358
- async read(agent, path, range, signal) {
399
+ async read(workspaceFileScope, path, range, signal) {
359
400
  const { offset, limit } = this.resolvePage(range);
360
- const { target, info } = await this.locateFile(agent, path, signal);
401
+ const { target, info } = await this.locateFile(workspaceFileScope, path, signal);
361
402
  const page = await this.cutPage(target, offset, limit, signal, path);
362
403
  if (page.text.includes(NUL)) throw new RemoteError("workspace-file/not-text", `"${path}" contains NUL bytes`, { path });
363
404
  return {
@@ -369,17 +410,17 @@ let WorkspaceFiles = (() => {
369
410
  };
370
411
  }
371
412
  /**
372
- * Read one byte window of a regular file inside the Agent's workspace: raw
413
+ * Read one byte window of a regular file readable by the filesystem backend: raw
373
414
  * 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.
415
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
416
+ * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
376
417
  * @param range - the byte window; omitted fields take the window defaults.
377
418
  * @param signal - caller cancellation.
378
419
  * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
379
420
  */
380
- async readBytes(agent, path, range, signal) {
421
+ async readBytes(workspaceFileScope, path, range, signal) {
381
422
  const { offset, length } = this.resolveWindow(range, path);
382
- const { target, info } = await this.locateFile(agent, path, signal);
423
+ const { target, info } = await this.locateFile(workspaceFileScope, path, signal);
383
424
  const data = await this.ctx.fs.readByteRange(target, {
384
425
  offset,
385
426
  length
@@ -393,25 +434,70 @@ let WorkspaceFiles = (() => {
393
434
  };
394
435
  }
395
436
  /**
437
+ * Read a complete regular file as bytes, subject to the configured full-file cap.
438
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
439
+ * @param path - absolute or workspace-relative file path.
440
+ * @param signal - caller cancellation.
441
+ * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
442
+ */
443
+ async readAll(workspaceFileScope, path, signal) {
444
+ const { target, info } = await this.locateFile(workspaceFileScope, path, signal);
445
+ const limit = this.config.maxFileBytes;
446
+ if (info.size !== void 0 && info.size > limit) throw new RemoteError("workspace-file/too-large", `"${path}" exceeds the ${limit} byte full-file cap`, {
447
+ path,
448
+ limit
449
+ });
450
+ const data = await this.ctx.fs.readByteRange(target, {
451
+ offset: 0,
452
+ length: limit + 1
453
+ }, signal);
454
+ if (data.length > limit) throw new RemoteError("workspace-file/too-large", `"${path}" exceeds the ${limit} byte full-file cap`, {
455
+ path,
456
+ limit
457
+ });
458
+ return {
459
+ ...this.statOf(target, info),
460
+ offset: 0,
461
+ data: Buffer.from(data).toString("base64"),
462
+ eof: true
463
+ };
464
+ }
465
+ /**
466
+ * Read a complete file relative to another file's directory, including outside the workspace.
467
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
468
+ * @param path - base file, absolute or workspace-relative.
469
+ * @param relativePath - relative filesystem path, not a URL or absolute path.
470
+ * @param signal - caller cancellation.
471
+ * @returns the complete related file using the ordinary file-size and access checks.
472
+ */
473
+ async readRelated(workspaceFileScope, path, relativePath, signal) {
474
+ const relative = relativePath.replace(/\\/g, "/");
475
+ if (relative.length === 0 || relative.startsWith("/") || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) throw new RemoteError("gateway/bad-request", "relativePath must be a relative filesystem path", {});
476
+ const { target } = await this.locateFile(workspaceFileScope, path, signal);
477
+ const absolute = this.ctx.fs.processPath(target);
478
+ const paths = absolute.startsWith("/") ? posix : win32;
479
+ return this.readAll(workspaceFileScope, paths.resolve(paths.dirname(absolute), relative), signal);
480
+ }
481
+ /**
396
482
  * 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.
483
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
484
+ * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
399
485
  * @param signal - caller cancellation.
400
486
  * @returns the file's absolute path, current version, and byte size.
401
487
  */
402
- async stat(agent, path, signal) {
403
- const { target, info } = await this.locateFile(agent, path, signal);
488
+ async stat(workspaceFileScope, path, signal) {
489
+ const { target, info } = await this.locateFile(workspaceFileScope, path, signal);
404
490
  return this.statOf(target, info);
405
491
  }
406
492
  /**
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.
493
+ * List the direct children of one directory inside the Session's workspace.
494
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
409
495
  * @param path - workspace path, absolute or relative to the workspace root.
410
496
  * @param signal - caller cancellation.
411
497
  * @returns the directory's children in the backend's stable name order, bounded by the entry cap.
412
498
  */
413
- async list(agent, path, signal) {
414
- const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal);
499
+ async list(workspaceFileScope, path, signal) {
500
+ const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal);
415
501
  if (entry.type !== "directory") throw new RemoteError("workspace-file/not-directory", `"${path}" is a ${entry.type}`, {
416
502
  path,
417
503
  kind: entry.type
@@ -425,16 +511,16 @@ let WorkspaceFiles = (() => {
425
511
  };
426
512
  }
427
513
  /**
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.
514
+ * Stream every `fs/observed` observation of a file inside the Session's
515
+ * workspace. Only instrumented filesystem operations report here; the OS is
516
+ * not watched.
517
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
432
518
  * @param signal - generation cancellation.
433
519
  * @returns `ready` once the Host observation queue is active and the workspace
434
520
  * root is resolved, then queued and live observations in emission order.
435
521
  */
436
- changes(agent, signal) {
437
- return this.feed.follow(this.workspaceRootOf(agent), signal);
522
+ changes(workspaceFileScope, signal) {
523
+ return this.feed.follow(workspaceFileScope.workspaceRoot, signal);
438
524
  }
439
525
  /** Apply the page defaults and caps here, so the request never carries them implicitly. */
440
526
  resolvePage(range) {
@@ -461,25 +547,12 @@ let WorkspaceFiles = (() => {
461
547
  };
462
548
  }
463
549
  /**
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.
550
+ * Inspect the requested path itself before resolution follows its final
551
+ * component. Directory containment is checked separately by `list`.
479
552
  */
480
- async inspect(agent, path, signal) {
553
+ async inspect(workspaceFileScope, path, signal) {
481
554
  if (path.length === 0) throw new RemoteError("gateway/bad-request", "path is required", {});
482
- const workspaceRoot = this.workspaceRootOf(agent);
555
+ const { workspaceRoot } = workspaceFileScope;
483
556
  const root = await this.ctx.fs.resolve(workspaceRoot, { signal });
484
557
  const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal);
485
558
  if (entry === void 0) throw new RemoteError("workspace-file/not-found", `no entry at "${path}"`, { path });
@@ -503,13 +576,16 @@ let WorkspaceFiles = (() => {
503
576
  * and size. The stat re-checks what `lstat` saw: the file may have gone or
504
577
  * changed kind in between.
505
578
  */
506
- async locateFile(agent, path, signal) {
507
- const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal);
579
+ async locateFile(workspaceFileScope, path, signal) {
580
+ const { workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal);
508
581
  if (entry.type !== "file") throw new RemoteError("workspace-file/not-regular-file", `"${path}" is a ${entry.type}`, {
509
582
  path,
510
583
  kind: entry.type
511
584
  });
512
- const target = await this.confine(root, workspaceRoot, path, signal);
585
+ const target = await this.ctx.fs.resolve(path, {
586
+ cwd: workspaceRoot,
587
+ signal
588
+ });
513
589
  const info = await this.ctx.fs.stat(target, signal);
514
590
  if (info === void 0) throw new RemoteError("workspace-file/not-found", `no entry at "${path}"`, { path });
515
591
  if (info.type !== "file") throw new RemoteError("workspace-file/not-regular-file", `"${path}" is a ${info.type}`, {