The sandbox execute wrapper JSON round-tripped the return and blindly cast it
to ToolExecuteReturn. A JSON-valid but wrong-shape return — a bare string,
{ content: 'ok' }, blocks without a type tag — sailed through: the registry
spreads result.content, so { content: 'ok' } became ['o','k'], passed the
session log's isJsonValue gate, and the DeepSeek serializer then flattened it
to '(no output)' — silent corruption of the next model request and every
replay, instead of a contained tool error.
The round-tripped value is now shape-checked against the two ToolExecuteReturn
forms (array of content blocks, or { content: blocks, meta? }); block checks
are structural only (plain object + string type tag) because the ContentBlock
union is merge-extensible. A wrong shape — and the formerly cryptic
forgot-return/bare-string cases — fails that one call with a teaching error
echoing a truncated preview of what was returned and the two valid forms.
New specs pin the object-form pass-through (meta included), six rejection
shapes, and the preview truncation; per-file 100% coverage holds.
21 KiB
RFC: The self-referential cordis toolset
Status: implemented
Problem
Everything in this harness is a cordis plugin, but the agent running inside that plugin runtime cannot see or touch it: it cannot enumerate the services and events around it, cannot extend itself with a new tool mid-session, and cannot compose capabilities it invents. Handing the model that power is worth exploring — a self-referential agent that inspects and modifies its own runtime — but it raises three correctness problems at once, and the design is about answering them rather than the raw "let the model run code" mechanic.
First, model-written registration must be validated where it happens: a malformed tool schema has to fail at registration, not when a later request tries to assemble it into a prompt. Second, model-written code has to call service APIs whose source it has never seen — guessed method signatures and, worse, guessed return-value shapes cost many steps of blind probing. Third, everything the model mounts must be fully disposable, by the model on demand and by the ordinary plugin lifecycle when the host plugin reloads, or a long session accretes orphaned listeners and tools.
Decision
The toolset ships as @deepseek-ai/dsh-tool-cordis — a new top-level packages/cordis/ group — and is demoed by examples/cordis-agent. It gives the model three tools over the live cordis runtime it is running inside: inspect it, mount model-written plugins into it, dispose them again.
The trust stance, stated once and threaded through the rest: the node:vm sandbox isolates the global context only — it prevents accidental global pollution, not malice — and the ctx a mounted plugin's apply receives is a whitelist façade that narrows the surface (framework internals withheld) but not the privilege of what it exposes. The verbs the façade does expose reach the real runtime: a mounted tool can shell out through ctx.bash, read the filesystem through ctx.fs, reach the network through ctx.web. Neither the sandbox nor the façade is a security boundary; handing the model this power is the point of the toolset. A deployment loads this plugin exactly as deliberately as it grants a bash tool — an opt-in capability in the app's cordis.yml, never a product default.
The three tools
| Tool | Contract |
|---|---|
cordis_inspect |
Read-only report over the live runtime, one Markdown section per what value (omit what for all sections). Never mutates. |
cordis_mount |
Evaluates code (the body of an async JavaScript function) in a node:vm sandbox; the code must return a cordis plugin, which is mounted as a child of the cordis-dynamic group fiber and tracked under a fresh id (dyn-1, dyn-2, …). |
cordis_unmount |
Disposes one dynamic mount by id and returns only after disposal reaches quiescence — every registration the plugin made is unwound, not merely requested to stop. |
cordis_inspect sections: services (every provided ctx service and the owning fiber, non-active owners flagged), plugins (a flat list of every loaded plugin with its lifecycle state, from ctx.registry — what capabilities are loaded, deliberately not the tree shape), tools (what the model can call), dynamic (the mount table: id, name, state, provided services, awaited services), api (live service signatures + the type shapes they reference, from the generated catalog), and events (harness events with dispatch mode and signature). The model-facing tool descriptions carry the operational rules the model needs at call time; the generated tool catalog is their exhaustive rendering.
Sandbox semantics
Mount code runs via vm.createContext + runInContext, wrapped as the body of an async function under a per-mount filename (cordis-mount-<id>.js). The vm gives the code a fresh realm: writes to globalThis stay inside the sandbox, and no Node API is handed in — capability access is steered toward the cordis services (ctx.fs for files, ctx.web for HTTP, ctx.bash for processes, the ctx.timer helpers for timing) rather than Node built-ins, so a well-behaved mount stays inspectable through cordis_inspect and disposable with its fiber. This is steering, not containment: consistent with the trust stance above, the small global surface keeps honest code on the cordis services but is not a security boundary — the host-realm helpers it exposes (harness, console, btoa) are reachable functions, so mount code that goes looking (through such a helper's .constructor, say) can still reach the host realm and Node itself, which is accepted because the ctx a mount ultimately receives is fully privileged anyway. The vmTimeoutMs config bounds only the synchronous portion of evaluation; an async body escapes the bound (also acceptable under the trust stance).
Sandbox globals are deliberately small: a tagged write-through console ([cordis:<id>] … on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the harness.defineTool / harness.registerTool registration pair, the encoding primitives fresh vm contexts lack (btoa/atob as host closures over Buffer — a sanctioned exception, Buffer itself is never exposed — plus TextEncoder/TextDecoder), and callable traps over the withheld Node APIs (require, setTimeout/setInterval/setImmediate/clearTimeout/clearInterval, fetch) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; process and Buffer stay undefined so a typeof feature probe stays inert rather than detonating a throwing accessor.
Three boundary mechanisms make model-written code behave correctly across the realm seam. Dual-realm instanceof: most objects sandbox code touches are host-realm (tool args, event payloads, service returns), so a plain x instanceof Array in the vm would silently be false — a per-sandbox prelude gives the vm realm's own constructors a Symbol.hasInstance that checks both the vm constructor and its host counterpart, patching only vm-realm globals. Realm normalization of tool results: objects built inside the vm carry the vm realm's Object.prototype, which the session log's append-time plainness check (isJsonValue in dsh-session, a prototype-identity comparison) rejects, so the sandbox's harness.defineTool JSON round-trips every execute return into the host realm — which also projects it onto exactly what the log durably stores — and then shape-checks it against the two ToolExecuteReturn forms, so a JSON-valid but wrong-shape return (a bare string, { content: 'ok' }) fails that one call with a teaching error instead of entering the log as corrupt tool-result content. A whitelist context façade: the ctx a mounted plugin's apply receives is NOT the real context nor a pass-through proxy over it — it is a façade exposing only what a mount legitimately needs (tools.register marker-guarded, a read-only tools.get/schemas, on/once, provide, the timer helpers, and the services the plugin DECLARED in inject), with every framework-plumbing member (root, parent, fiber, reflect, registry, extend, isolate, intercept, plugin, set, mixin, …) denied with a teaching error. This closes an escape class rather than a single hole: a proxy that merely 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 realm normalization — a raw vm-realm result then errors a real agent turn at the plainness check. The façade has no context-valued member to reach, and the one indirect leak (an injected-service method returning a Context) is rejected on the way back to sandbox code. Two narrower rules complete the surface. First, service access requires an inject declaration: reaching a service the mount did not declare is refused even when a global provider is live — otherwise a mount could depend on a provider cordis never sees, and unmounting that provider would neither park the consumer nor unwind the tools it registered, leaving a model-visible tool that fails only at execution time. Because the read is gated on the declaration, cross-mount provide/inject keeps its lifecycle guarantees (the plugin's own inject and the fiber's pending/active gating drive activation and unload); only the apply-time ctx surface is narrowed. Second, ctx.tools.get returns a read-only schema view (name/description/parameters), never the live ToolDefinition — handing back the definition would expose its execute, letting mount code call another tool directly and bypass ToolRegistry.execute and its pre/post-execute hooks and accounting; a mount that wants to invoke a tool must go through the registry, and one that wants to introspect gets the same view schemas() returns.
Boundary errors are written around the mistakes models actually make (see Consequences for how each was found), and the boundary normalizes rather than lectures wherever the input has exactly one meaning: schema parameters accept the JSON-Schema dialect models write by strong prior — the { type: 'object', properties, required: […] } wrapper unwraps to the SchemaSpec DSL (the required array becoming per-property flags, at any nesting level), type: 'integer' maps to number, and required: false reads as optional — while genuinely meaningless input is rejected with the vocabulary enumerated (an unknown type lists the five valid ones; a non-boolean required names the rule). The remaining teaching errors: an unbalanced }); closing gets the vm's offending source line plus a "code is a function body" reminder; TypeScript syntax gets the remove-annotations fix (detected on the failing line only, so an as inside a description string does not misfire); a forgotten return gets the two valid plugin forms; a Node built-in call gets the redirect to its cordis service; a tool-name collision on re-mount gets the unmount-first-then-remount recipe.
The dynamic group and mount lifecycle
Every dynamic mount is a child of a single cordis-dynamic group fiber, itself a child of the tool-cordis plugin's fiber. The group exists so the mounts form one subtree: they are disposed as a unit, and disposing tool-cordis (HMR reload, config unload) cascades over every mount through the ordinary parent→child fiber lifecycle — no bespoke cleanup. Mounting settles before it reports: the returned fiber is await()ed, and a startup error (a throwing apply, a duplicate tool name, a duplicate service) disposes the fiber and surfaces as the tool error, so a failed mount never lingers. A settled fiber that is not active is a legal pending mount — cordis semantics for unsatisfied inject — kept mounted and reported with what it waits for. Everything the plugin registers is an effect on its fiber, so cordis_unmount is nothing but an awaited fiber.dispose().
Cross-mount composition via provide/inject
Mounts relate to each other through ordinary cordis service semantics, with their ids as the lifecycle handles: mount A calls ctx.provide('foo', value), mount B declares inject: ['foo'] and activates the moment foo exists; mounted first, B stays pending and names the missing service; unmounting A sends B back to pending (its registrations unwound) and a later re-provide re-runs B's apply through a fresh sandbox façade; a duplicate provide fails loud with the owning fiber named. One realm caveat: a service value provided by a mount is a vm-realm object — method calls on it work from anywhere, but consumers must not assume host prototypes on it.
The generated API catalog
cordis_inspect what:"api" and what:"events" answer from a machine-readable catalog generated at build time, never a hand-maintained table that would drift from the JSDoc it paraphrases. scripts/gen-cordis-api.ts reuses collectServices / collectEvents from scripts/gen-cordis-catalog.ts — the same AST walk that generates the cordis service catalog and events catalog — and emits packages/cordis/tool-cordis/src/api-catalog.ts, a committed, banner-commented data module. The artifact carries, per service, its key + one-line summary + raw method signatures; per event, name + @mode + signature + summary; the comment-stripped declarations of every exported type the service signatures reference (transitive closure — so a consumer sees that a bash run's stdout is { text, truncated }, not a string); plus the curated inherited ctx surface shared with the cordis catalog generator. A type name declared in more than one package (each plugin's Config) is dropped as ambiguous, and an oversized declaration is truncated with a marker.
Freshness is gated like every generated artifact: pnpm run verify-cordis-api (in doc-sync) regenerates in memory and fails on any diff, so a JSDoc edit that changes a public signature cannot ship without regenerating the catalog the model reads. At runtime the inspect tool intersects the catalog with the live runtime rather than dumping it: live catalogued services render summary + signatures, live services without a catalog entry (mount-provided ones) render name + owning fiber, catalogued services with no live provider are listed tersely, and the referenced type shapes follow.
Configuration, rendering, and observability
The plugin exposes one config field, validated by schemastery and documented in the config catalog: vmTimeoutMs (default 5000), the millisecond bound on the synchronous portion of mount-code evaluation. Tool names, the cordis-dynamic group name, and the dyn- id prefix are structural vocabulary and stay fixed. All three tools render as generic cards per the tool cookbook (cordis_inspect a read, cordis_mount an execute carrying the code as rawInput, cordis_unmount a delete), with no presentResult overrides.
Model-visible ⟺ logged holds with no new session event type: a mount or unmount is visible only through its own tool/call / tool/result pair, which the loop logs, and the changed tool set a mount induces is logged by the request-header delta the loop already emits when schemas change between steps. There is deliberately no cordis/mount provenance event — it would duplicate what the tool-call pair records. Dynamic mounts are process-lifetime, not session state: resuming a persisted session rehydrates the conversation but does not re-mount plugins.
Alternatives considered
A structured per-capability registration tool instead of cordis_mount. The most tempting alternative is a cordis_register_tool with explicit name / description / parameters / code fields (and siblings cordis_register_listener, cordis_register_service, …) rather than a single "mount a plugin" primitive. It was rejected because its one real win — no plugin boilerplate for the single commonest case — does not pay for its costs, while a single mount primitive answers every capability at once.
| Dimension | Structured per-capability tools | Single cordis_mount |
|---|---|---|
| Schema correctness | parameters is still a model-written JSON object needing SchemaSpec validation, merely one step earlier |
The same validation runs at the sandbox boundary, with the same instructive errors |
| The code field | An execute body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged |
One sandbox, one normalization path, one guarded registration |
| Capability coverage | Tools only; listeners, services, inject relations each need another structured tool — a surface that grows without bound |
One vocabulary (a cordis plugin) covers every effect, present and future |
| Cross-mount composition | Not expressible in a tool-registration payload | Native provide/inject, ordinary cordis semantics |
| Inspectability | Registers something the plugin list cannot show as a plugin | What the model mounts is exactly what cordis_inspect renders |
| Model ergonomics | Wins for the single most common case (no plugin boilerplate) | Mitigated by the canonical recipe in the mount description plus boundary errors that teach the fix |
The correctness investment therefore goes where it pays for every capability at once: the generated API catalog surfaced through cordis_inspect, and sandbox-boundary validation whose error messages teach the correct call. A structured registration tool remains addable later as sugar that synthesizes mount code; nothing here forecloses it.
A hand-maintained service/event reference in the tool. The first cut of the inspect tool carried a hand-written table of service method signatures. It was replaced by the generated api-catalog.ts because a hand table drifts from the JSDoc the moment a signature changes and nothing gates the drift, whereas the generated artifact is freshness-checked against the same AST the docs use.
A new cordis/mount session event. A durable provenance event recording each mount (source, name) has clear precedent (hook/invoked, compact/start). It was declined for v1: mount and unmount are already visible as tool/call / tool/result pairs and the tool-set change is already logged as a request-header delta, so a dedicated event would only duplicate the record. It remains addable if an audit use case needs mount provenance separable from the tool call.
A hardened / capability-restricted sandbox. Trapping Node built-ins and handing mount code a whitelist façade rather than the raw context might suggest an intent to sandbox for safety. It is explicitly not that: the traps and the façade narrow the surface mount code sees — steering it onto cordis services and away from leak-prone Node built-ins and framework internals — for correctness and to close the unguarded-context escape, but the capabilities the façade exposes (ctx.bash, ctx.fs, ctx.web) reach the real runtime, so it is not a security boundary. A real one (separate process, permission prompts) was out of scope for a dev/opt-in toolset and would fight the entire point — handing the model the live runtime.
Consequences
The toolset is a deliberate opt-in with a fully-privileged ctx, so a deployment adopts it as consciously as a bash tool. Several facts follow that the tool descriptions warn the model about directly: a waterfall listener (e.g. tools/pre-execute) that returns without calling next() vetoes the chain, so a mounted listener can lobotomize the agent's own tool dispatch (waterfall semantics); mount code runs inside a tool call of the current turn, so awaiting anything that resolves only after the turn deadlocks; vmTimeoutMs bounds synchronous evaluation only; and mounts do not survive session resume.
The instructive boundary errors were not guessed — they were written against live self-design sessions in which a real model was asked to build itself coding tools. Those sessions surfaced the failure modes now mitigated: the model closed a returned plugin object with }); and got only a bare Unexpected token ')' it retried blind; it hit a false-positive "this is TypeScript" hint because a description string contained the word "as"; it guessed a bash run's stdout was a string and burned six steps building throwaway debug tools to discover it is { text, truncated }; and it wrote tool schemas in the JSON-Schema dialect (type: 'integer', required: false, then the full wrapper) three rejections in a row — the rejection text itself pushing it from a nearly-correct DSL attempt back to raw JSON Schema. The fixes — source-line-plus-caret parse errors, line-scoped TypeScript detection, the type-shape closure in the API catalog, the redirect traps, and schema-dialect normalization in place of rejection — cut later sessions from dozens of tool calls with repeated errors to a first-try success on every capability, including a model that hit a Node-setTimeout trap and self-corrected to inject: ['timer'] in one step.
Coverage is named per tier: package unit specs drive the three tools through a real ToolRegistry on a real fiber tree (the mount success/failure family, vm isolation, dual-realm instanceof, realm normalization against the real isJsonValue, the SchemaSpec and raw-registration rejections, the Node-API traps, the cross-mount provide/inject matrix, catalog-backed api/events rendering, config validation, presenters, quiescent unmount, and the HMR cascade), a MockAdapter loop test proves a tool mounted in one step is dispatchable in the next, and the example carries a keyless Loader smoke plus a with-key smoke that world-verifies a live model mounting a listener, building its own tool, and composing two mounts. No snapshot scenario is added: the toolset ships in no ACP-served app, so it changes no editor-facing transcript, and its presenters are unit-tested pure functions — adding it to the ACP example solely for a golden would rewrite the pinned request-header tool set of every recorded scenario.