1
0
Эх сурвалжийг харах

Merge pull request #3521 from deepseek-harness/turtle/issue-2349-windows-root-workspace

fix(workspace): preserve Windows drive roots
Turtle 1 сар өмнө
parent
commit
9656a1742b
22 өөрчлөгдсөн 199 нэмэгдсэн , 55 устгасан
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml
  2. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md
  3. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.i18n.yaml
  5. 35 0
      .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md
  6. 35 0
      .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.zh.md
  7. 2 2
      docs/subsystems/workspace.i18n.yaml
  8. 8 8
      docs/subsystems/workspace.md
  9. 8 8
      docs/subsystems/workspace.zh.md
  10. 3 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  11. 2 2
      packages/util/workspace-path/README.i18n.yaml
  12. 1 1
      packages/util/workspace-path/README.md
  13. 1 1
      packages/util/workspace-path/README.zh.md
  14. 2 1
      packages/util/workspace-path/src/index.ts
  15. 6 0
      packages/util/workspace-path/tests/index.spec.ts
  16. 2 2
      packages/workspace/workspace/README.i18n.yaml
  17. 1 1
      packages/workspace/workspace/README.md
  18. 1 1
      packages/workspace/workspace/README.zh.md
  19. 9 10
      packages/workspace/workspace/src/index.ts
  20. 43 8
      packages/workspace/workspace/src/paths.ts
  21. 1 1
      packages/workspace/workspace/src/types.ts
  22. 27 0
      packages/workspace/workspace/tests/workspace.spec.ts

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.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/bug-fix/2026-07-31-same-basename-workspace-adoption.md
-2026-07-31-same-basename-workspace-adoption.md: a634972448d8a3fea3d08c5713602cec0584a44e
-2026-07-31-same-basename-workspace-adoption.zh.md: 82be4aee3b3da2e9fb02783f1cd9bf470802cae0
+2026-07-31-same-basename-workspace-adoption.md: d7c4f7a9d67d760dccbdc536b4894292a171ee3e
+2026-07-31-same-basename-workspace-adoption.zh.md: 968863a24c6e7c6237d814f20e9055fd029178a4

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md

@@ -6,11 +6,11 @@ English | [中文](2026-07-31-same-basename-workspace-adoption.zh.md)
 
 ## Problem
 
-A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its basename-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id.
+A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its directory-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id.
 
 ## Decision
 
-`ctx.workspaceRegistry.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record still derives its title from `basename(path)` without suffixing or rewriting it.
+`ctx.workspaceRegistry.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record derives its title from the final path segment, falling back to the root spelling when that segment is empty. The [fully qualified Workspace path decision](2026-09-03-fully-qualified-workspace-paths.md) owns path admission and the title fallback.
 
 The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation.
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md

@@ -6,11 +6,11 @@ Status: implemented
 
 ## 问题
 
-Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按 basename 派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。
+Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按目录名称派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。
 
 ## 决策
 
-`ctx.workspaceRegistry.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录仍从 `basename(path)` 派生标题,不添加后缀,也不改写标题。
+`ctx.workspaceRegistry.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录从最终路径段派生标题,该路径段为空时回退到根路径拼写。[Workspace 完全限定路径决策](2026-09-03-fully-qualified-workspace-paths.zh.md)负责规定路径准入和标题回退。
 
 Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-09-03-fully-qualified-workspace-paths.md
+2026-09-03-fully-qualified-workspace-paths.md: 0bc6215ec5f6485856153b5a5c59d0ed68f485bb
+2026-09-03-fully-qualified-workspace-paths.zh.md: b8e67f9d34fc8ccb7759c95017dd3ab4fde56113

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md

@@ -0,0 +1,35 @@
+# Agent Note: Fully qualified Workspace paths
+
+Status: implemented
+
+English | [中文](2026-09-03-fully-qualified-workspace-paths.zh.md)
+
+## Problem
+
+Workspace path identity must name one directory independently of process state. POSIX relative paths, Windows drive-relative paths such as `C:work`, and Windows root-relative paths such as `\\work` can resolve against the Host cwd or the current directory retained for a drive. Passing those spellings to `realpath` can therefore register a different directory when host state changes. Filesystem roots also have an empty basename, which can create an empty default Workspace title.
+
+Windows drive roots need separate handling in browser-safe relative-path joins. Removing the trailing separator from `C:\\` produces `C:`, which changes an absolute path into a drive-relative path.
+
+## Decision
+
+`WorkspaceRegistry.create()` and `resolveByPath()` reject paths that are not fully qualified before calling `realpath`. POSIX requires an absolute path. Windows requires `win32.isAbsolute(path)` plus a parsed root that is neither `\\` nor `/`; this accepts drive-qualified and UNC paths while rejecting current-drive-root and drive-relative spellings without maintaining a second path grammar.
+
+Canonical paths remain the registry identity. A default title uses the final path segment, or `node:path`'s parsed root when that segment is empty. This refines the display rule owned by [same-basename Workspace adoption](2026-07-31-same-basename-workspace-adoption.md) without making titles unique.
+
+The browser-safe `resolveWorkspacePath()` removes trailing separators only after choosing the separator from the Workspace spelling. Backslash drive and UNC paths keep `\\`; forward-slash drive paths keep `/`; joining a drive root always retains the separator after the colon.
+
+## Alternatives considered
+
+**Resolve relative paths against the Host cwd.** Rejected because Workspace identity would depend on process state that callers do not supply and remote clients cannot observe.
+
+**Resolve relative paths against another stored Workspace.** Rejected because create and lookup requests do not identify such an anchor, and guessing one would make the same path spelling address different records.
+
+**Maintain a regular expression for drive and UNC syntax.** Rejected because `node:path.win32` already parses roots and absolute paths; a second grammar can diverge on separator variants and UNC roots.
+
+**Use an empty title for filesystem roots.** Rejected because the title is the primary Workspace label. The root spelling is short, stable, and already distinguishes drive and UNC roots.
+
+## Consequences
+
+Callers must submit fully qualified Workspace paths. Invalid path spellings fail before filesystem access, while nonexistent fully qualified paths still return the original filesystem error. Windows drive and UNC roots remain valid identities and have non-empty default titles; an UNC share root uses the share name as its final segment.
+
+Workspace-relative joins preserve the separator style already present in the Workspace root. Unit tests cover POSIX roots, drive roots, UNC roots, rejected drive-relative and current-drive-root paths, and both Windows separator styles.

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: Workspace 完全限定路径
+
+Status: implemented
+
+[English](2026-09-03-fully-qualified-workspace-paths.md) | 中文
+
+## 问题
+
+Workspace 路径身份必须在不依赖进程状态的情况下指向唯一目录。POSIX 相对路径、`C:work` 等 Windows 驱动器相对路径,以及 `\\work` 等 Windows 当前驱动器根相对路径,可能依据宿主 cwd 或驱动器保留的当前目录完成解析。把这些拼写传给 `realpath`,会在宿主状态变化时注册不同目录。文件系统根目录的 basename 也为空,可能产生空的默认 Workspace 标题。
+
+浏览器安全的相对路径连接还需要单独处理 Windows 驱动器根。移除 `C:\\` 的尾部分隔符会得到 `C:`,从而把绝对路径变成驱动器相对路径。
+
+## 决策
+
+`WorkspaceRegistry.create()` 和 `resolveByPath()` 会在调用 `realpath` 前拒绝不是完全限定形式的路径。POSIX 要求绝对路径。Windows 要求 `win32.isAbsolute(path)`,且解析出的根既不是 `\\` 也不是 `/`;该规则接受驱动器限定路径与 UNC 路径,同时无需维护第二套路径语法即可拒绝当前驱动器根拼写和驱动器相对拼写。
+
+规范路径继续作为注册表身份。默认标题使用最终路径段;该路径段为空时,则使用 `node:path` 解析出的根。该规则细化了[接纳 basename 相同 Workspace](2026-07-31-same-basename-workspace-adoption.zh.md)拥有的显示规则,但不会要求标题唯一。
+
+浏览器安全的 `resolveWorkspacePath()` 会先根据 Workspace 拼写选择分隔符,再移除尾部分隔符。使用反斜杠的驱动器与 UNC 路径保留 `\\`,使用正斜杠的驱动器路径保留 `/`,与驱动器根连接时始终保留冒号后的分隔符。
+
+## 考虑过的替代方案
+
+**依据宿主 cwd 解析相对路径。** 不予采纳,因为 Workspace 身份将依赖调用方未提供、远程客户端无法观察的进程状态。
+
+**依据另一个已存储 Workspace 解析相对路径。** 不予采纳,因为 create 和 lookup 请求没有指定这种锚点,猜测锚点会让同一路径拼写指向不同记录。
+
+**维护用于驱动器和 UNC 语法的正则表达式。** 不予采纳,因为 `node:path.win32` 已经解析根和绝对路径;第二套语法可能在分隔符变体和 UNC 根上发生偏差。
+
+**为文件系统根目录使用空标题。** 不予采纳,因为标题是 Workspace 的主要标签。根路径拼写简短、稳定,并且已经可以区分驱动器与 UNC 根。
+
+## 后果
+
+调用方必须提交完全限定的 Workspace 路径。无效路径拼写会在文件系统访问前失败,而不存在的完全限定路径仍返回原始文件系统错误。Windows 驱动器根与 UNC 根继续作为有效身份,并具有非空默认标题;UNC share 根使用 share 名称作为最终路径段。
+
+Workspace 相对路径连接会保留 Workspace 根中已有的分隔符风格。单元测试覆盖 POSIX 根、驱动器根、UNC 根、被拒绝的驱动器相对路径与当前驱动器根路径,以及两种 Windows 分隔符风格。

+ 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: 82bbe310d490653bae5d88f41d1fcbe76833b431
-workspace.zh.md: 9bf63b7a90f9136173fbec16b55a22821fa380e5
+workspace.md: 3c53300a5be6678802da950d22854da76fa46bf1
+workspace.zh.md: b651a21f7641619c28bad9d3fdf368c718ca2c29

+ 8 - 8
docs/subsystems/workspace.md

@@ -40,7 +40,7 @@ interface Workspace {
    */
   readonly path: string
 
-  /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
+  /** Display title. Defaults to the final path segment, or a filesystem root's own spelling; duplicates are allowed. */
   readonly title: string
 
   /** ISO-8601 creation instant, stamped at create and never rewritten. */
@@ -117,7 +117,7 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session
 
 ## The registry: `ctx.workspaceRegistry`
 
-`WorkspaceRegistry` ([signatures](#ctxworkspaceregistry--workspaceregistry)) owns registration and resolution. `create(path, title?)` canonicalizes the path, rejects a nonexistent path (the original `ENOENT`) or a non-directory, returns the existing entity unchanged when the canonical path is already owned, and otherwise creates a record with `title ?? basename(path)` prepended to the durable registry order (different canonical paths may share a display title). `get(id)` and the ordered `list()` are synchronous cache reads; `resolveByPath(path)` applies the same realpath canon without creating. `delete(id)` removes only the registration, order entry, and session account — the directory, user files, live sessions, and persisted logs are never touched, so those sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)); unknown ids return `false`. Create and delete persist a pending-mutation marker before their two writes (record + order) can diverge; startup resolves exactly the marked mutation — by deleting the marked table row, which completes an interrupted delete and rolls back an interrupted create (the registration is re-creatable, so rollback is the safe direction) — and an unmarked order/table mismatch fails loud as corruption.
+`WorkspaceRegistry` ([signatures](#ctxworkspaceregistry--workspaceregistry)) owns registration and resolution. `create(path, title?)` requires a fully qualified path, canonicalizes it, rejects a nonexistent path (the original `ENOENT`) or a non-directory, returns the existing entity unchanged when the canonical path is already owned, and otherwise creates a record with `title ?? defaultWorkspaceTitle(path)` prepended to the durable registry order (different canonical paths may share a display title, and a path with no final segment uses its root spelling). `get(id)` and the ordered `list()` are synchronous cache reads; `resolveByPath(path)` applies the same fully qualified realpath canon without creating. `delete(id)` removes only the registration, order entry, and session account — the directory, user files, live sessions, and persisted logs are never touched, so those sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)); unknown ids return `false`. Create and delete persist a pending-mutation marker before their two writes (record + order) can diverge; startup resolves exactly the marked mutation — by deleting the marked table row, which completes an interrupted delete and rolls back an interrupted create (the registration is re-creatable, so rollback is the safe direction) — and an unmarked order/table mismatch fails loud as corruption.
 
 Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`.
 
@@ -250,13 +250,13 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c
 
 ```ts cordis-catalog
 /**
- * Create or reuse a workspace for an existing directory. The path is
- * canonicalized through `fs.realpath`; a nonexistent path rejects with the
- * original error and a non-directory rejects. Repeated calls for the same
- * canonical path return the existing entity without changing its title.
+ * Create or reuse a workspace for an existing directory. The fully qualified
+ * path is canonicalized through `fs.realpath`; a relative, nonexistent, or
+ * non-directory path rejects. Repeated calls for the same canonical path
+ * return the existing entity without changing its title.
  * A newly created workspace is prepended to the durable registry order.
  * Different canonical paths may share a display title.
- * @param path - Existing directory to own, in any path spelling.
+ * @param path - Existing directory to own, in a fully qualified path spelling.
  * @param title - Display title used only when a new record is created.
  * @returns the existing or newly durable workspace.
  */
@@ -309,7 +309,7 @@ archiveSession(sessionId: SessionId): Promise<void>
  * Resolve by canonical directory path without creating or mutating a
  * workspace. A missing path rejects during `realpath`; an existing unowned
  * directory returns `undefined`.
- * @param path - Existing directory path in any spelling.
+ * @param path - Existing directory path in a fully qualified spelling.
  * @returns the workspace owning the canonical path, when one exists.
  */
 async resolveByPath(path: string): Promise<Workspace | undefined>

+ 8 - 8
docs/subsystems/workspace.zh.md

@@ -40,7 +40,7 @@ interface Workspace {
    */
   readonly path: string
 
-  /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
+  /** Display title. Defaults to the final path segment, or a filesystem root's own spelling; duplicates are allowed. */
   readonly title: string
 
   /** ISO-8601 creation instant, stamped at create and never rewritten. */
@@ -117,7 +117,7 @@ interface Workspace {
 
 ## 注册表:`ctx.workspaceRegistry`
 
-`WorkspaceRegistry`([签名](#ctxworkspaceregistry--workspaceregistry))拥有注册与解析。`create(path, title?)` 规范化路径,拒绝不存在的路径(原样传出原始 `ENOENT`)或非目录;当规范路径已被拥有时原样返回既有实体;否则创建一条标题为 `title ?? basename(path)` 的记录并前插到持久的注册表顺序中(不同规范路径可以共享同一显示标题)。`get(id)` 与有序的 `list()` 是同步缓存读取;`resolveByPath(path)` 应用同一套 realpath 规范但不创建。`delete(id)` 只移除注册记录、顺序条目和会话账本——目录、用户文件、实时会话和已持久化日志一概不动,因此这些会话变为 Ungrouped([决策](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md));未知 id 返回 `false`。create 与 delete 会在其两次写入(记录 + 顺序)可能分叉之前先持久写入一个待定变更标记;启动时恰好解决被标记的那次变更——通过删除被标记的表行:这会补完被中断的 delete,并回滚被中断的 create(注册可以重建,因此回滚是安全方向)——而没有标记的顺序/表不一致则作为损坏大声失败。
+`WorkspaceRegistry`([签名](#ctxworkspaceregistry--workspaceregistry))拥有注册与解析。`create(path, title?)` 要求完全限定路径并将其规范化,拒绝不存在的路径(原样传出原始 `ENOENT`)或非目录;当规范路径已被拥有时原样返回既有实体;否则创建一条标题为 `title ?? defaultWorkspaceTitle(path)` 的记录并前插到持久的注册表顺序中(不同规范路径可以共享同一显示标题,没有最终路径段时使用根路径拼写)。`get(id)` 与有序的 `list()` 是同步缓存读取;`resolveByPath(path)` 应用同一套完全限定 realpath 规范但不创建。`delete(id)` 只移除注册记录、顺序条目和会话账本——目录、用户文件、实时会话和已持久化日志一概不动,因此这些会话变为 Ungrouped([决策](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md));未知 id 返回 `false`。create 与 delete 会在其两次写入(记录 + 顺序)可能分叉之前先持久写入一个待定变更标记;启动时恰好解决被标记的那次变更——通过删除被标记的表行:这会补完被中断的 delete,并回滚被中断的 create(注册可以重建,因此回滚是安全方向)——而没有标记的顺序/表不一致则作为损坏大声失败。
 
 会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。
 
@@ -250,13 +250,13 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c
 
 ```ts cordis-catalog
 /**
- * Create or reuse a workspace for an existing directory. The path is
- * canonicalized through `fs.realpath`; a nonexistent path rejects with the
- * original error and a non-directory rejects. Repeated calls for the same
- * canonical path return the existing entity without changing its title.
+ * Create or reuse a workspace for an existing directory. The fully qualified
+ * path is canonicalized through `fs.realpath`; a relative, nonexistent, or
+ * non-directory path rejects. Repeated calls for the same canonical path
+ * return the existing entity without changing its title.
  * A newly created workspace is prepended to the durable registry order.
  * Different canonical paths may share a display title.
- * @param path - Existing directory to own, in any path spelling.
+ * @param path - Existing directory to own, in a fully qualified path spelling.
  * @param title - Display title used only when a new record is created.
  * @returns the existing or newly durable workspace.
  */
@@ -309,7 +309,7 @@ archiveSession(sessionId: SessionId): Promise<void>
  * Resolve by canonical directory path without creating or mutating a
  * workspace. A missing path rejects during `realpath`; an existing unowned
  * directory returns `undefined`.
- * @param path - Existing directory path in any spelling.
+ * @param path - Existing directory path in a fully qualified spelling.
  * @returns the workspace owning the canonical path, when one exists.
  */
 async resolveByPath(path: string): Promise<Workspace | undefined>

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

@@ -2792,8 +2792,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     methods: [
       {
         signature: 'async create(path: string, title?: string): Promise<Workspace>',
-        description: 'Create or reuse a workspace for an existing directory. The path is canonicalized through `fs.realpath`; a nonexistent path rejects with the original error and a non-directory rejects. Repeated calls for the same canonical path return the existing entity without changing its title. A newly created workspace is prepended to the durable registry order. Different canonical paths may share a display title.',
-        parameters: [{ name: 'path', description: 'Existing directory to own, in any path spelling.' }, { name: 'title', description: 'Display title used only when a new record is created.' }],
+        description: 'Create or reuse a workspace for an existing directory. The fully qualified path is canonicalized through `fs.realpath`; a relative, nonexistent, or non-directory path rejects. Repeated calls for the same canonical path return the existing entity without changing its title. A newly created workspace is prepended to the durable registry order. Different canonical paths may share a display title.',
+        parameters: [{ name: 'path', description: 'Existing directory to own, in a fully qualified path spelling.' }, { name: 'title', description: 'Display title used only when a new record is created.' }],
         returns: 'the existing or newly durable workspace.',
       },
       {
@@ -2829,7 +2829,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       {
         signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
         description: 'Resolve by canonical directory path without creating or mutating a workspace. A missing path rejects during `realpath`; an existing unowned directory returns `undefined`.',
-        parameters: [{ name: 'path', description: 'Existing directory path in any spelling.' }],
+        parameters: [{ name: 'path', description: 'Existing directory path in a fully qualified spelling.' }],
         returns: 'the workspace owning the canonical path, when one exists.',
       },
     ],

+ 2 - 2
packages/util/workspace-path/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/util/workspace-path/README.md
-README.md: 72addf2d85c62ef5afbfc4824af8c96c59bfbecb
-README.zh.md: 2a115f1a9dbdb23b7121dea41eb6da75b1515358
+README.md: 1b09bda74c8836655789c57fa05d03a2892c16b9
+README.zh.md: 25128e5fec504e1724f5cc7952fed976985912d5

+ 1 - 1
packages/util/workspace-path/README.md

@@ -22,7 +22,7 @@ Browser-safe path helpers shared by Workspace-facing client and controller packa
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **Resolution is lexical** — it recognizes POSIX absolute paths, Windows drive paths, and UNC paths but does not access a filesystem or canonicalize `.` and `..` segments.
+- **Resolution is lexical** — it recognizes POSIX absolute paths, Windows drive paths, and UNC paths, preserves the Workspace path's separator when joining a relative path, and does not access a filesystem or canonicalize `.` and `..` segments.
 - **Home abbreviation is POSIX-only** — Windows paths remain unchanged because a portable browser cannot infer Windows home-path equivalence safely.
 
 

+ 1 - 1
packages/util/workspace-path/README.zh.md

@@ -22,7 +22,7 @@ kind: "package-library"
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **路径解析仅处理字面值**——它识别 POSIX 绝对路径、Windows 盘符路径和 UNC 路径,但不访问文件系统,也不规范化 `.` 与 `..` 路径段。
+- **路径解析仅处理字面值**——它识别 POSIX 绝对路径、Windows 盘符路径和 UNC 路径,拼接相对路径时保留 Workspace 路径的分隔符,但不访问文件系统,也不规范化 `.` 与 `..` 路径段。
 - **主目录缩写仅支持 POSIX**——Windows 路径保持不变,因为可移植浏览器无法安全推断 Windows 主目录路径等价关系。
 
 

+ 2 - 1
packages/util/workspace-path/src/index.ts

@@ -17,9 +17,10 @@ function isWindowsStylePath(value: string): boolean {
 export function resolveWorkspacePath(cwd: string | undefined, path: string): string {
   if (path.startsWith('/') || isWindowsStylePath(path)) return path
   if (cwd === undefined || cwd === '') return path
+  const separator = isWindowsStylePath(cwd) && cwd.includes('\\') ? '\\' : '/'
   const base = cwd.replace(/[/\\]+$/, '')
   const relative = path.replace(/^[/\\]+/, '')
-  return `${base}/${relative}`
+  return `${base}${separator}${relative}`
 }
 
 /**

+ 6 - 0
packages/util/workspace-path/tests/index.spec.ts

@@ -13,6 +13,12 @@ describe('Workspace path helpers', () => {
     expect(resolveWorkspacePath('/w', '\\\\server\\share')).toBe('\\\\server\\share')
   })
 
+  it('keeps Windows drive-root and directory joins fully qualified', () => {
+    expect(resolveWorkspacePath('C:\\', 'src\\a.ts')).toBe('C:\\src\\a.ts')
+    expect(resolveWorkspacePath('C:\\work\\', 'src\\a.ts')).toBe('C:\\work\\src\\a.ts')
+    expect(resolveWorkspacePath('C:/work/', 'src/a.ts')).toBe('C:/work/src/a.ts')
+  })
+
   it('abbreviates only descendants of a POSIX home', () => {
     expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~')
     expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~')

+ 2 - 2
packages/workspace/workspace/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/workspace/workspace/README.md
-README.md: 49ca861742fe9ea5f36edbaeb8d9700c475b1d50
-README.zh.md: 707044970b716577d2f1f95e558c341e217985c4
+README.md: eb3d2a4f89b6023d8a916a41723500186cc10d93
+README.zh.md: 5059a6edd69a82bd8b0a364793ca1b5c941d67e2

+ 1 - 1
packages/workspace/workspace/README.md

@@ -50,7 +50,7 @@ With these rows mounted, creating a project shows up in the list immediately and
 
 ### Creating and ordering projects
 
-Create a project from any directory that exists: give its path and an optional title, and the project appears in the list, newest first. A path that does not exist, or a file instead of a directory, is rejected and nothing changes; creating a project for a directory that already has one returns the existing project unchanged. Rename a project at any time, and move it to any position in the list:
+Create a project from any fully qualified directory that exists: filesystem roots such as `C:\` and ordinary directories are valid. Relative paths, Windows drive-relative paths such as `C:work`, missing paths, and files are rejected without creating a project; creating a project for a directory that already has one returns the existing project unchanged. Rename a project at any time, and move it to any position in the list:
 
 ```text
 // Host consumer code, after the composition above is loaded:

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

@@ -50,7 +50,7 @@ kind: "package-reference"
 
 ### 创建与排序项目
 
-从任何存在的目录创建项目:给出路径和可选标题,项目即出现在列表中,新到旧排列。不存在的路径或文件而非目录会被拒绝,且不会有任何变化;为已有项目的目录再次创建会原样返回现有项目。你可以随时重命名项目,并把它移动到列表中的任意位置:
+从任何存在且完整限定的目录创建项目:`C:\` 等文件系统根目录和普通目录都有效。相对路径、`C:work` 等 Windows 盘符相对路径、不存在的路径和文件都会被拒绝,且不会创建项目;为已有项目的目录再次创建会原样返回现有项目。你可以随时重命名项目,并把它移动到列表中的任意位置:
 
 ```text
 // Host consumer code, after the composition above is loaded:

+ 9 - 10
packages/workspace/workspace/src/index.ts

@@ -7,7 +7,6 @@
 
 import { randomUUID } from 'node:crypto'
 import { stat } from 'node:fs/promises'
-import { basename } from 'node:path'
 import { Context, Service } from '@deepseek-ai/cordis'
 import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-session-persistence'
@@ -16,7 +15,7 @@ import { WorkspaceEntity } from './entity.ts'
 import type { WorkspaceEntityHost } from './entity.ts'
 
 export { WorkspaceMoveInvalidError } from './entity.ts'
-import { realpathNormalize } from './paths.ts'
+import { defaultWorkspaceTitle, realpathNormalize } from './paths.ts'
 import { workspaceDomainSpec } from './spec.ts'
 import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
 import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts'
@@ -140,13 +139,13 @@ export class WorkspaceRegistry extends Service {
   }
 
   /**
-   * Create or reuse a workspace for an existing directory. The path is
-   * canonicalized through `fs.realpath`; a nonexistent path rejects with the
-   * original error and a non-directory rejects. Repeated calls for the same
-   * canonical path return the existing entity without changing its title.
+   * Create or reuse a workspace for an existing directory. The fully qualified
+   * path is canonicalized through `fs.realpath`; a relative, nonexistent, or
+   * non-directory path rejects. Repeated calls for the same canonical path
+   * return the existing entity without changing its title.
    * A newly created workspace is prepended to the durable registry order.
    * Different canonical paths may share a display title.
-   * @param path - Existing directory to own, in any path spelling.
+   * @param path - Existing directory to own, in a fully qualified path spelling.
    * @param title - Display title used only when a new record is created.
    * @returns the existing or newly durable workspace.
    */
@@ -271,7 +270,7 @@ export class WorkspaceRegistry extends Service {
    * Resolve by canonical directory path without creating or mutating a
    * workspace. A missing path rejects during `realpath`; an existing unowned
    * directory returns `undefined`.
-   * @param path - Existing directory path in any spelling.
+   * @param path - Existing directory path in a fully qualified spelling.
    * @returns the workspace owning the canonical path, when one exists.
    */
   async resolveByPath(path: string): Promise<Workspace | undefined> {
@@ -287,7 +286,7 @@ export class WorkspaceRegistry extends Service {
       if (entity.path === canonical) return entity
     }
 
-    const workspaceName = title ?? basename(canonical)
+    const workspaceName = title ?? defaultWorkspaceTitle(canonical)
     const table = this.requireTable()
     const state = this.requireState()
     const id = WorkspaceId(randomUUID())
@@ -459,7 +458,7 @@ export class WorkspaceRegistry extends Service {
         const createdAt = new Date(group.newestAt).toISOString()
         const record: WorkspaceRecord = {
           path: group.path,
-          title: basename(group.path),
+          title: defaultWorkspaceTitle(group.path),
           sessionIds,
           createdAt,
           updatedAt: createdAt,

+ 43 - 8
packages/workspace/workspace/src/paths.ts

@@ -4,19 +4,54 @@
  */
 
 import { realpath } from 'node:fs/promises'
+import { posix, win32 } from 'node:path'
 
 /**
- * Canonicalize a directory path via `fs.realpath`: trailing slashes, `..`
- * segments, and symlinks are all resolved. This is the ONE uniqueness canon of
- * the package — workspace paths are stored canonicalized, uniqueness is
- * string equality of canonicalized paths (a symlink to an existing
- * workspace's directory collides), and attach-time session `cwd` checks go
- * through the same canon. A path that does not exist rejects with the
- * original `ENOENT` — this is `create`'s reject path (a workspace must point
- * at an existing directory).
+ * Check whether a path names one fixed Host location without process cwd or
+ * current-drive resolution.
+ * @param path - Candidate Workspace path.
+ * @param platform - Host platform; injectable for deterministic path tests.
+ * @returns Whether the path is fully qualified on that platform.
+ */
+export function fullyQualifiedWorkspacePath(
+  path: string,
+  platform: NodeJS.Platform = process.platform,
+): boolean {
+  if (platform !== 'win32') return posix.isAbsolute(path)
+  const root = win32.parse(path).root
+  return win32.isAbsolute(path) && root !== '\\' && root !== '/'
+}
+
+/**
+ * Derive a non-empty default title from a canonical Workspace path.
+ * @param path - Canonical Workspace path.
+ * @param platform - Host platform; injectable for deterministic path tests.
+ * @returns The final segment when present, otherwise the complete root spelling.
+ */
+export function defaultWorkspaceTitle(
+  path: string,
+  platform: NodeJS.Platform = process.platform,
+): string {
+  const pathApi = platform === 'win32' ? win32 : posix
+  return pathApi.basename(path) || pathApi.parse(path).root
+}
+
+/**
+ * Canonicalize a fully qualified directory path via `fs.realpath`: trailing
+ * slashes, `..` segments, and symlinks are all resolved. This is the ONE
+ * uniqueness canon of the package — workspace paths are stored canonicalized,
+ * uniqueness is string equality of canonicalized paths (a symlink to an
+ * existing workspace's directory collides), and attach-time session `cwd`
+ * checks go through the same canon. Relative paths reject before `realpath` can
+ * resolve them from the Host cwd or current Windows drive. A path that does not
+ * exist rejects with the original `ENOENT` — this is `create`'s reject path (a
+ * workspace must point at an existing directory).
  * @param path - The path to canonicalize.
  * @returns the canonical absolute path.
  */
 export async function realpathNormalize(path: string): Promise<string> {
+  if (!fullyQualifiedWorkspacePath(path)) {
+    throw new TypeError(`Workspace path is not fully qualified: '${path}'`)
+  }
   return await realpath(path)
 }

+ 1 - 1
packages/workspace/workspace/src/types.ts

@@ -39,7 +39,7 @@ export interface Workspace {
    */
   readonly path: string
 
-  /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
+  /** Display title. Defaults to the final path segment, or a filesystem root's own spelling; duplicates are allowed. */
   readonly title: string
 
   /** ISO-8601 creation instant, stamped at create and never rewritten. */

+ 27 - 0
packages/workspace/workspace/tests/workspace.spec.ts

@@ -18,6 +18,7 @@ import WorkspaceRegistry, {
   WorkspaceOrderInvalidError,
 } from '../src/index.ts'
 import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
+import { defaultWorkspaceTitle, fullyQualifiedWorkspacePath } from '../src/paths.ts'
 
 const DOMAIN_VERSION = 2
 
@@ -356,6 +357,24 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
 })
 
 describe('WorkspaceRegistry create and lookup', () => {
+  it('accepts fully qualified roots and directories without accepting drive-relative paths', () => {
+    expect(fullyQualifiedWorkspacePath('C:\\', 'win32')).toBe(true)
+    expect(fullyQualifiedWorkspacePath('C:\\work', 'win32')).toBe(true)
+    expect(fullyQualifiedWorkspacePath('\\\\server\\share', 'win32')).toBe(true)
+    expect(defaultWorkspaceTitle('C:\\', 'win32')).toBe('C:\\')
+    expect(defaultWorkspaceTitle('C:\\work', 'win32')).toBe('work')
+    expect(defaultWorkspaceTitle('\\\\server\\share', 'win32')).toBe('share')
+    expect(fullyQualifiedWorkspacePath('C:', 'win32')).toBe(false)
+    expect(fullyQualifiedWorkspacePath('C:work', 'win32')).toBe(false)
+    expect(fullyQualifiedWorkspacePath('\\work', 'win32')).toBe(false)
+    expect(fullyQualifiedWorkspacePath('.', 'win32')).toBe(false)
+    expect(fullyQualifiedWorkspacePath('/', 'linux')).toBe(true)
+    expect(fullyQualifiedWorkspacePath('/work', 'darwin')).toBe(true)
+    expect(defaultWorkspaceTitle('/', 'linux')).toBe('/')
+    expect(defaultWorkspaceTitle('/work', 'darwin')).toBe('work')
+    expect(fullyQualifiedWorkspacePath('work', 'linux')).toBe(false)
+  })
+
   it('creates newest-first and idempotently reuses a canonical path without retitling', async () => {
     const firstDir = await makeDir('first')
     const secondDir = await makeDir('second')
@@ -407,6 +426,14 @@ describe('WorkspaceRegistry create and lookup', () => {
     expect(registry.list()).toEqual([])
   })
 
+  it('rejects a resolvable relative path instead of adopting it from the Host cwd', async () => {
+    const { registry } = await harness()
+    const fromHostCwd = '.'
+    await expect(registry.create(fromHostCwd)).rejects.toThrow(/fully qualified/)
+    await expect(registry.resolveByPath(fromHostCwd)).rejects.toThrow(/fully qualified/)
+    expect(registry.list()).toEqual([])
+  })
+
   it('rolls back the provisional cache when the record write fails', async () => {
     const dir = await makeDir('write-failure')
     const pool = new MemoryMediaPool()