diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index eb96366cab..2c423a61d4 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -11,15 +11,15 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. +`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. In non-native modes `run_code` is reserved presentation infrastructure rather than a filterable capability: allow/deny restrictions cannot remove it, and registering, shadowing, or explicitly filtering that name fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw. Disposed with the calling fiber (= the agent, for scoped registrations). -- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL tool surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). +- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations). +- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. -- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree. -- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction name universe configuration (`toolOrder`, `restrict`) validates against: a typo fails loud while a restricted-away tool stays a normal absence. +- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree. +- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.execute(exec: ToolExecution): Promise` Execute one tool call through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline. @@ -133,7 +133,7 @@ const bash = defineTool({ ### Code Mode -Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per registered tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. +Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself. - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw. - **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `:code:`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode). diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 1eb63cea1a..c985eb852c 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -138,7 +138,8 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { /** * Build the `run_code` {@link ToolDefinition}: one required `code` parameter, * executed through the dispatch bridge described in the module doc. The - * registry registers it under non-native modes. + * registry reserves it as presentation infrastructure under non-native modes, + * outside the filterable global/scoped capability layers. * @param registry - the owning registry (sub-calls go through its `execute`, * bindings cover its registered tools). * @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5fe0d8c1b9..c3b708ecb8 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -359,8 +359,10 @@ export interface Config { * `deny` removes the listed ones; both present = allow first, then deny. * Restrictions never touch scoped registrations — a tool registered through * the same scope is an explicit grant that bypasses them (which is what keeps - * e.g. a structured-output capture tool alive under an allow-list). Multiple - * restrictions on one scope compose by intersection: every one must admit. + * e.g. a structured-output capture tool alive under an allow-list). The + * reserved `run_code` presentation transport is likewise outside capability + * filtering, and naming it explicitly is rejected. Multiple restrictions on + * one scope compose by intersection: every one must admit. */ export interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -374,8 +376,8 @@ export interface ToolRestriction { * loop executes calls through the `tools/pre-execute` → `tools/execute` → * `tools/post-execute` pipeline. The registry contributes its schemas into the * system-prompt assembly — WHICH schemas is governed by its `mode` config - * (see {@link Config.mode}); under a non-native mode it also registers the - * `run_code` tool and the `tools:sdk` prompt section itself. + * (see {@link Config.mode}); under a non-native mode it also owns the reserved + * `run_code` presentation transport and the `tools:sdk` prompt section. * * Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a * plain plugin context is GLOBAL (visible to every agent); one through a @@ -401,15 +403,24 @@ export class ToolRegistry extends Service { /** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */ private restrictions = new Map() private readonly mode: ToolPresentationMode + /** Reserved presentation transport, kept outside the filterable registration layers. */ + private readonly codeTransport: ToolDefinition | undefined constructor(ctx: Context, config: Config = {}) { super(ctx, 'tools') // The schema already defaulted an omitted mode; the ?? narrows the // optional-input type for direct (non-Loader) construction in tests. this.mode = config.mode ?? 'native' + // `run_code` is presentation infrastructure, not an end capability. It + // therefore does not enter the global layer: per-agent restrictions must + // not remove it, and a scoped registration must not shadow it. The + // visibility resolver appends this reserved definition after resolving + // the filterable global/scoped capability layers. + this.codeTransport = this.mode === 'native' + ? undefined + : createRunCodeTool(this, () => this.requireCodeRuntime()) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) if (this.mode !== 'native') { - this.register(createRunCodeTool(this, () => this.requireCodeRuntime())) ctx.systemPrompt.section({ name: 'tools:sdk', order: SDK_SECTION_ORDER, @@ -442,7 +453,9 @@ export class ToolRegistry extends Service { * pre-restriction and a restricted-away tool in `toolOrder` is a normal * absence — while the MODE collapse is deployment config, so under * `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a - * native tool is dead configuration that fails every assembly loud. + * native tool is dead configuration that fails every assembly loud. Under + * `mode: 'both'`, the provider adds the reserved transport to the + * capability-only {@link knownNames} universe for `toolOrder` validation. */ private wireSchemas(scope?: ScopeKey): ToolProviderResult { if (this.mode === 'native') return { schemas: this.schemas(scope), knownNames: this.knownNames(scope) } @@ -451,7 +464,7 @@ export class ToolRegistry extends Service { if (this.mode === 'code') { return { schemas: all.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME] } } - return { schemas: all, knownNames: this.knownNames(scope) } + return { schemas: all, knownNames: [...this.knownNames(scope), RUN_CODE_NAME] } } /** @@ -480,9 +493,10 @@ export class ToolRegistry extends Service { * with the scope, and shadowing a same-named global tool for that agent. * Throws if the SAME layer already has the name (cross-layer name twins are * the shadowing feature, not an error; the global-duplicate message names - * `agent.ctx` as the per-agent alternative). The visible schema set flows - * into prompt assembly automatically. Disposed with the calling fiber. - * Emits `tools/change` on register/unregister. + * `agent.ctx` as the per-agent alternative), or if a non-native mode reserves + * the `run_code` name for its presentation transport. The visible schema set + * flows into prompt assembly automatically. Disposed with the calling + * fiber. Emits `tools/change` on register/unregister. * @param definition - the tool's schema plus its execute (and optional * presentation) functions. * @returns the disposer that unregisters the tool. The exact @@ -491,6 +505,9 @@ export class ToolRegistry extends Service { */ register(definition: ToolDefinition): () => Promise | void { const scope = scopeOf(this.ctx) + if (this.codeTransport !== undefined && definition.name === RUN_CODE_NAME) { + throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) + } const dispose = this.ctx.effect(function* (this: ToolRegistry) { const layer = scope === undefined ? this.global : this.layerFor(scope) if (layer.has(definition.name)) { @@ -531,10 +548,13 @@ export class ToolRegistry extends Service { * name universe ({@link knownNames}) and throws on an unknown one (fail loud * beats a typo silently filtering nothing) — register restrictions after the * global tools they mask exist (the agent-creation `setup` window satisfies - * this). The filter is SNAPSHOT at registration: later caller mutation of - * the arrays changes nothing. Multiple restrictions compose by intersection. - * Scoped registrations bypass restrictions (explicit grants win). Disposed - * with the calling fiber (revocable independently); emits `tools/change`. + * this). A non-native mode's reserved `run_code` presentation transport is + * not a filterable capability; naming it explicitly throws, while omitting + * it from an allow-list cannot remove it. The filter is SNAPSHOT at + * registration: later caller mutation of the arrays changes nothing. + * Multiple restrictions compose by intersection. Scoped registrations + * bypass restrictions (explicit grants win). Disposed with the calling + * fiber (revocable independently); emits `tools/change`. * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). * @returns the disposer that lifts this restriction. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -553,6 +573,10 @@ export class ToolRegistry extends Service { ...filter.allow !== undefined ? { allow: [...filter.allow] } : {}, ...filter.deny !== undefined ? { deny: [...filter.deny] } : {}, } + if (this.codeTransport !== undefined + && [...snapshot.allow ?? [], ...snapshot.deny ?? []].includes(RUN_CODE_NAME)) { + throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`) + } const known = new Set(this.knownNames(scope)) const unknown = [...snapshot.allow ?? [], ...snapshot.deny ?? []].filter(name => !known.has(name)) if (unknown.length > 0) { @@ -604,7 +628,8 @@ export class ToolRegistry extends Service { * THE visibility function — one resolution feeding prompt assembly, * {@link get}, and {@link execute}: the global layer masked by the scope's * restrictions, unioned with the scope's own layer, scoped shadowing global - * on a name conflict. No scope = the unrestricted global view. + * on a name conflict, then the non-native mode's reserved `run_code` + * presentation transport. No scope = the unrestricted global view. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the visible definitions (scoped shadows applied), in per-layer * registration order, global layer first. @@ -618,6 +643,10 @@ export class ToolRegistry extends Service { // Scoped layer second: same-name entries REPLACE (shadow) the global ones, // and grants bypass restrictions by construction (never filtered above). for (const [name, definition] of layer ?? []) result.set(name, definition) + // Presentation infrastructure is resolved last and outside capability + // filtering. Registration rejects this reserved name, so this set is an + // invariant assertion as well as protection against future layer changes. + if (this.codeTransport !== undefined) result.set(RUN_CODE_NAME, this.codeTransport) return [...result.values()] } @@ -631,6 +660,7 @@ export class ToolRegistry extends Service { * @returns the definition the scope resolves, or undefined when none is visible. */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined { + if (name === RUN_CODE_NAME && this.codeTransport !== undefined) return this.codeTransport const shadowed = scope === undefined ? undefined : this.scoped.get(scope)?.get(name) if (shadowed) return shadowed if (!this.admits(scope, name)) return undefined @@ -658,10 +688,13 @@ export class ToolRegistry extends Service { } /** - * The PRE-restriction name universe for `scope`: every global name plus the - * scope's own layer, ignoring restrictions. This is the set configuration - * (`toolOrder`, `restrict()` filters) validates against, so a typo fails - * loud while a restricted-away tool remains a normal, non-erroneous absence. + * The PRE-restriction END-CAPABILITY name universe for `scope`: every global + * name plus the scope's own layer, ignoring restrictions. This is the set + * `restrict()` validates against, so a typo fails loud while a + * restricted-away tool remains a normal, non-erroneous absence. Reserved + * presentation transports are deliberately absent: `restrict()` rejects + * naming one, while {@link wireSchemas} adds it to the separate `toolOrder` + * validation universe when its presentation mode contributes it. * @param scope - the viewing scope (the agent); omitted = global names only. * @returns the known names, deduplicated. */ diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index f7f4b058d8..782447e7ec 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' +import { createScope } from '@deepseek-ai/dsh-scope' +import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap } from '@deepseek-ai/dsh-session' @@ -54,6 +57,15 @@ async function setup(options: SetupOptions = {}) { return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! } } +/** Mint one production-shaped agent scope that can register scoped tool policy. */ +async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> { + const agent = { id: AgentId(name) } as Agent + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, + { inject: ['tools', 'systemPrompt'] })) + return { scope, agent } +} + /** Register a trivial echo tool; returns the calls it received. */ function registerEcho(ctx: Context, name = 'echo'): unknown[] { const calls: unknown[] = [] @@ -119,6 +131,100 @@ describe('mode-aware wire contribution', () => { expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true) }) + it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped allow-list filtering in mode %s', async (mode) => { + const { ctx, systemPrompt, runtime } = await setup({ mode }) + registerEcho(ctx, 'echo') + registerEcho(ctx, 'hidden') + const { scope, agent } = await mintAgentScope(ctx) + const lift = scope.ctx.tools.restrict({ allow: ['echo'] }) + + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code' + ? [RUN_CODE_NAME] + : ['echo', RUN_CODE_NAME]) + const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text + expect(sdk).toContain('echo(args:') + expect(sdk).not.toContain('hidden(args:') + + runtime.behavior = request => Promise.resolve({ + logs: [], + value: Object.keys(request.bindings[0]!.functions).sort().join(','), + }) + const result = await runCode(ctx, 'return Object.keys(tools)', { agent }) + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'echo' }]) + + await lift() + const unrestricted = await systemPrompt.assemble({ scope: agent }) + expect(unrestricted.tools.map(tool => tool.name)).toEqual(mode === 'code' + ? [RUN_CODE_NAME] + : ['echo', 'hidden', RUN_CODE_NAME]) + }) + + it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped deny-list filtering in mode %s', async (mode) => { + const { ctx, systemPrompt, runtime } = await setup({ mode }) + registerEcho(ctx, 'denied') + registerEcho(ctx, 'kept') + const { scope, agent } = await mintAgentScope(ctx) + scope.ctx.tools.restrict({ deny: ['denied'] }) + + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code' + ? [RUN_CODE_NAME] + : ['kept', RUN_CODE_NAME]) + const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text + expect(sdk).not.toContain('denied(args:') + expect(sdk).toContain('kept(args:') + + runtime.behavior = request => Promise.resolve({ + logs: [], + value: Object.keys(request.bindings[0]!.functions).sort().join(','), + }) + const result = await runCode(ctx, 'return Object.keys(tools)', { agent }) + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'kept' }]) + }) + + it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => { + const { ctx, systemPrompt } = await setup({ mode }) + const { scope, agent } = await mintAgentScope(ctx) + const impostor = defineTool({ + name: RUN_CODE_NAME, + description: 'Scoped impostor.', + parameters: {}, + execute: () => Promise.resolve([{ type: 'text' as const, text: 'impostor' }]), + }) + + 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.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 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(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 }) + expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }]) + }) + + it.each(['code', 'both'] as const)('keeps run_code in the toolOrder universe without exposing it as a restriction target in mode %s', async (mode) => { + const { ctx, systemPrompt } = await setup({ + mode, + toolOrder: [RUN_CODE_NAME, ''], + }) + registerEcho(ctx) + const { agent } = await mintAgentScope(ctx) + + expect(ctx.tools.knownNames(agent)).toEqual(['echo']) + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code' + ? [RUN_CODE_NAME] + : [RUN_CODE_NAME, 'echo']) + }) + it("never exposes run_code to programs, even under mode 'both' (no recursive dispatch path)", async () => { const { ctx, runtime } = await setup({ mode: 'both' }) registerEcho(ctx)