# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.spec.ts
126 lines
6.4 KiB
TypeScript
126 lines
6.4 KiB
TypeScript
/**
|
|
* 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<Agent> {
|
|
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 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 = 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')
|
|
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)
|
|
})
|
|
})
|