diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml new file mode 100644 index 0000000000..c15af141bd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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/architecture/2026-07-28-api-browser-trust-boundary.md +2026-07-28-api-browser-trust-boundary.md: e56d0fc2a7bd551899605491f3a0522b62b961b0 +2026-07-28-api-browser-trust-boundary.zh.md: 2958f7e49bfd4a258c63fc96c2e8aee0f98183ee diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md new file mode 100644 index 0000000000..e56d0fc2a7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -0,0 +1,31 @@ +# Agent Note: One carrier-level browser-trust boundary for the whole /api surface + +Status: implemented + +English | [中文](2026-07-28-api-browser-trust-boundary.zh.md) + +## Problem + +The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--host 0.0.0.0` supported), and the surface includes remote-code-execution-grade methods — `session.prompt` drives an agent that runs bash. A browser turns the operator into a confused deputy against such a local API in two classic ways: a malicious page fires a "simple" cross-site POST (`text/plain` — sent without a CORS preflight) whose side effects execute even though the response stays unreadable, and a DNS-rebound origin talks to the socket as if same-origin, making CORS inapplicable entirely, with only the `Host` header betraying the attacker's domain. Before this decision the system's only browser-trust check (`isTrustedNativeDialogRequest`: loopback socket + same-origin + loopback Host) guarded exactly one cosmetic route — `host.pickDirectory`, whose native dialog pops on the host's screen — while every consequential method was unguarded. Guarding per-RPC also could not survive the upcoming in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse. + +## Decision + +Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs: + +- **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. + +Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover. + +## Alternatives considered + +- **Per-RPC guards (status quo extended).** Rejected: the guard list trails the method list forever, the highest-value methods were already unguarded, and a loopback rule on browse RPCs would break the remote deployments they exist for. +- **CORS headers + credential omission.** Rejected: we never want cross-origin reads at all, so answering preflights only widens the surface; refusing them is strictly stronger and simpler. +- **Auth tokens now.** Rejected for this change: token minting/storage/rotation is real product surface; the fence closes the browser-deputy holes today without pre-deciding the auth design. + +## Consequences + +- Any future `/api` method is covered by construction; there is no per-route trust decision left to forget. +- Non-loopback deployments must have their serving authorities trusted or requests are refused. The dsh CLI keeps its advertised `--host 0.0.0.0` LAN URL working by deriving the machine's LAN IP literals into the connection row (port-less entries — an IP-literal Host cannot be a rebound name, and the bound port may be OS-assigned) and offers `dsh web --trusted-host` for named authorities; compositions the CLI does not boot declare `trustedHosts` themselves. Non-browser automation rides the same fence: loopback, a derived LAN IP, or a declared authority passes; an undeclared DNS alias is refused. +- Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header). +- The trusted-network assumption of an unauthenticated `0.0.0.0` deployment is now documented instead of implicit. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md new file mode 100644 index 0000000000..2958f7e49b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -0,0 +1,31 @@ +# Agent Note:整个 /api 面共用一道载体级浏览器信任边界 + +状态:已实现 + +[English](2026-07-28-api-browser-trust-boundary.md) | 中文 + +## 问题 + +Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent 可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的"混淆代理人":恶意页面发出跨站"简单请求" POST(`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以"同源"身份直连 socket,CORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket + 同源 + 回环 Host)只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正要命的方法都在裸奔。按 RPC 逐个设防也活不过即将到来的应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。 + +## 决策 + +在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: + +- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯的、规范形权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 + +两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 + +## 曾考虑的替代方案 + +- **按 RPC 设防(延续现状)。** 否决:守卫清单永远追着方法清单跑,价值最高的方法本来就没被守住,而 browse RPC 上的回环规则会破坏它们为之存在的远程部署。 +- **CORS 头 + 省略凭据。** 否决:我们根本不想要任何跨源读取,应答预检只会扩大暴露面;拒绝预检严格更强也更简单。 +- **现在就上认证令牌。** 在本变更中否决:令牌的签发/存储/轮换是真实的产品面;栅栏今天就能封死浏览器代理人漏洞,无需预先决定认证设计。 + +## 后果 + +- 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 +- 非回环部署的服务权威必须获得信任,否则请求会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。非浏览器自动化走同一道栅栏:回环地址、推导的 LAN IP 或已声明的权威可通过;未声明的 DNS 别名会被拒绝。 +- 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。 +- 无认证 `0.0.0.0` 部署的"信任网络"假设从隐含变为成文。 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 new file mode 100644 index 0000000000..bb9425fa64 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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/architecture/2026-07-28-directory-picker-capability-seam.md +2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38 +2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464 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 new file mode 100644 index 0000000000..7c8f8cb676 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -0,0 +1,39 @@ +# Agent Note: A capability-discriminated directory-picker seam for the web-GUI host + +Status: implemented + +English | [中文](2026-07-28-directory-picker-capability-seam.zh.md) + +## Problem + +The web GUI's "Open local folder" flow was hardwired to one interaction: `host.pickDirectory` invoked a native OS chooser compiled into `dsh-host-apiproxy` (private module, test-only injection seam). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the planned in-app directory browser (Figma `Harness` 802-56979) needs listing/creation primitives, which are a different interaction contract, not a different implementation of the same one. Swapping interactions required editing gateway source, against the repo's everything-is-a-plugin stance. + +## Decision + +A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. + +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. + +Placement and policy rulings folded into this decision: + +- **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. +- **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. +- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. +- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. +- **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. +- **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. + +## Alternatives considered + +- **Extend `ctx.fs` with browse methods.** Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam. +- **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. +- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. +- **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. + +## Consequences + +- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together; `-native` remains the host-display alternative. +- The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests. +- A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. +- `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 new file mode 100644 index 0000000000..05545fc3cd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -0,0 +1,39 @@ +# Agent Note:web GUI 宿主的能力可辨识目录选择 seam + +状态:已实现 + +[English](2026-07-28-directory-picker-capability-seam.md) | 中文 + +## 问题 + +web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pickDirectory` 调用编译进 `dsh-host-apiproxy` 的原生 OS 选择器(私有模块,仅测试注入缝)。这个形态服务不了远程部署——没有任何 OS 对话框能弹到另一台机器的浏览器里——而计划中的应用内目录浏览器(Figma `Harness` 802-56979)需要列举/创建原语,那是**另一种交互契约**,不是同一契约的另一种实现。想换交互只能改网关源码,违背仓库"一切皆插件"的立场。 + +## 决策 + +在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 + +**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**:browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 + +并入本决策的位置与策略裁决: + +- **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 +- **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 +- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 +- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 +- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 +- **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 + +## 曾考虑的替代方案 + +- **给 `ctx.fs` 增加浏览方法。** 否决:上述权限域耦合;且面向展示的列举契约(hidden 标志、面包屑、home 锚点)不属于存储 seam。 +- **统一方法集的 seam(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。 +- **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。 +- **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 + +## 后果 + +- `cordis.yml` 决定交互形态;`apps/cli` 挂 `-browse`(随附默认——开箱即得可远程的选取),一行同时切换了后端与 UI;`-native` 仍是宿主屏幕方案。 +- 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。 +- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。 +- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml new file mode 100644 index 0000000000..4d686731fe --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.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 +2026-07-23-web-permission-and-approval.md: cd402a039e55e7a24a038055dab5793aa0d08438 +2026-07-23-web-permission-and-approval.zh.md: ce4964789bc94a0962796bb2f5fbf1a94e8f5145 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md new file mode 100644 index 0000000000..cd402a039e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md @@ -0,0 +1,33 @@ +# Agent Note: Web UI permission presets and approval answering + +Status: implemented + +English | [中文](2026-07-23-web-permission-and-approval.zh.md) + +## Problem + +The web host booted an unconfined agent: `bootHost` composed `dsh-bash-local` and `dsh-fs-local`, so every web session ran with full file access, no approval channel, and no permission control — while the ACP composition had shipped the complete sandboxed product path (sandbox provider + policy home + confined bash/fs + approval + presets) for months. The web wire contract had already reserved the seats — `approval/requested`/`approval/resolved` mux frames, `POST /api/respond` with `ApprovalResponsePayload`, client-side `pendingBuffers` — but the host `respond` was a stub, no answerer bridged `ctx.approval` to the stream, no RPC exposed the permission select, and the PendingCard rendered approvals as visible-but-unanswerable. + +## Decision + +The web host composes the same sandboxed product path as the acp-agent composition: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission`, with `BootHostOptions.sandbox` supplying the deployment defaults (`mode`, default `workspace-write`; `approvalPolicy`, default `ask`). + +`createApiProxy` owns the approval pending registry. Its `approval/request` waterfall answerer reads the approval id from the session's just-appended `approval/asked` audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable `approval/requested` frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. `respond` routes by the echoed rpcId, validates `ApprovalResponsePayload` with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts `approval/resolved`; the ask's abort signal withdraws the question as `cancelled`. + +The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. + +Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. + +## Alternatives considered + +**Reuse the ACP `session/set_config_option` shape on the web wire.** Rejected: the web contract's unary method registry (`RpcMethodMap` + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature. + +**A session event for pending approvals instead of a proxy-side registry.** Rejected: approval requests are transient interaction state, not durable session data — the `approval/asked`/`decided` audit pair already logs the durable half. Persisting requested frames would re-ask dead questions on replay. + +**Registering the answerer only when a mux subscriber exists.** Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window. + +**Optimistic card removal on click.** Rejected: the broadcast resolved frame is the truth; removing on click would hide a question that a rejected receipt or transport failure left standing. The panel disables its buttons locally and re-arms them on failure instead. + +## Consequences + +Web sessions now start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering shipped separately through the same registry pattern (ui-question over the question pending table). The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, and the keyless web smoke exercises the fixture-mode approval answer and preset switch in a real browser. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md new file mode 100644 index 0000000000..ce4964789b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web UI 权限预设与审批应答 + +Status: implemented + +[English](2026-07-23-web-permission-and-approval.md) | 中文 + +## 问题 + +Web 承载层启动的是一个不受限的 agent(智能体):`bootHost` 组合了 `dsh-bash-local` 与 `dsh-fs-local`,因此每个 Web 会话都以完整文件访问权限运行,既无审批通道,也无权限管控——而 ACP 组合早在数月前就已交付完整的沙箱化产品路径(沙箱提供方 + 策略归属 + 受限的 bash/fs + 审批 + 预设)。Web 协议契约其实早已预留了对应位置——`approval/requested`/`approval/resolved` 的 mux 帧、携带 `ApprovalResponsePayload` 的 `POST /api/respond`、client 侧的 `pendingBuffers`——但 host 的 `respond` 只是一个 stub,没有应答者把 `ctx.approval` 桥接到流上,没有 RPC 暴露权限选择,PendingCard 把审批渲染成可见却无法应答的样子。 + +## 决策 + +Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 与 `dsh-permission`,由 `BootHostOptions.sandbox` 提供部署默认值(`mode`,默认 `workspace-write`;`approvalPolicy`,默认 `ask`)。 + +`createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall(瀑布式事件)应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是契约早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 `approval/resolved`;ask 的中断信号会以 `cancelled` 撤回该问题。 + +权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 + +在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。问题占位符仍留在消息流中。侧边栏用一枚琥珀色警示圆点同步呈现这一阻塞状态,且其优先级高于表示运行中的圆环:manager 跟踪每个会话尚未解决的 approvalId(对 mux 打开时的回放幂等,并按连接代次清除,以保证重开后的回放才是权威依据),而非读取 Session 实例,因此从未实例化过的会话也能点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 + +## 曾考虑的替代方案 + +**在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳:Web 契约的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。 + +**用一个会话事件承载 pending 审批,而非 proxy 侧注册表。** 不予采纳:审批请求是瞬态的交互状态,而非持久的会话数据——`approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 + +**仅在存在 mux 订阅者时才注册应答者。** 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。 + +**点击即乐观移除卡片。** 不予采纳:广播的 resolved 帧才是真相;点击即移除会隐藏一个因拒绝回执或传输失败而仍然悬置的问题。面板改为在本地禁用其按钮,并在失败时重新启用。 + +## 后果 + +Web 会话现在从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答已通过同一注册表模式单独交付(ui-question 基于问题 pending 表)。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖情况:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件,以及无密钥 Web 冒烟测试在真实浏览器中演练 fixture 模式的审批应答与预设切换。 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index 1800464cd2..e0fc203ce6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.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/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: ac0c2624d4980f33a01d67cf699259b5913cb274 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 8ec7d8a3d93a51e4a4d315caa067a649ec483120 +2026-07-29-web-message-icon-actions-and-clock.md: e0072458e4c0d3e37998b5564ad14ce17aa41515 +2026-07-29-web-message-icon-actions-and-clock.zh.md: 1cc25a9656e7a100d78dd3b6b3675ca490f455f9 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md index ac0c2624d4..e0072458e4 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -6,13 +6,13 @@ English | [中文](2026-07-29-web-message-icon-actions-and-clock.zh.md) ## Problem -The web chat user bubble already had copy / branch / edit IconActions but no clock. Finalized assistant narration had no under-body action chrome at all, even though the Harness design shows a copy / branch / clock row after the answer settles. Streaming replies must not flash that chrome mid-token. +The web chat user bubble already had copy / branch / edit IconActions but no clock. Finalized assistant narration had no under-body action chrome at all, even though the Harness design shows a copy / branch / clock row after the answer settles. Streaming replies must not flash that chrome mid-token. Memoized rows also keep stable props across midnight, so a one-shot `Date.now()` would leave yesterday's messages stuck on `HH:mm`. ## Decision -**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`.** +**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`; both seats re-format at the next local midnight.** -Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helper live in `message-chrome.ts`. +Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. ## Alternatives considered @@ -20,6 +20,8 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day **Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat. +**Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source. + ## Consequences -Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules. Per-message paging remains a deferred footer seat in the package README. Tests pin the three clock shapes, assistant footer presence only when not streaming, and copy payload (text blocks only). +Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes and the midnight widen; the web e2e scenario pins the assembled IconActions chrome. diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 8ec7d8a3d9..1cc25a9656 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 Harness 设计稿在回答结束后展示复制/分支/时钟。流式回复不得在 token 中途闪出该栏。 +Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 Harness 设计稿在回答结束后展示复制/分支/时钟。流式回复不得在 token 中途闪出该栏。经 memo 的行在跨午夜时仍保持稳定 props,因此一次性的 `Date.now()` 会让昨日消息一直卡在 `HH:mm`。 ## 决策 -**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟。** +**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边都在下一个本地午夜重新格式化。** -两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。 +两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 ## 曾考虑的方案 @@ -20,6 +20,8 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 **把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。 +**通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。 + ## 后果 -已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则。逐消息分页仍是包 README 中的暂缓 footer 座位。测试钉住三种时钟形态、仅在非流式时出现 assistant footer,以及复制载荷(仅 text 块)。 +已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态与午夜加宽;Web e2e 场景钉住组装后的 IconActions chrome。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index e9e5df7630..106c2f2c2a 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 13a80b1d0e0105bc0c30c019209b2e0295b7bef9 -README.zh.md: 2a5d9c15c57351ef03ebe60a5cdf90f0d0c8f18b +README.md: 6b3a31a30a941e67341a518a7e32b1bf99eee8b0 +README.zh.md: 133c721612012b8fd1327344ae4e2951d05047cb diff --git a/apps/cli/README.md b/apps/cli/README.md index 13a80b1d0e..6b3a31a30a 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: @@ -16,6 +16,8 @@ The TUI surface: The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. + `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). ## Install (developer machine) diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 2a5d9c15c5..133c721612 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -4,7 +4,7 @@ `dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: @@ -16,6 +16,8 @@ TUI 界面: Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 + `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 ## 安装(开发机) diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b17bc614a8..ed44f75c7f 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -86,6 +86,19 @@ apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL +# Common pi-ai provider routes read credentials and endpoint overrides from the +# boot's layered environment. +- id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + - provider: openai + apiKey: !!js process.env.OPENAI_API_KEY + baseURL: !!js process.env.OPENAI_BASE_URL + - provider: anthropic + apiKey: !!js process.env.ANTHROPIC_API_KEY + baseURL: !!js process.env.ANTHROPIC_BASE_URL + # Transient-failure recovery around the loop's model calls (same policy as # the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff). - id: llm-retry @@ -130,8 +143,45 @@ - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' -- id: bash-local - name: '@deepseek-ai/dsh-bash-local' +# The sandboxed product path (the acp-agent composition): per-platform +# runner provider, the shared policy home, the confined bash executor, and +# the approval seam its escalation asks through. The web deployment default +# is danger-full-access + never (same behavior as the former bash-local +# rows); DSH_PERMISSION_MODE opts a process into a confined default, and +# per-session switches ride the /permission command's knob events. +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' + workspaceRoot: !!js process.cwd() + +- id: bash-sandbox + name: '@deepseek-ai/dsh-bash-sandbox' + +- id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'" + +# Presets over the two knobs (requires the confining executor + approval): +# the web permission chip's table, served through the permissions projection +# and switched through /permission. +- id: permission + name: '@deepseek-ai/dsh-permission' + config: + presets: + read-only: + sandbox: read-only + approval: ask + workspace-write: + sandbox: workspace-write + approval: ask + danger-full-access: + sandbox: danger-full-access + approval: never - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' @@ -143,9 +193,11 @@ name: '@deepseek-ai/dsh-tool-tasks' # fs cwd stays the package default (process.cwd()) — the same value the -# gateway injects into session.cwd, so paths and sessions agree. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' +# gateway injects into session.cwd, so paths and sessions agree. The +# sandboxed backend rides the SAME policy as bash: write/edit fence by the +# effective mode, so read/write/edit stay available under every mode. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' - id: fs-policy name: '@deepseek-ai/dsh-fs-policy' @@ -262,6 +314,13 @@ # The API gateway: the transport-agnostic dispatch face every client shape # shares. provider/model are the host default routing — the profile json's # mapping target (user config overrides these engineering defaults). +# Directory-picking package, dual-face: the node half serves the gateway's +# host.* picker RPCs, the browser half fills ui-workspace's directory-flow +# slots — one row composes the whole interaction. Swap point: mount +# '-native' instead for the host-display OS chooser. +- id: directory-picker + name: '@deepseek-ai/dsh-host-directory-picker-browse' + - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' config: @@ -346,6 +405,10 @@ - id: ui-model name: '@deepseek-ai/dsh-client-ui-model' +# The /permission popup picker (hostBacked over the host /permission command). +- id: ui-permission + name: '@deepseek-ai/dsh-client-ui-permission' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' diff --git a/apps/cli/package.json b/apps/cli/package.json index e31e7378a0..0914aaf5aa 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -20,7 +20,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -48,17 +49,23 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", @@ -90,6 +97,7 @@ "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 344c66e2b3..668d8f7f02 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' +import { networkInterfaces } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from 'cordis' @@ -25,6 +26,41 @@ import type {} from '@deepseek-ai/dsh-host-webserver' const PROFILE_DIR = '.dsh-tmp-profile' const PROFILE_FILE = 'config.json' +/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */ +const ALL_INTERFACES_HOST = '0.0.0.0' + +/** + * Non-internal IPv4 interface addresses of this machine — the IP-literal + * authorities an all-interfaces bind is reachable by on the LAN. + * @returns the addresses in interface order (possibly empty). + */ +function lanIPv4Addresses(): string[] { + return Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) +} + +/** + * One LAN-trust resolution for one invocation, sampled exactly once: the + * machine's LAN IP literals when the effective bind is all-interfaces, and + * the `trustedHosts` value built from them plus the explicit extras. The + * single sample is deliberate — display must advertise only addresses the + * fence was configured with, so both read this snapshot. Derived entries are + * port-less IP literals: DNS rebinding needs an attacker-controlled name, so + * an IP-literal Host is safe on any port, and the bound port may be + * OS-assigned, unknowable pre-boot. + * @param bindHost - the effective webserver bind host (CLI flag, else the yml default). + * @param extra - `--trusted-host` values, in argv order. + * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). + */ +export function resolveLanTrust( + bindHost: string | undefined, + extra: readonly string[], +): { lanAddresses: string[]; trustedHosts: string[] } { + const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } +} + /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string @@ -79,6 +115,8 @@ export interface AppCLIEntryOptions { port?: number /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ workspaceRoot?: string + /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */ + trustedHosts?: string[] } /** @@ -91,6 +129,14 @@ export class AppCLIEntry { /** The root context, set by {@link run}. */ ctx!: Context + /** + * LAN IPv4 addresses sampled once at patch composition — the exact snapshot + * the /api trust fence was configured with. Display reads this instead of + * re-sampling, so the advertised LAN URL can never name an address the + * fence rejects. Empty unless the effective bind is all-interfaces. + */ + lanAddresses: readonly string[] = [] + private patches: PatchOptions[] = [] constructor(private readonly options: AppCLIEntryOptions) {} @@ -152,6 +198,13 @@ export class AppCLIEntry { if (this.options.port !== undefined) put('webserver', 'port', this.options.port) if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) + // Source 2b: authorities for the /api browser-trust fence (rationale on + // resolveLanTrust). + const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host + const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) + this.lanAddresses = lanAddresses + if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts) + // Source 3: the frontend dist — an assembly fact of this app, never yml // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 9fd0f4d9bf..b929dc73f2 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -40,6 +40,8 @@ interface WebInvocation { port?: number dev: boolean workspaceRoot?: string + /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */ + trustedHosts?: string[] } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ @@ -51,6 +53,7 @@ interface WebOptions { port?: string dev?: boolean workspaceRoot?: string + trustedHost?: string[] } /** @@ -66,6 +69,7 @@ function resolveWeb(options: WebOptions): WebInvocation { ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, + ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } } @@ -117,6 +121,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') .option('--workspace-root ', 'parent directory for name-created workspaces') + .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { // Commander parses the parent (default-surface) options on either side of // the subcommand into `program.opts()`. `web` shares none of them, so a diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index f9e1eefc9b..88dbece55a 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) break } case 'headless': { diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 31282c8f5f..69e79ab5d9 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -6,17 +6,14 @@ * gates them at boot. */ -import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import { AppCLIEntry } from './app-cli-entry.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// Display-only mirrors of the webserver schema's allowed hosts: the loopback -// address the local URL always prints, and the all-interfaces value that gates -// LAN-address discovery. Not a source of truth — the schema is. +// Display-only mirror of the webserver schema's loopback host: the address the +// local URL always prints. Not a source of truth — the schema is. const LOOPBACK_HOST = '127.0.0.1' -const ALL_INTERFACES_HOST = '0.0.0.0' /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed @@ -25,12 +22,14 @@ const ALL_INTERFACES_HOST = '0.0.0.0' * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. + * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. */ export async function runWeb( host: string | undefined, port: number | undefined, dev: boolean, workspaceRoot: string | undefined, + trustedHosts: string[] | undefined, ): Promise { const entry = new AppCLIEntry({ configPath: CONFIG_PATH, @@ -38,6 +37,7 @@ export async function runWeb( ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, + ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() @@ -48,12 +48,11 @@ export async function runWeb( void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lanCandidate = host === ALL_INTERFACES_HOST - ? Object.values(networkInterfaces()).flat() - .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - : undefined + // The entry's boot-time snapshot, not a fresh sample: the printed LAN URL + // must name an address the /api trust fence was configured with. + const lanCandidate = entry.lanAddresses[0] const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` - console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`) + console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`) process.on('SIGTERM', () => { shutdown(0) }) process.on('SIGINT', () => { shutdown(130) }) diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 052e96e9a0..45830eee30 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -35,6 +35,9 @@ describe('parseDshArgs', () => { // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + // --trusted-host is variadic and repeatable; authorities pass through unvalidated. + expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) + .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) it('exits nonzero instead of silently starting fresh or dropping inputs', () => { diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts new file mode 100644 index 0000000000..571a9f76b7 --- /dev/null +++ b/apps/cli/tests/trusted-hosts.spec.ts @@ -0,0 +1,33 @@ +/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ + +import { describe, expect, it, vi } from 'vitest' +import { resolveLanTrust } from '../src/app-cli-entry.ts' + +vi.mock('node:os', () => ({ + networkInterfaces: () => ({ + lo0: [ + { family: 'IPv4', internal: true, address: '127.0.0.1' }, + ], + en0: [ + { family: 'IPv6', internal: false, address: 'fe80::1' }, + { family: 'IPv4', internal: false, address: '192.168.1.5' }, + ], + en1: [ + { family: 'IPv4', internal: false, address: '10.0.0.7' }, + ], + utun0: undefined, + }), +})) + +describe('resolveLanTrust', () => { + it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { + const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) + expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) + expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) + }) + + it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) + expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) + }) +}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index b54a2a8bbb..ce43c0f31c 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../../packages/client/ui-models" }, + { + "path": "../../packages/client/ui-permission" + }, { "path": "../../packages/client/locale" }, diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts new file mode 100644 index 0000000000..a453d53cbc --- /dev/null +++ b/apps/web/tests/message-actions.e2e.ts @@ -0,0 +1,93 @@ +// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history +// fixture (zero model calls) and pins the settled conversation aria after the +// user/assistant footers are focus-revealed — the surface package jsdom tests +// cannot substitute for (docs/testing.md snapshot rule). +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url)) +// Borrowed read-only: this scenario needs any settled user+assistant pair, not +// a new recording (workspace-management / sidebar-scrollbar pattern). +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'message-actions-web-e2e' + +const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' + +describe('web e2e: message IconActions and clocks on settled history', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const sessionCwd = join(scaffold.workspaceCwd, 'workspace') + await mkdir(sessionCwd, { recursive: true }) + await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') + await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') + const raw = await readFile(SEED, 'utf8') + expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + await seedSession(scaffold, raw, SEED_ID) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + // Focus-reveal the footers (hover:hover keeps them opacity-hidden until + // hover/focus-within). User has three actions; each finalized assistant + // text node has copy + branch. + const copyButtons = page.getByRole('button', { name: '复制' }) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + await copyButtons.first().focus() + await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 }) + .toBeGreaterThanOrEqual(2) + await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1) + }, 60_000) + + it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) + await page.getByRole('button', { + name: '选择模型,当前 deepseek-v4-flash', + }).waitFor({ timeout: 10_000 }) + // Keep a footer focused so opacity-hidden actions stay in the a11y tree + // as an active/focused control during the capture. + await page.getByRole('button', { name: '复制' }).first().focus() + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f722ead61b..753cdc2953 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -389,6 +389,11 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { .split(base).join('{{workspace}}') .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') .replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}') + // Message IconActions clocks widen by calendar day/year; collapse every + // shape so goldens stay stable across midnight and year boundaries. + .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') + .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') + .replace(/(? { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) }) const revised = titleSurfaces(revisedLabel) + // fx-alpha carries the fixture's resident answerable approval, so the + // approval panel has taken over the composer (the real takeover behavior); + // answer it to restore the composer chrome before asserting the model seat. + fireEvent.click(await screen.findByRole('button', { name: '允许一次' })) const modelTrigger = await screen.findByRole('button', { name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High', }) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index ea364a44db..4847924619 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -1,21 +1,30 @@ - banner: - navigation "Session hierarchy": - 'button "Using ONE run_code program: run" [disabled]' - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop." +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - 'button "Think The user wants me to write a single `run_code` program that:"': + - img - img - text: "Think The user wants me to write a single `run_code` program that:" - button: - img -- text: Code Run bash echo and catch missing file read Echo CODE_ROUND_OK -- button -- text: Read missing.txt + - img +- text: Code Run bash echo and catch missing file read +- img +- text: Bash Echo CODE_ROUND_OK Read +- button "missing.txt" - button "Think The program ran successfully. Let me now reply DONE as instructed.": + - img - img - text: Think The program ran successfully. Let me now reply DONE as instructed. - paragraph: DONE @@ -23,10 +32,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e2a1275f6c..e5e5626be3 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -1,7 +1,6 @@ - banner: - navigation "Session hierarchy": - button "Use only Cordis tools. First" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -13,14 +12,16 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to:": + - img - img - text: "Think The user wants me to:" - button: - img + - img - text: Inspect temporary - 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."': + - img - img - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code." - button [expanded]: @@ -29,12 +30,15 @@ - button "复制" - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."': + - img - img - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id." - button: - img + - img - text: Unmount temporary Plugin dyn-1 - button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.": + - img - img - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop. - paragraph: CORDIS_UI_DONE @@ -42,7 +46,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index dc572023fd..6a827420c4 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -1,17 +1,25 @@ - banner: - navigation "Session hierarchy": - button "Use the bash tool to" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop." +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": + - img - img - text: Think The user wants me to run a simple bash command and reply with "DONE". -- text: Echo the test string +- img +- text: Bash Echo the test string - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": + - img - img - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". - paragraph: DONE @@ -19,10 +27,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 2f958fa552..ee6c1a7475 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -1,3 +1,4 @@ +- button "New session" - button "Collapse sidebar": - img - button "New session": @@ -27,11 +28,13 @@ - textbox "Describe what you want to build" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] - text: 详情 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 056bd71144..33d1f7e6bf 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -1,13 +1,19 @@ - banner: - navigation "Session hierarchy": - button "Reply with the single word" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Reply with the single word LIGHTHOUSE and stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to reply with a single word. Let me comply.": + - img - img - text: Think The user wants me to reply with a single word. Let me comply. - paragraph: LIGHTHOUSE @@ -15,10 +21,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 972062a3c5..3d092b17ec 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -1,21 +1,28 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - paragraph: partial - text: 已停止 0 tokens · 1 turns · 1 steps - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 64bdb9c39b..5272bcf2d1 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -1,19 +1,26 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 9d41e87f41..5935872557 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -1,13 +1,19 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": + - img - img - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. @@ -15,10 +21,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md new file mode 100644 index 0000000000..c8c61bae5e --- /dev/null +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -0,0 +1,52 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the read tool twice" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - tab "Waterfall" +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" +- button "复制": + - img +- tooltip "复制" +- button "在新对话中分支": + - img +- button "编辑": + - img +- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": + - img + - img + - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} +- img +- text: Read +- button "a.txt" +- img +- text: Read +- button "b.txt" +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": + - img + - img + - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- paragraph: DONE +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- textbox "Message the agent" +- button "Add attachment": + - img +- text: Danger Full Access +- combobox "Access mode": + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 94179416a4..91ff2cdf88 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -1,7 +1,6 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -14,12 +13,15 @@ - button "编辑": - img - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": + - img - img - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. - button: - img + - img - text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}" - button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.": + - img - img - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. - paragraph: DONE @@ -27,10 +29,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index a26bbb7bd8..8d33ea6283 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -1,19 +1,27 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": + - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. -- button +- button: + - img + - img - text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)" - button "▸ 问题内容" -- text: 请在原客户端处理(web 端作答后续里程碑提供) cache hit 98% · 7,946 tokens · 1 turns · 1 steps +- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index a5735a09d5..f08fc518e8 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -1,19 +1,27 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": + - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. - button: - img + - img - text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply." - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": + - img - img - text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer. - paragraph: Great, let's move forward. BANANA! @@ -21,10 +29,12 @@ - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md new file mode 100644 index 0000000000..7fbcd35d75 --- /dev/null +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -0,0 +1,23 @@ +- dialog "选择工作区目录": + - heading "选择工作区目录" [level=2] + - navigation: + - button "主目录" + - img + - button "browse-golden" + - button "编辑路径" + - list: + - listitem: + - button "alpha": + - img + - text: alpha + - img + - listitem: + - button "beta": + - img + - text: beta + - img + - button "新建文件夹": + - img + - text: 新建文件夹 + - button "取消" + - button "打开" diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index b33df1b157..86a20e73fe 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -37,6 +37,15 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ ], }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, + // Dual-face host package: its browser half fills the directory-flow holes + // (the same composition row apps/cli mounts for the node-side backend). + { + id: '@deepseek-ai/dsh-host-directory-picker-browse', + dir: '../host/directory-picker-browse', + url: '/plugins/directory-picker-browse.js', + rev: 'fx', + inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'], + }, ] const bundles = new Map(PLUGINS.map(plugin => [ @@ -174,6 +183,37 @@ it('locks the composer in the New Session view state until a Workspace is chosen `) }) +it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => { + boot('?fixture=empty') + + await findLockedComposer() + fireEvent.click(workspaceChip()) + const menu = await screen.findByRole('menu') + // The composed flow package occupies the directory-flow hole, so the + // picking affordance is present (no advertised-kind read exists anymore). + expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item))) + .toEqual(['Open local folder…', 'Create a new workspace']) + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' })) + // The browse occupant renders the Select Workspace Directory dialog at the + // fixture home; select Documents, advance into project, and adopt it. + const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 }) + // Row targeting goes through the visible label text: listitem accessible-name + // computation differs across dom-accessibility-api environments, while the + // row's name span is stable (clicks bubble to the row button). + fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 })) + fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 })) + // Open disables while the selection's child listing is in flight; wait for + // the enabled state or the click lands on a dead button on slow runners. + await waitFor(() => { + expect(within(dialog).getByRole('button', { name: '打开' }).disabled).toBe(false) + }, { timeout: 10_000 }) + fireEvent.click(within(dialog).getByRole('button', { name: '打开' })) + await findHeroComposer() + await waitFor(() => { + expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project') + }) +}) + it('selects the recent Workspace and opens its blank Session on first load', async () => { boot('?fixture') diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index fd18d89087..ff9d458280 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -13,8 +13,8 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import { - acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, - webSnapshotMode, type WebScaffold, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { saveFailureShot } from './support.ts' @@ -23,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', i // spec needs any one cold session row, not new recorded content. const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const MODE = webSnapshotMode() +const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' describe('web e2e: workspace management (create / rename / flat view / hover card)', () => { @@ -30,14 +31,40 @@ describe('web e2e: workspace management (create / rename / flat view / hover car let browser: Browser let page: Page let tripwire: ReturnType - let pickedDirectory: string | null = null + + /** + * Drive the in-app browser to a directory via its path-edit affordance, + * confirm it, and wait for the adoption to settle host-side (workspace + * registered + the flow's New-Session agent up), so later test steps can't + * race the in-flight blank-session attach. + */ + async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise { + const agentsBefore = scaffold.ctx.agents.list().length + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '编辑路径' }).click() + await dialog.getByLabel('编辑路径').fill(path) + await dialog.getByLabel('编辑路径').press('Enter') + await dialog.getByRole('button', { name: '打开' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(path), + { timeout: 10_000 }, + ).not.toBeUndefined() + // First adoption births a blank Session+Agent whose workspace attach must + // settle before a test may delete the registration; the reuse path (same + // canonical cwd already has a blank session) creates no agent, so callers + // opt in only where a fresh attach is possible. + if (options.waitForAgent === true) { + await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 }) + .toBeGreaterThan(agentsBefore) + } + } beforeAll(async () => { scaffold = await launchWebScaffold({}) - scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({ - rpcId: request.rpcId, - result: { ok: true, value: { path: pickedDirectory } }, - }) // Seed one cold session (Ungrouped bucket) for the flat view + hover card. const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) @@ -137,14 +164,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) // Register the scaffold's existing project directory through the real UI. - pickedDirectory = scaffold.workspaceCwd - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() - - await expect.poll( - () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), - { timeout: 10_000 }, - ).not.toBeUndefined() + await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true }) const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) if (workspace === undefined) throw new Error('GUI did not register the existing project directory') await workspace.attachSession(SessionId(SEED_ID)) @@ -200,9 +220,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car // Re-registering the exact deleted path immediately, without a reload, is // a supported reversible flow. It creates a fresh Workspace id without // re-adopting the retained Session. - pickedDirectory = scaffold.workspaceCwd - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await openLocalFolder(scaffold.workspaceCwd) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), { timeout: 10_000 }, @@ -272,9 +290,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) - pickedDirectory = oldPath - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await openLocalFolder(oldPath) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(oldPath), { timeout: 10_000 }, @@ -330,6 +346,42 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('matches the directory-browser dialog aria golden at a staged directory', async () => { + // A staged subtree under the scaffold cwd keeps the listing deterministic + // (normalizeAria scrubs the cwd), and pointing the in-process host's HOME + // at the cwd collapses the breadcrumb ancestry into the Home crumb — no + // machine-specific path segments or real $HOME contents enter the golden. + const staged = join(scaffold.workspaceCwd, 'browse-golden') + await mkdir(join(staged, 'alpha'), { recursive: true }) + await mkdir(join(staged, 'beta'), { recursive: true }) + // homedir() reads HOME on POSIX and USERPROFILE on Windows: root both + // at the scaffold cwd so the golden's ancestry collapses everywhere. + const realHome = process.env.HOME + const realUserProfile = process.env.USERPROFILE + process.env.HOME = scaffold.workspaceCwd + process.env.USERPROFILE = scaffold.workspaceCwd + try { + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '编辑路径' }).click() + await dialog.getByLabel('编辑路径').fill(staged) + await dialog.getByLabel('编辑路径').press('Enter') + await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE) + await dialog.getByRole('button', { name: '取消' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + } finally { + if (realHome === undefined) delete process.env.HOME + else process.env.HOME = realHome + if (realUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = realUserProfile + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('shows the session hover card after a dwell on the row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) // Expand Ungrouped to reveal the seeded session row, then dwell on it @@ -361,8 +413,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover car it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) - // This spec mints no fixture directory contents of its own; the seed it - // reuses is owned (and inventory-guarded) by seeded-history. - await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep']) + // The directory-browser aria golden is this spec's one owned artifact; + // the seed it reuses is owned (and inventory-guarded) by seeded-history. + await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md']) }) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1b0f807d5f..9952014dad 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -34,7 +34,8 @@ "tests/seeded-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", "tests/code-mode-round.e2e.ts", - "tests/cordis-tool-round.e2e.ts" + "tests/cordis-tool-round.e2e.ts", + "tests/message-actions.e2e.ts" ], "references": [ { diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e56a3f91bb..c296e10fb1 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 054985ac5ea32a44b9daca3c1abfd58dcdc5d897 -architecture.zh.md: 84876faf2ae27069ba8bd026bcfbc56e32f65574 +architecture.md: 2ae982eba49b6dbd2365496915f9917071167813 +architecture.zh.md: abaef961504ff64dbcd1e8e8ba9bd002406fa7f4 diff --git a/docs/architecture.md b/docs/architecture.md index 054985ac5e..2ae982eba4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,27 +25,28 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | ctx key | Package family | Role | |---|---|---| -| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | -| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request and surface pressure | +| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry, streaming model calls | +| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware request and surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | -| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for bash, LSP, and ACP subagent backends | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement through argv wrapping and per-call policy | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | | `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | semantic navigation registry | -| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure | +| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | -| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction and optional model-free result pruning | +| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | -| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry and generic `task_*` controls | +| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace interface, SQLite FTS backend, workspace-authorized model tools | -| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks and one optional asynchronous provider | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools | +| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks, one optional asynchronous provider | +| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 84876faf2a..abaef96150 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,7 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力与表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | -| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash、LSP 与 ACP subagent 后端使用的受管子进程树 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 通过 argv 包装和逐调用策略限制同一执行环境内的进程 | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | @@ -44,8 +44,9 @@ | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端、经工作区授权的模型工具 | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 | +| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 8f75b634b5..43f5b20716 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -141,6 +141,10 @@ flowchart LR svc_spillStore["ctx.spillStore
Spill storage seam"] pkg_spill_local["spill-local"] pkg_spill_policy["spill-policy"] + pkg_directory_picker["directory-picker"] + svc_directoryPicker["ctx.directoryPicker
Workspace-directory picking seam"] + pkg_directory_picker_native["directory-picker-native"] + pkg_directory_picker_browse["directory-picker-browse"] pkg_webserver["webserver"] svc_httpServer["ctx.httpServer
HTTP route registration"] pkg_connection["connection"] @@ -164,6 +168,9 @@ flowchart LR pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune + pkg_directory_picker --> svc_directoryPicker + pkg_directory_picker_browse --> svc_directoryPicker + pkg_directory_picker_native --> svc_directoryPicker pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs @@ -242,6 +249,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui svc_compact --> pkg_compact_basic + svc_directoryPicker --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_httpServer --> pkg_connection svc_httpServer --> pkg_hmr @@ -361,6 +369,7 @@ flowchart LR | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | +| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | | `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f7ff742322..3c0543e4ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -270,6 +270,27 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts) +## `@deepseek-ai/dsh-client-connection` + +Requires: `httpServer` · `apiProxy` + +```ts config-catalog +/** Plugin config: the deployment's non-loopback serving authorities. */ +export interface ConnectionConfig { + /** + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by (the dsh CLI derives the machine's LAN IP literals itself). An entry + * that is not a bare, canonical authority fails the plugin load. + */ + trustedHosts?: string[] +} +``` + +Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) + ## `@deepseek-ai/dsh-client-hmr` Requires: `clientModuleHost` · `httpServer` @@ -486,7 +507,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agents` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace` +Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace` ```ts config-catalog /** Gateway plugin config: host-level agent routing and Workspace creation root. */ @@ -502,6 +523,18 @@ export interface Config { Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +## `@deepseek-ai/dsh-host-directory-picker-browse` + +```ts config-catalog +/** Validated plugin configuration. */ +export interface Config { + /** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */ + maxEntries: number +} +``` + +Source: [`packages/host/directory-picker-browse/src/index.ts:181`](../packages/host/directory-picker-browse/src/index.ts) + ## `@deepseek-ai/dsh-host-webserver` ```ts config-catalog @@ -832,7 +865,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/ui/permission/src/index.ts:83`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:130`](../packages/ui/permission/src/index.ts) ## `@deepseek-ai/dsh-plan-mode` @@ -2166,7 +2199,6 @@ Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) @@ -2176,6 +2208,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) @@ -2191,6 +2224,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) @@ -2215,6 +2249,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) @@ -2242,6 +2277,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) +- `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0d80e4be57..db31daaf06 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -488,6 +488,20 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) +## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam) + +Abstract directory-picking service. Subclass, implement `capability()`, and load the subclass as a plugin — it registers as `ctx.directoryPicker` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). The capability object must be stable for the service lifetime: consumers may capture it across calls. + +```ts cordis-catalog +/** + * The backend's interaction capability. + * @returns the discriminated capability consumers switch on. + */ +abstract capability(): DirectoryPickerCapability +``` + +Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) + ## `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. @@ -822,6 +836,14 @@ Owns the deployment's permission presets and their write path. Requires a confin */ current(events: readonly SessionEvent[]): string +/** + * Build the whole select value for one folded knob state: every table + * option in declaration order, `custom` appended exactly while derived. + * @param state - the folded knob overrides. + * @returns the `permissions` projection payload. + */ +selectFor(state: KnobState): PermissionSelect + /** * Resolve a preset's knob bundle. * @param name - the preset name to resolve. @@ -850,7 +872,7 @@ set(session: Session, name: string): void Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:144`](../../packages/ui/permission/src/index.ts) ## `ctx.planMode` — `PlanModeService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9d66b52bfb..71238305e5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -23,7 +23,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | +| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -63,8 +63,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `commands/changed` | `runtime` (`emit`) | - | -| `connection/reset` | `runtime` (`emit`) | - | +| `commands/changed` | `runtime` (`emit`) | `ui-command` | +| `connection/reset` | `runtime` (`emit`) | `ui-command` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index b0f1111826..e83228b00d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -9,6 +9,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri flowchart TD subgraph group_util["packages/util"] pkg_brand["brand"] + pkg_native_command["native-command"] pkg_paths["paths"] pkg_retention["retention"] pkg_timeout["timeout"] @@ -148,6 +149,7 @@ flowchart TD pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] pkg_client_ui_models["client-ui-models"] + pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] @@ -185,6 +187,9 @@ flowchart TD end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] + pkg_host_directory_picker["host-directory-picker"] + pkg_host_directory_picker_browse["host-directory-picker-browse"] + pkg_host_directory_picker_native["host-directory-picker-native"] pkg_host_webserver["host-webserver"] end subgraph group_lsp["packages/lsp"] @@ -245,6 +250,7 @@ flowchart TD pkg_workspace["workspace"] end pkg_brand --> pkg_invariants + pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants pkg_timeout --> pkg_invariants @@ -264,6 +270,7 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants + pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants @@ -355,6 +362,16 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -576,10 +593,12 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_permission --> pkg_bash + pkg_permission --> pkg_commands pkg_permission --> pkg_invariants pkg_permission --> pkg_sandbox pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session + pkg_permission --> pkg_session_projection pkg_permission --> pkg_user_approval pkg_client_ui_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_runtime @@ -734,6 +753,11 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -944,6 +968,7 @@ flowchart TD | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | +| [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | @@ -963,6 +988,7 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | +| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | @@ -992,6 +1018,8 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1047,7 +1075,7 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | @@ -1073,6 +1101,7 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7c027afd68..a1535f36dc 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -346,7 +346,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src 'permission/preset': { preset: string } ``` -Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:49`](../packages/ui/permission/src/index.ts) ### `plan/*` diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index c6fc223113..86129d3fa4 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_hmr plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] + cfg --> plugin_tui_llm_pi_ai plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] cfg --> plugin_tui_subprocess plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] @@ -71,6 +73,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 7c8b03db05..2b14271c96 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,4 +1,4 @@ -# Full-screen TUI coding agent with swappable DeepSeek and local-bash backends. +# Full-screen TUI coding agent with swappable model and local-bash backends. # `dsh-tui-demo` supplies the agent spine, workspace instructions, generic # task controls, JSONL persistence, the pi-tui front door, and `main`. # HMR remains a leaf because it depends on Loader internals. The app bin loads @@ -20,6 +20,17 @@ thinking: enabled reasoningEffort: max +- id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + - provider: openai + apiKey: !!js process.env.OPENAI_API_KEY + baseURL: !!js process.env.OPENAI_BASE_URL + - provider: anthropic + apiKey: !!js process.env.ANTHROPIC_API_KEY + baseURL: !!js process.env.ANTHROPIC_BASE_URL + # Local executor for the app bundle's bash tool. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). - id: subprocess diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 0969696696..ba5ab61b06 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: f5420b6f2f30837b030a0e832a438c34674a6f23 -README.zh.md: 7beeaadf380a742cbdb6447553f42692a97fad10 +README.md: 7a86e0f034264d4059e75775016d8d5d84600d8d +README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc diff --git a/packages/README.md b/packages/README.md index f5420b6f2f..7a86e0f034 100644 --- a/packages/README.md +++ b/packages/README.md @@ -44,6 +44,8 @@ Packages live at `packages///`; groups are containers, while names r | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface | | [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface | +| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface | +| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | diff --git a/packages/README.zh.md b/packages/README.zh.md index 7beeaadf38..bfcba626be 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -44,6 +44,8 @@ | [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 | | [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 | | [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 | +| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定表面 | +| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 | | [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | | [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml new file mode 100644 index 0000000000..0bac8a9924 --- /dev/null +++ b/packages/client/README.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 packages/client/README.md +README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b +README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8 diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000000..b111d67fa4 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,34 @@ +# client/ — web-GUI browser half + +English | [中文](README.zh.md) + +The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-`. + +| Package | Role | ctx key / slot | +|---|---|---| +| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) | +| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) | +| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) | +| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` | +| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` | +| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) | +| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` | +| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) | +| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` | +| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) | +| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` | +| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) | +| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) | +| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) | +| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) | +| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` | +| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` | +| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) | +| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) | +| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` | +| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) | +| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) | +| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) | +| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) | + +Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer. diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md new file mode 100644 index 0000000000..b498008eb8 --- /dev/null +++ b/packages/client/README.zh.md @@ -0,0 +1,34 @@ +# client/ — web GUI 浏览器半侧 + +[English](README.md) | 中文 + +dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-`。 + +| 包 | 角色 | ctx 键/slot | +|---|---|---| +| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) | +| `modules/` | 客户端模块系统:Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) | +| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) | +| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` | +| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` | +| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) | +| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` | +| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) | +| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` | +| `ui-primitives/` | 纯 React 原子:图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) | +| `ui-layout/` | shell 三栏 AppFrame;声明 `sidebar`/`conversation`/`details`/`conversation.empty` | `ctx.layout` | +| `ui-sidebar/` | 侧栏 shell:Workspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | (slot 宿主) | +| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces`、`conversation.hero.workspace`) | +| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | (slot 宿主) | +| `ui-trajectory/` | Trajectory/Waterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view`) | +| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` | +| `ui-slash/` | 输入触发流水线:光标下的 `/` 与 `@` 检测、分组候选菜单、源名册 | `ctx.slash` | +| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash`) | +| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash`) | +| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` | +| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer`) | +| `ui-settings/` | 设置 shell:触发 chrome + 模态面板;声明 `settings.*` slot | (slot 宿主) | +| `ui-settings-general/` | 设置的无主文案:chrome 内容 + General 分区骨架 | (填充 `settings.*`) | +| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section`) | + +特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 0dd8860d65..6b8558f9be 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -1,6 +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 -README.md: 80228a180faba0c556ff720e999b29b5bb1635b6 -README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819 +# pnpm run verify-translation-pairing --write packages/client/connection/README.md +README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6 +README.zh.md: ca5da643db443956c25399f07c8b460900942ad4 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 80228a180f..173a9b9998 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -4,6 +4,10 @@ English | [中文](README.zh.md) Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +## /api browser-trust fence + +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). + ## Keyless fixture Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index f4b857886b..ca5da643db 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -4,6 +4,10 @@ 协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +## /api 浏览器信任栅栏 + +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 + ## 无密钥 fixture 任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 3fba84aab2..d86b2bdf2a 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -33,7 +33,8 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "schemastery": "^3.18.0" }, "files": [ "lib/index.js", diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts new file mode 100644 index 0000000000..8c1bddd631 --- /dev/null +++ b/packages/client/connection/src/api-request-trust.ts @@ -0,0 +1,129 @@ +/** + * Browser-trust fence for every /api request. Defends the two confused-deputy + * paths a browser opens against a local HTTP API — DNS rebinding (Host names + * the attacker's domain while the socket reaches this server) and cross-site + * requests fired from a malicious page. The Host fence binds every request, + * browser-looking or not: over plain HTTP a browser attaches neither Origin + * nor Fetch-Metadata to reads (EventSource, images, navigations — those + * headers go only to trustworthy destinations), so an unmarked request may + * still be a rebound browser read and Host is the one header rebinding cannot + * forge. Non-browser and remote clients pass the same fence via loopback, the + * CLI-derived LAN IP literals, or a declared `trustedHosts` authority. + * Network reachability and authentication stay out of scope: binding policy + * belongs to the webserver config, and this fence is not an auth layer. + */ + +import type { IncomingHttpHeaders } from 'node:http' + +/** The request facts the fence reads (structural subset of IncomingMessage). */ +interface ApiTrustRequest { + headers: IncomingHttpHeaders +} + +function header(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + return typeof value === 'string' ? value : undefined +} + +function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ +function parseAuthority(authority: string): URL | undefined { + try { + // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws. + return new URL(`http://${authority}`) + } catch { + return undefined + } +} + +/** + * Assert one configured `trustedHosts` entry is a bare authority (`host` or + * `host:port`) in canonical form: it must survive WHATWG parsing unchanged + * (case aside). Anything parsing would silently rewrite is refused as a typo + * that must fail the load loudly instead of being ignored until requests 403 + * or quietly changing the grant: URL parts beyond the authority + * (`harness.internal/path`, `user@harness.internal` — which would authorize + * the embedded hostname), stripped whitespace, a dangling colon or + * zero-padded port (which would broaden an intended exact-port grant to every + * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding, + * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire + * carries). + * @param entry - the configured value, verbatim. + */ +export function assertTrustedAuthority(entry: string): void { + const entryUrl = parseAuthority(entry) + if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return + throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`) +} + +/** + * Canonical form of a parsed authority: `hostname` when no port was written, + * else `hostname:port`. The port is judged from URL parses under both special + * schemes (their default ports differ, so `:80` and `:443` still count as + * explicit), never from the raw string, where WHATWG trimming would misread + * shapes like `host:port ` as port-less. + */ +function canonicalAuthority(entry: string, entryUrl: URL): string { + // An authority that parsed under http cannot fail under https. + const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port + return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}` +} + +/** + * Whether the request authority matches a `trustedHosts` entry. An entry with + * an explicit port matches that exact authority; a port-less entry matches the + * hostname on any port (the shape the CLI derives for IP-literal LAN serving, + * where the bound port may be OS-assigned). Both sides compare through WHATWG + * normalization, so case and a redundant `:80` never decide trust. + */ +function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean { + return trustedHosts.some((entry) => { + const entryUrl = parseAuthority(entry) + if (entryUrl === undefined) return false + return canonicalAuthority(entry, entryUrl) === entryUrl.hostname + ? entryUrl.hostname === hostUrl.hostname + : entryUrl.host === hostUrl.host + }) +} + +/** + * Decide whether one /api request may reach the RPC bridge. + * @param request - node HTTP request facts (headers). + * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. + * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. + */ +export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { + // Host fence (DNS-rebinding defense), applied to every request: the browser + // fills Host from the URL it believes it is talking to, so a rebound page + // carries the attacker's domain here even though the socket lands on this + // server. There is no marker shortcut — a browser read over plain HTTP + // (EventSource, images, navigations) arrives with neither Origin nor + // Fetch-Metadata, indistinguishable from curl, and its response is readable + // by the rebound page. + const host = header(request.headers, 'host') + if (host === undefined) return false + const hostUrl = parseAuthority(host) + if (hostUrl === undefined) return false + if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false + // Cross-site fence: modern browsers label the initiator relationship on + // every fetch; an explicit cross-site marker is refused regardless of Origin. + if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false + // Origin fence: when a browser attaches an Origin it must be exactly this + // authority (compared through the same normalization as the Host). Absent + // Origin is fine — the Host fence above already bound the request. The + // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. + const origin = header(request.headers, 'origin') + if (origin === undefined) return true + try { + return new URL(origin).host === hostUrl.host + } catch { + return false + } +} diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 978d4c3378..a8e561ba33 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,6 +8,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + DirectoryEntry, DirectoryListing, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index bcb88ac0f7..cab616aabd 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -331,6 +331,44 @@ function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: b } /** Fixture parallel of the host's projection units: whole current values per key over the full log. */ +/** Fixture preset table (the host PermissionService defaults). */ +const PERMISSION_PRESETS: Record = { + 'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' }, +} + +/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */ +function permissionSelectOf( + log: readonly SessionEvent[], +): { options: { value: string; name: string; description?: string }[]; currentValue: string } { + let preset: string | null = null + let sandbox = 'workspace-write' + let approval = 'ask' + for (const event of log) { + const item = event as { type: string; data: Record } + if (item.type === 'permission/preset') preset = item.data['preset'] as string + else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string + else if (item.type === 'approval/policy') approval = item.data['policy'] as string + } + const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval + let currentValue = 'custom' + const folded = preset === null ? undefined : PERMISSION_PRESETS[preset] + if (preset !== null && folded !== undefined && matches(folded)) { + currentValue = preset + } else { + for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) { + if (matches(spec)) { currentValue = name; break } + } + } + return { + options: [ + ...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })), + ...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [], + ], + currentValue, + } +} + function projectionValuesOf(log: readonly SessionEvent[]): Record { const values: Record = {} const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title') @@ -339,6 +377,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record([ + ['/', ['home']], + ['/home', ['fixture']], + [FIXTURE_HOME, ['Documents', 'Downloads', '.config']], + [`${FIXTURE_HOME}/Documents`, [ + 'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform', + 'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog', + ]], + ]) + const childrenOf = (path: string): string[] | undefined => { + const known = directoryTree.get(path) + if (known !== undefined) return known + const parent = path.slice(0, path.lastIndexOf('/')) || '/' + const name = path.slice(path.lastIndexOf('/') + 1) + return directoryTree.get(parent)?.includes(name) === true ? [] : undefined + } + const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => { + const crumbs = [{ name: '/', path: '/', hidden: false }] + let acc = '' + for (const segment of path.split('/').filter(Boolean)) { + acc += `/${segment}` + crumbs.push({ name: segment, path: acc, hidden: false }) + } + return crumbs + } const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) - /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */ + /** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */ const pendingApprovalRpcId = mint() + const pendingApprovalId = 'fx-approval-1' as Extract['approvalId'] + /** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */ + let approvalPending = true const pendingQuestionRpcId = mint() let questionPending = true const fixtureQuestions: Extract['questions'] = [ @@ -980,7 +1064,42 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), - pickDirectory: request => ok(request, { path: null }), + // Deterministic native pick: the keyless lanes drive the full + // pick-then-adopt path without an OS chooser (design-mock content, + // same tree the browse primitives serve). + pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), + listDirectory: (request) => { + const target = request.payload.path ?? FIXTURE_HOME + const children = childrenOf(target) + if (children === undefined) { + return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }) + } + return ok(request, { + path: target, + home: FIXTURE_HOME, + crumbs: crumbsOf(target), + entries: [...children].sort((a, b) => a.localeCompare(b)) + .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), + // The fixture tree is tiny; no level ever reaches a backend bound. + truncated: false, + }) + }, + createDirectory: (request) => { + const parent = request.payload.path + const children = childrenOf(parent) + if (children === undefined) { + return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) + } + // Same root special case as listDirectory's entry paths: a plain join + // under '/' would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}` + if (children.includes(request.payload.name)) { + return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) + } + directoryTree.set(parent, [...children, request.payload.name]) + directoryTree.set(target, []) + return ok(request, { path: target }) + }, openPath: request => ok(request, { opened: true as const }), }, workspace: { @@ -1082,6 +1201,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, + { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, ], }) @@ -1098,6 +1218,26 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim()) const name = match?.[1] const args = match?.[2] ?? '' + // /permission mirrors the host handler: switch through the knob + // events (each append pushes a permissions projection frame). + if (name === 'permission') { + const preset = args.trim() + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const spec = PERMISSION_PRESETS[preset] + if (preset === '') { + const current = permissionSelectOf(logOf(id)).currentValue + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } }) + } else if (spec === undefined) { + append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) + } else { + if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) + append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) + append(id, { type: 'approval/policy', data: { policy: spec.approval } }) + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } }) + } + return ok(request, { matched: true as const, commandId }) + } if (name === 'goal') { // Host parallel: /goal with an objective creates (or reports) the // current goal; the command lifecycle pair brackets the mutation. @@ -1232,14 +1372,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } }) } } - conn.push({ - rpcId: pendingApprovalRpcId, - payload: { - type: 'approval/requested', sessionId: sid('fx-alpha'), - approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract['approvalId'], - toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)', - }, - }) + if (approvalPending) { + conn.push({ + rpcId: pendingApprovalRpcId, + payload: { + type: 'approval/requested', sessionId: sid('fx-alpha'), + approvalId: pendingApprovalId, + toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', + }, + }) + } if (questionPending) { conn.push({ rpcId: pendingQuestionRpcId, @@ -1277,6 +1419,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, }, respond(message: ClientResponse): Promise { + // Same routing discipline as the host: rpcId first, then the payload's + // audit correlation; a settled or unknown id is not-pending. + if (message.rpcId === pendingApprovalRpcId) { + if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' }) + if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) + const value = message.result.value as { approvalId?: unknown; outcome?: unknown } + if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) { + return Promise.resolve({ accepted: false, reason: 'bad-response' }) + } + approvalPending = false + emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome }) + return Promise.resolve({ accepted: true }) + } if (!questionPending || message.rpcId !== pendingQuestionRpcId) { return Promise.resolve({ accepted: false, reason: 'not-pending' }) } @@ -1335,6 +1490,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) + case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) + case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 53e5b2bc3f..0aa2cc3f82 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -13,6 +13,7 @@ import { WebApiClient } from './web-api-client.ts' export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 33f6d0cc41..ce55089bdb 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,11 +1,12 @@ /** Host HTTP bridge for browser-client RPC. */ import type { Context } from 'cordis' +import z from 'schemastery' // Activates the httpServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' -import { isTrustedNativeDialogRequest } from './native-dialog-request.ts' +import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -15,20 +16,42 @@ export const name = 'client-connection' /** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy'] +/** Plugin config: the deployment's non-loopback serving authorities. */ +export interface ConnectionConfig { + /** + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by (the dsh CLI derives the machine's LAN IP literals itself). An entry + * that is not a bare, canonical authority fails the plugin load. + */ + trustedHosts?: string[] +} + +export const Config: z = z.object({ + trustedHosts: z.array(String).default([]), +}) + /** - * Mounts the API gateway under the browser transport prefix. + * Mounts the API gateway under the browser transport prefix. Every request on + * the prefix passes the browser-trust fence first (DNS-rebinding and + * cross-site defense — [api-request-trust](./api-request-trust.ts)). * @param ctx - Host plugin context. + * @param config - resolved plugin config (schema defaults applied). */ -export function apply(ctx: Context): void { +export function apply(ctx: Context, config?: ConnectionConfig): void { + // The Loader resolves schema defaults; hand-built test contexts may pass none. + const trustedHosts = config?.trustedHosts ?? [] + // Config boundary: a malformed entry fails the load loudly here rather than + // silently authorizing its hostname prefix at request time. + for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) const route: WebRoute = { kind: 'prefix', path: API_PATH, handler: async (req, res) => { - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - if ((pathname === `${API_PATH}/host.pickDirectory` - || pathname === `${API_PATH}/host.openPath`) - && !isTrustedNativeDialogRequest(req)) { + if (!isTrustedApiRequest(req, trustedHosts)) { res.writeHead(403) res.end('forbidden') return diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts deleted file mode 100644 index 0eaf09f149..0000000000 --- a/packages/client/connection/src/native-dialog-request.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** Trust check for browser requests that can invoke privileged native host actions. */ - -import type { IncomingHttpHeaders } from 'node:http' - -interface NativeDialogRequest { - headers: IncomingHttpHeaders - socket: { remoteAddress?: string | undefined } -} - -function header(headers: IncomingHttpHeaders, name: string): string | undefined { - const value = headers[name] - return typeof value === 'string' ? value : undefined -} - -function isLoopback(address: string | undefined): boolean { - if (address === undefined) return false - if (address === '::1') return true - const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address - const first = ipv4.split('.')[0] - return first === '127' -} - -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true - const parts = hostname.split('.') - return parts.length === 4 - && parts[0] === '127' - && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) -} - -/** - * Require a local socket plus browser-controlled same-origin metadata. - * @param request - the node HTTP request facts used by the carrier guard. - * @returns true only for a same-origin browser request whose peer and URL are loopback. - */ -export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean { - if (!isLoopback(request.socket.remoteAddress)) return false - if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false - const origin = header(request.headers, 'origin') - const host = header(request.headers, 'host') - if (origin === undefined || host === undefined) return false - try { - const parsed = new URL(origin) - const hostUrl = new URL(`http://${host}`) - return (parsed.protocol === 'http:' || parsed.protocol === 'https:') - && parsed.host === host - && isLoopbackHostname(parsed.hostname) - && isLoopbackHostname(hostUrl.hostname) - } catch { - return false - } -} diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts new file mode 100644 index 0000000000..f145230a8b --- /dev/null +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -0,0 +1,108 @@ +/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */ + +import { describe, expect, it } from 'vitest' +import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts' + +function request(headers: Record): { headers: Record } { + return { headers } +} + +describe('isTrustedApiRequest', () => { + it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => { + // Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to + // reads (EventSource, images, navigations), so a rebound-origin GET is + // markerless and its response readable: no marker shortcut may exist. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true) + expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true) + expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false) + expect(isTrustedApiRequest(request({}), [])).toBe(false) + }) + + it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => { + for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) { + expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true) + } + }) + + it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => { + expect(isTrustedApiRequest(request({ + host: 'evil.example:3080', + origin: 'http://evil.example:3080', + 'sec-fetch-site': 'same-origin', + }), [])).toBe(false) + }) + + it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => { + const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' } + expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true) + expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false) + expect(isTrustedApiRequest(request(headers), [])).toBe(false) + }) + + it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => { + expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true) + // An unparsable entry never matches; it must not poison the rest of the list. + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false) + }) + + it('refuses cross-origin browser markers even on a loopback Host', () => { + // Origin present and different → cross-site request that survived preflight rules. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false) + // Explicit cross-site label → refused regardless of Origin. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false) + // Opaque origin (sandboxed iframe, file: page) parses to no authority. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false) + }) + + it('accepts a same-origin browser request, with or without an Origin header', () => { + expect(isTrustedApiRequest(request({ + host: 'localhost:3080', + origin: 'http://localhost:3080', + 'sec-fetch-site': 'same-origin', + }), [])).toBe(true) + // Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site. + expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true) + }) + + it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => { + for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) { + expect(() => { assertTrustedAuthority(entry) }).not.toThrow() + } + // WHATWG parsing would quietly read a hostname out of each of these; the + // config boundary must refuse them instead of authorizing the prefix. + for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + // WHATWG trimming would silently strip these; the entry must fail instead. + for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + // WHATWG parsing would silently rewrite these — a dangling colon or + // zero-padded port would broaden an intended exact-port grant to every + // port, and non-canonical host spellings would not read back as written. + for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + }) + + it('never lets stray whitespace broaden an exact-port entry to every port', () => { + // Defense in depth below the load-time assert: the explicit-port judgment + // reads the parsed URL, so a trimmed `host:port ` entry stays exact. + const trusted = ['harness.internal:3080 '] + expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true) + }) + + it('refuses malformed or untrusted authorities on browser requests', () => { + const markers = { 'sec-fetch-site': 'same-origin' } + expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false) + }) +}) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index d974c4bf3b..6a876c8ed4 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -70,6 +70,18 @@ export class FakeApiClient implements IApiClient { onOpenPath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) + onListDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) + + onCreateDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake/new' })) + private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -91,6 +103,8 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)), + createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index f589024a24..70ee127b3d 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => { expect(response.rpcId).toBe(request.rpcId) if (!response.result.ok) throw new Error('list failed') const commands = response.result.value.commands - expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'plan']) + expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') expect(echo?.input?.hint).toBeTruthy() diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 8f51861283..09a3efecd3 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -72,8 +72,19 @@ describe('createFixtureApi', () => { // Fixture composes the todos + plan units (host parallel when tool-todo // and plan-mode are mounted): the empty-log values. expect(empty.result.value).toEqual({ - events: [], hasMore: false, - projections: { asOfSeq: -1, values: { goal: null, todos: null, plan: { active: false, pending: false } } }, + events: [], hasMore: false, projections: { asOfSeq: -1, values: { + todos: null, + // Permission unit composed: the composition-default select. + permissions: { + options: [ + { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, + ], + currentValue: 'workspace-write', + }, + plan: { active: false, pending: false }, + goal: null, + } }, }) }) @@ -208,7 +219,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 7) abort.abort() + if (envelopes.length >= 8) abort.abort() } return envelopes } @@ -216,15 +227,16 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow the subscribed frame (title + todos + plan + goal units). + // Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units). expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) - expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) - expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) - expect(first[5]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[5]?.rpcId).toBe(first[5]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[6]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[6]?.rpcId).toBe(first[6]?.rpcId) + expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' }) + expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) + expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) + expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[7]?.rpcId).toBe(first[7]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { @@ -311,6 +323,44 @@ describe('createFixtureApi', () => { })).toEqual({ accepted: true }) }) + it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => { + const api = createFixtureApi() + // Discover the resident approval's stable rpcId from the mux baseline. + const abort = new AbortController() + const seen: { rpcId: string; frame: MuxFrame }[] = [] + const consuming = (async () => { + for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload }) + })() + await vi.waitFor(() => { + expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true) + }) + const requested = seen.find(s => s.frame.type === 'approval/requested') + if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable') + const approvalId = requested.frame.approvalId + + // Routed but malformed answers. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + // The real answer settles the question and broadcasts resolved. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } })) + .toEqual({ accepted: true }) + await vi.waitFor(() => { + expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true) + }) + // Settled: a duplicate answer is late, and a fresh mux open replays nothing. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } })) + .toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + await consuming + const abort2 = new AbortController() + const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2) + expect(replayed.some(f => f.type === 'approval/requested')).toBe(false) + }) + it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) @@ -319,6 +369,22 @@ describe('createFixtureApi', () => { expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) + it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { + const api = createFixtureApi() + const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) + if (!created.result.ok) throw new Error('create failed') + expect(created.result.value.path).toBe('/srv') + const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal) + if (!listed.result.ok) throw new Error('list failed') + expect(listed.result.value.crumbs).toEqual([ + { name: '/', path: '/', hidden: false }, + { name: 'srv', path: '/srv', hidden: false }, + ]) + const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal) + if (!root.result.ok) throw new Error('root list failed') + expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + }) + it('workspace.list serves the resident account and create reuses on path collision', async () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts deleted file mode 100644 index 1a3d70dd15..0000000000 --- a/packages/client/connection/tests/native-dialog-request.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { IncomingHttpHeaders } from 'node:http' -import { describe, expect, it } from 'vitest' -import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' - -function request( - remoteAddress: string | undefined, - headers: IncomingHttpHeaders = { - host: '127.0.0.1:3080', - origin: 'http://127.0.0.1:3080', - 'sec-fetch-site': 'same-origin', - }, -) { - return { socket: { remoteAddress }, headers } -} - -describe('native dialog request trust', () => { - it('accepts loopback same-origin browser requests', () => { - expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true) - expect(isTrustedNativeDialogRequest(request('::1', { - host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(true) - expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(true) - expect(isTrustedNativeDialogRequest(request('127.0.0.2', { - host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(true) - }) - - it('rejects remote sockets and requests without matching browser metadata', () => { - expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false) - expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - }) -}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 2c90cd8b7a..2c7fd0b281 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,4 +1,6 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { IncomingMessage, ServerResponse } from 'node:http' @@ -6,48 +8,113 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, inject } from '../src/index.ts' -describe('connection node half', () => { - it('registers the /api prefix route and removes it with the fiber', async () => { - const ctx = new Context() - const routes: WebRoute[] = [] - // Structural fake: the plugin only touches register(); the service class - // carries private state a literal cannot (and need not) reproduce. - const httpServer: Pick = { - register(route) { - routes.push(route) - return () => { routes.splice(routes.indexOf(route), 1) } - }, - tapIndex: () => () => {}, - port: 0, - } - ctx.provide('httpServer', httpServer as HttpServerService) - ctx.provide('apiProxy', {} as unknown as ApiProxy) +/** Structural httpServer fake: the plugin only touches register(). */ +function fakeHttpServer(routes: WebRoute[]): Pick { + return { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } +} - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() +/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */ +function fakeRequest(headers: Record): IncomingMessage { + const request = Readable.from([]) as unknown as IncomingMessage + Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers }) + return request +} + +/** Response recorder compatible with both the fence's short-circuit and the bridge. */ +function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { + const state: { status?: number; body?: unknown } = {} + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(value: number) { state.status = value; return this }, + write() { return true }, + end(this: { writableEnded: boolean }, value?: unknown) { + if (value !== undefined) state.body = value + this.writableEnded = true + return this + }, + }) as unknown as ServerResponse + return { response, state } +} + +async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + const fiber = ctx.plugin({ inject: [...inject], apply }, config) + await fiber.await() + return { routes, dispose: () => fiber.dispose() } +} + +describe('connection node half', () => { + it('fails the load on a trustedHosts entry that is not a bare authority', async () => { + const routes: WebRoute[] = [] + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + // The apply throw also escapes cordis as a late rejection — the shape the + // boot's installFailLoud is contracted to catch. Capture it so the run + // stays clean, same pattern as the webserver bind-failure test. + const rejections: unknown[] = [] + const onUnhandled = (err: unknown): void => { rejections.push(err) } + process.on('unhandledRejection', onUnhandled) + try { + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) + await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/) + expect(routes).toHaveLength(0) + for (let i = 0; i < 100 && rejections.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority') + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('registers the /api prefix route and removes it with the fiber', async () => { + const { routes, dispose } = await mounted() expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) - - for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) { - let status: number | undefined - let body: unknown - const deniedRequest = { - url, - headers: { - host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', - }, - socket: { remoteAddress: '192.168.1.8' }, - } as unknown as IncomingMessage - const deniedResponse = { - writeHead(value: number) { status = value; return this }, - end(value?: unknown) { body = value; return this }, - } as unknown as ServerResponse - await routes[0]!.handler(deniedRequest, deniedResponse) - expect(status).toBe(403) - expect(body).toBe('forbidden') - } - - await fiber.dispose() + await dispose() expect(routes).toHaveLength(0) }) + + it('refuses an untrusted Host on any /api path before the bridge runs', async () => { + const { routes, dispose } = await mounted() + const { response, state } = fakeResponse() + await routes[0]!.handler(fakeRequest({ + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }), response) + expect(state.status).toBe(403) + expect(state.body).toBe('forbidden') + await dispose() + }) + + it('passes loopback and declared-authority requests through to the bridge', async () => { + const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] }) + // Loopback, no browser markers (curl shape): the fence passes; the carrier + // answers 404 for a GET unary path — proof the bridge ran. + const loopback = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response) + expect(loopback.state.status).toBe(404) + // LAN authority declared as a port-less IP literal — the shape the CLI + // derives for `--host 0.0.0.0` — passes markerless curl on any port. + const lan = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response) + expect(lan.state.status).toBe(404) + // Declared public authority, same-origin browser shape. + const declared = fakeResponse() + await routes[0]!.handler(fakeRequest({ + host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin', + }), declared.response) + expect(declared.state.status).toBe(404) + await dispose() + }) }) diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 2ada5e6fbc..31335c388c 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -46,6 +46,13 @@ export interface ISession { * @returns completion; failures land in snapshot.openState/loadingOlder. */ loadOlder(): Promise + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle). + * @param line - the full command line, leading slash included. + * @returns the admission result, or the error branch on transport failure. + */ + command(line: string): Promise> } /** diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index cd32cbf56f..9238ea5fd0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -6,7 +6,7 @@ * the concrete class. Widening this interface is the explicit act of * widening what features may do to the workspaces domain. */ -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import type { WorkspaceListState } from '../workspaces/service.ts' import type { ObservableSnapshot } from './store.ts' @@ -37,6 +37,20 @@ export interface IWorkspaces { * @returns the selected path, or null when the user cancelled. */ pickDirectory(): Promise + /** + * List one directory level through the Host's `browse` capability. + * @param path - absolute directory to list; absent lists the Host home directory. + * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. + * @returns the level's listing with breadcrumb ancestry. + */ + listDirectory(path?: string, signal?: AbortSignal): Promise + /** + * Create one child directory through the Host's `browse` capability. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ + createDirectory(path: string, name: string): Promise /** * Open a filesystem path with the Host operating system's default application. * @param path - absolute or host-resolvable path. diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index fb4985fef4..3b8e02b5a9 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -18,7 +18,7 @@ export { SessionProvideChannel } from './sessions/provide.ts' export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' -export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' +export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' export type { ISessions } from './contract/sessions.ts' @@ -29,7 +29,9 @@ export type { export type { SessionListPhase } from './sessions/manager.ts' export type { WorkspaceListPhase } from './workspaces/manager.ts' export type { WorkspaceListState } from './workspaces/service.ts' -export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +export type { + DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-client-connection/client' // Runtime owns the snapshot store; web-react only binds it to React. export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' export type { @@ -152,6 +154,12 @@ export function apply(ctx: Context): void { workspaces.handleConnected() ctx.emit('connection/reset') }, + onStateChange: (state) => { + // Generation death fires before any next-generation frame can arrive + // (reconnect replays flow from stream open, ahead of onConnected): + // the only safe moment to drop generation-scoped interaction state. + if (state === 'reconnecting') sessions.handleDisconnected() + }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') } diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 461d11660a..d003c9e9be 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary { title?: string } -/** One flattened session-list row (summary + lineage indent depth). */ +/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */ export interface SessionListEntry { sessionId: SessionId title?: string @@ -19,6 +19,8 @@ export interface SessionListEntry { blank: boolean parentSessionId?: SessionId cwd?: string + /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ + waitingApproval: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } @@ -28,9 +30,10 @@ export interface SessionListEntry { * follows the established input order; this projection never re-sorts a * hydrated list from mutable timestamps. * @param summaries - the host's session.list items. + * @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false). * @returns display rows in render order. */ -export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] { +export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet): SessionListEntry[] { const byId = new Map() for (const s of summaries) byId.set(s.sessionId, s) @@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess return } visited.add(s.sessionId) - out.push({ ...s, depth }) + out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth }) const kids = children.get(s.sessionId) if (kids === undefined) return for (const kid of kids) walk(kid, depth + 1) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 1e88e9f8ba..396c0be6e7 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -57,6 +57,11 @@ export class SessionManager { * drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ private readonly pendingBuffers = new Map[]>() + /** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open + * replays of the same requested frame). Manager-owned rather than read off Session instances + * because the sidebar must light up for sessions never instantiated. Cleared per connection + * generation — the reopen replay re-adds still-pending questions — and on session-removed. */ + private readonly waitingApprovals = new Map>() /** Per-session projection value stores, retained independently of instance arrival (the * title-snapshot precedent, generalized): push frames land here whether or not the Session * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the @@ -360,6 +365,22 @@ export class SessionManager { } } } + // List-level waiting-approval bit (the sidebar amber dot): tracked here for + // every session, instantiated or not; approvalId keys make replays idempotent. + if (frame.type === 'approval/requested') { + let ids = this.waitingApprovals.get(frame.sessionId) + if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set()) + if (!ids.has(frame.approvalId)) { + ids.add(frame.approvalId) + this.notifier.markDirty() + } + } else if (frame.type === 'approval/resolved') { + const ids = this.waitingApprovals.get(frame.sessionId) + if (ids !== undefined && ids.delete(frame.approvalId)) { + if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId) + this.notifier.markDirty() + } + } const session = this.sessions.get(frame.sessionId) if (session === undefined) { // Approval/question/queued frames never hit history: buffer for replay on @@ -404,6 +425,7 @@ export class SessionManager { this.recordMutation({ kind: 'remove', sessionId: frame.sessionId }) this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation + this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance return } @@ -421,6 +443,30 @@ export class SessionManager { } } + /** + * The moment a connection generation dies (before any next-generation frame + * can arrive — onConnected waits for the readiness handshake while replayed + * frames flow from stream open, so clearing there would race the replay): + * drop generation-scoped live state. Approvals resolved while disconnected + * send no frame, so the stale bits and the buffered answerable frames must + * not survive into the next generation — the mux-open replay re-adds every + * still-pending question with its live rpcId. + */ + handleDisconnected(): void { + if (this.waitingApprovals.size > 0) { + this.waitingApprovals.clear() + this.notifier.markDirty() + } + for (const [sessionId, buffer] of [...this.pendingBuffers]) { + const kept = buffer.filter(item => + item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved' + && item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved') + if (kept.length === buffer.length) continue + if (kept.length === 0) this.pendingBuffers.delete(sessionId) + else this.pendingBuffers.set(sessionId, kept) + } + } + /** After each connection generation: refresh the session baseline and rebuild opened windows. */ handleConnected(): void { void this.refreshList() @@ -436,7 +482,7 @@ export class SessionManager { ? { ...summary, title } : summary }) - const fresh = flattenLineage(merged) + const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys())) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( @@ -444,6 +490,7 @@ export class SessionManager { && prev.blank === entry.blank && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.title === entry.title && prev.depth === entry.depth + && prev.waitingApproval === entry.waitingApproval ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 64680587e2..71067d6330 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -40,6 +40,8 @@ export interface SessionSummary { cwd?: string parentId?: SessionId running: boolean + /** An approval question is pending on this session (sidebar amber-dot state). */ + waitingApproval: boolean /** * Empty-log bit (host summary derivation mirror). New Session reuses a blank * one targeting the same workspace. Filtering stays with the consumer: the @@ -292,6 +294,11 @@ export class SessionsService implements ISessions { this.manager.handleConnected() } + /** Drop generation-scoped live interaction state the moment a connection generation dies. */ + handleDisconnected(): void { + this.manager.handleDisconnected() + } + /** * Create a session on the host. Resolution guarantee: by the time the * promise resolves, the created session is in the list store and @@ -463,6 +470,7 @@ export class SessionsService implements ISessions { id: entry.sessionId, displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, + waitingApproval: entry.waitingApproval, blank: entry.blank, updatedAt: entry.updatedAt, ...(entry.title !== undefined ? { title: entry.title } : {}), diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 77af4601ab..0f5d39ac8e 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -252,6 +252,21 @@ export class Session implements SessionFace { return result } + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle; + * outcomes render as flow nodes, never as a response echo). + * @param line - the full command line, leading slash included. + * @returns the admission result, or the error branch on transport failure. + */ + async command(line: string): Promise> { + try { + return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result + } catch (error) { + return transportError(error) + } + } + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ open(): Promise { if (this.openState === 'open') return Promise.resolve() @@ -812,7 +827,10 @@ export class Session implements SessionFace { queue: this.queueCache.value, running: this.running, composerPhase: derivePhase( - nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0, + // Command lifecycle nodes are not conversation: running /permission + // or /plan on a fresh session keeps the hero (the client mirror of + // the host's no-turn sessionBlank predicate). + nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0, this.promptAttempted, ), removed: this.removed, @@ -833,7 +851,9 @@ export class Session implements SessionFace { * object: `hasContent` only grows within a window and `promptAttempted` is * sticky, so blank → engaging → active never steps back; a failed first * prompt stays engaging (retry semantics — see ComposerPhase). - * @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits). + * @param hasContent - any conversation material exists (non-command nodes, + * partial, running turn, pending waits; command lifecycle rows alone keep + * the session blank). * @param promptAttempted - a prompt was initiated on this session object. * @returns the derived phase. */ diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index c69661b130..1dd3319e79 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -2,7 +2,8 @@ import type { Context } from 'cordis' import type { - IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, + DirectoryListing, IApiClient, RpcError, + SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' @@ -30,6 +31,14 @@ export class WorkspaceCreateError extends Error { } } +/** Structured browse failure so the directory browser can branch on Host business codes. */ +export class DirectoryBrowseError extends Error { + constructor(readonly rpcError: RpcError) { + super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`) + this.name = 'DirectoryBrowseError' + } +} + /** Real Workspace object layer and Host actions. */ export class WorkspacesService implements IWorkspaces { /** UI-facing immutable projection; the manager remains wire truth. */ @@ -172,7 +181,7 @@ export class WorkspacesService implements IWorkspaces { } /** - * Open the Host's native directory picker. + * Open the Host's native directory picker (the `native` capability). * @returns the selected path, or null when the user cancelled. */ async pickDirectory(): Promise { @@ -183,6 +192,30 @@ export class WorkspacesService implements IWorkspaces { return response.result.value.path } + /** + * List one directory level through the Host's `browse` capability. + * @param path - absolute directory to list; absent lists the Host home directory. + * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. + * @returns the level's listing with breadcrumb ancestry. + */ + async listDirectory(path?: string, signal?: AbortSignal): Promise { + const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) + if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) + return response.result.value + } + + /** + * Create one child directory through the Host's `browse` capability. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ + async createDirectory(path: string, name: string): Promise { + const response = await this.api.host.createDirectory({ path, name }) + if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) + return response.result.value.path + } + /** * Open a filesystem path with the Host operating system's default application. * @param path - absolute or host-resolvable path. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index bf1a4a04b3..0a1de3f7e1 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient { payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = @@ -88,6 +89,18 @@ export class FakeApiClient implements IApiClient { onOpenPath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) + onListDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) + + onCreateDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake/new' })) + private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -109,6 +122,8 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)), + createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 85b9ed6b75..33b46538d9 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -376,3 +376,62 @@ describe('connected generation', () => { }) }) }) + +describe('waiting-approval list bit', () => { + it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + // Mux-open replay of the same question (same approvalId) is idempotent. + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + }) + + it('clears only when the last outstanding question resolves; session-removed drops the bit', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + // Removed sessions drop their bit outright. + manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } }) + manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + expect(manager.getListSnapshot().items).toHaveLength(0) + }) + + it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + // Generation death clears (resolved-while-disconnected questions send no frame)… + manager.handleDisconnected() + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + // …and a replayed frame arriving before onConnected (stream open precedes + // the readiness handshake) survives the later handleConnected untouched. + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + manager.handleConnected() + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + }) + + it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + // Buffered pre-instantiation: an approval pair and a queued row. + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) + manager.handleDisconnected() + // Instantiate after the death sweep: no zombie interaction replays (the + // pendingBuffers held only dead-generation rpcIds), so the session mints + // no pending waits. + const session = manager.get(S1) + expect(session.getSnapshot().pending).toEqual([]) + }) +}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index ca9193eda1..c7be330d55 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -126,6 +126,21 @@ describe('live event path', () => { }) }) + it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => { + // A fresh session whose only window content is a command pair (plus the + // knob events a /permission switch appends — not surface-eligible, so + // they never become nodes) stays phase 'blank': selecting a preset from + // the hero must not enter the conversation view. + const { session } = await opened([]) + expect(session.getSnapshot().composerPhase).toBe('blank') + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) + feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.')) + const snapshot = session.getSnapshot() + expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' }) + expect(snapshot.composerPhase).toBe('blank') + }) + it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 6768fddff7..54ec218765 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' -import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' +import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (id: string): SessionId => id as SessionId @@ -234,6 +234,29 @@ describe('WorkspacesService', () => { api.onPickDirectory = () => Promise.resolve(ok({ path: null })) await expect(workspaces.pickDirectory()).resolves.toBeNull() expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}]) + api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} })) + await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/) + }) + + it('passes listings and creation through the browse wire, wrapping business failures', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api)) + const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false } + api.onListDirectory = () => Promise.resolve(ok(listing)) + await expect(workspaces.listDirectory()).resolves.toEqual(listing) + await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing) + // The optional path is omitted from the payload, not sent as undefined. + expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }]) + api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } })) + const listFailure = workspaces.listDirectory('/x') + await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) + await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) + + await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new') + expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }]) + api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } })) + await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } }) }) it('opens a filesystem path through the host without local state', async () => { diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index ebd419b369..5ec4652aef 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -92,6 +92,14 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`) } + /** + * Fail-loud stub; supply `command` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + command(): never { + throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`) + } + /** * Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it. * @returns never — always throws. @@ -183,6 +191,7 @@ export class TestSessions implements ISessions { id, displayTitle: fixture.id, running: false, + waitingApproval: false, blank: false, updatedAt: this.records.size + 1, ...fixture.summary, diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 32c9bff36e..6c1a9d0aad 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -1,7 +1,7 @@ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { - IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, + DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import { workspaceListState } from './fixtures.ts' import type { Stabilizer } from './fixtures.ts' @@ -109,6 +109,48 @@ export class TestWorkspaces implements IWorkspaces { return null } + /** + * Browse listing (recorded). The default serves an empty home level; stub + * to shape a tree. + * @param path - absolute directory to list; absent lists the home level. + * @returns the level's listing. + */ + async listDirectory(path?: string, signal?: AbortSignal): Promise { + // The signal is recorded and forwarded like the production face passes + // it to the wire, so cancellation integration tests can observe or + // reject on a superseded scan. + this.calls.push({ method: 'listDirectory', args: [path, signal] }) + const stub = this.stubs.get('listDirectory') + if (stub !== undefined) return await (stub(path, signal) as Promise) + // The chain runs root-to-target inclusive, per the DirectoryListing + // contract — a bare root crumb would mislabel the level in browsers + // driven by this double. + return { + path: '/home/test', + home: '/home/test', + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'test', path: '/home/test', hidden: false }, + ], + entries: [], + truncated: false, + } + } + + /** + * Browse child creation (recorded). The default joins parent and name. + * @param path - absolute existing parent directory. + * @param name - single path segment. + * @returns the created directory's absolute path. + */ + async createDirectory(path: string, name: string): Promise { + this.calls.push({ method: 'createDirectory', args: [path, name] }) + const stub = this.stubs.get('createDirectory') + if (stub !== undefined) return await (stub(path, name) as Promise) + return `${path}/${name}` + } + /** * Rename a Workspace (recorded). The default echoes a minimal view. * @param workspaceId - target workspace. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index e63fcc3821..22d3bb5cfc 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -322,6 +322,32 @@ describe('workspaces', () => { expect(stub).toHaveBeenCalledOnce() await runtime.dispose() }) + + it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => { + const runtime = await runtimeWithFrame() + // Defaults: an empty home level and parent/name joining. + await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) + await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) + await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') + // The recorded signal seat mirrors the production face (undefined here; + // cancellation tests pass and observe a real one). + expect(runtime.workspaces.calls).toEqual([ + { method: 'listDirectory', args: [undefined, undefined] }, + { method: 'listDirectory', args: ['/home/test', undefined] }, + { method: 'createDirectory', args: ['/home/test', 'fresh'] }, + ]) + // Stubs replace the defaults like every sibling method. + const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } + const listStub = vi.fn(() => Promise.resolve(listing as never)) + runtime.workspaces.stub('listDirectory', listStub) + runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never))) + const scan = new AbortController() + await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing) + // The stub receives the signal too, like the production face gives the wire. + expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal) + await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made') + await runtime.dispose() + }) }) describe('feature mount and disposal', () => { @@ -442,6 +468,7 @@ describe('fixture session face', () => { const bare = runtime.sessions.behavior('s1') expect(() => bare.prompt()).toThrow(/prompt is not stubbed/) expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) + expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) await runtime.dispose() }) diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index 6d15efd511..00aa8a1e43 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -1,6 +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 -README.md: 17bc4edd7d002d6bba4470c9418a9179b2cb131b -README.zh.md: 1291556409b993aa893e102386f75c45bb195adf +# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md +README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da +README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index 17bc4edd7d..64d06f1d9b 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md). -`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. +`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 1291556409..0cec3b8c8f 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -4,7 +4,7 @@ 客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。 -`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 +`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key:每个会话恒为 agent-backed,因此 `command.list({sessionId})` 是唯一的寻址形状,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts index a9a1116664..61ab4de2e2 100644 --- a/packages/client/ui-command/src/client/contract.ts +++ b/packages/client/ui-command/src/client/contract.ts @@ -43,6 +43,24 @@ export interface CommandContribution { readonly ui: CommandUiSpec } +/** + * A UI decoration hung on one HOST command: what its BARE invocation does on + * this client. Not a second command — the host command keeps its catalog + * row, its argument claim (space / argued enter), and its lifecycle logging; + * the decoration replaces only the bare menu-pick/enter with a popup whose + * onSelect typically submits a completed line back through command.execute. + * A decoration never manufactures a row: a name with no host catalog entry + * in the session's directory simply never reaches the decoration. + */ +export interface CommandDecoration { + /** The HOST command name this decorates (without the leading slash). */ + readonly name: string + /** Capability filter, called with a fresh projection per bare invocation. */ + available(session: ClientSessionContext): boolean + /** The bare-invocation UI (this phase: popupSelect only). */ + readonly ui: CommandUiSpec +} + /** The `ctx.command` service face visible to business packages. */ export interface CommandServiceContract { /** @@ -50,6 +68,11 @@ export interface CommandServiceContract { * names throw at registration. */ register(contribution: CommandContribution): () => void + /** + * Hang a bare-invocation decoration on one host command; effect disposer. + * Duplicate names throw at registration. + */ + decorate(decoration: CommandDecoration): () => void /** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */ popupFor(actx: ClientContext): unknown } diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index 4765dc7d86..f40078212e 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected } from './PopupSelectView.tsx' export type { - CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption, + CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption, } from './contract.ts' declare module 'cordis' { diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 8f9f96731f..9da7b5dbe6 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -14,7 +14,7 @@ import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-slash/client' -import type { CommandContribution, CommandServiceContract } from './contract.ts' +import type { CommandContribution, CommandDecoration, CommandServiceContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' import { CommandDirectory } from './directory.ts' import { PopupSelectController } from './popup.ts' @@ -23,6 +23,7 @@ import type { TokenSegment } from './popup.ts' /** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */ interface LiveState { readonly contributions: Map + readonly decorations: Map readonly popups: Map> } @@ -31,7 +32,7 @@ export class CommandService extends Service implements CommandServiceContract { static inject = ['slash', 'sessions', 'connection'] private readonly directory: CommandDirectory - private readonly live: LiveState = { contributions: new Map(), popups: new Map() } + private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() } /** * @param ctx - owning root context (plugin fiber; the service registers @@ -79,6 +80,24 @@ export class CommandService extends Service implements CommandServiceContract { return () => { void dispose() } } + /** + * Hang a bare-invocation decoration on one host command; effect disposer + * (rides the caller's fiber). Duplicate names throw. + * @param decoration - host command name + availability + popup spec. + * @returns the disposer removing the registration. + */ + decorate(decoration: CommandDecoration): () => void { + const dispose = this.ctx.effect(() => { + const { decorations } = this.live + if (decorations.has(decoration.name)) { + throw new Error(`ui-command: duplicate decoration for /${decoration.name}`) + } + decorations.set(decoration.name, decoration) + return () => { decorations.delete(decoration.name) } + }, 'command.decorate()') + return () => { void dispose() } + } + /** * Resolve the per-session popup controller (lazy; dies with the session * scope). The controller's consume callback dispatches the scoped @@ -148,16 +167,24 @@ export class CommandService extends Service implements CommandServiceContract { .filter(c => req.position === 'leading' || c.hint === undefined) } - /** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */ + /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ private dispatch(pick: SlashPick): PickOutcome { const name = pick.candidate.name const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(pick.session)) { - this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span }) + this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span }) return 'handled' } const desc = this.directory.resolve(pick.session.sessionId, name) if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss + // A decoration replaces the HOST row's bare invocation with its popup; + // it decorates only a resolvable host command (checked above), never + // manufactures one, and never touches the argument claim below. + const decoration = this.live.decorations.get(name) + if (decoration !== undefined && decoration.available(pick.session)) { + this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span }) + return 'handled' + } if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) } // Menu-pick execute consumes the trigger span before the detached run // (scoped event; the input owns the CAS guard). @@ -193,12 +220,21 @@ export class CommandService extends Service implements CommandServiceContract { const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined - this.openPopup(contribution, session, { via: 'enter', token }) + this.openPopup(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } await this.directory.ensureReady(session.sessionId, signal) const desc = this.directory.resolve(session.sessionId, name) if (desc === undefined) return undefined + // Bare enter on a decorated host command opens its popup; an argued line + // never consults the decoration (the claim/detached paths below own it). + if (bare) { + const decoration = this.live.decorations.get(name) + if (decoration !== undefined && decoration.available(session)) { + this.openPopup(name, decoration.ui, session, { via: 'enter', token }) + return 'handled' + } + } if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) } if (!bare) return undefined this.consumeVia(session.sessionId, { via: 'enter', token }) @@ -206,15 +242,16 @@ export class CommandService extends Service implements CommandServiceContract { return 'handled' } - /** Open the session's popup for one contribution (menu pick / bare enter). */ + /** Open the session's popup for one contribution or decoration (menu pick / bare enter). */ private openPopup( - contribution: CommandContribution, + name: string, + ui: CommandContribution['ui'], session: ClientSessionContext, segment: TokenSegment, ): void { const actx = this.scopeFor(session.sessionId) if (actx === undefined) return - this.popupFor(actx).open(contribution.name, contribution.ui, session, segment) + this.popupFor(actx).open(name, ui, session, segment) } /** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */ diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index d3e6b5d34c..1123c2c48c 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' -import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts' +import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandService } from '../src/client/service.ts' @@ -197,6 +197,68 @@ describe('candidates', () => { command.register(themeContribution({ name: 'plan' })) await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command') }) + +}) + +describe('decorations (bare-invocation UI on host commands)', () => { + const goalDecoration = (over: Partial = {}): CommandDecoration => ({ + name: 'goal', + available: () => true, + ui: themeUi(), + ...over, + }) + + it('adds no catalog row: the host row stands alone', async () => { + const { command, source } = await bench() + command.decorate(goalDecoration()) + const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name) + expect(names).toEqual(['plan', 'goal']) + }) + + it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => { + const { command, source, mint, warm } = await bench() + command.decorate(goalDecoration()) + const scope = mint('s1') + await warm(proj('s1')) + expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled') + expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' }) + const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal) + if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim') + expect(argued.claim.token).toBe('/goal ') + }) + + it('space never consults the decoration (host claim)', async () => { + const { command, source, warm } = await bench() + command.decorate(goalDecoration()) + await warm(proj('s1')) + const outcome = source.matchSpace!(proj('s1'), '/goal') + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim') + expect(outcome.claim.token).toBe('/goal ') + }) + + it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => { + const { command, source, mint, warm } = await bench() + command.decorate(goalDecoration({ name: 'phantom' })) + const scope = mint('s1') + await warm(proj('s1')) + expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined() + expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined() + expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) + }) + + it('an unavailable decoration falls through to the host bare path (detached execute)', async () => { + const { command, source, warm, executeCalls } = await bench() + command.decorate(goalDecoration({ name: 'plan', available: () => false })) + await warm(proj('s1')) + expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled') + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + }) + + it('duplicate decoration names fail loud', async () => { + const { command } = await bench() + command.decorate(goalDecoration()) + expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal') + }) }) describe('dispatch (menu column)', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a33f296d5a..c9f0551797 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 5f5708042639a8c8d87b8f09c8e06a2c87bc7117 -README.zh.md: fd6f8603d056112d62edf18ecfd1bcd3721b1c84 +README.md: e2148cfca658196540e3800912dccd0568ae8d0e +README.zh.md: 45f05ce2e03e015701e85f2853a4a656511058a9 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5f57080426..e2148cfca6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,6 +8,8 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); a pick submits the `/permission ` command line through the bar's injected `command` callback. + Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). @@ -32,7 +34,7 @@ None; this package neither assembles nor sends a provider request. - **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source. - **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch) ships; branch remains a chrome stub. +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. -- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project. +- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index fd6f8603d0..45f05ce2e0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,6 +12,8 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 + todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 @@ -32,7 +34,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。 - **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支)已落地;分支仍是 chrome stub。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 -- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。 +- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 2e37415bdc..42812dce24 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -57,6 +57,9 @@ "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 44c4834ec6..2b845e6c83 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -5,7 +5,8 @@ import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/clien import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ViewTab } from './contract/views.ts' import type { - ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected, + ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected, + ConversationSessionInjected, DetailsInjected, } from './contract/slots.ts' import { resolveToolPath } from './contract/tool-call-model.ts' import { createChatStore } from './stores.ts' @@ -15,6 +16,7 @@ import { InputHub } from './input/hub.ts' import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { queueDockEntry } from './queue/QueueDock.tsx' @@ -34,6 +36,11 @@ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { return conversation } +/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */ +function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null { + return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null +} + /** Mounts the conversation plugin. * @param ctx - Client root context. */ @@ -146,11 +153,27 @@ export function apply(ctx: Context): void { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, + command: async (line) => { + const session = sessions.binding(sessionId)?.session + if (session === undefined) return false + const result = await session.command(line) + return result.ok && result.value.matched + }, hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, }, InputBar) + // The approval takeover: a selector-routed entry of the chain this package + // just declared (the ui-question registration pattern; the entry lives here + // because approval answering is core conversation UX, not an optional tool). + // Zero business face — data and verbs both ride the matched carrier. + // priority 1: question takeovers (default 0) win when both kinds are + // pending — a question is a conversation the model is waiting on, while an + // approval only blocks one tool call; answering the question first cannot + // strand the approval (it re-elects the moment the question resolves). + slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel) + // The chat view: first entry of the ring this package just declared. // Declaring the keyed toolview hole here is claiming it: ChatView is the // only component authorized to render per-tool rows. Shares the chat diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 68f5d5e179..a3a05b7af9 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -13,6 +13,7 @@ import { JsonBlock, MarkdownText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { formatMessageClock, writeClipboard } from './message-chrome.ts' +import { useCalendarDay } from './use-calendar-day.ts' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -56,6 +57,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { /** Finalized assistant IconActions (figma 43:32997): copy live; branch stub; clock. */ function AssistantActions({ text, time }: { text: string; time: number }) { + const day = useCalendarDay() const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) @@ -71,7 +73,7 @@ function AssistantActions({ text, time }: { text: string; time: number }) { - {formatMessageClock(time)} + {formatMessageClock(time, day)} ) } diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 71ee48bbc6..0f6b3dc5ae 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,6 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem } from './MessageItem.tsx' -import { PendingCard } from './PendingCard.tsx' import { StatsLine } from './StatsLine.tsx' import css from './ChatView.module.css' @@ -229,7 +228,6 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) - const pending = useSession(s => s.pending) const openState = useSession(s => s.openState) const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) const hasMore = useSession(s => s.hasMore) @@ -383,9 +381,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio ))} )} - {pending.map(item => item.kind === 'approval' - ? - : null)} + {/* No pending placeholders: questions (ui-question) and approvals + (ApprovalPanel) both take over the composer, so a flow card would + double-render the same wait. */} {/* Turn-level loading signal: rides the whole running turn (first-token wait, tool execution, streaming) so it never flickers per step. */} {running && } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index b942937f47..acd2a7023f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -14,6 +14,7 @@ import { JsonBlock, MessageText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { formatMessageClock, writeClipboard } from './message-chrome.ts' +import { useCalendarDay } from './use-calendar-day.ts' import css from './MessageItem.module.css' export interface MessageItemProps { @@ -65,12 +66,13 @@ function projectUserText(text: string): ReactNode { /** User-bubble IconActions (figma 388:20051): clock + copy live; branch/edit stubs. */ function UserActions({ text, time }: { text: string; time: number }) { + const day = useCalendarDay() const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) return (
- {formatMessageClock(time)} + {formatMessageClock(time, day)} + +
+ + + + ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index c9476bd653..a98ee09114 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -6,7 +6,7 @@ * region-slot content) ride the owner props. Session facts * (running/removed/promptError) are self-selected via useSession. */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -15,6 +15,7 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-plan-mode/client' import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' +import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' /** Prompt failure surface (derived from promptError). */ @@ -25,13 +26,8 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps -const READONLY_OPTIONS: readonly { id: string; label: string }[] = [ - { id: 'readonly', label: 'Read-only' }, - { id: 'readwrite', label: 'Read-write' }, -] - export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, useProjection, + useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection, variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) @@ -64,9 +60,9 @@ export function InputBar({ }, 10) } - // Placeholder chrome: Access selection stays local until its seam lands - // (plan/model are real seats now — the named single slots below). - const [readonlyId, setReadonlyId] = useState('readonly') + // The Access seat's data: the host-computed permissions projection + // (undefined = capability absent → the chip renders nothing). + const permissions = useProjection('permissions') // Queue cut 1: running input stays free; locked = session disabled only. // The transient machine locks (adjudicating pending / submitting) render @@ -229,19 +225,10 @@ export function InputBar({ if (!empty && !disabled && !machineBusy) inputActions.submit('queue') } - // Access placeholder select (the one remaining local-chrome control). + // The Access seat: the projection-fed permission chip (renders nothing + // while the permissions key is absent — permission-less host or Draft). const accessSelect: ReactNode = ( - + ) // Mirror-layer decorations: a visible backdrop with transparent text. The diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css new file mode 100644 index 0000000000..dd5986992c --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -0,0 +1,49 @@ +/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a + quiet text chip with a chevron; hover paints the standard interactive pill. + The native select is stretched invisibly over the chip so the platform + dropdown does the menu work — keyboard/AT semantics come free. */ + +.root { + position: relative; + display: inline-flex; + align-items: center; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + border-radius: 8px; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 20px; + pointer-events: none; /* the overlaid select owns the interaction */ +} + +.root:hover .chip { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + color: var(--dsw-alias-label-caption); +} + +/* Invisible native select stretched over the chip: real menu, zero drawing. */ +.select { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + border: none; + cursor: pointer; +} + +.select:disabled { + cursor: default; +} + +.root:has(.select:disabled) .chip { + opacity: 0.5; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx new file mode 100644 index 0000000000..0622e64500 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -0,0 +1,80 @@ +// PermissionSelect: the composer bottom-row permission chip (draft +// start.jpeg's `Read-only ∨` control), the Access seat's wired occupant. +// Options and the current value read from the host-computed `permissions` +// projection (baseline block + push frames — no fetch, no mount timing); +// key absence (a permission-less composition, or a Draft with no host +// session yet) renders nothing. The visible chip is presentation only — an +// invisible native select stretched over it owns the menu and interaction. +// A switch submits the `/permission ` command line (the one write +// path); the control shows the picked value optimistically and disables +// until the admission result, then re-follows the projection — the pushed +// frame confirms the switch, and a failed/unmatched submit falls back to +// the still-authoritative projection value (`custom` is shown as the +// current value but never offered as a target — the host omits it from +// switchable options). + +import { useState } from 'react' +import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' +import css from './PermissionSelect.module.css' + +/** + * Display transform: kebab-case machine names render as title-case labels + * (`workspace-write` → `Workspace Write`). Presentation-only — the wire + * vocabulary and the host's advertised names are untouched; a host-configured + * name that is not kebab-case (contains spaces or uppercase) passes through. + */ +function displayName(name: string): string { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name + return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') +} + +export interface PermissionSelectProps { + /** The host-computed select, or undefined while the capability is absent. */ + value: PermissionSelectValue | undefined + /** Session-removed lock (the bar's chrome disable state). */ + locked: boolean + /** Submit one slash-command line; resolves admission (false = rejected/unmatched). */ + command: (line: string) => Promise +} + +export function PermissionSelect({ value, locked, command }: PermissionSelectProps) { + // Optimistic pick, shown while the admission round-trip runs; null follows + // the projection (the pushed frame lands the confirmed value there). + const [pick, setPick] = useState(null) + if (value === undefined) return null + + const currentValue = pick ?? value.currentValue + const current = value.options.find(option => option.value === currentValue) + + const onChange = (next: string): void => { + if (next === value.currentValue) return + setPick(next) + void command(`/permission ${next}`) + .catch(() => false) + .then(() => { setPick(null) }) + } + + return ( + + ) +} diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 1219b8b4cb..9bb6ba539a 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -1,19 +1,17 @@ // @vitest-environment jsdom // Remaining chat branch tails: MessageItem context/unknown/steering arms, -// user IconActions, StatsLine no-cache join, PendingCard reason strip, +// user IconActions, StatsLine no-cache join, // AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live // with the keyed-slot machinery specs since the tool ring dissolved into // renderSlot.) -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { formatMessageClock } from '../src/client/chat/message-chrome.ts' +import { + formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, +} from '../src/client/chat/message-chrome.ts' import { MessageItem } from '../src/client/chat/MessageItem.tsx' -import { PendingCard } from '../src/client/chat/PendingCard.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' @@ -135,16 +133,43 @@ describe('formatMessageClock', () => { it('prefixes year, month, and day across years', () => { expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05') }) + + it('arms the next local midnight from an in-day instant', () => { + const noon = new Date(2026, 6, 29, 12, 0).getTime() + expect(startOfLocalDay(noon)).toBe(new Date(2026, 6, 29).getTime()) + expect(msUntilNextLocalMidnight(noon)).toBe(12 * 3_600_000) + }) +}) + +describe('useCalendarDay boundary refresh', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('widens a same-day user clock after local midnight', () => { + const dayStart = new Date(2026, 6, 29, 23, 50).getTime() + vi.setSystemTime(dayStart) + const time = new Date(2026, 6, 29, 14, 24).getTime() + render( + , + ) + expect(screen.getByText('14:24')).toBeTruthy() + act(() => { + vi.advanceTimersByTime(msUntilNextLocalMidnight(dayStart) + 1) + }) + expect(screen.getByText('7月29日 14:24')).toBeTruthy() + }) }) describe('small branch tails', () => { - it('PendingCard approval reason renders when present', () => { - const view = render( - ['payload'], vi.fn())} />, - ) - expect(view.getByText('careful')).toBeTruthy() - }) - it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { const view = render( , diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 39d2b8283a..1b4d1ee158 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -78,7 +78,7 @@ async function bench(snapshot: ConversationSnapshot) { const session = createSnapshotStore(snapshot) const list = createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } }, current: SID, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index bee3e89e2e..6985991074 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -123,8 +123,8 @@ describe('bash sample row', () => { return createSnapshotStore({ ids: [ROOT, CHILD], byId: { - [ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 }, - [CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, blank: false, updatedAt: 0 }, + [ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 }, + [CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 0 }, }, current: undefined, phase: 'ready', @@ -158,7 +158,7 @@ describe('bash sample row', () => { const orphan = 'late-child' as SessionId store.update((d) => { d.ids.push(orphan) - d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, blank: false, updatedAt: 0 } + d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, blank: false, updatedAt: 0 } }) const view = render() expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index cd372779f3..328ba38340 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -392,13 +392,18 @@ describe('ChatView', () => { expect(lv.getByText('载入历史…')).toBeTruthy() }) - it('pending interactions render placeholder cards', () => { + it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => { const h = makeHarness({ - pending: [new PendingWait('approval', RpcId('r1'), SID, - { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())], + pending: [ + new PendingWait('approval', RpcId('r1'), SID, + { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()), + new PendingWait('question', RpcId('r2'), SID, + { questions: [{ id: 'q1', question: '选择' }] }, vi.fn()), + ], }) const view = render() - expect(view.getByText(/等待审批/)).toBeTruthy() + expect(view.queryByText(/等待回答/)).toBeNull() + expect(view.queryByText(/等待审批/)).toBeNull() }) it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => { diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index e2c5244ada..5062e0f09e 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // Branch tails the acceptance specs do not reach: ToolRow stopped-state dot, -// PendingCard approval wait, bash sample state dots, the node-half empty +// bash sample state dots, the node-half empty // apply, and AssistantMarkdown reasoning/unknown block arms. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -8,13 +8,10 @@ import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' -import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as nodeApply } from '../src/index.ts' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' import { ToolRow } from '../src/client/chat/ToolRow.tsx' -import { PendingCard } from '../src/client/chat/PendingCard.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { BashRow } from '../src/client/toolviews/bash-sample.tsx' @@ -33,13 +30,6 @@ describe('tails', () => { expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() }) - it('PendingCard renders the approval wait with its tool name', () => { - const view = render( - ['payload'], vi.fn())} />, - ) - expect(view.getByText(/等待审批/)).toBeTruthy() - }) - it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], - byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, + byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', }) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index dbd9b180bd..23d853dd1a 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -35,6 +35,7 @@ interface BenchOptions { modelEntry?: React.ReactNode /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */ lexicon?: ReadonlyMap<'/' | '@', readonly string[]> + permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string } draft?: string running?: boolean disabled?: boolean @@ -90,14 +91,15 @@ function bench(over?: BenchOptions) { items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), - useProjection: ((_key: string, selector?: (v: unknown) => unknown) => - (selector ?? (v => v))(over?.plan)), + useProjection: ((key: string, selector?: (v: unknown) => unknown) => + (selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), stop, + command: () => Promise.resolve(true), renderSlot, variant: over?.variant ?? 'composer', ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), @@ -368,16 +370,37 @@ describe('strips and variants', () => { }) describe('placeholder chrome and control seats', () => { - it('renders attach + Access placeholder; plan/model seats render EMPTY without entries (B ruling)', () => { + it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { const { view, slotCalls } = bench() expect(view.getByLabelText('Add attachment')).toBeTruthy() - expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly') + // Capability absent (no projection value): the chip renders nothing. + expect(view.queryByLabelText('Access mode')).toBeNull() // Both seats dispatched, nothing rendered. expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model']) expect(view.queryByLabelText('Plan mode')).toBeNull() expect(view.queryByLabelText('Model')).toBeNull() }) + it('the Access chip renders the projection value and submits /permission on pick', async () => { + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view } = bench({ permissions }) + const select = view.getByLabelText('Access mode') as HTMLSelectElement + expect(select.value).toBe('workspace-write') + // Title-case display is presentation only; the option values stay machine names. + expect([...select.options].map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) + fireEvent.change(select, { target: { value: 'danger-full-access' } }) + // Optimistic pick + disable until admission resolves (command stub resolves true). + expect(select.disabled).toBe(true) + await act(async () => {}) + expect(select.disabled).toBe(false) + }) + it('a registered entry fills its seat and receives the locked owner prop', () => { const { view, slotCalls } = bench({ disabled: true, @@ -393,12 +416,13 @@ describe('placeholder chrome and control seats', () => { expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true) }) - it('disabled locks the Access placeholder and attach control (running does not)', () => { - const { view } = bench({ disabled: true }) + it('disabled locks the Access chip and attach control (running does not)', () => { + const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } + const { view } = bench({ disabled: true, permissions }) expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true) cleanup() - const live = bench({ running: true }) + const live = bench({ running: true, permissions }) expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false) }) }) diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 29769afd5d..f6694f8cb4 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -47,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), + command: () => Promise.resolve(true), variant: 'composer', } return render() diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 4464f4876c..9d9ace032c 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -133,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), + command: () => Promise.resolve(true), variant: 'composer', } const view = render() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 9d34608156..0d32e2edea 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -64,8 +64,8 @@ function mount( const sessions = createSnapshotStore({ ids: [root, SID], byId: { - [root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }, - [SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 }, + [root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }, + [SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 }, }, current: SID, phase: 'ready', @@ -123,6 +123,7 @@ function mount( useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} stop={stop} + command={() => Promise.resolve(true)} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} /> diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index c08f4b28f5..14ae91598a 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -43,6 +43,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../ui/permission" } ], "exclude": [ diff --git a/packages/client/ui-permission/README.i18n.yaml b/packages/client/ui-permission/README.i18n.yaml new file mode 100644 index 0000000000..f963bb9dda --- /dev/null +++ b/packages/client/ui-permission/README.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 packages/client/ui-permission/README.md +README.md: 0cd8e7f878a151ffacd749eb625afcb20d44ad93 +README.zh.md: 6bc299529c9795ef44cbe5429e78d6355c02a6ca diff --git a/packages/client/ui-permission/README.md b/packages/client/ui-permission/README.md new file mode 100644 index 0000000000..0cd8e7f878 --- /dev/null +++ b/packages/client/ui-permission/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-client-ui-permission + +English | [中文](README.zh.md) + +Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row). + +The `/client` export surface is the plugin body (`apply`/`inject`). + +## Model Experience + +Indirectly, through the host `/permission` command the picker submits: a switch appends the whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), which select the sandbox mode and approval policy later tool calls resolve. Picker interaction adds no prompt content. + +#### KV Cache effect + +No direct invalidation; the knob consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **No keyless snapshot exercises the picker yet** — the popup flow is covered by unit specs over fake faces; the assembled-transcript scenario rides the deferred approval/preset e2e work. diff --git a/packages/client/ui-permission/README.zh.md b/packages/client/ui-permission/README.zh.md new file mode 100644 index 0000000000..6bc299529c --- /dev/null +++ b/packages/client/ui-permission/README.zh.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-client-ui-permission + +[English](README.md) | 中文 + +权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)。 + +`/client` 导出面为插件本体(`apply`/`inject`)。 + +## Model Experience + +间接影响,经由选择框提交的 host `/permission` 命令:一次切换追加全量值旋钮事件(`permission/preset`、`sandbox/mode`、`approval/policy`),决定后续工具调用解析到的沙箱模式与审批策略。选择框交互本身不添加任何提示词内容。 + +#### KV Cache effect + +无直接失效;请求前缀的变化由旋钮消费方自行承担。 + +## Known Limitations and Deferred Work + +- **尚无无密钥快照覆盖选择框** —— popup 流程由基于 fake face 的单元 spec 覆盖;组装态转写场景随延后的审批/预设 e2e 工作一并补齐。 diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json new file mode 100644 index 0000000000..cee54f104c --- /dev/null +++ b/packages/client/ui-permission/package.json @@ -0,0 +1,61 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-permission", + "description": "Permission preset selection: the /permission popupSelect over the permissions projection and the host /permission command", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-command" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-command": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-permission": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-command": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts new file mode 100644 index 0000000000..42043ee008 --- /dev/null +++ b/packages/client/ui-permission/src/client/index.ts @@ -0,0 +1,70 @@ +/** + * Permission preset plugin, browser half — a popupSelect DECORATION hung on + * the host `/permission` command: one flat list of presets, current value + * marked active, a pick executes the switch. The decoration owns only the + * bare invocation; the host command keeps its catalog row, the argued path + * (`/permission ` still switches directly), and the lifecycle + * logging. Options and the active mark read the session's `permissions` + * projection (the same host-computed select the composer chip renders); a + * pick submits the `/permission ` command line, so both surfaces + * write through one path and the pushed projection frame is the one + * confirmation. + */ +import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' +import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' + +/** Required services (cordis fiber inject). */ +export const inject = ['command', 'sessions'] + +/** Read one session's current permissions projection value (undefined = capability absent). */ +function selectOf(session: SessionFace | undefined): PermissionSelect | undefined { + return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined +} + +/** Flatten the projection select into popup rows; `custom` is display state, never a target. */ +function optionsOf(value: PermissionSelect): SelectOption[] { + return value.options + .filter(option => option.value !== 'custom') + .map(option => ({ + id: option.value, + label: option.name, + ...(option.description !== undefined ? { detail: option.description } : {}), + ...(option.value === value.currentValue ? { active: true } : {}), + })) +} + +/** + * Client plugin body: register the /permission popup picker over the + * permissions projection. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + const command = ctx.get('command') as CommandServiceContract + const sessions = ctx.sessions + const sessionFor = (session: ClientSessionContext): SessionFace | undefined => + sessions.binding(session.sessionId)?.session + ctx.effect(() => command.decorate({ + name: 'permission', + // The picker exists exactly while the projection does: a permission-less + // host serves no key and the bare invocation falls through to the host + // command (which is absent too — the line simply misses). + available: session => selectOf(sessionFor(session)) !== undefined, + ui: { + kind: 'popupSelect', + options: (session) => { + const value = selectOf(sessionFor(session)) + if (value === undefined) throw new Error('permission presets are not available on this host') + return Promise.resolve(optionsOf(value)) + }, + onSelect: async (option, session) => { + const live = sessionFor(session) + if (live === undefined) throw new Error('this session is not materialized yet') + const result = await live.command(`/permission ${option.id}`) + if (!result.ok) throw new Error(`permission switch failed: ${result.error.code}: ${result.error.message}`) + if (!result.value.matched) throw new Error('the host offers no /permission command') + }, + }, + }), 'ui-permission: /permission decoration') +} diff --git a/packages/client/ui-permission/src/index.ts b/packages/client/ui-permission/src/index.ts new file mode 100644 index 0000000000..5359562972 --- /dev/null +++ b/packages/client/ui-permission/src/index.ts @@ -0,0 +1,9 @@ +/** + * Permission preset selection plugin, node half. Pure UI plugin: the empty + * apply exists so the plugin appears in the host cordis.yml / Loader; the + * browser half ships via exports["./client"], discovered through the + * package.json dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-permission/src/invariant.ts b/packages/client/ui-permission/src/invariant.ts new file mode 100644 index 0000000000..c0fd33a80b --- /dev/null +++ b/packages/client/ui-permission/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-permission`. + * @module @deepseek-ai/dsh-client-ui-permission/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-permission' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-permission-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a single command contribution registration whose disposal is + * proven by the HMR-safety spec — it emits no cordis events and owns no + * cross-plugin mutable state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..167cf17362 --- /dev/null +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -0,0 +1,115 @@ +/** + * ui-permission browser half on a real cordis Context with fake command/ + * sessions faces: the plugin hangs the /permission popup decoration on the + * host command; options flatten the session's permissions projection with + * the current value active and `custom` excluded; availability follows the + * projection key's presence; a pick submits the /permission line through + * Session.command and surfaces rejection/unmatched as thrown errors; fiber + * disposal removes the contribution (HMR safety). + */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client' +import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' +import { apply, inject } from '../src/client/index.ts' + +const sid = (k: string): SessionId => k as SessionId + +const SELECT: PermissionSelect = { + options: [ + { value: 'read-only', name: 'read-only', description: 'Reads only.' }, + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', +} + +async function bench() { + const ctx = new Context() + let decoration: CommandDecoration | undefined + ctx.provide('command', { + decorate(c: CommandDecoration) { + decoration = c + return () => { decoration = undefined } + }, + }) + const values = new Map() + const commands: string[] = [] + let commandResult: { ok: boolean; matched?: boolean } = { ok: true, matched: true } + const session = (id: SessionId) => ({ + projections: { + faceOf: (key: string) => ({ + getSnapshot: () => (key === 'permissions' ? values.get(id) : undefined), + subscribe: () => () => {}, + }), + }, + command: (line: string) => { + commands.push(line) + return Promise.resolve(commandResult.ok + ? { ok: true as const, value: { matched: commandResult.matched ?? true } } + : { ok: false as const, error: { code: 'internal', message: 'boom' } }) + }, + }) + ctx.provide('sessions', { + binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined), + }) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { + ctx, fiber, values, commands, + setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, + decoration: () => decoration, + } +} + +describe('ui-permission browser plugin', () => { + it('hangs the /permission popup decoration on the host command', async () => { + const b = await bench() + const c = b.decoration()! + expect(c.name).toBe('permission') + expect(c.ui.kind).toBe('popupSelect') + }) + + it('availability follows the projection key; options mark the current value active and exclude custom', async () => { + const b = await bench() + const c = b.decoration()! + const proj = { sessionId: sid('s1') } + expect(c.available(proj)).toBe(false) + b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' }) + expect(c.available(proj)).toBe(true) + const options = await c.ui.options(proj, new AbortController().signal) + expect(options.map(option => option.id)).toEqual(['read-only', 'workspace-write', 'danger-full-access']) + expect(options.every(option => option.active !== true)).toBe(true) + b.values.set(sid('s1'), SELECT) + const again = await c.ui.options(proj, new AbortController().signal) + expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) + expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') + // A projection that vanished between availability and open throws. + expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal)) + .toThrow(/not available on this host/) + }) + + it('a pick submits the /permission line; rejection and unmatched throw', async () => { + const b = await bench() + const c = b.decoration()! + const proj = { sessionId: sid('s1') } + b.values.set(sid('s1'), SELECT) + await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj) + expect(b.commands).toEqual(['/permission danger-full-access']) + b.setResult({ ok: false }) + await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/) + b.setResult({ ok: true, matched: false }) + await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/) + // An unmaterialized session throws before any submit. + await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') })) + .rejects.toThrow(/not materialized/) + }) + + it('disposal removes the decoration (HMR safety)', async () => { + const b = await bench() + expect(b.decoration()).toBeDefined() + await b.fiber.dispose() + expect(b.decoration()).toBeUndefined() + }) +}) diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json new file mode 100644 index 0000000000..b66ce746b2 --- /dev/null +++ b/packages/client/ui-permission/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-command" + }, + { + "path": "../ui-slash" + }, + { + "path": "../../ui/permission" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-permission/tsdown.config.ts b/packages/client/ui-permission/tsdown.config.ts new file mode 100644 index 0000000000..a98451eaa7 --- /dev/null +++ b/packages/client/ui-permission/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-permission', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 820ff3d7a3..ef790c8b6a 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -12,13 +12,16 @@ import css from './Modal.module.css' * Render a centered modal over a blurred page mask. * @param props.open - whether the dialog is showing. * @param props.onClose - Escape or mask click. - * @param props.title - dialog heading. + * @param props.title - dialog heading (aria-label in every mode). * @param props.description - optional supporting sentence under the title. * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). + * @param props.headless - render children directly in the card (no default + * header/close/body chrome) for dialogs whose figma frame owns its own + * header structure; mask, card, Escape, and aria-label remain. * @returns null when closed; otherwise the overlay tree. */ -export function Modal({ open, onClose, title, description, children, footer, className }: { +export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: { open: boolean onClose: () => void title: string @@ -26,6 +29,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla children?: ReactNode footer?: ReactNode className?: string + headless?: boolean }) { useEffect(() => { if (!open) return @@ -47,19 +51,25 @@ export function Modal({ open, onClose, title, description, children, footer, cla aria-modal="true" aria-label={title} > -
-
-

{title}

- -
- {description !== undefined && description !== '' && ( -

{description}

+ {headless + ? children + : ( + <> +
+
+

{title}

+ +
+ {description !== undefined && description !== '' && ( +

{description}

+ )} + {children !== undefined &&
{children}
} +
+ {footer !== undefined &&
{footer}
} + )} - {children !== undefined &&
{children}
} -
- {footer !== undefined &&
{footer}
} ) diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts index af85c27f97..68f509b916 100644 --- a/packages/client/ui-slots/src/deferred.ts +++ b/packages/client/ui-slots/src/deferred.ts @@ -36,13 +36,22 @@ export interface DeferredRegistration { * @param name - target slot name. * @param component - the component whose ledger presence marks "registered". * @param register - performs the actual registration; returns its disposer. + * @param onFailure - owns a registration failure that fires from a LATER + * ledger flush (a declaration landing after two providers deferred, say): + * the deferral first removes its own subscription, then hands the error + * over instead of throwing through the flush — the callback's chance to + * roll back sibling deferrals and surface the conflict on a loud channel. + * Absent, a late failure rethrows out of the flush. * @returns the deferral handle (dispose in the owning effect's disposer). + * @throws the immediate registration's failure, after removing the + * just-installed subscription — a throwing construction leaves nothing live. */ export function deferRegistration( registry: DeferralRegistry, name: string, component: unknown, register: () => () => void, + onFailure?: (error: unknown) => void, ): DeferredRegistration { let dispose: (() => void) | undefined const tryRegister = (): void => { @@ -50,8 +59,24 @@ export function deferRegistration( if (registry.entries(name).some(e => e.component === component)) return dispose = register() } - const unsubscribe = registry.subscribe(name, () => { tryRegister() }) - tryRegister() + const unsubscribe = registry.subscribe(name, () => { + try { + tryRegister() + } catch (error) { + unsubscribe() + if (onFailure === undefined) throw error + onFailure(error) + } + }) + try { + tryRegister() + } catch (error) { + // A synchronous registration failure (the declared slot is already + // occupied) must not leave the just-installed subscription behind: the + // caller receives no handle to dispose it through. + unsubscribe() + throw error + } return { refresh() { dispose?.() @@ -64,3 +89,40 @@ export function deferRegistration( }, } } + +/** + * Defer ONE occupant into several holes as a unit. Construction that throws + * partway (a declared hole already occupied registers synchronously) rolls + * every earlier deferral back before rethrowing; a failure surfacing from a + * LATER ledger flush (holes declared after rival providers activated) rolls + * the whole group back the same way and re-raises the wrapped error on the + * global channel the boot's fail-loud handler owns — never a throw through + * the slot flush, never partial occupancy from the group's owner. + * @param registry - the slot registry face. + * @param names - the target holes (one registration per name). + * @param component - the occupant whose ledger presence marks "registered". + * @param register - performs one hole's registration; returns its disposer. + * @returns the group handle (dispose in the owning effect's disposer). + * @throws the immediate registration's failure, after rolling the group back. + */ +export function deferGroupRegistration( + registry: DeferralRegistry, + names: readonly K[], + component: unknown, + register: (name: K) => () => void, +): { dispose: () => void } { + const deferred: DeferredRegistration[] = [] + const lateFailure = (error: unknown): void => { + for (const entry of deferred) entry.dispose() + queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) }) + } + try { + for (const name of names) { + deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure)) + } + } catch (error) { + for (const entry of deferred) entry.dispose() + throw error + } + return { dispose: () => { for (const entry of deferred) entry.dispose() } } +} diff --git a/packages/client/ui-slots/tests/deferred.spec.ts b/packages/client/ui-slots/tests/deferred.spec.ts new file mode 100644 index 0000000000..6e7b983852 --- /dev/null +++ b/packages/client/ui-slots/tests/deferred.spec.ts @@ -0,0 +1,126 @@ +// deferRegistration lifecycle: declaration-aware registration, HMR +// re-registration, and — the failure contract — no subscription survives a +// construction that throws synchronously (an already-occupied single slot). +import { describe, expect, it, vi } from 'vitest' +import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots' + +// Shares the merges declared by core.spec.ts (same program); reuse its keys. +const HOLE = 'test.single' as const + +function declared(): SlotCore { + const core = new SlotCore() + core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never) + return core +} + +describe('deferRegistration', () => { + it('registers immediately under an existing declaration and disposes cleanly', () => { + const core = declared() + const component = (): null => null + const handle = deferRegistration(core, HOLE, component, () => + core.register({ name: HOLE } as never, component as never)) + expect(core.entries(HOLE)).toHaveLength(1) + handle.dispose() + expect(core.entries(HOLE)).toHaveLength(0) + }) + + it('hands a late registration failure to onFailure after unsubscribing itself', async () => { + const core = new SlotCore() + const component = (): null => null + const foreign = (): null => null + const failures: unknown[] = [] + // Nothing is declared yet: the deferral just subscribes and waits. + const register = vi.fn(() => core.register({ name: HOLE } as never, component as never)) + deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) }) + // The declaration lands with a foreign occupant racing in first: the + // deferral's flush-time attempt fails, unsubscribes itself, and reports + // through onFailure instead of throwing out of the flush. + core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never) + const disposeForeign = core.register({ name: HOLE } as never, foreign as never) + await Promise.resolve() + expect(failures.map(String).join('')).toContain('already has a registration') + // Unsubscribed: freeing the hole must not resurrect the loser. + disposeForeign() + await Promise.resolve() + expect(core.entries(HOLE)).toHaveLength(0) + }) + + it('drops its subscription when the immediate registration throws', async () => { + const core = declared() + const foreign = (): null => null + const disposeForeign = core.register({ name: HOLE } as never, foreign as never) + const component = (): null => null + const register = vi.fn(() => core.register({ name: HOLE } as never, component as never)) + // The single hole is occupied: the immediate attempt throws out of the + // constructor, and the caller never receives a handle to dispose. + expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/) + expect(register).toHaveBeenCalledOnce() + // The subscription rolled back with it: freeing the hole flushes a + // notification that must not resurrect the failed registration. + disposeForeign() + await Promise.resolve() + expect(register).toHaveBeenCalledOnce() + expect(core.entries(HOLE)).toHaveLength(0) + }) +}) + +describe('deferGroupRegistration', () => { + const HOLES = ['test.single', 'test.grandchild'] as const + + function declaredPair(): SlotCore { + const core = new SlotCore() + core.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, (() => null) as never) + return core + } + + it('registers the whole group and disposes it as a unit', () => { + const core = declaredPair() + const component = (): null => null + const group = deferGroupRegistration(core, HOLES, component, name => + core.register({ name } as never, component as never)) + for (const name of HOLES) expect(core.entries(name)).toHaveLength(1) + group.dispose() + for (const name of HOLES) expect(core.entries(name)).toHaveLength(0) + }) + + it('rolls the group back when construction fails partway', () => { + const core = declaredPair() + const component = (): null => null + core.register({ name: HOLES[1] } as never, (() => null) as never) + expect(() => deferGroupRegistration(core, HOLES, component, name => + core.register({ name } as never, component as never))).toThrow(/already has a registration/) + // The first hole's registration and subscription rolled back with it. + expect(core.entries(HOLES[0])).toHaveLength(0) + }) + + it('rolls the group back and re-raises loudly on a late conflict', async () => { + const core = new SlotCore() + const component = (): null => null + const failures: unknown[] = [] + const onLoud = (reason: unknown): void => { failures.push(reason) } + process.on('uncaughtException', onLoud) + try { + const group = deferGroupRegistration(core, HOLES, component, name => + core.register({ name } as never, component as never)) + // Declaration lands with a rival racing in ahead of the flush. + core.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, (() => null) as never) + core.register({ name: HOLES[0] } as never, (() => null) as never) + core.register({ name: HOLES[1] } as never, (() => null) as never) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(failures.map(String).join('')).toContain('already has a registration') + // No partial occupancy from the group's owner survives. + for (const name of HOLES) { + expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0) + } + group.dispose() + } finally { + process.off('uncaughtException', onLoud) + } + }) +}) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 0d78a8d648..00b639f625 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: 8acf819121b46512d38b39ff858bb2bf797cfe96 +README.zh.md: e97d93f7e38d00af91b43f0df9fb0e3b17ae8ed5 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index edd6c2f937..8acf819121 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. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **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. @@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions. -- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. +- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f7b73dde95..e97d93f7e3 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 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -19,4 +19,4 @@ ## 已知限制与暂缓事项 - **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 -- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。 +- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index dfca686a6e..7effff36b5 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -253,7 +253,8 @@ export function WorkspaceBrowser({ deleteWorkspace, insertSessionBefore, createWorkspace, - pickDirectory, + useDirectoryFlow, + renderSlot, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) @@ -371,7 +372,8 @@ export function WorkspaceBrowser({ anchorRef={wsPlusRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} - pickDirectory={pickDirectory} + useDirectoryFlow={useDirectoryFlow} + renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)} createOnly side="right" onPick={(workspaceId) => { diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index f33e7dc526..903b0e115b 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -2,10 +2,12 @@ * Workspace pick/create flow. WorkspaceCreateFlow is the reusable core * (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same * package) and wrapped by WorkspacePicker for the conversation empty-state - * slot registration. + * slot registration. Directory picking itself lives in the composed flow + * package's slot occupant (see the contract module doc): this core only + * opens the flow, adopts the picked path, and owns the error surface. */ -import type { RefObject } from 'react' -import { useCallback, useRef, useState } from 'react' +import type { ReactNode, RefObject } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -13,7 +15,8 @@ import { WorkspaceCreateError, type WorkspaceId, type WorkspaceListState, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspacePickerProps } from './contract/slots.ts' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' const OPEN_LOCAL_FOLDER = '::open-local-folder' @@ -31,8 +34,10 @@ export interface WorkspaceCreateFlowProps { useWorkspaces: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Open the Host's native single-directory picker. */ - pickDirectory: () => Promise + /** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */ + useDirectoryFlow: SnapshotSelectorHook + /** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */ + renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode /** A real Workspace was picked or created. */ onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ @@ -55,7 +60,8 @@ export function WorkspaceCreateFlow({ anchorRef, useWorkspaces, createWorkspace, - pickDirectory, + useDirectoryFlow, + renderDirectoryFlow, onPick, onClose, createOnly = false, @@ -72,16 +78,37 @@ export function WorkspaceCreateFlow({ const [workspaceName, setWorkspaceName] = useState('') const [creating, setCreating] = useState(false) const [modalError, setModalError] = useState(null) + const [flowOpen, setFlowOpen] = useState(false) const [pickingFolder, setPickingFolder] = useState(false) const [folderConflict, setFolderConflict] = useState(false) const composingRef = useRef(false) + // One picking interaction at a time: while the flow is open (native chooser + // pending, browse dialog up) or its pick is being adopted, every other + // menu action stays disabled — a late outcome must not race a concurrent + // selection or creation. + const flowBusy = flowOpen || pickingFolder const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) + // The occupied hole gates the picking affordance: with no composed flow the + // entry simply is not there (the seam's documented no-flow default). The + // framework-bound hook keeps occupancy live: flow plugins activate (and + // HMR-reload) independently of this menu's renders. + const flowAvailable = useDirectoryFlow(occupied => occupied) + // An occupant that unloads mid-interaction leaves nobody to cancel: an + // open flow over an empty hole withdraws so the menu actions come back. + // flowOpen is a dependency because the flow can also OPEN over an already + // empty hole (Choose again after the occupant unloaded with the error + // dialog up) — that transition must snap back too, not just occupancy loss. + useEffect(() => { + if (flowOpen && !flowAvailable) setFlowOpen(false) + }, [flowOpen, flowAvailable]) const createEntries: MenuEntry[] = [ - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, - { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, + ...(flowAvailable + ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: flowBusy }] + : []), + { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: flowBusy }, ] // With workspaces listed, the create actions pin below the scroll region // (divider + always visible); otherwise they ARE the menu. @@ -91,7 +118,7 @@ export function WorkspaceCreateFlow({ id: workspace.workspaceId, label: workspace.title, icon: , - disabled: pickingFolder, + disabled: flowBusy, })) : createEntries @@ -101,15 +128,10 @@ export function WorkspaceCreateFlow({ setModalError(null) } - const openLocalFolder = (): void => { - onClose() - setModalKind(null) - setModalError(null) - setFolderConflict(false) - setPickingFolder(true) - void pickDirectory().then(async (path) => { - if (path === null) return - const workspace = await createWorkspace({ path }) + /** Adopt a picked directory; failures land in the folder-error dialog (Choose again reopens the flow). */ + const adoptDirectory = (path: string): Promise => + createWorkspace({ path }).then((workspace) => { + setFlowOpen(false) onPick(workspace.workspaceId) }).catch((reason: unknown) => { setFolderConflict( @@ -117,8 +139,33 @@ export function WorkspaceCreateFlow({ && reason.rpcError.code === 'workspace-name-conflict', ) setModalError(reason instanceof Error ? reason.message : String(reason)) + setFlowOpen(false) setModalKind('folder-error') - }).finally(() => { setPickingFolder(false) }) + }) + + const openLocalFolder = (): void => { + onClose() + setModalKind(null) + setModalError(null) + setFolderConflict(false) + setFlowOpen(true) + } + + /** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */ + const flowOwner: DirectoryFlowOwnerProps = { + open: flowOpen, + busy: pickingFolder, + onPicked: (path) => { + setPickingFolder(true) + void adoptDirectory(path).finally(() => { setPickingFolder(false) }) + }, + onCancel: () => { setFlowOpen(false) }, + onError: (message) => { + setFlowOpen(false) + setFolderConflict(false) + setModalError(message) + setModalKind('folder-error') + }, } const handleSelect = (id: string): void => { @@ -172,6 +219,7 @@ export function WorkspaceCreateFlow({ getAnchorRect={getAnchorRect} /> {open && workspaceSnapshot.phase === 'pending' &&
Loading workspaces…
} + {renderDirectoryFlow(flowOwner)} - + {/* Retrying needs an occupant to serve the flow; without one the + * button would open a flow nobody can answer or cancel. */} + )} > @@ -249,7 +299,8 @@ export function WorkspacePicker({ onPick, onClose, createWorkspace, - pickDirectory, + useDirectoryFlow, + renderSlot, }: WorkspacePickerProps) { return ( renderSlot('conversation.hero.workspace.directoryFlow', owner)} selectedId={selectedId} onPick={onPick} onClose={onClose} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index fcdd0e304e..63b60d8051 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -7,8 +7,19 @@ * consumes the shell's two-fact owner share (wide / expandSidebar). * - WorkspacePicker fills the conversation empty-state hole (menu + * create dialogs shared with the browser). + * + * Each registration also declares one **directory-flow hole** (`single` + * kind): the slot a composed picker package's client half fills with its + * picking interaction — a renderless native-chooser driver or an in-app + * browsing dialog. ui-workspace owns the trigger (the "Open local folder…" + * menu entry, shown only while the hole is occupied) and the adoption + * semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose + * again); the occupant owns everything between `open` and the picked path. + * Two holes exist because the two menu surfaces are independent slot entries + * and a hole has exactly one declaring entry — they carry the same owner + * contract and the same occupant. */ -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -16,12 +27,64 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { createWorkspaceViewStore } from '../stores.ts' +/** + * Owner share of the directory-flow holes: the complete conversation between + * the trigger surface and the picking interaction. The occupant reads `open` + * to run/render its interaction and reports exactly one outcome per open. + */ +export interface DirectoryFlowOwnerProps { + /** True while a picking interaction is requested; flipping back to false withdraws the request. */ + open: boolean + /** True while the owner adopts a picked path (`createWorkspace` in flight); occupants disable their commit affordances. */ + busy: boolean + /** The operator picked a directory (absolute host path); the owner adopts it. */ + onPicked: (path: string) => void + /** The operator dismissed the interaction; the owner just closes the flow. */ + onCancel: () => void + /** The interaction itself failed (chooser missing, listing denied); the owner shows its error surface. */ + onError: (message: string) => void +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Directory-flow hole under the conversation empty-state picker (declared by the WorkspacePicker entry). */ + 'conversation.hero.workspace.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps } + /** Directory-flow hole under the sidebar browsing region (declared by the WorkspaceBrowser entry). */ + 'sidebar.workspaces.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps } + } +} + +/** The two directory-flow holes; a flow package's client half registers its one component into both. */ +export type DirectoryFlowSlotName = + | 'conversation.hero.workspace.directoryFlow' + | 'sidebar.workspaces.directoryFlow' + +/** + * Directory-picking share both trigger surfaces consume. Occupancy rides the + * inject face's reserved `hooks` compartment: the renderer binds the source + * into the `useDirectoryFlow` selector hook, so an empty hole hides the + * "Open local folder…" entry reactively and the surface withdraws an open + * flow whose occupant unloaded mid-interaction (nobody is left to cancel). + */ +export type DirectoryPickingInjected = { + hooks: { + /** True while this surface's directory-flow hole is occupied. */ + directoryFlow: HostObservable + } +} + +/** Component-side view of the picking share: the bound occupancy selector hook. */ +export type DirectoryPickingHooks = { + /** Selector hook over this surface's directory-flow occupancy. */ + useDirectoryFlow: SnapshotSelectorHook +} + /** * Browser-private injected share (arrives via the register inject factory). * Data reads use the global framework hooks; these are the Host actions the * browsing region drives. */ -export type WorkspaceBrowserInjected = { +export type WorkspaceBrowserInjected = DirectoryPickingInjected & { /** * Start a New Session in a Workspace: reuse-or-create its blank session * and open it; with no workspace, clear the selection into the New Session @@ -42,26 +105,24 @@ export type WorkspaceBrowserInjected = { insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Ask the local Host to open its native single-directory picker. */ - pickDirectory: () => Promise } /** Full browser props: shell owner share + viewing store + injected actions. */ export type WorkspaceBrowserProps = PropsRuntime<'sidebar.workspaces'> + & PropsRenderSlots<'sidebar.workspaces.directoryFlow'> & PropsStore> - & WorkspaceBrowserInjected + & Omit + & DirectoryPickingHooks /** * Picker-private injected share. Pick semantics remain in the owner's onPick * callback; this callback creates only the real Host Workspace. A type alias * supplies the implicit index signature required by the registry. */ -export type WorkspacePickerInjected = { +export type WorkspacePickerInjected = DirectoryPickingInjected & { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Ask the local Host to open its native single-directory picker. */ - pickDirectory: () => Promise } /** @@ -70,4 +131,7 @@ export type WorkspacePickerInjected = { * currency, so one composed type serves both registrations. */ export type WorkspacePickerProps = - PropsRuntime<'conversation.hero.workspace'> & WorkspacePickerInjected + PropsRuntime<'conversation.hero.workspace'> + & PropsRenderSlots<'conversation.hero.workspace.directoryFlow'> + & Omit + & DirectoryPickingHooks diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index f27448f926..1cf5a7ae5a 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -3,9 +3,13 @@ * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), * and WorkspacePicker fills the conversation hero's picker hole * (`conversation.hero.workspace` — both hero forms). Both read real Host - * Workspaces through the global useWorkspaces hook. Export discipline: + * Workspaces through the global useWorkspaces hook, and each declares its + * own `single` directory-flow child hole for the composed picker package's + * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' import { createWorkspaceViewStore } from './stores.ts' @@ -13,6 +17,7 @@ import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' export type { + DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, } from './contract/slots.ts' @@ -33,6 +38,14 @@ export const inject = ['slots', 'sessions', 'workspaces'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + // Stable per-surface occupancy sources (the renderer's hook cache keys by + // source identity): true while the surface's directory-flow hole is filled. + const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable => ({ + getSnapshot: () => ctx.slots.entries(hole).length > 0, + subscribe: listener => ctx.slots.subscribe(hole, listener), + }) + const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') + const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session rides // the runtime's shared action (recent-Workspace projection inside). @@ -44,48 +57,40 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), - pickDirectory: () => ctx.workspaces.pickDirectory(), + hooks: { directoryFlow: browserFlowSource }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), - pickDirectory: () => ctx.workspaces.pickDirectory(), + hooks: { directoryFlow: pickerFlowSource }, }) - // Declaration-aware registration: each owner's declaring apply may activate - // after this one (entry activation order is unconstrained), and a register - // into an undeclared slot throws. Register once the declaration is on the - // ledger; the subscription also re-registers after an HMR collapse - // re-declares the slot (the cascade disposed our entry with it). + // Declaration-aware registration (deferRegistration): each owner's + // declaring apply may activate after this one, and a register into an + // undeclared slot throws; the deferral also re-registers after an HMR + // collapse re-declares the slot. Each registration declares its own + // directory-flow child hole in the same call (declaration = render + // authorization, one table). ctx.effect(() => { - const registrations = [ - { - name: 'sidebar.workspaces' as const, - component: WorkspaceBrowser, - register: () => ctx.slots.register( - { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, + const deferred = [ + deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () => + ctx.slots.register( + { + name: 'sidebar.workspaces', + children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } }, + store: createWorkspaceViewStore(), + inject: browserInjected, + }, WorkspaceBrowser, - ), - }, - { - name: 'conversation.hero.workspace' as const, - component: WorkspacePicker, - register: () => ctx.slots.register( - { name: 'conversation.hero.workspace', inject: pickerInjected }, + )), + deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () => + ctx.slots.register( + { + name: 'conversation.hero.workspace', + children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } }, + inject: pickerInjected, + }, WorkspacePicker, - ), - }, + )), ] - const disposers = new Map void>() - const tryRegister = (entry: (typeof registrations)[number]): void => { - if (ctx.slots.spec(entry.name) === undefined) return - if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return - disposers.set(entry.name, entry.register()) - } - const unsubscribers = registrations.map(entry => - ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) - for (const entry of registrations) tryRegister(entry) - return () => { - for (const unsubscribe of unsubscribers) unsubscribe() - for (const dispose of disposers.values()) dispose() - } + return () => { for (const entry of deferred) entry.dispose() } }, 'ui-workspace: browser + picker registrations') } diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 24434f22aa..817416663e 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -14,17 +14,16 @@ async function bench() { path: 'name' in input ? `/projects/${input.name}` : input.path, title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) - const pickDirectory = vi.fn(async () => '/tmp/picked') 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, 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, startSession, rename, insertSessionBefore, open, clear } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -73,14 +72,35 @@ describe('ui-workspace apply', () => { expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') await browser.createWorkspace({ name: 'project' }) expect(b.create).toHaveBeenCalledWith({ name: 'project' }) - await browser.pickDirectory() - expect(b.pickDirectory).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) + }) + + it('declares the two directory-flow holes and reports their occupancy per surface', async () => { + const b = await bench() + declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace') + await b.ctx.plugin({ inject: [...inject], apply }).await() + // Registration declared the child holes (declaration = render authorization). + expect(b.slots.spec('sidebar.workspaces.directoryFlow')).toMatchObject({ kind: 'single' }) + expect(b.slots.spec('conversation.hero.workspace.directoryFlow')).toMatchObject({ kind: 'single' }) + + const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() + const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() + expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) + expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) + // A flow occupant flips exactly its own surface, and the source notifies. + const notified = vi.fn() + const unsubscribe = browser.hooks.directoryFlow.subscribe(notified) + const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null) + expect(browser.hooks.directoryFlow.getSnapshot()).toBe(true) + expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) + await Promise.resolve() + expect(notified).toHaveBeenCalled() + dispose() + expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) + unsubscribe() }) it('unregisters every entry on teardown', async () => { diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 4af5d5f70c..eb34f633d8 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -8,7 +8,7 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), + id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), }) const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e8405c6bcd..12a5efc264 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -15,7 +15,7 @@ beforeEach(() => { localStorage.clear() }) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, overrides: Partial = {}): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides, + id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...overrides, }) const sessionState = (items: readonly SessionSummary[], overrides: Partial = {}): SessionListState => ({ ids: items.map(item => item.id), @@ -59,7 +59,8 @@ function mount(overrides: Partial = {}) { deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), - pickDirectory: vi.fn(async () => null), + useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), + renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, ...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 081ff0c044..490a86d782 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -5,6 +5,8 @@ import type { SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' afterEach(cleanup) @@ -35,14 +37,49 @@ function anchor(): { current: HTMLElement } { return { current: element } } +/** + * Probe occupant of the directory-flow hole: records the latest owner + * conversation so tests drive onPicked/onCancel/onError like a composed flow + * package would, and renders a marker element while the flow is open. + */ +function flowProbe() { + const probe: { owner: DirectoryFlowOwnerProps | undefined } = { owner: undefined } + const renderSlot = ((_name: string, owner: DirectoryFlowOwnerProps) => { + probe.owner = owner + return owner.open ?
: null + }) as never + return { probe, renderSlot } +} + +/** Manual occupancy source bound like the renderer would: flip() drives the hook like a real registration change. */ +function occupancySource(initial = true) { + let occupied = initial + const listeners = new Set<() => void>() + const useDirectoryFlow = bindSnapshotSelector({ + getSnapshot: () => occupied, + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }) + return { + useDirectoryFlow, + flip: (next: boolean) => { + occupied = next + for (const listener of [...listeners]) listener() + }, + } +} + function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), - pickDirectory = vi.fn(async () => null as string | null), + occupancy = occupancySource(), ) { const onPick = vi.fn() const onClose = vi.fn() const anchorRef = anchor() + const { probe, renderSlot } = flowProbe() const renderPicker = (nextItems: readonly WorkspaceView[]) => ( ) const view = render( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, pickDirectory, + view, onPick, onClose, createWorkspace, probe, occupancy, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } @@ -87,65 +125,75 @@ describe('WorkspacePicker', () => { await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) }) - it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => { + it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => { const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' } const createWorkspace = vi.fn(async () => created) - const pickDirectory = vi.fn(async () => '/tmp/project') - const b = mount([], createWorkspace, pickDirectory) + const b = mount([], createWorkspace) + expect(screen.queryByTestId('directory-flow')).toBeNull() chooseItem('Open local folder…') - expect(pickDirectory).toHaveBeenCalledOnce() - await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) + expect(b.onClose).toHaveBeenCalled() + expect(screen.getByTestId('directory-flow')).toBeTruthy() + await act(async () => { b.probe.owner!.onPicked('/tmp/project') }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) + // Successful adoption withdraws the flow request. + expect(screen.queryByTestId('directory-flow')).toBeNull() }) - it('treats native picker cancellation as a silent no-op', async () => { - const b = mount([], vi.fn(), vi.fn(async () => null)) + it('treats flow cancellation as a silent no-op', () => { + const b = mount([]) chooseItem('Open local folder…') - await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) + act(() => { b.probe.owner!.onCancel() }) + expect(screen.queryByTestId('directory-flow')).toBeNull() expect(b.createWorkspace).not.toHaveBeenCalled() expect(b.onPick).not.toHaveBeenCalled() expect(screen.queryByRole('dialog')).toBeNull() }) - it('shows a name conflict and retries through the native picker', async () => { - const pickDirectory = vi.fn() - .mockResolvedValueOnce('/one/project') - .mockResolvedValueOnce(null) + it('shows a name conflict and retries by reopening the flow', async () => { const createWorkspace = vi.fn(async () => { throw new WorkspaceCreateError({ code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' }, }) }) - const b = mount([], createWorkspace, pickDirectory) + const b = mount([], createWorkspace) chooseItem('Open local folder…') + await act(async () => { b.probe.owner!.onPicked('/one/project') }) await waitFor(() => { expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() }) expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.') + // The failed adoption withdrew the flow; Choose again reopens it. + expect(b.probe.owner!.open).toBe(false) fireEvent.click(screen.getByRole('button', { name: 'Choose again' })) - await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) }) + expect(b.probe.owner!.open).toBe(true) expect(b.onPick).not.toHaveBeenCalled() }) - it('disables the folder action while the native picker is already open', async () => { - let resolve!: (path: string | null) => void - const pending = new Promise((settle) => { resolve = settle }) - const b = mount([], vi.fn(), vi.fn(() => pending)) + it('disables every menu action from flow open through adoption, and reports busy to the flow', async () => { + let resolve!: (workspace: WorkspaceView) => void + const pending = new Promise((settle) => { resolve = settle }) + const created = workspace('adopted') + const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending)) chooseItem('Open local folder…') + // The flow is open but nothing is picked yet: a chooser pending on the + // host display must already block concurrent workspace actions. + expect(screen.getByRole('menuitem', { name: 'Alpha' }).disabled).toBe(true) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true) + act(() => { b.probe.owner!.onPicked('/tmp/project') }) + expect(b.probe.owner!.busy).toBe(true) 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…' })) - expect(b.pickDirectory).toHaveBeenCalledTimes(1) - await act(async () => { resolve(null); await pending }) + await act(async () => { resolve(created); await pending }) + expect(b.probe.owner!.busy).toBe(false) }) - it('reports non-Error native picker failures', async () => { - const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) + it('shows the flow-reported failure in the folder-error surface', () => { + const b = mount([]) chooseItem('Open local folder…') - await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('picker unavailable') - }) + act(() => { b.probe.owner!.onError('no chooser installed') }) + expect(screen.getByRole('alert').textContent).toBe('no chooser installed') + expect(screen.queryByTestId('directory-flow')).toBeNull() expect(b.createWorkspace).not.toHaveBeenCalled() }) @@ -215,10 +263,12 @@ describe('WorkspacePicker', () => { }) it('waits to show its menu until an optional anchor is available', () => { + const { renderSlot } = flowProbe() render( , ) expect(screen.queryByRole('menu')).toBeNull() @@ -228,12 +278,54 @@ describe('WorkspacePicker', () => { const state: WorkspaceListState = { ...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false, } + const { renderSlot } = flowProbe() render( , ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') }) + + it('hides the folder entry while the directory-flow hole is empty', () => { + mount([], vi.fn(), occupancySource(false)) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy() + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('shows the folder entry when a flow package activates after the first paint', () => { + const b = mount([], vi.fn(), occupancySource(false)) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + // Registration changes flow through the subscription, no re-render needed. + act(() => { b.occupancy.flip(true) }) + expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() + }) + + it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => { + const b = mount([], vi.fn(async () => { throw new Error('adoption failed') })) + chooseItem('Open local folder…') + await act(async () => { b.probe.owner!.onPicked('/one/project') }) + await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldn’t open folder' })).toBeTruthy() }) + // The occupant unloads while the error dialog is up: retrying would open + // a flow nobody can serve or cancel, so the button goes inert. + act(() => { b.occupancy.flip(false) }) + expect(screen.getByRole('button', { name: 'Choose again' }).disabled).toBe(true) + // Cancel stays the way out, and the menu actions are usable again. + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false) + }) + + it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => { + const b = mount([]) + chooseItem('Open local folder…') + expect(screen.getByTestId('directory-flow')).toBeTruthy() + // The flow plugin unloads mid-interaction (HMR): nobody is left to + // cancel, so the owner withdraws and the actions come back. + act(() => { b.occupancy.flip(false) }) + expect(b.probe.owner!.open).toBe(false) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) }) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index a2679cccb4..76babb3fea 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../locale" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8f604ad2ca..7d9b087b8f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -264,6 +264,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'directoryPicker', + summary: 'Abstract directory-picking service.', + methods: [ + { + signature: 'abstract capability(): DirectoryPickerCapability', + jsDoc: '/**\n * The backend\'s interaction capability.\n * @returns the discriminated capability consumers switch on.\n */', + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -414,6 +424,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'current(events: readonly SessionEvent[]): string', jsDoc: '/**\n * Resolve the preset matching the effective knob values. A still-matching\n * last selection wins shared-bundle ties; otherwise the first table match\n * wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param events - the session\'s events in log order.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', }, + { + signature: 'selectFor(state: KnobState): PermissionSelect', + jsDoc: '/**\n * Build the whole select value for one folded knob state: every table\n * option in declaration order, `custom` appended exactly while derived.\n * @param state - the folded knob overrides.\n * @returns the `permissions` projection payload.\n */', + }, { signature: 'resolve(name: string): PresetSpec', jsDoc: '/**\n * Resolve a preset\'s knob bundle.\n * @param name - the preset name to resolve.\n * @returns the configured bundle.\n * @throws when `name` is not in the table.\n */', @@ -1649,6 +1663,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DiffResultView', declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}', }, + { + name: 'DirectoryEntry', + declaration: 'export interface DirectoryEntry {\n name: string;\n path: string;\n hidden: boolean;\n}', + }, + { + name: 'DirectoryListing', + declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n truncated: boolean;\n}', + }, + { + name: 'DirectoryPickerBrowseCapability', + declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string, signal?: AbortSignal): Promise;\n createDirectory(path: string, name: string): Promise;\n}', + }, + { + name: 'DirectoryPickerCapabilities', + declaration: 'export interface DirectoryPickerCapabilities {\n native: DirectoryPickerNativeCapability;\n browse: DirectoryPickerBrowseCapability;\n}', + }, + { + name: 'DirectoryPickerCapability', + declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];', + }, + { + name: 'DirectoryPickerNativeCapability', + declaration: 'export interface DirectoryPickerNativeCapability {\n kind: \'native\';\n pick(signal: AbortSignal): Promise;\n}', + }, { name: 'Domain', declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}', @@ -1813,6 +1851,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, + { + name: 'KnobState', + declaration: 'export interface KnobState {\n preset: string | null;\n sandbox: SandboxMode | null;\n approval: ApprovalPolicy | null;\n}', + }, { name: 'KvTable', declaration: 'export interface KvTable {\n get(key: K): V | undefined;\n entries(): IterableIterator<[\n K,\n V\n ]>;\n keys(): IterableIterator;\n readonly size: number;\n put(key: K, value: V): Promise;\n delete(key: K): Promise;\n update(key: K, fn: (current: V) => V): Promise;\n}', @@ -1881,6 +1923,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', }, + { + name: 'PermissionSelect', + declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}', + }, { name: 'PreparedLlmCall', declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable;\n}', diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml new file mode 100644 index 0000000000..afc0e2a695 --- /dev/null +++ b/packages/host/README.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 packages/host/README.md +README.md: d44770f70be16c12f44b78155089e092a3e9bba0 +README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 diff --git a/packages/host/README.md b/packages/host/README.md new file mode 100644 index 0000000000..d44770f70b --- /dev/null +++ b/packages/host/README.md @@ -0,0 +1,15 @@ +# host/ — web-GUI host half + +English | [中文](README.zh.md) + +The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` | +| `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` | +| `directory-picker/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` | +| `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (registers `ctx.directoryPicker`) | +| `directory-picker-browse/` | Dual-face browse interaction: listing/creation primitives over Node stdlib (remote-capable) + the browser half rendering the in-app Select Workspace Directory dialog | (registers `ctx.directoryPicker`) | + +`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md new file mode 100644 index 0000000000..2b6878b08b --- /dev/null +++ b/packages/host/README.zh.md @@ -0,0 +1,15 @@ +# host/ — web GUI 宿主半侧 + +[English](README.md) | 中文 + +dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 + +| 包 | 角色 | ctx 键 | +|---|---|---| +| `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`)、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents`/`ctx.workspace` 的宿主实现 | `ctx.apiProxy` | +| `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` | +| `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `native`/`browse` 能力 | `ctx.directoryPicker` | +| `directory-picker-native/` | 双面原生交互:OS 选择器后端(osascript/PowerShell/Zenity+KDialog,仅宿主屏幕可用)+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `ctx.directoryPicker`) | +| `directory-picker-browse/` | 双面浏览交互:基于 Node 标准库的列举/创建原语(可远程)+ 渲染应用内选择工作区目录对话框的 browser half | (注册 `ctx.directoryPicker`) | + +`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 74bc03ffe2..73b0845370 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/apiproxy/README.md -README.md: c20887b73b9b9deb278db30d34d84df07257d664 -README.zh.md: 18a2f97477e5f57127371429b0dd59ba01f04341 +README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74 +README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index c20887b73b..ca4471454f 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -6,7 +6,7 @@ The API gateway every client shape shares: the TS contract (`src/api/`, zero Nod ## Contract layer (`/api`) -Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload`/`ResponseValue`. Zod schemas anchor `satisfies z.ZodType>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier. +Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload`/`ResponseValue`. Zod schemas anchor `satisfies z.ZodType>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier. Every `/api` POST must declare the `application/json` media type — anything else is refused with 415 before dispatch, so cross-site "simple" requests (which browsers send without a CORS preflight) can never execute a side-effectful method blind. The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md). @@ -18,7 +18,7 @@ Session model routing is a session-domain contract. `session.models` returns the Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. -`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests. +Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. @@ -41,4 +41,4 @@ None; this package neither assembles nor sends a provider request. - **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. -- **Linux native picker requires desktop tooling** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser. +- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 18a2f97477..953539e119 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -6,7 +6,7 @@ ## 契约层(`/api`) -协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload`/`ResponseValue` 派生。Zod schema 以 `satisfies z.ZodType>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。 +协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload`/`ResponseValue` 派生。Zod schema 以 `satisfies z.ZodType>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站"简单请求"(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 @@ -18,7 +18,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。 +目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 @@ -41,4 +41,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr - **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 -- **Linux 原生选择器依赖桌面工具**:Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径。 +- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 0db9568f7b..8cbdb91cf4 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -44,7 +44,9 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 56d4df78a3..f178bfefd0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -38,6 +38,12 @@ import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' +// Side-effect type import: resolves the `approval/request` waterfall and +// `ctx.get('approval')` without a value dependency on the seam (optional composition). +import type {} from '@deepseek-ai/dsh-user-approval' +import { approvalResponsePayloadSchema } from './api/approvals.schema.ts' import { questionResponsePayloadSchema } from './api/questions.schema.ts' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts' import { RpcId } from './api/rpc.ts' @@ -45,7 +51,7 @@ import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' -import { pickNativeDirectory } from './native-directory-picker.ts' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import { openNativePath } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ @@ -128,9 +134,9 @@ class FrameQueue { } /** - * Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids - * for answerable frames belong to the approval/question registry, absent in - * this minimal version). + * Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable + * frames — approval/question requested — mint their stable id in their + * pending registries instead). */ function frame(payload: F): RpcRequest { return { rpcId: RpcId(randomUUID()), payload } @@ -195,6 +201,14 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade } } +/** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */ +function directoryError(error: unknown): RpcError { + if (error instanceof DirectoryPickerError) { + return { code: error.code, message: error.message, details: { path: error.path } } + } + return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } +} + /** Resolved Host routing and project-directory defaults consumed by the API implementation. */ export interface ApiProxyDefaults { provider: string @@ -203,14 +217,42 @@ export interface ApiProxyDefaults { cwd: string /** Parent directory for name-created workspaces. */ workspaceRoot: string - /** Native single-directory picker; injectable for carrier tests. */ - pickDirectory?: (signal: AbortSignal) => Promise /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise } /** The tool/call payload fields the presenter path reads. */ interface ToolCallData { callId: string; name: string; arguments: string } +/** + * One outstanding approval question: the stable server-request id, the frame + * material replayed to late mux subscribers, and the resolver that settles the + * answerer's promise back into `ctx.approval`. + */ +interface PendingApproval { + rpcId: RpcId + sessionId: SessionId + approvalId: ApprovalRequestId + toolName: string + callId?: CallId + reason?: string + resolve(outcome: ApprovalOutcome): void +} + +/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */ +function requestedFrame(pending: PendingApproval): RpcRequest { + return { + rpcId: pending.rpcId, + payload: { + type: 'approval/requested', + sessionId: pending.sessionId, + approvalId: pending.approvalId, + toolName: pending.toolName, + ...pending.callId === undefined ? {} : { callId: pending.callId }, + ...pending.reason === undefined ? {} : { reason: pending.reason }, + }, + } +} + /** One host-owned question wait, addressed by the stable server-request id. */ interface PendingQuestion { rpcId: RpcId @@ -410,6 +452,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Serializes path ownership checks with record creation across spellings. */ let workspaceCreationChain = Promise.resolve() const pendingQuestions = new Map() + const pendingApprovals = new Map() const muxQueues = new Set>>() /** @@ -558,6 +601,90 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, 'api-proxy: user-interaction provider') + // --- Approval pending registry ------------------------------------------ + // The proxy is the approval channel for every agent this host owns: an ask + // through `ctx.approval` becomes an answerable server-request on the mux + // stream (stable rpcId), settled by POST /api/respond. The entry survives + // client disconnects — mux-open replays still-pending requested frames with + // the same rpcId (the refresh-recovery baseline) — and withdraws on the + // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers. + if (ctx.get('approval') !== undefined) { + // Teardown parity with the question provider above: a gateway disposed + // while approvals are pending settles every entry as 'cancelled' (the + // service's fail-closed vocabulary), so no ask promise dangles past the + // proxy's lifetime and subscribers see the withdrawal. + ctx.effect(() => () => { + for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled') + }, 'api-proxy: approval registry teardown') + ctx.on('approval/request', (req, next) => { + // Dispatch rides a microtask behind the service's own signal check: an + // abort landing in that window would register the abort listener AFTER + // the signal fired — never invoked, entry pending forever, zombie frame + // on every mux replay. Settle synchronously instead of publishing. + if (req.signal?.aborted === true) return Promise.resolve('cancelled') + // The audit pair `approval/asked` is already appended by the service + // before dispatch, but dispatch rides a microtask: parallel tool calls + // can append several asked events before any answerer runs. THIS + // request's event is therefore the newest asked event that is still + // undecided, unclaimed by another pending entry, and — when the ask + // names a call — carries the same callId. + const events = req.agent.session.events + const claimed = new Set() + for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId) + const decided = new Set() + let approvalId: ApprovalRequestId | undefined + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i] as SessionEvent + if (event.type === 'approval/decided') { + decided.add(event.data.id) + } else if (event.type === 'approval/asked') { + if (decided.has(event.data.id) || claimed.has(event.data.id)) continue + // Symmetric pairing: a callId-bearing ask only takes its own call's + // record, and a callId-less ask only takes a callId-less record — + // so neither shape can steal the other's audit id under parallel + // asks. (Today every producer — the tool executor — passes callId; + // the callId-less arm guards any future non-tool asker.) + if ((req.callId ?? null) !== (event.data.callId ?? null)) continue + approvalId = event.data.id + break + } + } + // No asked event means the request bypassed the service's audit path — + // not this channel's question; delegate to the fail-closed default. + if (approvalId === undefined) return next() + const id = approvalId + return new Promise((resolve) => { + const settle = (outcome: ApprovalOutcome): void => { + /* v8 ignore next 3 -- defensive double-settle guard: respond() routes + through the pending table (a settled id is not-pending before it can + re-settle) and the first settle removes the abort listener, so no + reachable path settles twice; kept against future settle callers. */ + if (!pendingApprovals.delete(pending.rpcId)) return + req.signal?.removeEventListener('abort', onAbort) + broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome }) + // A cancelled ask was already settled by the service's own signal + // race, which discards this late resolution; resolving is a no-op + // there and keeps this promise from dangling forever. + resolve(outcome) + } + const onAbort = (): void => { settle('cancelled') } + const pending: PendingApproval = { + rpcId: RpcId(randomUUID()), + sessionId: req.agent.session.id, + approvalId: id, + toolName: req.toolName, + ...req.callId === undefined ? {} : { callId: req.callId }, + ...req.reason === undefined ? {} : { reason: req.reason }, + resolve: settle, + } + pendingApprovals.set(pending.rpcId, pending) + req.signal?.addEventListener('abort', onAbort, { once: true }) + const envelope = requestedFrame(pending) + for (const queue of muxQueues) queue.push(envelope) + }) + }) + } + /** * Gate the cold path on the store: an id absent from it, or naming a legacy * log without a cwd (pre-release stance: not served, no compatibility), is @@ -1090,8 +1217,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async pickDirectory(request, signal) { + const capability = ctx.directoryPicker.capability() + if (capability.kind !== 'native') { + return err(request, { + code: 'directory-picker-unavailable', + message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`, + details: { capability: capability.kind }, + }) + } try { - const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal) + const path = await capability.pick(signal) return ok(request, { path }) } catch (error: unknown) { if (signal.aborted) { @@ -1109,6 +1244,45 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, + async listDirectory(request, signal) { + const capability = ctx.directoryPicker.capability() + if (capability.kind !== 'browse') { + return err(request, { + code: 'directory-picker-unavailable', + message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, + details: { capability: capability.kind }, + }) + } + try { + // The carrier's signal follows the caller: a disconnect or timeout + // stops the backend's directory scan instead of outliving it. + return ok(request, await capability.list(request.payload.path, signal)) + } catch (error: unknown) { + // An abort is the caller's own timeout/disconnect, not a server + // failure — same code pickDirectory and command.execute report. + if (signal.aborted) { + return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} }) + } + return err(request, directoryError(error)) + } + }, + + async createDirectory(request) { + const capability = ctx.directoryPicker.capability() + if (capability.kind !== 'browse') { + return err(request, { + code: 'directory-picker-unavailable', + message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, + details: { capability: capability.kind }, + }) + } + try { + return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) }) + } catch (error: unknown) { + return err(request, directoryError(error)) + } + }, + async openPath(request, signal) { try { const open = defaults.openPath @@ -1281,6 +1455,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }) } + // Refresh recovery: still-pending approval questions replay with their + // stable rpcId so a reconnecting client can still answer them. + for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending)) // Queue snapshot baseline (pendingQuestions precedent): frames replayed // in arrival order per session; a reconnecting client rebuilds its // queue view from these alone. @@ -1398,6 +1575,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, respond(message: ClientResponse): Promise { + // Route by the echoed rpcId (the wire correlation): approvals first, + // then questions — the two registries share one id space of UUIDs. + const approval = pendingApprovals.get(message.rpcId) + if (approval !== undefined) { + if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) + const parsed = approvalResponsePayloadSchema.safeParse(message.result.value) + // The payload's audit correlation must match the entry the rpcId routed + // to — a mismatched answer is malformed, not merely late. + if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) { + return Promise.resolve({ accepted: false, reason: 'bad-response' }) + } + approval.resolve(parsed.data.outcome) + return Promise.resolve({ accepted: true }) + } const pending = pendingQuestions.get(message.rpcId) if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' }) if (!message.result.ok) { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index e451c62100..43031288fc 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,6 +3,7 @@ */ import { z } from 'zod' +import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' @@ -16,6 +17,8 @@ export const hostDescribeValueSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), + // Open string, not a literal union: unknown kinds must survive the wire so + // a merge-added capability can advertise (the client hides the affordance). }) satisfies z.ZodType>> /** host.pickDirectory request payload (empty object literal). */ @@ -26,6 +29,41 @@ export const hostPickDirectoryValueSchema = z.object({ path: z.string().nullable(), }) satisfies z.ZodType>> +/** Directory row shared by listing entries and breadcrumb crumbs. */ +export const directoryEntrySchema = z.object({ + name: z.string(), + path: z.string(), + hidden: z.boolean(), +}) satisfies z.ZodType> + +/** host.listDirectory request payload; an absent path lists the home directory. */ +export const hostListDirectoryRequestSchema = z.object({ + path: z.string().optional(), +}) satisfies z.ZodType>> + +/** host.listDirectory response value. */ +export const hostListDirectoryValueSchema = z.object({ + path: z.string(), + home: z.string(), + crumbs: z.array(directoryEntrySchema), + entries: z.array(directoryEntrySchema), + truncated: z.boolean(), +}) satisfies z.ZodType>> + +/** host.createDirectory request payload: name must be one plain path segment. */ +export const hostCreateDirectoryRequestSchema = z.object({ + path: z.string(), + name: z.string(), +}).refine( + payload => payload.name.trim() !== '' && payload.name !== '.' && payload.name !== '..' + && !/[/\\]/.test(payload.name), + { message: 'host.createDirectory requires a single non-blank path segment name' }, +) satisfies z.ZodType>> + +/** host.createDirectory response value: the created directory's absolute path. */ +export const hostCreateDirectoryValueSchema = z.object({ + path: z.string(), +}) satisfies z.ZodType>> /** host.openPath request payload. */ export const hostOpenPathRequestSchema = z.object({ path: z.string().min(1), diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 2e62c972ae..3d0713e523 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -5,6 +5,33 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' +/** One directory row of a listing: a child entry or a breadcrumb ancestor. */ +export interface DirectoryEntry { + /** Base name shown in a browser row (a root crumb carries its full path). */ + name: string + /** Absolute host path — the client never joins path segments itself. */ + path: string + /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ + hidden: boolean +} + +/** host.listDirectory response value: one directory level plus its ancestry. */ +export interface DirectoryListing { + /** Absolute path of the listed directory. */ + path: string + /** The host account's home directory (breadcrumb "Home" rooting). */ + home: string + /** + * Ancestor chain from the filesystem root to the listed directory + * inclusive; every crumb is a jump target (crumb `hidden` is always false). + */ + crumbs: DirectoryEntry[] + /** Direct child directories, name-sorted; symlinks to directories included. */ + entries: DirectoryEntry[] + /** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */ + truncated: boolean +} + /** Host-level unary methods. */ export interface HostApi { /** @@ -13,7 +40,7 @@ export interface HostApi { * directory (root for session persistence and tool execution); provider/model = the defaults * applied when a new agent doesn't specify them explicitly, absent when the host configures * no explicit default (the adapter falls back internally); - * attachedSessions = count of currently attached sessions (those with a live agent). + * attachedSessions = count of currently attached sessions (those with a live agent); */ describe(request: RpcRequest<{}>): Promise> - /** Open the operating system's single-directory picker; cancellation returns null. */ + /** + * Open the operating system's single-directory picker; cancellation returns + * null. Only served under the `native` capability. + */ pickDirectory( request: RpcRequest<{}>, signal: AbortSignal, ): Promise> + /** + * List one directory level for the in-app browser; an absent path lists the + * host account's home directory. Only served under the `browse` capability; + * unreadable or missing targets fail with `directory-unreadable`. The + * carrier's request signal follows the caller, stopping the backend's scan + * on disconnect or timeout. + */ + listDirectory( + request: RpcRequest<{ path?: string }>, + signal: AbortSignal, + ): Promise> + + /** + * Create one child directory under an existing parent (the browser's + * "New folder"). Only served under the `browse` capability; an existing + * child fails with `directory-exists`, every other filesystem failure with + * `directory-create-failed`. + */ + createDirectory( + request: RpcRequest<{ path: string; name: string }>, + ): Promise> + /** * Open a filesystem path with the operating system's default application - * (Finder / Explorer / xdg-open hand-off). The browser carrier restricts this - * privileged method to loopback, same-origin requests. + * (Finder / Explorer / xdg-open hand-off). The browser carrier's + * prefix-wide trust fence covers this privileged method like every other + * `/api` request. */ openPath( request: RpcRequest<{ path: string }>, diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 451655d114..b91ce54e0f 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -31,7 +31,7 @@ export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary, } from './sessions.ts' -export type { HostApi } from './host.ts' +export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index bedd6f4b1f..5394691248 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -27,6 +27,8 @@ export interface RpcMethodMap { 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] + 'host.listDirectory': HostApi['listDirectory'] + 'host.createDirectory': HostApi['createDirectory'] 'host.openPath': HostApi['openPath'] 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 1e13645eae..58d3a17126 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -42,6 +42,10 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), + z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }), + z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), + z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), + z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index ce6b8186d3..fd99d015ee 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -39,6 +39,10 @@ export interface RpcErrorDetailsMap { 'workspace-invalid-path': { path: string } 'workspace-name-conflict': { name: string } 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } + 'directory-unreadable': { path: string } + 'directory-exists': { path: string } + 'directory-create-failed': { path: string } + 'directory-picker-unavailable': { capability: string } 'agent-busy': { reason: string } /** A known slash command reported a usage/state error; the message is the command's own text. */ 'command-error': {} diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index cd112c6546..f952c7c542 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -221,4 +221,5 @@ export interface SessionsApi { /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> + } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index fab8166c3f..8bcc6f7c3e 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -14,7 +14,8 @@ import type { Wire } from '../api/rpc.schema.ts' import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts' import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts' import { - hostDescribeValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema, + hostCreateDirectoryValueSchema, hostDescribeValueSchema, + hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema, } from '../api/host.schema.ts' import { sessionCancelValueSchema, @@ -71,6 +72,8 @@ export interface IApiClient { host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> + listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise>> + createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise>> openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise>> } workspace: { @@ -117,6 +120,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('host.pickDirectory', payload, signal, false), + listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal), + createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal), openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 31ed3a8dea..e1340aad5b 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -2,8 +2,8 @@ * Server side of the fetch carrier: maps an ApiProxy onto a pure * WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method + * path==method) -> payload dispatched per method. HTTP status expresses only the carrier - * (404 unknown path / 400 non-JSON body / 500 handler crash); business errors are always - * 200 + ServerResponse. + * (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash); + * business errors are always 200 + ServerResponse. */ import { randomUUID } from 'node:crypto' @@ -24,7 +24,9 @@ import { sessionSelectModelRequestSchema, } from '../api/sessions.schema.ts' import { - hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema, + hostCreateDirectoryRequestSchema, hostDescribeRequestSchema, + hostListDirectoryRequestSchema, hostOpenPathRequestSchema, + hostPickDirectoryRequestSchema, } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, @@ -70,6 +72,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, + 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, + 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, @@ -205,6 +209,17 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { return new Response('not found', { status: 404 }) } + // Cross-site write fence: browsers send "simple" POSTs (text/plain, + // form encodings) without a CORS preflight, so a malicious page could + // otherwise execute side-effectful RPCs blind — the response stays + // unreadable cross-origin, but session.prompt would still run. Only the + // JSON media type is accepted; anything else is forced into a preflight + // this server never answers. 415 = carrier layer, like the 400 below. + const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/json') { + return new Response('content type must be application/json', { status: 415 }) + } + let body: unknown try { body = await req.json() diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index c9a4e3afb9..e801278565 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -45,7 +45,7 @@ export interface Config { * project directory and the fallback parent for name-created Workspaces. */ export class ApiProxyService extends Service implements ApiProxy { - static inject = ['agents', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace'] + static inject = ['agents', 'directoryPicker', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace'] static Config: z = z.object({ provider: z.string().required(), diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index 15a6d7a73b..a4fbbaa72e 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,6 +1,6 @@ /** Cross-platform open-with-default-application used by the local GUI carrier. */ -import { runNativeCommand, type NativeCommandRunner } from './native-command.ts' +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ export type PathOpenerRunner = NativeCommandRunner diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts new file mode 100644 index 0000000000..f9a00cf9ef --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -0,0 +1,330 @@ +/** + * Approval pending registry over the proxy: an ask through `ctx.approval` + * becomes an answerable `approval/requested` mux frame (stable rpcId, replayed + * verbatim on a later mux open), `respond` routes by the echoed rpcId and + * validates the audit correlation, and the ask's abort signal withdraws the + * question with a broadcast `cancelled`. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' +import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId as mintRpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ApprovalService) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + return { ctx, api } +} + +/** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */ +function agentOf(ctx: Context): Agent { + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + return { session } as unknown as Agent +} + +/** Open a mux stream and capture frames into an array (returns an on-demand waiter). */ +function openMux(api: ApiProxy, abort: AbortController): { frames: MuxFrame[]; envelopes: RpcRequest[]; waitFor(type: MuxFrame['type']): Promise } { + const frames: MuxFrame[] = [] + const envelopes: RpcRequest[] = [] + const waiters: { type: MuxFrame['type']; resolve: (frame: MuxFrame) => void }[] = [] + void (async () => { + for await (const envelope of api.events.mux({ rpcId: mintRpcId('t-mux'), payload: {} }, abort.signal)) { + frames.push(envelope.payload) + envelopes.push(envelope) + for (let i = waiters.length - 1; i >= 0; i -= 1) { + const waiter = waiters[i] as (typeof waiters)[number] + if (waiter.type === envelope.payload.type) { + waiters.splice(i, 1) + waiter.resolve(envelope.payload) + } + } + } + })() + return { + frames, + envelopes, + waitFor: (type) => { + const found = frames.find(frame => frame.type === type) + if (found !== undefined) return Promise.resolve(found) + return new Promise((resolve) => { waiters.push({ type, resolve }) }) + }, + } +} + +function requestedOf(frame: MuxFrame): Extract { + if (frame.type !== 'approval/requested') throw new Error(`expected approval/requested, got ${frame.type}`) + return frame +} + +/** Wait until the stream delivered `count` frames of `type` (bounded poll; waitFor only covers the first). */ +async function waitForCount(mux: { frames: MuxFrame[] }, type: MuxFrame['type'], count: number): Promise { + for (let i = 0; i < 200 && mux.frames.filter(frame => frame.type === type).length < count; i += 1) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + expect(mux.frames.filter(frame => frame.type === type).length).toBeGreaterThanOrEqual(count) +} + +function answer(rpcId: RpcId, sessionId: unknown, approvalId: ApprovalRequestId, outcome: 'allowed-once' | 'rejected'): Parameters[0] { + return { type: 'client-response', rpcId, result: { ok: true, value: { sessionId, approvalId, outcome } } } +} + +describe('approval pending registry', () => { + it('round-trips ask → requested frame → respond → outcome + resolved broadcast', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + + const asked = ctx.approval.request({ agent, toolName: 'bash', reason: 'sandbox escalation' }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + expect(requested).toMatchObject({ toolName: 'bash', reason: 'sandbox escalation', sessionId: agent.session.id }) + + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + const receipt = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')) + expect(receipt).toEqual({ accepted: true }) + await expect(asked).resolves.toBe('allowed-once') + + const resolved = await mux.waitFor('approval/resolved') + expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'allowed-once' }) + + // The question settled: a duplicate answer is late, not re-decidable. + const dup = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'rejected')) + expect(dup).toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + }) + + it('replays a still-pending requested frame (same rpcId) on a later mux open', async () => { + const { ctx, api } = await harness() + const first = new AbortController() + const firstMux = openMux(api, first) + const agent = agentOf(ctx) + const asked = ctx.approval.request({ agent, toolName: 'write' }) + const requested = requestedOf(await firstMux.waitFor('approval/requested')) + const firstEnvelope = firstMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + first.abort() + + // A fresh subscriber (refresh recovery) sees the same stable rpcId. + const second = new AbortController() + const secondMux = openMux(api, second) + const replayed = requestedOf(await secondMux.waitFor('approval/requested')) + const secondEnvelope = secondMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + expect(secondEnvelope.rpcId).toBe(firstEnvelope.rpcId) + expect(replayed.approvalId).toBe(requested.approvalId) + + const receipt = await api.respond(answer(secondEnvelope.rpcId, replayed.sessionId, replayed.approvalId, 'rejected')) + expect(receipt).toEqual({ accepted: true }) + await expect(asked).resolves.toBe('rejected') + second.abort() + }) + + it('rejects malformed and mismatched answers as bad-response, unknown ids as not-pending', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + void ctx.approval.request({ agent, toolName: 'bash' }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + + // Unknown rpcId: not routed to any pending entry. + expect(await api.respond(answer(mintRpcId('ghost'), requested.sessionId, requested.approvalId, 'rejected'))) + .toEqual({ accepted: false, reason: 'not-pending' }) + // Error-branch result: the client can only answer with a value. + expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + // Wrong audit correlation: the rpcId routed, but the payload disagrees. + expect(await api.respond(answer(envelope.rpcId, requested.sessionId, 'other-approval' as ApprovalRequestId, 'rejected'))) + .toEqual({ accepted: false, reason: 'bad-response' }) + // Malformed payload shape. + expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: true, value: { nonsense: 1 } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + abort.abort() + }) + + it('withdraws the question on the ask signal: cancelled outcome, resolved broadcast, late answer not-pending', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + const cancel = new AbortController() + const asked = ctx.approval.request({ agent, toolName: 'bash', signal: cancel.signal }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + + cancel.abort() + await expect(asked).resolves.toBe('cancelled') + const resolved = await mux.waitFor('approval/resolved') + expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' }) + expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))) + .toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + }) + + it('an ask whose signal aborted before dispatch settles cancelled without publishing', async () => { + // The service checks the signal, then dispatch rides a microtask: an + // abort in that window must not register a dead listener and strand the + // entry (zombie frame on every replay). Drive the waterfall directly + // with a pre-aborted signal to hit the answerer's register-path guard. + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' }) + const agent = { session } as unknown as Agent + const cancelled = new AbortController() + cancelled.abort() + const outcome = await ctx.waterfall( + 'approval/request', + { agent, toolName: 'bash', signal: cancelled.signal }, + () => Promise.resolve('unavailable' as const), + ) + expect(outcome).toBe('cancelled') + // Nothing was published: a fresh mux open replays no approval frame. + const abort2 = new AbortController() + const mux2 = openMux(api, abort2) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(mux2.envelopes.some(e => e.payload.type === 'approval/requested')).toBe(false) + abort2.abort() + abort.abort() + void mux + }) + + it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => { + // Mount the proxy on its own fiber so disposal exercises the teardown + // effect while an ask is still pending. + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ApprovalService) + let api!: ApiProxy + const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { + api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) + await fiber.await() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.approval.request({ agent: agentOf(ctx), toolName: 'bash' }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + await fiber.dispose() + await expect(asked).resolves.toBe('cancelled') + const resolved = await mux.waitFor('approval/resolved') + expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' }) + abort.abort() + }) + + it('carries callId on the frame and ignores a late abort after the answer settled', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + const cancel = new AbortController() + const asked = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-9' as never, signal: cancel.signal }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + expect(requested.callId).toBe('call-9') + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toBe('allowed-once') + // Late abort: the pending entry is gone; settle's delete-guard returns. + cancel.abort() + expect(mux.frames.filter(f => f.type === 'approval/resolved')).toHaveLength(1) + abort.abort() + }) + + it('pairs parallel asks by callId: each requested frame carries its own audit id', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + // Both asks append their approval/asked audit events before either + // answerer's microtask dispatch runs — the parallel tool-call window. + const askA = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-a' as never }) + const askB = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-b' as never }) + await waitForCount(mux, 'approval/requested', 2) + const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested') + const frameA = frames.find(e => requestedOf(e.payload).callId === 'call-a') as RpcRequest + const frameB = frames.find(e => requestedOf(e.payload).callId === 'call-b') as RpcRequest + // Each frame claimed the asked event with its own callId, not merely the newest. + const askedIdByCall = new Map(agent.session.events + .filter(event => event.type === 'approval/asked') + .map(event => [String(event.data.callId), event.data.id])) + expect(requestedOf(frameA.payload).approvalId).toBe(askedIdByCall.get('call-a')) + expect(requestedOf(frameB.payload).approvalId).toBe(askedIdByCall.get('call-b')) + // Answers route back to the right ask through the pairing. + expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected'))) + .toEqual({ accepted: true }) + expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once'))) + .toEqual({ accepted: true }) + await expect(askA).resolves.toBe('allowed-once') + await expect(askB).resolves.toBe('rejected') + abort.abort() + }) + + it('gives parallel callId-less asks distinct audit ids (claimed-entry skip); both stay answerable', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + const askA = ctx.approval.request({ agent, toolName: 'alpha' }) + const askB = ctx.approval.request({ agent, toolName: 'beta' }) + await waitForCount(mux, 'approval/requested', 2) + const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested') + const frameA = frames.find(e => requestedOf(e.payload).toolName === 'alpha') as RpcRequest + const frameB = frames.find(e => requestedOf(e.payload).toolName === 'beta') as RpcRequest + // Without a callId the pairing is heuristic, but never shared: the second + // dispatch skips the id the first pending entry already claimed. + expect(requestedOf(frameA.payload).approvalId).not.toBe(requestedOf(frameB.payload).approvalId) + expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once'))) + .toEqual({ accepted: true }) + expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected'))) + .toEqual({ accepted: true }) + await expect(askA).resolves.toBe('allowed-once') + await expect(askB).resolves.toBe('rejected') + abort.abort() + }) + + it('delegates a dispatch whose only asked candidate is already decided (stale re-dispatch)', async () => { + const { ctx, api } = await harness() + void api // the answerer is registered; the fake below bypasses the service + // Bypass ApprovalService: a log whose sole asked event already has its + // decided partner must not be re-claimed — the answerer delegates. + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' }) + session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' }) + const agent = { session } as unknown as Agent + const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'bash' }, () => Promise.resolve('unavailable' as const)) + expect(outcome).toBe('unavailable') + }) + + it('delegates an ask whose session log carries no asked audit event (foreign channel)', async () => { + const { ctx, api } = await harness() + void api // the answerer is registered; the fake below bypasses the audit path + // Bypass ApprovalService: dispatch the waterfall directly with a session + // that has no approval/asked event — the proxy answerer must call next(). + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = { session } as unknown as Agent + const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const)) + expect(outcome).toBe('unavailable') + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index e5bd8bfbee..36d51a7542 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -1,7 +1,7 @@ /** * The summary blank bit means "conversation not started" (no turn has run), * not "log empty": standalone plugin events — command lifecycle records, - * plan/mode, session titles — never flip it, so running /plan or /goal on a + * plan/mode, permission knob events, session titles — never flip it, so running /plan or /goal on a * fresh session keeps it list-hidden and reusable, while the first accepted * prompt's turn/start clears it. The host/session-added frame shares the * same predicate function (covered by the workspace spec's frame assertion). @@ -15,6 +15,10 @@ import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { CommandId } from '@deepseek-ai/dsh-commands/brand' +// Side-effect type imports: the knob-event SessionEventMap merges. +import type {} from '@deepseek-ai/dsh-permission' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-user-approval' import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' @@ -48,6 +52,10 @@ function appendStandalone(session: Session): void { session.append('session/title', { title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' }, }) + // The three permission knob events (a /permission switch on a fresh session). + session.append('permission/preset', { preset: 'danger-full-access' }) + session.append('sandbox/mode', { mode: 'danger-full-access' }) + session.append('approval/policy', { policy: 'never' }) } async function listBlank(api: ApiProxy, id: string): Promise { diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 59515b5c16..3968cba5c7 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -3,13 +3,15 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import AgentRegistry, {} from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -57,10 +59,8 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), - extras: { - pickDirectory?: (signal: AbortSignal) => Promise - openPath?: (path: string, signal: AbortSignal) => Promise - } = {}, + picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, + extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -95,31 +95,34 @@ async function harness( }, } ctx.agents.setFactory(factory) + // Structural picker fake: the gateway only reads capability(); a stable + // object per harness mirrors the seam's stability contract. + ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', cwd: workspaceRoot, workspaceRoot, - ...extras.pickDirectory === undefined ? {} : { pickDirectory: extras.pickDirectory }, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, }) return { api, ctx, storageDomain, workspaceRoot } } describe('host.pickDirectory', () => { - it('returns a selected path or explicit cancellation from the injected native boundary', async () => { - const selected = await harness(undefined, { pickDirectory: async () => '/tmp/project' }) + it('returns a selected path or explicit cancellation from the native capability', async () => { + const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' }) expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: '/tmp/project' } }) - const cancelled = await harness(undefined, { pickDirectory: async () => null }) + const cancelled = await harness(undefined, { kind: 'native', pick: async () => null }) expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: null } }) }) - it('propagates abort into the native boundary as a cancelled RPC error', async () => { + it('propagates abort into the native capability as a cancelled RPC error', async () => { const { api } = await harness(undefined, { - pickDirectory: signal => new Promise((_resolve, reject) => { + kind: 'native', + pick: signal => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) }), }) @@ -128,12 +131,97 @@ describe('host.pickDirectory', () => { abort.abort() expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) }) + + it('folds a non-abort native-chooser failure into an internal error', async () => { + const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } }) + const response = await api.host.pickDirectory(request({}), new AbortController().signal) + expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) + }) + + it('refuses the native RPC under a browse composition', async () => { + const { api } = await harness(undefined, BROWSE_STUB) + const response = await api.host.pickDirectory(request({}), new AbortController().signal) + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } }, + }) + }) +}) + +/** Canned browse capability: one listing, one created path, typed failures on demand. */ +const BROWSE_STUB: DirectoryPickerCapability = { + kind: 'browse', + list: async (path) => { + if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') + const target = path ?? '/home/user' + return { + path: target, + home: '/home/user', + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], + truncated: false, + } + }, + createDirectory: async (path, name) => { + if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') + if (name === 'unwritable') throw new Error('disk detached') + return `${path}/${name}` + }, +} + +describe('host.listDirectory / host.createDirectory', () => { + it('serves listings and creation through the browse capability, defaulting to home', async () => { + const { api } = await harness(undefined, BROWSE_STUB) + const home = await api.host.listDirectory(request({}), new AbortController().signal) + expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) + const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal) + expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) + const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) + expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) + }) + + it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => { + const { api } = await harness(undefined, BROWSE_STUB) + expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({ + ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } }, + }) + expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({ + ok: false, error: { code: 'directory-exists' }, + }) + expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({ + ok: false, error: { code: 'internal' }, + }) + }) + + it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => { + const { api } = await harness(undefined, { + kind: 'browse', + list: (_path, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) + }), + createDirectory: async () => '/never', + }) + const abort = new AbortController() + const pending = api.host.listDirectory(request({}), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) + + it('refuses the browse RPCs under a native composition', async () => { + const { api } = await harness() + expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({ + ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, + }) + expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({ + ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, + }) + }) }) describe('host.openPath', () => { it('opens through the injected native boundary', async () => { const opened: string[] = [] - const { api } = await harness(undefined, { + const { api } = await harness(undefined, undefined, { openPath: async (path) => { opened.push(path) }, }) expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result) @@ -142,7 +230,7 @@ describe('host.openPath', () => { }) it('propagates abort into the native boundary as a cancelled RPC error', async () => { - const { api } = await harness(undefined, { + const { api } = await harness(undefined, undefined, { openPath: (_path, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) }), diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 3f2ef875f9..d807557d00 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -53,6 +53,8 @@ function scriptedApi(overrides: { host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), pickDirectory: r => ok(r, { path: null }), + listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }), + createDirectory: r => ok(r, { path: '/t/new' }), openPath: r => ok(r, { opened: true as const }), ...overrides.host, }, @@ -151,7 +153,7 @@ describe('unary round trip', () => { it('rejects a method/path mismatch as bad-request', async () => { const handler = toFetchHandler(scriptedApi()) const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} } - const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) }) + const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) expect(response.status).toBe(200) const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } } expect(parsed.result.ok).toBe(false) @@ -162,13 +164,13 @@ describe('unary round trip', () => { it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => { const handler = toFetchHandler(scriptedApi()) // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse. - const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) }) + const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) }) expect(noId.status).toBe(200) const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } } expect(noIdParsed.result.ok).toBe(false) expect(noIdParsed.rpcId).toBe('invalid-request') // A string rpcId in the otherwise-bad body is salvaged for correlation. - const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) + const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } } expect(withIdParsed.result.ok).toBe(false) expect(withIdParsed.rpcId).toBe('salvage-me') @@ -177,16 +179,34 @@ describe('unary round trip', () => { it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => { const handler = toFetchHandler(scriptedApi()) // Unknown method → 404. - const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' }) + const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) expect(notFound.status).toBe(404) // Non-JSON body → 400. - const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' }) + const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }) expect(badBody.status).toBe(400) // Impl crash → 500, and through the client that is a throw, not an err result. const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } }) await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/) }) + it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => { + const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] })) + const handler = toFetchHandler(scriptedApi({ sessions: { list } })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} }) + // A "simple" browser POST (text/plain — sent with no CORS preflight) is + // refused at the carrier before the impl runs. + const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body }) + expect(plain.status).toBe(415) + // A string body with no explicit header defaults to text/plain — same fence. + const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body }) + expect(unlabelled.status).toBe(415) + expect(list).not.toHaveBeenCalled() + // Media-type parameters pass: the fence checks the type, not the exact string. + const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body }) + expect(charset.status).toBe(200) + expect(list).toHaveBeenCalledTimes(1) + }) + it('rejects when the transport never resolves within timeoutMs', async () => { // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast. const never = new InProcessApiClient({ @@ -495,7 +515,7 @@ describe('respond path', () => { it('returns bad-response for a malformed client-response without reaching the impl', async () => { const respond = vi.fn() const handler = toFetchHandler(scriptedApi({ respond })) - const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) }) + const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) }) expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' }) expect(respond).not.toHaveBeenCalled() }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 3d5fac2a5a..d7528072f7 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -81,6 +81,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async pickDirectory(request) { return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } }, + async listDirectory(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } } + }, + async createDirectory(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } } + }, async openPath(request) { return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } }, @@ -233,6 +239,19 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } }) }) + it('round-trips the browse listing and creation calls through the wire form', async () => { + const c = client() + const listed = await c.host.listDirectory({ path: '/w' }) + expect(listed.result).toEqual({ + ok: true, + value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }, + }) + const home = await c.host.listDirectory({}) + expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } }) + const created = await c.host.createDirectory({ path: '/w', name: 'fresh' }) + expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } }) + }) + it('round-trips host.openPath through the wire form', async () => { const api = fakeApi() let opened: string | undefined @@ -263,7 +282,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } }) // The fake's /hang settles only when the invoke-level signal aborts: a // completed response with the cancelled error proves req.signal reached it. - const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal })) + const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal })) controller.abort() const response = await pending const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } @@ -288,7 +307,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const controller = new AbortController() const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} }) const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', { - method: 'POST', body, signal: controller.signal, + method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal, })) controller.abort() const parsed = await (await pending).json() as { result: { error?: { code: string } } } @@ -300,18 +319,18 @@ describe('handler carrier-layer statuses', () => { const handler = toFetchHandler(fakeApi()) it('404s unknown paths and non-POST non-stream methods', async () => { - expect((await handler.fetch(new Request('http://x/other', { method: 'POST', body: '{}' }))).status).toBe(404) + expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404) expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404) - expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) + expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) }) it('400s a non-JSON body', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: 'not json' })) + const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' })) expect(response.status).toBe(400) }) it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: JSON.stringify({ nope: true }) })) + const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) })) expect(response.status).toBe(200) const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } expect(body.rpcId).toBe('invalid-request') @@ -320,7 +339,7 @@ describe('handler carrier-layer statuses', () => { it('rejects a method/path mismatch echoing the envelope rpcId', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body })) + const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } } expect(parsed.rpcId).toBe('r-9') expect(parsed.result.error?.message).toContain('does not match path') @@ -328,7 +347,7 @@ describe('handler carrier-layer statuses', () => { it('rejects an invalid payload with the zod issues attached', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', body })) + const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } } expect(parsed.result.error?.code).toBe('bad-request') expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0) @@ -337,23 +356,23 @@ describe('handler carrier-layer statuses', () => { it('500s when the impl itself throws', async () => { const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' })) const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} }) - const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', body })) + const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) expect(response.status).toBe(500) expect(await response.text()).toContain('impl crashed') }) it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => { const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } }) - const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: good }))).json() + const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json() expect(goodReceipt).toEqual({ accepted: true }) const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} }) - const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: bad }))).json() + const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json() expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' }) }) it('accepts (url, init) form fetch invocation', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} }) - const response = await handler.fetch('http://x/api/session.list', { method: 'POST', body }) + const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) expect(response.status).toBe(200) }) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 783acc7056..fd1f107a80 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -12,7 +12,11 @@ import { sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema, sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema, } from '../src/api/sessions.schema.ts' -import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' +import { + hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema, + hostDescribeRequestSchema, hostDescribeValueSchema, + hostListDirectoryRequestSchema, hostListDirectoryValueSchema, +} from '../src/api/host.schema.ts' import { workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, @@ -227,6 +231,26 @@ describe('host domain schemas', () => { expect(value.attachedSessions).toBe(2) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() }) + + it('validates the browse listing/creation payloads', () => { + expect(hostListDirectoryRequestSchema.parse({})).toEqual({}) + expect(hostListDirectoryRequestSchema.parse({ path: '/x' })).toEqual({ path: '/x' }) + const listing = hostListDirectoryValueSchema.parse({ + path: '/home/u/p', + home: '/home/u', + crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }], + entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }], + truncated: false, + }) + expect(listing.entries[0]?.hidden).toBe(true) + // The flag is part of the wire value, not an optional decoration. + expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow() + expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' }) + for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { + expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow() + } + expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' }) + }) }) describe('workspace domain schemas', () => { diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 26a0af3636..7e1e83e39b 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -56,8 +56,14 @@ { "path": "../../workspace/workspace" }, + { + "path": "../directory-picker" + }, { "path": "../../support/invariants" + }, + { + "path": "../../util/native-command" } ] } diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml new file mode 100644 index 0000000000..4454afde3a --- /dev/null +++ b/packages/host/directory-picker-browse/README.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 packages/host/directory-picker-browse/README.md +README.md: 318380405214d5f25ad77e348c4e134a8981ffb3 +README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md new file mode 100644 index 0000000000..3183804052 --- /dev/null +++ b/packages/host/directory-picker-browse/README.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-host-directory-picker-browse + +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 native 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`) 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. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. 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). + +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). + +## Model Experience + +None, as the backend serves the GUI host's directory selection; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. +- **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. +- **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md new file mode 100644 index 0000000000..2f88f64cc2 --- /dev/null +++ b/packages/host/directory-picker-browse/README.zh.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-host-directory-picker-browse + +[English](README.md) | 中文 + +[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 + +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 + +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 + +## 模型体验 + +无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 +- **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 +- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json new file mode 100644 index 0000000000..a9f3fc2090 --- /dev/null +++ b/packages/host/directory-picker-browse/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-host-directory-picker-browse", + "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "clsx": "^2.0.0", + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-ui-workspace": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } +} diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css new file mode 100644 index 0000000000..800af854a1 --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -0,0 +1,306 @@ +/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders + * headless here — mask, card, Escape only — and this module owns the figma + * frame: 600×420 card (viewport-clamped), header (title + crumbs, l3 separator), + * the one-or-two-column Miller content, and the bordered footer. */ + +/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */ +/* Short viewports clamp the card: header/footer are flex-none and the + * columns scroll, so shrinking the height keeps Open/Cancel reachable + * instead of clipping them below a fixed overlay. */ +.dialog.dialog { + width: min(600px, 100%); + height: min(420px, calc(100dvh - 32px)); + padding: 0; + gap: 0; +} + +/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */ +.header { + display: flex; + flex-direction: column; + gap: 8px; + flex: none; + padding: 22px 14px 12px 24px; + border-bottom: 1px solid var(--dsw-alias-border-l3); +} + +.title { + display: flex; + align-items: flex-end; + min-height: 28px; + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.crumbBar { + display: flex; + align-items: center; + gap: 4px; + min-height: 20px; +} + +/* Deep chains scroll inside the trail (the effect pins the tail into view) + * so the edit zone to the right never leaves the bar. */ +/* The Miller columns keep their own row so a status/error line below never + * competes with the fixed column widths for horizontal space. */ +/* A narrow viewport shrinks the dialog below two fixed panes; the row + * scrolls horizontally (the effect pins the child pane into view) so + * descent never hides behind the Modal's clipping. */ +.millerRow { + display: flex; + align-items: stretch; + flex: 1 1 0; + min-height: 0; + gap: 20px; + overflow-x: auto; +} + +.crumbTrail { + display: flex; + align-items: center; + gap: 4px; + flex: 0 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.crumbSeat { + display: inline-flex; + align-items: center; + gap: 4px; + flex: none; + min-width: 0; +} + +.crumb { + border: none; + background: transparent; + padding: 0; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.crumb:hover { + color: var(--dsw-alias-label-primary); +} + +.crumbChevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +/* The empty remainder of the bar: invisible, but a real click target that + * flips the bar into path-edit mode. */ +.crumbEditZone { + flex: 1 0 34px; + min-width: 34px; + align-self: stretch; + border: none; + background: transparent; + cursor: text; +} + +.pathInput { + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + height: 24px; + padding: 0 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + outline: none; + background: transparent; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with + * the hairline divider centered between them; each column scrolls alone. */ +.content { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; + padding: 16px 24px 0; +} + +.column { + display: flex; + flex-direction: column; + gap: 2px; + width: 256px; + flex: none; + overflow-y: auto; +} + +.columnWide { + width: 100%; + flex: 1 1 0; +} + +.divider { + flex: none; + width: 1px; + background: var(--dsw-alias-border-l3); +} + +.rowSeat { + display: flex; + flex: none; +} + +.row { + width: 100%; + display: flex; + align-items: center; + gap: 4px; + height: 28px; + flex: none; + padding: 4px; + border: none; + border-radius: 6px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.row:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Selection: pill fill + the open-folder glyph in the info accent. */ +.rowSelected, +.rowSelected:hover { + background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover)); +} + +.rowIcon { + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.rowIconSelected { + flex: none; + color: var(--dsw-alias-button-info-fill); +} + +.rowName { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.rowChevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.status, +.error { + padding: 4px; + font-size: 12px; + line-height: 18px; +} + +.status { + color: var(--dsw-alias-label-secondary); +} + +.error { + color: var(--dsw-alias-state-error-primary); +} + +/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed + * card leaves the figma 28px below the 36px buttons. */ +.footerBar { + display: flex; + align-items: center; + /* Narrow viewports wrap the confirm/cancel pair onto their own row + * instead of clipping Open past the card's hidden overflow. */ + flex-wrap: wrap; + gap: 8px; + flex: none; + padding: 12px 24px 28px; + border-top: 1px solid var(--dsw-alias-border-l3); +} + +.footerGap { + flex: 1 1 0; +} + +.footerAction { + min-width: 72px; +} + +/* Nested create dialog (figma 813:23278): a small centered card. */ +.createDialog.createDialog { + width: min(380px, 100%); + padding: 0; + gap: 0; +} + +.createBody { + display: flex; + flex-direction: column; + gap: 12px; + padding: 22px 24px 20px; +} + +.createTitle { + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.createIn { + margin: 0; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.createInput { + box-sizing: border-box; + width: 100%; + height: 44px; + padding: 7px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; + outline: none; + background: transparent; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.createInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +.createActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx new file mode 100644 index 0000000000..f348f1dd8d --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -0,0 +1,503 @@ +/** + * The in-app workspace-directory browser (figma Harness 813-23126 family): a + * 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls + * sideways, the columns scroll down) whose header carries the title, the selection-path + * breadcrumb, and a click-to-edit path zone; below it a Miller view — one + * full-width level until a row is selected, then two 256px columns (level | + * selected folder's children) around a hairline divider. Selecting in the + * right column shifts the view one level deeper. "New folder" opens a nested + * create dialog targeting the selected folder (or the level itself) and + * selects the created folder. Open adopts the selected folder, falling back + * to the listed level. Pure consumer of the injected browse calls — the + * owning flow decides what "Open" means and owns the workspace-creation + * error surface. Hidden entries are host-flagged and filtered here (a + * show-hidden toggle is deferred work, client-side only). + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { + Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' +import type { Translate } from '@deepseek-ai/dsh-client-locale/client' +import css from './DirectoryBrowser.module.css' + +/** Owner-supplied browser props: browse calls, pick semantics, and copy. */ +export interface DirectoryBrowserProps { + /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ + open: boolean + /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ + listDirectory: (path?: string, signal?: AbortSignal) => Promise + /** Create one child directory under an existing parent. */ + createDirectory: (path: string, name: string) => Promise + /** The operator confirmed a directory (the selection, else the listed level). */ + onOpen: (path: string) => void + /** Close without picking (mask, Escape, Cancel). */ + onClose: () => void + /** The owner's confirm is in flight: Open disables, the view freezes. */ + busy: boolean + /** Localized copy. */ + t: Translate +} + +/** Failure text: the Host business message when typed, else the throw's text. */ +function failureText(error: unknown): string { + if (error instanceof DirectoryBrowseError) return error.rpcError.message + return error instanceof Error ? error.message : String(error) +} + +/** + * Breadcrumb rows for display: inside the home subtree the chain starts at a + * localized Home crumb; outside it the full ancestry shows, the root labeled + * by its own path. + */ +function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { + const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home) + if (homeIndex === -1) return listing.crumbs + const tail = listing.crumbs.slice(homeIndex + 1) + return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] +} + +/** One column of folder rows (the Miller view renders one or two of these). */ +function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { + entries: readonly DirectoryEntry[] + selectedPath: string | null + busy: boolean + onPick: (entry: DirectoryEntry) => void + wide: boolean +}) { + return ( +
+ {entries.filter(entry => !entry.hidden).map((entry) => { + const selected = entry.path === selectedPath + return ( + // The wrapper carries the list semantics; the row keeps its NATIVE + // button role so assistive technology exposes an actionable control. + + + + ) + })} +
+ ) +} + +/** + * Render the directory-browser dialog. + * @param props - owner-controlled browser props. + * @returns the dialog element (null while closed, via Modal). + */ +export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose, busy, t }: DirectoryBrowserProps) { + // Miller state: the listed level, the selected row in it, and the selected + // folder's own listing (the right column; null while nothing is selected). + const [parent, setParent] = useState(null) + const [selected, setSelected] = useState(null) + const [child, setChild] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + // Path-edit state: null = breadcrumb mode; a string = the draft being typed. + const [pathDraft, setPathDraft] = useState(null) + // Create-folder state: null = closed; a string = the nested dialog's draft. + const [folderDraft, setFolderDraft] = useState(null) + const [creatingFolder, setCreatingFolder] = useState(false) + const [createError, setCreateError] = useState(null) + const requestSeq = useRef(0) + // The in-flight listing's controller: superseding intent aborts the wire + // request too — the Host stops scanning — instead of only discarding the + // eventual result while the scan keeps consuming host resources. + const scanController = useRef(null) + // Bumped on every open/close edge: settlements from a previous open (a + // pending creation included) must never mutate a reopened dialog. + const openGeneration = useRef(0) + // Deep ancestry overflows the trail; keep its tail (the current directory + // and the edit zone beside it) in view whenever the chain changes. + const crumbTrailRef = useRef(null) + // IME confirmation (Enter selecting a candidate) must not submit either + // text input; the same guard the workspace-name inputs carry, shared by + // the path editor and the folder-name input. + const composingRef = useRef(false) + // HMR/unmount invalidation: a completion from a disposed flow must not + // update state or issue follow-up requests from a dead component. + useEffect(() => () => { + requestSeq.current += 1 + openGeneration.current += 1 + scanController.current?.abort() + }, []) + const compositionGuard = { + onCompositionStart: () => { composingRef.current = true }, + onCompositionEnd: () => { composingRef.current = false }, + } + + /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ + const supersede = useCallback((): number => { + scanController.current?.abort() + scanController.current = null + return ++requestSeq.current + }, []) + + /** Launch one listing under a fresh controller so a later supersession can abort it. */ + const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise } => { + const seq = supersede() + const controller = new AbortController() + scanController.current = controller + return { seq, scan: listDirectory(path, controller.signal) } + }, [supersede, listDirectory]) + + /** Replace the whole view with one freshly listed level (no selection). */ + const navigate = useCallback((path?: string) => { + const { seq, scan } = launchListing(path) + setLoading(true) + setError(null) + scan.then((next) => { + if (seq !== requestSeq.current) return + setParent(next) + setSelected(null) + setChild(null) + setLoading(false) + setPathDraft(null) + }, (reason: unknown) => { + if (seq !== requestSeq.current) return + setLoading(false) + setError(failureText(reason)) + }) + }, [launchListing]) + + /** Select a row of the listed level and preview its children on the right. */ + const select = useCallback((entry: DirectoryEntry) => { + const { seq, scan } = launchListing(entry.path) + setSelected(entry) + setChild(null) + setLoading(true) + setError(null) + scan.then((next) => { + if (seq !== requestSeq.current) return + setChild(next) + setLoading(false) + }, (reason: unknown) => { + if (seq !== requestSeq.current) return + setLoading(false) + setError(failureText(reason)) + // An unreadable selection cannot be the committing target while the + // breadcrumb still names the level: fall back to the single pane. + setSelected(null) + }) + }, [launchListing]) + + /** A right-column pick advances the view one level: child becomes the level. */ + const advance = useCallback((entry: DirectoryEntry) => { + /* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */ + if (child === null) return + setParent(child) + select(entry) + }, [child, select]) + + // Every open starts fresh at the Host home directory; closing invalidates + // any in-flight response so a late arrival cannot repopulate a closed dialog. + useEffect(() => { + openGeneration.current += 1 + if (open) { + setParent(null) + setSelected(null) + setChild(null) + setCreatingFolder(false) + navigate() + return + } + supersede() + setError(null) + setPathDraft(null) + setFolderDraft(null) + setCreateError(null) + }, [open, navigate, supersede]) + + /** The folder a create or Open acts on: the selection, else the listed level. */ + const targetPath = selected?.path ?? parent?.path ?? null + const targetName = selected?.name + ?? (parent === null ? '' : (displayCrumbs(parent, t('browser.home')).at(-1)?.name ?? parent.path)) + + const confirmCreate = (): void => { + /* v8 ignore next -- reentry fence: the nested dialog only renders with a target and disables while creating. */ + if (targetPath === null || folderDraft === null || creatingFolder) return + // Trim only rejects an all-whitespace draft; the Host gets the original + // spelling — the backend accepts any non-blank single segment verbatim, + // and trimming here would create (and select) a different sibling. + const name = folderDraft + if (name.trim() === '') return + setCreatingFolder(true) + setCreateError(null) + const generation = openGeneration.current + createDirectory(targetPath, name).then((createdPath) => { + // A settlement from a closed (possibly reopened) flow must not touch + // the fresh dialog or issue a relist against the stale target. + if (generation !== openGeneration.current) return + setCreatingFolder(false) + setFolderDraft(null) + // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the + // create target becomes the listed level and the new folder its selection. + const { seq, scan } = launchListing(targetPath) + setLoading(true) + scan.then((level) => { + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ + if (seq !== requestSeq.current) return + setParent(level) + setLoading(false) + select({ name, path: createdPath, hidden: false }) + }, (reason: unknown) => { + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ + if (seq !== requestSeq.current) return + setLoading(false) + setError(failureText(reason)) + }) + }, (reason: unknown) => { + if (generation !== openGeneration.current) return + setCreatingFolder(false) + setCreateError(failureText(reason)) + }) + } + + // After the hooks: a closed dialog renders nothing and evaluates no copy. + const crumbSource = child ?? parent + const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home')) + const crumbTail = crumbs.at(-1)?.path + useEffect(() => { + const trail = crumbTrailRef.current + if (trail !== null) trail.scrollLeft = trail.scrollWidth + }, [crumbTail]) + // On viewports too narrow for both fixed panes the Miller row scrolls; + // whenever a child preview lands, pin it into view the way the crumb tail + // pins — otherwise descent is unreachable on a phone-width window. + const millerRowRef = useRef(null) + const childPath = child?.path + useEffect(() => { + const row = millerRowRef.current + if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth + }, [childPath]) + + if (!open) return null + const twoPane = selected !== null + // The nested create dialog owns the interaction while open: Modal has no + // focus trap, so every parent control goes inert (Shift-Tab or AT must not + // close, adopt, or retarget underneath the child). + const parentInert = busy || folderDraft !== null + // An uncommitted path draft makes targetPath stale relative to the header: + // committing actions must not act on the previous selection/listing while + // a different path is displayed. + const draftPending = pathDraft !== null + + return ( + { if (folderDraft === null && !busy) onClose() }} + title={t('browser.title')} + className={clsx(css.dialog)} + headless + > +
+

{t('browser.title')}

+
+ {pathDraft === null + ? ( + <> + + {crumbs.map((crumb, index) => ( + + {index > 0 && } + + + ))} + + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
+
+
+
+ {parent !== null && ( + + )} + {twoPane && } + {twoPane && child !== null && ( + + )} +
+ {loading &&
{t('browser.loading')}
} + {/* The backend bounds a level at its complete-result limit; say so + * whenever a visible pane was cut instead of letting the tail of a + * huge directory go silently missing. */} + {(parent?.truncated === true || child?.truncated === true) && !loading + &&
{t('browser.truncated')}
} + {error !== null &&
{error}
} +
+
+ + + + +
+ {/* Nested create dialog (figma 813:23278): names one folder inside the target. */} + { if (!creatingFolder) setFolderDraft(null) }} + title={t('browser.newFolder')} + className={clsx(css.createDialog)} + headless + > +
+

{t('browser.newFolder')}

+

{t('browser.createIn', { name: targetName })}

+ { setFolderDraft(event.target.value) }} + {...compositionGuard} + onKeyDown={(event) => { + if (event.key === 'Enter' && !composingRef.current) { + event.preventDefault() + confirmCreate() + } + if (event.key === 'Escape') { + event.stopPropagation() + if (!creatingFolder) setFolderDraft(null) + } + }} + /> + {createError !== null &&
{createError}
} +
+ + +
+
+
+
+ ) +} diff --git a/packages/host/directory-picker-browse/src/client/flow.ts b/packages/host/directory-picker-browse/src/client/flow.ts new file mode 100644 index 0000000000..84e49b2c98 --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/flow.ts @@ -0,0 +1,43 @@ +/** + * The browse picking occupant (package-internal; the `./client` surface + * exposes only the Loader exports). Same-package tests exercise it directly + * through this module. + */ +import { createElement } from 'react' +import type { ReactElement } from 'react' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import type { Translate } from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the owner contract of the directory-flow holes. +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { DirectoryBrowser } from './DirectoryBrowser.tsx' + +/** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */ +export interface BrowseFlowInjected { + /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */ + listDirectory: (path?: string, signal?: AbortSignal) => Promise + /** Create one child directory under an existing parent. */ + createDirectory: (path: string, name: string) => Promise + /** Localized dialog copy (this package's namespace). */ + t: Translate +} + +/** + * Flow occupant: adapts the hole's owner conversation onto the browser + * dialog — a confirmed directory is the picked path, dismissal is the + * cancellation. Browse failures (unreadable targets, create conflicts) stay + * inside the dialog's own alert surfaces, so the owner's `onError` arm is + * never driven by this occupant. + * @param props - owner conversation plus the injected browse face. + * @returns the dialog element (renders nothing while closed). + */ +export function BrowseDirectoryFlow(props: DirectoryFlowOwnerProps & BrowseFlowInjected): ReactElement { + return createElement(DirectoryBrowser, { + open: props.open, + busy: props.busy, + listDirectory: props.listDirectory, + createDirectory: props.createDirectory, + t: props.t, + onOpen: props.onPicked, + onClose: props.onCancel, + }) +} diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts new file mode 100644 index 0000000000..8ec0ffc5f9 --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -0,0 +1,91 @@ +/** + * Browser half of the browse directory-picker backend: fills ui-workspace's + * two directory-flow holes with the in-app Select Workspace Directory dialog + * (figma `Harness` 813-23126 family), driving the node half's + * `host.listDirectory`/`host.createDirectory` primitives. Mounting this + * package therefore composes both sides of the browse interaction with one + * cordis.yml row; no client code branches on a capability kind. The dialog's + * copy is locale-registered here — the flow package owns its own strings. + */ +import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the SlotMap merge declaring the directory-flow holes. +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { BrowseFlowInjected } from './flow.ts' +import { BrowseDirectoryFlow } from './flow.ts' + +/** Locale namespace owning the browser dialog's copy. */ +const LOCALE_NS = 'directory-browser' + +/** Required services (cordis fiber inject): the slot registry, the wire-facing workspace service, and locale. */ +export const inject = ['slots', 'workspaces', 'locale'] + +/** + * Client plugin body: register the dialog's dictionaries and the browse flow + * into both directory-flow holes (declaration-aware deferral — the declaring + * ui-workspace entries may activate later, and an HMR collapse re-declares). + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + // The two dictionaries land as a unit: if the second registration hits a + // rival owner of the namespace, the first rolls back before the throw — + // a failed activation must not squat the namespace's other locale. + const disposers: (() => void)[] = [] + const dictionaries: [locale: string, dict: Record][] = [ + ['zh', { + 'browser.title': '选择工作区目录', + 'browser.home': '主目录', + 'browser.newFolder': '新建文件夹', + 'browser.folderName': '文件夹名称', + 'browser.createIn': '在"{name}"中新建文件夹', + 'browser.untitledFolder': '未命名文件夹', + 'browser.create': '创建', + 'browser.cancel': '取消', + 'browser.open': '打开', + 'browser.editPath': '编辑路径', + 'browser.loading': '加载中…', + 'browser.truncated': '文件夹过多,仅显示开头部分。', + }], + ['en', { + 'browser.title': 'Select Workspace Directory', + 'browser.home': 'Home', + 'browser.newFolder': 'New folder', + 'browser.folderName': 'Folder name', + 'browser.createIn': 'New folder in "{name}"', + 'browser.untitledFolder': 'Untitled folder', + 'browser.create': 'Create', + 'browser.cancel': 'Cancel', + 'browser.open': 'Open', + 'browser.editPath': 'Edit path', + 'browser.loading': 'Loading…', + 'browser.truncated': 'Too many folders to list; only the beginning is shown.', + }], + ] + try { + for (const [locale, dict] of dictionaries) disposers.push(ctx.locale.register(LOCALE_NS, locale, dict)) + } catch (error) { + for (const dispose of disposers.reverse()) dispose() + throw error + } + return () => { for (const dispose of disposers) dispose() } + }, 'directory-picker-browse: dialog dictionaries') + + const injected = (): BrowseFlowInjected => ({ + listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal), + createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), + t: ctx.locale.bind(LOCALE_NS), + }) + ctx.effect(() => { + // One occupant, both holes, as a unit: construction or late conflicts + // (holes declared after rival providers activated) roll the whole pair + // back and fail loud — semantics owned by deferGroupRegistration. + const group = deferGroupRegistration( + ctx.slots, + ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, + BrowseDirectoryFlow, + name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow), + ) + return () => { group.dispose() } + }, 'directory-picker-browse: flow registrations') +} diff --git a/packages/host/directory-picker-browse/src/css-modules.d.ts b/packages/host/directory-picker-browse/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/host/directory-picker-browse/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts new file mode 100644 index 0000000000..6fa157ea87 --- /dev/null +++ b/packages/host/directory-picker-browse/src/index.ts @@ -0,0 +1,324 @@ +/** + * Browse backend of the directory-picker seam: registers `ctx.directoryPicker` + * with the `browse` capability — one-level directory listing and child-directory + * creation over the host filesystem via 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. Policy decisions (hidden + * entries flagged but returned, symlinks followed, whole-filesystem scope) are + * recorded in the directory-picker seam Agent Note. + * @module @deepseek-ai/dsh-host-directory-picker-browse + */ + +import { mkdir, opendir, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, dirname, join, posix, resolve, win32 } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { + DirectoryPicker, DirectoryPickerError, +} from '@deepseek-ai/dsh-host-directory-picker' +import type { + DirectoryEntry, DirectoryListing, DirectoryPickerCapability, +} from '@deepseek-ai/dsh-host-directory-picker' + +/** + * Ancestor chain from the filesystem root to `target` inclusive — the + * breadcrumb rows of a listing, every one a jump target. + */ +function ancestryCrumbs(target: string): DirectoryEntry[] { + const crumbs: DirectoryEntry[] = [] + let current = target + for (;;) { + const parent = dirname(current) + // basename of a root is '' — label the root crumb by its full path ('/', 'C:\'). + crumbs.unshift({ name: parent === current ? current : basename(current), path: current, hidden: false }) + if (parent === current) return crumbs + current = parent + } +} + +/** + * True when the path names one fixed filesystem location regardless of + * process state: POSIX-absolute on POSIX; on Windows only drive-qualified + * (`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 platform - replaces `process.platform` for deterministic tests. + * @returns whether the path is fully qualified on the platform. + */ +export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean { + return platform === 'win32' + ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path) + : posix.isAbsolute(path) +} + +/** One streamed listing candidate: the dirent facts a row needs, nothing else retained. */ +export interface ListingCandidate { + /** Base name within the streamed level. */ + name: string + /** Dirent says directory (no probe needed). */ + isDirectory: boolean + /** Dirent says symlink (enterability needs a stat probe). */ + isSymbolicLink: boolean +} + +/** + * Insert a streamed candidate into the name-sorted bounded window, evicting + * the name-largest candidate when the window exceeds `keep`. Memory over an + * arbitrarily large level therefore stays O(keep) regardless of how many + * children the directory holds. + * @param window - the name-ascending window, mutated in place. + * @param candidate - the streamed candidate to place. + * @param keep - the window bound. + * @returns true when an eviction happened (the level has candidates beyond the window). + */ +export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean { + // Full window, name at or beyond the tail: one comparison rejects, so an + // oversized level costs O(1) per candidate past the head instead of a + // window scan (100k children against a 1,001 window must not approach + // 10^8 comparisons). + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a full window (length === keep >= 1) has a tail + if (window.length === keep && candidate.name.localeCompare(window[window.length - 1]!.name) >= 0) return true + // Binary insertion keeps a retained candidate at O(log keep) comparisons. + let lo = 0 + let hi = window.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + if (candidate.name.localeCompare(window[mid]!.name) < 0) hi = mid + else lo = mid + 1 + } + window.splice(lo, 0, candidate) + if (window.length <= keep) return false + window.pop() + return true +} + +/** + * Await `operation`, but reject with the signal's reason the moment it + * aborts. Node's filesystem reads are not retractable, so the operation + * itself keeps running against a handle the caller then closes — its late + * settlement is swallowed here so an abandoned read cannot surface as an + * unhandled rejection. + * @param operation - the in-flight filesystem step. + * @param signal - caller lifetime; absent means plain awaiting. + * @returns the operation's value. + */ +export function raceAbort(operation: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return operation + return new Promise((resolve, reject) => { + const onAbort = (): void => { + operation.catch(() => { + // Abandoned read: its handle is being closed by the aborting caller, + // and the abort reason already carried the outcome. + }) + reject(asError(signal.reason)) + } + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener('abort', onAbort, { once: true }) + operation.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (reason: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(asError(reason)) + }, + ) + }) +} + +/** The thrown value as an Error (wire/abort reasons may be anything). */ +function asError(reason: unknown): Error { + return reason instanceof Error ? reason : new Error(String(reason)) +} + +/* v8 ignore start -- a close failure of an abandoned handle has no consumer, and forcing one needs a filesystem torn down mid-request. */ +/** Swallow the close failure of a handle its caller already departed. */ +function swallowCloseFailure(): void {} +/* v8 ignore stop */ + +/** Message text of an unknown thrown value. */ +function messageOf(error: unknown): string { + /* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */ + return error instanceof Error ? error.message : String(error) +} + +/** + * One listing row for a dirent, following symlinks to directories; null for + * non-directories and broken/cyclic links (skipped silently — the browser + * shows what can be entered, and a broken link cannot). + */ +async function directoryRow( + parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean, signal: AbortSignal | undefined, +): Promise { + const path = join(parent, name) + let enterable = isDirectory + if (!enterable && isSymbolicLink) { + try { + // The probe races the caller too: a symlink target on a stalled + // network filesystem must not keep a departed caller's request alive. + enterable = (await raceAbort(stat(path), signal)).isDirectory() + } catch { + /* v8 ignore next 2 -- an abort landing mid-probe needs a stalled stat; the per-candidate check in list covers the settled path. */ + if (signal?.aborted) throw asError(signal.reason) + // Broken or cyclic symlink: stat is the probe, failure means "not enterable". + return null + } + } + if (!enterable) return null + // POSIX hidden convention; Windows' hidden attribute is not exposed by + // dirents (Known Limitations). The client owns whether hidden rows show. + return { name, path, hidden: name.startsWith('.') } +} + +/** Validated plugin configuration. */ +export interface Config { + /** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */ + maxEntries: number +} + +/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */ +export default class BrowseDirectoryPicker extends DirectoryPicker { + /** + * `maxEntries` bounds the complete listing level a single `list` call may + * materialize and put on the wire: at most this many child-directory rows + * (hidden rows included), with `truncated` flagging a cut level. The + * default follows GitHub's web UI, which truncates directory listings at + * 1,000 entries. + */ + static Config: z = z.object({ + maxEntries: z.natural().min(1).default(1000), + }) + + private readonly browseCapability: DirectoryPickerCapability = { + kind: 'browse', + list: (path, signal) => this.list(path, signal), + createDirectory: (path, name) => this.createDirectory(path, name), + } + + constructor(ctx: Context, private readonly config: Config) { + super(ctx) + } + + /** + * The browse interaction capability. + * @returns the stable `browse` capability object. + */ + capability(): DirectoryPickerCapability { + return this.browseCapability + } + + private async list(path?: string, signal?: AbortSignal): Promise { + const home = homedir() + // The seam contract takes fully qualified paths only; resolve() would + // silently rebase a relative or empty wire value under the host process + // cwd (or, for rooted drive-less Windows forms, its current drive). + if (path !== undefined && !fullyQualified(path)) { + throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`) + } + const target = resolve(path ?? home) + // Stream the level (opendir, one dirent at a time) into a name-sorted + // window of maxEntries + 1 candidates: memory stays bounded no matter how + // many children the directory holds, the window keeps the name-sorted + // head, and the +1 slot lets an in-window extra row prove the cut. A + // window candidate that turns out non-enterable (broken symlink) is not + // backfilled from beyond the window — an eviction already marks the + // level truncated, which stays the honest answer. + const keep = this.config.maxEntries + 1 + const window: ListingCandidate[] = [] + let evicted = false + try { + // Every filesystem await races the caller's signal: a stalled + // opendir/read on a network filesystem must not keep a departed + // caller's scan alive, and an already-aborted request rejects even + // when the level is empty. + const opening = opendir(target) + const level = await raceAbort(opening, signal).catch((error: unknown) => { + // The abandoned open can still mint a handle after the abort won; + // close it so a departed caller cannot leak a descriptor. (A lost + // race against opendir's own rejection has nothing to close, and + // the close's own failure is swallowed — the request already + // returned, so a cleanup error has no consumer.) + void opening.then(dir => dir.close().catch(swallowCloseFailure), () => { + // Already rejected: raceAbort surfaced or swallowed it. + }) + throw error + }) + try { + for (;;) { + const dirent = await raceAbort(level.read(), signal) + if (dirent === null) break + // Only rows a browser could enter contend for the window; dirent + // says "directory" outright, a symlink needs the later stat probe. + if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue + const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() } + if (boundedInsert(window, candidate, keep)) evicted = true + } + } finally { + // Manual read() never auto-closes; close on every exit. The aborted + // exit must not await it — Node queues close behind any in-flight + // read, so awaiting would chain the departed caller back onto the + // very stall the abort escaped (the abandoned read's settlement is + // already swallowed by raceAbort). + const closing = level.close() + /* v8 ignore next 3 -- an abort between open and close needs a stalled read; the abandoned-close arm has no observable outcome. */ + if (signal?.aborted) { + closing.catch(swallowCloseFailure) + } else { + await closing + } + } + } catch (error: unknown) { + // An abort is the caller's own reason, not an unreadable directory. + signal?.throwIfAborted() + throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`) + } + const entries: DirectoryEntry[] = [] + let truncated = evicted + for (const candidate of window) { + // A caller that departed between reads and probes stops before the + // next probe (each probe's own await is raced inside directoryRow). + signal?.throwIfAborted() + const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal) + if (row === null) continue + if (entries.length === this.config.maxEntries) { + truncated = true + break + } + entries.push(row) + } + return { path: target, home, crumbs: ancestryCrumbs(target), entries, truncated } + } + + private async createDirectory(path: string, name: string): Promise { + // Same fully-qualified fence as list: never rebase a parent under the + // cwd or the current drive. + if (!fullyQualified(path)) { + throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified 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). + if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { + throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`) + } + const target = join(parent, name) + try { + // Non-recursive: the parent is the directory the browser is showing, so + // a missing parent is a real failure, not a level to invent. + await mkdir(target) + return target + } catch (error: unknown) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') { + throw new DirectoryPickerError('directory-exists', target, `${target} already exists`) + } + throw new DirectoryPickerError('directory-create-failed', target, `cannot create ${target}: ${messageOf(error)}`) + } + } +} diff --git a/packages/host/directory-picker-browse/src/invariant.ts b/packages/host/directory-picker-browse/src/invariant.ts new file mode 100644 index 0000000000..ba4bfe7b13 --- /dev/null +++ b/packages/host/directory-picker-browse/src/invariant.ts @@ -0,0 +1,25 @@ +/** + * Package-owned invariant companion for the browse directory-picker backend. + * @module @deepseek-ai/dsh-host-directory-picker-browse/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-browse' + +/** Cordis companion plugin name. */ +export const name = 'host-directory-picker-browse-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: each list/create is one stateless filesystem round trip; the filesystem itself is the authoritative state. */ +const install: InvariantInstaller = () => {} + +/** + * Register the browse directory-picker invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx new file mode 100644 index 0000000000..8a9b27b5d7 --- /dev/null +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -0,0 +1,214 @@ +// @vitest-environment jsdom +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { apply, inject } from '../src/client/index.ts' +import { BrowseDirectoryFlow } from '../src/client/flow.ts' + +afterEach(cleanup) + +const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const + +const HOME = '/home/u' +const homeListing: DirectoryListing = { + path: HOME, + home: HOME, + crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'u', path: HOME, hidden: false }], + entries: [{ name: 'Documents', path: `${HOME}/Documents`, hidden: false }], + truncated: false, +} + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + ctx.provide('locale', new LocaleService(ctx)) + const listDirectory = vi.fn(async (): Promise => homeListing) + const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`) + ctx.provide('workspaces', { listDirectory, createDirectory } as never) + const slots = ctx.get('slots') as SlotsService + const declare = () => slots.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, () => null) + return { ctx, slots, listDirectory, createDirectory, declare } +} + +function owner(overrides: Partial = {}): DirectoryFlowOwnerProps { + return { + open: true, busy: false, + onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(), + ...overrides, + } +} + +describe('directory-picker-browse client half', () => { + it('declares the services it drives', () => { + expect(inject).toEqual(['slots', 'workspaces', 'locale']) + }) + + it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { + const before = await bench() + before.declare() + const fiber = before.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1) + // Registry-contribution disposal proof: the fiber going down empties the holes. + await fiber.dispose() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0) + after.declare() + await Promise.resolve() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) + }) + + it('rolls back the first deferral when the second hole is already occupied', async () => { + const b = await bench() + b.declare() + // Foreign occupant in the SECOND registered hole: the pair construction + // throws after the first deferral installed its subscription. + b.slots.register({ name: HOLES[1] } as never, () => null) + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await expect(fiber.await()).rejects.toThrow(/already has a registration/) + // A leaked first deferral would now race this probe registration and + // throw from its orphaned subscription against the HERO hole; the + // rollback leaves only the activation failure itself (cordis re-raises + // the apply throw as a late rejection — installFailLoud's contract). + const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([]) + disposeProbe() + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => { + const b = await bench() + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + process.on('uncaughtException', onUnhandled) + try { + // This provider activates BEFORE any hole exists: both deferrals wait. + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.declare() + // A rival occupies both holes ahead of the pending microtask flush. + b.slots.register({ name: HOLES[0] } as never, () => null) + b.slots.register({ name: HOLES[1] } as never, () => null) + await new Promise(resolve => setTimeout(resolve, 20)) + // The rival keeps both holes; this provider rolled back wholesale and + // surfaced the conflict on the fail-loud channel — no partial mix. + for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1) + expect(rejections.map(String).join('\n')).toContain('already has a registration') + + // Non-Error conflicts wrap before the loud rethrow (same channel). + const c = await bench() + await c.ctx.plugin({ inject: [...inject], apply }).await() + const original = c.slots.register.bind(c.slots) + const slotsAny = c.slots as { register: typeof original } + slotsAny.register = ((options: never, component: never) => { + if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict' + return original(options, component) + }) as typeof original + c.declare() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rejections.map(String).join('\n')).toContain('string conflict') + } finally { + process.off('unhandledRejection', onUnhandled) + process.off('uncaughtException', onUnhandled) + } + }) + + it('rolls back the zh dictionary when a rival already owns the namespace en slot', async () => { + const b = await bench() + b.declare() + const locale = b.ctx.get('locale') as LocaleService + const disposeRival = locale.register('directory-browser', 'en', { 'browser.title': 'rival' }) + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + // cordis re-raises the apply throw as a late rejection (installFailLoud's contract). + process.on('unhandledRejection', onUnhandled) + try { + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await expect(fiber.await()).rejects.toThrow(/already has locale/) + // The zh registration rolled back with the failure: once the rival + // leaves, a fresh registrant owns the whole namespace again. + disposeRival() + const disposeZh = locale.register('directory-browser', 'zh', { 'browser.title': '空闲' }) + disposeZh() + } finally { + await new Promise(resolve => setTimeout(resolve, 0)) + process.off('unhandledRejection', onUnhandled) + } + }) + + it('registers the dialog dictionaries and binds this package namespace', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries(HOLES[0])[0]! + const injected = (entry.inject as () => { t: (key: string) => string })() + // zh is the shipped default locale. + expect(injected.t('browser.title')).toBe('选择工作区目录') + expect(injected.t('browser.newFolder')).toBe('新建文件夹') + }) + + it('drives the injected browse calls through the hole entry', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries(HOLES[1])[0]! + const injected = (entry.inject as () => { + listDirectory: (path?: string) => Promise + createDirectory: (path: string, name: string) => Promise + })() + await expect(injected.listDirectory()).resolves.toBe(homeListing) + await expect(injected.createDirectory(HOME, 'fresh')).resolves.toBe(`${HOME}/fresh`) + expect(b.listDirectory).toHaveBeenCalledOnce() + expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh') + }) + + it('adapts the owner conversation onto the dialog: confirm picks, dismissal cancels', async () => { + const props = owner() + const listDirectory = vi.fn(async (): Promise => homeListing) + const t = (key: string): string => key + render( + '')} + t={t} + />, + ) + // The dialog opened at home; its confirm (browser.open) adopts the listed level. + const openButton = await screen.findByRole('button', { name: 'browser.open' }) + openButton.click() + expect(props.onPicked).toHaveBeenCalledWith(HOME) + screen.getByRole('button', { name: 'browser.cancel' }).click() + expect(props.onCancel).toHaveBeenCalled() + expect(props.onError).not.toHaveBeenCalled() + }) + + it('renders nothing while the flow is closed', () => { + const view = render( + homeListing)} + createDirectory={vi.fn(async () => '')} + t={key => key} + />, + ) + expect(view.container.innerHTML).toBe('') + }) +}) diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx new file mode 100644 index 0000000000..babe7c8bf7 --- /dev/null +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -0,0 +1,792 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' +import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' + +afterEach(cleanup) + +const HOME = '/home/u' +const DOCS = `${HOME}/Documents` +const HARNESS = `${DOCS}/harness` + +/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */ +function listingFor(path?: string): DirectoryListing { + const target = path ?? HOME + const tree: Record = { + [HOME]: { + path: HOME, + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'u', path: HOME, hidden: false }, + ], + entries: [ + { name: '.config', path: `${HOME}/.config`, hidden: true }, + { name: 'Documents', path: DOCS, hidden: false }, + ], + truncated: false, + }, + [DOCS]: { + path: DOCS, + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'u', path: HOME, hidden: false }, + { name: 'Documents', path: DOCS, hidden: false }, + ], + entries: [{ name: 'harness', path: HARNESS, hidden: false }], + truncated: false, + }, + [HARNESS]: { + path: HARNESS, + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'u', path: HOME, hidden: false }, + { name: 'Documents', path: DOCS, hidden: false }, + { name: 'harness', path: HARNESS, hidden: false }, + ], + entries: [], + truncated: false, + }, + } + const found = tree[target] + if (found === undefined) { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: `cannot list ${target}`, details: { path: target } }) + } + return found +} + +function mount(overrides: Partial[0]> = {}) { + const listDirectory = vi.fn(async (path?: string) => listingFor(path)) + const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`) + const onOpen = vi.fn() + const onClose = vi.fn() + const props = { + open: true, + listDirectory, + createDirectory, + onOpen, + onClose, + busy: false, + t: (key: string, params?: Record) => (params === undefined ? key : `${key}:${String(params.name)}`), + ...overrides, + } + const view = render() + return { view, props, listDirectory, createDirectory, onOpen, onClose } +} + +/** The rendered level columns, left-to-right. */ +function columns(): HTMLElement[] { + return screen.getAllByRole('list') +} + +/** The actionable button inside a listitem seat (rows keep native button semantics). */ +function rowButton(item: HTMLElement): HTMLButtonElement { + return within(item).getByRole('button') +} + +describe('DirectoryBrowser', () => { + it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(b.listDirectory).toHaveBeenCalledWith(undefined, expect.any(AbortSignal)) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + expect(screen.queryByText('.config')).toBeNull() + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '/' })).toBeNull() + }) + + it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + const [level, preview] = columns() + const selectedRow = within(level!).getByRole('listitem') + expect(selectedRow.textContent).toBe('Documents') + expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') + expect(within(preview!).getByRole('listitem').textContent).toBe('harness') + expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) + expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy() + }) + + it('advances one level when a right-column row is picked', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(rowButton(within(columns()[1]!).getByRole('listitem'))) + await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() }) + const [level] = columns() + const selectedRow = within(level!).getByRole('listitem') + expect(selectedRow.textContent).toBe('harness') + expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') + }) + + it('aborts a superseded listing on the wire, and the in-flight one on close', async () => { + const signals: (AbortSignal | undefined)[] = [] + const gates: (() => void)[] = [] + const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => { + signals.push(signal) + if (signals.length === 1) return Promise.resolve(listingFor(path)) + // Later listings hang until released: supersession must abort them + // on the wire, not merely discard their eventual results. + return new Promise((resolve) => { gates.push(() => { resolve(listingFor(path)) }) }) + }) + const b = mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + expect(signals).toHaveLength(2) + // A crumb jump supersedes the hanging preview: its request aborts. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + expect(signals[1]?.aborted).toBe(true) + expect(signals[2]?.aborted).toBe(false) + // Closing the dialog aborts the still-pending navigation too. + b.view.rerender() + expect(signals[2]?.aborted).toBe(true) + }) + + it('jumps back through a crumb into a fresh single-column level', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + }) + + it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) + expect(b.onOpen).toHaveBeenCalledWith(HOME) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) + expect(b.onOpen).toHaveBeenLastCalledWith(DOCS) + fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' })) + expect(b.onClose).toHaveBeenCalled() + + const busy = mount({ busy: true }) + await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() }) + expect(screen.getAllByRole('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true) + }) + + it('edits the path from the crumb bar: Enter navigates, Escape restores, blank is ignored', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + expect(input.value).toBe(HOME) + fireEvent.change(input, { target: { value: DOCS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + expect(columns()).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const again = screen.getByLabelText('browser.editPath') + fireEvent.change(again, { target: { value: ' ' } }) + fireEvent.keyDown(again, { key: 'Enter' }) + expect(b.listDirectory).toHaveBeenCalledTimes(2) + fireEvent.keyDown(again, { key: 'Escape' }) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + }) + + it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => { + // The initial home listing hangs; Edit Path supersedes it while parent + // is still null, and Escape must not strand a blank picker. + let settled = false + const gate = new Promise(() => {}) + const listDirectory = vi.fn(async (path?: string) => { + if (!settled) { settled = true; return gate } + return listingFor(path) + }) + mount({ listDirectory }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + expect(input.value).toBe('') + fireEvent.keyDown(input, { key: 'Escape' }) + // Cancellation relaunched the home listing instead of leaving neither + // rows nor status behind. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(listDirectory).toHaveBeenCalledTimes(2) + expect(listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal)) + }) + + it('passes the entered path to the Host untrimmed (trim only gates blank drafts)', async () => { + const listDirectory = vi.fn(async (path?: string) => listingFor(path)) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS} ` } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // A trailing space may name a real directory; trimming would list its sibling. + await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `, expect.any(AbortSignal)) }) + }) + + it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: '/nope' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('cannot list /nope') }) + expect(screen.getByLabelText('browser.editPath')).toBeTruthy() + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + it('folds non-typed failures into readable text (Error message, String otherwise)', async () => { + const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') }) + b.view.rerender() + const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) }) + await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') }) + expect(raw.onOpen).not.toHaveBeenCalled() + }) + + it('renders the full ancestry when the level sits outside the home subtree', async () => { + const outside: DirectoryListing = { + path: '/srv/data', + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'srv', path: '/srv', hidden: false }, + { name: 'data', path: '/srv/data', hidden: false }, + ], + entries: [], + truncated: false, + } + mount({ listDirectory: vi.fn(async () => outside) }) + await waitFor(() => { expect(screen.getByRole('button', { name: 'data' })).toBeTruthy() }) + expect(screen.getByRole('button', { name: '/' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull() + }) + + it('scopes Escape to the topmost dialog: the nested create closes first, the browser only after', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + expect(screen.getByLabelText('browser.folderName')).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + // The nested dialog consumed Escape; the browser stays up. + expect(screen.queryByLabelText('browser.folderName')).toBeNull() + expect(b.onClose).not.toHaveBeenCalled() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(b.onClose).toHaveBeenCalledOnce() + }) + + it('keeps both dialogs open when Escape lands during an in-flight creation', async () => { + let resolve!: (path: string) => void + const createDirectory = vi.fn(() => new Promise((settle) => { resolve = settle })) + const b = mount({ createDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'pending' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + fireEvent.keyDown(document, { key: 'Escape' }) + // The in-flight fence holds the nested dialog, and the browser must not + // fall out from under it either. + expect(screen.getByLabelText('browser.folderName')).toBeTruthy() + expect(b.onClose).not.toHaveBeenCalled() + await act(async () => { resolve(`${HOME}/pending`) }) + }) + + it('keeps New folder disabled while the post-create relist is still loading', async () => { + const pending: (() => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + // Every listing after the create hangs until drained: the button must not + // offer a second create against a target the pending relist/select + // sequence is about to change. + const fresh: DirectoryListing = { + path: `${HOME}/fresh`, home: HOME, + crumbs: [...listingFor(HOME).crumbs, { name: 'fresh', path: `${HOME}/fresh`, hidden: false }], + entries: [], + truncated: false, + } + b.listDirectory.mockImplementation((path?: string) => + new Promise((settle) => { + pending.push(() => { settle(path === `${HOME}/fresh` ? fresh : listingFor(path)) }) + })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'fresh' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + expect(screen.getByRole('button', { name: 'browser.newFolder' }).disabled).toBe(true) + // Drain the relist and the follow-up selection listing; only then does + // the affordance return. + await act(async () => { for (const settle of pending.splice(0)) settle() }) + await act(async () => { for (const settle of pending.splice(0)) settle() }) + expect(screen.getByRole('button', { name: 'browser.newFolder' }).disabled).toBe(false) + }) + + it('keeps path entry available when the home listing fails', async () => { + const listDirectory = vi.fn(async (): Promise => { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'home unreadable', details: { path: HOME } }) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('home unreadable') }) + // With no listed level, typing an absolute path is the one way forward. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: DOCS } }) + listDirectory.mockImplementation(async (path?: string) => listingFor(path)) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + }) + + it('disables Open and New folder while a path draft is uncommitted', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + // targetPath still names the previous listing; committing actions must + // not act on it while a different path is displayed in the header. + expect(screen.getByRole('button', { name: 'browser.open' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'browser.newFolder' }).disabled).toBe(true) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + expect(screen.getByRole('button', { name: 'browser.open' }).disabled).toBe(false) + }) + + it('ignores Enter while an IME composition is active in either input', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // Path editor: a composing Enter confirms the candidate, not the path. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const pathInput = screen.getByLabelText('browser.editPath') + fireEvent.change(pathInput, { target: { value: DOCS } }) + const listCalls = b.listDirectory.mock.calls.length + fireEvent.compositionStart(pathInput) + fireEvent.keyDown(pathInput, { key: 'Enter' }) + expect(b.listDirectory.mock.calls.length).toBe(listCalls) + fireEvent.compositionEnd(pathInput) + fireEvent.keyDown(pathInput, { key: 'Enter' }) + await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) }) + // Create dialog: same guard. + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const nameInput = screen.getByLabelText('browser.folderName') + fireEvent.change(nameInput, { target: { value: '新建' } }) + fireEvent.compositionStart(nameInput) + fireEvent.keyDown(nameInput, { key: 'Enter' }) + expect(b.createDirectory).not.toHaveBeenCalled() + fireEvent.compositionEnd(nameInput) + fireEvent.keyDown(nameInput, { key: 'Enter' }) + await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, '新建') }) + }) + + it('surfaces a two-pane navigation failure as an alert below the columns', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + b.listDirectory.mockImplementation(async () => { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: HOME } }) + }) + fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + // Both panes survive the failure; the alert renders in the flow, not as a + // third column competing for the fixed widths. + expect(columns()).toHaveLength(2) + }) + + it('keeps the editor open when a pending listing settles right after Edit Path was clicked', async () => { + const pending: ((listing: DirectoryListing) => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // A crumb navigation hangs; the user opens the editor before it settles. + b.listDirectory.mockImplementation(() => + new Promise((settle) => { pending.push(settle) })) + fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'browser.home' })) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + expect(screen.getByLabelText('browser.editPath')).toBeTruthy() + await act(async () => { pending.shift()!(listingFor(HOME)) }) + // The superseded settlement must not close the editor underneath the user. + expect(screen.getByLabelText('browser.editPath')).toBeTruthy() + }) + + it('ignores a pending navigation that settles after Escape cancelled the editor', async () => { + const pending: ((listing: DirectoryListing) => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + b.listDirectory.mockImplementation(() => + new Promise((settle) => { pending.push(settle) })) + fireEvent.change(input, { target: { value: DOCS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.keyDown(input, { key: 'Escape' }) + // The cancelled navigation settling late must not jump the view to DOCS. + await act(async () => { pending.shift()!(listingFor(DOCS)) }) + expect(screen.queryByText('harness')).toBeNull() + expect(screen.getByText('Documents')).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('keeps a newer path edit when an older slow navigation settles', async () => { + const pending: ((listing: DirectoryListing) => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + b.listDirectory.mockImplementation(() => + new Promise((settle) => { pending.push(settle) })) + fireEvent.change(input, { target: { value: DOCS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // The user keeps typing while the lookup hangs; the older completion must + // neither clear this newer draft nor swap the view to the older path. + fireEvent.change(input, { target: { value: `${DOCS}/har` } }) + await act(async () => { pending.shift()!(listingFor(DOCS)) }) + expect(screen.getByLabelText('browser.editPath').value).toBe(`${DOCS}/har`) + expect(screen.queryByText('harness')).toBeNull() + }) + + it('keeps an intact selection preview when a path edit is cancelled', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + // Nothing was superseded: the two-pane view survives the cancel. + expect(columns()).toHaveLength(2) + }) + + it('falls back to the single-pane level when a path edit superseded the preview and was cancelled', async () => { + const pending: ((listing: DirectoryListing) => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // Selection starts a preview that never lands (superseded below). + b.listDirectory.mockImplementation(() => + new Promise((settle) => { pending.push(settle) })) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/x` } }) + fireEvent.keyDown(input, { key: 'Escape' }) + // No half-empty two-pane residue: back to the single wide level. + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() + }) + + it('drops a creation that settles after the browser unmounted', async () => { + let settleCreate!: (path: string) => void + const createDirectory = vi.fn(() => new Promise((settle) => { settleCreate = settle })) + const b = mount({ createDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'slow' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + const listCalls = b.listDirectory.mock.calls.length + b.view.unmount() + // The dead flow must not issue the post-create relist. + await act(async () => { settleCreate(`${HOME}/slow`) }) + expect(b.listDirectory.mock.calls.length).toBe(listCalls) + }) + + it('clears the selection when its preview listing fails', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + b.listDirectory.mockImplementation(async () => { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } }) + }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + // The breadcrumb names the level, so the level must be the committing + // target: no half-selected two-pane state survives the failure. + expect(columns()).toHaveLength(1) + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + }) + + it('ignores dismissal while adoption is busy', async () => { + const b = mount({ busy: true }) + await waitFor(() => { expect(screen.getByRole('dialog')).toBeTruthy() }) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(b.onClose).not.toHaveBeenCalled() + }) + + it('makes every parent control inert while the nested create dialog is open', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + // Modal traps no focus: Shift-Tab/AT reach the parent, so closing, + // adopting, and retargeting must all disable underneath the child. Both + // dialogs carry a cancel: the parent's disables, the child's stays live. + const cancels = screen.getAllByRole('button', { name: 'browser.cancel' }) + expect(cancels.map(button => button.disabled).sort()).toEqual([false, true]) + expect(screen.getByRole('button', { name: 'browser.open' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'browser.editPath' }).disabled).toBe(true) + for (const row of screen.getAllByRole('listitem')) { + expect(rowButton(row).disabled).toBe(true) + } + }) + + it('drops a creation failure that lands after the flow closed and reopened', async () => { + let rejectCreate!: (reason: unknown) => void + const createDirectory = vi.fn(() => new Promise((_settle, reject) => { rejectCreate = reject })) + const b = mount({ createDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'slow' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + b.view.rerender() + b.view.rerender() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // The stale failure must not surface an alert inside the fresh flow. + await act(async () => { rejectCreate(new Error('too late')) }) + expect(screen.queryByText('too late')).toBeNull() + }) + + it('drops a creation that settles after the flow closed and reopened', async () => { + let settleCreate!: (path: string) => void + const createDirectory = vi.fn(() => new Promise((settle) => { settleCreate = settle })) + const b = mount({ createDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'slow' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + b.view.rerender() + b.view.rerender() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + const listCallsBefore = b.listDirectory.mock.calls.length + // The stale settlement must not relist the old target or reopen the + // nested dialog's state inside the fresh flow. + await act(async () => { settleCreate(`${HOME}/slow`) }) + expect(b.listDirectory.mock.calls.length).toBe(listCallsBefore) + expect(screen.queryByLabelText('browser.folderName')).toBeNull() + expect(screen.getByText('Documents')).toBeTruthy() + }) + + it('passes the folder name to the Host untrimmed (trim only gates blank drafts)', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const input = screen.getByLabelText('browser.folderName') + fireEvent.change(input, { target: { value: 'project ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // A trailing space may be the wanted spelling; trimming would create a sibling. + await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'project ') }) + }) + + it('creates a folder through the nested dialog and lands with it selected', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + // The nested dialog names the create target (the selected folder). + expect(screen.getByText('browser.createIn:Documents')).toBeTruthy() + // The created folder becomes listable (like the real backend after mkdir). + b.listDirectory.mockImplementation(async (path?: string) => { + if (path === `${DOCS}/fresh`) { + return { + path: `${DOCS}/fresh`, home: HOME, + crumbs: [...listingFor(DOCS).crumbs, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }], + entries: [], + truncated: false, + } + } + if (path === DOCS) { + const docs = listingFor(DOCS) + return { ...docs, entries: [...docs.entries, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }] } + } + return listingFor(path) + }) + const input = screen.getByLabelText('browser.folderName') + fireEvent.change(input, { target: { value: 'fresh' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, 'fresh') }) + // The create target became the level and the new folder its selection. + await waitFor(() => { + expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy() + const level = columns()[0]! + const rows = within(level).getAllByRole('listitem') + expect(rows.some(row => row.textContent === 'fresh' && rowButton(row).getAttribute('aria-current') === 'true')).toBe(true) + }) + }) + + it('keeps the nested dialog open on a creation failure and cancels cleanly', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + b.createDirectory.mockRejectedValueOnce( + new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } })) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + expect(screen.getByText('browser.createIn:browser.home')).toBeTruthy() + const input = screen.getByLabelText('browser.folderName') + // A blank name never submits. + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(b.createDirectory).not.toHaveBeenCalled() + fireEvent.change(input, { target: { value: 'x' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') }) + fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' }) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + + // The nested Cancel button and the nested mask both close only the child dialog. + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const nested = screen.getByRole('dialog', { name: 'browser.newFolder' }) + fireEvent.click(within(nested).getByRole('button', { name: 'browser.cancel' })) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const masks = document.querySelectorAll('[aria-hidden="true"]') + fireEvent.click(masks[masks.length - 1]!) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() + }) + + it('surfaces a post-create relist failure on the browser surface', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + // Creation succeeds, but relisting the target fails afterwards. + b.listDirectory.mockRejectedValueOnce(new Error('level vanished')) + const input = screen.getByLabelText('browser.folderName') + fireEvent.change(input, { target: { value: 'fresh' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('level vanished') }) + }) + + it('drops a stale child listing that resolves after a crumb jump', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + let resolveSlow!: (value: DirectoryListing) => void + const slow = new Promise((settle) => { resolveSlow = settle }) + b.listDirectory.mockReturnValueOnce(slow) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + resolveSlow(listingFor(DOCS)) + await new Promise(settle => setTimeout(settle, 0)) + // The superseded selection preview did not reopen the second pane. + expect(columns()).toHaveLength(1) + }) + + it('drops a stale failure that rejects after a newer navigation', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + let rejectSlow!: (reason: unknown) => void + const slow = new Promise((_settle, fail) => { rejectSlow = fail }) + b.listDirectory.mockReturnValueOnce(slow) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) + rejectSlow(new Error('too late to matter')) + await new Promise(settle => setTimeout(settle, 0)) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + it('drops a stale navigation failure that rejects after a newer jump', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + let rejectSlow!: (reason: unknown) => void + const slow = new Promise((_settle, fail) => { rejectSlow = fail }) + b.listDirectory.mockReturnValueOnce(slow) + // A slow crumb jump superseded by a second jump. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })) + await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(4) }) + rejectSlow(new Error('late nav failure')) + await new Promise(settle => setTimeout(settle, 0)) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('drops a stale navigation listing that resolves after a newer jump', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + let resolveSlow!: (value: DirectoryListing) => void + const slow = new Promise((settle) => { resolveSlow = settle }) + b.listDirectory.mockReturnValueOnce(slow) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + resolveSlow(listingFor(undefined)) + await new Promise(settle => setTimeout(settle, 0)) + // The stale home listing did not replace the newer Documents level. + expect(screen.getByRole('listitem').textContent).toBe('harness') + }) + + it('names the create target by its path when the level reports no crumbs', async () => { + const bare: DirectoryListing = { path: '/srv/data', home: HOME, crumbs: [], entries: [], truncated: false } + mount({ listDirectory: vi.fn(async () => bare) }) + await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.newFolder' })).toBeTruthy() }) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'browser.newFolder' }).disabled).toBe(false) + }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy() + }) + + it('refuses to close the nested dialog while the creation is in flight', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + let settleCreate!: (path: string) => void + b.createDirectory.mockReturnValueOnce(new Promise((settle) => { settleCreate = settle })) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const input = screen.getByLabelText('browser.folderName') + fireEvent.change(input, { target: { value: 'slow' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // Escape and the mask are both inert while creating. + fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' }) + const masks = document.querySelectorAll('[aria-hidden="true"]') + fireEvent.click(masks[masks.length - 1]!) + expect(screen.getByLabelText('browser.folderName')).toBeTruthy() + settleCreate(`${HOME}/slow`) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + }) + + it('says a level is incomplete when the backend cut it at its bound', async () => { + const cut = { ...listingFor(HOME), truncated: true } + mount({ listDirectory: vi.fn(async () => cut) }) + await screen.findByText('browser.truncated') + }) + + it('flags a truncated child preview under a complete level', async () => { + mount({ + listDirectory: vi.fn(async (path?: string) => + (path === DOCS ? { ...listingFor(DOCS), truncated: true } : listingFor(path))), + }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(screen.queryByText('browser.truncated')).toBeNull() + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + await screen.findByText('browser.truncated') + }) + + it('pins the child pane into view when its preview lands (narrow viewports scroll the miller row)', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + const row = document.querySelector('[class*=millerRow]') as HTMLElement + // jsdom does no layout: stub the overflow width the effect pins against. + Object.defineProperty(row, 'scrollWidth', { value: 640, configurable: true }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + await waitFor(() => { expect(row.scrollLeft).toBe(640) }) + }) + + it('starts back at home on reopen', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + b.view.rerender() + b.view.rerender() + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(columns()).toHaveLength(1) + expect(b.listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal)) + }) +}) diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts new file mode 100644 index 0000000000..002d42e516 --- /dev/null +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -0,0 +1,231 @@ +/** Behavior of the browse backend over a real temporary directory tree. */ + +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker' +import BrowseDirectoryPicker, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts' +import type { ListingCandidate } from '../src/index.ts' + +let root: string +let capability: DirectoryPickerBrowseCapability +let dispose: () => Promise + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-browse-')) + await mkdir(join(root, 'projects')) + await mkdir(join(root, 'projects', 'harness')) + await mkdir(join(root, '.hidden-dir')) + await writeFile(join(root, 'notes.txt'), 'not a directory') + await symlink(join(root, 'projects'), join(root, 'linked'), 'junction') + await symlink(join(root, 'gone'), join(root, 'broken'), 'junction') + try { + await symlink(join(root, 'notes.txt'), join(root, 'file-link')) + } catch { + // Windows denies unprivileged file symlinks; the file-link row only + // feeds the POSIX lanes' coverage of the symlink-to-file arm, and every + // assertion below expects it to be filtered out anyway. + } + + const ctx = new Context() + const fiber = ctx.plugin(BrowseDirectoryPicker) + await fiber.await() + const picked = ctx.get('directoryPicker')!.capability() + if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability') + capability = picked + dispose = () => fiber.dispose() +}) + +afterAll(async () => { + await dispose() + await rm(root, { recursive: true, force: true }) +}) + +describe('BrowseDirectoryPicker', () => { + it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => { + const listing = await capability.list(root) + expect(listing.path).toBe(root) + expect(listing.home).toBe(homedir()) + expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects']) + expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false]) + // Every entry path is absolute and host-joined — clients never join segments. + expect(listing.entries.every(entry => entry.path === join(root, entry.name))).toBe(true) + // Well under the default bound: the complete level, not a cut one. + expect(listing.truncated).toBe(false) + }) + + it('cuts a level at maxEntries keeping the name-sorted head, and flags the cut', async () => { + const ctx = new Context() + const fiber = ctx.plugin(BrowseDirectoryPicker, { maxEntries: 1 }) + await fiber.await() + const bounded = ctx.get('directoryPicker')!.capability() + if (bounded.kind !== 'browse') throw new Error('browse backend must advertise the browse capability') + try { + const cut = await bounded.list(root) + expect(cut.entries.map(entry => entry.name)).toEqual(['.hidden-dir']) + expect(cut.truncated).toBe(true) + // Exactly at the bound is complete, not truncated. + const exact = await bounded.list(join(root, 'projects')) + expect(exact.entries.map(entry => entry.name)).toEqual(['harness']) + expect(exact.truncated).toBe(false) + // A level that fits the window but exceeds the bound (two rows, bound + // one): the in-window extra row proves the cut without any eviction. + await mkdir(join(root, 'projects', 'harness', 'a')) + await mkdir(join(root, 'projects', 'harness', 'b')) + const inWindow = await bounded.list(join(root, 'projects', 'harness')) + expect(inWindow.entries.map(entry => entry.name)).toEqual(['a']) + expect(inWindow.truncated).toBe(true) + } finally { + await fiber.dispose() + } + }) + + it('stops the scan with the caller: an aborted signal rejects with its own reason', async () => { + const gone = new AbortController() + gone.abort(new Error('caller left')) + // The abort surfaces as-is, not dressed as an unreadable directory — + // and rejects even before any level row is read. + await expect(capability.list(root, gone.signal)).rejects.toThrow('caller left') + // The abandoned open that still succeeds is closed, not leaked. + await new Promise(resolve => setTimeout(resolve, 10)) + // Aborted against a missing target: the abandoned open rejects on its + // own and there is nothing to close. + await expect(capability.list(join(root, 'no-such-dir'), gone.signal)).rejects.toThrow('caller left') + await new Promise(resolve => setTimeout(resolve, 10)) + // A live signal leaves a normal listing untouched — the reads and the + // symlink probes race it without ever losing. + const live = new AbortController() + const complete = await capability.list(root, live.signal) + expect(complete.truncated).toBe(false) + expect(complete.entries.map(entry => entry.name)).toContain('linked') + // A live signal changes nothing about ordinary failures. + const missing = join(root, 'no-such-dir') + const failure = await capability.list(missing, live.signal).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-unreadable') + }) + + it('raceAbort follows the operation until the signal wins, and swallows the abandoned settlement', async () => { + // No signal / settled operations: plain passthrough, listener removed. + await expect(raceAbort(Promise.resolve('ok'), undefined)).resolves.toBe('ok') + const live = new AbortController() + await expect(raceAbort(Promise.resolve('ok'), live.signal)).resolves.toBe('ok') + // Failure passthrough keeps the operation's own error. + await expect(raceAbort(Promise.reject(new Error('raw failure')), live.signal)).rejects.toThrow('raw failure') + // The abort wins over a pending operation and carries its own reason; + // the operation's late rejection is swallowed, never unhandled. + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + let rejectLate!: (reason: unknown) => void + const pending = new Promise((_resolve, reject) => { rejectLate = reject }) + const controller = new AbortController() + const raced = raceAbort(pending, controller.signal) + // A bare-string abort reason exercises the Error wrap. + controller.abort('caller left') + await expect(raced).rejects.toThrow('caller left') + rejectLate(new Error('late read failure')) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(rejections).toEqual([]) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => { + const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false }) + const window: ListingCandidate[] = [] + expect(boundedInsert(window, candidate('m'), 2)).toBe(false) + expect(boundedInsert(window, candidate('z'), 2)).toBe(false) + // A smaller name lands in place and pushes the current largest out. + expect(boundedInsert(window, candidate('a'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + // A name at or beyond the full window's tail rejects on one comparison. + expect(boundedInsert(window, candidate('t'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + expect(boundedInsert(window, candidate('m'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + }) + + it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => { + const listing = await capability.list(join(root, 'projects')) + const tail = listing.crumbs.at(-1)! + expect(tail).toMatchObject({ name: 'projects', path: join(root, 'projects'), hidden: false }) + expect(listing.crumbs.at(-2)!.path).toBe(root) + expect(listing.crumbs.at(-2)!.name).toBe(basename(root)) + // The chain starts at the filesystem root, whose crumb is labeled by its full path. + expect(listing.crumbs[0]!.name).toBe(listing.crumbs[0]!.path) + }) + + it('lists the home directory when no path is given', async () => { + const listing = await capability.list() + expect(listing.path).toBe(homedir()) + }) + + it('throws directory-unreadable for a missing target', async () => { + const missing = join(root, 'no-such-dir') + const failure = await capability.list(missing).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-unreadable') + expect((failure as DirectoryPickerError).path).toBe(missing) + }) + + it('classifies fully qualified paths per platform (drive-less rooted Windows forms rejected)', () => { + expect(fullyQualified('/home/x', 'linux')).toBe(true) + expect(fullyQualified('x/y', 'darwin')).toBe(false) + 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/deep', 'win32')).toBe(true) + // Rooted but drive-less: isAbsolute accepts these, yet resolve() would + // inject the process's current drive. + expect(fullyQualified('\\foo', 'win32')).toBe(false) + expect(fullyQualified('/foo', '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 () => { + 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')) + const listing = await capability.list(root) + expect(listing.entries.map(entry => entry.name)).toContain('fresh') + }) + + it('refuses an existing child with directory-exists', async () => { + const failure = await capability.createDirectory(root, 'projects').catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-exists') + }) + + it('refuses non-segment names and other filesystem failures with directory-create-failed', async () => { + for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { + const failure = await capability.createDirectory(root, name).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-create-failed') + } + // Missing parent is a real failure, not a level to invent. + const missingParent = await capability.createDirectory(join(root, 'no-such-dir'), 'child').catch((error: unknown) => error) + expect((missingParent as DirectoryPickerError).code).toBe('directory-create-failed') + }) +}) diff --git a/packages/host/directory-picker-browse/tsconfig.json b/packages/host/directory-picker-browse/tsconfig.json new file mode 100644 index 0000000000..00dcdf8fde --- /dev/null +++ b/packages/host/directory-picker-browse/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../directory-picker" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../client/ui-slots" + }, + { + "path": "../../client/ui-primitives" + }, + { + "path": "../../client/locale" + }, + { + "path": "../../client/runtime" + }, + { + "path": "../../client/ui-workspace" + } + ] +} diff --git a/packages/host/directory-picker-browse/tsdown.config.ts b/packages/host/directory-picker-browse/tsdown.config.ts new file mode 100644 index 0000000000..4b2be38c3d --- /dev/null +++ b/packages/host/directory-picker-browse/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-host-directory-picker-browse', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml new file mode 100644 index 0000000000..e798bd6471 --- /dev/null +++ b/packages/host/directory-picker-native/README.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 packages/host/directory-picker-native/README.md +README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8 +README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md new file mode 100644 index 0000000000..0b54c651d4 --- /dev/null +++ b/packages/host/directory-picker-native/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-host-directory-picker-native + +English | [中文](README.zh.md) + +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). + +**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). + +## Model Experience + +None, as the backend serves the GUI host's directory selection; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md new file mode 100644 index 0000000000..e5ac2762a6 --- /dev/null +++ b/packages/host/directory-picker-native/README.zh.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-host-directory-picker-native + +[English](README.md) | 中文 + +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 + +**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 + +## 模型体验 + +无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json new file mode 100644 index 0000000000..bafe18e09f --- /dev/null +++ b/packages/host/directory-picker-native/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-host-directory-picker-native", + "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-ui-workspace": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + } +} diff --git a/packages/host/directory-picker-native/src/client/flow.ts b/packages/host/directory-picker-native/src/client/flow.ts new file mode 100644 index 0000000000..14f705d071 --- /dev/null +++ b/packages/host/directory-picker-native/src/client/flow.ts @@ -0,0 +1,65 @@ +/** + * The native picking occupant (package-internal; the `./client` surface + * exposes only the Loader exports). Same-package tests exercise it directly + * through this module. + */ +import { useEffect, useRef } from 'react' +import type { ReactElement } from 'react' +// Type-only: the owner contract of the directory-flow holes. +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' + +/** Injected face: the wire call the flow drives (bound in apply's closure). */ +export interface NativeFlowInjected { + /** Ask the local Host to open its native single-directory chooser. */ + pick: () => Promise +} + +/** + * Renderless flow occupant: each rising `open` edge runs exactly one pick and + * reports exactly one outcome; the ref arms once per open so re-renders (and + * an adoption keeping `open` true while `busy`) never launch a second + * chooser. The owner withdrawing `open` re-arms the next request. + * @param props - owner conversation plus the injected pick call. + * @returns nothing — the native chooser renders on the host display. + */ +export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowInjected): ReactElement | null { + const { open, pick } = props + const armed = useRef(false) + // Callbacks ride a ref so the settled pick reports through the owner's + // latest handlers, not the ones captured when the chooser opened. + const outcome = useRef(props) + outcome.current = props + // Unmount (HMR replacing the occupant) discards settlements wholesale: the + // dead instance must neither adopt a path nor drive the owner's error + // surface. The wire carries no per-request abort, so the host-side chooser + // survives until answered — its answer just lands nowhere; the replacement + // instance re-arms under the owner's still-open request. An injected-face + // identity change alone (re-registration) keeps the pending settlement: + // the chooser on the host display is still the same dialog. + const alive = useRef(true) + useEffect(() => { + // StrictMode's development replay runs the cleanup once before the real + // lifetime: re-arm on setup or every outcome would be discarded. + alive.current = true + return () => { alive.current = false } + }, []) + useEffect(() => { + if (!open) { + armed.current = false + return + } + if (armed.current) return + armed.current = true + pick().then( + (path) => { + if (!alive.current) return + if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path) + }, + (reason: unknown) => { + if (!alive.current) return + outcome.current.onError(reason instanceof Error ? reason.message : String(reason)) + }, + ) + }, [open, pick]) + return null +} diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts new file mode 100644 index 0000000000..2d5de9a9e7 --- /dev/null +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -0,0 +1,41 @@ +/** + * Browser half of the native directory-picker backend: fills ui-workspace's + * two directory-flow holes with a renderless occupant that answers each + * `open` by driving `host.pickDirectory` (the node half's OS chooser) and + * reporting the one outcome — picked path, cancellation, or failure — back + * through the owner conversation. Mounting this package therefore composes + * both sides of the native interaction with one cordis.yml row; no client + * code branches on a capability kind. + */ +import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the SlotMap merge declaring the directory-flow holes. +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { NativeFlowInjected } from './flow.ts' +import { NativeDirectoryFlow } from './flow.ts' + + +/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */ +export const inject = ['slots', 'workspaces'] + +/** + * Client plugin body: register the renderless native flow into both + * directory-flow holes (declaration-aware deferral — the declaring + * ui-workspace entries may activate later, and an HMR collapse re-declares). + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() }) + ctx.effect(() => { + // One occupant, both holes, as a unit: construction or late conflicts + // (holes declared after rival providers activated) roll the whole pair + // back and fail loud — semantics owned by deferGroupRegistration. + const group = deferGroupRegistration( + ctx.slots, + ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, + NativeDirectoryFlow, + name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow), + ) + return () => { group.dispose() } + }, 'directory-picker-native: flow registrations') +} diff --git a/packages/host/directory-picker-native/src/index.ts b/packages/host/directory-picker-native/src/index.ts new file mode 100644 index 0000000000..f131c8bb0c --- /dev/null +++ b/packages/host/directory-picker-native/src/index.ts @@ -0,0 +1,33 @@ +/** + * Native backend of the directory-picker seam: registers `ctx.directoryPicker` + * with the `native` capability, opening one native OS chooser on the host + * display per pick (macOS `osascript`, Windows STA PowerShell + * `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable + * when the operator sits at the host's screen; remote deployments compose the + * browse backend instead. + * @module @deepseek-ai/dsh-host-directory-picker-native + */ + +import { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' +import { pickNativeDirectory } from './native-picker.ts' + +export type { DirectoryPickerInternals, DirectoryPickerRunner } from './native-picker.ts' +export { pickNativeDirectory } from './native-picker.ts' + +/** The `ctx.directoryPicker` native implementation (stable capability object per service life). */ +export default class NativeDirectoryPicker extends DirectoryPicker { + private readonly nativeCapability: DirectoryPickerCapability = { + kind: 'native', + /* v8 ignore next -- pure forward to pickNativeDirectory (its spec owns behavior); invoking here opens a real chooser. */ + pick: signal => pickNativeDirectory(signal), + } + + /** + * The native interaction capability. + * @returns the stable `native` capability object. + */ + capability(): DirectoryPickerCapability { + return this.nativeCapability + } +} diff --git a/packages/host/directory-picker-native/src/invariant.ts b/packages/host/directory-picker-native/src/invariant.ts new file mode 100644 index 0000000000..777acd57dd --- /dev/null +++ b/packages/host/directory-picker-native/src/invariant.ts @@ -0,0 +1,25 @@ +/** + * Package-owned invariant companion for the native directory-picker backend. + * @module @deepseek-ai/dsh-host-directory-picker-native/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-native' + +/** Cordis companion plugin name. */ +export const name = 'host-directory-picker-native-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: each pick is one stateless subprocess round trip; the chooser outcome is only the returned path. */ +const install: InvariantInstaller = () => {} + +/** + * Register the native directory-picker invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/host/apiproxy/src/native-directory-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts similarity index 95% rename from packages/host/apiproxy/src/native-directory-picker.ts rename to packages/host/directory-picker-native/src/native-picker.ts index 0ddba8e30d..2c8e236acc 100644 --- a/packages/host/apiproxy/src/native-directory-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -1,6 +1,6 @@ -/** Cross-platform native single-directory picker used by the local GUI carrier. */ +/** Cross-platform native single-directory chooser behind the native backend's capability. */ -import { runNativeCommand, type NativeCommandRunner } from './native-command.ts' +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ export type DirectoryPickerRunner = NativeCommandRunner diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx new file mode 100644 index 0000000000..ecddf84eb4 --- /dev/null +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { apply, inject } from '../src/client/index.ts' +import { NativeDirectoryFlow } from '../src/client/flow.ts' + +afterEach(cleanup) + +const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const pickDirectory = vi.fn(async (): Promise => '/tmp/picked') + ctx.provide('workspaces', { pickDirectory } as never) + const slots = ctx.get('slots') as SlotsService + const declare = () => slots.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, () => null) + return { ctx, slots, pickDirectory, declare } +} + +function owner(overrides: Partial = {}): DirectoryFlowOwnerProps { + return { + open: true, busy: false, + onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(), + ...overrides, + } +} + +describe('directory-picker-native client half', () => { + it('declares the services it drives', () => { + expect(inject).toEqual(['slots', 'workspaces']) + }) + + it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { + const before = await bench() + before.declare() + const fiber = before.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1) + // Registry-contribution disposal proof: the fiber going down empties the holes. + await fiber.dispose() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0) + after.declare() + await Promise.resolve() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) + }) + + it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => { + const b = await bench() + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + // queueMicrotask throws surface as uncaughtException, not a rejection. + process.on('unhandledRejection', onUnhandled) + process.on('uncaughtException', onUnhandled) + try { + // This provider activates BEFORE any hole exists: both deferrals wait. + // (Duplicate rows of the SAME package converge silently — the deferral + // skips a hole its own component already occupies; the conflict needs + // a rival provider.) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.declare() + // A rival occupies both holes ahead of the pending microtask flush. + b.slots.register({ name: HOLES[0] } as never, () => null) + b.slots.register({ name: HOLES[1] } as never, () => null) + await new Promise(resolve => setTimeout(resolve, 20)) + // The rival keeps both holes; this provider rolled back wholesale and + // surfaced the conflict on the fail-loud channel — no partial mix. + for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1) + expect(rejections.map(String).join('\n')).toContain('already has a registration') + + // Non-Error conflicts wrap before the loud rethrow (same channel). + const c = await bench() + await c.ctx.plugin({ inject: [...inject], apply }).await() + const original = c.slots.register.bind(c.slots) + const slotsAny = c.slots as { register: typeof original } + slotsAny.register = ((options: never, component: never) => { + if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict' + return original(options, component) + }) as typeof original + c.declare() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rejections.map(String).join('\n')).toContain('string conflict') + } finally { + process.off('unhandledRejection', onUnhandled) + process.off('uncaughtException', onUnhandled) + } + }) + + it('rolls back the first deferral when the second hole is already occupied', async () => { + const b = await bench() + b.declare() + // Foreign occupant in the SECOND registered hole: the pair construction + // throws after the first deferral installed its subscription. + b.slots.register({ name: HOLES[1] } as never, () => null) + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await expect(fiber.await()).rejects.toThrow(/already has a registration/) + // A leaked first deferral would now race this probe registration and + // throw from its orphaned subscription against the HERO hole; the + // rollback leaves only the activation failure itself (cordis re-raises + // the apply throw as a late rejection — installFailLoud's contract). + const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([]) + disposeProbe() + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('rejects a second flow occupant at load (single-kind hole)', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(() => b.slots.register({ name: HOLES[0] } as never, () => null)) + .toThrow(/already has a registration/) + }) + + it('drives the injected pick through the hole entry and reports the picked path', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries(HOLES[0])[0]! + const injected = (entry.inject as () => { pick: () => Promise })() + await expect(injected.pick()).resolves.toBe('/tmp/picked') + expect(b.pickDirectory).toHaveBeenCalledOnce() + }) + + it('runs one pick per open edge and reports the path to the latest onPicked', async () => { + let resolve!: (path: string | null) => void + const pick = vi.fn(() => new Promise((settle) => { resolve = settle })) + const first = owner() + const view = render() + expect(pick).toHaveBeenCalledOnce() + // Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser. + const second = owner() + view.rerender() + expect(pick).toHaveBeenCalledOnce() + // Even a fresh injected face (re-registration re-runs the inject factory) + // must not relaunch while the same request is still open. + const replacedPick = vi.fn(() => new Promise(() => {})) + view.rerender() + expect(replacedPick).not.toHaveBeenCalled() + await act(async () => { resolve('/tmp/project') }) + expect(second.onPicked).toHaveBeenCalledWith('/tmp/project') + expect(first.onPicked).not.toHaveBeenCalled() + }) + + it('discards a settlement that lands after the flow unmounted', async () => { + let resolve!: (path: string | null) => void + const pick = vi.fn(() => new Promise((settle) => { resolve = settle })) + const props = owner() + const view = render() + expect(pick).toHaveBeenCalledOnce() + view.unmount() + // The dead instance must neither adopt nor error; the owner's callbacks + // stay untouched by the orphaned chooser's answer. + await act(async () => { resolve('/tmp/late') }) + expect(props.onPicked).not.toHaveBeenCalled() + expect(props.onCancel).not.toHaveBeenCalled() + expect(props.onError).not.toHaveBeenCalled() + + // The failure arm is discarded the same way. + let reject!: (reason: unknown) => void + const failing = vi.fn(() => new Promise((_settle, rejectPick) => { reject = rejectPick })) + const late = owner() + const failingView = render() + failingView.unmount() + await act(async () => { reject(new Error('too late')) }) + expect(late.onError).not.toHaveBeenCalled() + }) + + it('reports null as cancellation and re-arms after the owner withdraws open', async () => { + const pick = vi.fn(async () => null as string | null) + const props = owner() + const view = render() + await act(async () => {}) + expect(props.onCancel).toHaveBeenCalledOnce() + expect(props.onPicked).not.toHaveBeenCalled() + // Withdraw and reopen: a fresh request runs a fresh pick. + view.rerender() + view.rerender() + await act(async () => {}) + expect(pick).toHaveBeenCalledTimes(2) + }) + + it('folds pick failures into onError messages', async () => { + const props = owner() + render( { throw new Error('no chooser installed') })} />) + await act(async () => {}) + expect(props.onError).toHaveBeenCalledWith('no chooser installed') + + const nonError = owner() + render( { throw 'denied' })} />) + await act(async () => {}) + expect(nonError.onError).toHaveBeenCalledWith('denied') + }) + + it('renders nothing while closed and while open', () => { + const closed = render( null)} />) + expect(closed.container.innerHTML).toBe('') + const opened = render( null)} />) + expect(opened.container.innerHTML).toBe('') + }) +}) diff --git a/packages/host/apiproxy/tests/native-directory-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts similarity index 99% rename from packages/host/apiproxy/tests/native-directory-picker.spec.ts rename to packages/host/directory-picker-native/tests/native-picker.spec.ts index 783a67a04b..87e25ff877 100644 --- a/packages/host/apiproxy/tests/native-directory-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { describe, expect, it, vi } from 'vitest' -import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts' +import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-picker.ts' function failure(code: string | number, stderr = ''): Error { return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr }) diff --git a/packages/host/directory-picker-native/tests/service.spec.ts b/packages/host/directory-picker-native/tests/service.spec.ts new file mode 100644 index 0000000000..61b5adddaf --- /dev/null +++ b/packages/host/directory-picker-native/tests/service.spec.ts @@ -0,0 +1,21 @@ +/** Registration/capability behavior of the native backend (the seam's cordis half). */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import NativeDirectoryPicker from '../src/index.ts' + +describe('NativeDirectoryPicker', () => { + it('registers ctx.directoryPicker with a stable native capability and leaves with its fiber', async () => { + const ctx = new Context() + const fiber = ctx.plugin(NativeDirectoryPicker) + await fiber.await() + const picker = ctx.get('directoryPicker') + expect(picker).toBeInstanceOf(NativeDirectoryPicker) + const capability = picker!.capability() + expect(capability.kind).toBe('native') + // Stability: consumers may capture the capability object across calls. + expect(picker!.capability()).toBe(capability) + await fiber.dispose() + expect(ctx.get('directoryPicker')).toBeUndefined() + }) +}) diff --git a/packages/host/directory-picker-native/tsconfig.json b/packages/host/directory-picker-native/tsconfig.json new file mode 100644 index 0000000000..395595e836 --- /dev/null +++ b/packages/host/directory-picker-native/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../directory-picker" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../util/native-command" + }, + { + "path": "../../client/ui-slots" + }, + { + "path": "../../client/runtime" + }, + { + "path": "../../client/ui-workspace" + } + ] +} diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts new file mode 100644 index 0000000000..4f280f8112 --- /dev/null +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml new file mode 100644 index 0000000000..3e5bae41b5 --- /dev/null +++ b/packages/host/directory-picker/README.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 packages/host/directory-picker/README.md +README.md: 8ef8889c875f5b1d07c015ddef819591041c8d7f +README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md new file mode 100644 index 0000000000..8ef8889c87 --- /dev/null +++ b/packages/host/directory-picker/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-host-directory-picker + +English | [中文](README.zh.md) + +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. + +Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). + +## Model Experience + +None, as the seam serves the GUI host's directory selection; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note. diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md new file mode 100644 index 0000000000..8aefffa7b2 --- /dev/null +++ b/packages/host/directory-picker/README.zh.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-host-directory-picker + +[English](README.md) | 中文 + +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。 + +浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 + +## 模型体验 + +无。该 seam 服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **没有多根词汇**——浏览契约每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。 diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json new file mode 100644 index 0000000000..17d68c141b --- /dev/null +++ b/packages/host/directory-picker/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-host-directory-picker", + "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts new file mode 100644 index 0000000000..4dba9c9c22 --- /dev/null +++ b/packages/host/directory-picker/src/index.ts @@ -0,0 +1,143 @@ +/** + * The `ctx.directoryPicker` seam: how the web-GUI host lets an operator + * select a workspace directory. Backends differ in interaction shape, not + * just mechanism, so the service exposes a discriminated capability instead + * of one method set: a `native` backend opens one OS chooser on the + * host's display, while a `browse` backend serves listing/creation primitives + * for an in-app browser (and thereby works for remote clients no OS dialog + * can reach). Consumers switch on `capability().kind`; the union is + * merge-extensible, and the documented default for an unknown kind is to + * hide the picking affordance rather than fail. + * @module @deepseek-ai/dsh-host-directory-picker + */ + +import { Context, Service } from 'cordis' + +/** The native interaction: one OS directory chooser on the host display. */ +export interface DirectoryPickerNativeCapability { + kind: 'native' + /** + * Open the chooser and wait for the operator. + * @param signal - caller/connection lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ + pick(signal: AbortSignal): Promise +} + +/** One directory row: a listing child or a breadcrumb ancestor. */ +export interface DirectoryEntry { + /** Base name shown in a browser row (a root crumb carries its full path). */ + name: string + /** Absolute host path — clients never join path segments themselves. */ + path: string + /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ + hidden: boolean +} + +/** One directory level plus its ancestry, as a browse backend reports it. */ +export interface DirectoryListing { + /** Absolute path of the listed directory. */ + path: string + /** The host account's home directory (breadcrumb "Home" rooting). */ + home: string + /** + * Ancestor chain from the filesystem root to the listed directory + * inclusive; every crumb is a jump target (crumb `hidden` is always false). + */ + crumbs: DirectoryEntry[] + /** Direct child directories, name-sorted; symlinks to directories included. */ + entries: DirectoryEntry[] + /** + * True when the backend cut `entries` at its complete-result bound: the + * level has more child directories than reported, and the missing rows are + * the name-sorted tail (hidden rows count toward the bound). + */ + truncated: boolean +} + +/** + * The browse interaction: listing/creation primitives an in-app browser + * drives one level at a time. Works for remote clients — nothing renders on + * the host display. + */ +export interface DirectoryPickerBrowseCapability { + kind: 'browse' + /** + * List one directory level. + * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the scan (a stalled network + * directory must not outlive a disconnected caller) and rejects with the + * abort reason. + * @returns the level's listing with ancestry; backends bound the complete + * result, and a cut level reports `truncated`. + * @throws {DirectoryPickerError} `directory-unreadable` when the target is not fully + * qualified (a wire value must never resolve against the host cwd or, on + * Windows, its current drive) or cannot be listed. + */ + list(path?: string, signal?: AbortSignal): Promise + /** + * Create one child directory under an existing parent. + * @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` for a parent that is not fully qualified or any other failure. + */ + createDirectory(path: string, name: string): Promise +} + +/** + * Merge-extensible registry of interaction shapes keyed by capability kind: a + * new backend declaration-merges its shape here (the entry's `kind` literal + * must equal its key) instead of editing this package. + */ +export interface DirectoryPickerCapabilities { + native: DirectoryPickerNativeCapability + browse: DirectoryPickerBrowseCapability +} + +/** Union of interaction shapes a backend can provide, derived from the merge-extensible {@link DirectoryPickerCapabilities} map. */ +export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities] + +/** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */ +export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed' + +/** Typed failure thrown by browse primitives so consumers can map business codes without string matching. */ +export class DirectoryPickerError extends Error { + /** + * @param code - closed business code of the failure. + * @param path - the absolute path the failure is about. + * @param message - operator-facing description. + */ + constructor(readonly code: DirectoryPickerErrorCode, readonly path: string, message: string) { + super(message) + this.name = 'DirectoryPickerError' + } +} + +declare module 'cordis' { + interface Context { + directoryPicker: DirectoryPicker + } +} + +/** + * Abstract directory-picking service. Subclass, implement `capability()`, and + * load the subclass as a plugin — it registers as `ctx.directoryPicker` (one + * implementation per context; loading a second throws, cordis' standard + * duplicate-service behavior). The capability object must be stable for the + * service lifetime: consumers may capture it across calls. + */ +export abstract class DirectoryPicker extends Service { + constructor(ctx: Context) { + super(ctx, 'directoryPicker') + } + + /** + * The backend's interaction capability. + * @returns the discriminated capability consumers switch on. + */ + abstract capability(): DirectoryPickerCapability +} + +export default DirectoryPicker diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts new file mode 100644 index 0000000000..9128850f3b --- /dev/null +++ b/packages/host/directory-picker/src/invariant.ts @@ -0,0 +1,22 @@ +/** Package-owned invariant companion for the directory-picker seam. @module @deepseek-ai/dsh-host-directory-picker/invariant */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker' + +/** Cordis companion plugin name. */ +export const name = 'host-directory-picker-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this stateless seam owns the capability vocabulary, while backends and the RPC consumer own observations. */ +const install: InvariantInstaller = () => {} + +/** + * Register the directory-picker invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/host/directory-picker/tests/seam.spec.ts b/packages/host/directory-picker/tests/seam.spec.ts new file mode 100644 index 0000000000..52722b9b0d --- /dev/null +++ b/packages/host/directory-picker/tests/seam.spec.ts @@ -0,0 +1,35 @@ +/** Contract behavior the seam itself owns: registration identity and typed failures. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { DirectoryPicker, DirectoryPickerError } from '../src/index.ts' +import type { DirectoryPickerCapability } from '../src/index.ts' + +/** Minimal concrete backend: all a subclass owes the abstract class is capability(). */ +class StubPicker extends DirectoryPicker { + private readonly stub: DirectoryPickerCapability = { kind: 'native', pick: async () => null } + capability(): DirectoryPickerCapability { + return this.stub + } +} + +describe('DirectoryPicker seam', () => { + it('registers a subclass as ctx.directoryPicker and leaves with its fiber', async () => { + const ctx = new Context() + const fiber = ctx.plugin(StubPicker) + await fiber.await() + expect(ctx.get('directoryPicker')).toBeInstanceOf(StubPicker) + expect(ctx.get('directoryPicker')!.capability().kind).toBe('native') + await fiber.dispose() + expect(ctx.get('directoryPicker')).toBeUndefined() + }) + + it('carries the business code and subject path on DirectoryPickerError', () => { + const failure = new DirectoryPickerError('directory-exists', '/home/u/x', '/home/u/x already exists') + expect(failure.name).toBe('DirectoryPickerError') + expect(failure.code).toBe('directory-exists') + expect(failure.path).toBe('/home/u/x') + expect(failure.message).toContain('already exists') + expect(failure).toBeInstanceOf(Error) + }) +}) diff --git a/packages/host/directory-picker/tsconfig.json b/packages/host/directory-picker/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/host/directory-picker/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 9addd33a69..40d5fcf9d3 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -1,6 +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 -README.md: c589c32c4e641e188f19ac6c5ad2e88e3eb79be3 -README.zh.md: 767195086b90a76160d87865caebf514ca75b0e3 +# pnpm run verify-translation-pairing --write packages/host/webserver/README.md +README.md: e715e4452ddb808f36e6b097eee0fda7b8d0bfb0 +README.zh.md: 05e7e10d7815c8f26bb90597b38b7c6b83a86dbc diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index c589c32c4e..e715e4452d 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. +Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 767195086b..05e7e10d78 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -朴素的 HTTP 路由注册插件(默认导出 `WebServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 +朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index c29bf60910..f6f5f49a14 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/README.i18n.yaml @@ -1,6 +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 -README.md: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa -README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e +# pnpm run verify-translation-pairing --write packages/ui/permission/README.md +README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4 +README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 6a59ad9425..814085ed6f 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -8,6 +8,8 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. + ## Model Experience Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only. @@ -18,7 +20,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **No shipped composition currently mounts the service** — the ACP bridge was its only selector before [ACP became automation-only](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md); the preset table is kept for the interactive front door that next exposes a runtime policy switch. - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet. - **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service. - **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin. diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 0e7db1bd41..36880d6b8c 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -8,6 +8,8 @@ 该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元折叠三个全量值旋钮事件,在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 + ## 模型体验 间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的调节项事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。 @@ -18,7 +20,6 @@ ## 已知限制与延期工作 -- **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前,ACP 桥接层是唯一的选择器;preset 表为下一个公开运行时策略切换的交互式入口保留。 - **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。 - **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。 diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index 3ec4cc7685..c5021af37b 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -15,12 +15,21 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -28,22 +37,27 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/ui/permission/src/client.ts b/packages/ui/permission/src/client.ts new file mode 100644 index 0000000000..d758cf5960 --- /dev/null +++ b/packages/ui/permission/src/client.ts @@ -0,0 +1,10 @@ +/** + * Client-namespace projection of the permission domain: a pure re-export of + * the package's types outlet. Client code imports ONLY the client namespace + * (repo discipline), so `./client` projects the same single-source content + * `./types` serves to host consumers — zero duplication. + * + * @module @deepseek-ai/dsh-permission/client + */ + +export type * from './types.ts' diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index d44dff3df4..597d17199a 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -3,13 +3,16 @@ * approval-policy knobs. A switch records the selected preset, then writes * changed knobs through their canonical setters. Execution, prompt narration, * and replay keep reading their knob folds. The preset event preserves user - * intent when two presets share a bundle. + * intent when two presets share a bundle. The read side ships as the + * `permissions` session projection; the write side ships as the + * `/permission` command — both optional children over the same service. * * @module dsh-permission */ import { Context, Service } from 'cordis' import z from 'schemastery' +import { z as zod } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -18,6 +21,16 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children. +import type {} from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-commands' +import type { PermissionSelect, PresetOption } from './types.ts' + +// The `permissions` projection-key declaration lives in src/types.ts (its one +// home); this re-export projects the type face onto the package root AND +// keeps the module edge in the emitted index.d.ts, so aggregate programs +// consuming the declarations still receive the SessionProjectionMap merge. +export type * from './types.ts' declare module 'cordis' { interface Context { @@ -49,16 +62,6 @@ export interface PresetSpec { description?: string } -/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */ -export interface PresetOption { - /** Stable option value: the table key, or `custom`. */ - value: string - /** The display label. */ - name: string - /** One user-facing sentence on what the value means. */ - description?: string -} - /** * Returned when effective knob values match no table entry. Clients may show * it as the current value, but it is never a switch target or event payload. @@ -79,6 +82,50 @@ export function effectivePermissionPreset(events: readonly SessionEvent[]): stri return undefined } +/** + * The projection unit's state: the last seen value of each knob event, null + * before an override (composition defaults apply at view time). Plain JSON + * (persisted-cache precondition). + */ +export interface KnobState { + /** Last `permission/preset` payload, or null. */ + preset: string | null + /** Last `sandbox/mode` payload, or null. */ + sandbox: SandboxMode | null + /** Last `approval/policy` payload, or null. */ + approval: ApprovalPolicy | null +} + +/** State for the empty log: every knob at its composition default. */ +const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null } + +/** + * One-event knob transition (the projection unit's `apply`). Uninterested + * events return the same reference — the registry's change gate. + * @param state - the folded knob state before `event`. + * @param event - one committed session event. + * @returns the next state; the same reference when the event is not a knob. + */ +export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState { + switch (event.type) { + case 'permission/preset': + return { ...state, preset: event.data.preset } + case 'sandbox/mode': + return { ...state, sandbox: event.data.mode } + case 'approval/policy': + return { ...state, approval: event.data.policy } + default: + return state + } +} + +/** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */ +function foldKnobs(events: readonly SessionEvent[]): KnobState { + let state = EMPTY_KNOBS + for (const event of events) state = applyKnobEvent(state, event) + return state +} + /** The {@link PermissionService} config: the deployment's preset table. */ export interface Config { /** @@ -128,6 +175,55 @@ export class PermissionService extends Service { if (ctx.bash.sandboxMode === undefined) { throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration') } + + // The permissions projection unit: fold the three whole-value knob + // events; view derives the select over the composition defaults this + // service already owns. The unit child activates only when a projection + // registry is composed (headless assemblies stay unaffected). + // zod `.optional()` types the key `string | undefined` while the domain + // says `description?: string`; on the JSON wire the two serialize + // identically (absent), so the cast records exactly that + // exactOptionalPropertyTypes widening (the Wire precedent). + const selectSchema = zod.object({ + options: zod.array(zod.object({ + value: zod.string().min(1), + name: zod.string().min(1), + description: zod.string().optional(), + })), + currentValue: zod.string().min(1), + }) as unknown as zod.ZodType + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'permissions', KnobState>({ + key: 'permissions', + schema: selectSchema, + init: () => EMPTY_KNOBS, + apply: applyKnobEvent, + view: state => this.selectFor(state), + stateVersion: 1, + }) + }) + + // The /permission command: the one write path a web client uses (the + // popup contribution submits the picked preset as this line). The child + // activates only when a command registry is composed. + ctx.inject(['commands'], (commandCtx) => { + commandCtx.commands.register({ + name: 'permission', + description: 'Switch the permission preset (sandbox mode + approval policy)', + input: { hint: '' }, + handler: ({ agent, rawInput }) => { + const name = rawInput.trim() + if (name === '') { + return { kind: 'success', text: `Current permission preset: ${this.current(agent.session.events)}. Available: ${this.names.join(', ')}.` } + } + if (!this.names.includes(name)) { + return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` } + } + this.set(agent.session, name) + return { kind: 'success', text: `Permission preset: ${name}.` } + }, + }) + }) } /** @@ -146,13 +242,17 @@ export class PermissionService extends Service { * @returns the effective preset name, or `custom` when nothing matches. */ current(events: readonly SessionEvent[]): string { - const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode - const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask' + return this.derive(foldKnobs(events)) + } + + /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */ + private derive(state: KnobState): string { + const sandbox = state.sandbox ?? this.ctx.bash.sandboxMode + const approval = state.approval ?? this.ctx.approval.config.policy ?? 'ask' const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval - const folded = effectivePermissionPreset(events) - if (folded !== undefined) { - const spec = this.presets[folded] - if (spec !== undefined && matches(spec)) return folded + if (state.preset !== null) { + const spec = this.presets[state.preset] + if (spec !== undefined && matches(spec)) return state.preset } for (const [name, spec] of Object.entries(this.presets)) { if (matches(spec)) return name @@ -160,6 +260,23 @@ export class PermissionService extends Service { return CUSTOM_PRESET } + /** + * Build the whole select value for one folded knob state: every table + * option in declaration order, `custom` appended exactly while derived. + * @param state - the folded knob overrides. + * @returns the `permissions` projection payload. + */ + selectFor(state: KnobState): PermissionSelect { + const currentValue = this.derive(state) + return { + options: [ + ...this.names.map(name => this.optionOf(name)), + ...currentValue === CUSTOM_PRESET ? [this.optionOf(CUSTOM_PRESET)] : [], + ], + currentValue, + } + } + /** * Resolve a preset's knob bundle. * @param name - the preset name to resolve. diff --git a/packages/ui/permission/src/types.ts b/packages/ui/permission/src/types.ts new file mode 100644 index 0000000000..607cc96837 --- /dev/null +++ b/packages/ui/permission/src/types.ts @@ -0,0 +1,44 @@ +/** + * Pure types of the permission domain: the ONE home of the `permissions` + * projection-key declaration plus its payload types, free of this package's + * host-side value imports (cordis, schemastery). Two namespace projections + * serve it — the package root re-export for host consumers, `./client` (the + * browser half-entry's re-export) for client aggregates — with zero content + * duplication. + * + * @module @deepseek-ai/dsh-permission/types + */ + +/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */ +export interface PresetOption { + /** Stable option value: the table key, or `custom`. */ + value: string + /** The display label. */ + name: string + /** One user-facing sentence on what the value means; omitted when not configured. */ + description?: string +} + +/** + * Whole `permissions` projection value: every switchable preset in table + * order (plus the derived current-only `custom` when the knobs match no + * entry) and the effective current value. + */ +export interface PermissionSelect { + /** Switchable presets, plus `custom` appended exactly while it is current. */ + options: PresetOption[] + /** The effective current value: a preset table key, or `custom`. */ + currentValue: string +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** + * The session's permission select, folded from the three whole-value + * knob events (`permission/preset`, `sandbox/mode`, `approval/policy`) + * over the composition defaults. Key absence means no permission service + * is composed — clients hide the control. + */ + permissions: PermissionSelect + } +} diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 864f25629d..05a747de8d 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -34,6 +34,9 @@ describe('effectivePermissionPreset', () => { session.append('permission/preset', { preset: 'danger-full-access' }) session.append('permission/preset', { preset: 'workspace-write' }) expect(effectivePermissionPreset(session.events)).toBe('workspace-write') + // The backward scan steps over non-preset events to the latest selection. + session.append('sandbox/mode', { mode: 'read-only' }) + expect(effectivePermissionPreset(session.events)).toBe('workspace-write') }) }) diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts new file mode 100644 index 0000000000..2046adb6a1 --- /dev/null +++ b/packages/ui/permission/tests/projection.spec.ts @@ -0,0 +1,116 @@ +/** + * The `permissions` projection unit and the `/permission` command: mounting + * the permission service beside the projection registry serves the whole + * select (table options + effective current value, `custom` appended exactly + * while derived) folded from the three knob events over the composition + * defaults; the command child registers `/permission` whose handler switches + * through `permission.set` (bare invocation reports, unknown names error); + * compositions without either registry are unaffected; unmounting the + * service removes the key (HMR safety). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createScope } from '@deepseek-ai/dsh-scope' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import CommandService from '@deepseek-ai/dsh-commands' +import PermissionService from '@deepseek-ai/dsh-permission' +import type { Config } from '@deepseek-ai/dsh-permission' + +async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(CommandService) + ctx.provide('bash', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission tests do not execute bash') }, + run() { throw new Error('permission tests do not execute bash') }, + start() { throw new Error('permission tests do not execute bash') }, + }) + ctx.provide('approval', { config: { policy: 'ask' } }) + if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {}) + return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) } +} + +/** Mint a scoped agent over a live session (the command executor's addressing shape). */ +async function agentFor(ctx: Context, session: Session): Promise { + const agent = { id: session.id, session } as Agent + await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] })) + return agent +} + +describe('permissions projection unit', () => { + it('serves the composition-default select at zero events', async () => { + const { ctx, session } = await harness() + const value = ctx.sessionProjections.snapshot(session).values.permissions + expect(value).toMatchObject({ currentValue: 'workspace-write' }) + expect(value?.options.map(option => option.value)).toEqual(['workspace-write', 'danger-full-access']) + }) + + it('folds the knob events and notifies the change feed per knob append', async () => { + const { ctx, session } = await harness() + const changes: { key: string; value: unknown; seq: number }[] = [] + ctx.sessionProjections.onChanged((_session, key, value, seq) => { + changes.push({ key, value, seq }) + }) + ctx.permission.set(session, 'danger-full-access') + // set() appends preset + sandbox/mode + approval/policy: three knob transitions. + expect(changes).toHaveLength(3) + expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } }) + // Unrelated event: same-reference apply, no notification. + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(changes).toHaveLength(3) + }) + + it('appends custom as a current-only option when the knobs match no preset', async () => { + const { ctx, session } = await harness() + session.append('sandbox/mode', { mode: 'read-only' }) + const value = ctx.sessionProjections.snapshot(session).values.permissions + expect(value?.currentValue).toBe('custom') + expect(value?.options.at(-1)).toMatchObject({ value: 'custom', name: 'Custom' }) + }) + + it('has no permissions key without the service, and drops it on unload (HMR safety)', async () => { + const { ctx, session } = await harness({ withPermission: false }) + expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) + const fiber = await ctx.plugin(PermissionService, {}) + expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' }) + await fiber.dispose() + expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) + }) +}) + +describe('/permission command', () => { + it('switches through permission.set and logs the lifecycle pair', async () => { + const { ctx, session } = await harness() + const agent = await agentFor(ctx, session) + const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'success', text: 'Permission preset: danger-full-access.' }) + expect(ctx.permission.current(session.events)).toBe('danger-full-access') + const run = session.events.find(event => event.type === 'command/run') + expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) + }) + + it('reports the current preset and the table on bare invocation', async () => { + const { ctx, session } = await harness() + const agent = await agentFor(ctx, session) + const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal) + expect(execution?.result).toEqual({ + kind: 'success', + text: 'Current permission preset: workspace-write. Available: workspace-write, danger-full-access.', + }) + expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0) + }) + + it('rejects an unknown preset without touching the log', async () => { + const { ctx, session } = await harness() + const agent = await agentFor(ctx, session) + const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) + expect(execution?.result).toMatchObject({ kind: 'error' }) + expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0) + }) +}) diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 0971399f53..493fbf358e 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -34,6 +34,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../session-projection/session-projection" + }, + { + "path": "../commands" } ] } diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 1fc811bc8b..8bd1ff35b2 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -1,6 +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 -README.md: 140df90571d84320fb4eb888508c67e60aa29a22 -README.zh.md: 4c16df2a56476c0a7c965a037389fa5ba231e273 +# pnpm run verify-translation-pairing --write packages/util/README.md +README.md: 605c3dd0beebc16109e8e6bc944ea722a60975c0 +README.zh.md: 5c66ded33a36079f80965cf466449843e07511f0 diff --git a/packages/util/README.md b/packages/util/README.md index 140df90571..605c3dd0be 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -10,6 +10,7 @@ Zero-dependency primitives shared across the other groups. A package lands here | `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | +| `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 4c16df2a56..5c66ded33a 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -10,6 +10,7 @@ | `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | | `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | | `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | +| `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 | `dsh-brand` 是规范示例:它只负责 `Branded` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml new file mode 100644 index 0000000000..b57a63ef98 --- /dev/null +++ b/packages/util/native-command/README.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 packages/util/native-command/README.md +README.md: 7fc8b1f4640ef87ada62b6656854feb37080e4e6 +README.zh.md: 4bc66c4047194de03f236fa7591dad88e5c3fb57 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md new file mode 100644 index 0000000000..7fc8b1f464 --- /dev/null +++ b/packages/util/native-command/README.md @@ -0,0 +1,27 @@ +# dsh-native-command + +English | [中文](README.zh.md) + +A **zero-dependency no-shell `execFile` runner** shared by host-native OS integrations: one `runNativeCommand(command, args, signal)` call spawns the executable directly (never a shell string), captures utf8 stdout/stderr, propagates the caller's abort into child termination, and hides the transient console window on Windows. Failures reject with the exit `code` and both captured streams attached, so callers classify (missing tool, cancelled, real failure) without re-running anything. + +Its two consumers are the host-side native integrations: the [`directory-picker-native`](../../host/directory-picker-native/README.md) backend's OS chooser commands and the gateway's open-with-default-application hand-off ([`dsh-host-apiproxy`](../../host/apiproxy/README.md) `host.openPath`). The `NativeCommandRunner` type is the injectable command boundary those callers expose for deterministic tests. + +It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state, emits no events. + +## Surface + +```ts +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +``` + +## Model Experience + +None, as this is host-side subprocess plumbing; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **No output bounding** — both streams buffer unbounded in memory; every current caller invokes small native tools whose output is a path or an error line. Adopt `dsh-retention` bounding before pointing this at commands with meaningful output volume. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md new file mode 100644 index 0000000000..4bc66c4047 --- /dev/null +++ b/packages/util/native-command/README.zh.md @@ -0,0 +1,27 @@ +# dsh-native-command + +[English](README.md) | 中文 + +宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接派生可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr,把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时以附带退出 `code` 与两路已捕获输出的错误拒绝,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。 + +它的两个消费者都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.md) 后端的 OS 选择器命令,以及网关的按默认应用打开转交([`dsh-host-apiproxy`](../../host/apiproxy/README.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方为确定性测试暴露的可注入命令边界。 + +它是**库,不是服务或插件**:没有 `ctx`、不注册任何东西、不持有状态、不发事件。 + +## Surface + +```ts +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +``` + +## Model Experience + +无;这是宿主侧子进程管道,这里没有任何东西进入模型请求。 + +#### KV Cache effect + +无;该包既不组装也不发送 provider 请求。 + +## Known Limitations and Deferred Work + +- **不做输出限量**——两路流在内存中无界缓冲;当前每个调用方只运行输出为一个路径或一行错误的小型原生工具。把它指向输出量可观的命令之前,先接入 `dsh-retention` 限量。 diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json new file mode 100644 index 0000000000..a20e61c33d --- /dev/null +++ b/packages/util/native-command/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-native-command", + "description": "Zero-dependency no-shell execFile runner for host-native OS integrations: utf8 stdio capture, abort propagation, Windows hide", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/host/apiproxy/src/native-command.ts b/packages/util/native-command/src/index.ts similarity index 77% rename from packages/host/apiproxy/src/native-command.ts rename to packages/util/native-command/src/index.ts index 0efe9679d8..8ad8b81749 100644 --- a/packages/host/apiproxy/src/native-command.ts +++ b/packages/util/native-command/src/index.ts @@ -1,4 +1,10 @@ -/** Shared no-shell `execFile` runner for native host dialogs and openers. */ +/** + * Shared no-shell `execFile` runner for host-native OS integrations (the + * native directory chooser, the open-with-default-application hand-off): + * utf8 stdio capture, abort propagation, Windows console hide. A library, + * not a plugin — no ctx, no state, no events. + * @module @deepseek-ai/dsh-native-command + */ import { execFile } from 'node:child_process' diff --git a/packages/util/native-command/src/invariant.ts b/packages/util/native-command/src/invariant.ts new file mode 100644 index 0000000000..bec1d4b774 --- /dev/null +++ b/packages/util/native-command/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-native-command`. + * @module @deepseek-ai/dsh-native-command/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-native-command' + +/** Cordis companion plugin name. */ +export const name = 'native-command-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: each run is one stateless child-process round trip + * with no owned event stream or mutable runtime data; behavior is enforced by + * unit tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/native-command/tests/native-command.spec.ts b/packages/util/native-command/tests/native-command.spec.ts new file mode 100644 index 0000000000..4c0adc40f4 --- /dev/null +++ b/packages/util/native-command/tests/native-command.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { runNativeCommand } from '@deepseek-ai/dsh-native-command' + +const node = process.execPath + +describe('runNativeCommand', () => { + it('captures utf8 stdout and stderr on exit 0', async () => { + const result = await runNativeCommand( + node, + ['-e', 'process.stdout.write("out✓"); process.stderr.write("err")'], + new AbortController().signal, + ) + expect(result).toEqual({ stdout: 'out✓', stderr: 'err' }) + }) + + it('rejects a non-zero exit with code, stdout, and stderr attached', async () => { + const failure = await runNativeCommand( + node, + ['-e', 'process.stdout.write("partial"); process.stderr.write("boom"); process.exit(3)'], + new AbortController().signal, + ).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error) + expect(failure).toMatchObject({ code: 3, stdout: 'partial', stderr: 'boom' }) + expect((failure as Error).cause).toBeInstanceOf(Error) + }) + + it('rejects a missing executable with the spawn ENOENT code', async () => { + const failure = await runNativeCommand( + 'dsh-definitely-missing-command', + [], + new AbortController().signal, + ).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error) + expect(failure).toMatchObject({ code: 'ENOENT' }) + }) + + it('terminates the child when the signal aborts', async () => { + const abort = new AbortController() + const pending = runNativeCommand(node, ['-e', 'setTimeout(() => {}, 60_000)'], abort.signal) + abort.abort() + const failure = await pending.then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error) + expect(failure).toBeInstanceOf(Error) + expect((failure as { code?: unknown }).code).toBe('ABORT_ERR') + }) +}) diff --git a/packages/util/native-command/tsconfig.json b/packages/util/native-command/tsconfig.json new file mode 100644 index 0000000000..d970a00263 --- /dev/null +++ b/packages/util/native-command/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed70444992..66ea5bea26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,9 +128,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot - '@deepseek-ai/dsh-bash-local': + '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ - version: link:../../packages/bash/bash-local + version: link:../../packages/bash/bash-sandbox '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../packages/client/connection @@ -164,6 +164,9 @@ importers: '@deepseek-ai/dsh-client-ui-models': specifier: workspace:^ version: link:../../packages/client/ui-models + '@deepseek-ai/dsh-client-ui-permission': + specifier: workspace:^ + version: link:../../packages/client/ui-permission '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../packages/client/ui-plan @@ -212,12 +215,12 @@ importers: '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../packages/fs/fs-local '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal @@ -227,6 +230,12 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-browse': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-browse + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-native '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -236,15 +245,27 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:^ + version: link:../../packages/llm/llm-pi-ai '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../packages/ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -338,6 +359,9 @@ importers: '@deepseek-ai/dsh-tui': specifier: workspace:^ version: link:../../packages/ui/tui + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../packages/ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction @@ -821,6 +845,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1040,6 +1067,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode @@ -1191,6 +1221,27 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-permission: + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../ui-command + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../ui-slash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-plan: devDependencies: '@deepseek-ai/dsh-client-connection': @@ -2818,9 +2869,15 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2868,6 +2925,86 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/host/directory-picker: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/host/directory-picker-browse: + dependencies: + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + clsx: + specifier: ^2.0.0 + version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/host/directory-picker-native: + dependencies: + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/host/webserver: dependencies: schemastery: @@ -4770,10 +4907,16 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4786,6 +4929,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval @@ -4954,6 +5100,15 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/native-command: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/paths: devDependencies: '@deepseek-ai/dsh-invariants': diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 017c341a34..42198d4eea 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 835 + "packages/README.md": 870 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 43f48019c5..0f3270f8ee 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -226,6 +226,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', + DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', Domain: 'domain interface is owned by packages/storage/storage-domain/README.md', DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts', @@ -251,6 +252,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', + KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md', + PermissionSelect: 'permissions projection payload is owned by packages/ui/permission/src/types.ts', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md', SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 075ca3a69e..8846cca3eb 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -424,6 +424,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['spill-policy'], note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.', }, + { + key: 'directoryPicker', + pkg: 'directory-picker', + title: 'Workspace-directory picking seam', + mode: 'seam', + implementations: ['directory-picker-native', 'directory-picker-browse'], + consumers: ['apiproxy'], + note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement).', + }, { key: 'httpServer', pkg: 'webserver', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 023ba64522..fd74c6d13e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -60,6 +60,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, 'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' }, + 'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' }, 'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, @@ -76,6 +77,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' }, + 'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers no model surface.' }, + 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, + 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, @@ -117,6 +121,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, 'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' }, 'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' }, + 'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers no model surface.' }, 'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' }, 'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, 'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 12f96ba81d..00c19c4b8c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -111,6 +111,12 @@ // tsconfig.client.json) stay explicit — TS project references have no // wildcard form. "@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"], + "@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"], + "@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"], + "@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"], + "@deepseek-ai/dsh-host-directory-picker-browse/*": ["./packages/host/directory-picker-browse/src/*"], + "@deepseek-ai/dsh-host-directory-picker-native": ["./packages/host/directory-picker-native/src"], + "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"], "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index f4063d52a6..5a52f59bb0 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -19,6 +19,10 @@ "packages/client/*/src/css-modules.d.ts", "packages/client/*/tests/**/*.ts", "packages/client/*/tests/**/*.tsx", + "packages/host/directory-picker-browse/tests/**/*.ts", + "packages/host/directory-picker-browse/tests/**/*.tsx", + "packages/host/directory-picker-native/tests/**/*.ts", + "packages/host/directory-picker-native/tests/**/*.tsx", "packages/client/tsdown.client.ts", "scripts/client-bundle-purity.spec.ts" ], @@ -27,6 +31,11 @@ // smoke policy). webserver has zero workspace deps and no cordis merge, // so it cannot drag host-side Context augmentation into this program. { "path": "./packages/host/webserver" }, + // Dual-face host leaf: the node half is the native picking backend, the + // browser half registers the picking flow into ui-workspace's slot — + // client-side Context merges keep it out of the host program. + { "path": "./packages/host/directory-picker-native" }, + { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/web-react" }, @@ -45,6 +54,7 @@ { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, + { "path": "./packages/client/ui-permission" }, { "path": "./packages/client/ui-plan" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index d211330396..abdb1f90c8 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -22,6 +22,7 @@ "apps/web/tests/sidebar-scrollbar.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", + "apps/web/tests/message-actions.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", @@ -33,6 +34,8 @@ ], "exclude": [ "packages/client/**", + "packages/host/directory-picker-browse/**", + "packages/host/directory-picker-native/**", "scripts/client-bundle-purity.spec.ts" ], "references": [ @@ -46,6 +49,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/native-command" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, @@ -166,6 +170,7 @@ { "path": "./packages/hooks/hooks-codex" }, { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/host/apiproxy" }, + { "path": "./packages/host/directory-picker" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, diff --git a/vitest.config.ts b/vitest.config.ts index 16f28449a5..55f172416f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -138,6 +138,7 @@ export default defineConfig({ 'packages/client/ui-command/src/client/service.ts', 'packages/client/ui-command/src/client/PopupSelectView.tsx', 'packages/client/ui-model/src/index.ts', + 'packages/client/ui-permission/src/index.ts', 'packages/client/ui-model/src/client/ModelSelect.tsx', 'packages/client/ui-model/src/client/directory.ts', 'packages/client/ui-model/src/client/index.ts',