refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
6
packages/interaction/permission/README.i18n.yaml
Normal file
6
packages/interaction/permission/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/README.md
|
||||
README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4
|
||||
README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa
|
||||
28
packages/interaction/permission/README.md
Normal file
28
packages/interaction/permission/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# @deepseek-ai/dsh-permission
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs.
|
||||
|
||||
`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, 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).
|
||||
|
||||
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.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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.
|
||||
28
packages/interaction/permission/README.zh.md
Normal file
28
packages/interaction/permission/README.zh.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# @deepseek-ai/dsh-permission
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
通过 `ctx.permission`([`PermissionService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。
|
||||
|
||||
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
|
||||
|
||||
该服务拥有 `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)。
|
||||
|
||||
两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permission` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接使缓存失效;具名消费方拥有所有请求前缀变更。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只组合两个机制级可调参数**:预设选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。
|
||||
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。
|
||||
- **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。
|
||||
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。
|
||||
64
packages/interaction/permission/package.json
Normal file
64
packages/interaction/permission/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-permission",
|
||||
"description": "User-facing permission presets (ctx.permission) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"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"
|
||||
],
|
||||
"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-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"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-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
10
packages/interaction/permission/src/client.ts
Normal file
10
packages/interaction/permission/src/client.ts
Normal file
@@ -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'
|
||||
433
packages/interaction/permission/src/index.ts
Normal file
433
packages/interaction/permission/src/index.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* User-facing permission presets over the independent sandbox-mode and
|
||||
* 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. 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'
|
||||
// Side-effect type import: declaration-merges `ctx.bash` (the capability fact
|
||||
// `sandboxMode` this service reads), without a value dependency on the seam.
|
||||
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'
|
||||
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
// 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 {
|
||||
permission: PermissionService
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* Records the selected preset as durable, log-only user intent. The knob
|
||||
* events follow in the same turn and control execution; this event stays
|
||||
* out of the model transcript and lets {@link effectivePermissionPreset}
|
||||
* preserve a selection when bundles match.
|
||||
*/
|
||||
'permission/preset': { preset: string }
|
||||
}
|
||||
}
|
||||
|
||||
/** One preset's sandbox/approval bundle and optional client presentation. */
|
||||
export interface PresetSpec {
|
||||
/** The `sandbox/mode` value the preset writes through. */
|
||||
sandbox: SandboxMode
|
||||
/** The `approval/policy` value the preset writes through. */
|
||||
approval: ApprovalPolicy
|
||||
/** The display label a client shows for this preset; the raw table key when omitted. */
|
||||
name?: string
|
||||
/** One user-facing sentence on what the preset means; omitted when not configured. */
|
||||
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.
|
||||
*/
|
||||
export const CUSTOM_PRESET = 'custom'
|
||||
|
||||
/** Settings namespace carrying the default for future sessions. */
|
||||
export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
|
||||
|
||||
/**
|
||||
* Fold the last selected preset from the durable log; replay needs no catch-up
|
||||
* state.
|
||||
* @param events - session events in log order; other event types are ignored.
|
||||
* @returns the last selected preset, or undefined when none was recorded.
|
||||
*/
|
||||
export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'permission/preset') return event.data.preset
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** User setting resolved when a new session receives its initial permission. */
|
||||
export interface PermissionSettings {
|
||||
/** Preset pinned into a newly created session. */
|
||||
defaultPreset: string
|
||||
}
|
||||
|
||||
/** The {@link PermissionService} config: preset table and composition default. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The preset table: name → knob bundle. Defaults to `workspace-write`
|
||||
* (workspace-write + ask) and `danger-full-access` (danger-full-access +
|
||||
* never). The name `custom` is reserved for the derived not-a-preset state.
|
||||
*/
|
||||
presets?: Record<string, PresetSpec>
|
||||
/**
|
||||
* Default for new sessions. When omitted, the preset matching the composed
|
||||
* sandbox and approval defaults is used.
|
||||
*/
|
||||
defaultPreset?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the deployment's permission presets and their write path. Requires a
|
||||
* confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are
|
||||
* reported as {@link CUSTOM_PRESET}, not an error.
|
||||
*/
|
||||
export class PermissionService extends Service {
|
||||
// Inline schema call: the config catalog walks `static Config` statically.
|
||||
static Config: z<Config> = z.object({
|
||||
presets: z.dict(z.object({
|
||||
sandbox: z.union(SANDBOX_MODES as SandboxMode[]).required(),
|
||||
approval: z.union(APPROVAL_POLICIES as ApprovalPolicy[]).required(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
})).default({
|
||||
'workspace-write': {
|
||||
sandbox: 'workspace-write', approval: 'ask',
|
||||
name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
|
||||
},
|
||||
'danger-full-access': {
|
||||
sandbox: 'danger-full-access', approval: 'never',
|
||||
name: 'danger-full-access', description: 'Full file access without approval prompts.',
|
||||
},
|
||||
}),
|
||||
defaultPreset: z.string(),
|
||||
})
|
||||
|
||||
static inject = ['bash', 'approval', 'sessions']
|
||||
|
||||
private readonly presets: Record<string, PresetSpec>
|
||||
private defaultSettings: () => PermissionSettings
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'permission')
|
||||
// The schema defaulted the table — the cast records that runtime fact.
|
||||
this.presets = config.presets as Record<string, PresetSpec>
|
||||
if (CUSTOM_PRESET in this.presets) {
|
||||
throw new Error(`permission: "${CUSTOM_PRESET}" is reserved for the derived not-a-preset state and cannot name a table entry`)
|
||||
}
|
||||
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')
|
||||
}
|
||||
const inferredDefault = this.derive(EMPTY_KNOBS)
|
||||
const defaultPreset = config.defaultPreset ?? inferredDefault
|
||||
if (defaultPreset === CUSTOM_PRESET) {
|
||||
throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly')
|
||||
}
|
||||
this.resolve(defaultPreset)
|
||||
const baseSettings: PermissionSettings = { defaultPreset }
|
||||
this.defaultSettings = () => baseSettings
|
||||
const presetChoices = this.names.map((name) => {
|
||||
const choice = z.const(name)
|
||||
const label = this.presets[name]?.name
|
||||
return label === undefined ? choice : choice.description(label)
|
||||
})
|
||||
const settingsSchema: z<PermissionSettings> = z.object({
|
||||
defaultPreset: z.union(presetChoices).required(),
|
||||
})
|
||||
installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, {
|
||||
setSource: (current) => {
|
||||
this.defaultSettings = current
|
||||
},
|
||||
// The source thunk reads the latest scope snapshot at session creation;
|
||||
// no process-level registration needs replacement on change.
|
||||
onChange: () => {},
|
||||
})
|
||||
|
||||
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
|
||||
// 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<T> 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<PermissionSelect>
|
||||
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: '<preset>' },
|
||||
// No settlement text labels its value with this command's own name: a
|
||||
// surface that renders `name · text` (the web command row) would
|
||||
// otherwise read `permission · Permission preset: workspace-write.`
|
||||
handler: ({ agent, rawInput }) => {
|
||||
const name = rawInput.trim()
|
||||
if (name === '') {
|
||||
return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` }
|
||||
}
|
||||
if (!this.names.includes(name)) {
|
||||
return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
|
||||
}
|
||||
this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) })
|
||||
return { kind: 'success', text: `preset ${name}` }
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The advertised preset names, in the preset table's declaration order.
|
||||
* @returns every switchable preset name.
|
||||
*/
|
||||
get names(): readonly string[] {
|
||||
return Object.keys(this.presets)
|
||||
}
|
||||
|
||||
/**
|
||||
* The preset currently selected as the default for future sessions.
|
||||
* @returns the resolved settings value, or the composition default without
|
||||
* a mounted settings provider.
|
||||
*/
|
||||
get defaultPreset(): string {
|
||||
return this.defaultSettings().defaultPreset
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preset matching the effective knob values. A still-matching
|
||||
* last selection wins shared-bundle ties; otherwise the first table match
|
||||
* wins, or {@link CUSTOM_PRESET} when no entry matches.
|
||||
* @param events - the session's events in log order.
|
||||
* @returns the effective preset name, or `custom` when nothing matches.
|
||||
*/
|
||||
current(events: readonly SessionEvent[]): string {
|
||||
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
|
||||
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
|
||||
}
|
||||
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.
|
||||
* @returns the configured bundle.
|
||||
* @throws when `name` is not in the table.
|
||||
*/
|
||||
resolve(name: string): PresetSpec {
|
||||
const spec = this.presets[name]
|
||||
if (spec === undefined) {
|
||||
throw new Error(`permission: unknown preset "${name}" (known: ${Object.keys(this.presets).join(', ')})`)
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the client option for a table entry or {@link CUSTOM_PRESET}. A
|
||||
* missing label falls back to the table key.
|
||||
* @param name - a table key, or `custom`.
|
||||
* @returns the option a client renders.
|
||||
* @throws when `name` is neither a table key nor `custom`.
|
||||
*/
|
||||
optionOf(name: string): PresetOption {
|
||||
if (name === CUSTOM_PRESET) {
|
||||
return { value: CUSTOM_PRESET, name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }
|
||||
}
|
||||
const spec = this.resolve(name)
|
||||
return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a changed preset, then update each changed knob through its own
|
||||
* setter. Selecting the effective preset again appends nothing.
|
||||
* @param session - the session the switch belongs to.
|
||||
* @param name - the preset to switch to; unknown names throw.
|
||||
*/
|
||||
set(session: Session, name: string): void {
|
||||
this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) })
|
||||
}
|
||||
|
||||
/** Apply one preset with the caller-selected live or initialization policy writer. */
|
||||
private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
|
||||
const spec = this.resolve(name)
|
||||
if (this.current(session.events) !== name) {
|
||||
session.append('permission/preset', { preset: name })
|
||||
}
|
||||
const events = session.events
|
||||
if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) {
|
||||
setSandboxMode(session, spec.sandbox)
|
||||
}
|
||||
if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
|
||||
setApproval(spec.approval)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill every missing permission fact before a session is published. A
|
||||
* genuinely fresh session uses the current user default; seeded or partially
|
||||
* initialized sessions preserve their effective knob values and only gain
|
||||
* the missing durable facts.
|
||||
*/
|
||||
private pinInitialPermission(session: Session): void {
|
||||
const events = session.events
|
||||
const selected = effectivePermissionPreset(events)
|
||||
const sandbox = effectiveSandboxMode(events)
|
||||
const approval = effectiveApprovalPolicy(events)
|
||||
const seeded = events.some(event => event.type === 'session/end-seed')
|
||||
if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
|
||||
const name = this.defaultPreset
|
||||
const spec = this.resolve(name)
|
||||
session.append('permission/preset', { preset: name })
|
||||
setSandboxMode(session, spec.sandbox)
|
||||
setApprovalPolicy(session, spec.approval)
|
||||
return
|
||||
}
|
||||
|
||||
const state: KnobState = {
|
||||
preset: selected ?? null,
|
||||
sandbox: sandbox ?? null,
|
||||
approval: approval ?? null,
|
||||
}
|
||||
const effective = this.derive(state)
|
||||
if (selected === undefined && effective !== CUSTOM_PRESET) {
|
||||
session.append('permission/preset', { preset: effective })
|
||||
}
|
||||
if (sandbox === undefined) {
|
||||
setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode)
|
||||
}
|
||||
if (approval === undefined) {
|
||||
setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PermissionService
|
||||
39
packages/interaction/permission/src/invariant.ts
Normal file
39
packages/interaction/permission/src/invariant.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-permission'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'permission-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate the package-owned event shape and ignore unrelated events. */
|
||||
function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void {
|
||||
if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) {
|
||||
fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation that loaded and newly appended preset events remain resolvable. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(ctx, event, fail)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const event = (args as [Session, SessionEvent])[1]
|
||||
validateEvent(ctx, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['permission', 'sessions'] })
|
||||
|
||||
/**
|
||||
* Register the permission invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
44
packages/interaction/permission/src/types.ts
Normal file
44
packages/interaction/permission/src/types.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
53
packages/interaction/permission/tests/invariant.spec.ts
Normal file
53
packages/interaction/permission/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, Service } from 'cordis'
|
||||
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
class PermissionProbe extends Service {
|
||||
readonly names = ['safe', 'trusted']
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'permission')
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(PermissionProbe)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(PermissionInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function presetEvent(preset: string): SessionEvent {
|
||||
return { type: 'permission/preset', seq: 0, time: 0, data: { preset } }
|
||||
}
|
||||
|
||||
describe('permission invariants', () => {
|
||||
it('accepts configured preset events and ignores other session data', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, presetEvent('safe')) }).not.toThrow()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, {
|
||||
type: 'turn/end', seq: 0, time: 0, data: {},
|
||||
} as SessionEvent) }).not.toThrow()
|
||||
expect(() => { ctx.emit('tools/change') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a durable preset that the active table cannot resolve', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, presetEvent('missing')) })
|
||||
.toThrow(/unknown preset "missing"/)
|
||||
})
|
||||
|
||||
it('rejects an unknown preset already present on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(PermissionProbe)
|
||||
ctx.sessions.create().append('permission/preset', { preset: 'missing' })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PermissionInvariant).then(() => undefined)).rejects.toThrow(/unknown preset "missing"/)
|
||||
})
|
||||
})
|
||||
301
packages/interaction/permission/tests/permission.spec.ts
Normal file
301
packages/interaction/permission/tests/permission.spec.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import PermissionService, {
|
||||
CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE,
|
||||
} from '@deepseek-ai/dsh-permission'
|
||||
import type { Config } from '@deepseek-ai/dsh-permission'
|
||||
import { Settings } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
|
||||
/** Writable memory provider for the permission/settings lifecycle specs. */
|
||||
class MemorySettings extends Settings {
|
||||
readonly doc: Record<string, unknown> = {}
|
||||
readonly writable = true
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc[ns] = structuredClone(section)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function mounted(options: {
|
||||
config?: Config
|
||||
bashDefault?: SandboxMode | undefined
|
||||
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') },
|
||||
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: 'approvalDefault' in options ? options.approvalDefault : 'ask' } })
|
||||
await ctx.plugin(PermissionService, options.config ?? {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
function freshSession(id: string): Session {
|
||||
return Session.create(SessionId(id))
|
||||
}
|
||||
|
||||
async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(MemorySettings)
|
||||
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: 'approvalDefault' in options ? options.approvalDefault : 'ask' },
|
||||
})
|
||||
await ctx.plugin(PermissionService, {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('effectivePermissionPreset', () => {
|
||||
it('folds to the last event, or undefined without one', () => {
|
||||
const session = freshSession('sess-fold')
|
||||
expect(effectivePermissionPreset(session.events)).toBeUndefined()
|
||||
session.append('permission/preset', { preset: 'danger-full-access' })
|
||||
session.append('permission/preset', { preset: 'workspace-write' })
|
||||
expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
|
||||
// The backward scan steps over non-preset events to the latest selection.
|
||||
session.append('sandbox/mode', { mode: 'read-only' })
|
||||
expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PermissionService', () => {
|
||||
it('advertises the preset table in declaration order and resolves bundles', async () => {
|
||||
const ctx = await mounted()
|
||||
expect(ctx.permission.names).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(ctx.permission.resolve('danger-full-access')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' })
|
||||
expect(() => ctx.permission.resolve('plan')).toThrow(/unknown preset "plan"/)
|
||||
})
|
||||
|
||||
it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-current')
|
||||
expect(ctx.permission.current(session.events)).toBe('workspace-write')
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
it('a knob state matching no table entry derives custom — a state, not an error', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-custom')
|
||||
session.append('sandbox/mode', { mode: 'read-only' })
|
||||
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
|
||||
})
|
||||
|
||||
it('composition defaults outside the table still derive custom when an explicit new-session default is configured', async () => {
|
||||
const ctx = await mounted({
|
||||
approvalDefault: 'never',
|
||||
config: { defaultPreset: 'workspace-write' },
|
||||
})
|
||||
const session = freshSession('sess-defaults-custom')
|
||||
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
|
||||
})
|
||||
|
||||
it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => {
|
||||
const ctx = await mounted({ config: { presets: {
|
||||
'workspace-write': { sandbox: 'workspace-write', approval: 'ask' },
|
||||
agentish: { sandbox: 'workspace-write', approval: 'ask' },
|
||||
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
|
||||
} } })
|
||||
const session = freshSession('sess-tie')
|
||||
ctx.permission.set(session, 'agentish')
|
||||
expect(ctx.permission.current(session.events)).toBe('agentish')
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
session.append('sandbox/mode', { mode: 'danger-full-access' })
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
it('set() writes through: one preset event plus both knob events', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-set')
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
expect(session.events.map(e => [e.type, e.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['sandbox/mode', { mode: 'danger-full-access' }],
|
||||
['approval/policy', { policy: 'never' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('set() to the current preset is a no-op when the knobs already match (clicks are not switches)', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-noop')
|
||||
ctx.permission.set(session, 'workspace-write')
|
||||
expect(session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-drift')
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
// Re-selecting from a drifted state records the choice and repairs only
|
||||
// the changed knob.
|
||||
session.append('sandbox/mode', { mode: 'read-only' })
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
const tail = session.events.slice(4)
|
||||
expect(tail.map(e => [e.type, e.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['sandbox/mode', { mode: 'danger-full-access' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects composition over a non-confining executor at load', async () => {
|
||||
await expect(mounted({ bashDefault: undefined }))
|
||||
.rejects.toThrow(/does not confine/)
|
||||
})
|
||||
|
||||
it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => {
|
||||
const ctx = await mounted()
|
||||
expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' })
|
||||
expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' })
|
||||
const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } })
|
||||
expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' })
|
||||
expect(() => ctx.permission.optionOf('plan')).toThrow(/unknown preset/)
|
||||
})
|
||||
|
||||
it('rejects a table entry named custom (reserved for the derived state)', async () => {
|
||||
await expect(mounted({ config: { presets: { custom: { sandbox: 'read-only', approval: 'ask' } } } }))
|
||||
.rejects.toThrow(/reserved for the derived not-a-preset state/)
|
||||
})
|
||||
|
||||
it('requires an explicit default when composition defaults match no preset', async () => {
|
||||
await expect(mounted({ approvalDefault: 'never' }))
|
||||
.rejects.toThrow(/configure defaultPreset explicitly/)
|
||||
})
|
||||
|
||||
it('reads a schema-less approval stand-in as the ask default', async () => {
|
||||
const ctx = await mounted({ approvalDefault: undefined })
|
||||
const session = freshSession('sess-standin')
|
||||
ctx.permission.set(session, 'workspace-write')
|
||||
expect(session.events).toHaveLength(0)
|
||||
expect(ctx.permission.current(session.events)).toBe('workspace-write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('new-session default', () => {
|
||||
it('pins the current setting into each new session without changing earlier sessions', async () => {
|
||||
const ctx = await mountedStore()
|
||||
const first = ctx.sessions.create(SessionId('first'))
|
||||
expect(first.events.map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'workspace-write' }],
|
||||
['sandbox/mode', { mode: 'workspace-write' }],
|
||||
['approval/policy', { policy: 'ask' }],
|
||||
])
|
||||
|
||||
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
|
||||
defaultPreset: 'danger-full-access',
|
||||
})
|
||||
expect(ctx.permission.defaultPreset).toBe('danger-full-access')
|
||||
const second = ctx.sessions.create(SessionId('second'))
|
||||
expect(ctx.permission.current(first.events)).toBe('workspace-write')
|
||||
expect(ctx.permission.current(second.events)).toBe('danger-full-access')
|
||||
expect(second.events.map(event => event.type)).toEqual([
|
||||
'permission/preset', 'sandbox/mode', 'approval/policy',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves a seeded legacy session instead of applying the latest user default', async () => {
|
||||
const ctx = await mountedStore()
|
||||
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
|
||||
defaultPreset: 'danger-full-access',
|
||||
})
|
||||
const legacy = freshSession('legacy-source')
|
||||
legacy.append('turn/start', { turn: 1 })
|
||||
legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events })
|
||||
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
|
||||
expect(resumed.events.slice(-3).map(event => event.type)).toEqual([
|
||||
'permission/preset', 'sandbox/mode', 'approval/policy',
|
||||
])
|
||||
})
|
||||
|
||||
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')
|
||||
partial.append('sandbox/mode', { mode: 'workspace-write' })
|
||||
partial.append('approval/policy', { policy: 'ask' })
|
||||
const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events })
|
||||
expect(resumed.events.at(-1)).toMatchObject({
|
||||
type: 'permission/preset',
|
||||
data: { preset: 'workspace-write' },
|
||||
})
|
||||
|
||||
const custom = freshSession('custom-source')
|
||||
custom.append('sandbox/mode', { mode: 'read-only' })
|
||||
custom.append('approval/policy', { policy: 'never' })
|
||||
const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events })
|
||||
expect(ctx.permission.current(unmatched.events)).toBe(CUSTOM_PRESET)
|
||||
expect(unmatched.events.at(-1)?.type).toBe('session/end-seed')
|
||||
})
|
||||
|
||||
it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => {
|
||||
const ctx = await mountedStore({ approvalDefault: undefined })
|
||||
const partial = freshSession('approval-fallback-source')
|
||||
partial.append('sandbox/mode', { mode: 'workspace-write' })
|
||||
const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events })
|
||||
expect(resumed.events.at(-1)).toMatchObject({
|
||||
type: 'approval/policy',
|
||||
data: { policy: 'ask' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a stored default outside the configured preset table', async () => {
|
||||
const ctx = await mountedStore()
|
||||
await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
|
||||
defaultPreset: 'missing',
|
||||
})).rejects.toThrow()
|
||||
expect(ctx.permission.defaultPreset).toBe('workspace-write')
|
||||
})
|
||||
})
|
||||
133
packages/interaction/permission/tests/projection.spec.ts
Normal file
133
packages/interaction/permission/tests/projection.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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, vi } 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'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
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') },
|
||||
})
|
||||
await ctx.plugin(ApprovalService)
|
||||
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) {
|
||||
const inject = vi.fn<Agent['inject']>()
|
||||
const agent = { id: session.id, session, inject } as unknown as Agent
|
||||
await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] }))
|
||||
return { agent, inject }
|
||||
}
|
||||
|
||||
describe('permissions projection unit', () => {
|
||||
it('serves the pinned new-session default select', 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 })
|
||||
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, inject } = 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: 'preset danger-full-access' })
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
expect(inject.mock.calls[0]?.[0]).toMatchObject({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'The approval policy changed from "ask" to "never" (changed by the user).',
|
||||
}],
|
||||
})
|
||||
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 preset workspace-write (available: workspace-write, danger-full-access)',
|
||||
})
|
||||
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects an unknown preset without touching the log', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const { agent } = await agentFor(ctx, session)
|
||||
const before = session.events.filter(event =>
|
||||
event.type !== 'command/run' && event.type !== 'command/done')
|
||||
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
|
||||
// The error text carries the same no-self-labelling rule as the success
|
||||
// texts: `permission · unknown preset "yolo" (…)`, not `unknown permission
|
||||
// preset`, which the row's own title already says.
|
||||
expect(execution?.result).toEqual({
|
||||
kind: 'error',
|
||||
text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)',
|
||||
})
|
||||
expect(session.events.filter(event =>
|
||||
event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before)
|
||||
})
|
||||
})
|
||||
48
packages/interaction/permission/tsconfig.json
Normal file
48
packages/interaction/permission/tsconfig.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../commands"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user