Forráskód Böngészése

Merge remote-tracking branch 'origin/master' into worktree/pr-3819-release-sync

creatixchu 1 hónapja
szülő
commit
2ab73152ae
33 módosított fájl, 427 hozzáadás és 291 törlés
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml
  2. 13 13
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
  3. 13 13
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md
  4. 2 2
      apps/web/tests/preview-boot.e2e.ts
  5. 14 5
      apps/web/tests/sidebar-right.e2e.ts
  6. 2 2
      docs/config-catalog.i18n.yaml
  7. 2 2
      docs/config-catalog.md
  8. 2 2
      docs/config-catalog.zh.md
  9. 2 2
      docs/subsystems/workspace.i18n.yaml
  10. 18 20
      docs/subsystems/workspace.md
  11. 18 20
      docs/subsystems/workspace.zh.md
  12. 2 2
      packages/api/README.i18n.yaml
  13. 1 1
      packages/api/README.md
  14. 1 1
      packages/api/README.zh.md
  15. 2 2
      packages/api/workspace-files/README.i18n.yaml
  16. 7 7
      packages/api/workspace-files/README.md
  17. 7 7
      packages/api/workspace-files/README.zh.md
  18. 1 1
      packages/api/workspace-files/package.json
  19. 2 2
      packages/api/workspace-files/src/changes.ts
  20. 96 50
      packages/api/workspace-files/src/index.ts
  21. 3 3
      packages/api/workspace-files/src/types.ts
  22. 3 3
      packages/api/workspace-files/tests/changes.spec.ts
  23. 12 7
      packages/api/workspace-files/tests/harness.ts
  24. 13 13
      packages/api/workspace-files/tests/list.spec.ts
  25. 19 19
      packages/api/workspace-files/tests/read-all.spec.ts
  26. 21 21
      packages/api/workspace-files/tests/read-bytes.spec.ts
  27. 34 34
      packages/api/workspace-files/tests/read.spec.ts
  28. 75 0
      packages/api/workspace-files/tests/scope.spec.ts
  29. 13 13
      packages/api/workspace-files/tests/stat.spec.ts
  30. 3 3
      packages/api/workspace-files/tsconfig.host.json
  31. 20 16
      packages/extensions/tool-cordis/src/api-catalog.ts
  32. 3 3
      pnpm-lock.yaml
  33. 1 0
      scripts/gen-cordis-catalog.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
-2026-09-05-workspace-files-service.md: 39b73b71517934cf3007f042ac58061f655d6b85
-2026-09-05-workspace-files-service.zh.md: e4769a44a3517dffe36003e93cdeb3b258d6b443
+2026-09-05-workspace-files-service.md: b2dbdfc99388d8c2f18991a7704599d2d95f070b
+2026-09-05-workspace-files-service.zh.md: d860d220af50a24a6e0f40b640d0a8fb534c4741

+ 13 - 13
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md

@@ -20,23 +20,23 @@ Two constraints frame the service. File reads through `ctx.fs` use the Session's
 
 | Face | Package | Files | Depends on |
 |---|---|---|---|
-| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-agent`, `dsh-session` |
-| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-api-session-controller/client`, `dsh-client-resources`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` |
+| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-session`, `dsh-session-persistence` |
+| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-session/types`, `dsh-client-resources`, `dsh-client-ui-slots`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` |
 
 `api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI.
 
 ### The `workspaceFiles` Remote namespace
 
-Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The seven signatures, as `src/index.ts` declares them:
+Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from the wire Session identity by reading the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The seven signatures, as `src/index.ts` declares them:
 
 ```ts ignore-check
-@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
-@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
-@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
-@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
+@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
+@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
+@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
+@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
 ```
 
 - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only.
@@ -57,7 +57,7 @@ Two path vocabularies leave the service, and each method uses exactly one. `read
 `read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks:
 
 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`.
-2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets.
+2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets.
 3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`.
 4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected.
 
@@ -131,7 +131,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
 
 ## Consequences
 
-- Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate.
+- Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate. Header-only Session scope lets ordinary, subagent, live, and cold Sessions resolve their own relative paths without an Agent lifecycle or parent fallback.
 - A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host; complete reads instead enforce `maxFileBytes`; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines.
 - Every filesystem provider now offers a windowed raw read. `fs-e2b` pays for it by transferring the skipped prefix, since its SDK cannot seek; `fs-local` seeks.
 - Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. A follower binds to successful `stat.absolutePath`, so another spelling of the same file — a workspace root reached through a symlink — uses that canonical change key.
@@ -142,7 +142,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
 
 ## Testing
 
-Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, outside-workspace reads and backend refusals, `list` with containment, truncation, symlink children, and `not-directory`, and the `changes` stream driven by `fs/observed` and filtered by root. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. `readAll` and `readRelated` specs cover complete-read caps, outside base and related paths, and Host backend authorization. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
+Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
 
 ## Deferred
 

+ 13 - 13
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md

@@ -20,23 +20,23 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 
 | 面 | 包 | 文件 | 依赖 |
 |---|---|---|---|
-| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-agent`、`dsh-session` |
-| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-api-session-controller/client`、`dsh-client-resources`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` |
+| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-session`、`dsh-session-persistence` |
+| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-session/types`、`dsh-client-resources`、`dsh-client-ui-slots`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` |
 
 `api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。
 
 ### `workspaceFiles` Remote 命名空间
 
-每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。七个签名照 `src/index.ts` 的声明:
+每个 Host 方法首参都是 `WorkspaceFileScope`。Gateway 从线路上的 Session 身份解析它:优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。七个签名照 `src/index.ts` 的声明:
 
 ```ts ignore-check
-@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
-@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
-@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
-@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
+@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
+@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
+@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
+@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
 ```
 
 - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。
@@ -57,7 +57,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 `read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查:
 
 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。
-2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。
+2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。
 3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。
 4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。
 
@@ -131,7 +131,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
 
 ## Consequences
 
-- 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。
+- 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。header-only Session scope 让普通、subagent、live 与 cold Session 都能解析自己的相对路径,不需要 Agent 生命周期,也不回退父 Session。
 - 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存;全文读取则受 `maxFileBytes` 约束;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。
 - 每个文件系统提供者现在都提供开窗的原始读取。`fs-e2b` 为此付出传输被跳过前缀的代价,因为其 SDK 不能 seek;`fs-local` 能 seek。
 - 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。跟随者绑定到成功的 `stat.absolutePath`,因此同一文件的另一种拼法——经符号链接到达的工作区根——也使用该规范变更键。
@@ -142,7 +142,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
 
 ## Testing
 
-`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、工作区外读取及后端拒绝、带包含限制、截断、符号链接子项与 `not-directory` 的 `list`,以及由 `fs/observed` 驱动并按根过滤的 `changes` 流。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。`readAll` 与 `readRelated` 的 spec 覆盖全文读取上限、工作区外基准与关联路径,以及 Host 后端授权。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
+`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
 
 ## Deferred
 

+ 2 - 2
apps/web/tests/preview-boot.e2e.ts

@@ -398,8 +398,8 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
     await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 })
 
     expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0)
-    await page.getByText('PREVIEW.md', { exact: true }).waitFor()
-    await page.getByText('src/preview.ts', { exact: true }).waitFor()
+    await page.getByRole('button', { name: 'PREVIEW.md', exact: true }).waitFor()
+    await page.getByRole('button', { name: 'src/preview.ts', exact: true }).waitFor()
     await page.getByText('Update to-do list', { exact: true }).waitFor()
     await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor()
 

+ 14 - 5
apps/web/tests/sidebar-right.e2e.ts

@@ -265,9 +265,9 @@ describe('web e2e: shipped right Sidebar', () => {
       // real because the preview reads it through the workspace endpoint.
       //
       // It goes in the SESSION's cwd, not the scaffold's: the endpoint resolves
-      // relative paths against `sandboxPolicy.resolve({session}).workspaceRoot`,
-      // which is the session header's cwd. Writing anywhere else makes the read
-      // fail with workspace-file/not-found, which is the endpoint being right.
+      // relative paths against the header-derived workspace root. Writing
+      // anywhere else makes the read fail with workspace-file/not-found, which
+      // is the endpoint being right.
       writeFileSync(join(agent.session.header.cwd ?? scaffold.workspaceCwd, SAMPLE_NAME), SAMPLE_TEXT, 'utf8')
       agent.session.append('tool/call', {
         turn: 1,
@@ -662,18 +662,27 @@ describe('web e2e: shipped right Sidebar', () => {
     it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => {
       const files = (scaffold.ctx as unknown as {
         get(name: string): {
-          read(agent: unknown, path: string, range: object, signal: AbortSignal): Promise<{ text: string; eof: boolean }>
+          read(
+            scope: { sessionId: string; workspaceRoot: string },
+            path: string,
+            range: object,
+            signal: AbortSignal,
+          ): Promise<{ text: string; eof: boolean }>
         } | undefined
       }).get('workspaceFiles')
       if (files === undefined) throw new Error('host endpoint is not provided')
       const agent = scaffold.ctx.agents.list()[0]
       if (agent === undefined) throw new Error('no Agent to read for')
+      const scope = {
+        sessionId: agent.session.id,
+        workspaceRoot: agent.session.header.cwd ?? scaffold.workspaceCwd,
+      }
 
       // Raced against a timer so a hang reports a verdict instead of stalling
       // the suite: this case exists to tell host logic apart from the wire.
       // A page is the file's lines joined by `\n`, without the final terminator.
       const verdict = await Promise.race([
-        files.read(agent, SAMPLE_NAME, {}, new AbortController().signal)
+        files.read(scope, SAMPLE_NAME, {}, new AbortController().signal)
           .then(value => ({ kind: 'settled' as const, text: value.text, eof: value.eof }))
           .catch((error: unknown) => ({ kind: 'threw' as const, text: String(error), eof: false })),
         new Promise<{ kind: 'hung'; text: string; eof: boolean }>((resolve) => {

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 7b677ae08d84c9ad94f0ecd59820f52202b90e93
-config-catalog.zh.md: df4b107f28040962f38802a77f8b91c47ad2931d
+config-catalog.md: 1b7a52346a95a4933362f646f5873c49fc1bd71b
+config-catalog.zh.md: 8100201248be35987ac536a5dcf9d2b9c90f4a17

+ 2 - 2
docs/config-catalog.md

@@ -233,7 +233,7 @@ Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/set
 
 ## `@deepseek-ai/dsh-api-workspace-files`
 
-Requires: `fs` · `sandboxPolicy` · `typert`
+Requires: `fs` · `sandboxPolicy` · `sessions` · `typert`
 
 ```ts config-catalog
 /** Deployment caps on one page or one listing. */
@@ -255,7 +255,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts)
+Source: [`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts)
 
 <a id="deepseek-aidsh-attachment-local"></a>
 

+ 2 - 2
docs/config-catalog.zh.md

@@ -235,7 +235,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-api-workspace-files`
 
-Requires: `fs` · `sandboxPolicy` · `typert`
+Requires: `fs` · `sandboxPolicy` · `sessions` · `typert`
 
 ```ts config-catalog
 /** Deployment caps on one page or one listing. */
@@ -257,7 +257,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts)
+来源:[`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts)
 
 <a id="deepseek-aidsh-attachment-local"></a>
 

+ 2 - 2
docs/subsystems/workspace.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
-workspace.md: a7c4b8843f0a0b2d2fb251157f7928a40df1e405
-workspace.zh.md: 3474cbf147848b04a4ec51147983a343bcc1e91d
+workspace.md: b8b531497b33ddc9e8897f737fb507ac5f409cad
+workspace.zh.md: 309bff841c36b7cd344c49a0c4e750c40d38a43e

+ 18 - 20
docs/subsystems/workspace.md

@@ -251,76 +251,74 @@ Host Remote file reads and workspace directory observations over the composed fi
 ```ts cordis-catalog
 /**
  * Read one page of lines from a UTF-8 file readable by the filesystem backend.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the line window; omitted fields take the page defaults.
  * @param signal - caller cancellation.
  * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
  */
-@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
+@Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise<WorkspaceFileText>
 
 /**
  * Read one byte window of a regular file readable by the filesystem backend: raw
  * bytes, no text decoding and no binary rejection.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the byte window; omitted fields take the window defaults.
  * @param signal - caller cancellation.
  * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
  */
-@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
 
 /**
  * Read a complete regular file as bytes, subject to the configured full-file cap.
- * @param agent - target Agent whose workspace resolves relative paths.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute or workspace-relative file path.
  * @param signal - caller cancellation.
  * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
  */
-@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 
 /**
  * Read a complete file relative to another file's directory, including outside the workspace.
- * @param agent - Agent whose workspace resolves the base file's relative path.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - base file, absolute or workspace-relative.
  * @param relativePath - relative filesystem path, not a URL or absolute path.
  * @param signal - caller cancellation.
  * @returns the complete related file using the ordinary file-size and access checks.
  */
-@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
 
 /**
  * Report one regular file's identity, version, and size without its content.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param signal - caller cancellation.
  * @returns the file's absolute path, current version, and byte size.
  */
-@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
+@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
 
 /**
- * List the direct children of one directory inside the Agent's workspace.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * List the direct children of one directory inside the Session's workspace.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - workspace path, absolute or relative to the workspace root.
  * @param signal - caller cancellation.
  * @returns the directory's children in the backend's stable name order, bounded by the entry cap.
  */
-@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
+@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
 
 /**
- * Stream every `fs/observed` observation of a file inside the Agent's
- * workspace. Only Agent filesystem operations report here; the OS is not
- * watched.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * Stream every `fs/observed` observation of a file inside the Session's
+ * workspace. Only instrumented filesystem operations report here; the OS is
+ * not watched.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param signal - generation cancellation.
  * @returns `ready` once the Host observation queue is active and the workspace
  *   root is resolved, then queued and live observations in emission order.
  */
-@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
+@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
 ```
 
-Types: [Agent](core.md)
-
 Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts)
 
 <a id="ctxworkspaceregistry--workspaceregistry"></a>

+ 18 - 20
docs/subsystems/workspace.zh.md

@@ -251,76 +251,74 @@ Host Remote file reads and workspace directory observations over the composed fi
 ```ts cordis-catalog
 /**
  * Read one page of lines from a UTF-8 file readable by the filesystem backend.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the line window; omitted fields take the page defaults.
  * @param signal - caller cancellation.
  * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
  */
-@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
+@Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise<WorkspaceFileText>
 
 /**
  * Read one byte window of a regular file readable by the filesystem backend: raw
  * bytes, no text decoding and no binary rejection.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the byte window; omitted fields take the window defaults.
  * @param signal - caller cancellation.
  * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
  */
-@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
 
 /**
  * Read a complete regular file as bytes, subject to the configured full-file cap.
- * @param agent - target Agent whose workspace resolves relative paths.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute or workspace-relative file path.
  * @param signal - caller cancellation.
  * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
  */
-@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 
 /**
  * Read a complete file relative to another file's directory, including outside the workspace.
- * @param agent - Agent whose workspace resolves the base file's relative path.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - base file, absolute or workspace-relative.
  * @param relativePath - relative filesystem path, not a URL or absolute path.
  * @param signal - caller cancellation.
  * @returns the complete related file using the ordinary file-size and access checks.
  */
-@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
 
 /**
  * Report one regular file's identity, version, and size without its content.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param signal - caller cancellation.
  * @returns the file's absolute path, current version, and byte size.
  */
-@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
+@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
 
 /**
- * List the direct children of one directory inside the Agent's workspace.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * List the direct children of one directory inside the Session's workspace.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - workspace path, absolute or relative to the workspace root.
  * @param signal - caller cancellation.
  * @returns the directory's children in the backend's stable name order, bounded by the entry cap.
  */
-@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
+@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
 
 /**
- * Stream every `fs/observed` observation of a file inside the Agent's
- * workspace. Only Agent filesystem operations report here; the OS is not
- * watched.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * Stream every `fs/observed` observation of a file inside the Session's
+ * workspace. Only instrumented filesystem operations report here; the OS is
+ * not watched.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param signal - generation cancellation.
  * @returns `ready` once the Host observation queue is active and the workspace
  *   root is resolved, then queued and live observations in emission order.
  */
-@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
+@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
 ```
 
-Types: [Agent](core.zh.md)
-
 Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts)
 
 <a id="ctxworkspaceregistry--workspaceregistry"></a>

+ 2 - 2
packages/api/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/README.md
-README.md: 20455e6b5622ffd6e826b1d4b427838f96f6610f
-README.zh.md: 0294b1823e6a09cae4591b4c6081a546d8d60494
+README.md: 5bc878fa53b9ad0c3795c13e9442274b98820190
+README.zh.md: 814113f405b3d5c40f74b529bff57d0aa2a9c77f

+ 1 - 1
packages/api/README.md

@@ -31,7 +31,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust
 | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` |
 | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` |
 | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` |
-| [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the agent-write `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
+| [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
 
 Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads.
 

+ 1 - 1
packages/api/README.zh.md

@@ -31,7 +31,7 @@ kind: "package-group"
 | [`session-controller/`](session-controller/README.zh.md) | 拥有 Session 命令、历史 stream、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` |
 | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` |
 | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` |
-| [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与 agent 写入的 `changes` 流——以及其上的 Client `file` 资源提供者。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
+| [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供者。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
 
 Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。
 

+ 2 - 2
packages/api/workspace-files/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md
-README.md: e49f5bef7291233eb688bc9aeab44e56efb3d7fa
-README.zh.md: 1d3808969ac377408801a7516978bbebd6860b62
+README.md: f0f9cb1532fa65954415e10a5e248b775cdff83a
+README.zh.md: c33d0632fde318c330da4102211b69ad2be048f7

+ 7 - 7
packages/api/workspace-files/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Workspace file service for the web GUI: bounded file reads through the composed filesystem, plus directory listing and Agent-write observation inside the Session workspace root."
+description: "Workspace file service for the web GUI: bounded file reads through the composed filesystem, plus directory listing and instrumented filesystem observation inside the Session workspace root."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. File reads may target paths outside the workspace; directory listing and Agent-write change observation remain workspace-scoped. The service exposes no mutation operation.
+Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. File reads may target paths outside the workspace; directory listing and instrumented filesystem observations remain workspace-scoped. The service exposes no mutation operation.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ Use this package to preview files readable through a Session's filesystem from t
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(agent, path, range, signal)`, `stat(agent, path, signal)`, `readBytes(agent, path, range, signal)`, `list(agent, path, signal)`, or `changes(agent, signal)` and never names a root itself.
+Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, range, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`.
 
 | Method | Returns | Purpose |
 |---|---|---|
@@ -35,11 +35,11 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway;
 | `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated |
 | `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host |
 | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory |
-| `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then Agent observations inside the workspace root |
+| `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then filesystem observations inside the workspace root |
 
 ### Addressing and paths
 
-`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` likewise reports only Agent observations inside the workspace root.
+`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` likewise reports only instrumented filesystem observations inside the workspace root.
 
 ### Pages
 
@@ -92,7 +92,7 @@ One supervised `changes` stream serves every followed file in a Session. Followe
 
 ### Design concept
 
-Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports.
+Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. A Typert lookup derives `WorkspaceFileScope` from a live Session header or the persistence service's header-only `stat`, so cold subagent Sessions need neither Agent activation nor event-body reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports.
 
 ### Source map
 
@@ -136,7 +136,7 @@ None; this package neither assembles nor sends a provider request.
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **Agent writes only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame.
+- **Instrumented operations only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame.
 - **Directory scope only** — `list` and `changes` stay inside the Session workspace even though file preview reads may use any path readable by the filesystem backend.
 - **No total line count** — a page reports `eof`, not how many lines follow; a consumer that needs the total pages to the end or estimates from `bytes`.
 - **One giant line has no page** — a single line above `maxBytes` fails `too-large` at every window that includes it, because pages are cut by lines, not bytes.

+ 7 - 7
packages/api/workspace-files/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "面向 Web GUI 的工作区文件服务:通过组合文件系统进行有界文件读取,并在 Session 工作区根内列举目录和观察 Agent 写入。"
+description: "面向 Web GUI 的工作区文件服务:通过组合文件系统进行有界文件读取,并在 Session 工作区根内列举目录和观察已埋点的文件系统操作。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。文件读取可以指向工作区外路径;目录列举与 Agent 写入变更观察仍限定于工作区。本服务不提供修改操作。
+使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。文件读取可以指向工作区外路径;目录列举与已埋点的文件系统观察仍限定于工作区。本服务不提供修改操作。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-把本包与 `dsh-fs`、`dsh-sandbox-policy` 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(agent, path, range, signal)`、`stat(agent, path, signal)`、`list(agent, path, signal)` 或 `changes(agent, signal)`,从不自己指定根。
+把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, range, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。
 
 | 方法 | 返回 | 用途 |
 |---|---|---|
@@ -35,11 +35,11 @@ kind: "package-reference"
 | `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 |
 | `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 |
 | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 |
-| `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的 Agent 观察 |
+| `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的文件系统观察 |
 
 ### 寻址与路径
 
-`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 同样只报告工作区根内的 Agent 观察。
+`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 同样只报告工作区根内已埋点的文件系统观察。
 
 ### 分页
 
@@ -92,7 +92,7 @@ kind: "package-reference"
 
 ### 设计概念
 
-经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。
+经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。Typert lookup 从 live Session header 或持久层的 header-only `stat` 导出 `WorkspaceFileScope`,所以 cold subagent Session 不需要激活 Agent 或读取事件正文。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。
 
 ### 源码地图
 
@@ -136,7 +136,7 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **仅覆盖 Agent 写入**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。
+- **仅覆盖已埋点操作**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。
 - **仅目录受限**——尽管文件预览可以读取文件系统后端允许的任意路径,`list` 与 `changes` 仍限定在 Session 工作区内。
 - **没有总行数**——页只报告 `eof`,不报告后面还有多少行;需要总数的消费方要翻到末尾或按 `bytes` 估算。
 - **超长单行没有页**——超过 `maxBytes` 的单行在包含它的每个窗口都以 `too-large` 失败,因为页按行而非按字节切。

+ 1 - 1
packages/api/workspace-files/package.json

@@ -62,13 +62,13 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-client-resources": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-fs": "workspace:^",
     "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
   },
   "files": [

+ 2 - 2
packages/api/workspace-files/src/changes.ts

@@ -1,8 +1,8 @@
 /**
  * Producer of the `changes` stream: every `fs/observed` emission whose target
  * lies inside a generation's workspace root becomes one frame of that
- * generation. Observations are emitted by tools after their own filesystem
- * operation, so the feed covers Agent writes only; the OS is not watched.
+ * generation. Instrumented filesystem operations emit these observations; the
+ * operating system is not watched.
  * Each generation acknowledges its observation queue and resolved workspace
  * root with `ready` before emitting any queued or live changes.
  */

+ 96 - 50
packages/api/workspace-files/src/index.ts

@@ -1,12 +1,14 @@
 /**
  * Workspace file service: read-only file previews, workspace directory
- * listings, and the agent-write change feed, exposed as `workspaceFiles`.
+ * listings, and the filesystem-observation change feed, exposed as
+ * `workspaceFiles`.
  *
  * File reads follow the composed filesystem's read access, including paths
- * outside the workspace. The Session's policy supplies the base for relative
- * paths, not a read-containment restriction. Directory listings and change
- * observations remain workspace-scoped. File-kind checks and configured read
- * caps apply to every preview; this service exposes no mutations.
+ * outside the workspace. The selected Session header supplies the base for
+ * relative paths, with the sandbox policy root as its no-cwd fallback, not a
+ * read-containment restriction. Directory listings and change observations
+ * remain workspace-scoped. File-kind checks and configured read caps apply to
+ * every preview; this service exposes no mutations.
  *
  * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
  * goes, so the file is read only up to the first character past the page and
@@ -20,11 +22,13 @@
 import { posix, win32 } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import type { Agent } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-fs'
 import type { FsDirEntry, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
 import type {} from '@deepseek-ai/dsh-sandbox-policy'
-import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-persistence'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { Remote, RemoteError, TypertRemoteService, type TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
 import { WorkspaceChangeFeed } from './changes.ts'
 import type {
   WorkspaceByteRange,
@@ -46,6 +50,21 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
+/** Header-derived file resolution context for one Session identity. */
+export interface WorkspaceFileScope {
+  /** Session identity received on the wire. */
+  readonly sessionId: SessionId
+  /** Session workspace root, or the deployment fallback when its header has no cwd. */
+  readonly workspaceRoot: string
+}
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface TypertLookupMap {
+    /** Resolve a Session id to its workspace root without loading its event body or activating an Agent. */
+    workspaceFileScope: TypertLookup<WorkspaceFileScope, SessionId>
+  }
+}
+
 /** Deployment caps on one page or one listing. */
 export interface Config {
   /**
@@ -161,7 +180,7 @@ function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry {
 
 /** Host Remote file reads and workspace directory observations over the composed filesystem. */
 export class WorkspaceFiles extends TypertRemoteService {
-  static inject = ['fs', 'sandboxPolicy', 'typert']
+  static inject = ['fs', 'sandboxPolicy', 'sessions', 'typert']
 
   static Config: z<Config> = z.object({
     maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024),
@@ -179,20 +198,45 @@ export class WorkspaceFiles extends TypertRemoteService {
   constructor(ctx: Context, private readonly config: Config) {
     super(ctx, 'workspaceFiles')
     this.feed = new WorkspaceChangeFeed(ctx)
+    ctx.inject(['sessions', 'typert'], (scope) => {
+      scope.typert.lookups.register('workspaceFileScope', {
+        parameter: 'workspaceFileScope',
+        wire: 'workspaceFileScopeId',
+        hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope',
+        wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
+        resolve: async (sessionId) => {
+          const live = scope.sessions.get(sessionId)?.header
+          const stored = live === undefined
+            ? await scope.get('sessionPersistence')?.stat(sessionId)
+            : undefined
+          const header = live ?? stored?.header
+          if (header === undefined) return undefined
+          return {
+            sessionId,
+            workspaceRoot: header.cwd ?? scope.sandboxPolicy.workspaceRoot,
+          }
+        },
+      })
+    })
   }
 
   /**
    * Read one page of lines from a UTF-8 file readable by the filesystem backend.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
    * @param range - the line window; omitted fields take the page defaults.
    * @param signal - caller cancellation.
    * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
    */
   @Remote
-  async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText> {
+  async read(
+    workspaceFileScope: WorkspaceFileScope,
+    path: string,
+    range: WorkspaceFileRange,
+    signal: AbortSignal,
+  ): Promise<WorkspaceFileText> {
     const { offset, limit } = this.resolvePage(range)
-    const { target, info } = await this.locateFile(agent, path, signal)
+    const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
     const page = await this.cutPage(target, offset, limit, signal, path)
     if (page.text.includes(NUL)) {
       throw new RemoteError('workspace-file/not-text', `"${path}" contains NUL bytes`, { path })
@@ -203,16 +247,21 @@ export class WorkspaceFiles extends TypertRemoteService {
   /**
    * Read one byte window of a regular file readable by the filesystem backend: raw
    * bytes, no text decoding and no binary rejection.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
    * @param range - the byte window; omitted fields take the window defaults.
    * @param signal - caller cancellation.
    * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
    */
   @Remote
-  async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes> {
+  async readBytes(
+    workspaceFileScope: WorkspaceFileScope,
+    path: string,
+    range: WorkspaceByteRange,
+    signal: AbortSignal,
+  ): Promise<WorkspaceFileBytes> {
     const { offset, length } = this.resolveWindow(range, path)
-    const { target, info } = await this.locateFile(agent, path, signal)
+    const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
     const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal)
     const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size
     return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof }
@@ -220,14 +269,14 @@ export class WorkspaceFiles extends TypertRemoteService {
 
   /**
    * Read a complete regular file as bytes, subject to the configured full-file cap.
-   * @param agent - target Agent whose workspace resolves relative paths.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - absolute or workspace-relative file path.
    * @param signal - caller cancellation.
    * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
    */
   @Remote
-  async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
-    const { target, info } = await this.locateFile(agent, path, signal)
+  async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
+    const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
     const limit = this.config.maxFileBytes
     if (info.size !== undefined && info.size > limit) {
       throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
@@ -241,47 +290,52 @@ export class WorkspaceFiles extends TypertRemoteService {
 
   /**
    * Read a complete file relative to another file's directory, including outside the workspace.
-   * @param agent - Agent whose workspace resolves the base file's relative path.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - base file, absolute or workspace-relative.
    * @param relativePath - relative filesystem path, not a URL or absolute path.
    * @param signal - caller cancellation.
    * @returns the complete related file using the ordinary file-size and access checks.
    */
   @Remote
-  async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
+  async readRelated(
+    workspaceFileScope: WorkspaceFileScope,
+    path: string,
+    relativePath: string,
+    signal: AbortSignal,
+  ): Promise<WorkspaceFileBytes> {
     const relative = relativePath.replace(/\\/g, '/')
     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', {})
     }
-    const { target } = await this.locateFile(agent, path, signal)
+    const { target } = await this.locateFile(workspaceFileScope, path, signal)
     const absolute = this.ctx.fs.processPath(target)
     const paths = absolute.startsWith('/') ? posix : win32
-    return this.readAll(agent, paths.resolve(paths.dirname(absolute), relative), signal)
+    return this.readAll(workspaceFileScope, paths.resolve(paths.dirname(absolute), relative), signal)
   }
 
   /**
    * Report one regular file's identity, version, and size without its content.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
    * @param signal - caller cancellation.
    * @returns the file's absolute path, current version, and byte size.
    */
   @Remote
-  async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat> {
-    const { target, info } = await this.locateFile(agent, path, signal)
+  async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat> {
+    const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
     return this.statOf(target, info)
   }
 
   /**
-   * List the direct children of one directory inside the Agent's workspace.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * List the direct children of one directory inside the Session's workspace.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - workspace path, absolute or relative to the workspace root.
    * @param signal - caller cancellation.
    * @returns the directory's children in the backend's stable name order, bounded by the entry cap.
    */
   @Remote
-  async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing> {
-    const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal)
+  async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing> {
+    const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
     if (entry.type !== 'directory') {
       throw new RemoteError(
         'workspace-file/not-directory',
@@ -299,17 +353,17 @@ export class WorkspaceFiles extends TypertRemoteService {
   }
 
   /**
-   * Stream every `fs/observed` observation of a file inside the Agent's
-   * workspace. Only Agent filesystem operations report here; the OS is not
-   * watched.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * Stream every `fs/observed` observation of a file inside the Session's
+   * workspace. Only instrumented filesystem operations report here; the OS is
+   * not watched.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param signal - generation cancellation.
    * @returns `ready` once the Host observation queue is active and the workspace
    *   root is resolved, then queued and live observations in emission order.
    */
   @Remote({ mode: 'stream' })
-  changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
-    return this.feed.follow(this.workspaceRootOf(agent), signal)
+  changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
+    return this.feed.follow(workspaceFileScope.workspaceRoot, signal)
   }
 
   /** Apply the page defaults and caps here, so the request never carries them implicitly. */
@@ -338,29 +392,17 @@ export class WorkspaceFiles extends TypertRemoteService {
     }
     return { offset, length }
   }
-
-
-  /**
-   * The workspace root comes from the policy, not from the backend's own cwd
-   * default: the `minimal` preset shadows the host provider with a bare
-   * `fs-local` whose cwd differs, and resolving explicitly makes the answer
-   * the same whichever instance answers.
-   */
-  private workspaceRootOf(agent: Agent): string {
-    return this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot
-  }
-
   /**
    * Inspect the requested path itself before resolution follows its final
    * component. Directory containment is checked separately by `list`.
    */
   private async inspect(
-    agent: Agent,
+    workspaceFileScope: WorkspaceFileScope,
     path: string,
     signal: AbortSignal,
   ): Promise<{ root: FsTarget; workspaceRoot: string; entry: FsPathInfo }> {
     if (path.length === 0) throw new RemoteError('gateway/bad-request', 'path is required', {})
-    const workspaceRoot = this.workspaceRootOf(agent)
+    const { workspaceRoot } = workspaceFileScope
     const root = await this.ctx.fs.resolve(workspaceRoot, { signal })
     // Gate on the path itself before anything follows it.
     const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal)
@@ -384,8 +426,12 @@ export class WorkspaceFiles extends TypertRemoteService {
    * and size. The stat re-checks what `lstat` saw: the file may have gone or
    * changed kind in between.
    */
-  private async locateFile(agent: Agent, path: string, signal: AbortSignal): Promise<{ target: FsTarget; info: FsInfo }> {
-    const { workspaceRoot, entry } = await this.inspect(agent, path, signal)
+  private async locateFile(
+    workspaceFileScope: WorkspaceFileScope,
+    path: string,
+    signal: AbortSignal,
+  ): Promise<{ target: FsTarget; info: FsInfo }> {
+    const { workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
     if (entry.type !== 'file') {
       throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type })
     }

+ 3 - 3
packages/api/workspace-files/src/types.ts

@@ -115,9 +115,9 @@ export interface WorkspaceDirectoryListing {
 }
 
 /**
- * One observation of a workspace file made by an Agent's own filesystem
- * operation. Frames report observations, not deltas: a consumer already holding
- * `version` learns nothing new from the frame and can ignore it.
+ * One observation of a workspace file made by an instrumented filesystem
+ * operation. Frames report observations, not deltas: a consumer already
+ * holding `version` learns nothing new from the frame and can ignore it.
  */
 export type WorkspaceFileChange =
   | {

+ 3 - 3
packages/api/workspace-files/tests/changes.spec.ts

@@ -6,7 +6,7 @@ import type { FsObservation } from '@deepseek-ai/dsh-fs'
 import { FsVersion } from '@deepseek-ai/dsh-fs'
 import { WorkspaceFiles } from '../src/index.ts'
 import type { WorkspaceFileWatchFrame } from '../src/types.ts'
-import { agent, openWorkspace, type Harness } from './harness.ts'
+import { openWorkspace, type Harness } from './harness.ts'
 
 let harness: Harness
 const closeStreams: Array<() => Promise<unknown>> = []
@@ -38,7 +38,7 @@ function open(
   service: WorkspaceFiles,
   controller = new AbortController(),
 ): { next(): Promise<IteratorResult<WorkspaceFileWatchFrame>>; controller: AbortController } {
-  const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]()
+  const iterator = service.changes(harness.scope, controller.signal)[Symbol.asyncIterator]()
   closeStreams.push(async () => {
     controller.abort()
     await iterator.return?.()
@@ -245,7 +245,7 @@ describe('workspaceFiles.changes — ending', () => {
   it('stops delivering to a generation the consumer returned from', async () => {
     const service = harness.endpoint()
     const controller = new AbortController()
-    const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]()
+    const iterator = service.changes(harness.scope, controller.signal)[Symbol.asyncIterator]()
     closeStreams.push(async () => {
       controller.abort()
       await iterator.return?.()

+ 12 - 7
packages/api/workspace-files/tests/harness.ts

@@ -12,13 +12,15 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
 import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
 import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
-import { WorkspaceFiles, type Config } from '../src/index.ts'
+import { WorkspaceFiles, type Config, type WorkspaceFileScope } from '../src/index.ts'
 
-/** The Agent shape the service reads: only its session reaches the policy. */
-export const agent = { id: 'a-test', session: { id: 's-test' } } as unknown as Agent
+/** Build the header-derived scope that direct service calls receive after Typert lookup. */
+function fileScope(workspaceRoot: string): WorkspaceFileScope {
+  return { sessionId: SessionId('s-test'), workspaceRoot }
+}
 
 export const signal = (): AbortSignal => new AbortController().signal
 
@@ -27,6 +29,7 @@ export interface Harness {
   readonly workspace: string
   readonly outside: string
   readonly ctx: Context
+  readonly scope: WorkspaceFileScope
   /**
    * The service under test, at the given caps. One per test: the service key is
    * global to the Context, so a second call with caps is a defect in the test.
@@ -49,14 +52,16 @@ export async function openWorkspace(prefix: string): Promise<Harness> {
   await mkdir(outside, { recursive: true })
   const ctx = new Context()
   const fiber = await ctx.plugin(LocalFileSystem, { cwd: workspace })
-  // The policy is the service's only source for the workspace root, so the
-  // fake supplies exactly that and nothing else.
-  ctx.provide('sandboxPolicy', { resolve: () => ({ mode: 'workspace-write', workspaceRoot: workspace }) } as never)
+  ctx.provide('sandboxPolicy', {
+    workspaceRoot: workspace,
+    resolve: () => ({ mode: 'workspace-write', workspaceRoot: workspace }),
+  } as never)
   let service: WorkspaceFiles | undefined
   return {
     workspace,
     outside,
     ctx,
+    scope: fileScope(workspace),
     endpoint: (caps) => {
       if (service !== undefined) {
         if (caps !== undefined) throw new Error('the harness serves one WorkspaceFiles per test; hoist the endpoint')

+ 13 - 13
packages/api/workspace-files/tests/list.spec.ts

@@ -2,7 +2,7 @@
 import { afterEach, beforeEach, describe, expect, it } from 'vitest'
 import { mkdir, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 let workspace: string
@@ -25,7 +25,7 @@ describe('workspaceFiles.list — the happy path', () => {
     await mkdir(join(workspace, 'src'))
     await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8')
     await writeFile(join(workspace, '.hidden'), '', 'utf8')
-    const listing = await endpoint().list(agent, '.', signal())
+    const listing = await endpoint().list(harness.scope, '.', signal())
     expect(listing.path).toBe('')
     expect(listing.truncated).toBe(false)
     expect(listing.entries).toEqual([
@@ -36,7 +36,7 @@ describe('workspaceFiles.list — the happy path', () => {
   })
 
   it('accepts the absolute workspace root and reports the same empty path', async () => {
-    const listing = await endpoint().list(agent, workspace, signal())
+    const listing = await endpoint().list(harness.scope, workspace, signal())
     expect(listing.path).toBe('')
     expect(listing.entries).toEqual([])
   })
@@ -44,7 +44,7 @@ describe('workspaceFiles.list — the happy path', () => {
   it('reports a nested directory as its `/`-joined path relative to the root, decoded', async () => {
     await mkdir(join(workspace, 'src', 'my dir', '子目录'), { recursive: true })
     await writeFile(join(workspace, 'src', 'my dir', '子目录', 'a.ts'), '', 'utf8')
-    const listing = await endpoint().list(agent, 'src/my dir/子目录', signal())
+    const listing = await endpoint().list(harness.scope, 'src/my dir/子目录', signal())
     expect(listing.path).toBe('src/my dir/子目录')
     expect(listing.entries.map(entry => entry.name)).toEqual(['a.ts'])
   })
@@ -55,7 +55,7 @@ describe('workspaceFiles.list — the happy path', () => {
     await symlink(join(workspace, 'real.txt'), join(workspace, 'to-file'))
     await symlink(join(workspace, 'dir'), join(workspace, 'to-dir'))
     await symlink(join(workspace, 'missing'), join(workspace, 'dangling'))
-    const listing = await endpoint().list(agent, '.', signal())
+    const listing = await endpoint().list(harness.scope, '.', signal())
     expect(listing.entries).toEqual([
       { name: 'dangling', type: 'other' },
       { name: 'dir', type: 'directory' },
@@ -69,14 +69,14 @@ describe('workspaceFiles.list — the happy path', () => {
 describe('workspaceFiles.list — the entry cap', () => {
   it('cuts at the cap in name order and says so', async () => {
     for (const name of ['a', 'b', 'c', 'd', 'e']) await writeFile(join(workspace, name), '', 'utf8')
-    const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal())
+    const listing = await endpoint({ maxEntries: 2 }).list(harness.scope, '.', signal())
     expect(listing.entries.map(entry => entry.name)).toEqual(['a', 'b'])
     expect(listing.truncated).toBe(true)
   })
 
   it('does not report a cut at exactly the cap', async () => {
     for (const name of ['a', 'b']) await writeFile(join(workspace, name), '', 'utf8')
-    const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal())
+    const listing = await endpoint({ maxEntries: 2 }).list(harness.scope, '.', signal())
     expect(listing.entries).toHaveLength(2)
     expect(listing.truncated).toBe(false)
   })
@@ -84,36 +84,36 @@ describe('workspaceFiles.list — the entry cap', () => {
 
 describe('workspaceFiles.list — gates', () => {
   it('rejects an absolute directory outside the workspace', async () => {
-    const failure = await failureOf(endpoint().list(agent, outside, signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, outside, signal()))
     expect(failure.code).toBe('workspace-file/outside-workspace')
   })
 
   it('rejects a traversal that climbs out of the workspace', async () => {
-    const failure = await failureOf(endpoint().list(agent, '..', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, '..', signal()))
     expect(failure.code).toBe('workspace-file/outside-workspace')
   })
 
   it('rejects a symlinked directory before following it, wherever it points', async () => {
     await symlink(outside, join(workspace, 'escape'))
-    const failure = await failureOf(endpoint().list(agent, 'escape', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, 'escape', signal()))
     expect(failure.code).toBe('workspace-file/not-directory')
     expect(failure.details).toMatchObject({ kind: 'symlink' })
   })
 
   it('rejects a file, which has no children to list', async () => {
     await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8')
-    const failure = await failureOf(endpoint().list(agent, 'notes.txt', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, 'notes.txt', signal()))
     expect(failure.code).toBe('workspace-file/not-directory')
     expect(failure.details).toMatchObject({ path: 'notes.txt', kind: 'file' })
   })
 
   it('reports a missing path as not found', async () => {
-    const failure = await failureOf(endpoint().list(agent, 'nope', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, 'nope', signal()))
     expect(failure.code).toBe('workspace-file/not-found')
   })
 
   it('refuses an empty path as a bad request', async () => {
-    const failure = await failureOf(endpoint().list(agent, '', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, '', signal()))
     expect(failure.code).toBe('gateway/bad-request')
   })
 })

+ 19 - 19
packages/api/workspace-files/tests/read-all.spec.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdir, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { FsVersion } from '@deepseek-ai/dsh-fs'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 
@@ -14,21 +14,21 @@ describe('workspaceFiles.readAll', () => {
   it('reads the complete bytes independently of the window cap', async () => {
     const bytes = Buffer.from([0, 255, 1, 2])
     await writeFile(join(harness.workspace, 'file.bin'), bytes)
-    const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(agent, 'file.bin', signal())
+    const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(harness.scope, 'file.bin', signal())
     expect(Buffer.from(result.data, 'base64')).toEqual(bytes)
     expect(result).toMatchObject({ offset: 0, eof: true, bytes: 4 })
   })
 
   it('returns an empty complete file', async () => {
     await writeFile(join(harness.workspace, 'empty'), '')
-    expect(await harness.endpoint().readAll(agent, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
+    expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
   })
 
   it.each(['workspace', 'outside'] as const)('rejects a known oversized %s file before reading bytes', async (location) => {
     const path = join(harness[location], 'large')
     await writeFile(path, 'abcde')
     const read = vi.spyOn(harness.ctx.fs, 'readByteRange')
-    expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, path, signal())))
+    expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal())))
       .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } })
     expect(read).not.toHaveBeenCalled()
   })
@@ -36,7 +36,7 @@ describe('workspaceFiles.readAll', () => {
   it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => {
     await writeFile(join(harness.workspace, 'growing'), 'abcde')
     vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v'), ...size === undefined ? {} : { size } })
-    expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, 'growing', signal()))).code)
+    expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'growing', signal()))).code)
       .toBe('workspace-file/too-large')
   })
 
@@ -44,9 +44,9 @@ describe('workspaceFiles.readAll', () => {
     await mkdir(join(harness.workspace, 'directory'))
     await writeFile(join(harness.outside, 'outside'), 'outside')
     const files = harness.endpoint()
-    expect((await failureOf(files.readAll(agent, 'missing', signal()))).code).toBe('workspace-file/not-found')
-    expect((await failureOf(files.readAll(agent, 'directory', signal()))).code).toBe('workspace-file/not-regular-file')
-    const outside = await files.readAll(agent, join(harness.outside, 'outside'), signal())
+    expect((await failureOf(files.readAll(harness.scope, 'missing', signal()))).code).toBe('workspace-file/not-found')
+    expect((await failureOf(files.readAll(harness.scope, 'directory', signal()))).code).toBe('workspace-file/not-regular-file')
+    const outside = await files.readAll(harness.scope, join(harness.outside, 'outside'), signal())
     expect(Buffer.from(outside.data, 'base64').toString()).toBe('outside')
   })
 })
@@ -58,25 +58,25 @@ describe('workspaceFiles.readRelated', () => {
     await writeFile(join(harness.workspace, 'nested/near.txt'), 'near')
     await writeFile(join(harness.workspace, 'root.txt'), 'root')
     const files = harness.endpoint()
-    const near = await files.readRelated(agent, 'nested/base.txt', './near.txt', signal())
-    const root = await files.readRelated(agent, 'nested/base.txt', '../root.txt', signal())
+    const near = await files.readRelated(harness.scope, 'nested/base.txt', './near.txt', signal())
+    const root = await files.readRelated(harness.scope, 'nested/base.txt', '../root.txt', signal())
     expect(Buffer.from(near.data, 'base64').toString()).toBe('near')
     expect(Buffer.from(root.data, 'base64').toString()).toBe('root')
-    const fromRoot = await files.readRelated(agent, 'root.txt', 'nested\\near.txt', signal())
+    const fromRoot = await files.readRelated(harness.scope, 'root.txt', 'nested\\near.txt', signal())
     expect(Buffer.from(fromRoot.data, 'base64').toString()).toBe('near')
   })
 
   it.each(['', '/outside', 'C:\\outside', '\\\\host\\share', 'https://example.test/a.js', 'bad\0path'])('rejects non-relative path %j', async (path) => {
-    expect((await failureOf(harness.endpoint().readRelated(agent, 'base', path, signal()))).code).toBe('gateway/bad-request')
+    expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', path, signal()))).code).toBe('gateway/bad-request')
   })
 
   it('resolves related files on either side of the workspace root', async () => {
     await writeFile(join(harness.workspace, 'base'), 'base')
     await writeFile(join(harness.outside, 'outside'), 'outside')
     const files = harness.endpoint()
-    expect((await failureOf(files.readRelated(agent, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found')
-    const fromOutside = await files.readRelated(agent, join(harness.outside, 'outside'), '../workspace/base', signal())
-    const toOutside = await files.readRelated(agent, 'base', '../outside/outside', signal())
+    expect((await failureOf(files.readRelated(harness.scope, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found')
+    const fromOutside = await files.readRelated(harness.scope, join(harness.outside, 'outside'), '../workspace/base', signal())
+    const toOutside = await files.readRelated(harness.scope, 'base', '../outside/outside', signal())
     expect(Buffer.from(fromOutside.data, 'base64').toString()).toBe('base')
     expect(Buffer.from(toOutside.data, 'base64').toString()).toBe('outside')
   })
@@ -86,7 +86,7 @@ describe('workspaceFiles.readRelated', () => {
     const base = join(harness.outside, 'space # assets', 'page.html')
     await writeFile(base, '<script src="./app.js"></script>')
     await writeFile(join(harness.outside, 'space # assets', 'app.js'), 'EXTERNAL_ASSET')
-    const result = await harness.endpoint().readRelated(agent, base, './app.js', signal())
+    const result = await harness.endpoint().readRelated(harness.scope, base, './app.js', signal())
     expect(Buffer.from(result.data, 'base64').toString()).toBe('EXTERNAL_ASSET')
   })
 
@@ -100,14 +100,14 @@ describe('workspaceFiles.readRelated', () => {
     vi.spyOn(harness.ctx.fs, 'processPath').mockReturnValue(base)
     const read = vi.spyOn(files, 'readAll').mockResolvedValue({ absolutePath: expected, version: 'v', offset: 0, data: '', eof: true })
     const caller = signal()
-    await files.readRelated(agent, 'base', './app.js', caller)
-    expect(read).toHaveBeenCalledWith(agent, expected, caller)
+    await files.readRelated(harness.scope, 'base', './app.js', caller)
+    expect(read).toHaveBeenCalledWith(harness.scope, expected, caller)
   })
 
   it('rejects a related symlink rather than following it', async () => {
     await writeFile(join(harness.workspace, 'base'), 'base')
     await writeFile(join(harness.workspace, 'target'), 'target')
     await symlink('target', join(harness.workspace, 'link'))
-    expect((await failureOf(harness.endpoint().readRelated(agent, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file')
+    expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file')
   })
 })

+ 21 - 21
packages/api/workspace-files/tests/read-bytes.spec.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdir, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { FsVersion } from '@deepseek-ai/dsh-fs'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 let workspace: string
@@ -27,7 +27,7 @@ const decode = (data: string): Buffer => Buffer.from(data, 'base64')
 describe('workspaceFiles.readBytes — the window', () => {
   it('returns the whole file as one window by default, with its absolute path, version, and size', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal())
+    const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal())
     expect(decode(result.data).equals(RAMP)).toBe(true)
     expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 })
     expect(result.absolutePath.endsWith('ramp.bin')).toBe(true)
@@ -36,14 +36,14 @@ describe('workspaceFiles.readBytes — the window', () => {
 
   it('cuts the requested window and reports that more follows', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 16, length: 8 }, signal())
+    const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 16, length: 8 }, signal())
     expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23])
     expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 })
   })
 
   it('reads a window of a file far above the byte cap', async () => {
     await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7))
-    const result = await endpoint({ maxBytes: 1024 }).readBytes(agent, 'huge.bin', { offset: 199_000, length: 1024 }, signal())
+    const result = await endpoint({ maxBytes: 1024 }).readBytes(harness.scope, 'huge.bin', { offset: 199_000, length: 1024 }, signal())
     expect(decode(result.data)).toHaveLength(1000)
     expect(result).toMatchObject({ eof: true, bytes: 200_000 })
   })
@@ -51,46 +51,46 @@ describe('workspaceFiles.readBytes — the window', () => {
   it('infers eof from a short window when the backend reports no size', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
     vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' })
-    const full = await endpoint().readBytes(agent, 'ramp.bin', { offset: 0, length: 256 }, signal())
+    const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 0, length: 256 }, signal())
     expect(full.eof).toBe(false)
-    const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 10 }, signal())
+    const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 10 }, signal())
     expect(short).toMatchObject({ eof: true })
     expect(short.bytes).toBeUndefined()
   })
 
   it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const exact = await endpoint().readBytes(agent, 'ramp.bin', { offset: 248, length: 8 }, signal())
+    const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 248, length: 8 }, signal())
     expect(exact.eof).toBe(true)
     expect(decode(exact.data)).toHaveLength(8)
-    const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 100 }, signal())
+    const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 100 }, signal())
     expect(short.eof).toBe(true)
     expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255])
   })
 
   it('returns an empty eof window for an offset at or past the end', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 300, length: 8 }, signal())
+    const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 300, length: 8 }, signal())
     expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 })
   })
 
   it('returns an empty eof window for an empty file', async () => {
     await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0))
-    const result = await endpoint().readBytes(agent, 'empty.bin', {}, signal())
+    const result = await endpoint().readBytes(harness.scope, 'empty.bin', {}, signal())
     expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
   })
 
   it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => {
     const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0])
     await writeFile(join(workspace, 'blob.bin'), raw)
-    const result = await endpoint().readBytes(agent, 'blob.bin', {}, signal())
+    const result = await endpoint().readBytes(harness.scope, 'blob.bin', {}, signal())
     expect(decode(result.data).equals(raw)).toBe(true)
   })
 
   it('names the version a stat of the same file reports', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const stat = await endpoint().stat(agent, 'ramp.bin', signal())
-    const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal())
+    const stat = await endpoint().stat(harness.scope, 'ramp.bin', signal())
+    const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal())
     expect(result.version).toBe(stat.version)
   })
 })
@@ -98,21 +98,21 @@ describe('workspaceFiles.readBytes — the window', () => {
 describe('workspaceFiles.readBytes — defaults and cap', () => {
   it('defaults the length to the configured byte cap', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64))
-    const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', {}, signal())
+    const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', {}, signal())
     expect(decode(result.data)).toHaveLength(64)
     expect(result.eof).toBe(true)
   })
 
   it('refuses a window longer than the cap as too-large rather than shortening it', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 65 }, signal()))
+    const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 65 }, signal()))
     expect(failure.code).toBe('workspace-file/too-large')
     expect(failure.details).toMatchObject({ limit: 64 })
   })
 
   it('accepts a window exactly at the cap', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64))
-    const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 64 }, signal())
+    const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 64 }, signal())
     expect(decode(result.data)).toHaveLength(64)
   })
 
@@ -124,7 +124,7 @@ describe('workspaceFiles.readBytes — defaults and cap', () => {
       { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 },
     ]
     for (const range of ranges) {
-      const failure = await failureOf(endpoint().readBytes(agent, 'ramp.bin', range, signal()))
+      const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', range, signal()))
       expect(failure.code).toBe('gateway/bad-request')
     }
   })
@@ -133,14 +133,14 @@ describe('workspaceFiles.readBytes — defaults and cap', () => {
 describe('workspaceFiles.readBytes — the gates it shares with read', () => {
   it('rejects a directory, a missing path, and an empty path', async () => {
     await mkdir(join(workspace, 'dir'))
-    expect((await failureOf(endpoint().readBytes(agent, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file')
-    expect((await failureOf(endpoint().readBytes(agent, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found')
-    expect((await failureOf(endpoint().readBytes(agent, '', {}, signal()))).code).toBe('gateway/bad-request')
+    expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file')
+    expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found')
+    expect((await failureOf(endpoint().readBytes(harness.scope, '', {}, signal()))).code).toBe('gateway/bad-request')
   })
 
   it('reads a bounded byte window outside the workspace', async () => {
     await writeFile(join(harness.outside, 'sample.bin'), RAMP)
-    const result = await endpoint().readBytes(agent, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal())
+    const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal())
     expect(decode(result.data)).toEqual(RAMP.subarray(2, 6))
     expect(result).toMatchObject({ offset: 2, eof: false })
   })

+ 34 - 34
packages/api/workspace-files/tests/read.spec.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { FsError } from '@deepseek-ai/dsh-fs'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 let workspace: string
@@ -39,7 +39,7 @@ async function lateNul(): Promise<void> {
 describe('workspaceFiles.read — the happy path', () => {
   it('returns the whole file as one page with its absolute path, version, and byte size', async () => {
     await writeFile(join(workspace, 'notes.txt'), 'hello\nworld\n', 'utf8')
-    const result = await endpoint().read(agent, 'notes.txt', {}, signal())
+    const result = await endpoint().read(harness.scope, 'notes.txt', {}, signal())
     expect(result.text).toBe('hello\nworld')
     expect(result.offset).toBe(1)
     expect(result.lines).toBe(2)
@@ -52,19 +52,19 @@ describe('workspaceFiles.read — the happy path', () => {
   it('reads a nested path relative to the workspace root, not to any backend cwd', async () => {
     await mkdir(join(workspace, 'src', 'deep'), { recursive: true })
     await writeFile(join(workspace, 'src', 'deep', 'a.ts'), 'export {}\n', 'utf8')
-    const result = await endpoint().read(agent, 'src/deep/a.ts', {}, signal())
+    const result = await endpoint().read(harness.scope, 'src/deep/a.ts', {}, signal())
     expect(result.text).toBe('export {}')
   })
 
   it('returns an empty page for an empty file', async () => {
     await writeFile(join(workspace, 'empty.txt'), '', 'utf8')
-    const result = await endpoint().read(agent, 'empty.txt', {}, signal())
+    const result = await endpoint().read(harness.scope, 'empty.txt', {}, signal())
     expect(result).toMatchObject({ text: '', lines: 0, eof: true, bytes: 0 })
   })
 
   it('accepts multi-byte UTF-8 and counts the file bytes, not its characters', async () => {
     await writeFile(join(workspace, 'zh.txt'), '侧栏', 'utf8')
-    const result = await endpoint().read(agent, 'zh.txt', {}, signal())
+    const result = await endpoint().read(harness.scope, 'zh.txt', {}, signal())
     expect(result.text).toBe('侧栏')
     expect(result.bytes).toBe(6)
   })
@@ -73,16 +73,16 @@ describe('workspaceFiles.read — the happy path', () => {
 describe('workspaceFiles.read — the line window', () => {
   it('cuts the requested lines and reports that more follow', async () => {
     await twentyLines()
-    const result = await endpoint().read(agent, 'long.txt', { offset: 6, limit: 3 }, signal())
+    const result = await endpoint().read(harness.scope, 'long.txt', { offset: 6, limit: 3 }, signal())
     expect(result).toMatchObject({ offset: 6, text: 'line 6\nline 7\nline 8', lines: 3, eof: false })
   })
 
   it('reports eof on the page that holds the last line, whether or not the limit is reached', async () => {
     await twentyLines()
     const service = endpoint()
-    const exact = await service.read(agent, 'long.txt', { offset: 16, limit: 5 }, signal())
+    const exact = await service.read(harness.scope, 'long.txt', { offset: 16, limit: 5 }, signal())
     expect(exact).toMatchObject({ text: 'line 16\nline 17\nline 18\nline 19\nline 20', lines: 5, eof: true })
-    const beyond = await service.read(agent, 'long.txt', { offset: 19, limit: 10 }, signal())
+    const beyond = await service.read(harness.scope, 'long.txt', { offset: 19, limit: 10 }, signal())
     expect(beyond).toMatchObject({ text: 'line 19\nline 20', lines: 2, eof: true })
   })
 
@@ -90,21 +90,21 @@ describe('workspaceFiles.read — the line window', () => {
     await writeFile(join(workspace, 'two.txt'), 'a\nb\n', 'utf8')
     await writeFile(join(workspace, 'three.txt'), 'a\nb\n\n', 'utf8')
     const service = endpoint()
-    expect(await service.read(agent, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true })
-    expect(await service.read(agent, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false })
+    expect(await service.read(harness.scope, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true })
+    expect(await service.read(harness.scope, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false })
     // The third line is empty, not absent: `lines` tells it from a page past the end.
-    expect(await service.read(agent, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true })
+    expect(await service.read(harness.scope, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true })
   })
 
   it('returns an empty eof page for an offset past the last line', async () => {
     await twentyLines()
-    const result = await endpoint().read(agent, 'long.txt', { offset: 21 }, signal())
+    const result = await endpoint().read(harness.scope, 'long.txt', { offset: 21 }, signal())
     expect(result).toMatchObject({ offset: 21, text: '', lines: 0, eof: true })
   })
 
   it('defaults the limit to the configured page size', async () => {
     await twentyLines()
-    const result = await endpoint({ maxLines: 5 }).read(agent, 'long.txt', {}, signal())
+    const result = await endpoint({ maxLines: 5 }).read(harness.scope, 'long.txt', {}, signal())
     expect(result.text.split('\n')).toHaveLength(5)
     expect(result.eof).toBe(false)
   })
@@ -113,14 +113,14 @@ describe('workspaceFiles.read — the line window', () => {
     await twentyLines()
     const service = endpoint({ maxLines: 5 })
     for (const range of [{ limit: 6 }, { offset: 0 }, { limit: 1.5 }, { offset: -3 }]) {
-      const failure = await failureOf(service.read(agent, 'long.txt', range, signal()))
+      const failure = await failureOf(service.read(harness.scope, 'long.txt', range, signal()))
       expect(failure.code).toBe('gateway/bad-request')
     }
   })
 
   it('keeps carriage returns: the page is the file text, not a rendering of it', async () => {
     await writeFile(join(workspace, 'crlf.txt'), 'a\r\nb\r\n', 'utf8')
-    const result = await endpoint().read(agent, 'crlf.txt', {}, signal())
+    const result = await endpoint().read(harness.scope, 'crlf.txt', {}, signal())
     expect(result.text).toBe('a\r\nb\r')
   })
 })
@@ -130,7 +130,7 @@ describe('workspaceFiles.read — read access and file kinds', () => {
     await writeFile(join(outside, 'notes.txt'), 'outside\nread only\n', 'utf8')
     const write = vi.spyOn(harness.ctx.fs, 'writeText')
     const edit = vi.spyOn(harness.ctx.fs, 'editText')
-    const result = await endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())
+    const result = await endpoint().read(harness.scope, join(outside, 'notes.txt'), {}, signal())
     expect(result).toMatchObject({ text: 'outside\nread only', lines: 2, eof: true })
     expect(write).not.toHaveBeenCalled()
     expect(edit).not.toHaveBeenCalled()
@@ -138,14 +138,14 @@ describe('workspaceFiles.read — read access and file kinds', () => {
 
   it('resolves a relative file outside the workspace on the Host', async () => {
     await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
-    expect(await endpoint().read(agent, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true })
+    expect(await endpoint().read(harness.scope, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true })
   })
 
   it('preserves a filesystem provider refusal for an outside file', async () => {
     await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
     const refusal = new FsError('backend denied read', 'FS_SANDBOX_DENIED')
     vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(refusal)
-    await expect(endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal)
+    await expect(endpoint().read(harness.scope, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal)
   })
 
   it('rejects a symlink that points out of the workspace — the case a prefix test cannot see', async () => {
@@ -153,7 +153,7 @@ describe('workspaceFiles.read — read access and file kinds', () => {
     // The path itself is inside the workspace and would pass any string
     // comparison; only lstat (before the follow) or realpath containment catches it.
     await symlink(join(outside, 'secret.txt'), join(workspace, 'link.txt'))
-    const failure = await failureOf(endpoint().read(agent, 'link.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'link.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-regular-file')
     expect(failure.details).toMatchObject({ kind: 'symlink' })
   })
@@ -161,24 +161,24 @@ describe('workspaceFiles.read — read access and file kinds', () => {
   it('rejects a symlink even when it points back inside the workspace', async () => {
     await writeFile(join(workspace, 'real.txt'), 'fine', 'utf8')
     await symlink(join(workspace, 'real.txt'), join(workspace, 'alias.txt'))
-    const failure = await failureOf(endpoint().read(agent, 'alias.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'alias.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-regular-file')
   })
 
   it('rejects a directory, which has no text to return', async () => {
     await mkdir(join(workspace, 'src'), { recursive: true })
-    const failure = await failureOf(endpoint().read(agent, 'src', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'src', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-regular-file')
     expect(failure.details).toMatchObject({ kind: 'directory' })
   })
 
   it('reports a missing path as not found', async () => {
-    const failure = await failureOf(endpoint().read(agent, 'nope.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'nope.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-found')
   })
 
   it('refuses an empty path as a bad request', async () => {
-    const failure = await failureOf(endpoint().read(agent, '', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, '', {}, signal()))
     expect(failure.code).toBe('gateway/bad-request')
   })
 })
@@ -186,26 +186,26 @@ describe('workspaceFiles.read — read access and file kinds', () => {
 describe('workspaceFiles.read — gate 3: the page byte cap', () => {
   it('fails a page above the cap rather than returning it shortened', async () => {
     await writeFile(join(workspace, 'big.txt'), 'x'.repeat(4096), 'utf8')
-    const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(agent, 'big.txt', {}, signal()))
+    const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(harness.scope, 'big.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/too-large')
     expect(failure.details).toMatchObject({ limit: 1024 })
   })
 
   it('accepts a page exactly at the cap, because the cap is inclusive', async () => {
     await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
-    const result = await endpoint({ maxBytes: 64 }).read(agent, 'exact.txt', {}, signal())
+    const result = await endpoint({ maxBytes: 64 }).read(harness.scope, 'exact.txt', {}, signal())
     expect(result.text).toHaveLength(64)
   })
 
   it('counts the newlines between the page lines against the cap', async () => {
     await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
-    const failure = await failureOf(endpoint({ maxBytes: 63 }).read(agent, 'exact.txt', {}, signal()))
+    const failure = await failureOf(endpoint({ maxBytes: 63 }).read(harness.scope, 'exact.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/too-large')
   })
 
   it('caps the page, not the file: a small window of a file far above the cap reads', async () => {
     await writeFile(join(workspace, 'huge.txt'), Array.from({ length: 2000 }, (_, i) => `row ${i} ${'z'.repeat(100)}`).join('\n'), 'utf8')
-    const result = await endpoint({ maxBytes: 1024 }).read(agent, 'huge.txt', { offset: 1990, limit: 3 }, signal())
+    const result = await endpoint({ maxBytes: 1024 }).read(harness.scope, 'huge.txt', { offset: 1990, limit: 3 }, signal())
     expect(result.text.split('\n')).toHaveLength(3)
     expect(result.eof).toBe(false)
     expect(result.bytes).toBeGreaterThan(200_000)
@@ -215,22 +215,22 @@ describe('workspaceFiles.read — gate 3: the page byte cap', () => {
 describe('workspaceFiles.read — gate 4: text only', () => {
   it('rejects bytes that are not valid UTF-8', async () => {
     await writeFile(join(workspace, 'bin.dat'), Buffer.from([0xff, 0xfe, 0xfd]))
-    const failure = await failureOf(endpoint().read(agent, 'bin.dat', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'bin.dat', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-text')
   })
 
   it('rejects a page that carries NUL bytes, wherever in the file the page lies', async () => {
     await writeFile(join(workspace, 'nul.dat'), Buffer.from([0x61, 0x00, 0x62]))
     const service = endpoint()
-    expect((await failureOf(service.read(agent, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text')
+    expect((await failureOf(service.read(harness.scope, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text')
     // Past the backend's own binary sample, so only the page scan can see it.
     await lateNul()
-    expect((await failureOf(service.read(agent, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text')
+    expect((await failureOf(service.read(harness.scope, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text')
   })
 
   it('reads a page that ends before a NUL byte, because detection is per page', async () => {
     await lateNul()
-    const result = await endpoint().read(agent, 'late-nul.txt', { limit: 1 }, signal())
+    const result = await endpoint().read(harness.scope, 'late-nul.txt', { limit: 1 }, signal())
     expect(result.text).toHaveLength(9000)
     expect(result.eof).toBe(false)
   })
@@ -251,7 +251,7 @@ describe('workspaceFiles.read — the file changing under its gate', () => {
   it('reports a file deleted after the gate as not found, not as an internal failure', async () => {
     await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8')
     afterGate(() => rm(join(workspace, 'fleeting.txt')))
-    const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'fleeting.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-found')
   })
 
@@ -261,7 +261,7 @@ describe('workspaceFiles.read — the file changing under its gate', () => {
       await rm(join(workspace, 'fleeting.txt'))
       await mkdir(join(workspace, 'fleeting.txt'))
     })
-    const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'fleeting.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-regular-file')
     expect(failure.details).toMatchObject({ kind: 'directory' })
   })
@@ -269,6 +269,6 @@ describe('workspaceFiles.read — the file changing under its gate', () => {
   it('passes any other backend failure through unchanged', async () => {
     await writeFile(join(workspace, 'notes.txt'), 'x', 'utf8')
     vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(new FsError('disk unreadable', 'FS_IO_ERROR'))
-    await expect(endpoint().read(agent, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
+    await expect(endpoint().read(harness.scope, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
   })
 })

+ 75 - 0
packages/api/workspace-files/tests/scope.spec.ts

@@ -0,0 +1,75 @@
+import { resolve } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
+import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
+import { describe, expect, it, vi } from 'vitest'
+import WorkspaceFiles from '../src/index.ts'
+
+const CAPS = {
+  maxBytes: 1024,
+  maxFileBytes: 1024,
+  maxLines: 100,
+  maxEntries: 100,
+}
+
+function header(id: SessionId, cwd?: string): SessionHeader {
+  return {
+    version: SESSION_FORMAT_VERSION,
+    id,
+    createdAt: 1,
+    isSeeded: false,
+    origin: 'subagent',
+    ...cwd === undefined ? {} : { cwd },
+  }
+}
+
+describe('Workspace Files Session scope lookup', () => {
+  it('uses live or stored headers without an Agent and leaves with its plugin', async () => {
+    const liveId = SessionId('live-subagent')
+    const coldId = SessionId('cold-subagent')
+    const fallbackId = SessionId('cold-without-cwd')
+    const missingId = SessionId('missing')
+    const liveRoot = resolve('live-workspace')
+    const coldRoot = resolve('cold-workspace')
+    const fallbackRoot = resolve('fallback-workspace')
+    const stat = vi.fn(async (id: SessionId) => {
+      if (id === coldId) return { header: header(coldId, coldRoot) }
+      if (id === fallbackId) return { header: header(fallbackId) }
+      return undefined
+    })
+    const ctx = new Context()
+    ctx.provide('fs', {} as never)
+    ctx.provide('sandboxPolicy', { workspaceRoot: fallbackRoot } as never)
+    ctx.provide('sessionPersistence', { stat } as never)
+    const sessions = await ctx.plugin(SessionStore)
+    const typert = await ctx.plugin(TypertRegistry)
+    const workspaceFiles = await ctx.plugin(WorkspaceFiles, CAPS)
+
+    try {
+      ctx.sessions.create(liveId, { meta: { cwd: liveRoot, origin: 'subagent' } })
+      expect(ctx.get('agents')).toBeUndefined()
+      const lookup = ctx.typert.lookups.get('workspaceFileScope')
+      expect(lookup).toMatchObject({
+        parameter: 'workspaceFileScope',
+        wire: 'workspaceFileScopeId',
+        hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope',
+        wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
+      })
+      if (lookup === undefined) throw new Error('workspaceFileScope lookup did not register')
+
+      await expect(lookup.resolve(liveId)).resolves.toEqual({ sessionId: liveId, workspaceRoot: liveRoot })
+      expect(stat).not.toHaveBeenCalled()
+      await expect(lookup.resolve(coldId)).resolves.toEqual({ sessionId: coldId, workspaceRoot: coldRoot })
+      await expect(lookup.resolve(fallbackId)).resolves.toEqual({ sessionId: fallbackId, workspaceRoot: fallbackRoot })
+      await expect(lookup.resolve(missingId)).resolves.toBeUndefined()
+      expect(stat.mock.calls.map(([id]) => id)).toEqual([coldId, fallbackId, missingId])
+
+      await workspaceFiles.dispose()
+      expect(ctx.typert.lookups.get('workspaceFileScope')).toBeUndefined()
+    } finally {
+      await workspaceFiles.dispose()
+      await sessions.dispose()
+      await typert.dispose()
+    }
+  })
+})

+ 13 - 13
packages/api/workspace-files/tests/stat.spec.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdir, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { FsVersion } from '@deepseek-ai/dsh-fs'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 
@@ -18,7 +18,7 @@ afterEach(async () => {
 describe('workspaceFiles.stat', () => {
   it('returns the absolute path, a version, and the byte size', async () => {
     await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8')
-    const result = await harness.endpoint().stat(agent, 'notes.txt', signal())
+    const result = await harness.endpoint().stat(harness.scope, 'notes.txt', signal())
     expect(result.absolutePath).toBe(harness.ctx.fs.processPath(await harness.ctx.fs.resolve(join(harness.workspace, 'notes.txt'))))
     expect(result.version.length).toBeGreaterThan(0)
     expect(result.bytes).toBe(6)
@@ -28,11 +28,11 @@ describe('workspaceFiles.stat', () => {
     const path = join(harness.workspace, 'notes.txt')
     await writeFile(path, 'one\n', 'utf8')
     const endpoint = harness.endpoint()
-    const before = await endpoint.stat(agent, 'notes.txt', signal())
-    const page = await endpoint.read(agent, 'notes.txt', {}, signal())
+    const before = await endpoint.stat(harness.scope, 'notes.txt', signal())
+    const page = await endpoint.read(harness.scope, 'notes.txt', {}, signal())
     expect(page.version).toBe(before.version)
     await writeFile(path, 'one\ntwo\n', 'utf8')
-    const after = await endpoint.stat(agent, 'notes.txt', signal())
+    const after = await endpoint.stat(harness.scope, 'notes.txt', signal())
     expect(after.version).not.toBe(before.version)
     expect(after.bytes).toBe(8)
   })
@@ -40,7 +40,7 @@ describe('workspaceFiles.stat', () => {
   it('rejects under a signal the caller already aborted, before any path resolves', async () => {
     const controller = new AbortController()
     controller.abort()
-    await expect(harness.endpoint().stat(agent, 'notes.txt', controller.signal)).rejects.toThrow()
+    await expect(harness.endpoint().stat(harness.scope, 'notes.txt', controller.signal)).rejects.toThrow()
   })
 
   it('resolves the workspace root and then the file under the caller\'s signal', async () => {
@@ -49,7 +49,7 @@ describe('workspaceFiles.stat', () => {
     const original = fs.resolve.bind(fs)
     const spy = vi.spyOn(fs, 'resolve').mockImplementation((path, opts) => original(path, opts))
     const controller = new AbortController()
-    await harness.endpoint().stat(agent, 'notes.txt', controller.signal)
+    await harness.endpoint().stat(harness.scope, 'notes.txt', controller.signal)
     expect(spy.mock.calls.map(([, opts]) => opts?.signal)).toEqual([controller.signal, controller.signal])
     spy.mockRestore()
   })
@@ -57,7 +57,7 @@ describe('workspaceFiles.stat', () => {
   it('omits bytes when the backend reports no size', async () => {
     await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8')
     vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' })
-    const result = await harness.endpoint().stat(agent, 'notes.txt', signal())
+    const result = await harness.endpoint().stat(harness.scope, 'notes.txt', signal())
     expect(result).toEqual({ absolutePath: result.absolutePath, version: 'v-sizeless' })
   })
 
@@ -66,13 +66,13 @@ describe('workspaceFiles.stat', () => {
     await symlink(join(harness.outside, 'secret.txt'), join(harness.workspace, 'link.txt'))
     await mkdir(join(harness.workspace, 'src'))
     const endpoint = harness.endpoint()
-    expect(await failureOf(endpoint.stat(agent, 'link.txt', signal()))).toMatchObject({
+    expect(await failureOf(endpoint.stat(harness.scope, 'link.txt', signal()))).toMatchObject({
       code: 'workspace-file/not-regular-file',
       details: { kind: 'symlink' },
     })
-    expect((await failureOf(endpoint.stat(agent, 'src', signal()))).details).toMatchObject({ kind: 'directory' })
-    expect(await endpoint.stat(agent, join(harness.outside, 'secret.txt'), signal())).toMatchObject({ bytes: 2 })
-    expect((await failureOf(endpoint.stat(agent, 'nope.txt', signal()))).code).toBe('workspace-file/not-found')
-    expect((await failureOf(endpoint.stat(agent, '', signal()))).code).toBe('gateway/bad-request')
+    expect((await failureOf(endpoint.stat(harness.scope, 'src', signal()))).details).toMatchObject({ kind: 'directory' })
+    expect(await endpoint.stat(harness.scope, join(harness.outside, 'secret.txt'), signal())).toMatchObject({ bytes: 2 })
+    expect((await failureOf(endpoint.stat(harness.scope, 'nope.txt', signal()))).code).toBe('workspace-file/not-found')
+    expect((await failureOf(endpoint.stat(harness.scope, '', signal()))).code).toBe('gateway/bad-request')
   })
 })

+ 3 - 3
packages/api/workspace-files/tsconfig.host.json

@@ -17,9 +17,6 @@
     {
       "path": "../../../vendor/schemastery"
     },
-    {
-      "path": "../../core/agent"
-    },
     {
       "path": "../../core/session"
     },
@@ -29,6 +26,9 @@
     {
       "path": "../../sandbox/sandbox-policy"
     },
+    {
+      "path": "../../session/session-persistence"
+    },
     {
       "path": "../../typert/protocol"
     },

+ 20 - 16
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2922,45 +2922,45 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     description: 'Host Remote file reads and workspace directory observations over the composed filesystem.',
     methods: [
       {
-        signature: '@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>',
+        signature: '@Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise<WorkspaceFileText>',
         description: 'Read one page of lines from a UTF-8 file readable by the filesystem backend.',
-        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'path', description: 'absolute path or path relative to the workspace root; files outside it are allowed.' }, { name: 'range', description: 'the line window; omitted fields take the page defaults.' }, { name: 'signal', description: 'caller cancellation.' }],
+        parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute path or path relative to the workspace root; files outside it are allowed.' }, { name: 'range', description: 'the line window; omitted fields take the page defaults.' }, { name: 'signal', description: 'caller cancellation.' }],
         returns: 'the page, the file\'s version at the stat before it, and whether it reaches the last line.',
       },
       {
-        signature: '@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>',
+        signature: '@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise<WorkspaceFileBytes>',
         description: 'Read one byte window of a regular file readable by the filesystem backend: raw bytes, no text decoding and no binary rejection.',
-        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'path', description: 'absolute path or path relative to the workspace root; files outside it are allowed.' }, { name: 'range', description: 'the byte window; omitted fields take the window defaults.' }, { name: 'signal', description: 'caller cancellation.' }],
+        parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute path or path relative to the workspace root; files outside it are allowed.' }, { name: 'range', description: 'the byte window; omitted fields take the window defaults.' }, { name: 'signal', description: 'caller cancellation.' }],
         returns: 'the window in base64, the file\'s version and size at the stat before it, and whether it reaches the last byte.',
       },
       {
-        signature: '@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>',
+        signature: '@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>',
         description: 'Read a complete regular file as bytes, subject to the configured full-file cap.',
-        parameters: [{ name: 'agent', description: 'target Agent whose workspace resolves relative paths.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'signal', description: 'caller cancellation.' }],
+        parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'signal', description: 'caller cancellation.' }],
         returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.',
       },
       {
-        signature: '@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>',
+        signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise<WorkspaceFileBytes>',
         description: 'Read a complete file relative to another file\'s directory, including outside the workspace.',
-        parameters: [{ name: 'agent', description: 'Agent whose workspace resolves the base file\'s relative path.' }, { name: 'path', description: 'base file, absolute or workspace-relative.' }, { name: 'relativePath', description: 'relative filesystem path, not a URL or absolute path.' }, { name: 'signal', description: 'caller cancellation.' }],
+        parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'base file, absolute or workspace-relative.' }, { name: 'relativePath', description: 'relative filesystem path, not a URL or absolute path.' }, { name: 'signal', description: 'caller cancellation.' }],
         returns: 'the complete related file using the ordinary file-size and access checks.',
       },
       {
-        signature: '@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>',
+        signature: '@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>',
         description: 'Report one regular file\'s identity, version, and size without its content.',
-        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'path', description: 'absolute path or path relative to the workspace root; files outside it are allowed.' }, { name: 'signal', description: 'caller cancellation.' }],
+        parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute path or path relative to the workspace root; files outside it are allowed.' }, { name: 'signal', description: 'caller cancellation.' }],
         returns: 'the file\'s absolute path, current version, and byte size.',
       },
       {
-        signature: '@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>',
-        description: 'List the direct children of one directory inside the Agent\'s workspace.',
-        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'path', description: 'workspace path, absolute or relative to the workspace root.' }, { name: 'signal', description: 'caller cancellation.' }],
+        signature: '@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>',
+        description: 'List the direct children of one directory inside the Session\'s workspace.',
+        parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'workspace path, absolute or relative to the workspace root.' }, { name: 'signal', description: 'caller cancellation.' }],
         returns: 'the directory\'s children in the backend\'s stable name order, bounded by the entry cap.',
       },
       {
-        signature: '@Remote({ mode: \'stream\' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>',
-        description: 'Stream every `fs/observed` observation of a file inside the Agent\'s workspace. Only Agent filesystem operations report here; the OS is not watched.',
-        parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'signal', description: 'generation cancellation.' }],
+        signature: '@Remote({ mode: \'stream\' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>',
+        description: 'Stream every `fs/observed` observation of a file inside the Session\'s workspace. Only instrumented filesystem operations report here; the OS is not watched.',
+        parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'signal', description: 'generation cancellation.' }],
         returns: '`ready` once the Host observation queue is active and the workspace root is resolved, then queued and live observations in emission order.',
       },
     ],
@@ -6462,6 +6462,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'WorkspaceFileRange',
     declaration: 'export interface WorkspaceFileRange {\n    readonly offset?: number;\n    readonly limit?: number;\n}',
   },
+  {
+    name: 'WorkspaceFileScope',
+    declaration: 'export interface WorkspaceFileScope {\n    readonly sessionId: SessionId;\n    readonly workspaceRoot: string;\n}',
+  },
   {
     name: 'WorkspaceFileStat',
     declaration: 'export interface WorkspaceFileStat {\n    readonly absolutePath: string;\n    readonly version: string;\n    readonly bytes?: number;\n}',

+ 3 - 3
pnpm-lock.yaml

@@ -1194,9 +1194,6 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
-      '@deepseek-ai/dsh-agent':
-        specifier: workspace:^
-        version: link:../../core/agent
       '@deepseek-ai/dsh-api-gateway':
         specifier: workspace:^
         version: link:../gateway
@@ -1215,6 +1212,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-persistence':
+        specifier: workspace:^
+        version: link:../../session/session-persistence
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         version: link:../../util/workspace-path

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -763,6 +763,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
   WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
   WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
+  WorkspaceFileScope: 'Host workspace file lookup contract is owned by packages/api/workspace-files/README.md',
   WorkspaceByteRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
   WorkspaceDirectoryListing: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
   WorkspaceFileBytes: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',