docs: trim generated prose
This commit is contained in:
@@ -261,13 +261,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'agent/pre-step',
|
||||
mode: 'serial',
|
||||
signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void',
|
||||
summary: 'Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step\'s `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`.',
|
||||
summary: 'Awaited checkpoint for surface mutation before `step/start` snapshots request history.',
|
||||
},
|
||||
{
|
||||
name: 'agent/prompt-submit',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
|
||||
summary: 'Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.',
|
||||
summary: 'Waterfall: decide what happens to one drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.',
|
||||
},
|
||||
{
|
||||
name: 'agent/queued',
|
||||
@@ -285,7 +285,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'agent/session-prefix',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/session-prefix\'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>',
|
||||
summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider\'s system slot) on every request this loop instance sends.',
|
||||
summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the entire derived history (directly after the provider\'s system slot) on every request this loop instance sends.',
|
||||
},
|
||||
{
|
||||
name: 'agent/session-start',
|
||||
@@ -429,19 +429,19 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'tools/post-execute',
|
||||
mode: 'waterfall',
|
||||
signature: '\'tools/post-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
|
||||
summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
|
||||
summary: 'Waterfall after a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
|
||||
},
|
||||
{
|
||||
name: 'tools/pre-execute',
|
||||
mode: 'waterfall',
|
||||
signature: '\'tools/pre-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>',
|
||||
summary: 'Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
|
||||
summary: 'Waterfall before a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
|
||||
},
|
||||
{
|
||||
name: 'tools/result',
|
||||
mode: 'parallel',
|
||||
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void',
|
||||
summary: 'Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
|
||||
summary: 'Awaited notification of the authoritative final tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
|
||||
},
|
||||
{
|
||||
name: 'workflow/agent-end',
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
/**
|
||||
* Runtime mirror of the cordis `FiberState` const enum plus human-readable
|
||||
* labels, shared by the mount lifecycle (state reporting) and the inspect
|
||||
* renderers (plugin-list and mount-table labels).
|
||||
*
|
||||
* Cordis exposes `FiberState` as a `const enum`: there is no runtime object for
|
||||
* Node's type-stripping runner to import, so the members are mirrored here as
|
||||
* values — each typed (via the type-only import) as the cordis enum member it
|
||||
* mirrors, so enum-typed reads like `fiber.state` compare against them under a
|
||||
* shared enum type. Source of truth: vendor/cordis/src/fiber.ts (pinned; drift
|
||||
* only happens through a deliberate vendor sync).
|
||||
*
|
||||
* Runtime mirror of the cordis `FiberState` const enum plus human-readable labels, shared by
|
||||
* the mount lifecycle (state reporting) and the inspect renderers (plugin-list and mount-table
|
||||
* labels).
|
||||
* @module @deepseek-ai/dsh-tool-cordis/fiber-state
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,50 +1,9 @@
|
||||
/**
|
||||
* The registration boundary between sandboxed mount code and the real runtime:
|
||||
* SchemaSpec normalization + validation with teaching errors, the
|
||||
* marker-guarded `harness.defineTool` / `harness.registerTool` pair, the
|
||||
* SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives in place of the
|
||||
* real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
|
||||
* The registration boundary between sandboxed mount code and the real runtime: SchemaSpec
|
||||
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
|
||||
* `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives
|
||||
* in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
|
||||
* return values with.
|
||||
*
|
||||
* The façade is a WHITELIST, not a pass-through proxy. Mount code needs to do
|
||||
* exactly four things — register a tool, listen to an event, provide a service,
|
||||
* call an injected service (timers included) — so the façade exposes only those
|
||||
* verbs and the injected services, each object-valued service individually
|
||||
* wrapped (a primitive provided value passes through as-is — see
|
||||
* {@link sandboxContext}). Every framework plumbing member (`root`, `parent`, `scope`, `fiber`, `reflect`, `registry`,
|
||||
* `events`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) is
|
||||
* DENIED with a teaching error rather than passed through. This closes an
|
||||
* entire escape class at once: a pass-through proxy that only special-cased
|
||||
* `ctx.tools` still handed back the raw context through `ctx.root`,
|
||||
* `ctx.extend()`, or a service instance's `.ctx`, and mount code could then
|
||||
* `ctx.root.tools.register({…})` to bypass the marker check and host-realm
|
||||
* normalization — a raw vm-realm result then errors a real agent turn at the
|
||||
* session-log plainness check. The whitelist has no such hole: there is no
|
||||
* context-valued member to reach, and any injected-service method that returns
|
||||
* a `Context` is rejected (harness services never do — see {@link denyContext}).
|
||||
*
|
||||
* Two realm facts drive the tool path. Objects built inside the vm carry the vm
|
||||
* realm's `Object.prototype`, and the session log's append-time plainness check
|
||||
* (`dsh-session`'s `isJsonValue`, a prototype-identity comparison) rejects
|
||||
* foreign-realm data — so every dynamic tool's `execute` return is JSON
|
||||
* round-tripped into the host realm and shape-checked against the two
|
||||
* `ToolExecuteReturn` forms before it reaches the registry (the registry
|
||||
* trusts the shape blindly — it spreads `result.content`, so an unvalidated
|
||||
* `{ content: 'ok' }` would enter the session log as `['o','k']` and silently
|
||||
* corrupt the next model request), and the schema itself is rebuilt as fresh
|
||||
* host-realm objects. And a malformed tool
|
||||
* schema must fail at REGISTRATION, not when a later request assembles it — so
|
||||
* dynamic tool registration accepts only definitions produced by the sandbox's
|
||||
* `harness.defineTool`, which normalizes `parameters` up front.
|
||||
*
|
||||
* Normalize, don't lecture, where the input has exactly one meaning: models
|
||||
* write the JSON-Schema dialect by strong prior (the `{ type: 'object',
|
||||
* properties, required: […] }` wrapper, `type: 'integer'`, `required: false`),
|
||||
* and each rejection costs a model turn — so those convert to the SchemaSpec
|
||||
* DSL silently, and only genuinely meaningless input (an unknown type, a
|
||||
* non-boolean `required`) is rejected, with the error enumerating the valid
|
||||
* vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-cordis/guard
|
||||
*/
|
||||
|
||||
@@ -195,14 +154,11 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `harness.defineTool` handed into the sandbox: the real DSL, with
|
||||
* `parameters` normalized into a fresh host-realm SchemaSpec (JSON-Schema
|
||||
* wrapper unwrapped, `integer` mapped, `required: false` dropped) and the
|
||||
* tool's `execute` return normalized into the host realm via a JSON round-trip
|
||||
* (see the module doc). The round-trip projects the return onto exactly what
|
||||
* the log would durably store, and {@link assertExecuteReturn} then vets that
|
||||
* projection — so a non-JSON-serializable OR wrong-shape return surfaces as
|
||||
* that one call's teaching error instead of poisoning the turn.
|
||||
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
|
||||
* into a fresh host-realm SchemaSpec (JSON-Schema wrapper unwrapped, `integer` mapped,
|
||||
* `required: false` dropped) and the tool's `execute` return normalized into the host realm
|
||||
* via a JSON round-trip (see the module doc).
|
||||
*
|
||||
* @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper.
|
||||
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
|
||||
*/
|
||||
@@ -236,15 +192,9 @@ export function sandboxRegisterTool(ctx: Context, tool: unknown): () => Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* The verbs a mounted plugin may reach through the sandbox `ctx` façade,
|
||||
* beyond its injected services. `on`/`once` observe events, `provide` exposes
|
||||
* a service to other mounts, and the timer helpers schedule work — each a
|
||||
* fiber effect that unwinds on unmount. Everything else on a real cordis `ctx`
|
||||
* is framework plumbing and is denied. Forwarded LAZILY: the timer helpers are
|
||||
* mixin accessors that throw `without inject` when read on a plugin that did
|
||||
* not inject `timer`, so the façade reads `ctx[verb]` only at call time — the
|
||||
* plugin that never touches a timer never trips that, and one that does gets
|
||||
* cordis's own inject error at the call site.
|
||||
* The verbs a mounted plugin may reach through the sandbox `ctx` façade, beyond its injected
|
||||
* services. `on`/`once` observe events, `provide` exposes a service to other mounts, and the
|
||||
* timer helpers schedule work — each a fiber effect that unwinds on unmount.
|
||||
*/
|
||||
const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
|
||||
|
||||
@@ -258,11 +208,7 @@ const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setT
|
||||
* name/description/parameters view as `schemas()`, and nothing invocable.
|
||||
*/
|
||||
function sandboxTools(ctx: Context): Record<string, unknown> {
|
||||
// Reads resolve through the MOUNT's own scope (`scopeOf(ctx)`), mirroring
|
||||
// where the façade's `register` lands its writes (the calling context's
|
||||
// layer): mount code always sees the tools its own world sees — the global
|
||||
// view for today's global mounts, its agent's view if a mount ever runs
|
||||
// under an agent scope.
|
||||
// Resolve reads and writes through the mount's own scope.
|
||||
return {
|
||||
register: (tool: unknown): (() => Promise<void> | void) => sandboxRegisterTool(ctx, tool),
|
||||
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
|
||||
@@ -320,15 +266,7 @@ function declaredInjects(ctx: Context): Set<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox context façade handed to a mounted plugin's `apply` in place of
|
||||
* the real `ctx`. A whitelist (see the module doc): the registration/eventing
|
||||
* verbs, the timer helpers, a guarded `tools`, and injected services resolved
|
||||
* through a guarded `get` / property access. A service is reachable only if the
|
||||
* plugin DECLARED it in `inject` — an undeclared service is denied even when a
|
||||
* global provider exists, so cordis's activation/unload semantics (park the
|
||||
* mount when a declared provider goes away) actually bind. Every
|
||||
* framework-plumbing member is denied with a teaching error; there is no
|
||||
* context-valued member to reach.
|
||||
* The sandbox context façade handed to a mounted plugin's `apply` in place of the real `ctx`.
|
||||
*/
|
||||
function sandboxContext(ctx: Context): Context {
|
||||
const tools = sandboxTools(ctx)
|
||||
@@ -348,16 +286,8 @@ function sandboxContext(ctx: Context): Context {
|
||||
+ 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
|
||||
)
|
||||
}
|
||||
// Read a service for either access path (property or `get`). `tools` is the
|
||||
// façade's own surface. An UNDECLARED name is denied with the teaching
|
||||
// error; a DECLARED one resolves to the guarded service. A declared inject
|
||||
// is required in cordis (the fiber only activates once every declared
|
||||
// service is live), so at `apply`/`execute` time `ctx.get(name)` is present
|
||||
// for a declared name — no undefined case to handle here. `provide()`
|
||||
// accepts ANY value though (cross-mount composition advertises
|
||||
// `ctx.provide('name', value)`), so a primitive or null value passes
|
||||
// through unwrapped: Proxy throws on a non-object target, and only an
|
||||
// object can carry a method that hands back a Context.
|
||||
// Read a service for either access path (property or `get`). `tools` is the façade's own
|
||||
// surface.
|
||||
const readService = (name: string): unknown => {
|
||||
if (name === 'tools') return tools
|
||||
if (!declared.has(name)) return denyRead(name)
|
||||
@@ -408,20 +338,11 @@ export function isPlugin(value: unknown): value is Plugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a plugin so its `apply` receives the sandbox context façade instead of
|
||||
* the real `ctx` (see {@link sandboxContext} and the module doc). Both
|
||||
* function-form and object-form plugins go through the same wrap; the plugin's
|
||||
* own `inject` declaration is preserved (cordis reads it from the plugin
|
||||
* object, and pending/active gating happens on the real fiber before `apply`
|
||||
* runs), so cross-mount provide/inject works unmodified.
|
||||
*
|
||||
* `ctx.effect(customCleanup)` is deliberately absent from the façade for now —
|
||||
* `on` / `provide` / `tools.register` cover every mount seen so far, and each
|
||||
* is already a fiber effect. FIXME(sandbox-effect): expose a guarded `effect`
|
||||
* once a real mount needs a bespoke disposer.
|
||||
* Wrap a plugin so `apply` receives the sandbox context while preserving injection metadata.
|
||||
* @param plugin - the plugin the mount code returned.
|
||||
* @returns an equivalent plugin whose `apply` sees the sandbox context façade.
|
||||
*/
|
||||
// FIXME(sandbox-effect): expose guarded custom effects when a mount needs bespoke cleanup.
|
||||
export function guardedPlugin(plugin: Plugin): Plugin {
|
||||
if (typeof plugin === 'function') {
|
||||
const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
|
||||
|
||||
@@ -1,37 +1,6 @@
|
||||
/**
|
||||
* The self-referential cordis toolset: three model-facing tools that let the
|
||||
* agent inspect and MODIFY the live cordis runtime it is running inside.
|
||||
*
|
||||
* - `cordis_inspect` — read-only: provided services, the flat plugin list
|
||||
* with lifecycle states, registered tools, the dynamic mounts, and the
|
||||
* catalog-backed `api` / `events` references.
|
||||
* - `cordis_mount` — evaluate model-written code in a `node:vm` sandbox; the
|
||||
* code returns a cordis plugin, which is mounted as a child of a dedicated
|
||||
* `cordis-dynamic` group fiber and tracked under an id (`dyn-1`, `dyn-2`, …).
|
||||
* - `cordis_unmount` — dispose one dynamic mount by id, awaiting quiescence.
|
||||
*
|
||||
* Everything the model's plugin registers (listeners via `ctx.on`, tools via
|
||||
* `harness.registerTool`, services via `ctx.provide`) is an effect on the
|
||||
* dynamic fiber, so unmounting — or disposing this plugin itself (HMR) — cleans
|
||||
* it all up through the ordinary cordis lifecycle. The group fiber exists
|
||||
* exactly so the dynamic mounts form ONE subtree, disposed as a unit with
|
||||
* this plugin. Design home:
|
||||
* docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
|
||||
*
|
||||
* The vm sandbox guards against ACCIDENTAL global pollution only, and the `ctx`
|
||||
* a mounted plugin's `apply` receives is a WHITELIST façade (register a tool,
|
||||
* observe events, provide/consume services, use timers — framework internals
|
||||
* withheld; see the guard module). Neither is a security boundary: the verbs
|
||||
* the façade DOES expose reach the real runtime unsandboxed (a mounted tool can
|
||||
* shell out through `ctx.bash`), so a deployment loads this plugin as
|
||||
* deliberately as it grants a bash tool. Design home:
|
||||
* docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
|
||||
*
|
||||
* Plugin export shape: named exports, NO default. The cordis Loader's
|
||||
* `unwrapExports` does `exports.default ?? exports`, so a stray default would
|
||||
* collapse the module to the bare `apply` and drop `inject`, crashing at load
|
||||
* (see docs/postmortem/0001).
|
||||
*
|
||||
* The self-referential cordis toolset: three model-facing tools that let the agent inspect and
|
||||
* MODIFY the live cordis runtime it is running inside.
|
||||
* @module @deepseek-ai/dsh-tool-cordis
|
||||
*/
|
||||
|
||||
@@ -75,9 +44,7 @@ type ResolvedConfig = Required<Config>
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const { vmTimeoutMs } = config as ResolvedConfig
|
||||
// The one group fiber every dynamic mount hangs under. Mounted here (a child
|
||||
// of this plugin's fiber) so disposing tool-cordis cascades over the whole
|
||||
// dynamic subtree — the ordinary parent→child fiber lifecycle, nothing extra.
|
||||
// The one group fiber every dynamic mount hangs under.
|
||||
const group = ctx.plugin({ name: 'cordis-dynamic', apply: () => {} })
|
||||
|
||||
const mounts = new Map<string, DynamicMount>()
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* Read-only renderers over the live runtime for `cordis_inspect`: the service
|
||||
* list, the flat plugin list, the registered tools, the dynamic-mount
|
||||
* table (with per-mount provides/waits), and the catalog-backed `api` /
|
||||
* `events` sections. Every renderer is a pure function of the runtime handles
|
||||
* it receives — no session state, no clock — so inspect output is exactly the
|
||||
* runtime it describes.
|
||||
*
|
||||
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
|
||||
* plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits),
|
||||
* and the catalog-backed `api` / `events` sections.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/inspect
|
||||
*/
|
||||
|
||||
@@ -131,16 +127,11 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn
|
||||
}
|
||||
|
||||
/**
|
||||
* The `api` section: the generated service catalog intersected with the LIVE
|
||||
* runtime — catalogued live services render summary + method signatures, live
|
||||
* services without a catalog entry (e.g. ones another mount provides) render
|
||||
* name + owning fiber, catalog services that are not running are listed
|
||||
* tersely, the type shapes the live signatures reference follow, and the
|
||||
* inherited `ctx` surface closes the section.
|
||||
* Render the generated service catalog against the live runtime.
|
||||
* @param ctx - the runtime to intersect the catalog with.
|
||||
* @param api - the service catalog (the generated one by default; injectable for tests).
|
||||
* @param inherited - the inherited `ctx` surface lines (generated by default; injectable for tests).
|
||||
* @param types - the type-shape catalog (generated by default; injectable for tests).
|
||||
* @param api - generated service entries, replaceable in tests.
|
||||
* @param inherited - inherited `ctx` entries, replaceable in tests.
|
||||
* @param types - public type shapes, replaceable in tests.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeApi(
|
||||
|
||||
@@ -21,11 +21,8 @@ export interface DynamicMount {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a plugin under the group fiber and settle it. The group fiber loads
|
||||
* asynchronously right after the owning plugin's `apply`, so it is awaited
|
||||
* before hanging a child off its context. The child fiber's `await()` settles
|
||||
* its lifecycle work and rethrows a startup error (e.g. a throwing `apply`);
|
||||
* on error the fiber is disposed first — a failed mount never lingers.
|
||||
* Mount a plugin under the group fiber and settle it.
|
||||
*
|
||||
* @param group - the `cordis-dynamic` group fiber every mount hangs under.
|
||||
* @param plugin - the plugin the sandbox returned; wrapped with the registration guard before mounting.
|
||||
* @returns the settled child fiber (possibly pending on unsatisfied `inject`).
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
/**
|
||||
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose
|
||||
* globals are a tagged write-through console, the `harness` registration
|
||||
* helpers, the encoding primitives a bare vm context lacks, and callable traps
|
||||
* over the Node APIs the sandbox deliberately withholds. Capability access is
|
||||
* routed through cordis services, never Node built-ins: filesystem work goes
|
||||
* through `ctx.fs`, network through `ctx.web`, processes through `ctx.bash`,
|
||||
* timers through the `ctx.timer` helpers (fiber effects, unwound on unmount)
|
||||
* — so a well-behaved mount stays inspectable and disposable. That routing is
|
||||
* STEERING toward the cordis services, not containment: the sandbox guards
|
||||
* against ACCIDENTAL global pollution, and it is not a security boundary. The
|
||||
* host-realm helpers on the sandbox global (`harness`, `console`, `btoa`) are
|
||||
* reachable functions, so a mount that goes looking — e.g. through such a
|
||||
* helper's `.constructor` — can still reach the host realm; that is accepted,
|
||||
* because the `ctx` a mounted plugin's `apply` later receives is the real,
|
||||
* fully privileged runtime handle, and that is the point of the toolset.
|
||||
*
|
||||
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a
|
||||
* tagged write-through console, the `harness` registration helpers, the encoding primitives a
|
||||
* bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
|
||||
* withholds.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/sandbox
|
||||
*/
|
||||
|
||||
@@ -35,17 +23,8 @@ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | '
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-sandbox prelude: give the vm realm's own constructors a
|
||||
* `Symbol.hasInstance` that checks BOTH realms. Model code runs against a
|
||||
* fresh vm realm, but most objects it touches are HOST-realm (the `args` a
|
||||
* tool's `execute` receives, event payloads a listener observes, service
|
||||
* return values), so a plain `x instanceof Array` / `instanceof Object` in
|
||||
* sandbox code would silently be false. The patch replaces each vm
|
||||
* constructor's own `[Symbol.hasInstance]` with "ordinary check against the
|
||||
* vm constructor OR the host counterpart" — the ordinary algorithm is a pure
|
||||
* prototype-chain walk, so calling it with the host constructor as receiver
|
||||
* needs no host-side change. ONLY vm-realm globals are modified; host
|
||||
* intrinsics are passed in as values and never touched.
|
||||
* Per-sandbox prelude: give the vm realm's own constructors a `Symbol.hasInstance` that checks
|
||||
* BOTH realms.
|
||||
*/
|
||||
const DUAL_REALM_INSTANCEOF_PRELUDE = `
|
||||
(hostIntrinsics) => {
|
||||
@@ -156,13 +135,10 @@ export function syntaxErrorContext(error: Error): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate mount code as the body of an async function inside the sandbox.
|
||||
* `vmTimeoutMs` only bounds the SYNCHRONOUS portion; an async body escapes it
|
||||
* — acceptable under the module's trust stance. A parse failure is answered
|
||||
* with the offending line + caret and a teaching hint: TypeScript syntax on
|
||||
* the failing line gets the remove-annotations fix, anything else gets the
|
||||
* function-body/bracket-balance reminder (models habitually close the returned
|
||||
* plugin object with `});` as if it were a callback argument).
|
||||
* Evaluate mount code as the body of an async function inside the sandbox. `vmTimeoutMs` only
|
||||
* bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's
|
||||
* trust stance.
|
||||
*
|
||||
* @param sandbox - the contextified object from {@link createSandbox}.
|
||||
* @param code - the model-written function body; must `return` a plugin.
|
||||
* @param id - the mount id, used as the vm filename (`cordis-mount-<id>.js`).
|
||||
|
||||
@@ -48,12 +48,7 @@ describe('cordis_mount', () => {
|
||||
})
|
||||
|
||||
it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => {
|
||||
// The model's execute builds its content blocks INSIDE the vm, where
|
||||
// Object.prototype is a different object — dsh-session's isJsonValue (the
|
||||
// gate every `tool/result` append runs through) compares prototype
|
||||
// IDENTITY, so a raw foreign-realm result would error the whole turn the
|
||||
// first time the self-made tool runs. harness.defineTool round-trips the
|
||||
// return into host-realm JSON before it reaches the registry.
|
||||
// Normalize vm-realm results into host JSON before session validation.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
|
||||
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
|
||||
@@ -94,11 +89,9 @@ describe('cordis_mount', () => {
|
||||
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'],
|
||||
['undefined — a forgotten return', 'return undefined', 'undefined'],
|
||||
])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => {
|
||||
// The failure this prevents: the registry trusts the return shape
|
||||
// (postExecute spreads result.content), so an unvalidated { content: 'ok' }
|
||||
// would enter the session log as ['o','k'] and silently corrupt the next
|
||||
// model request. The shape check turns it into THIS call's error instead —
|
||||
// one well-formed text block the log and the model can digest.
|
||||
// The failure this prevents: the registry trusts the return shape (postExecute spreads
|
||||
// result.content), so an unvalidated { content: 'ok' } would enter the session log as
|
||||
// ['o','k'] and silently corrupt the next model request.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
@@ -150,10 +143,8 @@ describe('cordis_mount', () => {
|
||||
})
|
||||
|
||||
it('accepts a JSON-Schema-style parameters wrapper and normalizes it to the DSL', async () => {
|
||||
// The dialect models write by strong prior: the { type:'object',
|
||||
// properties, required: […] } wrapper, `type: 'integer'`, and
|
||||
// `required: false`. All of it has exactly one meaning — normalize instead
|
||||
// of burning a model turn on a lecture.
|
||||
// The dialect models write by strong prior: the { type:'object', properties, required: […]
|
||||
// } wrapper, `type: 'integer'`, and `required: false`.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
@@ -535,10 +526,9 @@ describe('cordis_mount', () => {
|
||||
})
|
||||
|
||||
it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => {
|
||||
// The args a tool's execute receives are HOST-realm objects; without the
|
||||
// dual-realm Symbol.hasInstance prelude, `args.items instanceof Array` in
|
||||
// sandbox code is silently false. The patch lives on the vm realm's own
|
||||
// constructors only — the host realm's must stay pristine.
|
||||
// The args a tool's execute receives are HOST-realm objects; without the dual-realm
|
||||
// Symbol.hasInstance prelude, `args.items instanceof Array` in sandbox code is silently
|
||||
// false.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
|
||||
@@ -77,10 +77,7 @@ describe('sandbox context façade — escape surface is closed', () => {
|
||||
|
||||
it('denies a service whose method returns a Context (the .ctx escape), registering nothing', async () => {
|
||||
// A cordis Service instance carries `.ctx` (a real Context), so
|
||||
// `ctx.systemPrompt.ctx.root.tools.register(…)` would be a fresh unguarded
|
||||
// handle. The service wrapper's return-value guard rejects any Context on
|
||||
// the way back to sandbox code, so the escape never lands. (`systemPrompt`
|
||||
// is in the setup harness, so the plugin activates and its apply runs.)
|
||||
// `ctx.systemPrompt.ctx.root.tools.register(…)` would be a fresh unguarded handle.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
@@ -104,10 +101,8 @@ describe('sandbox context façade — escape surface is closed', () => {
|
||||
})
|
||||
|
||||
it('guards an async injected-service method: a host-realm Promise resolves through the guard', async () => {
|
||||
// The return guard's Promise arm only fires for a HOST-realm Promise
|
||||
// (a vm-realm one is not `instanceof` the host `Promise`). Provide a
|
||||
// host-realm service from the test, then inject + await it from a mount:
|
||||
// the resolved value is non-Context data and passes through.
|
||||
// The return guard's Promise arm only fires for a HOST-realm Promise (a vm-realm one is not
|
||||
// `instanceof` the host `Promise`).
|
||||
const ctx = await setup()
|
||||
ctx.plugin({
|
||||
name: 'host-async-svc',
|
||||
@@ -194,11 +189,9 @@ describe('sandbox context façade — inject gate on services', () => {
|
||||
})
|
||||
|
||||
it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => {
|
||||
// The finding's scenario: a consumer registers a tool built on a provider's
|
||||
// service WITHOUT declaring inject. cordis would then never park the
|
||||
// consumer when the provider unmounts, leaving a tool that fails only at
|
||||
// execution. The gate refuses the undeclared access up front, so the
|
||||
// dependency is always visible to cordis.
|
||||
// The finding's scenario: a consumer registers a tool built on a provider's service WITHOUT
|
||||
// declaring inject. cordis would then never park the consumer when the provider unmounts,
|
||||
// leaving a tool that fails only at execution.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }',
|
||||
@@ -231,11 +224,9 @@ describe('sandbox context façade — inject gate on services', () => {
|
||||
|
||||
describe('sandbox tools façade — get is a read-only schema view', () => {
|
||||
it('ctx.tools.get returns a schema, not the live ToolDefinition with execute', async () => {
|
||||
// The finding: returning the raw ToolDefinition hands mount code the
|
||||
// tool's execute function, letting it bypass ToolRegistry.execute (and its
|
||||
// pre/post hooks). get now returns the same name/description/parameters
|
||||
// view as schemas(), with no execute. Asserted via a self-made tool that
|
||||
// reports the shape it saw — world-checked, not self-reported.
|
||||
// The finding: returning the raw ToolDefinition hands mount code the tool's execute
|
||||
// function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now
|
||||
// returns the same name/description/parameters view as schemas(), with no execute.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
|
||||
Reference in New Issue
Block a user