From cd7aa3c7d879d1d65df1dafc3ad2a5688f2252f2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:39:15 +0800 Subject: [PATCH] fix(host,client): gate the picker affordance on the advertised kind; reject non-absolute browse paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 2. The workspace UI never consulted the advertised directoryPicker kind: under a browse (or merge-added) backend it still rendered 'Open local folder…' and called pickDirectory(), which the host answers with directory-picker-unavailable. The create flow now reads directoryPickerKind() per menu open and renders the dialog affordance only under 'dialog' — browse (until its in-app browser UI lands) and unknown kinds hide the entry, realizing the seam's documented default; a keyless workspace-flow snapshot pins the hidden entry over the browse fixture. The browse backend also resolved wire paths, silently rebasing '' or relative parents under the host process cwd; both primitives now reject non-absolute explicit paths with their business codes, and the seam JSDoc carries the contract. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 14 ++++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 2 + .../src/client/WorkspacePicker.tsx | 29 +++++++- .../ui-workspace/src/client/contract/slots.ts | 6 +- .../client/ui-workspace/src/client/index.ts | 2 + .../client/ui-workspace/tests/apply.spec.ts | 9 ++- .../tests/workspace-browser.spec.tsx | 1 + .../tests/workspace-picker.spec.tsx | 66 ++++++++++++++----- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/src/index.ts | 11 +++- .../tests/service.spec.ts | 13 ++++ packages/host/directory-picker/src/index.ts | 6 +- 20 files changed, 146 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index ccef0728d9..c13d5e7a1c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 8d9d34e7aed4525b243380a4a90801fe59bfc213 -2026-07-28-directory-picker-capability-seam.zh.md: 282f3905c3551912915088f70247260310f442cb +2026-07-28-directory-picker-capability-seam.md: a30675f2d84b6ae68b95ab96df9e32106d6fbf5d +2026-07-28-directory-picker-capability-seam.zh.md: 5560aba07424d7307e386e2e8ae6b724486d6068 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 8d9d34e7ae..a30675f2d8 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -30,7 +30,7 @@ Placement and policy rulings folded into this decision: ## Consequences -- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior), and the in-app browser PR flips the default to `-browse` with the GUI branching on `describe`. +- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior). The GUI already gates its dialog affordance on `describe.directoryPicker` (non-`dialog` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI. - The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests. - A future interaction (or an Electron `dialog` provider) is one backend package plus a client branch — no gateway surgery. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 282f3905c3..5560aba074 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -30,7 +30,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变),应用内浏览器 PR 将把默认翻到 `-browse` 并让 GUI 按 `describe` 分支。 +- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变)。GUI 已按 `describe.directoryPicker` 门控其对话框入口(非 `dialog` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse` 并补上浏览 UI。 - 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。 - 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e1fe183ef1..715be624e0 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -174,6 +174,20 @@ it('locks the composer in the New Session view state until a Workspace is chosen `) }) +it('hides the Open-local-folder entry under the fixture host\'s browse picker capability', async () => { + boot('?fixture=empty') + + await findLockedComposer() + fireEvent.click(workspaceChip()) + const menu = await screen.findByRole('menu') + // Flush the advertised-kind read (fixture describe resolves in microtasks): + // the fixture serves `browse`, whose in-app UI is not wired yet, so the + // dialog affordance must not render — only the create action remains. + await act(async () => {}) + expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item))) + .toEqual(['Create a new workspace']) +}) + it('selects the recent Workspace and opens its blank Session on first load', async () => { boot('?fixture') diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 0d78a8d648..35cd3c3702 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-workspace/README.md -README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9 -README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4 +README.md: 58aaf56e1953f00417492d766d8f4ae4a0081c81 +README.zh.md: 7c7b33e0ea68fefee8f857cb5e67a36ec5a854f5 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index edd6c2f937..58aaf56e19 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action renders only when the Host advertises the `dialog` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f7b73dde95..7c7b33e0ea 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作仅在 Host 广播 `dialog` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index ea1753a63e..bf3a41522e 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -254,6 +254,7 @@ export function WorkspaceBrowser({ insertSessionBefore, createWorkspace, pickDirectory, + directoryPickerKind, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) @@ -372,6 +373,7 @@ export function WorkspaceBrowser({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + directoryPickerKind={directoryPickerKind} onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 4ec84c7d3d..8cef0af8f9 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -5,13 +5,13 @@ * slot registration. */ import type { RefObject } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import { WorkspaceCreateError, - type WorkspaceId, type WorkspaceListState, type WorkspaceView, + type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -33,6 +33,8 @@ export interface WorkspaceCreateFlowProps { createWorkspace: (input: { name: string } | { path: string }) => Promise /** Open the Host's native single-directory picker. */ pickDirectory: () => Promise + /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ + directoryPickerKind: () => Promise /** A real Workspace was picked or created. */ onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ @@ -50,6 +52,7 @@ export function WorkspaceCreateFlow({ useWorkspaces, createWorkspace, pickDirectory, + directoryPickerKind, onPick, onClose, }: WorkspaceCreateFlowProps) { @@ -69,6 +72,22 @@ export function WorkspaceCreateFlow({ const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) + // The advertised interaction gates the picking affordance: 'dialog' is the + // only kind pickDirectory() can serve, so its entry renders under that kind + // alone; 'browse' (until the in-app browser UI lands) and unknown kinds + // hide the entry, the seam's documented unknown-kind default. Re-read per + // flow open — no cache to go stale across reconnects. + const [dialogPicker, setDialogPicker] = useState(false) + useEffect(() => { + if (!open) return + void directoryPickerKind() + .then((kind) => { setDialogPicker(kind === 'dialog') }) + // A failed describe hides the entry too: the same Host that cannot + // answer describe cannot serve pickDirectory. (Post-unmount settlement + // is safe: React 18 no-ops setState on unmounted components.) + .catch(() => { setDialogPicker(false) }) + }, [open, directoryPickerKind]) + const items: MenuEntry[] = [ ...workspaces.map(workspace => ({ id: workspace.workspaceId, @@ -77,7 +96,9 @@ export function WorkspaceCreateFlow({ disabled: pickingFolder, })), ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + ...(dialogPicker + ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }] + : []), { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, ] @@ -229,6 +250,7 @@ export function WorkspacePicker({ onClose, createWorkspace, pickDirectory, + directoryPickerKind, }: WorkspacePickerProps) { return ( diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index fcdd0e304e..c20955b567 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -13,7 +13,7 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { createWorkspaceViewStore } from '../stores.ts' /** @@ -44,6 +44,8 @@ export type WorkspaceBrowserInjected = { createWorkspace: (input: { name: string } | { path: string }) => Promise /** Ask the local Host to open its native single-directory picker. */ pickDirectory: () => Promise + /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ + directoryPickerKind: () => Promise } /** Full browser props: shell owner share + viewing store + injected actions. */ @@ -62,6 +64,8 @@ export type WorkspacePickerInjected = { createWorkspace: (input: { name: string } | { path: string }) => Promise /** Ask the local Host to open its native single-directory picker. */ pickDirectory: () => Promise + /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ + directoryPickerKind: () => Promise } /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index f27448f926..6b84680fbc 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -45,10 +45,12 @@ export function apply(ctx: ClientContext): void { }, createWorkspace: input => ctx.workspaces.create(input), pickDirectory: () => ctx.workspaces.pickDirectory(), + directoryPickerKind: () => ctx.workspaces.directoryPickerKind(), }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), pickDirectory: () => ctx.workspaces.pickDirectory(), + directoryPickerKind: () => ctx.workspaces.directoryPickerKind(), }) // Declaration-aware registration: each owner's declaring apply may activate // after this one (entry activation order is unconstrained), and a register diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 24434f22aa..f9a5884206 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -15,16 +15,17 @@ async function bench() { title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) const pickDirectory = vi.fn(async () => '/tmp/picked') + const directoryPickerKind = vi.fn(async () => 'dialog' as const) const startSession = vi.fn() const rename = vi.fn(async () => ({})) const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() const clear = vi.fn() ctx.provide('workspaces', { - create, pickDirectory, startSession, rename, insertSessionBefore, + create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear } + return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, open, clear } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -75,12 +76,16 @@ describe('ui-workspace apply', () => { expect(b.create).toHaveBeenCalledWith({ name: 'project' }) await browser.pickDirectory() expect(b.pickDirectory).toHaveBeenCalledOnce() + await browser.directoryPickerKind() + expect(b.directoryPickerKind).toHaveBeenCalledOnce() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() await picker.createWorkspace({ path: '/tmp/project' }) expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) await picker.pickDirectory() expect(b.pickDirectory).toHaveBeenCalledTimes(2) + await picker.directoryPickerKind() + expect(b.directoryPickerKind).toHaveBeenCalledTimes(2) }) it('unregisters every entry on teardown', async () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7b8462f6db..779ad0b37a 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -60,6 +60,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), pickDirectory: vi.fn(async () => null), + directoryPickerKind: vi.fn(async () => 'dialog' as const), ...overrides, } const view = render() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 6cad175ff3..c7f8fd9765 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -39,6 +39,7 @@ function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), pickDirectory = vi.fn(async () => null as string | null), + directoryPickerKind = vi.fn(async () => 'dialog'), ) { const onPick = vi.fn() const onClose = vi.fn() @@ -53,19 +54,22 @@ function mount( onClose={onClose} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + directoryPickerKind={directoryPickerKind} /> ) const view = render( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, pickDirectory, + view, onPick, onClose, createWorkspace, pickDirectory, directoryPickerKind, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } -function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void { - fireEvent.click(screen.getByRole('menuitem', { name })) +// findByRole, not getByRole: the folder entry renders only after the advertised +// picker kind resolves, one microtask after the menu opens. +async function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): Promise { + fireEvent.click(await screen.findByRole('menuitem', { name })) } describe('WorkspacePicker', () => { @@ -79,7 +83,7 @@ describe('WorkspacePicker', () => { const created = workspace('new', 'New') const createWorkspace = vi.fn(async () => created) const b = mount([], createWorkspace) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.change(input, { target: { value: 'project-one' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -92,7 +96,7 @@ describe('WorkspacePicker', () => { const createWorkspace = vi.fn(async () => created) const pickDirectory = vi.fn(async () => '/tmp/project') const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') expect(pickDirectory).toHaveBeenCalledOnce() await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) @@ -101,7 +105,7 @@ describe('WorkspacePicker', () => { it('treats native picker cancellation as a silent no-op', async () => { const b = mount([], vi.fn(), vi.fn(async () => null)) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) expect(b.createWorkspace).not.toHaveBeenCalled() expect(b.onPick).not.toHaveBeenCalled() @@ -118,7 +122,7 @@ describe('WorkspacePicker', () => { }) }) const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') await waitFor(() => { expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() }) @@ -132,7 +136,7 @@ describe('WorkspacePicker', () => { let resolve!: (path: string | null) => void const pending = new Promise((settle) => { resolve = settle }) const b = mount([], vi.fn(), vi.fn(() => pending)) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') expect(screen.getByRole('menuitem', { name: 'Open local folder…' }).disabled).toBe(true) expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true) fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' })) @@ -142,23 +146,23 @@ describe('WorkspacePicker', () => { it('reports non-Error native picker failures', async () => { const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('picker unavailable') }) expect(b.createWorkspace).not.toHaveBeenCalled() }) - it('closes a creation modal when the user cancels', () => { + it('closes a creation modal when the user cancels', async () => { mount([]) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('dialog')).toBeNull() }) - it('blocks a create-new name already present in the Workspace list', () => { + it('blocks a create-new name already present in the Workspace list', async () => { const b = mount([workspace('alpha', 'Alpha')]) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } }) expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.') expect(screen.getByRole('button', { name: 'Create workspace' }).disabled).toBe(true) @@ -171,7 +175,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((settle) => { resolve = settle }) const created = workspace('fresh', 'same-name') const b = mount([], vi.fn(() => pending)) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -187,7 +191,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) const createWorkspace = vi.fn(() => pending) const b = mount([], createWorkspace) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.keyDown(input, { key: 'ArrowRight' }) fireEvent.change(input, { target: { value: 'broken' } }) @@ -204,7 +208,7 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') @@ -217,6 +221,7 @@ describe('WorkspacePicker', () => { 'dialog')} />, ) expect(screen.queryByRole('menu')).toBeNull() @@ -230,8 +235,37 @@ describe('WorkspacePicker', () => { 'dialog')} />, ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') }) + + it('hides the folder affordance unless the Host advertises the dialog interaction', async () => { + const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => 'browse')) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('hides the folder affordance when the Host cannot answer describe', async () => { + const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => { + throw new Error('host unreachable') + })) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('does not read the picker kind while the flow is closed', () => { + const directoryPickerKind = vi.fn(async () => 'dialog') + render( + , + ) + expect(directoryPickerKind).not.toHaveBeenCalled() + }) }) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index fea6c71d41..916db3c321 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/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; # 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 -README.md: f86f74acf4922d490b23c033a281436f1a428f13 -README.zh.md: 0d240630a8b21003c5285bc75e93aac9adf36d92 +README.md: 81357269e1d4b075f7e31b5f3ac5d4721811024a +README.zh.md: 06a7f7651b2abc0aa73eba41042f3d1a86661b76 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index f86f74acf4..81357269e1 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/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. -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. 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 a non-absolute explicit path (`directory-unreadable`/`directory-create-failed`) instead of letting `resolve` rebase it under the host process cwd. 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 diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 0d240630a8..06a7f7651b 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。失败抛出 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 的栅栏一致。两个原语都拒绝非绝对的显式路径(`directory-unreadable`/`directory-create-failed`),而不是任由 `resolve` 把它重定位到宿主进程 cwd 之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 5f2a9429c0..83a3f6762e 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -11,7 +11,7 @@ import { mkdir, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, isAbsolute, join, resolve } from 'node:path' import { DirectoryPicker, DirectoryPickerError, } from '@deepseek-ai/dsh-host-directory-picker' @@ -81,6 +81,11 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private async list(path?: string): Promise { const home = homedir() + // The seam contract takes absolute paths only; resolve() would silently + // rebase a relative or empty wire value under the host process cwd. + if (path !== undefined && !isAbsolute(path)) { + throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not an absolute path`) + } const target = resolve(path ?? home) let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[] try { @@ -100,6 +105,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } private async createDirectory(path: string, name: string): Promise { + // Same absolute-path fence as list: never rebase a parent under the cwd. + if (!isAbsolute(path)) { + throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not an absolute parent path`) + } const parent = resolve(path) // The backend owns segment validation (the wire schema also refuses these, // but direct service consumers must hit the same fence). diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 3833395c11..608fe89348 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -70,6 +70,19 @@ describe('BrowseDirectoryPicker', () => { expect((failure as DirectoryPickerError).path).toBe(missing) }) + it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => { + for (const relative of ['', 'projects', './projects', '..']) { + const listFailure = await capability.list(relative).catch((error: unknown) => error) + expect(listFailure).toBeInstanceOf(DirectoryPickerError) + expect((listFailure as DirectoryPickerError).code).toBe('directory-unreadable') + expect((listFailure as DirectoryPickerError).path).toBe(relative) + const createFailure = await capability.createDirectory(relative, 'child').catch((error: unknown) => error) + expect(createFailure).toBeInstanceOf(DirectoryPickerError) + expect((createFailure as DirectoryPickerError).code).toBe('directory-create-failed') + expect((createFailure as DirectoryPickerError).path).toBe(relative) + } + }) + it('creates one child directory and surfaces it in the next listing', async () => { const created = await capability.createDirectory(root, 'fresh') expect(created).toBe(join(root, 'fresh')) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5a6040e51f..322dc4551c 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -60,7 +60,8 @@ export interface DirectoryPickerBrowseCapability { * List one directory level. * @param path - absolute directory to list; absent lists the home directory. * @returns the level's listing with ancestry. - * @throws {DirectoryPickerError} `directory-unreadable` when the target cannot be listed. + * @throws {DirectoryPickerError} `directory-unreadable` when the target is not absolute + * (a wire value must never rebase under the host cwd) or cannot be listed. */ list(path?: string): Promise /** @@ -68,7 +69,8 @@ export interface DirectoryPickerBrowseCapability { * @param path - absolute existing parent directory. * @param name - single non-blank path segment (no separators, not `.`/`..`). * @returns the created directory's absolute path. - * @throws {DirectoryPickerError} `directory-exists` for an existing child, `directory-create-failed` otherwise. + * @throws {DirectoryPickerError} `directory-exists` for an existing child, + * `directory-create-failed` for a non-absolute parent or any other failure. */ createDirectory(path: string, name: string): Promise }