Browse Source

fix(host): require complete UNC forms in the fully-qualified path check

ds-review-bot round 5: '\\' and '\\server' satisfy win32.isAbsolute and
the previous two-separator test, yet resolve() collapses them to
drive-relative roots. The UNC arm now requires server and share components;
incomplete prefixes reject with the business codes, covered per-platform.
creatixchu 2 months ago
parent
commit
987ecc2ec2

+ 2 - 2
packages/host/directory-picker-browse/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
 #   pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
-README.md: 160a12a8594400c9e6c565881d8e9ca0517b4e24
-README.zh.md: 4cfc4a611fb17cb30ac841c8af8498b51a6388b0
+README.md: 9655fdb538da1addb4d10dbee6210965400394c8
+README.zh.md: 1c7b0c36cbb73286f3d4e6a48748c4415f9ff0e4

+ 1 - 1
packages/host/directory-picker-browse/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 
 The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot.
 The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot.
 
 
-Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
+Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
 
 
 ## Model Experience
 ## Model Experience
 
 

+ 1 - 1
packages/host/directory-picker-browse/README.zh.md

@@ -4,7 +4,7 @@
 
 
 [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。
 [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。
 
 
-行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
+行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
 
 
 ## 模型体验
 ## 模型体验
 
 

+ 4 - 4
packages/host/directory-picker-browse/src/index.ts

@@ -38,16 +38,16 @@ function ancestryCrumbs(target: string): DirectoryEntry[] {
 /**
 /**
  * True when the path names one fixed filesystem location regardless of
  * True when the path names one fixed filesystem location regardless of
  * process state: POSIX-absolute on POSIX; on Windows only drive-qualified
  * process state: POSIX-absolute on POSIX; on Windows only drive-qualified
- * (`C:\…`) or UNC (`\\server\…`) forms — rooted drive-less forms (`\foo`,
- * `/foo`) pass `isAbsolute` yet still resolve against the process's current
- * drive.
+ * (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less
+ * forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`)
+ * pass `isAbsolute` yet still resolve against the process's current drive.
  * @param path - candidate path.
  * @param path - candidate path.
  * @param platform - replaces `process.platform` for deterministic tests.
  * @param platform - replaces `process.platform` for deterministic tests.
  * @returns whether the path is fully qualified on the platform.
  * @returns whether the path is fully qualified on the platform.
  */
  */
 export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean {
 export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean {
   return platform === 'win32'
   return platform === 'win32'
-    ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(path)
+    ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
     : posix.isAbsolute(path)
     : posix.isAbsolute(path)
 }
 }
 
 

+ 5 - 0
packages/host/directory-picker-browse/tests/service.spec.ts

@@ -76,11 +76,16 @@ describe('BrowseDirectoryPicker', () => {
     expect(fullyQualified('C:\\projects', 'win32')).toBe(true)
     expect(fullyQualified('C:\\projects', 'win32')).toBe(true)
     expect(fullyQualified('C:/projects', 'win32')).toBe(true)
     expect(fullyQualified('C:/projects', 'win32')).toBe(true)
     expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true)
     expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true)
+    expect(fullyQualified('//server/share/deep', 'win32')).toBe(true)
     // Rooted but drive-less: isAbsolute accepts these, yet resolve() would
     // Rooted but drive-less: isAbsolute accepts these, yet resolve() would
     // inject the process's current drive.
     // inject the process's current drive.
     expect(fullyQualified('\\foo', 'win32')).toBe(false)
     expect(fullyQualified('\\foo', 'win32')).toBe(false)
     expect(fullyQualified('/foo', 'win32')).toBe(false)
     expect(fullyQualified('/foo', 'win32')).toBe(false)
     expect(fullyQualified('C:relative', 'win32')).toBe(false)
     expect(fullyQualified('C:relative', 'win32')).toBe(false)
+    // Incomplete UNC prefixes collapse to drive-relative roots under resolve().
+    expect(fullyQualified('\\\\', 'win32')).toBe(false)
+    expect(fullyQualified('\\\\server', 'win32')).toBe(false)
+    expect(fullyQualified('\\\\server\\', 'win32')).toBe(false)
   })
   })
 
 
   it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => {
   it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => {