fix(scope): close remaining ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 03:51:55 +08:00
parent 3dca90261c
commit 36b8370027
79 changed files with 3957 additions and 817 deletions

View File

@@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken, ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
@@ -141,6 +141,25 @@ describe('restrict()', () => {
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
})
it('reads restriction accessors once so the checked filter is the enforced filter', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('global'))
let allowReads = 0
const filter = {
get allow(): string[] | undefined {
allowReads += 1
return allowReads === 1 ? [] : undefined
},
} as ToolRestriction
scope.ctx.tools.restrict(filter)
expect(allowReads).toBe(1)
expect(ctx.tools.schemas(key)).toEqual([])
expect(await run(ctx, 'global', key)).toBe('Error: unknown tool "global"')
})
it('fails loud on an unscoped call, an empty filter, and unknown names', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
@@ -372,6 +391,116 @@ describe('scoped execution dispatch', () => {
expect(Object.isFrozen(forged)).toBe(false)
})
it('reads a stateful parent accessor once before policy, dispatch, and result observation', async () => {
const ctx = await mount()
const observed: (ToolExecutionToken | undefined)[] = []
ctx.tools.register({
...tool('t'),
execute: (_args, exec) => {
observed.push(exec.parent)
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
},
})
ctx.on('tools/pre-execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/result', (exec) => { observed.push(exec.parent) })
const forged = { fake: true } as unknown as ToolExecutionToken
let parentReads = 0
const input = {
callId: CallId('stateful-parent'),
name: 't',
arguments: {},
get parent(): ToolExecutionToken | undefined {
parentReads += 1
return parentReads === 1 ? undefined : forged
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(false)
expect(parentReads).toBe(1)
expect(observed).toEqual([undefined, undefined, undefined, undefined])
})
it('uses one input snapshot for the normalized error shell', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'accepted')
const driftAgent = { id: 'drift' as AgentId } as Agent
ctx.tools.register(tool('parent'))
ctx.tools.register(tool('t'))
let parent!: ToolExecutionToken
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
const acceptedSignal = new AbortController().signal
const driftSignal = new AbortController().signal
const forged = { fake: true } as unknown as ToolExecutionToken
const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 }
const input = {
get callId() { reads.callId += 1; return CallId('unstable-error') },
get name() { reads.name += 1; return 't' },
get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } },
get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent },
get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged },
get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal },
} as ToolExecutionInput
let observed: Readonly<ToolExecution> | undefined
let scopedObserved = 0
ctx.on('tools/result', (exec) => { observed = exec })
scope.ctx.on('tools/result', () => { scopedObserved += 1 })
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 })
expect(scopedObserved).toBe(1)
expect(observed).toMatchObject({
callId: CallId('unstable-error'),
name: 't',
agent: key,
parent,
signal: acceptedSignal,
})
expect(Object.isFrozen(observed)).toBe(true)
})
it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let argumentReads = 0
let observed = 0
ctx.on('tools/result', (exec, result) => {
observed += 1
expect(exec.arguments).toBeUndefined()
expect(result.isError).toBe(true)
})
const input = {
callId: CallId('throwing-arguments'),
name: 't',
get arguments(): unknown {
argumentReads += 1
throw new Error('getter exploded')
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }])
expect(argumentReads).toBe(1)
expect(observed).toBe(1)
})
it.each([
['Map', new Map([['mutable', true]])],
['class instance', new (class Arguments { value = 1 })()],
@@ -408,7 +537,7 @@ describe('scoped execution dispatch', () => {
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
})
it('rejects arguments that change to non-JSON data while being snapshotted', async () => {
it('reads nested arguments once into the executed snapshot', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let reads = 0
@@ -421,12 +550,11 @@ describe('scoped execution dispatch', () => {
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
})
expect(reads).toBe(1)
expect(result).toEqual({
callId: CallId('unstable-arguments'),
content: [{
type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data',
}],
isError: true,
content: [{ type: 'text', text: 'ran:t' }],
isError: false,
})
})

View File

@@ -6,8 +6,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult, type ToolGuard,
type DefineToolOptions, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolGuard,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -135,7 +135,7 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
it('normalizes a result that changes to non-JSON data while being snapshotted', async () => {
it('reads each result value once so later getter drift cannot change the snapshot', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let reads = 0
@@ -153,15 +153,59 @@ describe('ToolRegistry', () => {
callId: CallId('unstable-result'), name: 'echo', arguments: {},
})
expect(reads).toBe(1)
expect(result).toEqual({
callId: CallId('unstable-result'),
content: [{
type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult',
}],
isError: true,
content: [{ type: 'text', text: 'safe' }],
isError: false,
})
})
it('reads every top-level execution result field once before validation', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const reads = { callId: 0, content: 0, isError: 0, error: 0, additionalContext: 0, meta: 0 }
ctx.on('tools/execute', async exec => Object.defineProperties({}, {
callId: { enumerable: true, get: () => { reads.callId += 1; return reads.callId === 1 ? exec.callId : CallId('drifted') } },
content: { enumerable: true, get: () => { reads.content += 1; return reads.content === 1 ? [{ type: 'text', text: 'accepted' }] : [] } },
isError: { enumerable: true, get: () => { reads.isError += 1; return reads.isError !== 1 } },
error: { enumerable: true, get: () => { reads.error += 1; return undefined } },
additionalContext: { enumerable: true, get: () => { reads.additionalContext += 1; return undefined } },
meta: { enumerable: true, get: () => { reads.meta += 1; return undefined } },
}) as ToolExecutionResult)
const result = await ctx.tools.execute({
callId: CallId('one-read-result'), name: 'echo', arguments: {},
})
expect(reads).toEqual({ callId: 1, content: 1, isError: 1, error: 1, additionalContext: 1, meta: 1 })
expect(result).toEqual({
callId: CallId('one-read-result'),
content: [{ type: 'text', text: 'accepted' }],
isError: false,
})
})
it('rejects an exotic nested result before its prototype can be sanitized', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
class ExoticText { readonly value = 'not text' }
ctx.on('tools/execute', exec => Promise.resolve({
callId: exec.callId,
content: [{ type: 'text', text: new ExoticText() }],
isError: false,
} as unknown as ToolExecutionResult))
const result = await ctx.tools.execute({
callId: CallId('exotic-result'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text', text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult',
}])
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -729,6 +773,29 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
it('rejects non-JSON data at the defensive final-result notification boundary', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let execution: ToolExecution | undefined
ctx.on('tools/execute', async (exec, next) => {
execution = exec
return next()
})
await ctx.tools.execute({ callId: CallId('capture-execution'), name: 'echo', arguments: {} })
if (execution === undefined) throw new Error('test fixture did not capture the execution')
const internal = ctx.tools as unknown as {
notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise<void>
}
const invalid = {
callId: CallId('capture-execution'),
content: new Map() as unknown as ToolExecutionResult['content'],
isError: false,
}
await expect(internal.notifyResult(execution, invalid))
.rejects.toThrow('tool result notification must be losslessly JSON-serializable')
})
it.each([
{
name: 'non-object result',
@@ -780,6 +847,11 @@ describe('ToolRegistry', () => {
replacement: { kind: 'defer' },
message: 'tools/post-execute must return an accept or block decision',
},
{
name: 'non-JSON decision',
replacement: { kind: 'accept', content: new Map() },
message: 'tools/post-execute must return a losslessly JSON-serializable decision',
},
])('normalizes a tools/post-execute $name', async ({ replacement, message }) => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -886,7 +958,7 @@ describe('ToolRegistry', () => {
expect(ctx.tools.get('invalid-parameters')).toBeUndefined()
})
it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => {
it('reads nested tool parameters once into the accepted snapshot', async () => {
const ctx = await setup()
let reads = 0
const parameters = Object.defineProperty({}, 'properties', {
@@ -898,8 +970,58 @@ describe('ToolRegistry', () => {
...echoTool,
name: 'unstable-parameters',
parameters,
})).toThrow('tool parameters must be stable losslessly JSON-serializable data')
expect(ctx.tools.get('unstable-parameters')).toBeUndefined()
})).not.toThrow()
expect(reads).toBe(1)
expect(ctx.tools.get('unstable-parameters')?.parameters).toEqual({ properties: {} })
})
it('reads a top-level parameters accessor once so validation and storage use one value', async () => {
const ctx = await setup()
const accepted = { type: 'object', properties: { accepted: { type: 'string' } } }
class DriftedParameters {
readonly type = 'object'
readonly properties = { drifted: { type: 'number' } }
}
let reads = 0
const definition = { ...echoTool, name: 'top-level-parameters' }
Object.defineProperty(definition, 'parameters', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? accepted : new DriftedParameters()
},
})
ctx.tools.register(definition)
expect(reads).toBe(1)
expect(ctx.tools.get('top-level-parameters')?.parameters).toEqual(accepted)
})
it('rejects malformed fixed definition fields without freezing caller objects', async () => {
const ctx = await setup()
const badName = { value: 'object-name' }
const badDescription = { value: 'object-description' }
const badTimeout = { value: 100 }
expect(() => ctx.tools.register({ ...echoTool, name: badName as unknown as string }))
.toThrow('tool name must be a string')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-description', description: badDescription as unknown as string }))
.toThrow('description must be a string')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-timeout', timeoutMs: badTimeout as unknown as number }))
.toThrow('timeoutMs must be a positive finite number')
expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))
.toThrow('timeoutMs must be a positive finite number')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-execute', execute: { bind() {} } as unknown as typeof echoTool.execute }))
.toThrow('execute must be a function')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-present-call', presentCall: 1 as unknown as NonNullable<ToolDefinition['presentCall']> }))
.toThrow('presentCall must be a function')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-present-result', presentResult: 1 as unknown as NonNullable<ToolDefinition['presentResult']> }))
.toThrow('presentResult must be a function')
expect(Object.isFrozen(badName)).toBe(false)
expect(Object.isFrozen(badDescription)).toBe(false)
expect(Object.isFrozen(badTimeout)).toBe(false)
expect(ctx.tools.schemas()).toEqual([])
})
it('snapshots callbacks while preserving their registration-time method receiver', async () => {
@@ -1094,6 +1216,101 @@ describe('defineTool / schema DSL', () => {
expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
})
it('reads defineTool options once and keeps wire and runtime schemas on one detached snapshot', async () => {
const accepted: SchemaSpec = { value: { type: 'string', required: true, enum: ['accepted'] } }
const drifted: SchemaSpec = { count: { type: 'number', required: true } }
const reads = {
name: 0,
description: 0,
parameters: 0,
timeoutMs: 0,
execute: 0,
presentCall: 0,
presentResult: 0,
}
const options = {} as DefineToolOptions<SchemaSpec>
Object.defineProperties(options, {
name: { enumerable: true, get: () => { reads.name += 1; return reads.name === 1 ? 'accepted' : 'drifted' } },
description: { enumerable: true, get: () => { reads.description += 1; return reads.description === 1 ? 'accepted description' : 'drifted description' } },
parameters: { enumerable: true, get: () => { reads.parameters += 1; return reads.parameters === 1 ? accepted : drifted } },
timeoutMs: { enumerable: true, get: () => { reads.timeoutMs += 1; return reads.timeoutMs === 1 ? 250 : 0 } },
execute: {
enumerable: true,
get: () => {
reads.execute += 1
return (args: Record<string, unknown>) => Promise.resolve([{ type: 'text' as const, text: String(args['value']) }])
},
},
presentCall: {
enumerable: true,
get: () => {
reads.presentCall += 1
return (args: Record<string, unknown>) => ({ card: 'generic' as const, title: String(args['value']) })
},
},
presentResult: {
enumerable: true,
get: () => {
reads.presentResult += 1
return (args: Record<string, unknown>) => ({ card: 'generic' as const, title: String(args['value']) })
},
},
})
const tool = defineTool(options)
accepted.value!.type = 'number'
accepted.value!.enum!.push('mutated')
expect(tool).toMatchObject({
name: 'accepted',
description: 'accepted description',
timeoutMs: 250,
parameters: {
type: 'object',
properties: { value: { type: 'string', enum: ['accepted'] } },
required: ['value'],
},
})
await expect(tool.execute({ value: 'accepted' }, {} as ToolExecution))
.resolves.toEqual([{ type: 'text', text: 'accepted' }])
expect(tool.presentCall?.({ value: 'accepted' })).toEqual({ card: 'generic', title: 'accepted' })
expect(tool.presentResult?.(
{ value: 'accepted' },
{ content: [], isError: false },
)).toEqual({ card: 'generic', title: 'accepted' })
expect(reads).toEqual({
name: 1,
description: 1,
parameters: 1,
timeoutMs: 1,
execute: 1,
presentCall: 1,
presentResult: 1,
})
})
it('rejects an exotic defineTool schema before it can be normalized for the wire', () => {
class ExoticDefault { readonly value = 'not JSON' }
expect(() => defineTool({
name: 'exotic-schema',
description: 'must reject exotic defaults',
parameters: {
value: { type: 'string', default: new ExoticDefault() },
},
execute: () => Promise.resolve([]),
})).toThrow(/parameters must be losslessly JSON-serializable/)
})
it('rejects a malformed defineTool spec whose generated wire schema is not JSON', () => {
expect(() => defineTool({
name: 'malformed-schema',
description: 'missing property type',
parameters: { value: {} } as unknown as SchemaSpec,
execute: () => Promise.resolve([]),
})).toThrow(/generated parameters must be losslessly JSON-serializable/)
})
it('type-level: InferArgs maps required properties to non-optional', () => {
// Compile-time check: if this compiles, InferArgs is correct.
// args.a is string (required), args.b is number|undefined (optional).