fix(scope): close remaining ownership boundaries
This commit is contained in:
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user