Files
deepseek-harness/packages/api/session-controller/tests/subagent-access.host.spec.ts
T

129 lines
6.3 KiB
TypeScript

/** Target Session permissions are enforced before subagent input or cancellation is admitted. */
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import TypertGatewayService from '@deepseek-ai/dsh-api-gateway'
import type { RemoteAccessBinding } from '@deepseek-ai/dsh-api-remote-access'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { PeerId, PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { mountWorkingDirectoryFixture } from '../../../subagent/subagent/tests/working-directory-fixture.ts'
import { createSessionTestController } from './test-remote.ts'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface PrincipalMap {
/** Test-only non-owner caller shared by the access specs. */
'test-guest': { readonly kind: 'test-guest'; readonly guest: string }
}
}
const GUEST: Principal = { kind: 'test-guest', guest: 'subagent-controls' }
const ACTIONS = ['subagents/prompt', 'subagents/interruptByUser'] as const
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
})
async function harness() {
const root = await mkdtemp(join(tmpdir(), 'dsh-subagent-access-'))
const ctx = new Context()
cleanups.push(async () => {
try { await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) }
})
await ctx.plugin(TypertRegistry)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
await mountWorkingDirectoryFixture(ctx)
await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: root })
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'test', inheritsParentContext: false,
capabilities: { agentOptions: true, agentPreset: false, outputSchema: false, depthLimit: false },
prepareContinuable: async () => ({}),
})
const adapter = new MockAdapter(['hang'])
ctx.llm.registerAdapter(['mock'], adapter)
const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
const run = await ctx.subagents.startActivation({
provider: 'test', label: 'access target', delivery: 'caller',
parent, prompt: [{ type: 'text', text: 'hold' }],
signal: new AbortController().signal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(run.childId)!
await ctx.plugin(TypertGatewayService)
const bindings: RemoteAccessBinding[] = []
ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => bindings })
const call = (method: 'prompt' | 'interruptByUser', principal: Principal = GUEST, image = false) => {
const peer: PeerScope = {
id: 'subagent-access-peer' as PeerId, ctx, principal, interactions: 'answer',
signal: new AbortController().signal, dispose: async () => {},
}
return ctx.typertGateway.invoke({
peer, namespace: 'subagents', method,
args: method === 'prompt' ? { request: {
requestId: 'access-follow-up', childSessionId: child.id, delivery: 'queue',
content: image ? [{ type: 'image', mediaType: 'image/png', data: 'aGk=' }] : [{ type: 'text', text: 'follow-up' }],
} } : { childSessionId: child.id },
})
}
return { ctx, parent, child, bindings, call }
}
describe('subagent target Session access', () => {
it.each(['prompt', 'interruptByUser'] as const)('requires the target permission for %s', async (method) => {
const { ctx, parent, child, bindings, call } = await harness()
const attachments = vi.spyOn(ctx.attachments, 'admitPromptContent')
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
const cancel = vi.spyOn(child, 'cancel')
const grants: RemoteAccessBinding[][] = [
[],
[{ role: { name: 'global controls', resource: 'global', actions: ACTIONS }, resource: { kind: 'global' } }],
[{ role: { name: 'parent controls', resource: 'session', actions: ACTIONS }, resource: { kind: 'session', id: parent.id } }],
[{ role: { name: 'other control', resource: 'session', actions: [method === 'prompt' ? ACTIONS[1] : ACTIONS[0]] }, resource: { kind: 'session', id: child.id } }],
]
for (const grant of grants) {
bindings.splice(0, bindings.length, ...grant)
await expect(call(method, GUEST, true)).rejects.toMatchObject({
code: 'remote-access/denied', details: { action: `subagents/${method}`, reason: 'no-allow' },
})
}
expect(attachments).not.toHaveBeenCalled()
expect(observe).not.toHaveBeenCalled()
expect(cancel).not.toHaveBeenCalled()
expect(child.inbox.nextTurn).toHaveLength(0)
bindings.splice(0, bindings.length, {
role: { name: 'target control', resource: 'session', actions: [`subagents/${method}`] },
resource: { kind: 'session', id: child.id },
})
const accepted = await call(method)
if (method === 'prompt') {
expect(child.inbox.nextTurn).toHaveLength(1)
expect(accepted).toEqual({ messageId: child.inbox.nextTurn[0]?.id })
expect(child.inbox.nextTurn[0]?.source).toMatchObject({ kind: 'user', principal: GUEST })
} else {
expect(accepted).toEqual({ accepted: true })
expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
await child.whenIdle()
}
})
it('allows the owner without target bindings', async () => {
const { child, call } = await harness()
const accepted = await call('prompt', { kind: 'owner' })
expect(child.inbox.nextTurn).toHaveLength(1)
expect(accepted).toEqual({ messageId: child.inbox.nextTurn[0]?.id })
await expect(call('interruptByUser', { kind: 'owner' })).resolves.toEqual({ accepted: true })
await child.whenIdle()
})
})