Merge remote-tracking branch 'origin/master' into fix/workspace-blank-session-reuse-membership

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
This commit is contained in:
_Kerman
2026-08-05 16:55:41 +08:00
672 changed files with 6346 additions and 4018 deletions

View File

@@ -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/acp/README.md
README.md: 326615210e5cfc39004fc5ab7462623089ac4126
README.zh.md: 8679f2428a9e82a81de69d7d1413132a946fcafa
README.md: 3ba247598f29f2244456061fe7f9a3282086148f
README.zh.md: c13bc05b12fff2ef97b4d547936aa14a6556430a

View File

@@ -6,6 +6,6 @@ The ACP group exposes harness agents to programmatic clients. It is an interoper
| Package | Role |
|---|---|
| [`acp/`](acp/README.md) | Automation-only ACP server: fresh text sessions, committed assistant output, machine permission policy, cancellation, and connection-owned teardown. |
| [`acp/`](acp/README.md) | Automation-only ACP server. |
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.

View File

@@ -6,6 +6,6 @@ ACPAgent Client Protocol组将 harness 中的 agent智能体公开
| 包 | 职责 |
|---|---|
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接负责的清理。 |
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 |
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。

View File

@@ -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/acp/acp/README.md
README.md: 583025e94d72c1ab03d282f8f4eb101c4e6f4740
README.zh.md: ffffd97daa911636336ee9a515bf01bad33d31ae
README.md: 7bcf3c2581b25cb3c5223d00c05d9df6a7f6d61e
README.zh.md: 97a8e9c164bab71cb367ad54ce0bcc19ab4314a9

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md).
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the web and TUI modules.
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the Web host and client modules.
## Plugin

View File

@@ -4,7 +4,7 @@
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACPAgent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent智能体、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript文本记录回放、命令、模式、配置选择器、信息征集、推理reasoning、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。
此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript文本记录回放、命令、模式、配置选择器、信息征集、推理reasoning、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 宿主和客户端模块。
## 插件

View File

@@ -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/bash/README.md
README.md: ef82e9f4684ecf551ac7701d812088dd6b2ef6d0
README.zh.md: 84ff244ec3d1ff5d385a3eb334e4cf9f1fe31e03
README.md: 601782caad24d3555a206365a3f1954d81af1cf0
README.zh.md: 8b96c4f80ba8776bfd8bdde178178cea946ff36c

View File

@@ -2,16 +2,16 @@
English | [中文](README.zh.md)
The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All **product** packages.
The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All are **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` |
| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
| `pwsh-local/` | Local PowerShell `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (executable resolution, UTF-8-pinned spawn, Windows termination semantics) | (registers `ctx.bash`) |
| `bash-env/` | Tool-independent managed `DSH_*` shell environment registry shared by the shell tools (built-in facts + effect-scoped contributors) | (registers `ctx.bashEnv`) |
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
| `tool-pwsh/` | Model-facing PowerShell-dialect `pwsh` schema (behavior mirrors `tool-bash` minus the sandbox surface); background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
| [`bash/`](bash/README.md) | Defines the executor contract shared by implementations and consumers. | `ctx.bash` |
| [`bash-local/`](bash-local/README.md) | Executes commands through the local [`subprocess`](../subprocess/README.md) service. | (registers `ctx.bash`) |
| [`bash-sandbox/`](bash-sandbox/README.md) | Applies the configured [`sandbox`](../sandbox/README.md) backend before local execution. | (registers `ctx.bash`) |
| [`pwsh-local/`](pwsh-local/README.md) | Executes PowerShell commands with Windows-specific process behavior. | (registers `ctx.bash`) |
| [`bash-env/`](bash-env/README.md) | Provides the managed `DSH_*` environment shared by shell tools. | `ctx.bashEnv` |
| [`tool-bash/`](tool-bash/README.md) | Exposes Bash execution and background-task integration to the model. | (registers on `ctx.tools`) |
| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)).
A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring.

View File

@@ -1,17 +1,17 @@
# bash/bash 能力家族
# bash/bash 能力家族
[English](README.md) | 中文
能力家族横跨规范执行器 seam、其实现、共享 shell 环境面向模型的工具。这些全是**产品**包。
能力家族涵盖规范执行器 seam、其实现、共享 shell 环境面向模型的工具。这些全是**产品**包。
| 包 | 职责 | ctx key |
|---|---|---|
| `bash/` | 抽象 bash 执行器 seam接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` |
| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现命令默认值补全、deadline、终端环境、后台读取合并 | (注册 `ctx.bash` |
| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv标记拒绝强制执行事实扩展 `bash-local` 的机制) | (注册 `ctx.bash` |
| `pwsh-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 PowerShell `BashExecutor` 实现可执行文件解析、UTF-8 固定 spawn、Windows 终止语义) | (注册 `ctx.bash` |
| `bash-env/` | 工具无关的受管 `DSH_*` shell 环境注册表,由 shell 工具共享(内置事实 + 受 effect 作用域约束的 contributor | (注册 `ctx.bashEnv` |
| `tool-bash/` | 面向模型的 `bash` schema后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools` |
| `tool-pwsh/` | 面向模型的 PowerShell 方言 `pwsh` schema行为镜像 `tool-bash`,减去 sandbox 面);后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools` |
| [`bash/`](bash/README.md) | 定义实现与消费方共享的执行器契约。 | `ctx.bash` |
| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.bash` |
| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.bash` |
| [`pwsh-local/`](pwsh-local/README.md) | 以 Windows 专用进程行为执行 PowerShell 命令。 | (注册 `ctx.bash` |
| [`bash-env/`](bash-env/README.md) | 提供 shell 工具共享的托管 `DSH_*` 环境。 | `ctx.bashEnv` |
| [`tool-bash/`](tool-bash/README.md) | 向模型公开 Bash 执行和后台任务集成。 | (注册到 `ctx.tools` |
| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools` |
接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/)
叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方[ACPAgent Client Protocol示例](../../examples/acp-agent/)展示一套完整接线

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
README.zh.md: aeb33629def3fcc10294bbca19b37d43dcc73a0c
README.zh.md: b6f3aca41771f1ca990a4b708cd53119b8e8ad78

View File

@@ -17,7 +17,7 @@
## Managed environment
每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh``DSH_SHELL=1` 标识受管理的子进程。带 agent 的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。
每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh``DSH_SHELL=1` 标识受管理的子进程。带 agent(智能体)的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。
`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME``DSH_SHELL``DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`

View File

@@ -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/bash/bash-local/README.md
README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
README.zh.md: 8d6e3b8d2fdd92faeb7b1e6e373a938ae2064cd4
README.md: bb87ad6fe021e3144cef4adced3d798bf3d94d67
README.zh.md: d2f8c9091072bbf3d75909f6826432601001ab88

View File

@@ -20,15 +20,13 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
```
## Behavior (and where it came from)
## Behavior
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Task ids, ownership, polling, and notices belong to the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with.
## Model Experience

View File

@@ -20,15 +20,13 @@
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
```
## 行为(以及设计来源)
## 行为
设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下:
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`记录了两种已验证的有状态设计Claude Code 仅持久化 cwdCodex 使用 PTY exec 会话),供真实工作流需要时采用。
- **在受管进程组之上应用配置预算**`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算stderr 和后台运行仍使用 `maxOutputBytes`
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
- **在受管进程组之上应用配置预算**`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算stderr 和后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **适合模型的终端环境**设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **后台进程**`start()` 会立即返回活动的 `BashProcess` 句柄不应用超时Claude Code 在转为后台时会解除超时);句柄的 `readOutput()`服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并服务 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表
- **适合模型的终端环境**`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果这些作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **后台进程**`start()` 会立即返回活动的 `BashProcess` 句柄不应用超时`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行的进程属于 subprocess 服务,可在执行器重载后存活,并服务 dispose 被终止且等待退出。task id、所有权、轮询通知属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄。
## 模型体验

View File

@@ -1,10 +1,11 @@
/**
* Local implementation of the bash executor seam over the subprocess
* seam. Each command runs as `bash -c` in a managed process group spawned
* through `ctx.subprocess`; this executor owns command defaulting, deadlines
* and cause classification, the model-friendly terminal environment, and the
* model-facing stdout/stderr merge for background reads. Execution policy
* belongs in `tools/pre-execute` or a sandboxing executor.
* seam. Public commands run as `bash -c` in a managed process group spawned
* through `ctx.subprocess`; subclasses may reuse the same mechanics with an
* explicit argv. This executor owns command defaulting, deadlines and cause
* classification, the model-friendly terminal environment, and the model-facing
* stdout/stderr merge for background reads. Execution policy belongs in
* `tools/pre-execute` or a sandboxing executor.
* @module @deepseek-ai/dsh-bash-local
*/
@@ -137,13 +138,18 @@ export class LocalBashExecutor extends BashExecutor {
}
}
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
/** Map one resolved bash spec and explicit argv onto a fully-specified subprocess spawn. */
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
private spawnSpec(
spec: BashExecSpec,
argv: readonly string[],
stdoutMaxBytes: number,
signal: AbortSignal | undefined,
): SubprocessSpawnSpec {
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
return {
argv: ['bash', '-c', spec.command],
argv,
cwd: spec.workdir,
stdio: {
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
@@ -171,9 +177,21 @@ export class LocalBashExecutor extends BashExecutor {
}
async run(spec: BashExecSpec): Promise<BashRunResult> {
return this.runArgv(spec, ['bash', '-c', spec.command])
}
/**
* Run an explicit argv with the foreground lifecycle, environment, output,
* timeout, and cancellation semantics of this executor. Subclasses use this
* after replacing the public command's shell argv at an execution boundary.
* @param spec - resolved execution settings and caller-owned command metadata.
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
* @returns the settled foreground result with collected output and cause facts.
*/
protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> {
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, d.signal))
const outcome = await handle.done
const collected = LocalBashExecutor.collected(handle)
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
@@ -190,8 +208,21 @@ export class LocalBashExecutor extends BashExecutor {
}
start(spec: BashExecSpec): BashProcess {
return this.startArgv(spec, ['bash', '-c', spec.command])
}
/**
* Start an explicit argv with the background lifecycle, environment, output,
* cancellation, and process-tree ownership semantics of this executor.
* Subclasses use this after replacing the public command's shell argv at an
* execution boundary.
* @param spec - resolved execution settings and caller-owned command metadata.
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
* @returns the live background handle; spawn rejection settles it as killed.
*/
protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
const collected = LocalBashExecutor.collected(running)
// A spawn failure produces no process output, so the subprocess service has nothing
@@ -216,12 +247,12 @@ export class LocalBashExecutor extends BashExecutor {
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
}, (error: unknown) => {
// Background spawn failures settle as killed and surface through the read path.
proc.status = 'killed'
spawnFailureNote = `spawn failed: ${String(error)}`
this.onProcessDone(proc, spawnFailureNote)
this.onProcessDone(proc, spawnFailureNote, true, error)
}),
readOutput: (): BashProcessRead => {
const out = collected.stdout.readFrom(stdoutOffset)
@@ -261,8 +292,10 @@ export class LocalBashExecutor extends BashExecutor {
* empty.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
* @param _spawnFailed - whether the subprocess promise rejected before a process started.
* @param _spawnError - the original spawn rejection reason, which may itself be undefined.
*/
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
}
export default LocalBashExecutor

View File

@@ -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/bash/bash-sandbox/README.md
README.md: 035a8ad2401ca608d264049d454359eda7b2b9a7
README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb
README.md: 74c1e28f76db35603bb9f72e0522e16ece5589f7
README.zh.md: 2593049ce09c7bc1ae0a996321bb27cbac449977

View File

@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned argv directly. With the shipped native runners, the inner Bash retains shell semantics and evaluates `BASH_ENV` only after the runner establishes confinement. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
| Mode | File effects |
|---|---|
@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
- **Runner attribution is conservative.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same provenanced `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
@@ -36,8 +36,6 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
name: '@deepseek-ai/dsh-bash-sandbox'
```
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent). The agent-spine e2e additionally drives two concurrent sessions in one Cordis context and proves each real bash tool call can write only its own project. See [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
## Model Experience
### Bash tool schema, indirectly
@@ -72,7 +70,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
#### Token effect
@@ -86,5 +84,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
- **A background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`.
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a provenanced synchronous `SubprocessService` throw instead fails `start()` immediately.
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.

View File

@@ -4,9 +4,9 @@
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`引号处理与结果分类 helper 保留在内部。
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方, spawn 返回的已包装argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,并直接 spawn 返回的 argv。使用随附的原生 runner 时,内层 Bash 保留 shell 语义,并且只在 runner 建立约束后才求值 `BASH_ENV`。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
| 模式 | 文件影响 |
|---|---|
@@ -17,7 +17,7 @@
语义:
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言即提供方在每次包装时加上的特征bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement``full`,或在较旧 Landlock ABI 上为 `partial`)。
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`已结算的后台进程会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT``EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true``denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT``EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent智能体调用提供回退。已批准的升权只更改该策略的模式会话根目录仍然附着其上。`resolve()` 把策略带入 spec因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
- 进程机制spawn、进程组终止、输出收集spill、后台句柄、凭证清理继承自 [`dsh-bash-local`](../bash-local/)runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
@@ -36,8 +36,6 @@
name: '@deepseek-ai/dsh-bash-sandbox'
```
无密钥消费方集成证明是 `tests/bwrap.e2e.ts``tests/landlock.e2e.ts``tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner从外部验证实际文件效果并在相应 runner 缺失时各自自行跳过。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。
## 模型体验
### 间接的 Bash 工具 schema
@@ -72,7 +70,7 @@
#### 模型看到的内容
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT``EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀
#### Token 影响
@@ -86,5 +84,5 @@
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT``EACCES` 时,则会使 `start()` 立即失败
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。

View File

@@ -1,18 +1,61 @@
/**
* Internal shell-quoting and sandbox-result classification helpers.
* Internal sandbox-result classification helpers.
*
* @module @deepseek-ai/dsh-bash-sandbox/helpers
*/
import { accessSync, constants, statSync } from 'node:fs'
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
/** Whether the caller-owned spawn cwd can be entered. */
function isUsableWorkdir(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
/**
* Quote one string as a single-quoted POSIX shell word.
* @param text - raw argv element to preserve through the outer shell parse.
* @returns the quoted shell word.
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
* after independently ruling out the caller-owned cwd. A supplied error path
* must exactly identify the runner; without one, the syscall must. With a
* usable cwd, these codes describe resolution or execute permission for that
* argv[0] or its shebang interpreter.
* The workdir is checked at classification time, not atomically with spawn;
* concurrent path replacement may change attribution but cannot permit an
* unconfined execution.
* @param error - the original spawn rejection.
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
* @returns whether the rejection has executable-specific runner evidence.
*/
export function shellQuote(text: string): string {
return `'${text.replaceAll("'", String.raw`'\''`)}'`
export function isRunnerSpawnFailure(
error: unknown,
runnerProgram: string | undefined,
workdir: string,
): boolean {
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
if (typeof error !== 'object' || error === null) return false
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
if (typeof syscall !== 'string') return false
const exactSyscall = `spawn ${runnerProgram}`
if (path === undefined) return syscall === exactSyscall
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
return syscall === 'spawn' || syscall === exactSyscall
}
/** Fatal runner evidence retained for infrastructure-error detail. */
interface RunnerFailureMatch {
/** The original stderr line that matched a fatal signature. */
detail: string
}
/**
@@ -26,13 +69,37 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
}
/**
* Classify a failed run against the selected backend's runner-failure dialect.
* @param result - settled foreground run.
* @param signatures - case-insensitive runner-failure substrings from the active wrap.
* @returns whether the failed run matches that runner-failure dialect.
* Classify one settled process against the selected backend's structured
* runner-failure rules. Each rule requires a nonzero exit, its optional
* exit-code gate, and a fatal signature on one stderr line after exact
* informational lines are excluded.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text, left unchanged.
* @param rules - structured runner-failure rules from the active wrap.
* @returns the first matching fatal line, or undefined when evidence is insufficient.
*/
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
export function classifyRunnerFailure(
exitCode: number | null,
stderr: string,
rules: readonly RunnerFailureRule[],
): RunnerFailureMatch | undefined {
if (exitCode === null || exitCode === 0) return undefined
const lines = stderr.split(/\r?\n/)
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
// An empty or whitespace-only substring is not meaningful runner evidence.
// Ignore it while keeping any valid signatures beside it active.
const fatalSignatures = rule.fatalSignatures
.filter(signature => signature.trim().length > 0)
.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()
if (informationalLines.has(lowered)) continue
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
}
}
return undefined
}
/**

View File

@@ -1,21 +1,29 @@
/**
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
* mode, enforcement, and denial facts. Runner failure means the command never
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
* processes carry `runnerFailed`. The tool owns approval and passes a complete
* per-call policy.
* mode, enforcement, and denial facts. Positive runner-launch evidence means
* the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
* background processes carry `runnerFailed`; other spawn rejections retain
* local-executor semantics. The tool owns approval and passes a complete per-call policy.
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { Context } from 'cordis'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type {
ConfinedArgv,
ConfinedSandboxMode,
RunnerFailureRule,
SandboxEnforcement,
SandboxExecutionPolicy,
SandboxMode,
SandboxPolicy,
} from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
@@ -51,7 +59,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
runnerProgram: string | undefined
workdir: string
}>()
constructor(ctx: Context, config: Config) {
@@ -83,11 +93,22 @@ export class SandboxBashExecutor extends LocalBashExecutor {
return { ...result, sandbox: { mode, denied: false } }
}
const confined = this.confine(spec.command, { ...policy, mode })
const result = await super.run({ ...spec, command: confined.command })
// Runner failure outranks denial because the command did not run. Throw the
// same fail-closed error as confine-time discovery with the first stderr line.
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
let result: BashRunResult
try {
result = await this.runArgv(spec, confined.argv)
} catch (error) {
// An upstream abort remains cancellation even when it prevents spawn.
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
}
@@ -96,11 +117,29 @@ export class SandboxBashExecutor extends LocalBashExecutor {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') return super.start(spec)
// Install facts synchronously; promise settlement cannot run before start() returns.
// Once startArgv returns, install facts synchronously; promise settlement
// cannot run before start() returns.
const confined = this.confine(spec.command, { ...policy, mode })
const proc = super.start({ ...spec, command: confined.command })
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
let proc: BashProcess
try {
proc = this.startArgv(spec, confined.argv)
} catch (error) {
// LocalSubprocessService reports provenanced ENOENT/EACCES through async
// `done` rejection; this covers alternatives that throw that shape synchronously.
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, {
mode,
enforcement,
denialSignatures,
runnerFailureRules,
runnerProgram: confined.argv[0],
workdir: spec.workdir,
})
return proc
}
@@ -108,12 +147,15 @@ export class SandboxBashExecutor extends LocalBashExecutor {
* Stamp per-process sandbox facts before `done` settles. Full-access processes
* have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: BashProcess, stderr: string): void {
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.processFacts.delete(proc)
// Runner failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnFailed
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
@@ -121,30 +163,19 @@ export class SandboxBashExecutor extends LocalBashExecutor {
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.onProcessDone(proc, stderr)
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
}
/**
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
* exact `['bash', '-c', command]` argv this executor would spawn, get back
* the confined argv, and re-assemble it into the `exec …` command string
* the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
* `exec`s into the runner, so no extra shell lingers). Provider errors
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
* Wrap one shell command via the `ctx.sandbox` provider. Provider errors
* propagate unchanged; the returned argv is handed directly to the local
* executor's subprocess path.
* @param command - shell source for the confined inner `bash -c`.
* @param policy - resolved confined execution policy.
* @returns the provider's exact argv and settlement-classification facts.
*/
private confine(command: string, policy: SandboxPolicy): {
command: string
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureSignatures: readonly string[]
} {
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
return {
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
enforcement: confined.enforcement,
denialSignatures: confined.denialSignatures,
runnerFailureSignatures: confined.runnerFailureSignatures,
}
private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
return this.ctx.sandbox.confine(['bash', '-c', command], policy)
}
}

View File

@@ -0,0 +1,270 @@
/**
* Deterministic real-process proofs for runner classification: the real local
* provider and sandbox bash executor exercise direct runner-spawn failures
* and a POSIX fake Landlock launcher that prints its notice before exec.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
const FATAL_PREFIX = 'landlock-run: '
const FATAL = `${FATAL_PREFIX}landlock ruleset error: Invalid argument`
const contexts: Context[] = []
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
async function fakeLauncher(fatalExit?: number): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
tempDirs.push(dir)
const launcher = join(dir, 'landlock-run')
const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n`
await writeFile(launcher, `#!/bin/sh
while [ "$#" -gt 0 ]; do
case "$1" in
--ro|--rw) shift 2 ;;
--) shift; break ;;
*) printf '%s\\n' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;;
esac
done
printf '%s\\n' '${NOTICE}' >&2
${fatalBranch}exec "$@"
`, { mode: 0o755 })
return launcher
}
async function setup(fatalExit?: number): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {})
const sandbox = ctx.sandbox as LocalSandboxProvider
sandbox.internals = {
platform: 'linux',
probeBwrap: () => false,
probeLandlock: () => 'partial',
landlockLauncher: await fakeLauncher(fatalExit),
}
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
return ctx.bash as SandboxBashExecutor
}
async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {
runnerCommand: [runner],
runnerFailureSignatures: ['configured-runner: fatal'],
})
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
return ctx.bash as SandboxBashExecutor
}
describe('partial Landlock runner-failure classification', () => {
it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-'))
tempDirs.push(dir)
const runner = join(dir, `${kind}-runner`)
if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 })
if (kind === 'missing-interpreter') {
await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
}
const bash = await setupConfiguredRunner(runner)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(runner)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
it.each(['bare-name', 'relative'] as const)(
'classifies a %s runner whose shebang interpreter is missing',
async (form) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-'))
tempDirs.push(dir)
const filename = 'missing-interpreter-runner'
const runner = form === 'bare-name' ? filename : `./${filename}`
await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
const bash = await setupConfiguredRunner(runner)
const request = form === 'bare-name'
? { command: 'true', env: { PATH: dir } }
: { command: 'true', workdir: dir }
const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
// Empirically, Darwin and Linux Node 24 preserve the passed bare/relative
// argv[0] in this spawn error rather than resolving it to an absolute path.
expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
const task = bash.start(bash.resolve(request))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
},
)
it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-'))
tempDirs.push(dir)
const runner = join(dir, 'malformed-runner')
await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 })
const bash = await setupConfiguredRunner(runner)
const request = { command: 'true' }
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
// provenanced ENOENT/EACCES evidence required for runner attribution.
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
if (foreground instanceof Error) {
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect((foreground as { path?: unknown }).path).toBeUndefined()
let background: unknown
try {
bash.start(bash.resolve(request))
} catch (error) {
background = error
}
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect((background as { path?: unknown }).path).toBeUndefined()
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
} else {
expect(foreground).toMatchObject({
exitCode: 127,
signal: null,
sandbox: { mode: 'read-only', denied: false, enforcement: 'full' },
})
expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0)
const background = bash.start(bash.resolve(request))
await background.done
expect(background.status).toBe('completed')
expect(background.exitCode).toBe(127)
expect(background.signal).toBeNull()
expect(background.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
const output = background.readOutput().delta
expect(output.startsWith('[stderr]\n')).toBe(true)
expect(output.length).toBeGreaterThan('[stderr]\n'.length)
expect(output).not.toContain('spawn failed:')
}
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])(
'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
},
)
it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
const bash = await setup(exitCode)
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
const bash = await setup(LAUNCHER_FAILURE_EXIT)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
expect((error as Error).message).not.toContain(NOTICE)
})
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
})
it('applies the same evidence rule to notice-only background exits', async () => {
const bash = await setup()
for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
const task = bash.start(bash.resolve({ command }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
}
})
it('classifies a background notice plus child Permission denied as denial', async () => {
const bash = await setup()
const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
})
it('makes a background fatal line outrank denial text after the notice', async () => {
const bash = await setup(LAUNCHER_FAILURE_EXIT)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'partial',
runnerFailed: true,
})
const output = task.readOutput().delta
expect(output).toContain(NOTICE)
expect(output).toContain(FATAL)
})
})

View File

@@ -5,7 +5,7 @@
* the Unix denial signature used by the classifier without requiring a real sandbox runner.
*/
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -16,7 +16,8 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy }
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
@@ -30,12 +31,19 @@ interface ConfineCall {
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
const RUNNER_FAILURE = ['fake-runner: '] as const
/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */
const RUNNER_FORMS = [
['absolute', process.execPath],
['bare', 'node'],
['relative', './sandbox-runner'],
] as const
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
const passthrough = (argv: readonly string[]): ConfinedArgv =>
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
/**
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
@@ -90,15 +98,49 @@ describe('the provider hand-off', () => {
}])
})
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
// the sentinel only prints if the executor spawned the WRAPPED argv.
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => {
const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
expect(result.stdout.text).toBe('1')
expect(spawn).toHaveBeenCalledTimes(1)
expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-'))
const hook = join(dir, 'hook.sh')
const order = join(dir, 'order.txt')
writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n')
const runnerScript = [
'const { appendFileSync } = require("node:fs");',
'const { spawnSync } = require("node:child_process");',
'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");',
'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });',
'process.exit(child.status ?? 125);',
].join('')
const { bash } = await setup({}, argv => ({
argv: [process.execPath, '-e', runnerScript, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
try {
const result = await bash.run(bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: { DSH_ORDER_FILE: order },
}))
expect(result.exitCode).toBe(0)
expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
const { bash, calls } = await setup({ mode: 'workspace-write' })
const result = await bash.run(bash.resolve({ command: 'true' }))
@@ -120,9 +162,6 @@ describe('the provider hand-off', () => {
expect(calls).toHaveLength(2)
})
it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
})
})
describe('fail closed', () => {
@@ -132,6 +171,120 @@ describe('fail closed', () => {
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
})
it('preserves an already-aborted foreground call as cancellation', async () => {
const { bash } = await setup()
const controller = new AbortController()
const reason = new Error('caller cancelled before spawn')
controller.abort(reason)
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
})
it.each(RUNNER_FORMS)(
'keeps an invalid workdir ordinary with the %s provider-runner form',
async (_form, runner) => {
const { bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
},
)
it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => {
const { bash } = await setup({ mode: 'danger-full-access' })
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
it('keeps Node-shaped synchronous ENOEXEC ordinary in run() and start()', async () => {
const runner = join(spillDir, 'malformed-runner')
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' })
})
const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error)
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
let background: unknown
try {
bash.start(bash.resolve({ command: 'true' }))
} catch (error) {
background = error
}
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
})
it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => {
const runner = join(spillDir, 'unexecutable-runner')
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
// This pins an alternative SubprocessService's synchronous seam, not the
// shipped local behavior.
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner })
})
await expect(bash.run(bash.resolve({ command: 'true' })))
.rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(() => bash.start(bash.resolve({ command: 'true' })))
.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
})
it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
const runner = './sandbox-runner'
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
const workdir = join(parent, 'missing')
const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
try {
let thrown: unknown
try {
bash.start(bash.resolve({ command: 'true', workdir }))
} catch (error) {
thrown = error
}
expect(thrown).toBe(failure)
expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
})
describe('danger-full-access', () => {
@@ -233,15 +386,134 @@ describe('classifyDenial', () => {
})
})
describe('isRunnerSpawnFailure', () => {
it.each(['EACCES', 'ENOENT'])(
'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated',
(code) => {
const runner = join(spillDir, 'runner')
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true)
},
)
it.each(['ENOEXEC', 'ENOTDIR', 'EPERM'])(
'keeps unproven executable code %s ordinary despite synthetic argv[0] fields',
(code) => {
const runner = join(spillDir, 'runner')
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(false)
},
)
it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => {
const missingWorkdir = join(spillDir, 'missing-workdir')
for (const [, runner] of RUNNER_FORMS) {
const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false)
}
const fileWorkdir = join(spillDir, 'not-a-workdir')
writeFileSync(fileWorkdir, '')
const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' })
expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false)
})
it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => {
const missingRunner = join(spillDir, 'definitely-missing-runner')
const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) =>
Object.assign(new Error('spawn failed'), { code, syscall, path })
const spawnErrorWithoutPath = (syscall: string) =>
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall })
expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn other-runner'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
})
it('accepts only syscall provenance compatible with the exact runner program', () => {
const runner = join(spillDir, 'runner with spaces')
const spawnError = (syscall: string, path?: string) =>
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })
expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`), runner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError('spawn other-runner', runner), runner, process.cwd())).toBe(false)
})
})
describe('classifyRunnerFailure', () => {
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }]
expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined()
})
it('keeps valid fatal signatures active beside an ignored empty entry', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const fatal = 'landlock-run: ruleset creation failed'
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['', ' ', 'landlock-run: '],
informationalLines: [notice],
}]
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
})
it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined()
expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules))
.toEqual({ detail: `${notice}: extra detail` })
expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules))
.toEqual({ detail: 'landlock-run: exec failed: No such file or directory' })
})
it.each([
'landlock-run: usage error: missing `-- <argv>...` command',
'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)',
'landlock-run: cannot open rule path: /gone: No such file or directory',
'landlock-run: landlock ruleset error: Invalid argument',
'landlock-run: exec failed: Permission denied',
'landlock-run: out of memory',
'landlock-run: future fatal diagnostic',
])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => {
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['landlock-run: '],
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
}]
expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal })
})
})
describe('result facts', () => {
it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => {
const { bash } = await setup({}, argv => ({
argv: ['env', ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
const { bash } = await setup()
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
@@ -253,25 +525,66 @@ describe('result facts', () => {
})
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
})
describe('background sandbox facts', () => {
it('stamps facts and releases accounting when background spawn fails', async () => {
const { bash } = await setup()
const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing')
const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir }))
it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => {
const { bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
}
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
pid: -1,
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: { stdout: emptyReader, stderr: emptyReader },
// Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
done: Promise.reject(undefined),
terminate: vi.fn(),
waitForExit: async () => true,
} satisfies SubprocessHandle)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
expect(task.readOutput().delta).toContain('spawn failed: undefined')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
})
})
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
@@ -284,7 +597,7 @@ describe('background sandbox facts', () => {
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
// The wrap's runner prefix on a failed run means the SANDBOX broke and
// the command never ran — the late twin of the confine-time throw, with
// the runner's own first stderr line carried as the cause.
// the matched fatal stderr line carried as the cause.
const { bash } = await setup()
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
@@ -315,7 +628,7 @@ describe('background sandbox facts', () => {
let call = 0
const { bash } = await setup({}, (argv) => {
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
})
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
const quick = bash.start(bash.resolve({ command: 'true' }))

View File

@@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -76,6 +76,32 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
})
it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => {
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
const hook = join(workdir, 'bash-env-hook.sh')
const insideProbe = join(workdir, 'hook-ran.txt')
const outsideProbe = join(outside, 'escaped.txt')
await writeFile(hook, [
'printf hook > "$DSH_BASH_ENV_INSIDE"',
'printf escaped > "$DSH_BASH_ENV_OUTSIDE"',
'',
].join('\n'))
const bash = await sandboxedBash(workdir, 'workspace-write')
await bash.run(bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: {
DSH_BASH_ENV_INSIDE: insideProbe,
DSH_BASH_ENV_OUTSIDE: outsideProbe,
},
}))
expect(readFileSync(insideProbe, 'utf8')).toBe('hook')
expect(existsSync(outsideProbe)).toBe(false)
})
it('classifies a background denial once the task settles', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')

View File

@@ -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/bash/bash/README.md
README.md: d7bf746969f52000fe298b65b995b7c631d8001c
README.zh.md: 4eef38e436cb0a4bcb3505aeb228d18c4a8c170b
README.md: e459037205730455cc9bd3afb248d3a5541ce241
README.zh.md: d5acab202d81b72d8524b291a0b6550ce45eae2f

View File

@@ -25,7 +25,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
| `BashProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. |
| `BashProcess.kill()` | Kill the process group. Returns `false` when it already finished. |
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit — see the HMR-safety tests.
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
## Vocabulary

View File

@@ -25,7 +25,7 @@
| `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 |
| `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 |
实现会继承 `BashExecutor` 并实现抽象方法。dispose资源释放必须终止每个运行中的进程并等待其退出,详见 HMR热模块替换安全测试
实现会继承 `BashExecutor` 并实现抽象方法。dispose资源释放必须终止每个运行中的进程并等待其退出。
## 词汇

View File

@@ -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/bash/pwsh-local/README.md
README.md: 9deba9c1b63ccfdb9e1805b9896db33f144839bf
README.zh.md: e45c820e1d5e31aebd9ed365c6130850f1db2a62
README.md: 2914c46ab91dd9555dab04551e52321f6eac05bf
README.zh.md: ce9696b276a2e60acf116d7124cd5cd256d7ebde

View File

@@ -46,7 +46,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead.
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; interactive terminal sessions remain deferred until the roadmap's pwsh TUI/GUI rendering work lands.
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`.
- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.

View File

@@ -46,7 +46,7 @@
## 已知局限与延期工作
- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。
- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`;交互式终端会话在路线图的 pwsh TUI/GUI 渲染工作落地之前保持延期
- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`
- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束因此基于信号的状态分类POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`

View File

@@ -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/bash/tool-bash/README.md
README.md: 35a5647365dab6daa903c14cba8b83702b50305d
README.zh.md: eb7901f3445117988d77e6d39b73681480b7a754
README.md: 47101e1198d13518c3d82877df8726c1fbf26b82
README.zh.md: d60ac4b3826838e875f7d43bc314f62e1453c1d9

View File

@@ -6,7 +6,7 @@ The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreg
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests.
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain package-internal.
The plugin also contributes the `tool:bash` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on.

View File

@@ -6,7 +6,7 @@
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器。
package根只公开 Cordis 插件契约(`name``inject``Config``apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖
package根只公开 Cordis 插件契约(`name``inject``Config``apply`);结果渲染和后台进程适配仍保留在包内部
插件还会提供 `tool:bash` 提示词段落(顺序 105检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
@@ -42,7 +42,7 @@
## 工具仅使用具名参数构建请求
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes``stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes``stdin``env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes``stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes``stdin``env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
## 权限与升权

View File

@@ -16,10 +16,10 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash'
*/
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
// infrastructure-failure outcome, then map spawn failures and
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
// failure with a signal-less kill and a runner failure with an ordinary
// wrapper exit; real nonzero command exits must remain `completed`.
// infrastructure-failure outcome, then map it to task `failed`. Restricted
// runner failures expose sandbox.runnerFailed, but unconfined spawn failures
// still alias a signal-less kill; real nonzero command exits must remain
// `completed`.
if (proc.status === 'killed') {
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
}

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7
README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7
README.zh.md: 87a30130c2f4c56be34199dca39399c45eb4b323

View File

@@ -19,8 +19,8 @@
| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 |
| `description` | string (required) | 命令的一行主动语态摘要5-10 词),仅用于 UI/日志展示——不影响执行。 |
| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 |
| `workdir` | string | 本次调用的工作目录。默认取调用 agent 的会话 cwd`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
| `run_in_background` | boolean | 立即返回任务 id不适用超时。 |
| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
| `run_in_background` | boolean | 立即返回 task id不适用超时。 |
`command``workdir``timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`

View File

@@ -60,16 +60,16 @@ One UI feature = one plugin package (`src/client/` browser half). A multi-domain
## Styling
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. In short: design tokens live in `web-ui/src/style/global.css` (`:root` light values, `[data-theme='dark']` overrides); component CSS references tokens only — no literal color values. CSS Modules + `clsx`; no component library, no tailwind ([framework ruling](../../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)). Product copy is Chinese; code comments are English.
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Product copy is Chinese; code comments are English.
## Testing and coverage
The GUI test structure (three tiers, lane map) is settled in the [GUI testing system note](../../.agents/notes/implemented/process/2026-07-20-gui-testing-system.md); repo-wide policy in [docs/testing.md](../../docs/testing.md).
- **Both client packages are inside the per-file 100% coverage gate** (`pnpm run test:coverage`). `web-runtime` is covered by node-env object/protocol suites; `web-ui` rides the jsdom lane. Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
- **web-ui specs are end-to-end behavior checks, not unit tests.** A jsdom spec renders the component with realistic props (or a driven fixture runtime) and asserts what the user would see — never class names, hook internals, or render counts. Components are consumables: behavior-shaped specs survive a rewrite, implementation-shaped specs don't.
- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line the shared config stays node-env. Start a new spec from an existing one (`web-ui/tests/tool-card.spec.tsx` is a good template).
- **Each tier asserts its own layer.** Data-layer semantics (state machines, wire shapes, reference stability) belong to the `web-runtime` and `apiproxy` suites — don't re-assert them from component specs.
- Client source packages are inside the per-file 100% coverage gate (`pnpm run test:coverage`). Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
- Component specs render with realistic props or a driven fixture runtime and assert user-visible behavior, not class names, hook internals, or render counts.
- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line; the shared config stays node-env.
- Each tier asserts its own layer. Data-layer semantics belong to the runtime and host suites; component specs cover presentation behavior.
## Before you push: the local check ladder

View File

@@ -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/README.md
README.md: 31d884c04a8b0233713b77b82b3d9cc7052003ca
README.zh.md: 9c95db529306519136d6d68758889350e5dd65e4
README.md: b950772d4cad6d873426f8aee6416fa56afca2ee
README.zh.md: 8f1f7f46777b7037e8baa04c9ec16ef74ffd478d

View File

@@ -2,33 +2,38 @@
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-<name>`.
The browser side of the dsh web GUI: shell boot, browser-host communication, shared UI services, and feature plugins. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All except `test-runtime` are **product** packages named `@deepseek-ai/dsh-client-<name>`.
| 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 script-loaded 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`) |
| Package | Purpose |
|---|---|
| [`web/`](web/README.md) | Boots the browser shell from the client entry graph. |
| [`modules/`](modules/README.md) | Loads browser-side client modules. |
| [`web-react/`](web-react/README.md) | Connects the shell runtime to React rendering. |
| [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery. |
| [`runtime/`](runtime/README.md) | Provides shared client services for sessions, workspaces, and UI composition. |
| [`hmr/`](hmr/README.md) | Refreshes client plugins during development. |
| [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries. |
| [`schema-form/`](schema-form/README.md) | Provides schema-backed draft handling for settings editors. |
| [`test-runtime/`](test-runtime/README.md) | Provides shared repository test support for client feature packages. |
| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. |
| [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. |
| [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. |
| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. |
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
| [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. |
| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. |
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. |
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
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.
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.

View File

@@ -2,33 +2,38 @@
[English](README.md) | 中文
dsh web GUI 的浏览器侧shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名 `@deepseek-ai/dsh-client-<name>`
dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 UI 服务和特性插件。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。`test-runtime` 外,均为命名 `@deepseek-ai/dsh-client-<name>` 的**产品**包
| 包 | 角色 | 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/` | 开发用的外部脚本加载型客户端插件热重载(`--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/` | 侧栏 shellWorkspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | slot 宿主) |
| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces``conversation.hero.workspace` |
| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | slot 宿主) |
| `ui-trajectory/` | TrajectoryWaterfall 视图标签;最小纯消费者插件范例 | (填充 `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` |
| 包 | 目的 |
|---|---|
| [`web/`](web/README.md) | 从客户端条目图启动浏览器 shell。 |
| [`modules/`](modules/README.md) | 加载浏览器侧客户端模块。 |
| [`web-react/`](web-react/README.md) | 连接 shell 运行时与 React 渲染。 |
| [`connection/`](connection/README.md) | 维护浏览器与宿主之间的 RPC 通信和事件传递。 |
| [`runtime/`](runtime/README.md) | 为会话、Workspace 和 UI 组合提供共享客户端服务。 |
| [`hmr/`](hmr/README.md) | 开发期间刷新客户端插件。 |
| [`locale/`](locale/README.md) | 提供本地化偏好与消息词典。 |
| [`schema-form/`](schema-form/README.md) | 为设置编辑器提供 schema 驱动的草稿处理。 |
| [`test-runtime/`](test-runtime/README.md) | 为客户端特性包提供共享的仓库测试支持。 |
| [`ui-slots/`](ui-slots/README.md) | 定义 UI 特性注册和组合扩展 slot 的方式。 |
| [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 |
| [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 |
| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 |
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 |
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent智能体活动的其他视图。 |
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
| [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 |
| [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill技能引用。 |
| [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 |
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
特性 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) 拥有加载链与对象层
每个子文档负责自身的契约和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)[Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策

View File

@@ -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/connection/README.md
README.md: faf093964a740092983e13bf88f2cccd853c3e36
README.zh.md: b06ab245dedbde13957aa416be044ef107b2753c
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51

View File

@@ -2,20 +2,16 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The real browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the fixture and in-process carriers continue to satisfy the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. 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. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
## /api browser-trust fence
The node half guards every entry under `/api` before bridging or upgrading (`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 unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. 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. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. 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).
The node half guards every entry under `/api` before bridging or upgrading (`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 unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. 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. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. 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 a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
## `/api` WebSocket downlinks
`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier.
## 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. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.
@@ -26,5 +22,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.

View File

@@ -2,20 +2,16 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。真实浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocketfixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClientConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocket进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
## /api 浏览器信任栅栏
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取而 Host 是重绑唯一伪造不了的请求头WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--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)。
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取而 Host 是重绑唯一伪造不了的请求头WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`部署需要让自己的服务权威被信任dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略而不是认证Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
## `/api` WebSocket 下行
`/api/events.mux``/api/events.host` 各接受一条 WebSocket upgrade并只向浏览器发送对应的 `ServerRequest` text message客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token短语行为并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验
无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。
@@ -26,5 +22,4 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
## 已知限制与暂缓事项
- **history 的隐式恢复存在争议**:在未附加的会话打开 history,会在主机侧拉起 agent纯持久化读取的替代方案记录在 rt-core 协调账本中P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
- **计划移除 `ToolEventView``ToolCallView``ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时它们会一并移除呈现属于客户端在此之前fixture 保留一份局部 `viewFor` 镜像。
- **History 会恢复未附加的会话**打开 history 可能创建宿主侧 agent并增加首次打开的延迟没有仅从持久化读取的路径。

View File

@@ -2390,6 +2390,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// editor; real schema-driven forms ride the HTTP transport.
describe: request => ok(request, {
writable: true,
hasDocument: true,
namespaces: [{
ns: 'llm-deepseek',
schema: {},
@@ -2399,6 +2400,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
revision: 0,
}],
}),
// Native opens are deterministic no-op successes in this fixture, as is host.openPath.
openDocument: request => ok(request, { opened: true as const }),
update: request => err(request, {
code: 'settings-rejected',
message: 'fixture: the minimal readiness settings descriptor is read-only',
@@ -2549,6 +2552,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'goal.complete': return this.api.goals.complete(request)
case 'goal.clear': return this.api.goals.clear(request)
case 'settings.describe': return this.api.settings.describe(request)
case 'settings.openDocument': return this.api.settings.openDocument(request, signal)
case 'settings.update': return this.api.settings.update(request)
case 'settings.replace': return this.api.settings.replace(request)
case 'settings.mutate': return this.api.settings.mutate(request)

View File

@@ -53,6 +53,7 @@ const PRIVILEGED_METHODS = new Set([
'host.pickDirectory',
'host.openPath',
'settings.describe',
'settings.openDocument',
'settings.update',
'settings.replace',
'settings.mutate',

View File

@@ -181,7 +181,8 @@ export class FakeApiClient implements IApiClient {
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),

View File

@@ -134,7 +134,7 @@ describe('connection node half', () => {
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
]) {
const denied = fakeResponse()
@@ -218,7 +218,7 @@ describe('connection node half over a real HTTP server', () => {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
]) {

View File

@@ -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/hmr/README.md
README.md: f91a6c6f685c88a1ea19312985ad3e933222192a
README.zh.md: 1d20a22d211c13d62089fb5618f40636ab7ae60a
README.md: 454c03cc3cd11722943efd025d164d9ca8233d25
README.zh.md: fc4100c48e5db9ed6781117dd652232bbd7c7aaa

View File

@@ -17,5 +17,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.
- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; reconnect is the only refresh boundary.

View File

@@ -17,5 +17,5 @@
## 已知限制与暂缓事项
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突因此有意排除。
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现
- **重建帧不会刷新图 rev**:陈旧 rev 无害组合包端点以 no-cache 提供内容rev 刷新将在重新连接握手机制中实现
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中显示;系统不会自动恢复先前组合包。
- **重建帧不会刷新图 rev**:陈旧 rev 无害,因为组合包端点以 no-cache 提供内容;只有重新连接时才会刷新

View File

@@ -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/locale/README.md
README.md: 7f780092af9bc7079cc5080c06e986bef2dfdbce
README.zh.md: 288982b1247f93fa1e8578a9ece2fcf8ed86666d
README.md: f1efefde4557e1c29c0556f8b670f1534430ab79
README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291

View File

@@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs.
- **Some surfaces keep inline copy** — Settings rows, the sidebar, question composer, and model select use locale seats; other packages still own static text directly.
- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.

View File

@@ -14,5 +14,5 @@ locale 插件LocaleService——浏览器 locale 偏好(`zh``en`,以 `
## 已知限制与暂缓事项
- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移
- **部分界面仍保留内联文案**——设置行、侧边栏、问题作答器和模型选择使用 locale seat其他包仍直接拥有静态文本
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述在重新注册前保持注册时的语言slot 渲染的文案随切换实时更新。

View File

@@ -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/runtime/README.md
README.md: adcc9595bbf6865f1a01eedf9eead1da5d4b5657
README.zh.md: 5857b929e5a2535420a46b6933ec0497fb1ff117
README.md: 542d6e5bacf7842533339f4cbbeeddd35df8be79
README.zh.md: 8a0f10952eccad4df546c122c0365b027e94d7c0

View File

@@ -28,7 +28,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## The human transcript
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
@@ -70,6 +70,6 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **`loader.unload` is a stub** — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).

View File

@@ -28,7 +28,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 面向人的 transcript文本记录
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上外加每次落地的压缩compaction检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上外加每次落地的压缩compaction检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
@@ -70,6 +70,6 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
## 已知限制与暂缓事项
- **`loader.unload` 是 stub抛出 not-implemented**:完整链路(fiber dispose → 注册级联 → 样式移除)随 HMR热模块替换项目落地
- **`loader.unload` 是 stub**:它会抛出 not-implemented;客户端没有从 fiber dispose 到注册与样式移除的卸载链
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘postmortem所记录的问题。

View File

@@ -29,7 +29,6 @@ import { toAssistantBlocks } from './conversation.ts'
* import stays type-only because a value import would fail the client purity
* gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are
* forbidden in a browser bundle — while an erased type never reaches it.
* `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
*/
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'

View File

@@ -216,7 +216,8 @@ export class FakeApiClient implements IApiClient {
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),

View File

@@ -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/schema-form/README.md
README.md: 5dcef89cbffc8b03c3f2d874e870fa9767360d3c
README.zh.md: ebaa9e0d0a134a6fade5729215168a1a47fd375c
README.md: 716cc7ba3c24f3a4de081e2d26f803b905235fac
README.zh.md: 65b8767df84f90eedd70e9f8ac27d7d419f06f46

View File

@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. That is acceptable only because the envelope comes from the same host that serves the page; a browser schema protocol should carry a description the client cannot execute, which is deferred with the settings seam's [wire-boundary work](../../settings/settings/README.md#known-limitations-and-deferred-work).
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it.
- **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes.
- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. This is safe only for an envelope from the same trusted host that serves the page; the protocol provides no inert cross-trust representation.
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message, including its `$.path`; it does not map errors onto individual controls.
- **No generic renderer** — consumers build feature-specific forms over these helpers. The [Web config-plane Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) records that trade-off.

View File

@@ -18,6 +18,6 @@
## Known Limitations and Deferred Work
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback因此 schema 信封是可执行内容,而非惰性数据。只有信封来自提供该页面的同一 host 时才可接受;面向浏览器的 schema 协议应当传递客户端无法执行的描述,此项与 settings seam 的[协议边界工作](../../settings/settings/README.md#known-limitations-and-deferred-work)一并暂缓
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback因此 schema 信封是可执行内容,而非惰性数据。只有信封来自提供该页面的同一受信任 host 时才安全;该协议没有跨信任边界使用的惰性表示
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息及其 `$.path`;它不会把错误映射到各个控件
- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)记录该权衡

View File

@@ -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/test-runtime/README.md
README.md: dc8ee8cadf5e61af15f04b1b9842af1eb658c031
README.zh.md: a4c889d8a0291b52c8509403748df6b93567788e
README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e
README.zh.md: a86b9e469a5632886891628267002a14588afeaa

View File

@@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Every consumer is an in-repository Vitest suite; there is no Node-compatible runtime entry.
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production projection would never produce.

View File

@@ -20,5 +20,5 @@
## Known Limitations and Deferred Work
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件,可接受Node 兼容的运行时入口待出现仓外消费方再补
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。所有消费方都是仓内 Vitest 套件;不存在 Node 兼容的运行时入口。
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 storewire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。

View File

@@ -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-command/README.md
README.md: c892f2f244d7924014ad1b4d6e9fe16ff4e044e4
README.zh.md: e5d109af8ca94515e0b574c62c57968796af5ce8
README.md: a19bfe7135acc5408448dc73d04813ed4104dd48
README.zh.md: 79d903b916728c1200ab1311055f2be190008787

View File

@@ -22,5 +22,4 @@ None directly; this package neither assembles nor sends a provider request. Comm
## Known Limitations and Deferred Work
- **The popupSelect shell has no shipped business consumer** — model selection (host `selectModel`) is the design's reference case and lands with its own feature work; until then the shell is exercised by package tests only.
- **Detached-result notices fall back to the console off-session** — the fire-and-forget paths route results to the triggering session's composer via `SessionInput.notify`; after session teardown the console line is the only remaining surface.

View File

@@ -22,5 +22,4 @@
## 已知限制与暂缓事项
- **popupSelect 壳还没有已上架的业务消费方**模型选择host `selectModel`)是设计的参照用例,将随其自身的功能工作落地;在此之前,壳只由包测试演练。
- **脱离会话后detached result 的 notice 回退到 console**fire-and-forget 路径经 `SessionInput.notify` 把结果送到触发会话的编辑器会话拆除后console 输出行是仅剩的呈现面。

View File

@@ -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: 0d00eac1db5aed7feec9bb714fe3d8976cd39943
README.zh.md: 4219950a9d51eb1037051ca00d61fa0d5ffa6fd2
README.md: d7433c151b7ab8da66d319ce71dd24f533739176
README.zh.md: 338cb3a8673a24a387a96c4c5e65a528e01c4c68

View File

@@ -8,7 +8,7 @@ Compaction renders as one collapsed row at the checkpoint's flow position withou
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
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: <active id>`), 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.
The view ring is a slot: the conversation registration declares the session-scoped `'conversation.view'` list in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
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 runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. 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); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
@@ -20,7 +20,7 @@ A Think row stays collapsed by default and exposes live reasoning throughput wit
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.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card below its summary row; tool rows are summary surfaces, so the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which keeps the summary bounded; the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Both render sites show the same complete source list — the one the tool returned and the model saw — bounded only by the card's own scroll container height, with no row-versus-panel cap ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), [source scroll](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md)).
@@ -32,7 +32,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
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: '<tool>', 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); 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).
Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
@@ -48,7 +48,7 @@ The composer bar declares session-scoped single seats for `'conversation.input.p
The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The inline stats row remains the sole context UI; the model selector has no circle or accessory.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
`src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations.
## Model Experience
@@ -62,11 +62,11 @@ None; this package neither assembles nor sends a provider request.
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
- **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.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **The approval panel has no durable grant control** — it supports allow-once and reject only.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels.
- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `steering/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority.

View File

@@ -8,7 +8,7 @@
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`视图标签页从环账本的注册选项(`id``order``label`)投影而来。聊天视图是该包package自身的配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身
视图环是一个 slot会话注册 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`视图标签页从注册选项(`id``order``label`投影而来。聊天视图是该包自身的配置项ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
@@ -18,7 +18,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要保持有界——面板仍是单次调用的阅读。内联输出按渲染意图开放——终端卡片与 web 卡片各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片在摘要行下方;工具行是摘要 surface因此卡片的复制与展开控件是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16因此摘要保持有界面板仍是单次调用的阅读 surface。内联输出按渲染意图开放——终端卡片与 web 卡片各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值wire 上不可信其为 `search``fetch`),它返回 null落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search``web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片详情面板渲染它并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。两个渲染点显示同一份完整来源列表——工具返回、模型看到的那一份——仅受卡片自身滚动容器的高度约束,不存在行与面板的两级上限([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)、[来源滚动](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md))。
@@ -30,7 +30,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
声明 `search` 渲染意图的 `grep``glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line`glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card``kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files``paths` 格式错误的已知 kind它都返回 null落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep``glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`8面板为 16。被截断的搜索会从卡片里丢掉一些行但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控`'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明bash 示例是第三方姿态的范例。Trajectory/waterfall瀑布式事件工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明
工具行使用键控、Session scope `'conversation.chat.toolview'` slot;其渲染点通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 fallback。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam。Trajectorywaterfall瀑布式事件工具视图 slot 共享此形状并使用各自的渲染点RendersCheck 会拒绝没有任何渲染方的声明。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。

View File

@@ -76,11 +76,8 @@ function narrowDiffs(diffs: unknown): DiffHunk[] | null {
*
* This derivation consumes only `diffs`; the render intent's `title` field is
* deliberately dropped. The row supplies its own title (`Edit`/`Write · path`
* from the args) and that outranks the view's `title`, matching the TUI diff
* branch, which likewise draws no view title. A tool that names its own diff
* header therefore does not surface that text on the Web row — an accepted
* product choice, recorded here as the one asymmetry with the terminal card,
* whose derivation does consume the view's title.
* from the args), which outranks the view's `title`. A tool that names its own
* diff header therefore does not surface that text on the Web row.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @returns the diff-card props, or null for the generic path.
*/

View File

@@ -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-goal/README.md
README.md: 3da9d97c801a0a742de2601e5261c09ba193cf33
README.zh.md: 8a4c01394508d9ceb3eabb6cd38ad58abd7f0e38
README.md: c9a8f330949ed0db9c4986e7043a5063b8a26805
README.zh.md: 9df1a0091545436642ef5644d3258364e63a20ec

View File

@@ -16,4 +16,4 @@ None beyond the goal mutation's own context event, which appends to the log tail
## Known Limitations and Deferred Work
- **Durable phase only** — the projection value deliberately omits process-local activation (armed/disarmed), so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. A host-live-value channel is deferred until a real consumer needs it.
- **Durable phase only** — the projection omits process-local activation, so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. There is no host-live activation channel.

View File

@@ -16,4 +16,4 @@ Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.in
## 已知限制与暂缓事项
- **只反映持久 phase**——投影值有意省略进程本地 activationarmed/disarmed,条带无法区分 active-but-disarmed 与 armed 状态resume 通过 RPC 重新置为 armed 状态。host 活值通道待出现真实消费方后再议
- **只反映持久 phase**——投影省略进程本地 activation因此条带无法区分 active-but-disarmed 与 armed 状态resume 通过 RPC 重新置为 armed 状态。不存在 host 实时 activation 通道

View File

@@ -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-layout/README.md
README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
README.zh.md: a24dfa4d4eeb28fdc8df1d21daa3f6e9476d0062
README.md: 5cb8f01efb2e18109e917225dbce088ea77394af
README.zh.md: 6559fe595a6219b139fe46cf046906fa63636f64

View File

@@ -6,7 +6,7 @@ Shell plugin: three-column AppFrame (drag handles and concession chain) plus the
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`.
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal.
## Model Experience
@@ -20,4 +20,4 @@ None; this package neither assembles nor sends a provider request.
- **Panel geometry is transient** — reload restores the sidebar default and details closed; switching between distinct Session ids also closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry.
- **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth.
- **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project.
- **No scroll anchoring during squeeze reflow** — layout changes may move the reader's viewport.

View File

@@ -6,7 +6,7 @@
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话保持关闭显式打开详情栏的操作会使用契约默认宽度返回同一会话时恢复其未改变的宽度选择不同会话时详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。
`/client` 导出表层包含插件主体(`apply``inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现
`/client` 导出表层包含插件主体(`apply``inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部。
## 模型体验
@@ -20,4 +20,4 @@ AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionPr
- **面板几何信息是瞬时状态**:重新加载会恢复侧边栏默认值,并使详情栏保持关闭;在不同会话 id 之间切换同样会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。
- **让步链自动关闭通过推导零宽度实现,不会改动宽度偏好**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。
- **挤压重排期间尚未实现滚动锚定**与虚拟化列表项目一并暂缓
- **挤压重排期间不提供滚动锚定**布局变化可能移动读者的 viewport

View File

@@ -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-model/README.md
README.md: 27fb7b936b796b956f7348fa776856180350bb56
README.zh.md: 06dbcc21c31c8ed9fd72d3c07d43c8db1bcca0bb
README.md: 5f9fc65939eb747d916fa5609423d3186d1fefde
README.zh.md: 3ed8db3095d96e48813cf5b15a206ebf4c894950

View File

@@ -2,7 +2,11 @@
English | [中文](README.zh.md)
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort.
The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory.
Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.

View File

@@ -2,7 +2,11 @@
[English](README.md) | 中文
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService``ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent智能体的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService``ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。
Host 报告的提供方模型推理reasoning目标是唯一的选择事实但只有当该精确路由仍在已公布分组中时才会回显删除该目录行会保留仍可路由的目标但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。
目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent智能体的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService``ModelDirectory` 及其状态形状、slot 注入面类型。

View File

@@ -198,8 +198,7 @@
white-space: nowrap;
}
.description,
.unlisted {
.description {
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
@@ -208,10 +207,6 @@
white-space: nowrap;
}
.unlisted {
color: var(--dsw-alias-state-warn-label);
}
.check {
display: grid;
place-items: center;

View File

@@ -174,8 +174,13 @@ export function ModelSelect(
})
}
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
const modelLabel = currentChoice?.model.name ?? t('trigger.fallback')
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
const triggerAria = currentChoice === undefined
? t('trigger.selectAria')
: effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
itemRefs.current = []
let itemIndex = 0
const itemRef = () => {
@@ -189,9 +194,7 @@ export function ModelSelect(
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
aria-label={triggerAria}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
@@ -277,9 +280,6 @@ export function ModelSelect(
{model.description !== undefined && (
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}

View File

@@ -51,9 +51,7 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt
rows.push({
id: rowId(group.id, model.id),
label: model.name,
detail: model.unlisted === true
? t('option.unlisted', { group: group.name })
: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
...(directory.current.provider === group.id && directory.current.model === model.id
? { active: true } : {}),
})

View File

@@ -1,11 +1,19 @@
/** `model` namespace dictionaries. */
/**
* `model` namespace dictionaries.
*
* `trigger.selectAria` reads identically to `trigger.fallback` today and is
* still a separate key: the visible fallback label and the accessible name of
* an unset trigger are free to diverge per locale, and folding it into
* `trigger.aria` would announce the degenerate "Select model, current Select
* model".
*/
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'command.description': '选择本会话使用的模型',
'option.unlisted': '{group} · 未列入目录',
'option.loadError': '目录加载失败:{message}',
'trigger.fallback': '选择模型',
'trigger.selectAria': '选择模型',
'trigger.aria': '选择模型,当前 {model}',
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
'menu.aria': '模型与推理等级',
@@ -16,7 +24,6 @@ export const zh = {
'error.action': '模型操作失败:{message}',
'action.reload': '重新加载',
'warning.groupLoad': '{name} 加载失败:{message}',
'option.currentUnlisted': '当前模型 · 未列入目录',
'empty.models': '没有可用的模型。',
'empty.efforts': '当前模型未提供推理等级。',
} satisfies Record<string, string>
@@ -27,9 +34,9 @@ export type ModelKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'command.description': 'Select the model for this conversation',
'option.unlisted': '{group} · Not in catalog',
'option.loadError': 'Catalog failed to load: {message}',
'trigger.fallback': 'Select model',
'trigger.selectAria': 'Select model',
'trigger.aria': 'Select model, current {model}',
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
'menu.aria': 'Model and reasoning effort',
@@ -40,7 +47,6 @@ export const en = {
'error.action': 'Model operation failed: {message}',
'action.reload': 'Reload',
'warning.groupLoad': '{name} failed to load: {message}',
'option.currentUnlisted': 'Current model · Not in catalog',
'empty.models': 'No models available.',
'empty.efforts': 'This model provides no reasoning effort levels.',
} satisfies Record<ModelKey, string>

View File

@@ -111,6 +111,29 @@ describe('ModelSelect reasoning effort', () => {
.toEqual(['Default', 'Standard'])
})
it('prompts for a new selection when the current target is no longer advertised', () => {
const directory = createSnapshotStore(state({
current: { provider: 'deepseek-official', model: 'removed-model' },
}))
const select = vi.fn().mockResolvedValue(true)
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={select}
t={t}
/>)
const trigger = screen.getByRole('button', { name: '选择模型' })
expect(trigger.textContent).toContain('选择模型')
fireEvent.click(trigger)
expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
expect(screen.queryByText('removed-model')).toBeNull()
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
})
it('renders no Agent-bound control for an addressed subagent session', () => {
const load = vi.fn()
render(<ModelSelect

View File

@@ -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-models/README.md
README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767
README.zh.md: a467b65da0bf0a38951cd11dba9ec54a2b03c08e
README.md: c578ecfc9163245e8666cb6d2d327efdaccccf89
README.zh.md: 40da5b52f681071cb5b833866270db7b37fb0957

View File

@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model Experience
@@ -20,7 +20,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it.
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.

View File

@@ -4,11 +4,11 @@
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另 `reasoningEffort`deepseek`reasoning`pi-ai其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base而且必须先在本地化对话框中确认页面才会提交这次破坏性的 unset。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另 `reasoningEffort`deepseek`reasoning`pi-ai,以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base而且必须先在本地化对话框中确认页面才会提交这次破坏性的 unset。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置或凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型体验
@@ -20,7 +20,6 @@
## 已知限制与暂缓事项
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md);进阶字段(`models`、重试策略、超时……)`settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)。DeepSeek 公开 `baseURL``reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`pi-ai 公开 `baseURL``reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留`settings.yaml`编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile却刻意不清除那条派生凭据重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

View File

@@ -0,0 +1,364 @@
/**
* Curated editor for the direct DeepSeek adapter's advisory model catalog.
* The settings layer replaces `models` as one array, so the parent supplies
* the effective inherited rows until the first edit materializes a user
* override; reset removes that override instead of copying defaults into it.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import {
IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** One catalog entry kept structurally open so hidden or future fields survive an edit. */
export type DeepSeekModelDraft = Record<string, unknown>
/** The catalog fields this editor writes. */
type CatalogField = 'id' | 'name' | 'contextWindow' | 'maxTokens'
/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
type CapacityField = 'contextWindow' | 'maxTokens'
/** Row index encoded in an editing-buffer key. */
function rowOf(key: string): number {
return Number(key.slice(0, key.indexOf(':')))
}
/** Accepted capacity spellings: a decimal count with an optional K/M suffix. */
const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i
/** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */
const CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const
/**
* Read a typed capacity, so a user can write `256K` or `1M` instead of counting
* zeroes. The stored value stays a plain token count.
* @param text - raw field text.
* @returns the count; `undefined` when blank (inherit), `NaN` when unreadable
* (rejected by {@link validateDeepSeekModels} before any write).
*/
export function parseCapacity(text: string): number | undefined {
const trimmed = text.trim()
if (trimmed.length === 0) return undefined
const match = CAPACITY_PATTERN.exec(trimmed)
if (match === null) return Number.NaN
const suffix = match[2]?.toLowerCase()
const scale = suffix === 'k' || suffix === 'm' ? CAPACITY_SCALE[suffix] : 1
const scaled = Number(match[1]) * scale
// A decimal multiple is exact in intent but not in binary floating point
// (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back.
const rounded = Math.round(scaled)
return Math.abs(scaled - rounded) < 1e-6 ? rounded : scaled
}
/**
* Spell a stored count back in the shortest form that survives a round trip
* through {@link parseCapacity}; a count that is not a whole number of
* thousands stays written out.
* @param value - stored capacity.
* @returns the field text.
*/
export function formatCapacity(value: number): string {
if (!Number.isInteger(value) || value <= 0) return String(value)
if (value % CAPACITY_SCALE.m === 0) return `${String(value / CAPACITY_SCALE.m)}M`
if (value % CAPACITY_SCALE.k === 0) return `${String(value / CAPACITY_SCALE.k)}K`
return String(value)
}
/** A localized validation failure for one user-owned model array. */
export interface DeepSeekModelsValidationFailure {
/** Zero-based model position. */
index: number
/** Message key owned by the Models settings section. */
key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid'
| 'modelMaxTokensInvalid'
}
/** Convert a schema-validated catalog value into records without dropping hidden fields. */
export function modelDrafts(value: unknown): DeepSeekModelDraft[] {
if (!Array.isArray(value)) return []
return value.map(entry =>
typeof entry === 'object' && entry !== null && !Array.isArray(entry)
? entry as DeepSeekModelDraft
: {})
}
/**
* Validate adapter constraints that the serialized schema cannot express.
* @param value - user-owned `models` value, or undefined while inherited.
* @returns the first invalid row, or undefined when the adapter will accept it.
*/
export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidationFailure | undefined {
if (value === undefined) return undefined
const models = modelDrafts(value)
const seen = new Set<string>()
for (const [index, model] of models.entries()) {
// Compared trimmed: surrounding whitespace is a paste artifact the adapter
// would never match, and an untrimmed compare lets `model ` slip past the
// duplicate check against its own twin.
const id = model['id']
const trimmed = typeof id === 'string' ? id.trim() : undefined
if (trimmed === undefined || trimmed.length === 0) return { index, key: 'modelIdRequired' }
if (seen.has(trimmed)) return { index, key: 'modelIdDuplicate' }
seen.add(trimmed)
const name = model['name']
if (name !== undefined && (typeof name !== 'string' || name.length === 0)) {
return { index, key: 'modelNameInvalid' }
}
const contextWindow = model['contextWindow']
if (contextWindow !== undefined
&& (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) {
return { index, key: 'modelContextInvalid' }
}
const maxTokens = model['maxTokens']
if (maxTokens !== undefined
&& (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens <= 0)) {
return { index, key: 'modelMaxTokensInvalid' }
}
}
return undefined
}
/** Props of {@link DeepSeekModelsEditor}. */
export interface DeepSeekModelsEditorProps {
/** Effective rows: inherited until the parent materializes an override. */
models: readonly DeepSeekModelDraft[]
/** Whether the user layer currently owns the whole array. */
overridden: boolean
/** Fallback context capacity used when a row omits its exact value. */
defaultContextWindow: number | undefined
/** Fallback output cap used when a row omits its exact value. */
defaultMaxTokens: number | undefined
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable every mutation. */
disabled: boolean
/** Replace the user-owned array after one visible edit. */
onChange: (models: DeepSeekModelDraft[]) => void
/** Remove the user-owned array and return to inheritance. */
onReset: () => void
}
/**
* Render the direct DeepSeek adapter's model catalog: id and display name on
* each row, capacities behind the row's own disclosure.
* @param props - effective rows plus the array-level override actions.
* @returns the catalog editor.
*/
export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode {
// Capacities are edited as text, so a field's keystrokes are held here
// rather than re-derived from the parsed count on every change, which would
// rewrite `1000` to `1K` mid-word. Unreadable text is kept past blur so the
// save-time rejection names a row the user can still see — which is why
// this is one entry PER FIELD: a single active buffer would be displaced by
// editing any other field, and the abandoned one would fall back to
// rendering its stored NaN as the literal `NaN`.
//
// Keys carry the row index, so the two operations that move indexes maintain
// them: `remove` re-keys around the dropped row, and reset clears them all
// because the rows they annotated are gone.
const [editing, setEditing] = useState<ReadonlyMap<string, string>>(() => new Map())
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set())
const update = (index: number, key: CatalogField, value: unknown): void => {
const next = props.models.map((model, at) => {
const copy = { ...model }
if (at !== index) return copy
if (value === undefined) Reflect.deleteProperty(copy, key)
else copy[key] = value
return copy
})
props.onChange(next)
}
const remove = (index: number): void => {
setEditing((current) => {
const next = new Map<string, string>()
for (const [key, text] of current) {
const at = rowOf(key)
if (at === index) continue
// Only the row number moves; the field half of the key is untouched.
next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, text)
}
return next
})
setExpanded((current) => {
const next = new Set<number>()
for (const at of current) {
if (at === index) continue
next.add(at > index ? at - 1 : at)
}
return next
})
props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model })))
}
const reset = (): void => {
setEditing(new Map())
setExpanded(new Set())
props.onReset()
}
const toggle = (index: number): void => {
setExpanded((current) => {
const next = new Set(current)
if (!next.delete(index)) next.add(index)
return next
})
}
/** The field's text: its live keystrokes, else the stored count spelled short. */
const capacityText = (model: DeepSeekModelDraft, index: number, field: CapacityField): string => {
const typed = editing.get(`${String(index)}:${field}`)
if (typed !== undefined) return typed
const value = model[field]
return typeof value === 'number' ? formatCapacity(value) : ''
}
const settleCapacity = (index: number, field: CapacityField): void => {
const key = `${String(index)}:${field}`
const typed = editing.get(key)
if (typed === undefined) return
// Unreadable text stays on screen: the save-time rejection names a row the
// user can still see and correct.
const parsed = parseCapacity(typed)
if (parsed !== undefined && Number.isNaN(parsed)) return
setEditing((current) => {
const next = new Map(current)
next.delete(key)
return next
})
}
/** One capacity field of one row, rendered inside the row's disclosure. */
const capacityField = (
model: DeepSeekModelDraft,
index: number,
field: CapacityField,
fallback: number | undefined,
): ReactNode => (
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(model, index, field)}
placeholder={fallback === undefined
? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder')
: formatCapacity(fallback)}
aria-label={`${props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
const text = event.target.value
setEditing(current => new Map(current).set(`${String(index)}:${field}`, text))
update(index, field, parseCapacity(text))
}}
onBlur={() => { settleCapacity(index, field) }}
/>
</label>
)
return (
<section className={styles['modelCatalog']} aria-label={props.t('models')}>
<div className={styles['modelListHead']}>
<div className={styles['modelCatalogHeading']}>
<span className={styles['modelCatalogTitle']}>{props.t('models')}</span>
<span className={styles['modelCatalogMeta']}>
{props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')}
</span>
</div>
{props.overridden
? (
<button
type="button"
className={styles['linkButton']}
disabled={props.disabled}
onClick={reset}
>
{props.t('resetModels')}
</button>
)
: null}
</div>
{props.models.length === 0
? <p className={styles['modelEmpty']}>{props.t('modelsEmpty')}</p>
: (
<div className={styles['modelList']}>
{props.models.map((model, index) => (
<div className={styles['modelEntry']} key={index}>
<div className={styles['modelRow']}>
<input
className={styles['input']}
type="text"
value={typeof model['id'] === 'string' ? model['id'] : ''}
placeholder={props.t('modelId')}
aria-label={`${props.t('modelId')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => { update(index, 'id', event.target.value) }}
onBlur={(event) => {
// Settle a pasted id rather than trimming per keystroke,
// which would stop the user typing an interior space.
const trimmed = event.target.value.trim()
if (trimmed !== event.target.value) update(index, 'id', trimmed)
}}
/>
<input
className={styles['input']}
type="text"
value={typeof model['name'] === 'string' ? model['name'] : ''}
placeholder={props.t('modelName')}
aria-label={`${props.t('modelName')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(index, 'name', event.target.value === '' ? undefined : event.target.value)
}}
/>
<button
type="button"
className={styles['iconButton']}
aria-label={`${props.t('modelAdvanced')} ${String(index + 1)}`}
aria-expanded={expanded.has(index)}
title={props.t('modelAdvanced')}
onClick={() => { toggle(index) }}
>
{expanded.has(index) ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</button>
<button
type="button"
className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
aria-label={`${props.t('removeModel')} ${String(index + 1)}`}
title={props.t('removeModel')}
disabled={props.disabled}
onClick={() => { remove(index) }}
>
<IconTrashOutline16 size={14} />
</button>
</div>
{expanded.has(index)
? (
<div className={styles['modelAdvanced']}>
{capacityField(model, index, 'contextWindow', props.defaultContextWindow)}
{capacityField(model, index, 'maxTokens', props.defaultMaxTokens)}
</div>
)
: null}
</div>
))}
</div>
)}
<button
type="button"
className={styles['addModelButton']}
disabled={props.disabled}
onClick={() => { props.onChange([...props.models.map(model => ({ ...model })), { id: '' }]) }}
>
<IconPlusOutline16 size={14} />
{props.t('addModel')}
</button>
</section>
)
}

View File

@@ -1,3 +1,13 @@
/* Models settings section, in the settings-panel design language: 14/22 body,
* 12/18 caption, capsule controls (h36 r18; h28 r14 where a row is dense),
* 32px fields, and `border-l2` hairlines — the vocabulary GeneralSection and
* the Button/Input primitives already use.
*
* Every color resolves through a `--dsw-alias-*` token. The section used to
* name `--border` / `--surface` / `--text-*`, which nothing in this app
* defines, so it always rendered the light-mode literals written as their
* fallbacks and stayed light under the dark theme. */
.section {
display: flex;
flex-direction: column;
@@ -8,19 +18,23 @@
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.intro {
margin: 0;
font-size: 13px;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-tertiary);
}
.notice {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-warn-label);
}
@@ -31,9 +45,11 @@
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
/* A configured provider: outlined on the panel fill, so the filled editor
card it expands into reads as the nested object. */
.rowCard {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
@@ -41,7 +57,6 @@
display: flex;
flex-direction: column;
gap: 12px;
background: var(--dsw-alias-bg-layer-3);
}
.rowHead {
@@ -51,38 +66,59 @@
}
.rowName {
font-size: 15px;
font-weight: 600;
font-size: 14px;
line-height: 22px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.rowActions {
display: inline-flex;
gap: 8px;
align-items: center;
gap: 4px;
margin-left: auto;
}
.primaryButton {
/* `box-sizing` on every control here: the app has no global border-box reset,
so without it the outlined variants stand 2px taller than the filled ones
they sit beside (Cancel next to Apply, Edit next to Delete). */
.primaryButton,
.secondaryButton,
.addButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 999px;
padding: 8px 18px;
border-radius: 18px;
font: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
.primaryButton {
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-foreground);
font: inherit;
cursor: pointer;
}
.primaryButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.secondaryButton {
.secondaryButton,
.addButton {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 6px 14px;
background: var(--dsw-alias-bg-layer-3);
color: inherit;
font: inherit;
cursor: pointer;
background: transparent;
color: var(--dsw-alias-label-primary);
}
.secondaryButton:hover:not(:disabled),
.addButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.secondaryButton:hover:not(:disabled) {
@@ -90,12 +126,19 @@
}
.dangerButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 8px;
padding: 4px 8px;
background: none;
border-radius: 18px;
background: transparent;
color: var(--dsw-alias-state-error-primary);
font: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
@@ -103,17 +146,43 @@
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* Provider-row controls take the dense capsule (Button `.sm`). */
.rowActions .secondaryButton,
.rowActions .dangerButton {
height: 28px;
padding: 0 10px;
border-radius: 14px;
font-size: 12px;
line-height: 18px;
}
.primaryButton:disabled,
.secondaryButton:disabled,
.dangerButton:disabled {
opacity: 0.5;
.dangerButton:disabled,
.addButton:disabled,
.linkButton:disabled,
.addModelButton:disabled {
opacity: 0.4;
cursor: default;
}
.primaryButton:focus-visible,
.secondaryButton:focus-visible,
.dangerButton:focus-visible,
.addButton:focus-visible,
.linkButton:focus-visible,
.addModelButton:focus-visible,
.iconButton:focus-visible,
.customizedSummary:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
/* Editing surface: a filled module on the panel, matching the settings
selector fill rather than adding another outline inside the row. */
.editor {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-2);
background: var(--dsw-alias-bg-module-platform);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -128,11 +197,14 @@
.editorTitle {
font-size: 14px;
font-weight: 600;
line-height: 22px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.editorRoute {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
@@ -147,33 +219,36 @@
align-items: center;
gap: 10px;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.linkButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border: none;
background: none;
padding: 0;
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: 12px;
text-decoration: underline;
line-height: 18px;
cursor: pointer;
}
.linkButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.linkButton:disabled {
opacity: 0.5;
cursor: default;
}
.advancedHint {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
@@ -194,29 +269,12 @@
align-items: center;
gap: 6px;
align-self: flex-start;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 8px 16px;
font: inherit;
background: var(--dsw-alias-bg-layer-3);
color: inherit;
cursor: pointer;
}
.addButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
.addButton:disabled {
opacity: 0.5;
cursor: default;
}
.addCard,
.setupCard {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
background: var(--dsw-alias-bg-module-platform);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -224,9 +282,9 @@
list-style: none;
}
/* Nested in a card that already carries the module chrome. */
.addCard .editor,
.setupCard .editor {
border: none;
background: none;
padding: 0;
}
@@ -236,12 +294,44 @@
padding-top: 10px;
}
/* Native disclosure marker replaced by a rotating chevron: the built-in
triangle differs per engine and cannot take the label color. */
.customizedSummary {
display: flex;
align-items: center;
gap: 6px;
width: fit-content;
padding: 2px 4px;
margin-left: -4px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
list-style: revert;
list-style: none;
}
.customizedSummary::-webkit-details-marker {
display: none;
}
.customizedSummary::before {
content: '';
width: 5px;
height: 5px;
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
transform: rotate(-45deg) translate(-1px, -1px);
transition: transform 120ms ease;
}
.customized[open] > .customizedSummary::before {
transform: rotate(45deg) translate(-1px, -1px);
}
.customizedSummary:hover {
color: var(--dsw-alias-label-primary);
}
.customizedBody {
@@ -251,17 +341,173 @@
padding-top: 12px;
}
/* Model catalog: a table, not a stack of cards. The column captions are
written once above the rows, so a row is one line of fields plus its
delete control; each field still carries the indexed `aria-label` that
names it, and the caption strip is hidden from assistive tech to keep
that name from being announced twice. */
.modelCatalog {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.modelCatalogHeading {
display: flex;
flex-direction: column;
gap: 2px;
}
.modelCatalogTitle {
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.modelCatalogMeta,
.modelEmpty {
margin: 0;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
/* Model list, shared with the pi-ai provider form (PR #1368): one bordered
entry per model, id and display name on the row, capacities behind the
row's own disclosure. The token names are this file's, not that branch's —
`--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and
`--dsw-alias-text-primary` are undefined here and resolve to their
light-mode literals, which is the defect this section was just moved off. */
.modelList {
display: flex;
flex-direction: column;
gap: 8px;
}
.modelListHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.modelEntry {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 6px;
}
.modelRow {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
}
/* Square, label-free affordances: the row's own inputs carry the meaning, so
the actions stay glyphs and announce themselves through aria-label. */
.iconButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
cursor: default;
opacity: 0.4;
}
/* The delete glyph keeps the danger tint the rest of the section uses. */
.iconButtonDanger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.modelAdvanced {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
padding: 8px 4px 2px;
}
.modelField {
display: flex;
flex-direction: column;
gap: 4px;
}
.modelFieldLabel {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.modelEmpty {
padding: 12px;
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 8px;
text-align: center;
}
.addModelButton {
box-sizing: border-box;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 12px;
line-height: 18px;
cursor: pointer;
}
.addModelButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.input {
box-sizing: border-box;
padding: 9px 12px;
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
border-radius: 8px;
font: inherit;
font-size: 13px;
font-size: 14px;
line-height: 22px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
}
/* Enum pickers hold a handful of short options; a field-width dropdown reads
as a text field the user is expected to fill. */
select.input {
max-width: 240px;
cursor: pointer;
}
.input:focus {
outline: none;
border-color: var(--dsw-alias-brand-primary);
@@ -271,6 +517,11 @@
color: var(--dsw-alias-label-dimmed);
}
.input:disabled {
opacity: 0.6;
cursor: default;
}
/* Select variant of .input: replaces the OS arrow (which sits flush against
the right edge) with the shared 12px chevron inset like the composer's
.select chips; the right pad reserves its cell. */
@@ -288,6 +539,7 @@
.error {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
@@ -303,3 +555,20 @@
.deleteConfirm:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* Icon-button label seat: named for assistive tech and for the tests that
query these controls by their text. */
.hiddenLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.customizedSummary::before {
transition: none;
}
}

View File

@@ -5,19 +5,23 @@
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families, plus `reasoningEffort` for deepseek / `reasoning` for
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as
* minimal `settings.mutate` path ops against the stored section — the card
* reads the redacted descriptor, so it names only the fields it can see and a
* stored literal secret is never collaterally removed.
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
* DeepSeek's id/name/context-window model catalog). Everything else stays
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
* path ops against the stored section — the card reads the redacted
* descriptor, so it names only the fields it can see and a stored literal
* secret is never collaterally removed.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client'
import {
deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '@deepseek-ai/dsh-client-schema-form'
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -179,6 +183,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
&& stringAt(fallback, 'apiKeyEnv') === undefined
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
if (layout === 'deepseek') {
const modelFailure = validateDeepSeekModels(getPath(next, ['models']))
if (modelFailure !== undefined) {
return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
}
}
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
if (node !== undefined && settingsPath.length === 0) {
const sectionError = validateDraft(node, next)
@@ -229,6 +239,18 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const keyLocked = keyState?.writable === false
/**
* The catalog beneath the user layer: what the composition entry pinned, or
* else the schema default that `resolve` would supply. The effective value
* cannot answer this — it still carries the stored override until the unset
* is applied, so reading it would echo that override straight back the
* moment reset drops it, leaving the rows unchanged until a reload.
*/
const inheritedModels = (): unknown => {
const pinned = getPath(namespace.base, [...settingsPath, 'models'])
return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default
}
/**
* The curated fields of one known adapter family. Taking the narrowed
* family as a parameter is what makes `EFFORT_FIELD` total here: an
@@ -236,6 +258,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
*/
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
const effortField = EFFORT_FIELD[family]
const customModels = getPath(draft, ['models'])
const modelsOverridden = hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
return (
<>
<div className={styles['field']}>
@@ -289,6 +316,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
))}
</select>
</div>
{family === 'deepseek'
? (
<DeepSeekModelsEditor
models={models}
overridden={modelsOverridden}
defaultContextWindow={typeof defaultContextWindow === 'number'
? defaultContextWindow
: undefined}
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
t={t}
disabled={disabled}
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
onReset={() => { setDraft(current => deletePath(current, ['models'])) }}
/>
)
: null}
</div>
</details>
</>

View File

@@ -30,6 +30,27 @@ export const en = {
baseUrlDefault: 'Provider default',
effort: 'Reasoning effort',
effortInherit: 'Default',
models: 'Models',
modelsInherited: 'Using the adapter defaults',
modelsCustomized: 'Customized model catalog',
resetModels: 'Restore defaults',
model: 'Model',
modelId: 'Model ID',
modelName: 'Display name',
modelNamePlaceholder: 'Uses the model ID when empty',
contextWindow: 'Context window',
contextWindowPlaceholder: 'Uses the provider default',
maxTokens: 'Max output tokens',
maxTokensPlaceholder: 'Uses the provider default',
modelAdvanced: 'Capacities',
addModel: 'Add model',
removeModel: 'Delete model',
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
modelIdRequired: 'Model ID is required.',
modelIdDuplicate: 'Model ID must be unique.',
modelNameInvalid: 'Display name cannot be empty.',
modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.',
modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.',
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
onboardingTitle: 'Add an API key to get started',
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
@@ -70,6 +91,27 @@ export const zh: typeof en = {
baseUrlDefault: '提供方默认',
effort: '推理强度',
effortInherit: '默认',
models: '模型目录',
modelsInherited: '正在使用适配器默认模型',
modelsCustomized: '已自定义模型目录',
resetModels: '恢复默认模型',
model: '模型',
modelId: '模型 ID',
modelName: '显示名称',
modelNamePlaceholder: '留空时使用模型 ID',
contextWindow: '上下文窗口',
contextWindowPlaceholder: '使用提供方默认值',
maxTokens: '最大输出 token 数',
maxTokensPlaceholder: '使用提供方默认值',
modelAdvanced: '容量',
addModel: '添加模型',
removeModel: '删除模型',
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
modelIdRequired: '模型 ID 不能为空。',
modelIdDuplicate: '模型 ID 不能重复。',
modelNameInvalid: '显示名称不能为空。',
modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。',
modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',

View File

@@ -8,6 +8,9 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
import { pathOps } from '../src/client/ProviderEditor.tsx'
import {
DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
} from '../src/client/DeepSeekModelsEditor.tsx'
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
import type { ProviderRow } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
@@ -16,6 +19,16 @@ afterEach(cleanup)
const t: ModelsSectionInjected['t'] = key => en[key]
/** Open one row's capacity disclosure (1-based, as the labels read). */
function expandRow(position: number): void {
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${String(position)}`))
}
/** The capacity inputs of every open row, in row order. */
function capacityInputs(label: string): HTMLInputElement[] {
return screen.getAllByLabelText<HTMLInputElement>(new RegExp(label))
}
const PiAiConfig = Schema.object({
token: Schema.string().role('secret'),
providers: Schema.dict(Schema.object({
@@ -32,15 +45,54 @@ const DeepSeekConfig = Schema.object({
apiKeyEnv: Schema.string().role('credential-ref'),
baseURL: Schema.string().pattern(/^https:\/\//),
reasoningEffort: Schema.union(['off', 'high', 'max']),
defaultContextWindow: Schema.number().step(1).min(1),
models: Schema.array(Schema.object({
id: Schema.string().required(),
name: Schema.string(),
description: Schema.string(),
contextWindow: Schema.number().step(1).min(1),
// The adapter declares its catalog as a schema default rather than a
// composition entry, which is what the restore-defaults path has to read.
})).default([
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
description: '',
contextWindow: 1_000_000,
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
description: '',
contextWindow: 1_000_000,
},
]),
})
const DEFAULT_DEEPSEEK_MODELS = [
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
description: 'Preserved hidden detail',
contextWindow: 1_000_000,
},
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 },
]
function wireNamespaces(): SettingsNamespaceView[] {
return [
{
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' },
base: {},
value: {
apiKeyEnv: 'DEEPSEEK_API_KEY',
baseURL: 'https://base',
reasoningEffort: 'high',
defaultContextWindow: 1_000_000,
maxTokens: 256_000,
models: DEFAULT_DEEPSEEK_MODELS,
},
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
user: { reasoningEffort: 'high' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
@@ -104,7 +156,7 @@ function scriptedFace(overrides: {
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
},
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))),
update,
replace,
mutate,
@@ -244,6 +296,388 @@ describe('ModelsSection', () => {
})
})
it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
.toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
fireEvent.click(screen.getByText(en.addModel))
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
const names = screen.getAllByLabelText(new RegExp(en.modelName))
expandRow(3)
fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } })
fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } })
// Only row 3 is open, so its capacity is addressed by its own label.
fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
op: 'set',
path: ['models'],
value: [
...DEFAULT_DEEPSEEK_MODELS,
{ id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
],
}],
expectedRevision: 0,
})
})
it('rejects duplicate DeepSeek model ids before writing', async () => {
const { mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
fireEvent.click(screen.getByText(en.addModel))
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(`Model 3: ${en.modelIdDuplicate}`)
expect(mutate).not.toHaveBeenCalled()
})
it('validates every adapter-owned model catalog invariant', () => {
expect(modelDrafts(undefined)).toEqual([])
expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }])
expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' })
expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }]))
.toEqual({ index: 1, key: 'modelIdDuplicate' })
expect(validateDeepSeekModels([{ id: 'model', name: '' }]))
.toEqual({ index: 0, key: 'modelNameInvalid' })
expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }]))
.toEqual({ index: 0, key: 'modelContextInvalid' })
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }]))
.toEqual({ index: 0, key: 'modelContextInvalid' })
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }]))
.toEqual({ index: 0, key: 'modelContextInvalid' })
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined()
expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }]))
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }]))
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }]))
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined()
})
it('reads context windows written as counts, thousands, or millions', () => {
expect(parseCapacity('')).toBeUndefined()
expect(parseCapacity(' ')).toBeUndefined()
expect(parseCapacity('131072')).toBe(131_072)
expect(parseCapacity(' 256K ')).toBe(256_000)
expect(parseCapacity('256k')).toBe(256_000)
expect(parseCapacity('1M')).toBe(1_000_000)
expect(parseCapacity('1m')).toBe(1_000_000)
// 1M is 1000K, not 1024K: capacities are quoted in decimal.
expect(parseCapacity('1M')).toBe(parseCapacity('1000K'))
// 2.3 * 1e6 is a few ULPs high in binary floating point; an integral
// intent must not become a fractional count the validator rejects.
expect(parseCapacity('2.3M')).toBe(2_300_000)
expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true)
// A genuinely fractional count survives as one, for the validator to reject.
expect(parseCapacity('0.0001K')).toBeCloseTo(0.1)
expect(parseCapacity('abc')).toBeNaN()
expect(parseCapacity('1G')).toBeNaN()
expect(parseCapacity('1M1')).toBeNaN()
})
it('spells a stored count in the shortest form that round-trips', () => {
expect(formatCapacity(1_000_000)).toBe('1M')
expect(formatCapacity(256_000)).toBe('256K')
expect(formatCapacity(1_500_000)).toBe('1500K')
expect(formatCapacity(131_072)).toBe('131072')
// Values the validator will reject are shown as-is rather than dressed up.
expect(formatCapacity(Number.NaN)).toBe('NaN')
expect(formatCapacity(0)).toBe('0')
for (const text of ['1M', '256K', '131072', '1500K']) {
expect(formatCapacity(parseCapacity(text) as number)).toBe(text)
}
})
it('accepts a suffixed context window and stores the plain count', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
const windows = capacityInputs(en.contextWindow)
// The inherited 1000000 reads back short.
expect((windows[0] as HTMLInputElement).value).toBe('1M')
// Keystrokes stay verbatim while the row has focus, so typing `1000` does
// not rewrite itself to `1K` mid-word.
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } })
expect((windows[0] as HTMLInputElement).value).toBe('1000')
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } })
expect((windows[0] as HTMLInputElement).value).toBe('1000K')
// Blur settles the row to the canonical spelling of the same count.
fireEvent.blur(windows[0] as HTMLInputElement)
expect((windows[0] as HTMLInputElement).value).toBe('1M')
fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } })
fireEvent.blur(windows[1] as HTMLInputElement)
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
op: 'set',
path: ['models'],
value: [
{ ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 },
{ ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
],
}],
expectedRevision: 0,
})
})
it('keeps unreadable context-window text on screen and refuses the write', async () => {
const { mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
const windows = capacityInputs(en.contextWindow)
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } })
// Blurring a row that is not the edited one leaves the buffer alone.
fireEvent.blur(windows[1] as HTMLInputElement)
fireEvent.blur(windows[0] as HTMLInputElement)
// The text the user typed is still there to correct.
expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion')
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(`Model 1: ${en.modelContextInvalid}`)
expect(mutate).not.toHaveBeenCalled()
})
it.each([
['the schema default', undefined],
['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }],
])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => {
// The regression: reset read the EFFECTIVE value, which still carries the
// stored override until the unset is applied — so the rows did not change
// and the catalog only looked restored after reopening the card.
const { face } = scriptedFace()
const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
const overridden: SettingsNamespaceView = {
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
value: { ...stored, defaultContextWindow: 1_000_000 },
...base === undefined ? {} : { base },
user: stored,
applies: 'live',
secrets: [],
revision: 0,
}
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
render(<ProviderEditor
provider="deepseek-official"
displayName="DeepSeek"
namespace={overridden}
settingsPath={[]}
api={face as never}
t={t}
readOnly={false}
onClose={() => {}}
/>)
fireEvent.click(screen.getByText(en.customized))
expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
.toEqual(['user-only-model'])
fireEvent.click(screen.getByText(en.resetModels))
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
.toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment'])
})
it('keeps every row\'s unreadable text, not just the last one edited', async () => {
// The regression: one active buffer meant editing a second row displaced
// the first, which then fell back to rendering its stored NaN as `NaN` —
// losing the text the user was told they could still correct.
await mountSection()
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
const windows = capacityInputs(en.contextWindow)
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } })
fireEvent.blur(windows[0] as HTMLInputElement)
fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } })
expect((windows[0] as HTMLInputElement).value).toBe('not a number')
expect((windows[1] as HTMLInputElement).value).toBe('2M')
})
it('re-keys the typed text around a removed row', async () => {
await mountSection()
fireEvent.click(screen.getByText(en.customized))
const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
const removeRow = (at: number): void => {
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement)
}
// Three rows, with text parked on the outer two.
fireEvent.click(screen.getByText(en.addModel))
expandRow(1)
expandRow(2)
expandRow(3)
fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } })
fireEvent.blur(windows()[0] as HTMLInputElement)
fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } })
fireEvent.blur(windows()[2] as HTMLInputElement)
// Dropping the middle row leaves the row above untouched and carries the
// row below down with its own text, rather than stranding it.
removeRow(1)
expect(windows()).toHaveLength(2)
expect((windows()[0] as HTMLInputElement).value).toBe('top text')
expect((windows()[1] as HTMLInputElement).value).toBe('bottom text')
// Dropping a row that holds text takes that text with it; the survivor
// keeps its own rather than inheriting the deleted row's.
removeRow(0)
expect(windows()).toHaveLength(1)
expect((windows()[0] as HTMLInputElement).value).toBe('bottom text')
})
it('drops the typed text when reset replaces the rows it annotated', async () => {
// The regression: reset removed the override but left the buffer, so an
// inherited row displayed text no settings layer stores — and because an
// unreadable buffer never settles, it stayed there indefinitely.
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
const windows = capacityInputs(en.contextWindow)
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } })
fireEvent.blur(windows[0] as HTMLInputElement)
fireEvent.click(screen.getByText(en.resetModels))
// Reset collapses every row, so the restored capacity needs opening again.
expandRow(1)
const restored = capacityInputs(en.contextWindow)
expect((restored[0] as HTMLInputElement).value).toBe('1M')
// Reset put the draft back where it started, so Apply writes nothing at
// all rather than persisting whatever the stale text had parsed to.
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
expect(mutate).not.toHaveBeenCalled()
})
it('edits an output cap per model and carries its text across a removal', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
// The profile's own cap is the placeholder both rows inherit.
expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K'])
fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } })
fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`))
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 2`).value).toBe('64K')
// Dropping the row above carries the cap text down with its own row.
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('64K')
// The disclosure closes on a second press.
expandRow(1)
expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull()
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
op: 'set',
path: ['models'],
value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }],
}],
expectedRevision: 0,
})
})
it('settles a pasted id and refuses whitespace that would never match', async () => {
await mountSection()
fireEvent.click(screen.getByText(en.customized))
const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
fireEvent.blur(ids[0] as HTMLInputElement)
expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
// A settled id needs no second trim.
fireEvent.blur(ids[0] as HTMLInputElement)
expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
// An id that is only whitespace is as absent as an empty one, and a padded
// id no longer slips past the duplicate check against its own twin.
expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' })
expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }]))
.toEqual({ index: 1, key: 'modelIdDuplicate' })
})
it('renders malformed draft fallbacks without inventing catalog values', () => {
render(<DeepSeekModelsEditor
models={[{}]}
overridden={false}
defaultContextWindow={undefined}
defaultMaxTokens={undefined}
t={t}
disabled={true}
onChange={vi.fn()}
onReset={vi.fn()}
/>)
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
expandRow(1)
expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder)
.toBe(en.contextWindowPlaceholder)
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).placeholder)
.toBe(en.maxTokensPlaceholder)
})
it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel)))
expect(screen.getByText(en.modelsEmpty)).toBeTruthy()
fireEvent.click(screen.getByText(en.resetModels))
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
const names = screen.getAllByLabelText(new RegExp(en.modelName))
expandRow(1)
const windows = capacityInputs(en.contextWindow)
fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } })
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
op: 'set',
path: ['models'],
value: [
{ id: 'deepseek-v4-flash', description: 'Preserved hidden detail' },
DEFAULT_DEEPSEEK_MODELS[1],
],
}],
expectedRevision: 0,
})
})
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
// The data-loss shape: the old path rebuilt the section from the REDACTED
// user layer and replaced it wholesale, deleting any stored literal key.
@@ -541,6 +975,7 @@ describe('ModelsSection', () => {
const { face } = await mountSection()
face.settings.describe.mockImplementation(() => Promise.resolve(ok({
writable: false,
hasDocument: false,
namespaces: wireNamespaces(),
})))
const controller = new ModelsSettingsStore(face as unknown as WireFace)

View File

@@ -51,7 +51,7 @@ function api(overrides: {
models: () => Promise.resolve(ok({ groups: [], failures: [] })),
},
settings: {
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))),
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
update: () => Promise.resolve(fail('unused')),
replace: () => Promise.resolve(fail('unused')),
},
@@ -135,6 +135,7 @@ describe('ModelsSettingsStore', () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{
...NAMESPACES[0],
secrets: [
@@ -195,6 +196,7 @@ describe('edge joins', () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{
ns: 'llm-pi-ai',
schema: {},
@@ -221,6 +223,7 @@ describe('edge joins', () => {
const { face, seenRefs } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
})),
providers: () => Promise.resolve(ok({

View File

@@ -3,11 +3,36 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
const tokens = readFileSync(
fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.css', import.meta.url)),
'utf8',
)
/** The declarations of one top-level rule, by selector. */
function block(selector: string): string {
const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css)
if (match === null) throw new Error(`ModelsSection.module.css has no \`${selector}\` rule`)
return match[1] ?? ''
}
describe('ModelsSection theme styles', () => {
it('uses the shared theme tokens without light-only fallbacks', () => {
it('names only theme variables the token sheet defines', () => {
// A `--dsw-*` name the sheet never declares is not a near miss: it silently
// resolves to whatever literal sits in its fallback slot, which is how this
// section stayed light under the dark theme before. Undeclared names have
// no fallback at all and inherit, so both spellings must fail here.
const named = [...css.matchAll(/var\((--dsw-[a-z0-9-]+)/g)].map(match => match[1])
const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`))
expect(undeclared).toEqual([])
expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
expect(css).toContain('background: var(--dsw-alias-bg-layer-3)')
expect(css).toContain('color: var(--dsw-alias-label-primary)')
})
it('separates the row card from the editor it expands into', () => {
// `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800
// under the dark theme, so filling the row with either erases the nested
// editor's boundary. The row is outlined; the fill is the editor's alone.
expect(block('.editor')).toContain('background: var(--dsw-alias-bg-module-platform)')
expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)')
expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
})
})

View File

@@ -48,7 +48,7 @@ async function bench() {
settings: {
describe: () => Promise.resolve({
rpcId: 'describe',
result: { ok: true as const, value: { writable: true, namespaces: [] } },
result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
}),
mutate: () => Promise.reject(new Error('settings mutation is not exercised')),
},

View File

@@ -60,7 +60,7 @@ describe('PermissionRow', () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1))))
const controller = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
} as never,
})
@@ -87,7 +87,7 @@ describe('PermissionRow', () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
const controller = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
} as never,
})
@@ -111,7 +111,7 @@ describe('PermissionRow', () => {
it('hides an unavailable namespace and disables a read-only provider', async () => {
const absent = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
mutate: vi.fn(),
} as never,
})
@@ -121,7 +121,7 @@ describe('PermissionRow', () => {
const readonly = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })),
mutate: vi.fn(),
} as never,
})
@@ -148,7 +148,7 @@ describe('PermissionRow', () => {
})
mount(controller)
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
describe.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
const button = await screen.findByRole('button', { name: 'Read Only' })
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))

View File

@@ -88,6 +88,7 @@ describe('permission settings store', () => {
it('loads and writes defaultPreset with optimistic concurrency', async () => {
const describe = vi.fn(() => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [view('read-only', 4)],
})))
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5))))
@@ -115,7 +116,7 @@ describe('permission settings store', () => {
})
it('hides the row when the namespace is absent and contains write failures', async () => {
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] })))
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })))
const controller = new PermissionSettingsController({
settings: { describe, mutate: vi.fn() } as never,
})
@@ -124,7 +125,7 @@ describe('permission settings store', () => {
const failing = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate: () => Promise.resolve({
rpcId: 'test',
result: {
@@ -146,14 +147,14 @@ describe('permission settings store', () => {
}>>>()
const describe = vi.fn()
.mockImplementationOnce(() => first.promise)
.mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] }))
.mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] }))
const mutate = vi.fn()
const controller = new PermissionSettingsController({
settings: { describe, mutate } as never,
})
const stale = controller.load()
await controller.load()
first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] }))
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] }))
await stale
expect(controller.store.getSnapshot()).toMatchObject({
currentValue: 'read-only',
@@ -200,7 +201,7 @@ describe('permission settings store', () => {
expect(describe).not.toHaveBeenCalled()
const loading = idle.load()
idle.dispose()
read.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
await loading
expect(idle.store.getSnapshot().status).toBe('loading')
@@ -220,6 +221,7 @@ describe('permission settings store', () => {
const mutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
const activeDescribe = vi.fn(() => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [view('read-only')],
})))
const active = new PermissionSettingsController({
@@ -240,7 +242,7 @@ describe('permission settings store', () => {
const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
const disposedWrite = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate: () => rejectedMutation.promise,
} as never,
})

View File

@@ -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-primitives/README.md
README.md: 00e9560f43c83e1edc61c185a4fc562c6c923e8b
README.zh.md: 21226ab211106b7722139828762605cb71a4b498
README.md: d4e6c2508f07f4832f2e12a836c2d447b656421e
README.zh.md: 76dfbebd5e9db494b49d65a2528977b7ac9fed15

View File

@@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Terminal output
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
## Read rendering
@@ -22,7 +22,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Diff rendering
`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md).
`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md).
## Search results
@@ -44,6 +44,6 @@ None; this package neither assembles nor sends a provider request.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
- **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error.
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source-list and fetch truncation notes and its empty-search note stay inline Chinese, pending the same label-prop treatment.
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.

View File

@@ -14,7 +14,7 @@
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`因此重复空格、制表符与缩进续行都原样呈现同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循光标按终端列推进8 列制表位emoji 与 CJK 占两列组合标记不占列SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`因此重复空格、制表符与缩进续行都原样呈现同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循光标按终端列推进8 列制表位emoji 与 CJK 占两列组合标记不占列SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16时折叠为头部切片加尾部切片由展开按钮控制。原理[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
## Read 渲染
@@ -22,7 +22,7 @@
## Diff 渲染
`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `error token在新增行`+ `success token之上、同文件第二个 hunk 前一个 `⋯` gap以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16`TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap使多文件复制保持可归属并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock``+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。
`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `error token在新增行`+ `success token之上、同文件第二个 hunk 前一个 `⋯` gap以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16`TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap使多文件复制保持可归属并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。
## 搜索结果

Some files were not shown because too many files have changed in this diff Show More