fix(permission-presets): reject publication before initialization

This commit is contained in:
_Kerman committed 2026-10-19 15:26:29 +08:00
1 parent ab7bd24b60
commit 05f1066f8c
5 files changed
+115 -9

No files matched your search

@@ -42,8 +42,8 @@
en: 76deeb5fbcdf7525
zh: e96ad7e107947c23
/deepseek-ai-dsh-permission-presets/understand-the-implementation/session-pinning-and-blank-reuse:
en: 893cea88bce22903
zh: 2431e19ced670511
en: 048f156ac376668a
zh: 62399824edc1cda6
/deepseek-ai-dsh-permission-presets/understand-the-implementation/catalog-projection-and-optional-command:
en: 341f80202edd66a3
zh: bd8ebb9d011b547f
@@ -92,7 +92,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi
### Session pinning and blank reuse
Mounting pins every live and future session: a genuinely fresh session gains the configured default preset and both knob facts, while seeded or partially initialized sessions keep their effective knob values and gain only missing durable facts. The projection-owned seed marker makes this decision from the same incremental state as the knob values. A stored `auto` identity fails publication when the Auto integration is absent or rejects admission; the service neither rewrites it nor silently derives Full access.
Mounting pins every live and future session: a genuinely fresh session gains the configured default preset and both knob facts, while seeded or partially initialized sessions keep their effective knob values and gain only missing durable facts. The projection-owned seed marker makes this decision from the same incremental state as the knob values. The synchronous creation listener is installed before projection registration starts. While its reader is not ready, Session creation fails and Agent creation rolls back; callers must await plugin activation before creating or restoring sessions. A stored `auto` identity fails publication when the Auto integration is absent or rejects admission; the service neither rewrites it nor silently derives Full access.
### Catalog, projection, and optional command
@@ -92,7 +92,7 @@ kind: "package-reference"
### 会话固定与空白复用
挂载时会固定所有存活与未来的会话:真正全新的会话获得配置默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。投影自有的 seed 标记让该判断与旋钮值共用同一份增量状态。存储的 `auto` 身份在 Auto integration 缺失或拒绝准入时无法发布;服务既不会改写它,也不会静默推导为 Full access。
挂载时会固定所有存活与未来的会话:真正全新的会话获得配置默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。投影自有的 seed 标记让该判断与旋钮值共用同一份增量状态。同步创建监听器在投影注册开始前安装。读取句柄尚未就绪时,Session 创建失败,Agent 创建回滚;调用方必须等待插件激活完成后再创建或恢复会话。存储的 `auto` 身份在 Auto integration 缺失或拒绝准入时无法发布;服务既不会改写它,也不会静默推导为 Full access。
### 目录、投影与可选命令
@@ -197,7 +197,7 @@ export class PermissionPresetService extends TypertRemoteService {
private autoAdmit: (() => void) | undefined
private defaultSettings: () => PermissionSettings
private permissionProjectionHandle!: ProjectionHandle<PermissionProjectionState>
private permissionProjectionHandle: ProjectionHandle<PermissionProjectionState> | undefined
constructor(ctx: Context, config: Config) {
super(ctx, 'permissionPresets')
@@ -229,6 +229,9 @@ export class PermissionPresetService extends TypertRemoteService {
protected async [Service.init](): Promise<void> {
const ctx = this.ctx
ctx.on('session/created', (session) => {
this.pinInitialPermission(session)
})
const selectionSchema = zod.object({
currentValue: zod.string().min(1),
}) as zod.ZodType<PermissionSelection>
@@ -240,9 +243,6 @@ export class PermissionPresetService extends TypertRemoteService {
apply: applyPermissionEvent,
wire: { viewSchema: selectionSchema, view: state => ({ currentValue: this.derive(state) }) },
})
ctx.on('session/created', (session) => {
this.pinInitialPermission(session)
})
for (const session of ctx.sessions.list()) {
this.pinInitialPermission(session)
}
@@ -324,6 +324,9 @@ export class PermissionPresetService extends TypertRemoteService {
}
private permissionState(session: Session): PermissionProjectionState {
if (this.permissionProjectionHandle === undefined) {
throw new Error('permission: presets are not ready; await plugin activation before creating or restoring a session')
}
return this.permissionProjectionHandle.stateOf(session)
}
@@ -419,7 +422,8 @@ export class PermissionPresetService extends TypertRemoteService {
* initialized sessions preserve their effective knob values and only gain
* the missing durable facts. A stored Auto identity requires its live
* integration and passes its admission check before
* publication.
* publication. Creation while the permission reader is still registering
* fails synchronously so the publishing caller can roll back.
*/
private pinInitialPermission(session: Session): void {
const state = this.permissionState(session)
@@ -0,0 +1,102 @@
/** Permission admission must finish before a Session or Agent is published. */
import { Context } from '@deepseek-ai/cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import PermissionPresetService, { AUTO_PRESET } from '@deepseek-ai/dsh-permission-presets'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
async function setup() {
const ctx = new Context()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
onTestFinished(async () => {
release.resolve(undefined)
await ctx.fiber.dispose()
})
await mountAgentLoopTestDependencies(ctx, { workingDirectory: true })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(ApprovalService, { policy: 'ask' })
ctx.provide('shell', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission admission tests do not execute shell commands') },
run() { throw new Error('permission admission tests do not execute shell commands') },
start() { throw new Error('permission admission tests do not execute shell commands') },
})
const register = ctx.sessionProjections.register.bind(ctx.sessionProjections)
const spy = vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (definition) => {
const handle = await register(definition)
if (definition.key === 'permissions') {
entered.resolve(undefined)
await release.promise
}
return handle
})
onTestFinished(() => { spy.mockRestore() })
const fiber = ctx.plugin(PermissionPresetService)
await entered.promise
return { ctx, fiber, release }
}
function autoSeed() {
const source = Session.create(SessionId('auto-source'))
source.append('permission/preset', { preset: AUTO_PRESET })
source.append('sandbox/mode', { mode: 'danger-full-access' })
source.append('approval/policy', { policy: 'never' })
return source.snapshotEvents()
}
describe('permission admission during activation', () => {
it('rejects synchronous Session creation until the permission reader is ready', async () => {
const { ctx, fiber, release } = await setup()
const id = SessionId('pending-session')
expect(() => ctx.sessions.create(id)).toThrow(/permission:.*not ready/)
expect(ctx.sessions.get(id)).toBeUndefined()
release.resolve(undefined)
await fiber
const session = ctx.sessions.create(id)
expect(ctx.permissionPresets.current(session)).toBe('workspace-write')
expect(ctx.approval.overrideOf(session)).toBe('ask')
expect(session.snapshotEvents().map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
})
it.each(['fresh', 'auto'] as const)('rolls back %s Agent creation before publication while registration is pending', async (kind) => {
const { ctx, fiber, release } = await setup()
const id = SessionId('pending-agent')
const options = { sessionId: id, ...kind === 'auto' ? { seed: autoSeed() } : {} }
const published = vi.fn()
ctx.on('agent/created', published)
await expect(ctx.agents.create(options).then(() => 'published')).rejects.toThrow(/permission:.*not ready/)
expect(published).not.toHaveBeenCalled()
expect(ctx.agents.get(id)).toBeUndefined()
expect(ctx.sessions.get(id)).toBeUndefined()
release.resolve(undefined)
await fiber
expect(ctx.agents.get(id)).toBeUndefined()
expect(ctx.sessions.get(id)).toBeUndefined()
if (kind === 'auto') {
await expect(ctx.agents.create(options)).rejects.toThrow(/cannot restore preset "auto"/)
expect(published).not.toHaveBeenCalled()
expect(ctx.agents.get(id)).toBeUndefined()
expect(ctx.sessions.get(id)).toBeUndefined()
const admit = vi.fn()
ctx.permissionPresets.registerAuto(admit)
const { agent } = await ctx.agents.create(options)
expect(admit).toHaveBeenCalledTimes(1)
expect(ctx.permissionPresets.current(agent.session)).toBe(AUTO_PRESET)
expect(ctx.approval.overrideOf(agent.session)).toBe('never')
} else {
const { agent } = await ctx.agents.create(options)
expect(ctx.permissionPresets.current(agent.session)).toBe('workspace-write')
expect(ctx.approval.overrideOf(agent.session)).toBe('ask')
}
expect(published).toHaveBeenCalledTimes(1)
})
})