fix(core): enforce agent-scoped ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-11 22:55:26 +08:00
parent 850796bb35
commit 3263dab822
62 changed files with 3982 additions and 857 deletions

View File

@@ -123,6 +123,23 @@ describe('mode-aware wire contribution', () => {
expect(sdk?.text).not.toContain('run_code(args:')
})
it.each(['code', 'both'] as const)('restores Code Mode infrastructure after assembly listeners in mode %s', async (mode) => {
const { ctx, systemPrompt } = await setup({ mode })
registerEcho(ctx)
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const assembly = await next()
return {
...assembly,
sections: assembly.sections.filter(section => section.name !== 'tools:sdk'),
tools: assembly.tools.filter(tool => tool.name !== RUN_CODE_NAME),
}
}, { prepend: true })
const assembly = await systemPrompt.assemble()
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true)
expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(true)
})
it("mode 'both' contributes every native schema plus run_code, and the SDK section", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'both' })
registerEcho(ctx)
@@ -197,13 +214,40 @@ describe('mode-aware wire contribution', () => {
expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
expect(() => scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: -999, text: 'malicious SDK' }))
.toThrow(/globally protected and cannot be shadowed/)
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
const transport = ctx.tools.get(RUN_CODE_NAME)!
expect(Object.isFrozen(transport)).toBe(true)
expect(Object.isFrozen(transport.parameters)).toBe(true)
expect(() => { transport.name = 'mutated_transport' }).toThrow(TypeError)
const mutableSection = { name: 'scoped-note', order: 149, text: 'safe note' }
scope.ctx.systemPrompt.section(mutableSection)
mutableSection.name = 'tools:sdk'
mutableSection.text = 'mutated SDK'
const mutableTool = defineTool({
name: 'scoped_safe',
description: 'Safe scoped tool.',
parameters: {},
execute: () => Promise.resolve([{ type: 'text' as const, text: 'safe' }]),
})
scope.ctx.tools.register(mutableTool)
mutableTool.name = RUN_CODE_NAME
mutableTool.description = 'Mutated transport impostor.'
const stored = ctx.tools.get('scoped_safe', agent)!
expect(Object.isFrozen(stored)).toBe(true)
expect(Object.isFrozen(stored.parameters)).toBe(true)
expect(() => { stored.name = RUN_CODE_NAME }).toThrow(TypeError)
const assembly = await systemPrompt.assemble({ scope: agent })
const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
expect(transports).toHaveLength(1)
expect(transports[0]?.description).toContain('Execute a TypeScript program')
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).not.toContain('mutated SDK')
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME)
const result = await runCode(ctx, 'return 1', { agent })
@@ -306,6 +350,36 @@ describe('the run_code dispatch bridge', () => {
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 })
})
it('exposes only an opaque parent token to nested result observers', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
registerEcho(ctx)
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.echo!({ value: 'nested' })
return { logs: [], value: 'done' }
}
// Model a timeout-style outer wrapper: it temporarily installs a signal,
// delegates, then restores the exact prior shape. A nested result observer
// is observe-only and must not receive the live outer execution object;
// freezing the correlation value it sees therefore cannot break restore.
ctx.on('tools/execute', async (exec, next) => {
if (exec.name !== RUN_CODE_NAME) return next()
const previous = exec.signal
exec.signal = new AbortController().signal
const result = await next()
if (previous === undefined) delete exec.signal
else exec.signal = previous
return result
})
ctx.on('tools/result', (exec) => {
if (exec.parent !== undefined) Object.freeze(exec.parent)
})
const result = await runCode(ctx, 'await tools.echo({ value: "nested" })')
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'done' }])
})
it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const intervals: [string, string][] = []
@@ -639,16 +713,17 @@ describe('the run_code dispatch bridge', () => {
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
})
it('logs the value the tool RECEIVED even when the tool mutates its arguments', async () => {
it('gives the tool and durable log the same immutable argument value', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
let mutationSucceeded: boolean | undefined
ctx.tools.register(defineTool({
name: 'mutator',
description: 'Mutates its own args object.',
description: 'Attempts to mutate its args object.',
parameters: { list: { type: 'array', required: true } },
execute(args) {
args.list.push('injected-by-tool')
return Promise.resolve([{ type: 'text' as const, text: 'mutated' }])
mutationSucceeded = Reflect.set(args.list, 1, 'injected-by-tool')
return Promise.resolve([{ type: 'text' as const, text: 'protected' }])
},
}))
runtime.behavior = async (request) => {
@@ -657,6 +732,7 @@ describe('the run_code dispatch bridge', () => {
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(false)
expect(mutationSucceeded).toBe(false)
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
expect(dispatch.arguments).toEqual({ list: ['original'] })
})

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
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 } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } 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'
@@ -149,6 +149,11 @@ describe('restrict()', () => {
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/)
const emptyCtx = await mount()
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
.toThrow(/known tools for this scope: \(none\)/)
})
})
@@ -170,4 +175,296 @@ describe('scoped execution dispatch', () => {
expect(await run(ctx, 't')).toBe('ran:t')
expect(seen).toEqual(['a'])
})
it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as AgentId } as Agent
let bodyCalls = 0
ctx.tools.register({
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
},
})
let guardViewFrozen = false
const guard = (execution: Readonly<ToolExecution>): string => {
guardViewFrozen = Object.isFrozen(execution) && Object.isFrozen(execution.arguments)
return 'terminal policy'
}
const liftFirst = scope.ctx.tools.guard(guard)
scope.ctx.tools.guard(guard)
// Registered later and prepended outside every existing waterfall listener:
// it can force the extensible pre decision to allow, but cannot bypass the
// owner-level monotonic guard that runs after the waterfall.
scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true })
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
expect(guardViewFrozen).toBe(true)
expect(await run(ctx, 't', other)).toBe('ran:t')
expect(bodyCalls).toBe(1)
await liftFirst()
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
await scope.dispose()
expect(await run(ctx, 't', key)).toBe('ran:t')
expect(bodyCalls).toBe(2)
})
it('composes global guards monotonically when one abstains and a later one denies', async () => {
const ctx = await mount()
let bodyCalls = 0
ctx.tools.register({
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
},
})
ctx.tools.guard(() => undefined)
ctx.tools.guard(() => 'global denial')
expect(await run(ctx, 't')).toBe('Error: global denial')
expect(bodyCalls).toBe(0)
})
it('protects call identity before policy and dispatch while leaving only signal mutable', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
let safeCalls = 0
let dangerCalls = 0
let scopedResults = 0
let safeArguments: unknown
ctx.tools.register({
...tool('safe'),
execute: (args) => {
safeCalls += 1
safeArguments = args
return Promise.resolve([{ type: 'text', text: 'safe' }])
},
})
ctx.tools.register({
...tool('danger'),
execute: () => {
dangerCalls += 1
return Promise.resolve([{ type: 'text', text: 'danger' }])
},
})
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
ctx.on('tools/pre-execute', (exec, next) => {
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
expect(Reflect.set(exec, 'name', 'safe')).toBe(false)
expect(Reflect.set(exec.arguments as object, 'injected', true)).toBe(false)
return next()
})
ctx.on('tools/execute', (exec, next) => {
expect(Reflect.set(exec, 'name', 'danger')).toBe(false)
return next()
})
ctx.on('tools/post-execute', (exec, _result, next) => {
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
return next()
})
scope.ctx.on('tools/result', () => { scopedResults += 1 })
expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
const callerArguments = { source: true }
const safeResult = await ctx.tools.execute({
callId: CallId('safe-call'),
name: 'safe',
arguments: callerArguments,
agent: key,
})
expect(safeResult.content[0]).toMatchObject({ text: 'safe' })
expect(Object.isFrozen(callerArguments)).toBe(false)
expect(safeArguments).not.toBe(callerArguments)
expect(Object.isFrozen(safeArguments)).toBe(true)
expect(callerArguments).toEqual({ source: true })
expect({ safeCalls, dangerCalls, scopedResults }).toEqual({
safeCalls: 1,
dangerCalls: 0,
scopedResults: 2,
})
})
it('normalizes non-cloneable arguments and still publishes one scoped final outcome', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
let policyCalls = 0
let bodyCalls = 0
let scopedObserved = 0
let globalObserved = 0
ctx.tools.register({
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
},
})
ctx.on('tools/pre-execute', (_exec, next) => {
policyCalls += 1
return next()
})
let parent!: ToolExecutionToken
ctx.tools.register(tool('parent'))
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()
policyCalls = 0
const signal = new AbortController().signal
scope.ctx.on('tools/result', (exec, result) => {
scopedObserved += 1
expect(exec.arguments).toBeUndefined()
expect(exec.parent).toBe(parent)
expect(exec.signal).toBe(signal)
expect(Object.isFrozen(exec)).toBe(true)
expect(result.isError).toBe(true)
})
ctx.on('tools/result', () => { globalObserved += 1 })
const callerArguments = { invalid: () => undefined }
const scopedResult = await ctx.tools.execute({
callId: CallId('non-cloneable'),
name: 't',
arguments: callerArguments,
agent: key,
parent,
signal,
})
const subjectlessResult = await ctx.tools.execute({
callId: CallId('non-cloneable-subjectless'),
name: 't',
arguments: { invalid: () => undefined },
})
expect(scopedResult.isError).toBe(true)
expect(scopedResult.content[0]?.type === 'text' && scopedResult.content[0].text).toContain('losslessly JSON-serializable')
expect(subjectlessResult.isError).toBe(true)
expect({ policyCalls, bodyCalls, scopedObserved, globalObserved }).toEqual({
policyCalls: 0,
bodyCalls: 0,
scopedObserved: 1,
globalObserved: 2,
})
expect(Object.isFrozen(callerArguments)).toBe(false)
expect(callerArguments.invalid).toBeTypeOf('function')
})
it('rejects a forged mutable parent token without exposing it to final observers', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
const forged = { mutable: true } as unknown as ToolExecutionToken
let observedParent: ToolExecutionToken | undefined = forged
ctx.on('tools/result', (exec) => { observedParent = exec.parent })
const result = await ctx.tools.execute({
callId: CallId('forged-parent'), name: 't', arguments: {}, parent: forged,
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text', text: 'Error: tool execution parent must be a registry-minted opaque token',
}])
expect(observedParent).toBeUndefined()
expect(Object.isFrozen(forged)).toBe(false)
})
it.each([
['Map', new Map([['mutable', true]])],
['class instance', new (class Arguments { value = 1 })()],
])('rejects cloneable non-JSON arguments (%s) before policy or dispatch', async (_kind, argumentsValue) => {
const ctx = await mount()
let policyCalls = 0
let bodyCalls = 0
let observed = 0
ctx.tools.register({
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
},
})
ctx.on('tools/pre-execute', (_exec, next) => {
policyCalls += 1
return next()
})
ctx.on('tools/result', (exec, result) => {
observed += 1
expect(exec.arguments).toBeUndefined()
expect(result.isError).toBe(true)
})
const result = await ctx.tools.execute({
callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable',
}])
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
})
it('rejects arguments that change to non-JSON data while being snapshotted', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let reads = 0
const argumentsValue = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
})
const result = await ctx.tools.execute({
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
})
expect(result).toEqual({
callId: CallId('unstable-arguments'),
content: [{
type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data',
}],
isError: true,
})
})
it('notifies every tools/result observer with the frozen final outcome and contains failures', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('t'))
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
const seen: boolean[] = []
const dispatchModes: string[] = []
ctx.on('internal/dispatch', (mode, name) => {
if (name === 'tools/result') dispatchModes.push(mode)
})
ctx.on('tools/execute', async (exec, next) => {
await next()
return {
callId: exec.callId,
content: [{ type: 'text', text: 'outer failure' }],
isError: true,
}
}, { prepend: true })
scope.ctx.on('tools/result', (_exec, result) => {
expect(Object.isFrozen(_exec)).toBe(true)
expect(Object.isFrozen(_exec.arguments)).toBe(true)
expect(Object.isFrozen(result)).toBe(true)
expect(Object.isFrozen(result.content)).toBe(true)
seen.push(result.isError)
})
ctx.on('tools/result', () => {
throw { toString: () => { throw new Error('coercion trap') } }
})
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
expect(seen).toEqual([true, true])
expect(dispatchModes).toEqual(['parallel'])
expect(warn).toHaveBeenCalledOnce()
expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
})
})

View File

@@ -5,7 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
type ToolExecution, type ToolExecutionResult, type ToolGuard,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -113,6 +113,53 @@ describe('ToolRegistry', () => {
expect('meta' in result).toBe(false)
})
it('normalizes a contract-violating non-cloneable result before final notification', async () => {
const ctx = await setup()
let observedError: boolean | undefined
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
ctx.tools.register({
...echoTool,
name: 'bad-meta',
async execute() {
return { content: [], meta: () => undefined }
},
})
const result = await ctx.tools.execute({
callId: CallId('bad-meta'), name: 'bad-meta', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:')
expect(observedError).toBe(true)
})
it('normalizes a result that changes to non-JSON data while being snapshotted', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let reads = 0
const hostileBlock = Object.defineProperty({ type: 'text' }, 'text', {
enumerable: true,
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
})
ctx.on('tools/execute', async exec => ({
callId: exec.callId,
content: [hostileBlock],
isError: false,
}) as unknown as ToolExecutionResult)
const result = await ctx.tools.execute({
callId: CallId('unstable-result'), name: 'echo', arguments: {},
})
expect(result).toEqual({
callId: CallId('unstable-result'),
content: [{
type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult',
}],
isError: true,
})
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -134,6 +181,28 @@ describe('ToolRegistry', () => {
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
})
it('normalizes a hostile thrown value whose inspection and coercion both throw', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'hostile-throw',
async execute() {
throw new Proxy({}, {
getPrototypeOf: () => { throw new Error('prototype trap') },
has: () => { throw new Error('has trap') },
get: () => { throw new Error('get trap') },
})
},
})
await expect(ctx.tools.execute({
callId: CallId('hostile'), name: 'hostile-throw', arguments: {},
})).resolves.toMatchObject({
isError: true,
content: [{ type: 'text', text: 'Error: <unprintable thrown value>' }],
})
})
it('ToolNotFoundError carries the tool name and a stable code', async () => {
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
const err = new ToolNotFoundError('ghost')
@@ -158,6 +227,25 @@ describe('ToolRegistry', () => {
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
})
it('rejects a JavaScript guard that returns an async/non-string decision', async () => {
const ctx = await setup()
let bodyCalls = 0
ctx.tools.register({
...echoTool,
async execute() {
bodyCalls += 1
return []
},
})
ctx.tools.guard((() => Promise.resolve('late denial')) as unknown as ToolGuard)
const result = await ctx.tools.execute({ callId: CallId('bad-guard'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]?.type === 'text' && result.content[0].text)
.toContain('tools.guard() must return')
expect(bodyCalls).toBe(0)
})
it('an ask decision degrades to deny until the permission system lands', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -233,7 +321,7 @@ describe('ToolRegistry', () => {
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => {
// The decision is the ONLY sanctioned channel to change the outcome. A
// listener that reaches in and mutates the passed result reference (flipping
// isError, rewriting callId, attaching a bogus error) must NOT affect what
@@ -241,23 +329,45 @@ describe('ToolRegistry', () => {
// the waterfall and rebuilds from the snapshot + decision.
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {
callId: CallId('c1'),
content: [{ type: 'text', text: 'original' }],
isError: true,
error: { name: 'OriginalError', code: 'ORIGINAL' },
meta: { nested: { label: 'original' } },
}
})
ctx.on('tools/post-execute', async (_exec, result, next) => {
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
const mutable = result as {
callId: string
isError: boolean
error?: { name: string; code: string }
content: { type: 'text'; text: string }[]
meta?: { nested: { label: string } }
}
mutable.callId = 'hijacked'
mutable.isError = true
mutable.error = { name: 'Evil', code: 'EVIL' }
mutable.isError = false
if (mutable.error) {
mutable.error.name = 'Evil'
mutable.error.code = 'EVIL'
}
mutable.content[0]!.text = 'MUTATED'
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
if (mutable.meta) mutable.meta.nested.label = 'MUTATED'
return next() // delegate to the default accept — no decision-level override
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
expect(result.error).toBeUndefined() // no listener-injected error
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'OriginalError', code: 'ORIGINAL' })
expect(result.content).toHaveLength(1) // the in-place push did not leak in
expect(result.content[0]).toMatchObject({ text: 'hi' })
expect(result.content[0]).toMatchObject({ text: 'original' })
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
expect(result.meta).toEqual({ nested: { label: 'original' } })
})
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
@@ -416,6 +526,109 @@ describe('ToolRegistry', () => {
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
})
it('preserves additionalContext supplied by an around-dispatch result', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async exec => ({
callId: exec.callId,
content: [{ type: 'text', text: 'short-circuited with context' }],
isError: false,
additionalContext: {
content: [{ type: 'text', text: 'from around dispatch' }],
source: { kind: 'plugin', plugin: 'test' },
},
}))
const result = await ctx.tools.execute({
callId: CallId('around-context'), name: 'echo', arguments: {},
})
expect(result.additionalContext).toEqual({
content: [{ type: 'text', text: 'from around dispatch' }],
source: { kind: 'plugin', plugin: 'test' },
})
})
it('normalizes malformed tools/execute results instead of treating them as success', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let observedError: boolean | undefined
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {} as ToolExecutionResult
})
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
const result = await ctx.tools.execute({ callId: CallId('malformed'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/execute must return a ToolExecutionResult with content[] and boolean isError',
})
expect(observedError).toBe(true)
})
it.each([
{
name: 'non-object result',
replacement: null,
message: 'tools/execute must return a ToolExecutionResult object',
},
{
name: 'wrong call id',
replacement: { callId: CallId('other'), content: [], isError: false },
message: 'tools/execute returned callId "other" for authoritative call "malformed-shape"',
},
])('normalizes a tools/execute $name', async ({ replacement, message }) => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => replacement as ToolExecutionResult)
const result = await ctx.tools.execute({
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
})
it('normalizes malformed tools/post-execute decisions', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({ kind: 'accept', content: 'not blocks' }) as unknown as PostToolDecision)
const result = await ctx.tools.execute({ callId: CallId('malformed-post'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/post-execute accept content must be an array',
})
})
it.each([
{
name: 'non-object decision',
replacement: null,
message: 'tools/post-execute must return a PostToolDecision object',
},
{
name: 'block without feedback blocks',
replacement: { kind: 'block', feedback: 'not blocks' },
message: 'tools/post-execute block feedback must be an array',
},
{
name: 'unknown decision kind',
replacement: { kind: 'defer' },
message: 'tools/post-execute must return an accept or block decision',
},
])('normalizes a tools/post-execute $name', async ({ replacement, message }) => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => replacement as unknown as PostToolDecision)
const result = await ctx.tools.execute({
callId: CallId('malformed-post-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
})
it('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -493,6 +706,61 @@ describe('ToolRegistry', () => {
}])
})
it.each([
['Map', new Map([['mutable', true]])],
['class instance', new (class Parameters { value = 1 })()],
])('rejects cloneable non-JSON tool parameters (%s) without registry residue', async (_kind, parameters) => {
const ctx = await setup()
const definition = {
...echoTool,
name: 'invalid-parameters',
parameters,
} as unknown as typeof echoTool
expect(() => ctx.tools.register(definition)).toThrow(
'tool parameters must be losslessly JSON-serializable',
)
expect(ctx.tools.get('invalid-parameters')).toBeUndefined()
})
it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => {
const ctx = await setup()
let reads = 0
const parameters = Object.defineProperty({}, 'properties', {
enumerable: true,
get: () => ++reads === 1 ? {} : new Map([['mutable', true]]),
})
expect(() => ctx.tools.register({
...echoTool,
name: 'unstable-parameters',
parameters,
})).toThrow('tool parameters must be stable losslessly JSON-serializable data')
expect(ctx.tools.get('unstable-parameters')).toBeUndefined()
})
it('snapshots callbacks while preserving their registration-time method receiver', async () => {
const ctx = await setup()
const receivers: object[] = []
const definition = {
...echoTool,
name: 'callback-snapshot',
async execute() {
receivers.push(this)
return [{ type: 'text' as const, text: 'original' }]
},
}
ctx.tools.register(definition)
definition.execute = async () => [{ type: 'text' as const, text: 'replacement' }]
const result = await ctx.tools.execute({
callId: CallId('callback-snapshot'), name: definition.name, arguments: {},
})
expect(receivers).toEqual([definition])
expect(result.content).toEqual([{ type: 'text', text: 'original' }])
})
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)