From 05f1066f8c02b90e58ace6d2104338a8a3c19650 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:26:29 +0800 Subject: [PATCH] fix(permission-presets): reject publication before initialization --- .../permission-presets/README.i18n.yaml | 4 +- .../interaction/permission-presets/README.md | 2 +- .../permission-presets/README.zh.md | 2 +- .../permission-presets/src/index.ts | 14 ++- .../tests/initialization.spec.ts | 102 ++++++++++++++++++ 5 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 packages/interaction/permission-presets/tests/initialization.spec.ts diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 46968ede01..5ba3626c3f 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/README.i18n.yaml @@ -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 diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index d1179a9926..5b8e78077c 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -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 diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 6afa0a0163..73e83dd68a 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -92,7 +92,7 @@ kind: "package-reference" ### 会话固定与空白复用 -挂载时会固定所有存活与未来的会话:真正全新的会话获得配置默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。投影自有的 seed 标记让该判断与旋钮值共用同一份增量状态。存储的 `auto` 身份在 Auto integration 缺失或拒绝准入时无法发布;服务既不会改写它,也不会静默推导为 Full access。 +挂载时会固定所有存活与未来的会话:真正全新的会话获得配置默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。投影自有的 seed 标记让该判断与旋钮值共用同一份增量状态。同步创建监听器在投影注册开始前安装。读取句柄尚未就绪时,Session 创建失败,Agent 创建回滚;调用方必须等待插件激活完成后再创建或恢复会话。存储的 `auto` 身份在 Auto integration 缺失或拒绝准入时无法发布;服务既不会改写它,也不会静默推导为 Full access。 ### 目录、投影与可选命令 diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 935de3d5fa..de98208579 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -197,7 +197,7 @@ export class PermissionPresetService extends TypertRemoteService { private autoAdmit: (() => void) | undefined private defaultSettings: () => PermissionSettings - private permissionProjectionHandle!: ProjectionHandle + private permissionProjectionHandle: ProjectionHandle | undefined constructor(ctx: Context, config: Config) { super(ctx, 'permissionPresets') @@ -229,6 +229,9 @@ export class PermissionPresetService extends TypertRemoteService { protected async [Service.init](): Promise { const ctx = this.ctx + ctx.on('session/created', (session) => { + this.pinInitialPermission(session) + }) const selectionSchema = zod.object({ currentValue: zod.string().min(1), }) as zod.ZodType @@ -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) diff --git a/packages/interaction/permission-presets/tests/initialization.spec.ts b/packages/interaction/permission-presets/tests/initialization.spec.ts new file mode 100644 index 0000000000..82a41f49ff --- /dev/null +++ b/packages/interaction/permission-presets/tests/initialization.spec.ts @@ -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() + const release = Promise.withResolvers() + 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) + }) +})