fix(web): improve permission labels and blank defaults

This commit is contained in:
Kaige-Gao
2026-08-18 21:30:28 +08:00
parent 6c16d29b24
commit b03b1f2e7b
40 changed files with 313 additions and 125 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md
2026-08-17-blank-permission-default-refresh.md: 2765462385f6a0e41bcdda8fd4a1ed532f38be2d
2026-08-17-blank-permission-default-refresh.zh.md: 8619b9f2aed01b56347af5f99439e5f356067935
@@ -0,0 +1,29 @@
# 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
`dsh-permission-presets` treats a settings change as a chance to advance reusable blank placeholders. When `defaultPreset` changes, the service scans live sessions, finds sessions that have not started a turn, and switches only those whose effective permission still equals the previous default. Sessions that have started a turn are never changed. Blank sessions the user already switched away from the previous default are also left alone.
This keeps the existing Web blank-session reuse policy intact while making the reused placeholder observe the same default a freshly created session would receive. The update goes through the normal preset setter, so the durable `permission/preset`, `sandbox/mode`, and `approval/policy` facts remain the single 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: started sessions and seeded resumes remain pinned, while unseeded blank placeholders may advance because the Web treats them as New Session reuse targets.
## 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 or add a cross-plugin hook solely for this case. The permission service already owns the default and can repair its own blank placeholders.
**Update every blank session unconditionally.** Rejected because a user may deliberately switch the current blank session's permission before sending the first prompt. Matching only the previous default updates stale placeholders without overwriting an explicit blank-session selection.
## Consequences
A settings change may append permission facts to unseeded blank sessions, but those sessions remain blank because blankness is defined by the absence of `turn/start`. Started conversations, seeded resumes, and blank sessions with an explicit user-selected preset keep their original permission.
@@ -0,0 +1,29 @@
# Agent Note: Refresh blank session permission defaults
Status: implemented
[English](2026-08-17-blank-permission-default-refresh.md) | 中文
## Problem
Web 新会话流程会复用工作区中的空白会话,而不是不断创建隐藏占位会话。权限默认值在会话创建时被固定到该会话中,因此当某个空白占位会话已经存在后,用户再修改「通用设置」里的权限默认值,这个占位会话仍会保留旧预设。下一次“新”对话复用它时,权限 chip 就会和刚保存的默认设置不一致。
## Decision
`dsh-permission-presets` 将设置变更视为推进可复用空白占位会话的时机。当 `defaultPreset` 变化时,服务会扫描 live sessions,找到尚未开始过轮次的会话,并且只切换那些有效权限仍等于旧默认值的会话。已经开始过轮次的会话绝不会被改变。用户已经在空白会话中手动切离旧默认值的会话也会保持原样。
这样既保留了既有的 Web 空白会话复用策略,也让被复用的占位会话观察到与真正新建会话相同的默认值。更新仍走常规 preset setter,因此持久的 `permission/preset``sandbox/mode``approval/policy` 事实继续作为投影和执行的单一来源。
这项修复部分细化了较早的[新会话权限默认值](../feature/2026-07-31-permission-default-for-new-sessions.md)决策:已经开始的会话和带 seed 的恢复仍保持固定,而未带 seed 的空白占位会话可以推进,因为 Web 会将它们作为新会话复用目标。
## Alternatives considered
**权限设置变化后禁用空白会话复用。** 拒绝,因为这会留下额外的隐藏占位会话,并让新会话行为更不确定。既有复用策略有价值;错误只在于权限默认值过期。
**让客户端比较空白会话的权限投影和 Settings 行。** 拒绝,因为 workspace runtime 需要理解 permission settings namespace,或为这个场景新增跨插件 hook。权限服务已经拥有默认值,也能修复自己的空白占位会话。
**无条件更新所有空白会话。** 拒绝,因为用户可能在发送第一条 prompt 前,刻意切换当前空白会话的权限。只匹配旧默认值可以更新过期占位会话,同时避免覆盖明确的空白会话选择。
## Consequences
设置变更可能向未带 seed 的空白会话追加权限事实,但这些会话仍保持 blank,因为 blankness 由是否缺少 `turn/start` 定义。已经开始的对话、带 seed 的恢复,以及已有用户显式选择预设的空白会话都会保留原权限。
@@ -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: f63502cd3e2306f36b136e6ed8543641449c3d83
2026-07-31-gui-full-access-confirmation.zh.md: f4b3686d1e1ad9e51a08e513a7dd5930d311582d
2026-07-31-gui-full-access-confirmation.md: c8920981ee09c0496f045962e0736bf0cc39c59b
2026-07-31-gui-full-access-confirmation.zh.md: 8c453a3cb9f4efce079115ff5cf5d4a44dc06796
@@ -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 product label `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 locale product label for 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.
- `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.
- The full-access product label 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. Later, the same localized built-in-label rule was extended to the safer shipped presets while preserving the same wire names ([blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md)).
## Alternatives considered
@@ -10,13 +10,13 @@ Status: implemented
## 决策
**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以完全权限的本地化产品标签展示;所有取消路径都不作任何提交。**
- `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、关闭与点击遮罩均不会改动已存默认值。
- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
- 完全权限产品标签在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。后来,同一个本地化内置标签规则扩展到了更安全的随附预设,同时仍保留相同的 wire 名称([空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md))。
## 考虑过的替代方案
@@ -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: ebf7fe39712d64c18e12b9b26d86201a61ad6cfd
2026-07-31-permission-default-for-new-sessions.zh.md: c56a1b4ac3a6bc88a489dd7e945fa1c11581e7a6
2026-07-31-permission-default-for-new-sessions.md: 7383d4b0e51f6c5570818e1b0c3163b7c2f26146
2026-07-31-permission-default-for-new-sessions.zh.md: 398bb668cc9b08cc989ad099897d4aab17844015
@@ -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`. 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 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 does not change started conversations. Reusable, unseeded blank placeholders that still carry the previous default advance to the new default before Web New Session reuses them; this later bug fix is recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). 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 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.
Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter a started 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. Reusable blank placeholders may receive a new pinned triplet when they still reflect the previous default. 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`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。
服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset``sandbox/mode``approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更不会改变已经开始的对话。仍可复用、未带 seed、且还保持旧默认值的空白占位会话会在 Web 新会话复用前推进到新默认值;这项后续 bug 修复记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md)。带 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` 和选择器,但不会改变已打开会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`
在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已经开始的已打开会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。可复用空白占位会话若仍反映旧默认值,则可能收到新的固定三元组。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`
组装后的 Web 快照包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `workspace-write` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。
+5 -5
View File
@@ -49,13 +49,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('访问模式,当前:Workspace Write')
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:可写入工作区')
await access.click()
await page.getByRole('menuitem', { name: 'Full access' }).click()
const dialog = page.getByRole('dialog', { name: '确认启用 Full access' })
await page.getByRole('menuitem', { name: '完全权限' }).click()
const dialog = page.getByRole('dialog', { name: '确认启用完全权限' })
await dialog.waitFor({ timeout: 10_000 })
const enable = dialog.getByRole('button', { name: '启用 Full access' })
const enable = dialog.getByRole('button', { name: '启用完全权限' })
expect(await enable.isDisabled()).toBe(true)
// The modal is in this page's body (not a native/new window) and escapes
@@ -68,7 +68,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('访问模式,当前:Full access')
.toBe('访问模式,当前:完全权限')
expect(await dialog.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+9 -9
View File
@@ -60,7 +60,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: 'Workspace Write' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '可写入工作区' }).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: '打开配置文件' })
@@ -138,12 +138,12 @@ describe('web e2e: settings modal and General preferences', () => {
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: 'Workspace Write' })
const selector = dialog.getByRole('button', { name: '可写入工作区' })
await selector.waitFor({ timeout: 10_000 })
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
await selector.click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
await page.getByRole('menuitem', { name: '仅可查看' }).click()
await dialog.getByRole('button', { name: '仅可查看' }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('permission:')
@@ -158,14 +158,14 @@ describe('web e2e: settings modal and General preferences', () => {
['approval/policy', { policy: 'ask' }],
])
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' })
await dialog.getByRole('button', { name: '仅可查看' }).click()
await page.getByRole('menuitem', { name: '完全权限' }).click()
const confirmation = page.getByRole('dialog', { name: '确认启用完全权限' })
const enable = confirmation.getByRole('button', { name: '启用完全权限' })
expect(await enable.isDisabled()).toBe(true)
await confirmation.getByRole('checkbox').click()
await enable.click()
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '完全权限' }).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'))
@@ -1,10 +1,10 @@
- dialog "确认启用 Full access":
- heading "确认启用 Full access" [level=2]
- dialog "确认启用完全权限":
- heading "确认启用完全权限" [level=2]
- button "Close":
- img
- img
- paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
- paragraph: 启用完全权限后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
- checkbox "我已了解风险,并愿意继续"
- text: 我已了解风险,并愿意继续
- button "取消"
- button "启用 Full access" [disabled]
- button "启用完全权限" [disabled]
@@ -22,8 +22,8 @@
- text: 标准模式
- img
- text: 权限 选择新会话的默认权限模式
- button "Workspace Write":
- text: Workspace Write
- button "可写入工作区":
- text: 可写入工作区
- img
- text: 语言
- button "中文":
+1 -1
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: 1676a7e042348bbac8eca478cabcc76c22770af5
config-catalog.md: 646db6e251fdcbfcccea354de97f8db394abc8c2
config-catalog.zh.md: 0b32dd18e1d8ab1ca24ce3e5f144402d0d3fad76
+1 -1
View File
@@ -1339,7 +1339,7 @@ export interface PresetSpec {
Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md)
Source: [`packages/interaction/permission-presets/src/index.ts:140`](../packages/interaction/permission-presets/src/index.ts)
Source: [`packages/interaction/permission-presets/src/index.ts:150`](../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/subsystems/permission-presets.md
permission-presets.md: 16ce29a4c3b00fece089ebcdc959e57f419d35c9
permission-presets.zh.md: d2e5eff6696d5dc6f925032d7eaafb7b19774f4e
permission-presets.md: e16a0f11cb3df48d54387ffd39157887d41764ff
permission-presets.zh.md: 989d6033fff49b62cb17968dc5f5deb15f690c84
+1 -1
View File
@@ -127,5 +127,5 @@ set(session: Session, name: string): void
Types: [Session](session.md) · [SessionEvent](session.md)
Source: [`packages/interaction/permission-presets/src/index.ts:159`](../../packages/interaction/permission-presets/src/index.ts)
Source: [`packages/interaction/permission-presets/src/index.ts:169`](../../packages/interaction/permission-presets/src/index.ts)
<!-- END GENERATED cordis-surface -->
+1 -1
View File
@@ -127,5 +127,5 @@ set(session: Session, name: string): void
Types: [Session](session.md) · [SessionEvent](session.md)
Source: [`packages/interaction/permission-presets/src/index.ts:159`](../../packages/interaction/permission-presets/src/index.ts)
Source: [`packages/interaction/permission-presets/src/index.ts:169`](../../packages/interaction/permission-presets/src/index.ts)
<!-- END GENERATED cordis-surface -->
@@ -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: d1a265b5789d9f1d9b5e630e0548ae5f619eebbf
README.zh.md: 3f303391d39bc040b4a6a5a2d1f6a34fe8891919
README.md: e3540fcb3e866beac7a63bbdac20a7b1200510a4
README.zh.md: 554f7b42d08b1873100641cabe63d04547128c4d
+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 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.
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 and unknown kebab-case names render as title-case labels. 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.
The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. 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 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `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 原语下拉,其中内置预设 id 渲染为本地化产品标签,未知 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-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.md)。
@@ -66,11 +66,14 @@ export const zh = {
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
'settings.enter.queue': '排队发送',
'settings.enter.steer': '插话发送',
'access.confirm.title': '确认启用 Full access',
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.preset.readOnly': '仅可查看',
'access.preset.workspaceWrite': '可写入工作区',
'access.preset.fullAccess': '完全权限',
'access.confirm.title': '确认启用完全权限?',
'access.confirm.description': '启用完全权限后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'access.confirm.enable': '启用完全权限',
'hero.headline': '探索未至之境',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
@@ -236,6 +239,9 @@ 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',
@@ -46,19 +46,19 @@ function permissionGlyph(value: string): ReactNode | undefined {
}
/**
* 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.
* Display transform: built-in machine names render as locale product labels;
* non-kebab host-configured names pass through.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function optionLabel(option: PermissionSelectValue['options'][number]): string {
return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
function permissionLabel(value: string, name: string, t: ComposerBarProps['t']): string {
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)
}
export interface PermissionSelectProps {
@@ -86,13 +86,20 @@ 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: optionLabel(option), ...icon === undefined ? {} : { icon } }
return {
id: option.value,
label: permissionLabel(option.value, option.name, t),
...icon === undefined ? {} : { icon },
}
})
const submit = (id: string): void => {
@@ -138,7 +145,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
aria-label={t('input.accessMode', { name: currentLabel })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
@@ -146,7 +153,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}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
<span className={css.triggerLabel}>{currentLabel}</span>
{/* Same glyph + open rotation as the sibling ModelSelect trigger. */}
<span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
<IconChevronDownOutline14 />
@@ -1263,24 +1263,24 @@ describe('command launcher chrome and control seats', () => {
}
const { view } = bench({ permissions, command })
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Read Only')
// Product-label display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('仅可查看')
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(['Read Only', 'Workspace Write', 'Full access'])
expect(items.map(o => o.textContent)).toEqual(['仅可查看', '可写入工作区', '完全权限'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(busy.textContent).toBe('Workspace Write')
expect(busy.textContent).toBe('可写入工作区')
expect(busy.disabled).toBe(true)
expect(command).toHaveBeenCalledWith('/permission workspace-write')
await act(async () => {})
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
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: [
@@ -1291,11 +1291,11 @@ describe('command launcher chrome and control seats', () => {
}
const { view } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
expect(command).not.toHaveBeenCalled()
expect(view.getByRole('dialog', { name: '确认启用 Full access' })).toBeTruthy()
const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
expect(view.getByRole('dialog', { name: '确认启用完全权限' })).toBeTruthy()
const enable = view.getByRole('button', { name: '启用完全权限' }) as HTMLButtonElement
expect(enable.disabled).toBe(true)
fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
@@ -1305,11 +1305,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('Full access')
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('完全权限')
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: [
@@ -1321,21 +1321,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: 'Full access' }))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
}
openConfirmation()
fireEvent.click(view.getByRole('checkbox'))
fireEvent.click(view.getByRole('button', { name: '取消' }))
expect(command).not.toHaveBeenCalled()
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('可写入工作区')
openConfirmation()
expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
expect((view.getByRole('button', { name: '启用完全权限' }) 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: [
@@ -1346,14 +1346,14 @@ describe('command launcher chrome and control seats', () => {
}
const { view, session } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
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: [
@@ -1364,7 +1364,7 @@ describe('command launcher chrome and control seats', () => {
}
const { view, props } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
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: 20529e6ce8a187254774fc85cfd981e4a9115937
README.zh.md: afe7ff36e52511a182a783153879125d48cccca5
README.md: 006a5855cf17ff722e571468360ab3d661747131
README.zh.md: fd5ad3728e8600382b2ff2dfe760bb3aca7734f1
@@ -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. 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.
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 to later sessions; the host also advances reusable blank placeholders that still carry the previous default so New Session reuse follows the setting. Choosing the full-access preset 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 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 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, built-in preset ids rendered as localized product labels, and unknown kebab-case preset names rendered as title-case labels. 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`).
@@ -2,9 +2,9 @@
[English](README.md) | 中文
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。选择 Full access 时必须先显式确认风险,该行随后才会写入。
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值作用于后续会话;host 也会推进仍可复用、且还保持旧默认值的空白占位会话,使新会话复用继续跟随设置。选择完全权限预设时必须先显式确认风险,该行随后才会写入。
当前会话界面仍是挂在 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 行。
当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**`ctx.commandUi.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,内置预设 id 渲染为本地化产品标签,未知 kebab-case 预设名渲染为 Title Case 标签选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
`/client` 导出面为插件本体(`apply``inject`)。
@@ -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 { FULL_ACCESS_PRESET } from './presentation.ts'
import { displayPermissionPreset, FULL_ACCESS_PRESET } from './presentation.ts'
import css from './PermissionRow.module.css'
/** Registration-side business face for the host-backed preference. */
@@ -58,8 +58,9 @@ 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 label = selected?.label
?? (busy ? t('loading') : t('unavailable'))
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 description: string = state.error ?? t('description')
return (
@@ -72,7 +73,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={state.options.map(option => ({ id: option.id, label: option.label }))}
items={state.options.map(option => ({ id: option.id, label: optionLabel(option) }))}
selectedId={state.currentValue}
onSelect={(id) => {
setOpen(false)
@@ -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),
label: displayPermissionPreset(option.value, option.name, t),
...(option.description !== undefined ? { detail: option.description } : {}),
...(option.value === value.currentValue ? { active: true } : {}),
...(option.value === FULL_ACCESS_PRESET
@@ -87,6 +87,9 @@ 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'],
@@ -94,6 +97,9 @@ 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,11 +6,14 @@ export const zh = {
'description': '选择新会话的默认权限模式',
'loading': '加载中',
'unavailable': '不可用',
'confirm.title': '确认启用 Full access',
'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
'preset.readOnly': '仅可查看',
'preset.workspaceWrite': '可写入工作区',
'preset.fullAccess': '完全权限',
'confirm.title': '确认启用完全权限?',
'confirm.description': '启用完全权限后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用 Full access',
'confirm.enable': '启用完全权限',
} satisfies Record<string, string>
/** The settings.permission namespace key union. */
@@ -22,6 +25,9 @@ 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',
@@ -31,11 +37,14 @@ export const en = {
/** Simplified Chinese dictionary for the current-session popup gate. */
export const accessZh = {
'confirm.title': '确认启用 Full access',
'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'preset.readOnly': '仅可查看',
'preset.workspaceWrite': '可写入工作区',
'preset.fullAccess': '完全权限',
'confirm.title': '确认启用完全权限?',
'confirm.description': '启用完全权限后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用 Full access',
'confirm.enable': '启用完全权限',
} satisfies Record<string, string>
/** Current-session popup-gate key union. */
@@ -43,6 +52,9 @@ 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,6 +1,24 @@
/** 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.
@@ -15,8 +33,15 @@ 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.
* @returns the Full access product label or the conventional display name.
* @param t - optional locale dictionary lookup for built-in product labels.
* @returns the built-in product label or the conventional display name.
*/
export function displayPermissionPreset(value: string, name: string): string {
return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name)
export function displayPermissionPreset(
value: string,
name: string,
t?: (key: PermissionPresetLabelKey) => string,
): string {
const key = PRESET_LABEL_KEYS.get(value)
if (key !== undefined) return t?.(key) ?? DEFAULT_PRESET_LABELS[key]
return displayPresetName(name)
}
@@ -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.')
// Kebab-case names title-case; non-kebab host-configured names pass through.
// Built-ins use product labels; other kebab-case names title-case.
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,9 +137,13 @@ describe('ui-permission browser plugin', () => {
cancelLabel: 'Cancel',
confirmLabel: 'Enable Full access',
})
b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
b.values.set(sid('s1'), { ...SELECT, options: [
{ value: 'custom-mode', name: 'custom-mode' },
{ value: '__proto__', name: '__proto__' },
{ value: 'plain', name: 'Ask Every Time' },
] })
const passthrough = await c.ui.options(proj, new AbortController().signal)
expect(passthrough[0]?.label).toBe('Ask Every Time')
expect(passthrough.map(option => option.label)).toEqual(['Custom Mode', '__proto__', '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 { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
import { en } from '../src/client/locales.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 { 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> = en
const dictionary: Record<string, string> = zh
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: 'Read Only' })
const button = await screen.findByRole('button', { name: '仅可查看' })
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: 'Read Only' }))
fireEvent.click(screen.getByRole('menuitem', { name: '仅可查看' }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
await screen.findByRole('button', { name: 'Workspace Write' })
fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
await screen.findByRole('button', { name: '可写入工作区' })
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: 'Read Only' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(await screen.findByRole('button', { name: '仅可查看' }))
fireEvent.click(screen.getByRole('menuitem', { name: '完全权限' }))
expect(mutate).not.toHaveBeenCalled()
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' })
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: '启用完全权限' })
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: 'Read Only' })).hasAttribute('disabled')).toBe(true)
expect((await screen.findByRole('button', { name: '仅可查看' })).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: 'Loading' })).hasAttribute('disabled')).toBe(true)
expect((await screen.findByRole('button', { name: '加载中' })).hasAttribute('disabled')).toBe(true)
describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
const button = await screen.findByRole('button', { name: 'Read Only' })
const button = await screen.findByRole('button', { name: '仅可查看' })
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere')
})
})
@@ -47,12 +47,12 @@ function permissionController(api: object) {
}
describe('permission settings store', () => {
it('derives dynamic options and host labels from the descriptor schema', () => {
it('derives dynamic options and built-in labels from the descriptor schema', () => {
expect(resolveDefault(view('read-only'))).toEqual({
currentValue: 'read-only',
options: [
{ id: 'read-only', label: 'Read Only' },
{ id: 'workspace-write', label: 'Workspace' },
{ id: 'workspace-write', label: 'Workspace Write' },
],
})
const single = {
@@ -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: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6
README.zh.md: 686138c1f2b0b3770771d0e6d6a785ed17cc8621
README.md: a21acdbf3759e2af49e1f0a8993c1552d265a588
README.zh.md: 76955aee7ed1eb649ab6fa138587b0229697c0f3
@@ -6,7 +6,7 @@ User-facing permission presets through `ctx.permissionPresets` ([`PermissionPres
`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 `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 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. Later changes never alter a session that has started a turn, but reusable blank placeholders that still carry the previous default advance to the new default so Web New Session reuse stays aligned with the setting. 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).
@@ -6,7 +6,7 @@
`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset``sandbox/mode``approval/policy` 固定到该会话中,因此后续变更不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset``sandbox/mode``approval/policy` 固定到该会话中后续变更不会改变已经开始过轮次的会话,但仍可复用、且还保持旧默认值的空白占位会话会推进到新默认值,从而让 Web 新会话复用继续与设置一致。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
@@ -130,6 +130,16 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState {
return state
}
/**
* Whether a live session is still a fresh Web New Session placeholder. This
* mirrors the host blank rule (no `turn/start`) but excludes constructor
* seeds, which preserve their effective permissions instead of following
* later defaults.
*/
function isUnseededBlankSession(events: readonly SessionEvent[]): boolean {
return !events.some(event => event.type === 'turn/start' || event.type === 'session/end-seed')
}
/** User setting resolved when a new session receives its initial permission. */
export interface PermissionSettings {
/** Preset pinned into a newly created session. */
@@ -181,6 +191,7 @@ export class PermissionPresetService extends Service {
private readonly presets: Record<string, PresetSpec>
private defaultSettings: () => PermissionSettings
private activeDefaultPreset: string
constructor(ctx: Context, config: Config) {
super(ctx, 'permissionPresets')
@@ -200,6 +211,7 @@ export class PermissionPresetService extends Service {
this.resolve(defaultPreset)
const baseSettings: PermissionSettings = { defaultPreset }
this.defaultSettings = () => baseSettings
this.activeDefaultPreset = defaultPreset
const presetChoices = this.names.map((name) => {
const choice = z.const(name)
const label = this.presets[name]?.name
@@ -212,9 +224,7 @@ 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: () => {},
onChange: () => { this.syncBlankSessionsToDefault() },
})
ctx.on('session/created', (session) => {
@@ -391,6 +401,20 @@ export class PermissionPresetService extends Service {
}
}
/** Advance reusable blank sessions that still carry the previous default. */
private syncBlankSessionsToDefault(): void {
const previous = this.activeDefaultPreset
const next = this.defaultPreset
this.activeDefaultPreset = next
if (next === previous) return
this.resolve(next)
for (const session of this.ctx.sessions.list()) {
if (!isUnseededBlankSession(session.events)) continue
if (this.current(session.events) !== previous) continue
this.set(session, next)
}
}
/**
* Fill every missing permission fact before a session is published. A
* genuinely fresh session uses the current user default; seeded or partially
@@ -201,6 +201,7 @@ describe('new-session default', () => {
['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',
@@ -214,6 +215,44 @@ describe('new-session default', () => {
])
})
it('advances reusable blank sessions that still carry the previous 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('danger-full-access')
expect(blank.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'workspace-write' }],
['sandbox/mode', { mode: 'workspace-write' }],
['approval/policy', { policy: 'ask' }],
['permission/preset', { preset: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
})
it('leaves blank sessions with an explicit pick or a constructor seed 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]
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
expect(picked.events).toEqual(pickedEvents)
expect(restored.events).toEqual(restoredEvents)
})
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, {
@@ -1632,7 +1632,7 @@ describe('run lifecycle and quiescence', () => {
stopReason: 'error',
})
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: ['codex', 'app-server', '--stdio'],
argv: codexAppServerArgv(),
env: { OPENAI_API_KEY: 'fake' },
graceMs: 25,
cwd: process.cwd(),