feat(permission): add new-session default setting
This commit is contained in:
@@ -1,10 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
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 } from '@deepseek-ai/dsh-permission'
|
||||
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
|
||||
@@ -27,6 +46,23 @@ function freshSession(id: string): Session {
|
||||
return new Session(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')
|
||||
@@ -66,8 +102,11 @@ describe('PermissionService', () => {
|
||||
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
|
||||
})
|
||||
|
||||
it('composition defaults outside the table derive custom at zero events', async () => {
|
||||
const ctx = await mounted({ approvalDefault: 'never' })
|
||||
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)
|
||||
})
|
||||
@@ -138,6 +177,11 @@ describe('PermissionService', () => {
|
||||
.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')
|
||||
@@ -146,3 +190,79 @@ describe('PermissionService', () => {
|
||||
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, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
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('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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ async function agentFor(ctx: Context, session: Session): Promise<Agent> {
|
||||
}
|
||||
|
||||
describe('permissions projection unit', () => {
|
||||
it('serves the composition-default select at zero events', async () => {
|
||||
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' })
|
||||
@@ -103,12 +103,14 @@ describe('/permission command', () => {
|
||||
kind: 'success',
|
||||
text: 'current preset workspace-write (available: workspace-write, danger-full-access)',
|
||||
})
|
||||
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
|
||||
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
|
||||
@@ -117,6 +119,7 @@ describe('/permission command', () => {
|
||||
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')).toHaveLength(0)
|
||||
expect(session.events.filter(event =>
|
||||
event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user