fix(permission): simplify Auto identity and teardown
This commit is contained in:
1 parent
0861ee11f7
commit
197161e660
17 files changed
+78
-145
No files matched your search
@@ -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-08-28-auto-review.md
|
||||
2026-08-28-auto-review.md: dfd4c1179ae03d2fd9f612f87ae295ba492e1f3f
|
||||
2026-08-28-auto-review.zh.md: e1c2ff93ea4c3244a5147e8b381ec7b507f45850
|
||||
2026-08-28-auto-review.md: c80d9b6fa578897434c0850d7bb953d7378751a4
|
||||
2026-08-28-auto-review.zh.md: b9e4db7b0700776e176ffeee3388ac2fe2f96754
|
||||
@@ -36,7 +36,7 @@ An Auto denial gives the main agent a fixed message that names the rejected tool
|
||||
|
||||
Caller cancellation follows ordinary Tool settlement priority: a cancellation observed after a late allow becomes the canonical pre-dispatch cancellation, while a deny or technical failure that has already settled remains an Auto denial. A denied PTC inner call keeps the existing `ToolCallError` and program `catch` behavior, so a handled denial does not become an outer `run_code` failure. Allow produces no event, reason, or durable grant.
|
||||
|
||||
Publication and teardown are fail-closed. A stored Auto session cannot publish unless the Auto registration is live and admits that Session; the service does not rewrite it to Full access. During disposal, the integration first closes new Auto selection and review admission and captures the exact Session object identities retiring from Auto before it changes any preset; the listener checks this retiring set before derived permission state. It switches each captured Session through the existing preset writer to `danger-full-access`; because Auto and Full access share `danger-full-access` plus approval policy `never`, the transition records only the new preset identity, leaves the execution knobs and persistent terminals unchanged, and does not add a terminal fence. The integration then aborts the lifecycle and waits for every active review or admitted call before it removes the listener and Auto registration. An allowed call remains active until `tools/execute` or `tools/result`; the execute wrapper combines the lifecycle signal with the caller signal before delegating, so disposal cancels any admitted body that has not started. If any Session cannot migrate, the integration still aborts and drains active calls but retains both closed registrations, so new Auto selections fail and calls from retiring sessions are denied instead of entering an unreviewed execution window. Reinstalling the integration restores only the Auto catalog entry; migrated live Sessions remain in Full access until a user explicitly selects Auto again.
|
||||
Publication and teardown are fail-closed. A stored Auto session cannot publish unless the Auto registration is live and admits it; the service does not rewrite it to Full access. During disposal, the integration first closes new Auto selection and review admission, then switches live Auto sessions through the existing preset writer to `danger-full-access`. Auto and Full access share `danger-full-access` plus approval policy `never`, so the transition records only the new preset identity, leaves execution knobs and persistent terminals unchanged, and adds no terminal fence. Canonical permission state remains the authority: the single preset append commits atomically, and Session contains post-commit observer failures. The integration then aborts its lifecycle and waits for every active review or admitted call before removing the listener and Auto registration. An allowed call remains active until `tools/execute` or `tools/result`; the execute wrapper combines the lifecycle signal with the caller signal before delegating, so disposal cancels any admitted body that has not started. Cancellation and draining also run if a preset write fails; closed registrations continue to reject new Auto selections and calls from sessions still in Auto. Reinstalling the integration restores only the Auto catalog entry; migrated live sessions remain in Full access until a user explicitly selects Auto again.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Auto 拒绝会向主 agent 提供固定消息,其中会指明被拒绝的工
|
||||
|
||||
调用方取消沿用普通 Tool 的结算优先级:late allow 后观察到的取消会转为规范的 dispatch 前取消,而已经结算的拒绝或技术失败仍保持 Auto 拒绝。被拒绝的 PTC inner call 保留既有 `ToolCallError` 与程序 `catch` 行为,因此被处理的拒绝不会转成外层 `run_code` 失败。允许决定不产生事件、原因或持久 grant。
|
||||
|
||||
发布与资源释放均以拒绝方式关闭。存储的 Auto 会话只有在 Auto 注册为 live 状态并准入该 Session 时才能发布;服务不会把它改写为 Full access。资源释放期间,integration 会先关闭新的 Auto 选择与审查准入,并在改变任何预设前捕获正在退出 Auto 的精确 Session 对象身份;监听器会先检查该退役集合,再派生当前权限状态。integration 通过既有预设写入器把每个已捕获 Session 切换到 `danger-full-access`;由于 Auto 与 Full access 共用沙箱 `danger-full-access` 加审批策略 `never`,该切换只记录新的预设身份,执行旋钮与持久终端保持不变,也不会增加 terminal fence。随后 integration 中止生命周期,并等待每次在途审查或已准入调用完成,最后才移除监听器与 Auto 注册。允许的调用会保持 active 状态,直至进入 `tools/execute` 或 `tools/result`;execute 包装层会在继续分派前合并生命周期 signal 与调用方 signal,因此资源释放会取消任何尚未开始的已准入工具主体。若有 Session 无法迁移,integration 仍会中止并排空 active 调用,但会保留两项已关闭的注册,使新的 Auto 选择失败,并让退役会话的调用被拒绝,而不是留下未经审查的执行窗口。重新安装 integration 只会恢复 Auto 目录项;已经迁移的 live Session 会保持 Full access,直到用户再次显式选择 Auto。
|
||||
发布与资源释放均以拒绝方式关闭。存储的 Auto 会话只有在 Auto 注册为 live 状态并准入它时才能发布;服务不会把它改写为 Full access。资源释放期间,integration 先关闭新的 Auto 选择与审查准入,再通过既有预设写入器把 live Auto 会话切换到 `danger-full-access`。Auto 与 Full access 共用沙箱 `danger-full-access` 加审批策略 `never`,因此该切换只记录新的预设身份,执行旋钮与持久终端保持不变,也不会增加 terminal fence。规范权限状态仍是权威:单个预设 append 原子提交,Session 会隔离提交后的观察者错误。随后 integration 中止生命周期,并等待每次在途审查或已准入调用完成,最后才移除监听器与 Auto 注册。允许的调用会保持 active 状态,直至进入 `tools/execute` 或 `tools/result`;execute 包装层会在继续分派前合并生命周期 signal 与调用方 signal,因此资源释放会取消任何尚未开始的已准入工具主体。预设写入失败时也会执行取消与排空;已关闭的注册会继续拒绝新的 Auto 选择与仍处于 Auto 的会话调用。重新安装 integration 只恢复 Auto 目录项;已经迁移的 live Session 会保持 Full access,直到用户再次显式选择 Auto。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -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: 0194729f80b0ba462eb58e760a6ed3f87bdf08df
|
||||
permission-presets.zh.md: add735149a8821788abba814d82615eaf15400a7
|
||||
permission-presets.md: 14494deaff8b6a325981e3032aaaf68c2cb1a018
|
||||
permission-presets.zh.md: 4974e1b5ff1825dfe458e019e0b0f2cc7e7ce14f
|
||||
@@ -52,7 +52,7 @@ Registering or removing Auto emits the payload-free `permission-presets/catalog-
|
||||
|
||||
## Current preset and the derived `custom`
|
||||
|
||||
`current(session)` derives the effective preset from the required `permissions` projection. The unit folds the session's sandbox mode, approval policy, and recorded selection; values absent within that state fall back to the executor's configured mode and the approval service config, then `ask`. A missing projection key fails explicitly. The service prefers a still-matching selection, then the first matching configured entry, then live Auto when its fixed bundle matches, and otherwise returns `CUSTOM_PRESET` (`'custom'`). `custom` is derived-only: clients may display it as the current value, but it is never a switch target or an event payload.
|
||||
`current(session)` derives the effective preset from the required `permissions` projection. The unit folds the session's sandbox mode, approval policy, and recorded selection; values absent within that state fall back to the executor's configured mode and the approval service config, then `ask`. A missing projection key fails explicitly. The service prefers a still-matching selection, then the first matching configured entry, and otherwise returns `CUSTOM_PRESET` (`'custom'`). `custom` is derived-only: clients may display it as the current value, but it is never a switch target or an event payload.
|
||||
|
||||
`names` lists configured presets in declaration order followed by Auto while its integration is live. `catalog()` returns those selectable entries as one process-level snapshot. `optionOf(name)` builds an available entry (its label falls back to the key) or the derived `custom` presentation, and throws for any other name. Clients join the catalog with the Session projection; `custom` may label the current value but never becomes a catalog entry.
|
||||
|
||||
@@ -106,7 +106,7 @@ registerAuto(admit: () => void): () => Promise<void>
|
||||
/**
|
||||
* Resolve the preset matching the effective knob values. A still-matching
|
||||
* last selection wins shared-bundle ties; otherwise the first configured
|
||||
* match, then Auto when live, wins. Returns
|
||||
* match wins. Returns
|
||||
* {@link CUSTOM_PRESET} when no available preset matches.
|
||||
* @param session - the session whose knob state is read.
|
||||
* @returns the effective preset name, or `custom` when nothing matches.
|
||||
|
||||
@@ -52,7 +52,7 @@ Auto integration 会在自身 effect 生命周期内调用 `registerAuto(admit)`
|
||||
|
||||
## 当前预设与派生的 `custom`
|
||||
|
||||
`current(session)` 从必需的 `permissions` 投影派生实际生效的预设。该单元折叠会话的沙箱模式、审批策略和已记录选择;状态内部的缺失值回退到执行器配置的模式与审批服务配置,最后回退到 `ask`。投影 key 缺失时会显式失败。服务优先取仍然匹配的选择,其次取第一个匹配的配置条目,再取固定组合匹配时的 live Auto,否则返回 `CUSTOM_PRESET`(`'custom'`)。`custom` 只是派生值:客户端可以把它显示为当前值,但它绝不是切换目标,也绝不出现在事件 payload 中。
|
||||
`current(session)` 从必需的 `permissions` 投影派生实际生效的预设。该单元折叠会话的沙箱模式、审批策略和已记录选择;状态内部的缺失值回退到执行器配置的模式与审批服务配置,最后回退到 `ask`。投影 key 缺失时会显式失败。服务优先取仍然匹配的选择,其次取第一个匹配的配置条目,否则返回 `CUSTOM_PRESET`(`'custom'`)。`custom` 只是派生值:客户端可以把它显示为当前值,但它绝不是切换目标,也绝不出现在事件 payload 中。
|
||||
|
||||
`names` 先按声明顺序列出配置预设,再在 Auto integration 存活时列出 Auto。`catalog()` 把这些可选条目作为一份进程级快照返回。`optionOf(name)` 为可用条目(label 回退为该 key)或派生的 `custom` 展示构建选项,传入其他任何名称都会抛出异常。客户端把目录与 Session 投影合并;`custom` 可以标记当前值,但绝不会成为目录条目。
|
||||
|
||||
@@ -106,7 +106,7 @@ registerAuto(admit: () => void): () => Promise<void>
|
||||
/**
|
||||
* Resolve the preset matching the effective knob values. A still-matching
|
||||
* last selection wins shared-bundle ties; otherwise the first configured
|
||||
* match, then Auto when live, wins. Returns
|
||||
* match wins. Returns
|
||||
* {@link CUSTOM_PRESET} when no available preset matches.
|
||||
* @param session - the session whose knob state is read.
|
||||
* @returns the effective preset name, or `custom` when nothing matches.
|
||||
|
||||
@@ -1348,7 +1348,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'current(session: Session): string',
|
||||
description: 'Resolve the preset matching the effective knob values. A still-matching last selection wins shared-bundle ties; otherwise the first configured match, then Auto when live, wins. Returns CUSTOM_PRESET when no available preset matches.',
|
||||
description: 'Resolve the preset matching the effective knob values. A still-matching last selection wins shared-bundle ties; otherwise the first configured match wins. Returns CUSTOM_PRESET when no available preset matches.',
|
||||
parameters: [{ name: 'session', description: 'the session whose knob state is read.' }],
|
||||
returns: 'the effective preset name, or `custom` when nothing matches.',
|
||||
},
|
||||
|
||||
@@ -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/auto-review/README.md
|
||||
README.md: b5e87b42361d1417a6a0fa50f029cbef56534461
|
||||
README.zh.md: 3033f093078191f1508bfeb68a2f245428d4f67d
|
||||
README.md: 4a1b6692f6ad2ef36e0ae616202c9500a01fa42e
|
||||
README.zh.md: 4b9548578a5678242288b624e0cb332bb16b4c14
|
||||
@@ -72,7 +72,7 @@ The plugin prepends one `tools/pre-execute` listener. It rebuilds each native ac
|
||||
|
||||
An Auto denial uses a fixed model-facing message that names the rejected tool and states that its body was not executed. Its `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` identity and optional raw reason travel through the ordinary native or PTC structured-error fields. The Web tree recognizes that identity before keyed Tool-view dispatch and renders the generic denial card, so a specialized or external Tool view cannot hide the verdict. The presentation normalizes the reason only when it renders the card.
|
||||
|
||||
Publication and teardown are fail-closed. A persisted Auto session cannot publish without the live Auto registration. Before changing any preset during disposal, the plugin captures the exact Session object identities that are retiring from Auto, and the listener checks that set before derived permission state. It closes new admission, switches each captured session through the existing preset writer to `danger-full-access` without changing the shared sandbox/approval bundle or closing persistent terminals, then aborts the lifecycle and waits for every active review or admitted call. An allowed call remains active until `tools/execute` or `tools/result`; the execute wrapper combines the lifecycle signal with the caller signal before delegating, so disposal cancels any admitted body that has not started. If every migration succeeds, teardown removes the listener and Auto registration; if any migration fails, Cordis reports the cleanup error while retaining both in their closed state, so later calls from retiring sessions stay denied and new Auto selections fail. Reinstalling the integration restores only the catalog entry; migrated live sessions remain in Full access until a user selects Auto again.
|
||||
Publication and teardown are fail-closed. A persisted Auto session cannot publish without the live Auto registration. Disposal closes new admission and switches live Auto sessions through the existing preset writer to `danger-full-access`, preserving the shared sandbox/approval bundle and persistent terminals. The plugin then aborts its lifecycle and waits for every active review or admitted call before removing the listener and Auto registration. An allowed call remains active until `tools/execute` or `tools/result`; the execute wrapper combines the lifecycle signal with the caller signal before delegating, so disposal cancels any admitted body that has not started. Cancellation and draining also run if a preset write fails; Cordis reports the cleanup error and retains the closed registrations, which reject new Auto selections and calls from sessions still in Auto. Reinstalling the integration restores only the catalog entry; migrated live sessions remain in Full access until a user selects Auto again.
|
||||
|
||||
### Source map
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ DSH_AUTO_REVIEW_CERTIFICATION=1 pnpm exec vitest run --config vitest.e2e.config.
|
||||
|
||||
Auto 拒绝使用固定模型可见消息,其中会指明被拒绝的工具并说明其主体未执行。其 `AutoReviewDeniedError`/`AUTO_REVIEW_DENIED` 身份与可选 raw reason 通过普通原生或 PTC 结构化错误字段传播。Web 树会在 keyed Tool 视图分派前识别该身份并渲染通用拒绝卡,因此专用或外部 Tool 视图都不能遮住拒绝结论。展示层只在渲染工具卡时归一化原因。
|
||||
|
||||
发布与资源释放都以拒绝方式关闭。持久 Auto 会话在 live Auto 注册缺失时不能发布。资源释放在修改任何预设前,会先捕获所有正在退出 Auto 的精确 Session 对象身份,监听器也会先检查该集合,再派生当前权限状态。插件关闭新准入,通过既有预设写入器把每个已捕获会话切换到 `danger-full-access`,不改变 Auto 与 Full access 共用的沙箱/审批组合,也不关闭持久终端;随后中止生命周期并等待每次在途审查或已准入调用。允许的调用会保持 active 状态,直至进入 `tools/execute` 或 `tools/result`;execute 包装层会在继续分派前合并生命周期 signal 与调用方 signal,因此资源释放会取消任何尚未开始的已准入工具主体。如果全部迁移成功,资源释放会移除监听器与 Auto 注册;如果任一迁移失败,Cordis 会报告清理错误并让两者保留在已关闭状态,使退役会话的后续调用继续被拒绝、新的 Auto 选择继续失败。重新安装 integration 只恢复目录项;已经迁移的 live Session 会保持 Full access,直到用户再次选择 Auto。
|
||||
发布与资源释放都以拒绝方式关闭。持久 Auto 会话在 live Auto 注册缺失时不能发布。资源释放先关闭新准入,再通过既有预设写入器把 live Auto 会话切换到 `danger-full-access`,保留共用的沙箱/审批组合与持久终端。随后插件中止生命周期,并等待每次在途审查或已准入调用完成,最后才移除监听器与 Auto 注册。允许的调用会保持 active 状态,直至进入 `tools/execute` 或 `tools/result`;execute 包装层会在继续分派前合并生命周期 signal 与调用方 signal,因此资源释放会取消任何尚未开始的已准入工具主体。预设写入失败时也会执行取消与排空;Cordis 会报告清理错误并保留已关闭的注册,使新的 Auto 选择与仍处于 Auto 的会话调用被拒绝。重新安装 integration 只恢复目录项;已经迁移的 live Session 会保持 Full access,直到用户再次选择 Auto。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
||||
@@ -653,12 +653,10 @@ function denied(exec: ToolExecution, reason?: string): PreToolDecision {
|
||||
|
||||
/** Install the Auto preset and its prepended per-call review gate. */
|
||||
export function apply(ctx: Context): void {
|
||||
// Failed disposal keeps the closed listener installed after this plugin
|
||||
// context deactivates, so retain the dependency instance for that cancellation path.
|
||||
// Retain the injected service while this context drains on disposal.
|
||||
const permissionPresets = ctx.permissionPresets
|
||||
let accepting = true
|
||||
const active = new Map<ToolExecution['token'], ActiveCall>()
|
||||
const retiring = new Set<Agent['session']>()
|
||||
const lifecycle = new AbortController()
|
||||
|
||||
const completeActive = (exec: Pick<ToolExecution, 'token'>): void => {
|
||||
@@ -674,11 +672,10 @@ export function apply(ctx: Context): void {
|
||||
if (agent === undefined || (exec.parent === undefined && exec.name === RUN_CODE_NAME)) {
|
||||
return next()
|
||||
}
|
||||
const isRetiring = retiring.has(agent.session)
|
||||
if (!isRetiring && permissionPresets.current(agent.session) !== AUTO_PRESET) {
|
||||
if (permissionPresets.current(agent.session) !== AUTO_PRESET) {
|
||||
return next()
|
||||
}
|
||||
if (!accepting || isRetiring || lifecycle.signal.aborted) {
|
||||
if (!accepting || lifecycle.signal.aborted) {
|
||||
return { kind: 'cancel' }
|
||||
}
|
||||
|
||||
@@ -725,22 +722,15 @@ export function apply(ctx: Context): void {
|
||||
yield stopContribution
|
||||
yield async () => {
|
||||
accepting = false
|
||||
const errors: unknown[] = []
|
||||
for (const session of ctx.sessions.list()) {
|
||||
if (permissionPresets.current(session) !== AUTO_PRESET) continue
|
||||
retiring.add(session)
|
||||
}
|
||||
for (const session of retiring) {
|
||||
try {
|
||||
try {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
if (permissionPresets.current(session) !== AUTO_PRESET) continue
|
||||
permissionPresets.set(session, 'danger-full-access')
|
||||
retiring.delete(session)
|
||||
} catch (error: unknown) {
|
||||
errors.push(error)
|
||||
}
|
||||
} finally {
|
||||
lifecycle.abort(new Error('auto-review integration disposed'))
|
||||
await Promise.allSettled([...active.values()].map(item => item.done))
|
||||
}
|
||||
lifecycle.abort(new Error('auto-review integration disposed'))
|
||||
await Promise.allSettled([...active.values()].map(item => item.done))
|
||||
if (errors.length > 0) throw new AggregateError(errors, 'auto-review: failed to migrate live Auto sessions')
|
||||
}
|
||||
}, 'auto-review lifecycle')
|
||||
}
|
||||
@@ -22,11 +22,6 @@ import SessionStore, {
|
||||
SessionId,
|
||||
SessionLogOffset,
|
||||
type Session,
|
||||
type SessionEvent,
|
||||
type SessionEventMap,
|
||||
type SessionEventType,
|
||||
type SurfaceEventType,
|
||||
type SurfaceIntent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentRuntime, {
|
||||
@@ -1310,6 +1305,53 @@ describe('cancellation and integration teardown', () => {
|
||||
expect(ctx.permissionPresets.names).not.toContain(AUTO_PRESET)
|
||||
})
|
||||
|
||||
it('closes Auto admission before migration observers can start another review', async () => {
|
||||
const { ctx, adapter, auto } = await harness([
|
||||
decisionChunks('{"risk":"low","decision":"allow"}'),
|
||||
])
|
||||
const probe = registerProbe(ctx)
|
||||
const first = autoSession(ctx, 'dispose-migration-first')
|
||||
const second = autoSession(ctx, 'dispose-migration-second')
|
||||
appendHeader(second.session, [{ name: 'probe', description: 'probe', parameters: { type: 'object' } }])
|
||||
const callId = ToolCallId('dispose-migration-call')
|
||||
appendAssistant(second.session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }])
|
||||
appendNativeCall(second.session, callId, 'probe', '{}')
|
||||
let observedPreset: string | undefined
|
||||
let selectionFailure: unknown
|
||||
let competingCall: ReturnType<typeof ctx.tools.execute> | undefined
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== first.session || event.type !== 'permission/preset'
|
||||
|| event.data.preset !== 'danger-full-access') return
|
||||
observedPreset = ctx.permissionPresets.current(second.session)
|
||||
try {
|
||||
ctx.permissionPresets.set(second.session, AUTO_PRESET)
|
||||
} catch (error) {
|
||||
selectionFailure = error
|
||||
}
|
||||
competingCall = ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId,
|
||||
name: 'probe',
|
||||
arguments: {},
|
||||
agent: second.agent,
|
||||
})
|
||||
})
|
||||
|
||||
await auto.dispose()
|
||||
|
||||
expect(observedPreset).toBe(AUTO_PRESET)
|
||||
expect(selectionFailure).toEqual(new Error('auto-review: integration is closing'))
|
||||
await expect(competingCall).resolves.toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
})
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(probe.runs()).toBe(0)
|
||||
expect(ctx.permissionPresets.current(first.session)).toBe('danger-full-access')
|
||||
expect(ctx.permissionPresets.current(second.session)).toBe('danger-full-access')
|
||||
expect(ctx.permissionPresets.names).not.toContain(AUTO_PRESET)
|
||||
})
|
||||
|
||||
it('cancels an allowed review when disposal starts during a downstream guard', async () => {
|
||||
const downstreamEntered = Promise.withResolvers<undefined>()
|
||||
const releaseDownstream = Promise.withResolvers<undefined>()
|
||||
@@ -1429,91 +1471,6 @@ describe('cancellation and integration teardown', () => {
|
||||
await reinstalled.dispose()
|
||||
})
|
||||
|
||||
it('retains the closed cancellation gate after draining when one session migration fails', async () => {
|
||||
let release!: () => void
|
||||
const held = new Promise<void>((resolve) => { release = resolve })
|
||||
const controlled = async function* (): AsyncIterable<StreamChunk> {
|
||||
await held
|
||||
yield* decisionChunks('{"risk":"medium","decision":"allow"}')
|
||||
}
|
||||
const { ctx, adapter, auto } = await harness([controlled])
|
||||
const probe = registerProbe(ctx)
|
||||
const ordinarySession = ctx.sessions.create(SessionId('dispose-ordinary'), { meta: { cwd: '/workspace' } })
|
||||
const ordinaryAgent = agentFor(ordinarySession)
|
||||
const { session, agent } = autoSession(ctx, 'dispose-failure')
|
||||
appendHeader(session, [{ name: 'probe', description: 'probe', parameters: { type: 'object' } }])
|
||||
const firstId = ToolCallId('first-dispose-call')
|
||||
appendAssistant(session, [{ type: 'tool-call', id: firstId, name: 'probe', arguments: '{}' }])
|
||||
appendNativeCall(session, firstId, 'probe', '{}')
|
||||
const first = ctx.tools.execute({
|
||||
signal: new AbortController().signal, callId: firstId, name: 'probe', arguments: {}, agent,
|
||||
})
|
||||
await until(() => adapter.requests.length === 1)
|
||||
const logged = vi.spyOn(ctx.logger, 'error').mockImplementation(() => undefined)
|
||||
const append = session.append.bind(session)
|
||||
function appendWithMigrationFailure<T extends SessionEventType>(
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent<T>] : []
|
||||
): SessionEvent<T> {
|
||||
const event = append(type, data, ...opts)
|
||||
if (type === 'permission/preset'
|
||||
&& (data as SessionEventMap['permission/preset']).preset === 'danger-full-access') {
|
||||
throw new Error('migration failed after persisted identity')
|
||||
}
|
||||
return event
|
||||
}
|
||||
vi.spyOn(session, 'append').mockImplementation(appendWithMigrationFailure)
|
||||
|
||||
const disposal = auto.dispose()
|
||||
await until(() => adapter.requests[0]?.signal?.aborted === true)
|
||||
expect(session.snapshotEvents().at(-1)).toMatchObject({
|
||||
type: 'permission/preset', data: { preset: 'danger-full-access' },
|
||||
})
|
||||
expect(ctx.permissionPresets.current(session)).toBe('danger-full-access')
|
||||
expect(() => { ctx.permissionPresets.set(session, AUTO_PRESET) }).toThrow(/integration is closing/)
|
||||
const secondId = ToolCallId('second-dispose-call')
|
||||
appendAssistant(session, [{ type: 'tool-call', id: secondId, name: 'probe', arguments: '{}' }], 2, 1)
|
||||
appendNativeCall(session, secondId, 'probe', '{}', 2, 1)
|
||||
const second = await ctx.tools.execute({
|
||||
signal: new AbortController().signal, callId: secondId, name: 'probe', arguments: {}, agent,
|
||||
})
|
||||
expect(second).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
})
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
await expect(ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: ToolCallId('ordinary-call-after-failed-dispose'),
|
||||
name: 'probe',
|
||||
arguments: {},
|
||||
agent: ordinaryAgent,
|
||||
})).resolves.toMatchObject({ isError: false })
|
||||
release()
|
||||
await expect(disposal).resolves.toBeUndefined()
|
||||
expect(logged).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'auto-review: failed to migrate live Auto sessions',
|
||||
}))
|
||||
await expect(first).resolves.toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
})
|
||||
const thirdId = ToolCallId('post-dispose-call')
|
||||
appendAssistant(session, [{ type: 'tool-call', id: thirdId, name: 'probe', arguments: '{}' }], 3, 1)
|
||||
appendNativeCall(session, thirdId, 'probe', '{}', 3, 1)
|
||||
await expect(ctx.tools.execute({
|
||||
signal: new AbortController().signal, callId: thirdId, name: 'probe', arguments: {}, agent,
|
||||
})).resolves.toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
})
|
||||
expect(probe.runs()).toBe(1)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(ctx.permissionPresets.names).toContain(AUTO_PRESET)
|
||||
expect(() => { ctx.permissionPresets.set(session, AUTO_PRESET) }).toThrow(/integration is closing/)
|
||||
})
|
||||
|
||||
it('loads without a configured read-only preset', async () => {
|
||||
const invalid = new Context()
|
||||
contexts.push(invalid)
|
||||
|
||||
@@ -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: 301c66a6bef83931a20889fd4059f35792c24285
|
||||
README.zh.md: 050a99377d12282d8535eb7c25b67b4f7a98c17a
|
||||
README.md: 25b3e2b3bd3a15cc7cad29dd5203dd0cd11cbffd
|
||||
README.zh.md: 438f68b6e32e61f02b76ff96c3cea226ace6ae17
|
||||
@@ -53,7 +53,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
### Switching presets
|
||||
|
||||
Switching to Auto first runs its synchronous admission check; every preset switch then changes only the knobs whose effective value differs, and selecting the preset already in effect changes nothing. The current value resolves as the still-matching last recorded selection, else the first matching configured entry, else live Auto when its fixed bundle matches, else `custom`. Users switch through the `/permission` command: a bare invocation reports the current preset and every available entry, and a preset argument switches to it.
|
||||
Switching to Auto first runs its synchronous admission check; every preset switch then changes only the knobs whose effective value differs, and selecting the preset already in effect changes nothing. The current value resolves as the still-matching last recorded selection, else the first matching configured entry, else `custom`. Users switch through the `/permission` command: a bare invocation reports the current preset and every available entry, and a preset argument switches to it.
|
||||
|
||||
### What users see
|
||||
|
||||
@@ -87,7 +87,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi
|
||||
|
||||
### Read side and `custom`
|
||||
|
||||
`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties; otherwise the first configured match wins, followed by live Auto when its fixed bundle matches; otherwise the derived `CUSTOM_PRESET` is returned. A missing projection key fails explicitly.
|
||||
`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties; otherwise the first configured match wins; otherwise the derived `CUSTOM_PRESET` is returned. A missing projection key fails explicitly.
|
||||
|
||||
### Session pinning and blank reuse
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ kind: "package-reference"
|
||||
|
||||
### 切换预设
|
||||
|
||||
切换到 Auto 时,服务先同步执行其准入检查;每次预设切换随后只改变实际值不同的旋钮,再次选择当前已生效的预设不会产生任何变化。当前值解析顺序为:仍匹配的最近一次记录选择,其次是配置表中的第一个匹配项,再是固定组合匹配时的 live Auto,否则为 `custom`。用户通过 `/permission` 命令切换:不带参数调用时报告当前预设与所有可用条目,带预设参数时切换过去。
|
||||
切换到 Auto 时,服务先同步执行其准入检查;每次预设切换随后只改变实际值不同的旋钮,再次选择当前已生效的预设不会产生任何变化。当前值解析顺序为:仍匹配的最近一次记录选择,其次是配置表中的第一个匹配项,否则为 `custom`。用户通过 `/permission` 命令切换:不带参数调用时报告当前预设与所有可用条目,带预设参数时切换过去。
|
||||
|
||||
### 用户看到什么
|
||||
|
||||
@@ -87,7 +87,7 @@ kind: "package-reference"
|
||||
|
||||
### 读取侧与 `custom`
|
||||
|
||||
`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出;否则配置表中的第一个匹配项胜出,再是固定组合匹配时的 live Auto;否则返回推导出的 `CUSTOM_PRESET`。投影 key 缺失时会显式失败。
|
||||
`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出;否则配置表中的第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。投影 key 缺失时会显式失败。
|
||||
|
||||
### 会话固定与空白复用
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ export class PermissionPresetService extends TypertRemoteService {
|
||||
/**
|
||||
* Resolve the preset matching the effective knob values. A still-matching
|
||||
* last selection wins shared-bundle ties; otherwise the first configured
|
||||
* match, then Auto when live, wins. Returns
|
||||
* match wins. Returns
|
||||
* {@link CUSTOM_PRESET} when no available preset matches.
|
||||
* @param session - the session whose knob state is read.
|
||||
* @returns the effective preset name, or `custom` when nothing matches.
|
||||
@@ -359,7 +359,6 @@ export class PermissionPresetService extends TypertRemoteService {
|
||||
for (const [name, spec] of Object.entries(this.presets)) {
|
||||
if (matches(spec)) return name
|
||||
}
|
||||
if (this.autoAdmit !== undefined && matches(AUTO_PRESET_SPEC)) return AUTO_PRESET
|
||||
return CUSTOM_PRESET
|
||||
}
|
||||
|
||||
|
||||
@@ -139,19 +139,6 @@ describe('PermissionPresetService', () => {
|
||||
.toThrow(/already registered/)
|
||||
})
|
||||
|
||||
it('derives the fixed Auto bundle when no configured preset matches it', async () => {
|
||||
const ctx = await mounted({ config: { presets: {
|
||||
'workspace-write': { sandbox: 'workspace-write', approval: 'ask' },
|
||||
} } })
|
||||
await mountAuto(ctx)
|
||||
const session = freshSession('sess-auto-derived')
|
||||
session.append('sandbox/mode', { mode: 'danger-full-access' })
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
expect(ctx.permissionPresets.current(session)).toBe(AUTO_PRESET)
|
||||
session.append('sandbox/mode', { mode: 'read-only' })
|
||||
expect(ctx.permissionPresets.current(session)).toBe(CUSTOM_PRESET)
|
||||
})
|
||||
|
||||
it('runs Auto admission before any write, including a no-op selection', async () => {
|
||||
const ctx = await mounted()
|
||||
let admissions = 0
|
||||
|
||||
Reference in new issue
Block a user