Revert "Merge pull request #2608 from deepseek-harness/fix/permission-copy-and-default"

This reverts commit d51f4106a2b0669d33e0f5dc1d5dcf21a764d313, reversing
changes made to 69ace51625b6ac665b2f2ec1e81005d5b055f152.
This commit is contained in:
imccyu
2026-08-21 16:21:13 +08:00
parent 528c682e06
commit 7ce85283b5
66 changed files with 218 additions and 751 deletions
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md
2026-08-17-blank-permission-default-refresh.md: 6e54d030eed4523974c9ca55ff8a621e107704a9
2026-08-17-blank-permission-default-refresh.zh.md: 171395ade710082029f874da127b8934406ce6ac
@@ -1,29 +0,0 @@
# Agent Note: Refresh blank session permission defaults
Status: implemented
English | [中文](2026-08-17-blank-permission-default-refresh.zh.md)
## Problem
The Web New Session flow reuses a workspace's blank session instead of minting another hidden placeholder. Permission defaults are pinned into a session at creation time, so changing the General settings permission row after a blank placeholder already existed left that placeholder on the previous preset. The next "new" conversation could therefore reuse a blank session whose permission chip contradicted the newly saved default.
## Decision
The Web workspace runtime owns candidate selection: a reusable session must be blank, belong to the selected Workspace, match its canonical cwd, and not be archived. Instead of returning that id directly, `WorkspaceRuntime.connectWorkspace` explicitly adopts it through `session.create` with `reuseWorkspaceBlank: true`. The host rechecks blankness, Workspace membership, cwd, and archive state before notification, and can resume a cold persisted placeholder before notifying optional default owners about the exact eligible session.
`dsh-permission-presets` records each `permission/preset` origin as `default`, `selection`, or `inferred`. On confirmed reuse, it advances the session to the current `defaultPreset` only when no turn has started, the latest selection is default-origin, and the effective sandbox and approval knobs still match that selection. Explicit picks, inferred or origin-less legacy selections, and independently changed knobs remain pinned. The update goes through the normal preset writer, so durable `permission/preset`, `sandbox/mode`, and `approval/policy` facts remain the source for projections and execution.
This partially refines the earlier [permission default for new sessions](../feature/2026-07-31-permission-default-for-new-sessions.md) decision: a settings write alone does not mutate an existing session, while the later confirmed reuse of a default-origin Workspace blank may advance it after live or cold adoption.
## Alternatives considered
**Disable blank-session reuse after any permission settings change.** Rejected because it would leave extra hidden placeholders and make New Session less deterministic. The existing reuse policy is valuable; only stale permission defaults were wrong.
**Have the client compare a blank session's permission projection with the Settings row.** Rejected because the workspace runtime would need to understand the permission settings namespace. The client reports only its reuse decision; the permission service owns the default-origin test and update.
**Scan every live blank session when Settings changes.** Rejected because the live store omits cold persisted placeholders and includes blank sessions that Web cannot reuse, such as archived or non-member sessions. It also cannot distinguish an old default from an explicit selection after restart without a durable origin.
## Consequences
A Settings change does not rewrite an existing session. Confirmed New Session reuse may append permission facts to a live or cold default-origin placeholder, which remains blank because blankness is defined by the absence of `turn/start`. Started conversations, ordinary seeded resumes, explicit selections, and sessions outside the Web reuse decision keep their permission.
@@ -1,29 +0,0 @@
# Agent Note: Refresh blank session permission defaults
Status: implemented
[English](2026-08-17-blank-permission-default-refresh.md) | 中文
## Problem
Web 新会话流程会复用工作区中的空白会话,而不是不断创建隐藏占位会话。权限默认值在会话创建时被固定到该会话中,因此当某个空白占位会话已经存在后,用户再修改「通用设置」里的权限默认值,这个占位会话仍会保留旧预设。下一次“新”对话复用它时,权限 chip 就会和刚保存的默认设置不一致。
## Decision
Web workspace runtime 负责选择候选会话:可复用会话必须保持空白、属于所选 Workspace、匹配其规范 cwd,并且未归档。`WorkspaceRuntime.connectWorkspace` 不再直接返回该 id,而是通过带 `reuseWorkspaceBlank: true``session.create` 显式接纳它。host 会在通知前重新检查空白状态、Workspace 成员关系、cwd 与归档状态,并且可以先恢复冷存储中的持久占位会话,再向可选的默认值所有者通知确实符合资格的会话。
`dsh-permission-presets` 会把每条 `permission/preset` 的来源记录为 `default``selection``inferred`。复用确认后,只有在会话尚未开始轮次、最近选择来自默认值,并且有效沙箱与审批旋钮仍匹配该选择时,服务才会将它推进到当前 `defaultPreset`。显式选择、由旧旋钮推断或没有来源标记的旧选择,以及独立变更的旋钮都会保持固定。更新仍走常规 preset writer,因此持久的 `permission/preset``sandbox/mode``approval/policy` 事实继续作为投影和执行的来源。
这项修复部分细化了较早的[新会话权限默认值](../feature/2026-07-31-permission-default-for-new-sessions.zh.md)决策:单独写入设置不会改变既有会话,而 Web 之后确认复用、且权限来自默认值的 Workspace 空白会话可以在 live 或冷接纳后推进。
## Alternatives considered
**权限设置变化后禁用空白会话复用。** 拒绝,因为这会留下额外的隐藏占位会话,并让新会话行为更不确定。既有复用策略有价值;错误只在于权限默认值过期。
**让客户端比较空白会话的权限投影和 Settings 行。** 拒绝,因为 workspace runtime 需要理解 permission settings namespace。客户端只报告自己的复用决定;权限服务拥有默认来源检查和更新。
**Settings 变化时扫描所有 live 空白会话。** 拒绝,因为 live store 会漏掉冷存储中的持久占位会话,同时包含 Web 无法复用的空白会话,例如已归档或不属于 Workspace 的会话;重启后若没有持久来源,也无法区分旧默认值与显式选择。
## Consequences
Settings 变更不会改写既有会话。确认的新会话复用可能向 live 或冷存储中、权限来自默认值的占位会话追加权限事实;该会话仍保持 blank,因为 blankness 由是否缺少 `turn/start` 定义。已经开始的对话、普通 seed 恢复、显式选择,以及不在 Web 复用决定中的会话都会保留原权限。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
2026-07-31-gui-full-access-confirmation.md: 5822ae26d36329b865a05c59dd672daa55fb6224
2026-07-31-gui-full-access-confirmation.zh.md: 612300dccaf8aa5d75b223767350f769037ea0f1
2026-07-31-gui-full-access-confirmation.md: f63502cd3e2306f36b136e6ed8543641449c3d83
2026-07-31-gui-full-access-confirmation.zh.md: f4b3686d1e1ad9e51a08e513a7dd5930d311582d
@@ -10,13 +10,13 @@ Switching the web client to `danger-full-access` was a single click on a permiss
## Decision
**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the locale product label for full access; every dismissal path submits nothing.**
**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
- The `/permission` popup (ui-permission over the ui-commands shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
- The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
- The full-access product label overrides the kebab-to-title display transform when the host keeps the canonical built-in name; an explicit host label wins. Command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English. The same conditional localization applies to the safer shipped presets ([blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md)).
- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
## Alternatives considered
@@ -10,13 +10,13 @@ Status: implemented
## 决策
**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以完全权限的本地化产品标签展示;所有取消路径都不作任何提交。**
**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
- `RiskConfirmation`ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` slot,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
- composer chipui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale slot 以 `access.confirm.*` 键供给。
- `/permission` popupui-permission 构建于 ui-commands 外壳之上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`
- 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
- 当 host 保留内置预设的规范名称时,完全权限产品标签会覆盖 kebab 转 Title Case 的显示变换;显式 host 标签优先。命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。同一个条件式本地化规则也适用于更安全的随附预设([空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.zh.md))。
- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
## 考虑过的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
2026-07-31-permission-default-for-new-sessions.md: 818c105ce880f7b67bfd28030502bf6775e1a039
2026-07-31-permission-default-for-new-sessions.zh.md: 980b007a4d246da36446e79cd089a7c513f22c85
2026-07-31-permission-default-for-new-sessions.md: ebf7fe39712d64c18e12b9b26d86201a61ad6cfd
2026-07-31-permission-default-for-new-sessions.zh.md: c0f450c8efca8647e3058fb305724cf0a554cc8d
@@ -12,7 +12,7 @@ The Web General-settings page displayed Permission as a disabled skeleton even t
`dsh-permission-presets` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them.
The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`; the preset fact records that the selection came from the default. Those facts pin the permission selected at creation, so a later Settings change does not change started conversations. When Web later confirms a Workspace blank for New Session reuse, the host explicitly adopts that live or cold session and advances it only if its latest selection remains default-origin and effective; this refinement is recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). An ordinary seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default merely by resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows.
@@ -20,7 +20,7 @@ ApiProxy explicitly adds `permission` to its Web settings allowlist beside the c
## Consequences
Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter an existing session by itself. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. A Workspace blank may receive a new pinned triplet only when Web confirms it as the reuse target and its effective selection is still default-origin. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly.
Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly.
The assembled Web snapshot contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `workspace-write` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet.
@@ -12,7 +12,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `
`dsh-permission-presets` 拥有一个 `permission` Settings namespace,其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值,Web 客户端也能发现部署中的实际选项,而无需重复定义。
服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset``sandbox/mode``approval/policy`;preset 事实会记录该选择来自默认值。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更不会改变已经开始的对话。Web 之后把某个 Workspace 空白会话确认为新会话复用目标时,host 会显式接纳这个 live 或冷会话,并且只在其最近选择仍来自默认值且继续有效时推进;这项细化记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.zh.md)。普通的带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;仅仅恢复会话时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。
服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset``sandbox/mode``approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。
现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。
@@ -20,7 +20,7 @@ ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入
## 后果
在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但单凭该操作不会改变既有会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。只有当 Web 把某个 Workspace 空白会话确认为复用目标,并且其有效选择仍来自默认值时,该会话才可能收到新的固定三元组。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`
在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`
组装后的 Web 快照包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `workspace-write` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md
2026-07-31-workspace-write-surface-default.md: e096028e07f1e7905fef568c435ad7f3d1d19138
2026-07-31-workspace-write-surface-default.zh.md: d5b1a15ccb0acc1ebfe1df120af57e2c46d5aeb6
2026-07-31-workspace-write-surface-default.md: 2ce4d7b45ad7058768f412302eb86ac332cabfe9
2026-07-31-workspace-write-surface-default.zh.md: 40b97812eb17b5c11e422ff23cd718576c9c4364
@@ -12,7 +12,7 @@ The shipped terminal and browser surfaces exposed the same coding tools under di
[`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml) owns one sandbox and permission stack for every shipped TUI, Web, and browser-backed headless session: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission-presets`. The composition fallback is the `workspace-write` preset, which bundles `workspace-write` file effects with the `ask` approval policy. `DSH_PERMISSION_MODE` remains an explicit process override; a stored `permission.defaultPreset` remains the user preference for later sessions and outranks the fallback through the Settings seam.
A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Changing the General-settings default affects fresh sessions, while started and ordinary resumed sessions retain their logged permission. A later Web New Session action may advance a default-origin Workspace blank after confirming and adopting that exact reuse target, as recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). The browser keeps its Access picker, answerable approval cards, and risk confirmation for Full access. The TUI gains the existing `/permission` command because the shared Permission service activates its command child there.
A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Existing and resumed sessions retain their logged permission, and changing the General-settings default affects only sessions created afterward. The browser keeps its Access picker, answerable approval cards, and risk confirmation for Full access. The TUI gains the existing `/permission` command because the shared Permission service activates its command child there.
The mode governs file effects only. Sandboxed bash and filesystem mutations admit the session workspace and platform temporary roots; reads, network access, and process visibility remain outside this policy. If no platform runner can enforce a confined bash call, execution fails closed instead of falling through to an unrestricted command.
@@ -30,6 +30,6 @@ The keyless shipped-TUI pseudo-terminal smoke boots the real Loader tree, reads
## Consequences
Fresh sessions can modify the active workspace and temporary roots without extra prompts, while an attempted mutation elsewhere is denied before it reaches the target. Full access remains available by explicit selection, and browser selection retains its acknowledgement dialog. A Settings write does not rewrite logged session permissions; confirmed reuse may append a new default-origin permission triplet to the selected blank Workspace session.
Fresh sessions can modify the active workspace and temporary roots without extra prompts, while an attempted mutation elsewhere is denied before it reaches the target. Full access remains available by explicit selection, and browser selection retains its acknowledgement dialog. Stored user defaults and logged session permissions are not rewritten.
The browser-backed headless entry inherits the Web composition and therefore the same default. The TUI's missing approval answerer is a deliberate limitation of this change: automatic wider retries fail closed there instead of displaying a permission question.
@@ -12,7 +12,7 @@ Status: implemented
[`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml) 为所有已交付的 TUI、Web 以及由浏览器支撑的无头会话统一持有一套沙箱与权限栈:`dsh-sandbox-local``dsh-sandbox-policy``dsh-bash-sandbox``dsh-fs-sandbox``dsh-user-approval``dsh-permission-presets`。组合回退值为 `workspace-write` preset,其中包含 `workspace-write` 文件效果模式与 `ask` 审批策略。`DSH_PERMISSION_MODE` 仍是显式的进程级覆盖;已存储的 `permission.defaultPreset` 仍是面向后续会话的用户偏好,并通过 Settings seam 优先于该回退值。
真正的新会话会在执行前固定 `permission/preset: workspace-write``sandbox/mode: workspace-write``approval/policy: ask`更改「通用」设置中的默认值会影响新建会话,而已经开始的会话和普通恢复会话保留日志中记录的权限。Web 之后执行新会话操作时,可以在确认并接纳确切复用目标后推进权限来自默认值的 Workspace 空白会话;该规则记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.zh.md)。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。
真正的新会话会在执行前固定 `permission/preset: workspace-write``sandbox/mode: workspace-write``approval/policy: ask`现有会话和恢复会话保留日志中记录的权限,更改「通用」设置中的默认值只影响之后创建的会话。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。
该模式只管辖文件效果。受沙箱约束的 bash 与文件系统修改只允许写入会话工作区和平台临时根目录;读取、网络访问与进程可见性仍不受该策略约束。若没有平台 runner 能强制执行受限的 bash 调用,执行会以拒绝告终,不会退回不受限命令。
@@ -30,6 +30,6 @@ Status: implemented
## 后果
全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。Settings 写入不会改写会话日志中的权限;复用确认可以向选中的空白 Workspace 会话追加新的默认来源权限三元组
全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。系统不会重写已存储的用户默认值和会话日志中记录的权限
由浏览器支撑的无头入口继承 Web 组合,因此默认值相同。TUI 缺少审批应答者是本次变更的明确限制:自动请求更宽权限的重试会在那里以拒绝告终,而不会显示权限询问。
+5 -5
View File
@@ -50,13 +50,13 @@ describe('web e2e: Full access confirmation', () => {
const access = page.locator('button[aria-label^="访问模式"]').first()
await access.waitFor({ timeout: 10_000 })
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:可写入工作区')
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:Workspace Write')
await access.click()
await page.getByRole('menuitem', { name: '完全权限' }).click()
const dialog = page.getByRole('dialog', { name: '确认启用完全权限' })
await page.getByRole('menuitem', { name: 'Full access' }).click()
const dialog = page.getByRole('dialog', { name: '确认启用 Full access' })
await dialog.waitFor({ timeout: 10_000 })
const enable = dialog.getByRole('button', { name: '启用完全权限' })
const enable = dialog.getByRole('button', { name: '启用 Full access' })
expect(await enable.isDisabled()).toBe(true)
// The modal is in this page's body (not a native/new window) and escapes
@@ -69,7 +69,7 @@ describe('web e2e: Full access confirmation', () => {
expect(await enable.isEnabled()).toBe(true)
await enable.click()
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
.toBe('访问模式,当前:完全权限')
.toBe('访问模式,当前:Full access')
expect(await dialog.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+13 -13
View File
@@ -62,7 +62,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
// General is active by default; Permission, Language and Appearance are functional.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
await dialog.getByRole('button', { name: '可写入工作区' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
@@ -135,44 +135,44 @@ describe('web e2e: settings modal and General preferences', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'workspace-write', origin: 'default' })
.toEqual({ preset: 'workspace-write' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
const selector = dialog.getByRole('button', { name: '可写入工作区' })
const selector = dialog.getByRole('button', { name: 'Workspace Write' })
await selector.waitFor({ timeout: 10_000 })
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
await selector.click()
await page.getByRole('menuitem', { name: '仅可查看' }).click()
await dialog.getByRole('button', { name: '仅可查看' }).waitFor({ timeout: 10_000 })
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('permission:')
expect(document).toContain('defaultPreset: read-only')
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'workspace-write', origin: 'default' })
.toEqual({ preset: 'workspace-write' })
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
expect(created.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'read-only', origin: 'default' }],
['permission/preset', { preset: 'read-only' }],
['sandbox/mode', { mode: 'read-only' }],
['approval/policy', { policy: 'ask' }],
])
await dialog.getByRole('button', { name: '仅可查看' }).click()
await page.getByRole('menuitem', { name: '完全权限' }).click()
const confirmation = page.getByRole('dialog', { name: '确认启用完全权限' })
const enable = confirmation.getByRole('button', { name: '启用完全权限' })
await dialog.getByRole('button', { name: 'Read Only' }).click()
await page.getByRole('menuitem', { name: 'Full access' }).click()
const confirmation = page.getByRole('dialog', { name: '确认启用 Full access' })
const enable = confirmation.getByRole('button', { name: '启用 Full access' })
expect(await enable.isDisabled()).toBe(true)
await confirmation.getByRole('checkbox').click()
await enable.click()
await dialog.getByRole('button', { name: '完全权限' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'danger-full-access', origin: 'default' }],
['permission/preset', { preset: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
@@ -1,10 +1,10 @@
- dialog "确认启用完全权限":
- heading "确认启用完全权限" [level=2]
- dialog "确认启用 Full access":
- heading "确认启用 Full access" [level=2]
- button "Close":
- img
- img
- paragraph: 启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
- paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
- checkbox "我已了解风险,并愿意继续"
- text: 我已了解风险,并愿意继续
- button "取消"
- button "启用完全权限" [disabled]
- button "启用 Full access" [disabled]
@@ -22,8 +22,8 @@
- text: 标准模式
- img
- text: 权限 选择新会话的默认权限模式
- button "可写入工作区":
- text: 可写入工作区
- button "Workspace Write":
- text: Workspace Write
- img
- text: 语言
- button "中文":
+2 -2
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 docs/config-catalog.md
config-catalog.md: fa0e4caa36a3754356876b13507530de82ceb37b
config-catalog.zh.md: 8125a8a80de3f82f6135292f0b834fde2867a840
config-catalog.md: da7eae6642bf9516755cf4f68268b40b26ac2ee1
config-catalog.zh.md: bd1903f878aad3071e74d10df8c0782dbd8f3d57
+3 -4
View File
@@ -1437,9 +1437,8 @@ export interface Config {
*/
presets?: Record<string, PresetSpec>
/**
* Default for fresh sessions and eligible confirmed blank reuse. When
* omitted, the preset matching the composed sandbox and approval defaults
* is used.
* Default for new sessions. When omitted, the preset matching the composed
* sandbox and approval defaults is used.
*/
defaultPreset?: string
}
@@ -1459,7 +1458,7 @@ export interface PresetSpec {
Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md)
Source: [`packages/interaction/permission-presets/src/index.ts:168`](../packages/interaction/permission-presets/src/index.ts)
Source: [`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts)
<a id="deepseek-aidsh-persona"></a>
+3 -5
View File
@@ -1439,9 +1439,8 @@ export interface Config {
*/
presets?: Record<string, PresetSpec>
/**
* Default for fresh sessions and eligible confirmed blank reuse. When
* omitted, the preset matching the composed sandbox and approval defaults
* is used.
* Default for new sessions. When omitted, the preset matching the composed
* sandbox and approval defaults is used.
*/
defaultPreset?: string
}
@@ -1461,8 +1460,7 @@ export interface PresetSpec {
依赖:[`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md)
来源:[`packages/interaction/permission-presets/src/index.ts:168`](../packages/interaction/permission-presets/src/index.ts)
来源:[`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts)
<a id="deepseek-aidsh-persona"></a>
+2 -2
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 docs/module-graph.md
module-graph.md: f17c65854dbf349adba5ff99288676a4f7eb7402
module-graph.zh.md: 31608370c550ae7e32e1395e9f6833abf3d096c8
module-graph.md: b3d061b0b07ec830f88c9fa56fd6921b343d35cc
module-graph.zh.md: b4a748dbe0084afdd554e8295e5ba504e0cf5cea
+1 -2
View File
@@ -1055,7 +1055,6 @@ flowchart TD
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_cordis_host_runner
pkg_host_apiproxy --> pkg_invariants
pkg_host_apiproxy --> pkg_permission_presets
pkg_sdk_protocol --> pkg_invariants
pkg_sdk_protocol --> pkg_llm
pkg_sdk_protocol --> pkg_session
@@ -1630,7 +1629,7 @@ flowchart TD
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+1 -2
View File
@@ -1057,7 +1057,6 @@ flowchart TD
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_cordis_host_runner
pkg_host_apiproxy --> pkg_invariants
pkg_host_apiproxy --> pkg_permission_presets
pkg_sdk_protocol --> pkg_invariants
pkg_sdk_protocol --> pkg_llm
pkg_sdk_protocol --> pkg_session
@@ -1632,7 +1631,7 @@ flowchart TD
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+2 -2
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 docs/persistence-catalog.md
persistence-catalog.md: f8c10821e4b6daa8f10b0cff63f838ecb381125c
persistence-catalog.zh.md: 4cefec2eba0a3a3fa7f4c587236fa78e476ad9d7
persistence-catalog.md: aaedabf93db497dc3e760ea5609497151569a3a4
persistence-catalog.zh.md: c2810adb5866dafeea4b092794f241c229cd3c9e
+4 -7
View File
@@ -506,18 +506,15 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src
```ts persistence-catalog
/**
* Records the selected preset and whether it came from the session
* default, an explicit selection, or legacy-knob inference. The knob
* Records the selected preset as durable, log-only user intent. The knob
* events follow in the same turn and control execution; this event stays
* out of the model transcript and lets {@link effectivePermissionPreset}
* preserve a selection when bundles match. `origin` is optional so logs
* written before origin tracking remain readable but are never mistaken
* for refreshable defaults.
* preserve a selection when bundles match.
*/
'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' }
'permission/preset': { preset: string }
```
Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts)
Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts)
### `plan/*`
+4 -7
View File
@@ -508,18 +508,15 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
```ts persistence-catalog
/**
* Records the selected preset and whether it came from the session
* default, an explicit selection, or legacy-knob inference. The knob
* Records the selected preset as durable, log-only user intent. The knob
* events follow in the same turn and control execution; this event stays
* out of the model transcript and lets {@link effectivePermissionPreset}
* preserve a selection when bundles match. `origin` is optional so logs
* written before origin tracking remain readable but are never mistaken
* for refreshable defaults.
* preserve a selection when bundles match.
*/
'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' }
'permission/preset': { preset: string }
```
来源:[`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts)
来源:[`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts)
### `plan/*`
+2 -2
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 docs/subsystems/permission-presets.md
permission-presets.md: fd32572cc6f17c30ea5db8f0fdacdb14cec286ab
permission-presets.zh.md: 0a74303766aeebc0aa55bbdd3098f0bb764e72c2
permission-presets.md: 4afa5063f2ab107415fd6429cf0cee6a4ecdfe4f
permission-presets.zh.md: 3ec3fde572da32c3276cfcea2db2b256f55c845c
+2 -14
View File
@@ -34,9 +34,8 @@ interface Config {
*/
presets?: Record<string, PresetSpec>
/**
* Default for fresh sessions and eligible confirmed blank reuse. When
* omitted, the preset matching the composed sandbox and approval defaults
* is used.
* Default for new sessions. When omitted, the preset matching the composed
* sandbox and approval defaults is used.
*/
defaultPreset?: string
}
@@ -92,17 +91,6 @@ Owns the deployment's permission presets and their write path. Requires a confin
*/
current(events: readonly SessionEvent[]): string
/**
* Advance one blank session after the host has confirmed it as the exact
* Web New Session reuse target. Only a still-effective
* default-origin selection advances; a started session, an explicit pick,
* legacy origin-less data, or independently changed knobs remain pinned.
* This is the permission-side half of the Web candidate selection and the
* host's blankness, membership, cwd, and archive verification.
* @param session - the live session selected for Workspace blank reuse.
*/
refreshDefaultForReuse(session: Session): void
/**
* Build the whole select value for one folded knob state: every table
* option in declaration order, `custom` appended exactly while derived.
+2 -14
View File
@@ -34,9 +34,8 @@ interface Config {
*/
presets?: Record<string, PresetSpec>
/**
* Default for fresh sessions and eligible confirmed blank reuse. When
* omitted, the preset matching the composed sandbox and approval defaults
* is used.
* Default for new sessions. When omitted, the preset matching the composed
* sandbox and approval defaults is used.
*/
defaultPreset?: string
}
@@ -92,17 +91,6 @@ Owns the deployment's permission presets and their write path. Requires a confin
*/
current(events: readonly SessionEvent[]): string
/**
* Advance one blank session after the host has confirmed it as the exact
* Web New Session reuse target. Only a still-effective
* default-origin selection advances; a started session, an explicit pick,
* legacy origin-less data, or independently changed knobs remain pinned.
* This is the permission-side half of the Web candidate selection and the
* host's blankness, membership, cwd, and archive verification.
* @param session - the live session selected for Workspace blank reuse.
*/
refreshDefaultForReuse(session: Session): void
/**
* Build the whole select value for one folded knob state: every table
* option in declaration order, `custom` appended exactly while derived.
@@ -32,15 +32,11 @@ export interface SessionsPort {
/** Observable list snapshot (read face only; writes stay inside the sessions domain). */
readonly list: ObservableSnapshot<SessionsPortList>
/**
* Create or explicitly adopt a session on the host.
* @param opts - target workspace and optional confirmed blank-reuse id.
* @returns the created or adopted session id.
* Create a session on the host.
* @param opts - target workspace.
* @returns the new session id.
*/
create(opts: {
workspaceId: WorkspaceId
sessionId?: SessionId
reuseWorkspaceBlank?: true
}): Promise<SessionId>
create(opts: { workspaceId: WorkspaceId }): Promise<SessionId>
/**
* Select a session as current.
* @param id - session id (must exist in the list store).
@@ -534,18 +534,10 @@ export class SessionManager {
* @returns the create result.
*/
async create(
opts: {
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
reuseWorkspaceBlank?: true
} = {},
opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const shared = {
...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
...(opts.reuseWorkspaceBlank === undefined ? {} : { reuseWorkspaceBlank: opts.reuseWorkspaceBlank }),
}
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
const payload = opts.workspaceId !== undefined
? { workspaceId: opts.workspaceId, ...shared }
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
@@ -482,12 +482,7 @@ export class SessionRuntime implements ISessions {
* @returns the new session id.
* @throws {SessionCreateError} with the requested id.
*/
async create(opts: {
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
reuseWorkspaceBlank?: true
} = {}): Promise<SessionId> {
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
const result = await this.manager.create(opts)
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
this.projectList()
@@ -53,7 +53,7 @@ export class WorkspaceRuntime implements IWorkspaces {
readonly list: SnapshotStore<WorkspaceListState>
/** Workspace baseline and frame owner. */
private readonly manager: WorkspaceManager
/** In-flight blank-session connects keyed by workspace (reuse or create). */
/** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */
private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
/** Guards the runtime-owned one-shot initial-selection subscription. */
private initialSelectionStarted = false
@@ -76,11 +76,9 @@ export class WorkspaceRuntime implements IWorkspaces {
/**
* Resolve the session a New Session flow lands in once this Workspace is
* chosen: explicitly adopt the workspace's existing blank session when one
* is in the list mirror, else create a fresh one on the host
* (`session.create` births or resumes the full Session+Agent the client
* holds no intermediate state). The adoption tells optional default owners
* that this exact session passed the reuse checks.
* chosen: reuse the workspace's existing blank session when one is in the
* list mirror, else create a fresh one on the host (`session.create` births
* the full Session+Agent the client holds no intermediate state). The
* caller owns navigation: take the returned id to `sessions.open`.
* Resolution guarantee (both arms): the returned id is already in the list
* store and `sessions.binding(id)` resolves synchronously draft hand-off
@@ -109,13 +107,7 @@ export class WorkspaceRuntime implements IWorkspaces {
const summary = sessions.byId[id]
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
&& workspace.sessionIds.includes(summary.id)
&& !archived.includes(summary.id)) {
return this.sessions.create({
workspaceId,
sessionId: summary.id,
reuseWorkspaceBlank: true,
})
}
&& !archived.includes(summary.id)) return summary.id
}
const attempt = this.sessions.create({ workspaceId })
.finally(() => { this.connecting.delete(workspaceId) })
@@ -242,28 +242,20 @@ describe('WorkspaceRuntime', () => {
{ sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
] as never[],
}))
api.onCreate = payload => Promise.resolve(ok({
sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'),
}))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
// Hit: same workspace → the parked member blank is explicitly adopted
// after the earlier cwd-matching non-member stray is skipped.
// Hit: same workspace → the parked member blank comes back (the earlier
// cwd-matching non-member stray is skipped), no create RPC.
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
expect(api.callsOf('session.create')).toEqual([{
workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true,
}])
expect(api.callsOf('session.create')).toEqual([])
// Resolution guarantee: the id is binding-resolvable synchronously.
expect(sessions.binding(sid('s-blank'))).toBeDefined()
// Miss: beta has only a non-blank session → host create with workspaceId.
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
expect(api.callsOf('session.create')).toEqual([
{ workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true },
{ workspaceId: 'beta' },
])
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }])
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
@@ -271,11 +263,7 @@ describe('WorkspaceRuntime', () => {
// never reused, a fresh accounted session is created instead.
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
expect(api.callsOf('session.create')).toEqual([
{ workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true },
{ workspaceId: 'beta' },
{ workspaceId: 'gamma' },
])
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
// Unknown workspace fails loud instead of silently creating in nowhere.
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
@@ -296,9 +284,6 @@ describe('WorkspaceRuntime', () => {
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
}))
api.onCreate = payload => Promise.resolve(ok({
sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'),
}))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
const session = sessions.binding(sid('s-blank'))!.session
@@ -307,9 +292,7 @@ describe('WorkspaceRuntime', () => {
await Promise.resolve()
// Failure leaves blank intact, so the same session is still the reuse hit.
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
expect(api.callsOf('session.create')).toEqual([{
workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true,
}])
expect(api.callsOf('session.create')).toEqual([])
})
it('returns created Workspaces and preserves Host business errors', async () => {
@@ -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: 1c2bcf8ea1a1986f5346495404fce3dc2a276044
README.zh.md: 08bb96c7db8ac85cc0a3748cee754dae9590c15b
README.md: 96f67677f690f12929cf691ab5901ded399504d3
README.zh.md: 05fdcd7a989fb74eb9f86f11cffb4eb77d38088a
+1 -1
View File
@@ -14,7 +14,7 @@ The view ring is a slot: the strict session-body registration declares the sessi
Chat business rows are independent registry contributions rather than a closed built-in union. A client plugin declaration-merges its typed `ChatNodeDataMap` key, registers a `ConversationNodeDefinition` on `ctx.conversationEvents`, and registers the matching keyed renderer on `conversation.chat.node`; it does not modify Session folds or a central renderer switch. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) covers stable event ids, append/prepend replay, Location data, and renderer constraints.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions 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-user-questions) 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 built-in preset ids render as localized product labels only when the host keeps their canonical names, while explicit host labels remain unchanged and unknown kebab-case names render in title case. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` 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.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions 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-user-questions) 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.
The session header dispatches each current ordinary title and subagent breadcrumb through the optional session-scoped `'conversation.session.header.lineage'` seat, followed by the `'conversation.session.header.actions'` list and the independent `'conversation.session.header.utilities'` list at the right edge. Each lineage owner supplies plain breadcrumb identity and display text; the render site retains the ordinary title as fallback, and an ancestor also supplies its upward-navigation callback. Removing the occupant restores every title without affecting header actions, and optional Session utilities cannot reorder or move either group. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
+1 -1
View File
@@ -24,7 +24,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
聊天流会把每条生产方关联的重试链投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每次失败尝试的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。终态失败会在其轮次边界渲染为持久的内联状态——重试耗尽后与定格的重试行并列——展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。
审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中内置预设 id 仅在 host 保留规范名称时渲染为本地化产品标签,显式 host 标签保持原样,未知 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(当前计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` 工具行属于 [`ui-tool`](../ui-tool/README.zh.md)。
@@ -66,14 +66,11 @@ export const zh = {
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
'settings.enter.queue': '排队发送',
'settings.enter.steer': '插话发送',
'access.preset.readOnly': '仅可查看',
'access.preset.workspaceWrite': '可写入工作区',
'access.preset.fullAccess': '完全权限',
'access.confirm.title': '确认启用完全权限?',
'access.confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.title': '确认启用 Full access',
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用完全权限',
'access.confirm.enable': '启用 Full access',
'hero.headline': '探索未至之境',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
@@ -246,9 +243,6 @@ export const en = {
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
'settings.enter.queue': 'Queue',
'settings.enter.steer': 'Steer',
'access.preset.readOnly': 'Read Only',
'access.preset.workspaceWrite': 'Workspace Write',
'access.preset.fullAccess': 'Full access',
'access.confirm.title': 'Enable Full access?',
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'access.confirm.acknowledge': 'I understand the risks and want to continue',
@@ -15,14 +15,14 @@ const FULL_ACCESS = 'danger-full-access'
const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
const permissionGlyphs = new Map<string, ReactNode>([
['read-only', (
const permissionGlyphs = {
'read-only': (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
<path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
</svg>
)],
['workspace-write', (
),
'workspace-write': (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M8.08887 0.251709C8.20479 0.23085 8.32486 0.241168 8.43652 0.282959L15.0215 2.75171C15.2787 2.84819 15.4492 3.09414 15.4492 3.3689V7.0105C15.4492 7.10986 15.4441 7.2081 15.4414 7.30542C15.0285 7.07175 14.5905 6.87695 14.1309 6.73022V3.82495L8.20508 1.60327L2.2793 3.82495V7.0105C2.27936 9.7171 3.4745 11.5379 5.02734 12.7947C5.01025 12.9942 5 13.1962 5 13.4001C5.00001 13.7617 5.02722 14.1169 5.08008 14.4636C2.91555 13.0393 0.961014 10.752 0.960938 7.0105V3.3689C0.960938 3.09417 1.13146 2.84821 1.38867 2.75171L7.97461 0.282959L8.08887 0.251709Z" fill="currentColor" />
<path d="M11.3525 5.64688V6.85688H5V5.64688H11.3525Z" fill="currentColor" />
@@ -30,44 +30,35 @@ const permissionGlyphs = new Map<string, ReactNode>([
<path d="M14.6647 15.6852H10.0338C10.3878 15.3751 10.7567 15.0517 11.0772 14.7706C11.2531 14.6164 11.4144 14.4746 11.5511 14.3547H14.6647V15.6852Z" fill="currentColor" />
<path d="M8.14852 14.1308L7.33925 15.4976C7.22458 15.6912 7.42245 15.9194 7.63037 15.8333L9.09785 15.2254L15.0399 10.0719L14.0905 8.97733L8.14852 14.1308Z" fill="currentColor" />
</svg>
)],
[FULL_ACCESS, (
),
[FULL_ACCESS]: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
<path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" />
<path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" />
</svg>
)],
])
),
} as Record<string, ReactNode>
/** Glyph for a permission option value; host-configured names outside the design set get none. */
function permissionGlyph(value: string): ReactNode | undefined {
return permissionGlyphs.get(value)
return permissionGlyphs[value]
}
/**
* Display transform: built-in machine names render as locale product labels;
* non-kebab host-configured names pass through.
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` `Workspace Write`); non-kebab host-configured names
* pass through. Full access intentionally overrides the machine-name
* transform so both permission surfaces use the product label `Full access`;
* the warning body remains locale-aware.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
const BUILT_IN_PERMISSION_NAMES = new Map<string, string>([
['read-only', 'Read Only'],
['workspace-write', 'Workspace Write'],
[FULL_ACCESS, 'Full access'],
])
function permissionLabel(value: string, name: string, t: ComposerBarProps['t']): string {
const builtInName = BUILT_IN_PERMISSION_NAMES.get(value)
if (builtInName !== undefined && (name === value || name === builtInName)) {
if (value === 'read-only') return t('access.preset.readOnly')
if (value === 'workspace-write') return t('access.preset.workspaceWrite')
if (value === FULL_ACCESS) return t('access.preset.fullAccess')
}
return displayName(name)
function optionLabel(option: PermissionSelectValue['options'][number]): string {
return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
}
export interface PermissionSelectProps {
@@ -95,20 +86,13 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
const currentValue = pick ?? value.currentValue
const current = value.options.find(option => option.value === currentValue)
const currentLabel = current === undefined
? permissionLabel(currentValue, currentValue, t)
: permissionLabel(current.value, current.name, t)
const busy = pick !== null || confirmation !== null
const items: MenuEntry[] = value.options
.filter(o => o.value !== 'custom')
.map((option) => {
const icon = permissionGlyph(option.value)
return {
id: option.value,
label: permissionLabel(option.value, option.name, t),
...icon === undefined ? {} : { icon },
}
return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } }
})
const submit = (id: string): void => {
@@ -154,7 +138,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: currentLabel })}
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
@@ -162,7 +146,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
{permissionGlyph(currentValue) !== undefined && (
<span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
)}
<span className={css.triggerLabel}>{currentLabel}</span>
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
{/* Same glyph + open rotation as the sibling ModelSelect trigger. */}
<span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
<IconChevronDownOutline14 />
@@ -1506,43 +1506,24 @@ describe('command launcher chrome and control seats', () => {
}
const { view } = bench({ permissions, command })
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
// Product-label display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('仅可查看')
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Read Only')
expect([...trigger.querySelectorAll('svg')]
.every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
expect(items.map(o => o.textContent)).toEqual(['仅可查看', '可写入工作区', '完全权限'])
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(busy.textContent).toBe('可写入工作区')
expect(busy.textContent).toBe('Workspace Write')
expect(busy.disabled).toBe(true)
expect(command).toHaveBeenCalledWith('/permission workspace-write')
await act(async () => {})
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
it('the Access chip preserves host labels for built-in preset values', () => {
const permissions = {
options: [
{ value: 'read-only', name: 'Review Only' },
{ value: 'workspace-write', name: 'Project Files' },
{ value: 'danger-full-access', name: 'Operator Mode' },
{ value: 'custom-mode', name: 'custom-mode' },
{ value: '__proto__', name: '__proto__' },
],
currentValue: 'workspace-write',
}
const { view } = bench({ permissions })
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(trigger.textContent).toBe('Project Files')
fireEvent.click(trigger)
expect(view.getAllByRole('menuitem').map(item => item.textContent))
.toEqual(['Review Only', 'Project Files', 'Operator Mode', 'Custom Mode', '__proto__'])
})
it('requires explicit risk acknowledgement before submitting full access', async () => {
it('requires explicit risk acknowledgement before submitting Full access', async () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
@@ -1553,11 +1534,11 @@ describe('command launcher chrome and control seats', () => {
}
const { view } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
expect(command).not.toHaveBeenCalled()
expect(view.getByRole('dialog', { name: '确认启用完全权限' })).toBeTruthy()
const enable = view.getByRole('button', { name: '启用完全权限' }) as HTMLButtonElement
expect(view.getByRole('dialog', { name: '确认启用 Full access' })).toBeTruthy()
const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
expect(enable.disabled).toBe(true)
fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
@@ -1567,11 +1548,11 @@ describe('command launcher chrome and control seats', () => {
expect(command).toHaveBeenCalledOnce()
expect(command).toHaveBeenCalledWith('/permission danger-full-access')
expect(view.queryByRole('dialog')).toBeNull()
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('完全权限')
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
await act(async () => {})
})
it('cancels a full access selection without changing permission and resets acknowledgement', () => {
it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
@@ -1583,21 +1564,21 @@ describe('command launcher chrome and control seats', () => {
const { view } = bench({ permissions, command })
const openConfirmation = () => {
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
}
openConfirmation()
fireEvent.click(view.getByRole('checkbox'))
fireEvent.click(view.getByRole('button', { name: '取消' }))
expect(command).not.toHaveBeenCalled()
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('可写入工作区')
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
openConfirmation()
expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
expect((view.getByRole('button', { name: '启用完全权限' }) as HTMLButtonElement).disabled).toBe(true)
expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
})
it('revokes an open full access confirmation when the task locks', () => {
it('revokes an open Full access confirmation when the task locks', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
@@ -1608,14 +1589,14 @@ describe('command launcher chrome and control seats', () => {
}
const { view, session } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('checkbox'))
act(() => { session.set(snapshotOf({ removed: true })) })
expect(view.queryByRole('dialog')).toBeNull()
expect(command).not.toHaveBeenCalled()
})
it('resets an open full access confirmation when switching tasks', () => {
it('resets an open Full access confirmation when switching tasks', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
@@ -1626,7 +1607,7 @@ describe('command launcher chrome and control seats', () => {
}
const { view, props } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('checkbox'))
view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
expect(view.queryByRole('dialog')).toBeNull()
@@ -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-permission-presets/README.md
README.md: 2d2f8243f878ef6adc5d969494bdc8fb5abf7ed1
README.zh.md: 05d555d1dd30811c62b4e8ee2daa7e9242f99512
README.md: 20529e6ce8a187254774fc85cfd981e4a9115937
README.zh.md: afe7ff36e52511a182a783153879125d48cccca5
@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. The value applies to fresh sessions; when Web later confirms an existing Workspace blank as the New Session reuse target, the host refreshes a still-default-derived permission after adopting that exact live or cold session. Choosing the full-access preset requires an explicit risk acknowledgement before the row writes it.
Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. This value applies only when a later session is created; changing it does not switch the current session. Choosing Full access requires an explicit risk acknowledgement before the row writes it.
The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.commandUi.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, canonical built-in names rendered as localized product labels, explicit host labels preserved, and unknown kebab-case preset names rendered in title case. A pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row.
The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.commandUi.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write``Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row.
The `/client` exports are the plugin body (`apply`/`inject`).
@@ -19,4 +19,3 @@ No direct invalidation; the knob consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **The Settings row is Web-only** — non-Web clients may still switch the current session through `/permission`, but do not receive this browser contribution.
- **Preset descriptions come from the host** — localized built-in labels may therefore appear beside a description written in another language.
@@ -2,9 +2,9 @@
[English](README.md) | 中文
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值作用于新建会话;Web 之后把某个既有 Workspace 空白会话确认为新会话复用目标时,host 会接纳这个确切的 live 或冷会话,并刷新仍来自默认值的权限。选择完全权限预设时必须先显式确认风险,该行随后才会写入。
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。选择 Full access 时必须先显式确认风险,该行随后才会写入。
当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**`ctx.commandUi.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,内置预设的规范名称渲染为本地化产品标签,显式 host 标签保持原样,未知 kebab-case 预设名渲染为 Title Case 标签选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**`ctx.commandUi.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签`workspace-write``Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
`/client` 导出面为插件本体(`apply``inject`)。
@@ -19,4 +19,3 @@
## 已知限制与暂缓事项
- **Settings 行仅在 Web 中可用**:非 Web 客户端仍可通过 `/permission` 切换当前会话,但不会获得这项浏览器贡献。
- **预设描述来自 host**:本地化的内置标签旁边可能显示另一种语言编写的描述。
@@ -12,7 +12,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PermissionSettingsState } from './settings-store.ts'
import type { PermissionSettingsKey } from './locales.ts'
import { displayPermissionPreset, FULL_ACCESS_PRESET } from './presentation.ts'
import { FULL_ACCESS_PRESET } from './presentation.ts'
import css from './PermissionRow.module.css'
/** Registration-side business face for the host-backed preference. */
@@ -58,9 +58,8 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
if (state.status === 'unavailable') return null
const selected = state.options.find(option => option.id === state.currentValue)
const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess
const optionLabel = (option: PermissionSettingsState['options'][number]): string =>
displayPermissionPreset(option.id, option.label, t)
const label = selected !== undefined ? optionLabel(selected) : (busy ? t('loading') : t('unavailable'))
const label = selected?.label
?? (busy ? t('loading') : t('unavailable'))
const description: string = state.error ?? t('description')
return (
@@ -73,7 +72,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={state.options.map(option => ({ id: option.id, label: optionLabel(option) }))}
items={state.options.map(option => ({ id: option.id, label: option.label }))}
selectedId={state.currentValue}
onSelect={(id) => {
setOpen(false)
@@ -10,8 +10,8 @@
* write through one path and the pushed projection frame is the one
* confirmation. The Full access row carries the same explicit risk gate as
* the composer chip; the shared popup shell owns the modal mechanics.
* The General-settings row separately writes the default preset for fresh
* sessions and eligible confirmed blank reuse through the host Settings API.
* The General-settings row separately writes the default preset for sessions
* created later through the host Settings API.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -56,7 +56,7 @@ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectO
.filter(option => option.value !== 'custom')
.map(option => ({
id: option.value,
label: displayPermissionPreset(option.value, option.name, t),
label: displayPermissionPreset(option.value, option.name),
...(option.description !== undefined ? { detail: option.description } : {}),
...(option.value === value.currentValue ? { active: true } : {}),
...(option.value === FULL_ACCESS_PRESET
@@ -87,9 +87,6 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposers = [
ctx.locale.register(ACCESS_NS, 'zh', {
'preset.readOnly': accessZh['preset.readOnly'],
'preset.workspaceWrite': accessZh['preset.workspaceWrite'],
'preset.fullAccess': accessZh['preset.fullAccess'],
'confirm.title': accessZh['confirm.title'],
'confirm.description': accessZh['confirm.description'],
'confirm.acknowledge': accessZh['confirm.acknowledge'],
@@ -97,9 +94,6 @@ export function apply(ctx: ClientContext): void {
'confirm.enable': accessZh['confirm.enable'],
}),
ctx.locale.register(ACCESS_NS, 'en', {
'preset.readOnly': accessEn['preset.readOnly'],
'preset.workspaceWrite': accessEn['preset.workspaceWrite'],
'preset.fullAccess': accessEn['preset.fullAccess'],
'confirm.title': accessEn['confirm.title'],
'confirm.description': accessEn['confirm.description'],
'confirm.acknowledge': accessEn['confirm.acknowledge'],
@@ -6,14 +6,11 @@ export const zh = {
'description': '选择新会话的默认权限模式',
'loading': '加载中',
'unavailable': '不可用',
'preset.readOnly': '仅可查看',
'preset.workspaceWrite': '可写入工作区',
'preset.fullAccess': '完全权限',
'confirm.title': '确认启用完全权限?',
'confirm.description': '启用完全权限后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
'confirm.title': '确认启用 Full access',
'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用完全权限',
'confirm.enable': '启用 Full access',
} satisfies Record<string, string>
/** The settings.permission namespace key union. */
@@ -25,9 +22,6 @@ export const en = {
'description': 'Choose the default permission mode for new sessions',
'loading': 'Loading',
'unavailable': 'Unavailable',
'preset.readOnly': 'Read Only',
'preset.workspaceWrite': 'Workspace Write',
'preset.fullAccess': 'Full access',
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.',
'confirm.acknowledge': 'I understand the risks and want to continue',
@@ -37,14 +31,11 @@ export const en = {
/** Simplified Chinese dictionary for the current-session popup gate. */
export const accessZh = {
'preset.readOnly': '仅可查看',
'preset.workspaceWrite': '可写入工作区',
'preset.fullAccess': '完全权限',
'confirm.title': '确认启用完全权限?',
'confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'confirm.title': '确认启用 Full access',
'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用完全权限',
'confirm.enable': '启用 Full access',
} satisfies Record<string, string>
/** Current-session popup-gate key union. */
@@ -52,9 +43,6 @@ export type PermissionAccessKey = keyof typeof accessZh
/** English dictionary for the current-session popup gate. */
export const accessEn = {
'preset.readOnly': 'Read Only',
'preset.workspaceWrite': 'Workspace Write',
'preset.fullAccess': 'Full access',
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'confirm.acknowledge': 'I understand the risks and want to continue',
@@ -1,24 +1,6 @@
/** Machine value of the preset that requires an explicit GUI risk gate. */
export const FULL_ACCESS_PRESET = 'danger-full-access'
/** Locale dictionary key for a built-in permission preset label. */
export type PermissionPresetLabelKey =
| 'preset.readOnly'
| 'preset.workspaceWrite'
| 'preset.fullAccess'
const PRESET_LABEL_KEYS = new Map<string, PermissionPresetLabelKey>([
['read-only', 'preset.readOnly'],
['workspace-write', 'preset.workspaceWrite'],
[FULL_ACCESS_PRESET, 'preset.fullAccess'],
])
const DEFAULT_PRESET_LABELS: Record<PermissionPresetLabelKey, string> = {
'preset.readOnly': 'Read Only',
'preset.workspaceWrite': 'Workspace Write',
'preset.fullAccess': 'Full access',
}
/**
* Convert conventional kebab-case preset names into user-facing title case.
* @param name - host-supplied preset label or key.
@@ -33,17 +15,8 @@ export function displayPresetName(name: string): string {
* Render a permission preset under its product label.
* @param value - preset machine value.
* @param name - host-supplied preset name.
* @param t - optional locale dictionary lookup for built-in product labels.
* @returns the built-in product label or the conventional display name.
* @returns the Full access product label or the conventional display name.
*/
export function displayPermissionPreset(
value: string,
name: string,
t?: (key: PermissionPresetLabelKey) => string,
): string {
const key = PRESET_LABEL_KEYS.get(value)
if (key !== undefined && (name === value || name === DEFAULT_PRESET_LABELS[key])) {
return t?.(key) ?? DEFAULT_PRESET_LABELS[key]
}
return displayPresetName(name)
export function displayPermissionPreset(value: string, name: string): string {
return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name)
}
@@ -20,7 +20,7 @@ import { displayPermissionPreset } from './presentation.ts'
/** Permission's settings namespace on the host wire. */
export const PERMISSION_SETTINGS_NS = 'permission'
/** One selectable fresh-session and confirmed-reuse default. */
/** One selectable new-session default. */
export interface PermissionDefaultOption {
/** Preset key written to Settings. */
id: string
@@ -121,8 +121,7 @@ export class PermissionPresetSettingsController {
}
/**
* Persist one preset as the default for fresh sessions and eligible
* confirmed blank reuse.
* Persist one preset as the default for subsequently created sessions.
* A selection made while one is already saving is ignored the row's
* control is disabled during the save, so this only drops programmatic
* double-submits rather than user intent.
@@ -128,7 +128,7 @@ describe('ui-permission browser plugin', () => {
const again = await c.ui.options(proj, new AbortController().signal)
expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
// Built-ins use product labels; other kebab-case names title-case.
// Kebab-case names title-case; non-kebab host-configured names pass through.
expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
title: 'Enable Full access?',
@@ -137,17 +137,9 @@ describe('ui-permission browser plugin', () => {
cancelLabel: 'Cancel',
confirmLabel: 'Enable Full access',
})
b.values.set(sid('s1'), { ...SELECT, options: [
{ value: 'workspace-write', name: 'Project Files' },
{ value: 'danger-full-access', name: 'Operator Mode' },
{ value: 'custom-mode', name: 'custom-mode' },
{ value: '__proto__', name: '__proto__' },
{ value: 'plain', name: 'Ask Every Time' },
] })
b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
const passthrough = await c.ui.options(proj, new AbortController().signal)
expect(passthrough.map(option => option.label)).toEqual([
'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time',
])
expect(passthrough[0]?.label).toBe('Ask Every Time')
// A projection that vanished between availability and open throws.
expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
.toThrow(/not available on this host/)
@@ -5,9 +5,9 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
import { zh } from '../src/client/locales.ts'
import { en } from '../src/client/locales.ts'
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
const schema = new SettingsSchemaService(new Context())
@@ -47,7 +47,7 @@ function ok<T>(value: T) {
return { rpcId: 'test', result: { ok: true as const, value } }
}
const dictionary: Record<string, string> = zh
const dictionary: Record<string, string> = en
const t: PermissionRowProps['t'] = key => dictionary[key] ?? key
const runtime = {
useSessions: (() => { throw new Error('unused') }) as never,
@@ -76,7 +76,7 @@ describe('PermissionRow', () => {
},
})
mount(controller)
const button = await screen.findByRole('button', { name: '仅可查看' })
const button = await screen.findByRole('button', { name: 'Read Only' })
expect(button.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe('true')
@@ -86,15 +86,15 @@ describe('PermissionRow', () => {
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: '仅可查看' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
await screen.findByRole('button', { name: '可写入工作区' })
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
await screen.findByRole('button', { name: 'Workspace Write' })
expect(mutate).toHaveBeenCalledOnce()
})
it('requires explicit acknowledgement before saving full access', async () => {
it('requires explicit acknowledgement before saving Full access', async () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
const controller = derivedController({
settings: {
@@ -103,15 +103,15 @@ describe('PermissionRow', () => {
},
})
mount(controller)
fireEvent.click(await screen.findByRole('button', { name: '仅可查看' }))
fireEvent.click(screen.getByRole('menuitem', { name: '完全权限' }))
fireEvent.click(await screen.findByRole('button', { name: 'Read Only' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('dialog', { name: '确认启用完全权限?' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '仅可查看' }))
fireEvent.click(screen.getByRole('menuitem', { name: '完全权限' }))
const dialog = screen.getByRole('dialog', { name: '确认启用完全权限?' })
const enable = screen.getByRole('button', { name: '启用完全权限' })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Read Only' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' })
const enable = screen.getByRole('button', { name: 'Enable Full access' })
expect((enable as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(enable)
@@ -137,7 +137,7 @@ describe('PermissionRow', () => {
},
})
mount(readonly)
expect((await screen.findByRole('button', { name: '仅可查看' })).hasAttribute('disabled')).toBe(true)
expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true)
})
it('shows loading and a contained write error', async () => {
@@ -158,11 +158,11 @@ describe('PermissionRow', () => {
},
})
mount(controller)
expect((await screen.findByRole('button', { name: '加载中' })).hasAttribute('disabled')).toBe(true)
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
const button = await screen.findByRole('button', { name: '仅可查看' })
const button = await screen.findByRole('button', { name: 'Read Only' })
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere')
})
})
@@ -47,7 +47,7 @@ function permissionController(api: object) {
}
describe('permission settings store', () => {
it('derives dynamic options and preserves labels from the descriptor schema', () => {
it('derives dynamic options and host labels from the descriptor schema', () => {
expect(resolveDefault(view('read-only'))).toEqual({
currentValue: 'read-only',
options: [
@@ -1082,11 +1082,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'events', description: 'the session\'s events in log order.' }],
returns: 'the effective preset name, or `custom` when nothing matches.',
},
{
signature: 'refreshDefaultForReuse(session: Session): void',
description: 'Advance one blank session after the host has confirmed it as the exact Web New Session reuse target. Only a still-effective default-origin selection advances; a started session, an explicit pick, legacy origin-less data, or independently changed knobs remain pinned. This is the permission-side half of the Web candidate selection and the host\'s blankness, membership, cwd, and archive verification.',
parameters: [{ name: 'session', description: 'the live session selected for Workspace blank reuse.' }],
},
{
signature: 'selectFor(state: KnobState): PermissionSelect',
description: 'Build the whole select value for one folded knob state: every table option in declaration order, `custom` appended exactly while derived.',
+1 -3
View File
@@ -78,15 +78,13 @@
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^"
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
+4 -16
View File
@@ -20,9 +20,6 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
// Type-only: resolves the optional permission-default owner notified after
// the Web proposes and the Host verifies a Workspace blank reuse target.
import type {} from '@deepseek-ai/dsh-permission-presets'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
@@ -451,9 +448,7 @@ function jobViews(snapshots: readonly JobSnapshot[]): JobView[] {
* turn is one model-loop execution). Standalone plugin events command
* lifecycle records, plan/mode, titles, goals never open a turn, so
* running `/plan` or `/goal` on a fresh session keeps it blank
* (list-hidden, reusable). `session.create` combines this predicate with the
* Workspace membership and archive state before a confirmed reuse can notify
* permission-default owners; they do not maintain a second blankness rule.
* (list-hidden, reusable).
*/
function sessionBlank(session: Session): boolean {
return !session.events.some(event => event.type === 'turn/start')
@@ -2101,13 +2096,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
const requestedPreset = request.payload.agentPreset
const refreshDefaultAfterReuse = request.payload.reuseWorkspaceBlank === true
&& workspace !== undefined
&& workspace.sessionIds.includes(sessionId)
&& !ctx.workspaceRegistry.archivedSessionIds.includes(sessionId)
let adopted: Agent
try {
adopted = await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
} catch (error: unknown) {
if (error instanceof AgentPresetConflict) {
return err(request, {
@@ -2153,9 +2143,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
}
if (refreshDefaultAfterReuse && sessionBlank(adopted.session)) {
ctx.get('permissionPresets')?.refreshDefaultForReuse(adopted.session)
}
// Echo the composition the session RUNS so a client can label it
// without waiting for the next list refresh — the create is the commit
// point that knows it (a caller that named none gets the default).
@@ -2164,7 +2151,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// switched while blank runs a preset its header no longer names, so
// echoing the header would contradict both the adoption this call just
// allowed and the row `session.list` serves for the same session.
const createdPreset = resolveSessionPreset(adopted.session)
const created = ctx.agents.get(sessionId)
const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session)
return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
},
@@ -104,14 +104,9 @@ export const sessionCreateRequestSchema = z.object({
cwd: z.string().optional(),
sessionId: sessionIdSchema.optional(),
agentPreset: z.string().optional(),
reuseWorkspaceBlank: z.literal(true).optional(),
}).refine(
payload => payload.workspaceId === undefined || payload.cwd === undefined,
{ message: 'session.create accepts workspaceId or cwd, not both' },
).refine(
payload => payload.reuseWorkspaceBlank !== true
|| (payload.workspaceId !== undefined && payload.sessionId !== undefined),
{ message: 'session.create reuseWorkspaceBlank requires workspaceId and sessionId' },
) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
/** session.create response value. */
+1 -12
View File
@@ -254,11 +254,6 @@ export interface SessionsApi {
* session, while a different cwd fails with `session-conflict`. Workspace
* creation attaches the session after publication; an attach failure
* returns `workspace-attach-failed` with the published session id.
* `reuseWorkspaceBlank: true` is valid only with both `workspaceId` and an
* existing `sessionId`; it reports the Web workspace runtime's New Session
* reuse candidate. The host notifies optional session-default owners only
* while that session is still blank, belongs to the Workspace, matches its
* cwd, and is not archived.
*
* `agentPreset` names the composition the new session's agent is built
* from; omitted, the effective default applies the user's stored choice
@@ -267,13 +262,7 @@ export interface SessionsApi {
* id fails with `agent-preset-not-found`, and a preset whose composition
* cannot be mounted fails with `agent-preset-invalid`.
*/
create(request: RpcRequest<{
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
agentPreset?: string
reuseWorkspaceBlank?: true
}>):
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>):
Promise<RpcResponse<{ sessionId: SessionId; agentPreset?: string }>>
/**
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { Session } from '@deepseek-ai/dsh-session'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
@@ -214,62 +214,6 @@ describe('sessions.list cold merge', () => {
})
})
describe('session.create cold blank reuse', () => {
it('resumes the persisted target before notifying the permission-default owner', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserQuestionService)
const sessionId = sid('cold-workspace-blank')
const meta = header(sessionId, 1000)
const events = [
{ type: 'permission/preset', seq: 0, time: 1, data: { preset: 'workspace-write', origin: 'default' } },
{ type: 'sandbox/mode', seq: 1, time: 2, data: { mode: 'workspace-write' } },
{ type: 'approval/policy', seq: 2, time: 3, data: { policy: 'ask' } },
] as SessionEvent[]
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
locate: () => undefined,
} as never)
const resumedSession = Session.create(sessionId, events, meta)
const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent
const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
agent: resumedAgent,
dispose: () => Promise.resolve(),
})
const attachSession = vi.fn(() => Promise.resolve())
const workspace = {
id: 'workspace-1',
path: '/proj',
sessionIds: [sessionId],
attachSession,
}
ctx.provide('workspaceRegistry', {
get: () => workspace,
list: () => [workspace],
archivedSessionIds: [],
} as never)
const refreshDefaultForReuse = vi.fn()
ctx.provide('permissionPresets', { refreshDefaultForReuse } as never)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
const response = await api.sessions.create(request({
workspaceId: 'workspace-1' as never,
sessionId,
reuseWorkspaceBlank: true as const,
}))
expect(response.result.ok).toBe(true)
expect(resume).toHaveBeenCalledOnce()
expect(attachSession).toHaveBeenCalledWith(sessionId)
expect(refreshDefaultForReuse).toHaveBeenCalledWith(resumedSession)
})
})
describe('attached updatedAt tracks human prompts', () => {
it('ignores pickup and non-prompt work after the latest human message', async () => {
const ctx = new Context()
@@ -64,7 +64,6 @@ async function harness(
extras: {
openPath?: (path: string, signal: AbortSignal) => Promise<void>
canOpenPath?: () => boolean
refreshDefaultForReuse?: (session: Session) => void
} = {},
) {
const ctx = new Context()
@@ -103,11 +102,6 @@ async function harness(
// Structural picker fake: the gateway only reads capability(); a stable
// object per harness mirrors the seam's stability contract.
ctx.provide('directoryPicker', { capability: () => picker } as never)
if (extras.refreshDefaultForReuse !== undefined) {
ctx.provide('permissionPresets', {
refreshDefaultForReuse: extras.refreshDefaultForReuse,
} as never)
}
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd: root,
@@ -371,63 +365,6 @@ describe('workspace.insertBefore', () => {
})
describe('session creation and Workspace membership', () => {
it('notifies the permission owner only while the confirmed reuse target remains eligible', async () => {
const refreshDefaultForReuse = vi.fn<(session: Session) => void>()
const { api, ctx, root } = await harness(undefined, undefined, { refreshDefaultForReuse })
const workspace = expectOk(await api.workspace.create(request({
path: stageDir(root, 'permission-refresh'),
}))).workspace
const reusedId = SessionId('session-reused-blank')
expectOk(await api.sessions.create(request({
workspaceId: workspace.workspaceId,
sessionId: reusedId,
})))
expect(refreshDefaultForReuse).not.toHaveBeenCalled()
expectOk(await api.sessions.create(request({
workspaceId: workspace.workspaceId,
sessionId: reusedId,
reuseWorkspaceBlank: true,
})))
expect(refreshDefaultForReuse).toHaveBeenCalledOnce()
expect(refreshDefaultForReuse.mock.calls[0]?.[0].id).toBe(reusedId)
const reused = ctx.sessions.get(reusedId)
if (reused === undefined) throw new Error('reused session was not published')
reused.append('turn/start', { turn: 1 })
expectOk(await api.sessions.create(request({
workspaceId: workspace.workspaceId,
sessionId: reusedId,
reuseWorkspaceBlank: true,
})))
expect(refreshDefaultForReuse).toHaveBeenCalledOnce()
const archivedId = SessionId('session-archived-blank')
expectOk(await api.sessions.create(request({
workspaceId: workspace.workspaceId,
sessionId: archivedId,
})))
expectOk(await api.workspace.archiveSession(request({ sessionId: archivedId })))
expectOk(await api.sessions.create(request({
workspaceId: workspace.workspaceId,
sessionId: archivedId,
reuseWorkspaceBlank: true,
})))
expect(refreshDefaultForReuse).toHaveBeenCalledOnce()
const nonMemberId = SessionId('session-non-member-blank')
expectOk(await api.sessions.create(request({
cwd: workspace.path,
sessionId: nonMemberId,
})))
expectOk(await api.sessions.create(request({
workspaceId: workspace.workspaceId,
sessionId: nonMemberId,
reuseWorkspaceBlank: true,
})))
expect(refreshDefaultForReuse).toHaveBeenCalledOnce()
})
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
const { api, ctx, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
@@ -193,16 +193,8 @@ describe('sessions domain schemas', () => {
})).toThrow()
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
expect(sessionCreateRequestSchema.parse({
workspaceId: 'w1', sessionId: 's1', reuseWorkspaceBlank: true,
}).reuseWorkspaceBlank).toBe(true)
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/)
expect(() => sessionCreateRequestSchema.parse({
workspaceId: 'w1', reuseWorkspaceBlank: true,
})).toThrow(/requires workspaceId and sessionId/)
expect(() => sessionCreateRequestSchema.parse({
sessionId: 's1', reuseWorkspaceBlank: true,
})).toThrow(/requires workspaceId and sessionId/)
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
-3
View File
@@ -80,9 +80,6 @@
{
"path": "../../interaction/commands"
},
{
"path": "../../interaction/permission-presets"
},
{
"path": "../../interaction/user-approval"
},
@@ -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/interaction/permission-presets/README.md
README.md: 7e993ece949aa54d0810d8a7434d3e74d86f4797
README.zh.md: 2946f340b2023f707d4d00c9b8161a71b4d05853
README.md: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6
README.zh.md: 2475740a1c7e3240a12f1f462bab05cc7e908b38
@@ -4,17 +4,17 @@ English | [中文](README.zh.md)
User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs.
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
The service owns the `permission` Settings namespace. Its `defaultPreset` initializes fresh sessions and default-origin Workspace blanks that Web explicitly confirms for New Session reuse: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. Session creation reads the current setting and pins `permission/preset`, `sandbox/mode`, and `approval/policy`; the preset fact records whether it came from the default, an explicit selection, or legacy-knob inference. A committed setting change does not scan or rewrite existing sessions. When the Web workspace runtime selects a member blank with matching cwd that is not archived, the host rechecks those facts, adopts that exact session, including a cold persisted one, and advances it only when it has not started a turn, its latest selection is default-origin, and its effective knobs still match that selection. Explicit picks, inferred or origin-less legacy selections, independently changed knobs, and ordinary seeded resumes remain pinned. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
## Model Experience
Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only.
Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permissionPresets/preset` itself is log-only.
#### KV Cache effect
@@ -25,4 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset.
- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permissionPresets` section in `settings.yaml` is updated or reset.
@@ -4,17 +4,17 @@
通过 `ctx.permissionPresets`[`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode``approval/policy` 组成一组;默认项为 `workspace-write``workspace-write` + `ask`)和 `danger-full-access``danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
该服务拥有 `permission` Settings namespace。其 `defaultPreset` 会初始化新建会话,以及 Web 明确确认为新会话复用目标、且权限来自默认值的 Workspace 空白会话:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。创建会话时会读取当前设置,并固定 `permission/preset``sandbox/mode``approval/policy`;preset 事实还会记录它来自默认值、显式选择还是旧旋钮推断。已提交的设置变更不会扫描或改写现有会话。当 Web workspace runtime 选中 cwd 匹配、属于该 Workspace 且未归档的空白会话时,host 会重新检查这些事实,并接纳这个确切会话(包括冷存储中的持久会话),而且只在它尚未开始轮次、最近选择来自默认值且有效旋钮仍匹配该选择时推进默认值。显式选择、由旧旋钮推断或没有来源标记的旧选择、独立变更的旋钮,以及普通 seed 恢复都会继续固定原权限。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset``sandbox/mode``approval/policy` 固定到该会话中,因此后续变更不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。
两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permission` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
## 模型体验
间接地,通过 `dsh-user-approval``dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。
间接地,通过 `dsh-user-approval``dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permissionPresets/preset` 本身只写入日志。
#### KV Cache 影响
@@ -25,4 +25,4 @@
- **只组合两个机制级可调参数**:预设选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。
- **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permissionPresets` 分节。
@@ -42,15 +42,12 @@ declare module '@deepseek-ai/cordis' {
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* Records the selected preset and whether it came from the session
* default, an explicit selection, or legacy-knob inference. The knob
* Records the selected preset as durable, log-only user intent. The knob
* events follow in the same turn and control execution; this event stays
* out of the model transcript and lets {@link effectivePermissionPreset}
* preserve a selection when bundles match. `origin` is optional so logs
* written before origin tracking remain readable but are never mistaken
* for refreshable defaults.
* preserve a selection when bundles match.
*/
'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' }
'permission/preset': { preset: string }
}
}
@@ -72,7 +69,7 @@ export interface PresetSpec {
*/
export const CUSTOM_PRESET = 'custom'
/** Settings namespace carrying the default for fresh sessions and confirmed reusable blanks. */
/** Settings namespace carrying the default for future sessions. */
export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
/**
@@ -149,18 +146,9 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState {
return state
}
/** Last recorded permission selection, including its durable origin. */
function latestPermissionSelection(events: readonly SessionEvent[]): Extract<SessionEvent, { type: 'permission/preset' }> | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'permission/preset') return event
}
return undefined
}
/** User setting resolved when a new session receives its initial permission. */
export interface PermissionSettings {
/** Preset pinned into a fresh session or an eligible confirmed blank reuse. */
/** Preset pinned into a newly created session. */
defaultPreset: string
}
@@ -173,9 +161,8 @@ export interface Config {
*/
presets?: Record<string, PresetSpec>
/**
* Default for fresh sessions and eligible confirmed blank reuse. When
* omitted, the preset matching the composed sandbox and approval defaults
* is used.
* Default for new sessions. When omitted, the preset matching the composed
* sandbox and approval defaults is used.
*/
defaultPreset?: string
}
@@ -241,6 +228,8 @@ export class PermissionPresetService extends Service {
setSource: (current) => {
this.defaultSettings = current
},
// The source thunk reads the latest scope snapshot at session creation;
// no process-level registration needs replacement on change.
onChange: () => {},
})
@@ -297,9 +286,7 @@ export class PermissionPresetService extends Service {
if (!this.names.includes(name)) {
return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
}
this.apply(agent.session, name, (policy) => {
this.ctx.approval.setPolicy(agent, policy)
}, 'selection')
this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) })
return { kind: 'success', text: `preset ${name}` }
},
})
@@ -315,7 +302,7 @@ export class PermissionPresetService extends Service {
}
/**
* The preset currently selected for fresh sessions and confirmed blank reuse.
* The preset currently selected as the default for future sessions.
* @returns the resolved settings value, or the composition default without
* a mounted settings provider.
*/
@@ -334,26 +321,6 @@ export class PermissionPresetService extends Service {
return this.derive(foldKnobs(events))
}
/**
* Advance one blank session after the host has confirmed it as the exact
* Web New Session reuse target. Only a still-effective
* default-origin selection advances; a started session, an explicit pick,
* legacy origin-less data, or independently changed knobs remain pinned.
* This is the permission-side half of the Web candidate selection and the
* host's blankness, membership, cwd, and archive verification.
* @param session - the live session selected for Workspace blank reuse.
*/
refreshDefaultForReuse(session: Session): void {
const events = session.events
if (events.some(event => event.type === 'turn/start')) return
const selected = latestPermissionSelection(events)
if (selected?.data.origin !== 'default') return
if (this.current(events) !== selected.data.preset) return
this.apply(session, this.defaultPreset, (policy) => {
setApprovalPolicy(session, policy)
}, 'default')
}
/** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
private derive(state: KnobState): string {
const sandbox = state.sandbox ?? this.ctx.shell.sandboxMode
@@ -422,21 +389,14 @@ export class PermissionPresetService extends Service {
* @param name - the preset to switch to; unknown names throw.
*/
set(session: Session, name: string): void {
this.apply(session, name, (policy) => {
setApprovalPolicy(session, policy)
}, 'selection')
this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) })
}
/** Apply one preset with the caller-selected live or initialization policy writer. */
private apply(
session: Session,
name: string,
setApproval: (policy: ApprovalPolicy) => void,
origin: 'default' | 'selection' | 'inferred',
): void {
private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
const spec = this.resolve(name)
if (this.current(session.events) !== name) {
session.append('permission/preset', { preset: name, origin })
session.append('permission/preset', { preset: name })
}
const events = session.events
if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) {
@@ -462,7 +422,7 @@ export class PermissionPresetService extends Service {
if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
const name = this.defaultPreset
const spec = this.resolve(name)
session.append('permission/preset', { preset: name, origin: 'default' })
session.append('permission/preset', { preset: name })
setSandboxMode(session, spec.sandbox)
setApprovalPolicy(session, spec.approval)
return
@@ -475,7 +435,7 @@ export class PermissionPresetService extends Service {
}
const effective = this.derive(state)
if (selected === undefined && effective !== CUSTOM_PRESET) {
session.append('permission/preset', { preset: effective, origin: 'inferred' })
session.append('permission/preset', { preset: effective })
}
if (sandbox === undefined) {
setSandboxMode(session, this.ctx.shell.sandboxMode as SandboxMode)
@@ -131,7 +131,7 @@ describe('PermissionPresetService', () => {
const session = freshSession('sess-set')
ctx.permissionPresets.set(session, 'danger-full-access')
expect(session.events.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access', origin: 'selection' }],
['permission/preset', { preset: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
@@ -154,7 +154,7 @@ describe('PermissionPresetService', () => {
ctx.permissionPresets.set(session, 'danger-full-access')
const tail = session.events.slice(4)
expect(tail.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access', origin: 'selection' }],
['permission/preset', { preset: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
])
})
@@ -197,16 +197,14 @@ describe('new-session default', () => {
const ctx = await mountedStore()
const first = ctx.sessions.create(SessionId('first'))
expect(first.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'workspace-write', origin: 'default' }],
['permission/preset', { preset: 'workspace-write' }],
['sandbox/mode', { mode: 'workspace-write' }],
['approval/policy', { policy: 'ask' }],
])
first.append('turn/start', { turn: 1 })
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
ctx.permissionPresets.refreshDefaultForReuse(first)
expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access')
const second = ctx.sessions.create(SessionId('second'))
expect(ctx.permissionPresets.current(first.events)).toBe('workspace-write')
@@ -216,89 +214,6 @@ describe('new-session default', () => {
])
})
it('advances a confirmed reusable blank session that still carries its default', async () => {
const ctx = await mountedStore()
const blank = ctx.sessions.create(SessionId('blank-placeholder'))
expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write')
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write')
ctx.permissionPresets.refreshDefaultForReuse(blank)
expect(ctx.permissionPresets.current(blank.events)).toBe('danger-full-access')
expect(blank.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'workspace-write', origin: 'default' }],
['sandbox/mode', { mode: 'workspace-write' }],
['approval/policy', { policy: 'ask' }],
['permission/preset', { preset: 'danger-full-access', origin: 'default' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
})
it('leaves explicit, inferred, legacy, absent, and independently changed selections unchanged', async () => {
const ctx = await mountedStore()
const picked = ctx.sessions.create(SessionId('blank-explicit-pick'))
ctx.permissionPresets.set(picked, 'danger-full-access')
const pickedEvents = [...picked.events]
const restored = ctx.sessions.create(SessionId('blank-restored'), { seed: [] })
expect(ctx.permissionPresets.current(restored.events)).toBe('workspace-write')
const restoredEvents = [...restored.events]
const drifted = ctx.sessions.create(SessionId('blank-drifted-knob'))
drifted.append('sandbox/mode', { mode: 'read-only' })
const driftedEvents = [...drifted.events]
const legacy = freshSession('blank-originless-selection')
legacy.append('permission/preset', { preset: 'workspace-write' })
legacy.append('sandbox/mode', { mode: 'workspace-write' })
legacy.append('approval/policy', { policy: 'ask' })
const legacyEvents = [...legacy.events]
const absent = freshSession('blank-without-selection')
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'workspace-write',
})
ctx.permissionPresets.refreshDefaultForReuse(picked)
ctx.permissionPresets.refreshDefaultForReuse(restored)
ctx.permissionPresets.refreshDefaultForReuse(drifted)
ctx.permissionPresets.refreshDefaultForReuse(legacy)
ctx.permissionPresets.refreshDefaultForReuse(absent)
expect(picked.events).toEqual(pickedEvents)
expect(restored.events).toEqual(restoredEvents)
expect(drifted.events).toEqual(driftedEvents)
expect(legacy.events).toEqual(legacyEvents)
expect(absent.events).toEqual([])
})
it('refreshes a cold default-origin placeholder after resume', async () => {
const ctx = await mountedStore()
const source = ctx.sessions.create(SessionId('cold-placeholder-source'))
const stored = [...source.events]
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
const resumed = ctx.sessions.create(SessionId('cold-placeholder-resumed'), { seed: stored })
expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write')
ctx.permissionPresets.refreshDefaultForReuse(resumed)
expect(ctx.permissionPresets.current(resumed.events)).toBe('danger-full-access')
expect(resumed.events.slice(-3).map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'danger-full-access', origin: 'default' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
})
it('preserves a seeded legacy session instead of applying the latest user default', async () => {
const ctx = await mountedStore()
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
@@ -2243,7 +2243,6 @@ describe('run lifecycle and quiescence', () => {
stopReason: 'error',
})
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: codexAppServerArgv(),
env: { OPENAI_API_KEY: 'fake' },
graceMs: 25,
cwd: process.cwd(),
-3
View File
@@ -5066,9 +5066,6 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
'@deepseek-ai/dsh-permission-presets':
specifier: workspace:^
version: link:../../interaction/permission-presets
'@deepseek-ai/dsh-storage':
specifier: workspace:^
version: link:../../storage/storage