merge master and address permission settings review

This commit is contained in:
Yichen Jiang
2026-07-31 13:24:30 +08:00
89 changed files with 1336 additions and 295 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/permission/README.md
README.md: 576555b56c82e4041f2862bcb41ce137cb2d4f77
README.zh.md: 894289314a4fb2fc22216462d3f3e0544d7d17bb
README.md: 4f7f560bb81eaad3b6b95b2742432fa252682d5a
README.zh.md: 79d0ce9c095d3426f3219f04d9cb7ec3b161a184

View File

@@ -6,7 +6,7 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s
`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.
The service owns the `permission` 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 `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed preserves its effective permission and receives only missing durable facts rather than the latest user default.
The service owns the `permission` 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 `permission/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.bash` 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).
@@ -25,3 +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.

View File

@@ -6,7 +6,7 @@
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed 会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。
该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
@@ -25,3 +25,4 @@
- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。
- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。

View File

@@ -177,7 +177,7 @@ export class PermissionService extends Service {
defaultPreset: z.string(),
})
static inject = ['bash', 'approval']
static inject = ['bash', 'approval', 'sessions']
private readonly presets: Record<string, PresetSpec>
private defaultSettings: () => PermissionSettings
@@ -220,6 +220,9 @@ export class PermissionService extends Service {
ctx.on('session/created', (session) => {
this.pinInitialPermission(session)
})
for (const session of ctx.sessions.list()) {
this.pinInitialPermission(session)
}
// The permissions projection unit: fold the three whole-value knob
// events; view derives the select over the composition defaults this

View File

@@ -31,6 +31,7 @@ async function mounted(options: {
approvalDefault?: ApprovalPolicy | undefined
} = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.provide('bash', {
sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
@@ -228,6 +229,38 @@ describe('new-session default', () => {
])
})
it('preserves composition defaults when an empty stored session resumes', async () => {
const ctx = await mountedStore()
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] })
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
expect(resumed.events.map(event => event.type)).toEqual([
'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy',
])
})
it('pins sessions that already exist when the service remounts', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.provide('bash', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', { config: { policy: 'ask' } })
const existing = ctx.sessions.create(SessionId('existing-before-permission'))
expect(existing.events).toEqual([])
await ctx.plugin(PermissionService, {})
expect(existing.events.map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
expect(ctx.permission.current(existing.events)).toBe('workspace-write')
})
it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => {
const ctx = await mountedStore()
const partial = freshSession('partial-source')