refactor(packages): merge timeout/ into guard/, rename cordis/ to self-modification/

git mv timeout-policy beside repeat-tool-guard (both are loop-hygiene
policies on the tool-execution pipeline, and the timeout/ group name
collided with util/timeout) and tool-cordis into self-modification/
(naming the role the framework name obscured). Merged/renamed group
README triplets, tsconfig globs, generator sources, hierarchy tables,
catalogs, and the timeout-policy design note's group references
follow. Adds the fifth FIXME marker (dsh-timeout-guard, recorded as a
suggestion to settle at resolution time).

guard + self-modification suites: 197 passed.
This commit is contained in:
Tianyi Cui
2026-07-30 03:40:42 +08:00
parent 3fc35c91ff
commit 2a40cbf8ef
107 changed files with 121 additions and 145 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
/**
* Runtime mirror and labels for Cordis's `FiberState` const enum. A const enum has no runtime
* object to import, so these values mirror the pinned vendored definition while retaining its
* type.
* @module @deepseek-ai/dsh-tool-cordis/fiber-state
*/
import type { FiberState as FiberStateEnum } from 'cordis'
/** Value mirror of the cordis `FiberState` const enum (see the module doc for why a mirror exists). */
export const FiberState = {
PENDING: 0 as FiberStateEnum.PENDING,
LOADING: 1 as FiberStateEnum.LOADING,
ACTIVE: 2 as FiberStateEnum.ACTIVE,
FAILED: 3 as FiberStateEnum.FAILED,
DISPOSED: 4 as FiberStateEnum.DISPOSED,
UNLOADING: 5 as FiberStateEnum.UNLOADING,
} as const
/** The cordis `FiberState` enum type, re-exported so mirror consumers need one import. */
export type FiberState = FiberStateEnum
/** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */
export const STATE_LABELS = {
[FiberState.PENDING]: 'pending',
[FiberState.LOADING]: 'loading',
[FiberState.ACTIVE]: 'active',
[FiberState.FAILED]: 'failed',
[FiberState.DISPOSED]: 'disposed',
[FiberState.UNLOADING]: 'unloading',
} as const satisfies Record<FiberState, string>

View File

@@ -0,0 +1,780 @@
/**
* The registration boundary between sandboxed mount code and the real runtime: ParameterSchemaSpec
* 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 of lifecycle-safe verbs and declared services;
* framework internals and context-valued service returns are denied.
*
* VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and
* presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they
* have one meaning; invalid vocabulary fails during registration with a teaching error.
* @module @deepseek-ai/dsh-tool-cordis/guard
*/
import { Context } from 'cordis'
import type { Plugin } from 'cordis'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json'])
const VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\''
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const prototype: unknown = Object.getPrototypeOf(value)
return prototype === null
|| typeof prototype === 'object'
&& Object.getPrototypeOf(prototype) === null
&& hasIntrinsicConstructor(prototype, 'Object')
}
/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
const constructor: unknown = descriptor?.value
if (typeof constructor !== 'function') return false
try {
return constructor.name === name
&& constructor.prototype === prototype
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
} catch {
return false
}
}
/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
function hasPlainArrayPrototype(value: unknown[]): boolean {
const prototype: unknown = Object.getPrototypeOf(value)
if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
const objectPrototype: unknown = Object.getPrototypeOf(prototype)
return typeof objectPrototype === 'object'
&& objectPrototype !== null
&& Object.getPrototypeOf(objectPrototype) === null
&& hasIntrinsicConstructor(objectPrototype, 'Object')
}
/* jscpd:ignore-end */
/** Whether a schema list is a dense intrinsic array with no JSON-invisible decorations. */
function isDensePlainArray(value: unknown): value is unknown[] {
if (!Array.isArray(value) || !hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
return false
}
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index)) return false
}
return true
}
/** Reject schema records whose declarations would disappear from object enumeration. */
function assertSchemaContainerKeys(value: Record<string, unknown>, path: string): void {
if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) {
throw new Error(`harness.defineTool ${path} must contain only own enumerable string keys`)
}
}
/** Where one cloned JSON value is installed. */
type CloneDestination =
| { kind: 'root' }
| { kind: 'array'; target: unknown[]; index: number }
| { kind: 'object'; target: Record<string, unknown>; key: string }
/** Deferred work for stack-safe cross-realm JSON cloning. */
type CloneTask =
| { kind: 'visit'; value: unknown; path: string; destination: CloneDestination }
| { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] }
| { kind: 'leave'; source: object }
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
function cloneJson(value: unknown, path: string): unknown {
const ancestors = new Set<object>()
let root: unknown
const assign = (destination: CloneDestination, item: unknown): void => {
if (destination.kind === 'root') {
root = item
return
}
if (destination.kind === 'array') {
destination.target[destination.index] = item
return
}
Object.defineProperty(destination.target, destination.key, {
value: item,
enumerable: true,
configurable: true,
writable: true,
})
}
const reject = (at: string): never => {
throw new Error(`harness.defineTool ${at} must be lossless JSON data`)
}
const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
ancestors.delete(task.source)
continue
}
if (task.kind === 'array-item') {
if (!Object.hasOwn(task.source, task.index)) reject(task.path)
tasks.push({
kind: 'visit',
value: task.source[task.index],
path: `${task.path}[${task.index}]`,
destination: { kind: 'array', target: task.target, index: task.index },
})
continue
}
const current = task.value
if (current === null || typeof current === 'string' || typeof current === 'boolean') {
assign(task.destination, current)
continue
}
if (typeof current === 'number') {
if (!Number.isFinite(current) || Object.is(current, -0)) reject(task.path)
assign(task.destination, current)
continue
}
if (typeof current !== 'object' || ancestors.has(current)) reject(task.path)
if (Array.isArray(current)) {
if (!hasPlainArrayPrototype(current) || Reflect.ownKeys(current).length !== current.length + 1) reject(task.path)
const output: unknown[] = []
assign(task.destination, output)
ancestors.add(current)
tasks.push({ kind: 'leave', source: current })
for (let index = current.length - 1; index >= 0; index--) {
tasks.push({ kind: 'array-item', source: current, index, path: task.path, target: output })
}
continue
}
if (!isPlainRecord(current)) reject(task.path)
const record = current as Record<string, unknown>
if (Reflect.ownKeys(record).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(record, key))) {
reject(task.path)
}
const output: Record<string, unknown> = {}
assign(task.destination, output)
ancestors.add(record)
tasks.push({ kind: 'leave', source: record })
const entries = Object.entries(record)
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({
kind: 'visit',
value: entry[1],
path: `${task.path}.${entry[0]}`,
destination: { kind: 'object', target: output, key: entry[0] },
})
}
}
return root
}
/** Copy and realm-materialize the shared annotation vocabulary. */
function copyAnnotations(value: Record<string, unknown>, output: Record<string, unknown>, path: string): void {
if (Object.hasOwn(value, 'description')) output.description = value.description
if (Object.hasOwn(value, 'title')) output.title = value.title
if (Object.hasOwn(value, 'default')) output.default = cloneJson(value.default, `${path}.default`)
if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `${path}.examples`)
}
/** Reject sandbox schema keys that the unified DSL would otherwise ignore. */
function assertSchemaKeys(value: Record<string, unknown>, path: string, allowed: readonly string[]): void {
assertSchemaContainerKeys(value, path)
for (const key of Object.keys(value)) {
if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`)
}
}
/**
* Normalize a sandbox-provided `parameters` value into a fresh host-realm
* ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root
* default, while the direct DSL is already an implicit open property map.
*/
function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): {
spec: Record<string, unknown>
rootAnnotations?: Record<string, unknown>
} {
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec object`)
}
if (value.type === 'object') {
assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS])
if (!isPlainRecord(value.properties)) {
throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
}
if (Object.hasOwn(value, 'additionalProperties') && value.additionalProperties !== true) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be true or omitted because the implicit parameter root is open`)
}
if (Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
const required = normalizeRequiredNames(value.required, value.properties, `${path}.required`)
const rootAnnotations: Record<string, unknown> = {}
copyAnnotations(value, rootAnnotations, path)
return {
spec: normalizePropertyMap(value.properties, path, required, true),
...(Object.keys(rootAnnotations).length === 0 ? {} : { rootAnnotations }),
}
}
return { spec: normalizePropertyMap(value, path, new Set(), false) }
}
/** Validate raw required names and return their lookup set. */
function normalizeRequiredNames(value: unknown, properties: Record<string, unknown>, path: string): Set<string> {
if (value === undefined) return new Set()
if (!isDensePlainArray(value)) {
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
}
const names = new Set<string>()
for (let index = 0; index < value.length; index++) {
const name = value[index]
if (typeof name !== 'string') {
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
}
names.add(name)
if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`)
}
return names
}
/** Mutable holder used only while one normalized property-map root is unresolved. */
interface NormalizeRoot {
value?: Record<string, unknown>
}
/** Where a normalized value node is installed. */
type NormalizeValueDestination =
| { kind: 'property'; target: Record<string, unknown>; key: string }
| { kind: 'item'; target: Record<string, unknown> }
| { kind: 'one-of'; target: Record<string, unknown>[]; index: number }
/** Where a normalized property map is installed. */
type NormalizeMapDestination =
| { kind: 'root'; holder: NormalizeRoot }
| { kind: 'properties'; target: Record<string, unknown> }
/** Deferred work for stack-safe sandbox schema normalization. */
type NormalizeTask =
| {
kind: 'map'
entries: Record<string, unknown>
path: string
requiredNames: ReadonlySet<string>
raw: boolean
destination: NormalizeMapDestination
}
| {
kind: 'value'
value: unknown
path: string
forceRequired: boolean
raw: boolean
parameterProperty: boolean
destination: NormalizeValueDestination
}
| { kind: 'leave'; value: object }
/** Install one normalized node without `__proto__` assignment semantics. */
function assignNormalizedValue(destination: NormalizeValueDestination, value: Record<string, unknown>): void {
if (destination.kind === 'property') {
Object.defineProperty(destination.target, destination.key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
} else if (destination.kind === 'item') {
destination.target.items = value
} else {
destination.target[destination.index] = value
}
}
/** Install one normalized property map at its root or containing object. */
function assignNormalizedMap(destination: NormalizeMapDestination, value: Record<string, unknown>): void {
if (destination.kind === 'root') destination.holder.value = value
else destination.target.properties = value
}
/** Normalize one implicit property map and all descendants with explicit work frames. */
function normalizePropertyMap(
entries: Record<string, unknown>,
path: string,
requiredNames: ReadonlySet<string>,
raw: boolean,
): Record<string, unknown> {
const holder: NormalizeRoot = {}
const ancestors = new Set<object>()
const tasks: NormalizeTask[] = [{
kind: 'map',
entries,
path,
requiredNames,
raw,
destination: { kind: 'root', holder },
}]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
ancestors.delete(task.value)
continue
}
if (task.kind === 'map') {
if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`)
assertSchemaContainerKeys(task.entries, task.path)
ancestors.add(task.entries)
const spec: Record<string, unknown> = {}
assignNormalizedMap(task.destination, spec)
tasks.push({ kind: 'leave', value: task.entries })
const mapEntries = Object.entries(task.entries)
for (let index = mapEntries.length - 1; index >= 0; index--) {
const entry = mapEntries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({
kind: 'value',
value: entry[1],
path: `${task.path}.${entry[0]}`,
forceRequired: task.requiredNames.has(entry[0]),
raw: task.raw,
parameterProperty: true,
destination: { kind: 'property', target: spec, key: entry[0] },
})
}
continue
}
const { value, path } = task
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
}
assertSchemaContainerKeys(value, path)
if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`)
ancestors.add(value)
const requiredKey = task.parameterProperty && !task.raw ? ['required'] : []
if (task.parameterProperty && task.raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
}
if (task.parameterProperty && !task.raw && Object.hasOwn(value, 'required') && value.required !== true) {
throw new Error(`harness.defineTool ${path}.required must be true when present`)
}
const prop: Record<string, unknown> = {}
assignNormalizedValue(task.destination, prop)
tasks.push({ kind: 'leave', value })
if (task.forceRequired || value.required === true) prop.required = true
copyAnnotations(value, prop, path)
if (Object.hasOwn(value, 'oneOf')) {
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
if (!isDensePlainArray(value.oneOf) || value.oneOf.length < 2) {
throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
}
const oneOf: Record<string, unknown>[] = []
prop.oneOf = oneOf
for (let index = value.oneOf.length - 1; index >= 0; index--) {
tasks.push({
kind: 'value',
value: value.oneOf[index],
path: `${path}.oneOf[${index}]`,
forceRequired: false,
raw: task.raw,
parameterProperty: false,
destination: { kind: 'one-of', target: oneOf, index },
})
}
continue
}
if (task.raw && !Object.hasOwn(value, 'type')) {
assertSchemaKeys(value, path, ANNOTATION_KEYS)
prop.type = 'json'
continue
}
if (!SCHEMA_TYPES.has(value.type) || task.raw && value.type === 'json') {
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
}
const type = value.type
prop.type = type
switch (type) {
case 'object': {
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(task.raw ? ['required'] : []), ...ANNOTATION_KEYS])
if (!task.raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
}
if (task.raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
}
if (task.raw && Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
prop.additionalProperties = task.raw ? value.additionalProperties ?? true : value.additionalProperties
if (Object.hasOwn(value, 'properties')) {
const properties = value.properties
if (!isPlainRecord(properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
const nestedRequired = task.raw
? normalizeRequiredNames(value.required, properties, `${path}.required`)
: new Set<string>()
tasks.push({
kind: 'map',
entries: properties,
path: `${path}.properties`,
requiredNames: nestedRequired,
raw: task.raw,
destination: { kind: 'properties', target: prop },
})
} else if (task.raw && value.required !== undefined) {
normalizeRequiredNames(value.required, {}, `${path}.required`)
}
break
}
case 'array':
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'items')) {
tasks.push({
kind: 'value',
value: value.items,
path: `${path}.items`,
forceRequired: false,
raw: task.raw,
parameterProperty: false,
destination: { kind: 'item', target: prop },
})
}
break
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'enum')) {
if (!isDensePlainArray(value.enum) || value.enum.length === 0) {
throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`)
}
prop.enum = cloneJson(value.enum, `${path}.enum`)
}
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
break
case 'json':
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
break
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
default:
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
}
}
/* v8 ignore next -- the root map task assigns before scheduling descendants. */
return holder.value ?? {}
}
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
Object.defineProperty(tool, DYNAMIC_TOOL, { value: true })
return tool as DynamicToolDefinition
}
function assertDynamicTool(tool: unknown): asserts tool is DynamicToolDefinition {
if (!isPlainRecord(tool) || (tool as DynamicToolMarker)[DYNAMIC_TOOL] !== true) {
throw new Error('dynamic tool registration must use a tool returned by harness.defineTool(...)')
}
}
/**
* Structurally a content block, checked AFTER the JSON round-trip: a plain
* object carrying a string `type` tag. Deliberately nothing deeper — the
* ContentBlock union is merge-extensible (an unknown tag must pass), and every
* downstream consumer dispatches on `type` and falls through unknowns.
*/
function isContentBlockShape(value: unknown): boolean {
return isPlainRecord(value) && typeof value.type === 'string'
}
/**
* How much of an invalid execute return the teaching error echoes back — a
* huge blob would burn the model turn the error is trying to save.
*/
const RETURN_PREVIEW_LIMIT = 120
/**
* Compact JSON preview of an invalid execute return for the teaching error
* (`String(…)` for the un-stringifiable undefined case), truncated to
* {@link RETURN_PREVIEW_LIMIT}.
*/
function describeReturn(value: JsonValue): string {
// The caller has already crossed cloneJson, so this value is lossless JSON
// and serialization cannot produce undefined.
const json = JSON.stringify(value)
return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json
}
/**
* Validate and host-materialize a sandbox renderer's content blocks.
*/
function assertRenderedContent(value: JsonValue): ContentBlock[] {
if (Array.isArray(value) && value.every(isContentBlockShape)) {
return value as unknown as ContentBlock[]
}
throw new Error(
`output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n`
+ ' ✓ return [{ type: \'text\', text: String(value) }]',
)
}
/**
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
* into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped,
* required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm
* via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning
* the session log.
* @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper.
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
*/
export function sandboxDefineTool(options: unknown): ToolDefinition {
if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object')
const normalized = normalizeParameterSchemaSpec(options.parameters)
if (!isPlainRecord(options.output)) {
throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }')
}
const output = options.output
if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function')
if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') {
throw new Error('harness.defineTool output.presentationMeta must be a function when present')
}
if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function')
const schema = cloneJson(output.schema, 'output.schema')
const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown>
const rawRender = output.render as (args: unknown, value: unknown) => unknown
const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined
const erasedDefineTool = defineTool as unknown as (definition: unknown) => ToolDefinition
const tool = erasedDefineTool({
...options,
parameters: normalized.spec,
output: {
schema,
render(args: unknown, value: unknown): ContentBlock[] {
return assertRenderedContent(cloneJson(rawRender(args, value), 'output.render result') as JsonValue)
},
...rawPresentationMeta !== undefined ? {
presentationMeta(args: unknown, value: unknown): JsonValue {
return cloneJson(rawPresentationMeta(args, value), 'output.presentationMeta result') as JsonValue
},
} : {},
},
async execute(args: unknown, exec: unknown): Promise<JsonValue> {
return cloneJson(await rawExecute(args, exec), 'execute result') as JsonValue
},
})
const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
assertSupportedJsonSchema(parameters)
return markDynamicTool({
...tool,
parameters,
})
}
/**
* The `harness.registerTool` handed into the sandbox: registers a
* marker-verified dynamic tool on the given context's registry.
* @param ctx - the (guarded) context whose `tools` service receives the tool.
* @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
* @returns the registry disposer for the registration.
*/
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
assertDynamicTool(tool)
return ctx.tools.register(tool)
}
/**
* 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'])
/**
* The tool-registry façade: `register` (marker-guarded) plus READ-ONLY
* metadata (`schemas`, and `get` returning a schema view, never the live
* `ToolDefinition`). Exposing the raw definition would hand mount code the
* tool's `execute` function, letting it call another tool directly and bypass
* `ToolRegistry.execute` — identity protection, pre-policy, monotonic guards,
* around dispatch, post-policy, final observation, and result normalization. So `get` returns the same
* name/description/parameters view as `schemas()`, and nothing invocable.
*/
function sandboxTools(ctx: Context): Record<string, unknown> {
// Resolve reads and writes through the mount's own scope.
return {
register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
get: (name: string) => ctx.tools.schemas(scopeOf(ctx)).find(schema => schema.name === name),
}
}
/**
* Reject any injected-service return that is a cordis `Context`. Harness
* services return data, never a context; a value that is one would be a
* fresh, unguarded handle back into the runtime — the exact escape the façade
* exists to close — so it fails loud instead of reaching sandbox code.
*/
function denyContext(value: unknown, service: string): unknown {
if (value instanceof Context) {
throw new Error(
`service "${service}" returned a cordis Context, which the sandbox does not expose. `
+ 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
+ 'and the services you inject — never another context.',
)
}
return value
}
/**
* Wrap an injected service so its methods forward to the real instance but
* their return values pass through {@link denyContext}. Non-function members
* (plain data) pass through as-is; a returned Promise is guarded on resolve.
*/
function guardedService(service: object, name: string): unknown {
return new Proxy(service, {
get(target, prop) {
const value = Reflect.get(target, prop, target) as unknown
if (typeof value !== 'function') return denyContext(value, name)
return (...args: unknown[]): unknown => {
const result = Reflect.apply(value, target, args) as unknown
if (result instanceof Promise) return result.then(v => denyContext(v, name))
return denyContext(result, name)
}
},
})
}
/**
* The service names a plugin declared in `inject`, as a lookup set. Whatever
* declaration style the plugin used — an `inject: ['bash', 'tools']` array or
* the `{ required, optional }` object form — cordis resolves it into a single
* name-keyed map on the fiber before `apply` runs (`{ bash: null, tools: null }`),
* so the gate just reads that map's keys. A mount may reach only the services
* it declared — that is what lets cordis park the mount when a declared
* provider unmounts.
*/
function declaredInjects(ctx: Context): Set<string> {
return new Set(Object.keys(ctx.fiber.inject))
}
/**
* Whitelist context for mounted plugins: lifecycle-safe verbs, guarded tools, and only declared
* injected services. Framework plumbing is denied, and service methods cannot return a Context.
*/
function sandboxContext(ctx: Context): Context {
const tools = sandboxTools(ctx)
const declared = declaredInjects(ctx)
// A framework member or an undeclared service — distinguish the two so the
// error teaches the right fix (declare it in inject vs it is withheld).
const denyRead = (prop: string): never => {
if (ctx.get(prop) !== undefined) {
throw new Error(
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
+ 'so cordis parks this temporary Plugin if the provider is later unmounted.',
)
}
throw new Error(
`sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
+ 'the timer helpers (ctx.setTimeout, ctx.interval, …) and any service you declared in inject. '
+ '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.
const readService = (name: string): unknown => {
if (name === 'tools') return tools
if (!declared.has(name)) return denyRead(name)
const service = denyContext(ctx.get(name), name)
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
return guardedService(service, name)
}
const get = (name: string): unknown => readService(name)
return new Proxy({}, {
get(_target, prop) {
if (prop === 'tools') return tools
if (prop === 'get') return get
if (typeof prop !== 'string') return undefined
// Lazy verb forwarder — reads `ctx[verb]` only when called, so a plugin
// that never uses a timer never triggers the timer mixin's inject check
// (cordis raises its own "without inject" error there for undeclared timer use).
if (CTX_VERBS.has(prop)) {
return (...args: unknown[]): unknown => {
const method = ctx[prop as keyof Context]
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
}
}
return readService(prop)
},
// A façade is not the real ctx; block writes rather than let mount code
// stash state on a throwaway object and think it persisted.
set(_target, prop) {
throw new Error(`sandbox ctx is read-only; cannot assign "${String(prop)}"`)
},
// `in` reflects reachability: the façade surface plus DECLARED services
// (whether or not currently live). Does not resolve/wrap — no throw.
has: (_target, prop) => prop === 'tools' || prop === 'get'
|| (typeof prop === 'string' && (CTX_VERBS.has(prop) || declared.has(prop))),
}) as unknown as Context
}
/**
* Narrow an arbitrary sandbox return value to a mountable cordis plugin: a
* function, or an object with an `apply` function. (A bare function passes the
* first arm, so the object arm never sees `Function.prototype.apply`.)
* @param value - whatever the mount code returned.
* @returns whether the value is mountable via `ctx.plugin`.
*/
export function isPlugin(value: unknown): value is Plugin {
if (typeof value === 'function') return true
return typeof value === 'object' && value !== null
&& typeof (value as { apply?: unknown }).apply === 'function'
}
/**
* 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.
*/
export function guardedPlugin(plugin: Plugin): Plugin {
if (typeof plugin === 'function') {
const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
return {
name: pluginName(plugin),
apply(ctx: Context, config?: unknown) {
return functionPlugin(sandboxContext(ctx), config)
},
}
}
const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
return {
...plugin,
apply(ctx: Context, config?: unknown) {
return objectPlugin.apply(sandboxContext(ctx), config)
},
}
}
/**
* Display name for a mounted plugin: its `name` property, else anonymous.
* @param plugin - the plugin the mount code returned.
* @returns the human-readable name used in mount results and inspect output.
*/
export function pluginName(plugin: Plugin): string {
const named = (plugin as { name?: unknown }).name
if (typeof named === 'string' && named.length > 0) return named
return '<anonymous>'
}

View File

@@ -0,0 +1,266 @@
/**
* Self-referential runtime tools: inspect live services/plugins/tools, mount a returned temporary
* plugin under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects,
* so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent
* accidental misuse, not hostile code: an allowed service such as `ctx.bash` reaches the real
* runtime. Named exports preserve loader injection metadata.
* @module @deepseek-ai/dsh-tool-cordis
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { STATE_LABELS } from './fiber-state.ts'
import { isPlugin, pluginName } from './guard.ts'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts'
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts'
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
import { createSandbox, evaluateMountCode } from './sandbox.ts'
export const name = 'tool-cordis'
export const inject = ['tools']
/** Config for the tool-cordis plugin: the sandbox evaluation bound. */
export interface Config {
/**
* Milliseconds the SYNCHRONOUS portion of mount code may run in the vm
* before evaluation is aborted (default 5000). An async body escapes this
* bound — see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
*/
vmTimeoutMs?: number
}
/** Schemastery validator for {@link Config}: `vmTimeoutMs` must be at least 1 (defaults to 5000). */
export const Config: z<Config> = z.object({
vmTimeoutMs: z.number().min(1).default(5000),
})
/** {@link Config} with every defaulted field present, as schemastery resolves it at load. */
type ResolvedConfig = Required<Config>
/**
* Register the three cordis tools and own every temporary plugin under one
* `cordis-dynamic` group fiber.
* @param ctx - the plugin context (`tools` injected).
* @param config - the schemastery-resolved {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
const { vmTimeoutMs } = config as ResolvedConfig
// The one group fiber every dynamic mount hangs under.
const group = ctx.plugin({ name: 'cordis-dynamic', apply: () => {} })
const mounts = new Map<string, DynamicMount>()
let nextId = 1
ctx.tools.register(defineTool({
name: 'cordis_inspect',
description:
'Inspect the live Cordis runtime in the current DSH process. Read-only. '
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
+ '`plugins` (all live plugin fibers with their lifecycle states), '
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
+ '`temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), '
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
+ '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). '
+ 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. '
+ 'The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. '
+ 'With `what:"api"` or `what:"events"`, pass an exact `name` '
+ 'to narrow to one service/event and include its original source JSDoc.',
parameters: {
what: {
type: 'string',
enum: ['services', 'plugins', 'tools', 'temporary', 'api', 'events'],
description: 'Limit the report to one section. Omit for all sections.',
},
name: {
type: 'string',
description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".',
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
execute(args, exec): Promise<string> {
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') {
throw new Error('name is valid only with what:"api" or what:"events"')
}
const sections: [key: string, heading: string, body: () => string[]][] = [
['services', 'services', () => describeServices(ctx)],
['plugins', 'plugins', () => describePlugins(ctx)],
// The calling agent's view: scoped/shadowed tools included, restricted
// globals absent — "what you can call", not the global registry.
['tools', 'tools', () => describeTools(ctx, exec.agent)],
['temporary', 'Temporary Plugins', () => describeDynamic(ctx, mounts)],
['api', 'api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
['events', 'events', () => describeEvents(EVENT_API, args.name)],
]
const selected = sections.filter(([key]) => args.what === undefined || args.what === key)
const text = selected
.map(([, heading, body]) => `## ${heading}\n${body().join('\n')}`)
.join('\n\n')
return Promise.resolve(text)
},
presentCall: presentInspectCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_mount',
description:
'Mount a temporary Cordis Plugin in the current DSH process. '
+ 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. '
+ 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. '
+ 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. '
+ 'To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. '
+ 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. '
+ '`code` runs now as the body of an async JavaScript function '
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: '
+ 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register '
+ 'tools, listen to events, and provide services, but reaching ANY service (e.g. '
+ 'ctx.bash) throws; use it only when you need no services. '
+ 'OBJECT form `return { name?, inject: [\'bash\', \'llm\', …], apply(ctx) { … } }` '
+ '— declares dependencies, and cordis activates the plugin only after the '
+ 'services exist; PREFER this form. You may reach ONLY the services you list in '
+ 'inject: an undeclared service throws even if it exists, because an undeclared '
+ 'dependency would not be cleaned up if its provider is unmounted. '
+ 'BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists '
+ 'method signatures AND the type shapes of their arguments/returns (do not guess a '
+ 'field\'s type; e.g. a bash run\'s stdout is an object, not a string). '
+ 'Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe '
+ 'events (see cordis_inspect what:"events"), or call '
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
+ '{ text: { type: \'string\', required: true } }, output: { schema: { type: \'string\' }, '
+ 'render(_args, value) { return [{ type: \'text\', text: value }] } }, async execute(args) { return args.text } }))` '
+ 'to give yourself a new tool — it becomes callable on your NEXT step. '
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', '
+ 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and '
+ 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A '
+ 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; '
+ '`output.render(args, value)` separately returns Native/model content blocks. '
+ 'Temporary Plugins can COMPOSE: one Plugin may `ctx.provide(\'name\', value)` a service and '
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
+ 'until the provider exists and returns to pending when the provider is unmounted. '
+ 'Everything registered inside `apply` is cleaned up automatically by cordis_unmount. '
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
+ 'Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, '
+ 'never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect '
+ 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for '
+ 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, '
+ 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
+ 'auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. '
+ 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). '
+ 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a '
+ 'trailing `next` callback which MUST be called — returning without `next()` '
+ 'VETOES the call; prefer plain notification events unless you intend to '
+ 'intercept. (2) Never await something that only resolves after the current '
+ 'turn (your code runs INSIDE a tool call of that turn — it would deadlock). '
+ '(3) Your `ctx` is a restricted façade: you can register tools, observe '
+ 'events, provide/consume services, and use timers, but framework internals '
+ '(ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a '
+ 'security boundary though — the services you inject (e.g. ctx.bash) reach the '
+ 'real runtime.',
parameters: {
code: {
type: 'string',
required: true,
description: 'JavaScript body returning a temporary Plugin; evaluated now and saved nowhere.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
pluginName: { type: 'string', required: true },
state: {
type: 'string',
required: true,
enum: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'],
},
provides: { type: 'array', required: true, items: { type: 'string' } },
waitingFor: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => {
const status = value.waitingFor.length > 0
? `is pending (plugin "${value.pluginName}"; missing services: ${value.waitingFor.join(', ')}`
: `is running (plugin "${value.pluginName}"`
return [{
type: 'text',
text: `Temporary Plugin ${value.id} ${status}; available until unmounted or DSH restarts).`,
}]
},
},
async execute(args) {
const id = `dyn-${nextId++}`
const sandbox = createSandbox(id)
const evaluated = await evaluateMountCode(sandbox, args.code, id, vmTimeoutMs)
if (!isPlugin(evaluated)) {
if (evaluated === undefined) {
throw new Error(
'temporary Plugin code returned `undefined` — did you forget `return`?\n'
+ ' ✓ return (ctx) => { … }\n'
+ ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }',
)
}
throw new Error(
'temporary Plugin code must `return` a Plugin: a function, or an object with an `apply(ctx)` method',
)
}
const fiber = await mountDynamic(group, evaluated)
mounts.set(id, { fiber, pluginName: pluginName(evaluated) })
// A settled fiber that is not ACTIVE is waiting on unsatisfied inject —
// legal cordis semantics (it activates when the service appears), so keep
// it mounted but tell the model what it is waiting for.
const missing = missingServices(ctx, fiber)
const state = STATE_LABELS[fiber.state]
return {
id,
pluginName: pluginName(evaluated),
state,
provides: providedServices(ctx, fiber),
waitingFor: missing,
}
},
presentCall: presentMountCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_unmount',
description:
'Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. '
+ 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.',
parameters: {
id: {
type: 'string',
required: true,
description: 'The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
pluginName: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was unmounted and removed.` }],
},
async execute(args) {
const mount = mounts.get(args.id)
if (!mount) {
throw new Error(`no temporary Plugin with id "${args.id}" (list them with cordis_inspect what:"temporary")`)
}
await mount.fiber.dispose()
mounts.delete(args.id)
return { id: args.id, pluginName: mount.pluginName }
},
presentCall: presentUnmountCall,
}))
}

View File

@@ -0,0 +1,229 @@
/**
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
* plugin list, the registered tools, the temporary-plugin table (with per-plugin provides/waits),
* and the catalog-backed `api` / `events` sections. Exact-name lookups add the
* original source JSDoc without inflating the default reports.
* @module @deepseek-ai/dsh-tool-cordis/inspect
*/
import type { Context, Fiber } from 'cordis'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, TypeApiEntry } from './api-catalog.ts'
import { FiberState, STATE_LABELS } from './fiber-state.ts'
import { missingServices } from './mount.ts'
import type { DynamicMount } from './mount.ts'
/** The live service registrations from `ctx.reflect.store` (map + filter keeps the possibly-undefined index read branch-free). */
function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
const store = ctx.reflect.store
return Object.getOwnPropertySymbols(store)
.map(key => store[key])
.filter((impl): impl is NonNullable<typeof impl> => impl !== undefined)
}
/** Whether `fiber` is `root` itself or mounted anywhere inside `root`'s subtree. */
function withinFiber(fiber: Fiber, root: Fiber): boolean {
let current = fiber
while (true) {
if (current === root) return true
const parent = current.parent.fiber
if (parent === current) return false
current = parent
}
}
/**
* Return the service names provided by a mount's fiber subtree.
* @param ctx - the runtime whose service registrations are inspected.
* @param fiber - the root of the mounted fiber subtree.
* @returns the provided service names in lexical order.
*/
export function providedServices(ctx: Context, fiber: Fiber): string[] {
return liveImpls(ctx)
.filter(impl => withinFiber(impl.fiber, fiber))
.map(impl => impl.name)
.sort()
}
/**
* The `services` section: every provided ctx service with its owning fiber,
* annotating non-active owners with their lifecycle state.
* @param ctx - the runtime to enumerate.
* @returns one line per service, or a single placeholder line when none are provided.
*/
export function describeServices(ctx: Context): string[] {
const lines = liveImpls(ctx).map((impl) => {
const active = impl.fiber.state === FiberState.ACTIVE
return `- ${impl.name} (provided by ${impl.fiber.name}${active ? '' : `, ${STATE_LABELS[impl.fiber.state]}`})`
})
return lines.length > 0 ? lines : ['(no services provided)']
}
/**
* The `plugins` section: a flat list of every fiber the registry knows, one
* line per fiber with its lifecycle state, sorted by plugin name (a plugin
* mounted more than once repeats — one line per instance). Temporary plugins are
* listed like any other plugin; their ids live in the `temporary` section.
* @param ctx - the runtime whose registry is enumerated.
* @returns one line per loaded plugin fiber.
*/
export function describePlugins(ctx: Context): string[] {
const fibers: Fiber[] = []
for (const runtime of ctx.registry.values()) {
for (const fiber of runtime.fibers) fibers.push(fiber)
}
return fibers
.sort((a, b) => a.name.localeCompare(b.name))
.map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`)
}
/**
* The `tools` section: the model-facing tool names the CALLING agent can see
* (its scoped layer shadowing/joining the restricted global surface) — the
* honest answer to the tool description's "what you can call".
* @param ctx - the runtime whose tool registry is read.
* @param scope - the calling agent (the viewing scope); omitted = global view.
* @returns one line per visible tool.
*/
export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
return ctx.tools.schemas(scope).map(schema => `- ${schema.name}`)
}
/**
* The `temporary` section: one line per temporary plugin with id, plugin name, lifecycle
* state, the services its subtree provides, and — for a pending mount — the
* services it waits for.
* @param ctx - the runtime the mounts live in.
* @param mounts - the tracked mounts, in mount order.
* @returns one line per mount, or a single placeholder line when none exist.
*/
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
if (mounts.size === 0) {
return ['No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.']
}
return [...mounts].map(([id, mount]) => {
const provides = providedServices(ctx, mount.fiber)
const waiting = missingServices(ctx, mount.fiber)
const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state]
return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until unmounted or DSH restarts`
})
}
/**
* The transitive closure of catalogued type shapes referenced (word-bounded)
* by the seed texts — the runtime scoping that keeps the `api` section to the
* shapes the LIVE signatures actually mention.
*/
function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEntry[] {
const included = new Map<string, TypeApiEntry>()
let frontier = seeds
while (frontier.length > 0) {
const next: string[] = []
for (const entry of types) {
if (included.has(entry.name)) continue
const pattern = new RegExp(`\\b${entry.name}\\b`)
if (frontier.some(text => pattern.test(text))) {
included.set(entry.name, entry)
next.push(entry.declaration)
}
}
frontier = next
}
return [...included.values()].sort((a, b) => a.name.localeCompare(b.name))
}
/** Render one catalogued service, optionally including source-owned method JSDoc. */
function serviceLines(entry: ServiceApiEntry, detailed: boolean): string[] {
const lines = [`- ${entry.key} — ${entry.summary}`]
for (const method of entry.methods) {
if (detailed) {
for (const docLine of method.jsDoc.split('\n')) lines.push(` ${docLine}`)
}
lines.push(` ${method.signature}`)
}
return lines
}
/**
* Render the generated catalog against the live runtime: live catalogued services with methods,
* uncatalogued live services with owners, absent loadable services, referenced type shapes, and
* inherited Context APIs.
* @param ctx - the runtime to intersect the catalog with.
* @param api - generated service entries, replaceable in tests.
* @param inherited - inherited `ctx` entries, replaceable in tests.
* @param types - public type shapes, replaceable in tests.
* @param name - exact live service key whose methods should include original JSDoc; omitted for the compact catalog.
* @returns the section lines.
*/
export function describeApi(
ctx: Context,
api: readonly ServiceApiEntry[] = SERVICE_API,
inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API,
types: readonly TypeApiEntry[] = TYPE_API,
name?: string,
): string[] {
const live = new Map<string, string>()
for (const impl of liveImpls(ctx)) live.set(impl.name, impl.fiber.name)
const lines: string[] = []
const liveMethodTexts: string[] = []
let selected = api.filter(entry => live.has(entry.key))
if (name !== undefined) {
const entry = api.find(candidate => candidate.key === name)
if (!entry) throw new Error(`no catalogued service named "${name}"`)
if (!live.has(name)) throw new Error(`catalogued service "${name}" is not running`)
selected = [entry]
}
for (const entry of selected) {
lines.push(...serviceLines(entry, name !== undefined))
for (const method of entry.methods) {
liveMethodTexts.push(method.signature)
}
}
if (name === undefined) {
const catalogued = new Set(api.map(entry => entry.key))
for (const [liveName, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) {
if (!catalogued.has(liveName)) lines.push(`- ${liveName} (provided by ${fiber}, no catalog entry)`)
}
const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key)
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
}
const shapes = typeClosure(liveMethodTexts, types)
if (shapes.length > 0) {
lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):')
for (const shape of shapes) {
for (const declLine of shape.declaration.split('\n')) lines.push(` ${declLine}`)
}
}
if (name === undefined) {
lines.push('inherited ctx API:')
for (const entry of inherited) lines.push(`- ${entry.name} — ${entry.summary}`)
}
return lines
}
/**
* The `events` section: every harness event with its dispatch mode, one-line
* summary, and exact signature, closed by the waterfall caution.
* @param events - the event catalog (the generated one by default; injectable for tests).
* @param name - exact event name whose signature should include original JSDoc; omitted for the compact catalog.
* @returns the section lines.
*/
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] {
let selected = events
if (name !== undefined) {
const event = events.find(candidate => candidate.name === name)
if (!event) throw new Error(`no catalogued event named "${name}"`)
selected = [event]
}
const lines = selected.flatMap((event) => {
const entry = [`- ${event.name} [${event.mode}] — ${event.summary}`]
if (name !== undefined) {
for (const docLine of event.jsDoc.split('\n')) entry.push(` ${docLine}`)
}
entry.push(` ${event.signature}`)
return entry
})
lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.')
return lines
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-cordis`.
* @module @deepseek-ai/dsh-tool-cordis/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis'
/** Cordis companion plugin name. */
export const name = 'tool-cordis-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
* relations are owned by the capability seam it calls.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,61 @@
/**
* Dynamic-mount lifecycle over the `cordis-dynamic` group fiber: settle a
* sandbox-produced plugin as a child fiber (never leaving a failed fiber
* mounted), and report the services a settled-but-pending fiber still waits
* for. Disposal needs no helper — a mount unwinds through an ordinary awaited
* `fiber.dispose()`, because everything the plugin registered is an effect on
* its fiber.
*
* @module @deepseek-ai/dsh-tool-cordis/mount
*/
import type { Context, Fiber, Plugin } from 'cordis'
import { guardedPlugin } from './guard.ts'
/** One tracked dynamic mount: the fiber plus the display name captured at mount time. */
export interface DynamicMount {
/** The child fiber under the `cordis-dynamic` group. */
fiber: Fiber
/** The plugin's display name at mount time (its `name`, else `<anonymous>`). */
pluginName: string
}
/**
* Await the group, mount and settle one guarded child, and dispose it before rethrowing any
* startup failure so a failed mount never lingers. A valid unresolved inject may remain pending.
* @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`).
*/
export async function mountDynamic(group: Fiber, plugin: Plugin): Promise<Fiber> {
await group.await()
const fiber = group.ctx.plugin(guardedPlugin(plugin))
try {
await fiber.await()
} catch (error) {
await fiber.dispose()
const message = error instanceof Error ? error.message : String(error)
// The commonest startup collision is remounting a NEW version of a tool
// while the old mount still holds the name — teach the replace recipe.
if (message.includes('already registered')) {
throw new Error(
`${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_unmount that Plugin's id `
+ '(find it with cordis_inspect what:"temporary"), then mount the new version.',
)
}
throw error instanceof Error ? error : new Error(message)
}
return fiber
}
/**
* The services a fiber declared in `inject` that do not exist yet — a settled
* fiber that is not active is waiting on exactly these (legal cordis
* semantics: it activates when the service appears).
* @param ctx - the context to resolve service existence against.
* @param fiber - the mount fiber whose `inject` declarations are checked.
* @returns the missing service names, in declaration order.
*/
export function missingServices(ctx: Context, fiber: Fiber): string[] {
return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
}

View File

@@ -0,0 +1,52 @@
/**
* UI render intents for the three cordis tools — all `generic` cards, decided
* up front as part of the tool design. Presenters are pure functions of the
* call arguments (they run on replay too): no I/O, no session state, no clock.
* No `presentResult` overrides exist — the tools' text results are their
* correct completed rendering.
*
* @module @deepseek-ai/dsh-tool-cordis/present
*/
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
/**
* The `cordis_inspect` call card: a read, titled with the requested section.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
export function presentInspectCall(args: { what?: string; name?: string }): GenericCallView {
const target = args.name === undefined ? args.what : `${args.what}: ${args.name}`
return {
card: 'generic',
kind: 'read',
title: target === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${target}`,
}
}
/**
* The `cordis_mount` call card: an execute carrying the temporary-plugin code as raw input.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
export function presentMountCall(args: { code: string }): GenericCallView {
return {
card: 'generic',
kind: 'execute',
title: 'Mount temporary Cordis Plugin',
rawInput: { code: args.code },
}
}
/**
* The `cordis_unmount` call card: a delete, titled with the temporary-plugin id.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
export function presentUnmountCall(args: { id: string }): GenericCallView {
return {
card: 'generic',
kind: 'delete',
title: `Unmount temporary Cordis Plugin ${args.id}`,
}
}

View File

@@ -0,0 +1,179 @@
/**
* 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. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
* `ctx.bash`, and Cordis timers. This keeps cooperative mounts inspectable and disposable but
* is not containment: host-realm helper functions remain an escape route.
* @module @deepseek-ai/dsh-tool-cordis/sandbox
*/
import { createContext, runInContext } from 'node:vm'
import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
/**
* A write-through console for one sandbox, tagging every line with the mount
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
* a mounted listener fires long after the mount call returned, and its output
* must land somewhere the user can see — for a terminal front door, the host terminal.
*/
function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> {
const tag = `[cordis:${id}]`
const log = (...args: unknown[]): void => { console.log(tag, ...args) }
const error = (...args: unknown[]): void => { console.error(tag, ...args) }
return { log, info: log, warn: log, debug: log, error }
}
/**
* Patch only VM constructors so `instanceof` accepts both VM values and host values passed as
* arguments, events, or service results; host intrinsics remain untouched.
*/
const DUAL_REALM_INSTANCEOF_PRELUDE = `
(hostIntrinsics) => {
'use strict'
const ordinary = Function.prototype[Symbol.hasInstance]
for (const name of Object.keys(hostIntrinsics)) {
const VmCtor = globalThis[name]
const HostCtor = hostIntrinsics[name]
if (typeof VmCtor !== 'function' || typeof HostCtor !== 'function') continue
Object.defineProperty(VmCtor, Symbol.hasInstance, {
value: (instance) => ordinary.call(VmCtor, instance) || ordinary.call(HostCtor, instance),
configurable: true,
})
}
}
`
/** Run {@link DUAL_REALM_INSTANCEOF_PRELUDE} in a freshly created sandbox, handing it the host intrinsics to pair up. */
function patchDualRealmInstanceof(sandbox: object): void {
const patch = runInContext(DUAL_REALM_INSTANCEOF_PRELUDE, sandbox) as (intrinsics: Record<string, unknown>) => void
patch({ Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set })
}
const TIMER_REDIRECT
= 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when unmounted.'
/**
* The callable Node APIs the sandbox deliberately disables, each mapped to the
* cordis alternative its trap error names. Only FUNCTION-shaped globals are
* trapped — a data-shaped global like `process` stays `undefined`, because a
* throwing accessor would detonate the common `typeof process` feature probe
* at resolution time.
*/
const NODE_API_REDIRECTS: Record<string, string> = {
require:
'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, '
+ '[\'web\'] for HTTP, [\'bash\'] for processes; cordis_inspect what:"api" lists what THIS runtime provides.',
setTimeout: TIMER_REDIRECT,
setInterval: TIMER_REDIRECT,
setImmediate: TIMER_REDIRECT,
clearTimeout: TIMER_REDIRECT,
clearInterval: TIMER_REDIRECT,
fetch:
'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
+ '(see cordis_inspect what:"api" for its methods).',
}
/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */
function nodeApiTraps(): Record<string, () => never> {
const traps: Record<string, () => never> = {}
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
traps[name] = () => {
throw new Error(`${name} is not available in the temporary Plugin sandbox — ${redirect}`)
}
}
return traps
}
/**
* Build the vm context one `cordis_mount` call evaluates in: the tagged
* console, the `harness` registration helpers, the encoding primitives, the
* Node-API traps, and the dual-realm `instanceof` patch, already
* `createContext`-ed.
* @param id - the mount id (`dyn-<n>`), used as the console tag and filename stem.
* @returns the contextified sandbox object to pass to {@link evaluateMountCode}.
*/
export function createSandbox(id: string): object {
const sandbox = {
...nodeApiTraps(),
console: taggedConsole(id),
harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool },
// Web APIs absent from fresh vm contexts — made available so the model
// can encode/decode base64 without Buffer (which is also absent). Host
// closures over Buffer, never Buffer itself.
btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'),
atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'),
TextEncoder,
TextDecoder,
}
createContext(sandbox)
patchDualRealmInstanceof(sandbox)
return sandbox
}
/**
* Cross-realm SyntaxError detection: a compile failure inside `runInContext`
* constructs its error in the SANDBOX realm, so a host `instanceof
* SyntaxError` is silently false — the `name` property is the realm-safe tag.
*/
function isSyntaxError(error: unknown): error is Error {
return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'SyntaxError'
}
/**
* The parse-failure context a vm `SyntaxError` carries: the vm prints the
* offending source line and a caret before the message, which is exactly what
* a model needs to self-correct — surface it instead of the bare message.
* Falls back to `String(error)` when the stack carries no such prelude.
* @param error - the `SyntaxError` (host- or sandbox-realm) thrown while compiling mount code.
* @returns the stack prefix up to and including the `SyntaxError: …` line.
*/
export function syntaxErrorContext(error: Error): string {
const lines = (error.stack ?? '').split('\n')
const messageIndex = lines.findIndex(line => line.startsWith('SyntaxError'))
if (messageIndex === -1) return String(error)
return lines.slice(0, messageIndex + 1).join('\n')
}
/**
* 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. Parse errors include the offending line and a TypeScript-removal or bracket-
* balance hint.
* @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`).
* @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
* @returns whatever the code returned, still un-narrowed (the mount lifecycle checks plugin shape).
*/
export async function evaluateMountCode(sandbox: object, code: string, id: string, vmTimeoutMs: number): Promise<unknown> {
try {
return await runInContext(
`(async () => {\n${code}\n})()`,
sandbox,
{ filename: `cordis-mount-${id}.js`, timeout: vmTimeoutMs },
)
} catch (error) {
if (!isSyntaxError(error)) throw error
const context = syntaxErrorContext(error)
// Scope the TypeScript heuristic to the OFFENDING line, not the whole
// code: an ` as ` inside an ordinary description string must not turn a
// plain syntax error into a misleading remove-annotations message.
const offendingLine = context.split('\n')[1] ?? ''
if (/\bas\b/.test(offendingLine)) {
throw new Error(
`temporary Plugin code failed to parse:\n${context}\n`
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
+ ' ✗ { type: \'text\' as const, text: x }\n'
+ ' ✓ { type: \'text\', text: x }',
)
}
throw new Error(
`temporary Plugin code failed to parse:\n${context}\n`
+ 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.',
)
}
}