From 451b7b0446b4220671cf46776983dd0022a20eef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:23:39 +0800 Subject: [PATCH] feat(permission): permissions projection unit and /permission command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read side becomes the 'permissions' session projection: src/types.ts is the key declaration's one home (PermissionSelect = whole select: table options in declaration order plus a current-only 'custom'), served through ./types and the ./client re-export. The unit folds the three whole-value knob events (permission/preset, sandbox/mode, approval/policy) into a plain KnobState and views the select over the composition defaults the service already owns; current() shares the same derive step, so the fold exists once. The write side becomes the /permission command (the /plan registration shape): bare invocation reports the current preset and the table, a preset argument switches through set() immediately — no turn anchoring (knob events need no enclosure), no dedicated RPC. Both children activate only when their registry is composed. --- packages/ui/permission/README.md | 3 +- packages/ui/permission/package.json | 16 +- packages/ui/permission/src/client.ts | 10 ++ packages/ui/permission/src/index.ts | 151 ++++++++++++++++-- packages/ui/permission/src/types.ts | 44 +++++ .../ui/permission/tests/projection.spec.ts | 116 ++++++++++++++ packages/ui/permission/tsconfig.json | 6 + pnpm-lock.yaml | 9 ++ 8 files changed, 336 insertions(+), 19 deletions(-) create mode 100644 packages/ui/permission/src/client.ts create mode 100644 packages/ui/permission/src/types.ts create mode 100644 packages/ui/permission/tests/projection.spec.ts diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 6a59ad9425..814085ed6f 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -8,6 +8,8 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. + ## Model Experience Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only. @@ -18,7 +20,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **No shipped composition currently mounts the service** — the ACP bridge was its only selector before [ACP became automation-only](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md); the preset table is kept for the interactive front door that next exposes a runtime policy switch. - **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. diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index 3ec4cc7685..c5021af37b 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -15,12 +15,21 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -28,22 +37,27 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/ui/permission/src/client.ts b/packages/ui/permission/src/client.ts new file mode 100644 index 0000000000..d758cf5960 --- /dev/null +++ b/packages/ui/permission/src/client.ts @@ -0,0 +1,10 @@ +/** + * Client-namespace projection of the permission domain: a pure re-export of + * the package's types outlet. Client code imports ONLY the client namespace + * (repo discipline), so `./client` projects the same single-source content + * `./types` serves to host consumers — zero duplication. + * + * @module @deepseek-ai/dsh-permission/client + */ + +export type * from './types.ts' diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index d44dff3df4..597d17199a 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -3,13 +3,16 @@ * approval-policy knobs. A switch records the selected preset, then writes * changed knobs through their canonical setters. Execution, prompt narration, * and replay keep reading their knob folds. The preset event preserves user - * intent when two presets share a bundle. + * intent when two presets share a bundle. The read side ships as the + * `permissions` session projection; the write side ships as the + * `/permission` command — both optional children over the same service. * * @module dsh-permission */ import { Context, Service } from 'cordis' import z from 'schemastery' +import { z as zod } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -18,6 +21,16 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children. +import type {} from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-commands' +import type { PermissionSelect, PresetOption } from './types.ts' + +// The `permissions` projection-key declaration lives in src/types.ts (its one +// home); this re-export projects the type face onto the package root AND +// keeps the module edge in the emitted index.d.ts, so aggregate programs +// consuming the declarations still receive the SessionProjectionMap merge. +export type * from './types.ts' declare module 'cordis' { interface Context { @@ -49,16 +62,6 @@ export interface PresetSpec { description?: string } -/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */ -export interface PresetOption { - /** Stable option value: the table key, or `custom`. */ - value: string - /** The display label. */ - name: string - /** One user-facing sentence on what the value means. */ - description?: string -} - /** * Returned when effective knob values match no table entry. Clients may show * it as the current value, but it is never a switch target or event payload. @@ -79,6 +82,50 @@ export function effectivePermissionPreset(events: readonly SessionEvent[]): stri return undefined } +/** + * The projection unit's state: the last seen value of each knob event, null + * before an override (composition defaults apply at view time). Plain JSON + * (persisted-cache precondition). + */ +export interface KnobState { + /** Last `permission/preset` payload, or null. */ + preset: string | null + /** Last `sandbox/mode` payload, or null. */ + sandbox: SandboxMode | null + /** Last `approval/policy` payload, or null. */ + approval: ApprovalPolicy | null +} + +/** State for the empty log: every knob at its composition default. */ +const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null } + +/** + * One-event knob transition (the projection unit's `apply`). Uninterested + * events return the same reference — the registry's change gate. + * @param state - the folded knob state before `event`. + * @param event - one committed session event. + * @returns the next state; the same reference when the event is not a knob. + */ +export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState { + switch (event.type) { + case 'permission/preset': + return { ...state, preset: event.data.preset } + case 'sandbox/mode': + return { ...state, sandbox: event.data.mode } + case 'approval/policy': + return { ...state, approval: event.data.policy } + default: + return state + } +} + +/** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */ +function foldKnobs(events: readonly SessionEvent[]): KnobState { + let state = EMPTY_KNOBS + for (const event of events) state = applyKnobEvent(state, event) + return state +} + /** The {@link PermissionService} config: the deployment's preset table. */ export interface Config { /** @@ -128,6 +175,55 @@ export class PermissionService extends Service { if (ctx.bash.sandboxMode === undefined) { throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration') } + + // The permissions projection unit: fold the three whole-value knob + // events; view derives the select over the composition defaults this + // service already owns. The unit child activates only when a projection + // registry is composed (headless assemblies stay unaffected). + // zod `.optional()` types the key `string | undefined` while the domain + // says `description?: string`; on the JSON wire the two serialize + // identically (absent), so the cast records exactly that + // exactOptionalPropertyTypes widening (the Wire precedent). + const selectSchema = zod.object({ + options: zod.array(zod.object({ + value: zod.string().min(1), + name: zod.string().min(1), + description: zod.string().optional(), + })), + currentValue: zod.string().min(1), + }) as unknown as zod.ZodType + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'permissions', KnobState>({ + key: 'permissions', + schema: selectSchema, + init: () => EMPTY_KNOBS, + apply: applyKnobEvent, + view: state => this.selectFor(state), + stateVersion: 1, + }) + }) + + // The /permission command: the one write path a web client uses (the + // popup contribution submits the picked preset as this line). The child + // activates only when a command registry is composed. + ctx.inject(['commands'], (commandCtx) => { + commandCtx.commands.register({ + name: 'permission', + description: 'Switch the permission preset (sandbox mode + approval policy)', + input: { hint: '' }, + handler: ({ agent, rawInput }) => { + const name = rawInput.trim() + if (name === '') { + return { kind: 'success', text: `Current permission preset: ${this.current(agent.session.events)}. Available: ${this.names.join(', ')}.` } + } + if (!this.names.includes(name)) { + return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` } + } + this.set(agent.session, name) + return { kind: 'success', text: `Permission preset: ${name}.` } + }, + }) + }) } /** @@ -146,13 +242,17 @@ export class PermissionService extends Service { * @returns the effective preset name, or `custom` when nothing matches. */ current(events: readonly SessionEvent[]): string { - const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode - const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask' + return this.derive(foldKnobs(events)) + } + + /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */ + private derive(state: KnobState): string { + const sandbox = state.sandbox ?? this.ctx.bash.sandboxMode + const approval = state.approval ?? this.ctx.approval.config.policy ?? 'ask' const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval - const folded = effectivePermissionPreset(events) - if (folded !== undefined) { - const spec = this.presets[folded] - if (spec !== undefined && matches(spec)) return folded + if (state.preset !== null) { + const spec = this.presets[state.preset] + if (spec !== undefined && matches(spec)) return state.preset } for (const [name, spec] of Object.entries(this.presets)) { if (matches(spec)) return name @@ -160,6 +260,23 @@ export class PermissionService extends Service { return CUSTOM_PRESET } + /** + * Build the whole select value for one folded knob state: every table + * option in declaration order, `custom` appended exactly while derived. + * @param state - the folded knob overrides. + * @returns the `permissions` projection payload. + */ + selectFor(state: KnobState): PermissionSelect { + const currentValue = this.derive(state) + return { + options: [ + ...this.names.map(name => this.optionOf(name)), + ...currentValue === CUSTOM_PRESET ? [this.optionOf(CUSTOM_PRESET)] : [], + ], + currentValue, + } + } + /** * Resolve a preset's knob bundle. * @param name - the preset name to resolve. diff --git a/packages/ui/permission/src/types.ts b/packages/ui/permission/src/types.ts new file mode 100644 index 0000000000..607cc96837 --- /dev/null +++ b/packages/ui/permission/src/types.ts @@ -0,0 +1,44 @@ +/** + * Pure types of the permission domain: the ONE home of the `permissions` + * projection-key declaration plus its payload types, free of this package's + * host-side value imports (cordis, schemastery). Two namespace projections + * serve it — the package root re-export for host consumers, `./client` (the + * browser half-entry's re-export) for client aggregates — with zero content + * duplication. + * + * @module @deepseek-ai/dsh-permission/types + */ + +/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */ +export interface PresetOption { + /** Stable option value: the table key, or `custom`. */ + value: string + /** The display label. */ + name: string + /** One user-facing sentence on what the value means; omitted when not configured. */ + description?: string +} + +/** + * Whole `permissions` projection value: every switchable preset in table + * order (plus the derived current-only `custom` when the knobs match no + * entry) and the effective current value. + */ +export interface PermissionSelect { + /** Switchable presets, plus `custom` appended exactly while it is current. */ + options: PresetOption[] + /** The effective current value: a preset table key, or `custom`. */ + currentValue: string +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** + * The session's permission select, folded from the three whole-value + * knob events (`permission/preset`, `sandbox/mode`, `approval/policy`) + * over the composition defaults. Key absence means no permission service + * is composed — clients hide the control. + */ + permissions: PermissionSelect + } +} diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts new file mode 100644 index 0000000000..2046adb6a1 --- /dev/null +++ b/packages/ui/permission/tests/projection.spec.ts @@ -0,0 +1,116 @@ +/** + * The `permissions` projection unit and the `/permission` command: mounting + * the permission service beside the projection registry serves the whole + * select (table options + effective current value, `custom` appended exactly + * while derived) folded from the three knob events over the composition + * defaults; the command child registers `/permission` whose handler switches + * through `permission.set` (bare invocation reports, unknown names error); + * compositions without either registry are unaffected; unmounting the + * service removes the key (HMR safety). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createScope } from '@deepseek-ai/dsh-scope' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import CommandService from '@deepseek-ai/dsh-commands' +import PermissionService from '@deepseek-ai/dsh-permission' +import type { Config } from '@deepseek-ai/dsh-permission' + +async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(CommandService) + 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' } }) + if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {}) + return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) } +} + +/** Mint a scoped agent over a live session (the command executor's addressing shape). */ +async function agentFor(ctx: Context, session: Session): Promise { + const agent = { id: session.id, session } as Agent + await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] })) + return agent +} + +describe('permissions projection unit', () => { + it('serves the composition-default select at zero events', async () => { + const { ctx, session } = await harness() + const value = ctx.sessionProjections.snapshot(session).values.permissions + expect(value).toMatchObject({ currentValue: 'workspace-write' }) + expect(value?.options.map(option => option.value)).toEqual(['workspace-write', 'danger-full-access']) + }) + + it('folds the knob events and notifies the change feed per knob append', async () => { + const { ctx, session } = await harness() + const changes: { key: string; value: unknown; seq: number }[] = [] + ctx.sessionProjections.onChanged((_session, key, value, seq) => { + changes.push({ key, value, seq }) + }) + ctx.permission.set(session, 'danger-full-access') + // set() appends preset + sandbox/mode + approval/policy: three knob transitions. + expect(changes).toHaveLength(3) + expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } }) + // Unrelated event: same-reference apply, no notification. + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(changes).toHaveLength(3) + }) + + it('appends custom as a current-only option when the knobs match no preset', async () => { + const { ctx, session } = await harness() + session.append('sandbox/mode', { mode: 'read-only' }) + const value = ctx.sessionProjections.snapshot(session).values.permissions + expect(value?.currentValue).toBe('custom') + expect(value?.options.at(-1)).toMatchObject({ value: 'custom', name: 'Custom' }) + }) + + it('has no permissions key without the service, and drops it on unload (HMR safety)', async () => { + const { ctx, session } = await harness({ withPermission: false }) + expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) + const fiber = await ctx.plugin(PermissionService, {}) + expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' }) + await fiber.dispose() + expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) + }) +}) + +describe('/permission command', () => { + it('switches through permission.set and logs the lifecycle pair', async () => { + const { ctx, session } = await harness() + const agent = await agentFor(ctx, session) + const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'success', text: 'Permission preset: danger-full-access.' }) + expect(ctx.permission.current(session.events)).toBe('danger-full-access') + const run = session.events.find(event => event.type === 'command/run') + expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) + }) + + it('reports the current preset and the table on bare invocation', async () => { + const { ctx, session } = await harness() + const agent = await agentFor(ctx, session) + const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal) + expect(execution?.result).toEqual({ + kind: 'success', + text: 'Current permission preset: workspace-write. Available: workspace-write, danger-full-access.', + }) + expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0) + }) + + it('rejects an unknown preset without touching the log', async () => { + const { ctx, session } = await harness() + const agent = await agentFor(ctx, session) + const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) + expect(execution?.result).toMatchObject({ kind: 'error' }) + expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0) + }) +}) diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 0971399f53..493fbf358e 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -34,6 +34,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../session-projection/session-projection" + }, + { + "path": "../commands" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5309a6838..3d1760f9e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4569,10 +4569,16 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4585,6 +4591,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval