• feat(self-modification): add dynamic Cordis plugin runtime and UI

This commit is contained in:
imccyu
2026-08-12 23:51:31 +08:00
parent 0367506471
commit 4064198560
147 changed files with 20904 additions and 2412 deletions
@@ -0,0 +1,836 @@
/**
* The registration boundary between a sandboxed host half and the real runtime: ParameterSchemaSpec
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
* `harness.registerTool` pair, the `harness.handle` invoke-handler normalizer, the SANDBOX CONTEXT
* FAÇADE a running plugin's `apply` receives in place of the real `ctx`, and the plugin-shape
* helpers the run 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-cordis-host-runner/guard
*/
import { Context } from '@deepseek-ai/cordis'
import type { Plugin } from '@deepseek-ai/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('cordis-host-runner.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; `path` carries the caller's own error prefix. */
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 => {
// Naming the executable next step matters more than naming the rule: the
// usual cause is a handler that returns whatever its last call produced,
// and the fix is one keyword.
throw new Error(`${at} must be lossless JSON data (objects, arrays, strings, numbers, booleans, null) — `
+ 'not a class instance, function, Map/Set, Date, or undefined. Return a plain object built from the '
+ 'values you need, or `return null` when the caller needs no value back.')
}
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, `harness.defineTool ${path}.default`)
if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `harness.defineTool ${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, `harness.defineTool ${path}.enum`)
}
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `harness.defineTool ${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, 'harness.defineTool 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), 'harness.defineTool output.render result') as JsonValue)
},
...rawPresentationMeta !== undefined ? {
presentationMeta(args: unknown, value: unknown): JsonValue {
return cloneJson(rawPresentationMeta(args, value), 'harness.defineTool output.presentationMeta result') as JsonValue
},
} : {},
},
async execute(args: unknown, exec: unknown): Promise<JsonValue> {
return cloneJson(await rawExecute(args, exec), 'harness.defineTool execute result') as JsonValue
},
})
const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
assertSupportedJsonSchema(parameters)
return markDynamicTool({
...tool,
parameters,
})
}
/**
* Normalize one `harness.handle` registration at the sandbox boundary: the
* method name must be a non-empty string and the handler a function whose
* result is host-materialized through the same cross-realm JSON clone as tool
* `execute` returns (a VM-realm object would otherwise escape the wire's
* plain-object contract).
* @param method - handler name the package's browser half calls through `host.call`.
* @param fn - sandbox handler receiving the wire-decoded JSON arguments.
* @returns the validated name and the clone-wrapped handler.
*/
export function normalizeHandler(method: unknown, fn: unknown): { method: string; handler: (args: unknown) => Promise<unknown> } {
if (typeof method !== 'string' || method.length === 0) {
throw new Error('harness.handle(method, fn) needs a non-empty string method name')
}
if (typeof fn !== 'function') {
throw new Error(`harness.handle("${method}") needs a handler function as its second argument`)
}
const rawHandler = fn as (args: unknown) => unknown
return {
method,
handler: async (args: unknown): Promise<unknown> =>
cloneJson(await rawHandler(args), `harness.handle("${method}") result`),
}
}
/**
* 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 running host half may reach through the sandbox `ctx` façade, beyond its injected
* services. `on`/`once` observe events, `provide` exposes a service to other packages, and the
* timer helpers schedule work — each a fiber effect that unwinds when the package stops.
*/
const CTX_VERBS = new Set(['effect', 'on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
const TIMER_VERBS = new Set(['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 package code the
* tool's `execute` function, letting it call another tool directly and bypass
* `ToolRuntime.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 package'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.
*/
// Twinned with the browser half's guard for the same reason as the ctx façade
// below: this is the rule "a service must never hand sandboxed code a Context",
// and each half must test against the Context class of ITS OWN face. Moving the
// rule into a shared package would move a security invariant out of the halves
// that enforce it, which is a design decision rather than a duplication fix.
/* jscpd:ignore-start */
function denyContext(value: unknown, service: string, reportFailure: (error: Error) => void): unknown {
if (value instanceof Context) {
return rejectGuard(reportFailure,
`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, reportFailure: (error: Error) => void): 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, reportFailure)
return (...args: unknown[]): unknown => {
const result = Reflect.apply(value, target, args) as unknown
if (result instanceof Promise) return result.then(v => denyContext(v, name, reportFailure))
return denyContext(result, name, reportFailure)
}
},
})
}
/* jscpd:ignore-end */
/**
* 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 host half may reach only the services
* it declared — that is what lets cordis park it when a declared provider
* goes away.
*/
function declaredInjects(ctx: Context): Set<string> {
return new Set(Object.keys(ctx.fiber.inject))
}
/**
* Whitelist context for running host halves: lifecycle-safe verbs, guarded
* tools, optional `ctx.get()` lookup, and declared-service property access.
* Framework plumbing is denied, and service methods cannot return a Context.
*/
function sandboxContext(ctx: Context, reportFailure: (error: Error) => void): 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) {
return rejectGuard(reportFailure,
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
+ 'so cordis parks this dynamic package if the provider later goes away.',
)
}
return rejectGuard(reportFailure,
`sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
+ 'the timer helpers after injecting timer, and any service you declared in inject. '
+ 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
)
}
// `get` is optional lookup; property access requires a declaration. `tools`
// is the façade's own API on either path.
const readService = (name: string, requireDeclaration: boolean): unknown => {
if (name === 'tools') return tools
if (requireDeclaration && !declared.has(name)) return denyRead(name)
const service = denyContext(ctx.get(name), name, reportFailure)
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
return guardedService(service, name, reportFailure)
}
const get = (name: string): unknown => readService(name, false)
// The browser half builds the same façade over its own Context
// (`@deepseek-ai/dsh-cordis-client-runner`, whose CTX_VERBS names this one its
// twin), and the sameness is the point: a package author meets ONE contract on
// both halves. Folding them together is not available — the two halves compile
// in separate programs where `Context` merges different service keys — so the
// duplication is declared here instead of hidden behind a config exception.
/* jscpd:ignore-start */
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. Timer mixins
// additionally require the Service declaration before Cordis resolves them.
if (CTX_VERBS.has(prop)) {
return (...args: unknown[]): unknown => {
if (TIMER_VERBS.has(prop) && !declared.has('timer')) return denyRead('timer')
const method = ctx[prop as keyof Context]
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
}
}
return readService(prop, true)
},
// A façade is not the real ctx; block writes rather than let package code
// stash state on a throwaway object and think it persisted.
set(_target, prop) {
return rejectGuard(reportFailure, `sandbox ctx is read-only; cannot assign "${String(prop)}"`)
},
// `in` reflects reachability: the façade API 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) && (!TIMER_VERBS.has(prop) || declared.has('timer'))) || declared.has(prop))),
}) as unknown as Context
/* jscpd:ignore-end */
}
/**
* Narrow an arbitrary sandbox return value to a runnable 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 host half returned.
* @returns whether the value can be started 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 host half returned.
* @param reportFailure - reports a guard rejection to the owning Agent.
* @returns an equivalent plugin whose `apply` sees the sandbox context façade.
*/
export function guardedPlugin(plugin: Plugin, reportFailure: (error: Error) => void): 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, reportFailure), config)
},
}
}
const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
return {
...plugin,
apply(ctx: Context, config?: unknown) {
return objectPlugin.apply(sandboxContext(ctx, reportFailure), config)
},
}
}
function rejectGuard(reportFailure: (error: Error) => void, message: string): never {
const error = new Error(message)
reportFailure(error)
throw error
}
/**
* Display name for a running plugin: its `name` property, else anonymous.
* @param plugin - the plugin the host half returned.
* @returns the human-readable name used in run 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>'
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,248 @@
/** Host registry for model-visible, read-only Cordis capability queries. */
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session/types'
import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools'
import type {
CordisInspectMethodManifest, CordisInspectPlatform, CordisInspectProviderManifest,
CordisInspectProviderView, CordisInspectQueryRequest, CordisInspectQueryResolution,
CordisInspectRequestId, CordisInspectResolveAck,
} from './types.ts'
/** Context supplied to a Host inspect query. */
export interface HostCordisInspectQueryContext {
/** Tool-call cancellation. */
signal: AbortSignal
/** Agent whose scoped runtime is being inspected. */
agent: Agent
}
/** Local registration paired with its serializable manifest. */
export interface HostCordisInspectProviderRegistration {
/** Provider and explicit method directory. */
manifest: CordisInspectProviderManifest
/** Execute one declared method. */
query(method: string, input: JsonValue | undefined, context: HostCordisInspectQueryContext): Promise<JsonValue>
}
interface PendingClientQuery {
request: CordisInspectQueryRequest
method: CordisInspectMethodManifest
settle(resolution: CordisInspectQueryResolution): void
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host registry for Cordis inspect providers and Client manifest/query routing. */
cordisInspect: CordisInspectRegistryService
}
}
/** Registry and cross-page router behind the two model-facing inspect tools. */
export class CordisInspectRegistryService extends Service {
private readonly providers = new Map<string, HostCordisInspectProviderRegistration>()
private readonly pending = new Map<CordisInspectRequestId, PendingClientQuery>()
private clientManifest: readonly CordisInspectProviderManifest[] | undefined
private nextRequest = 1
/** Register the process-global Host registry. */
constructor(ctx: Context) {
super(ctx, 'cordisInspect')
}
/**
* Register one Host provider.
* @param registration - manifest and local query handler.
* @returns idempotent disposer.
*/
register(registration: HostCordisInspectProviderRegistration): () => void {
const manifest = validateManifest(registration.manifest)
if (this.providers.has(manifest.id)) throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
const stored = { ...registration, manifest }
this.providers.set(manifest.id, stored)
return () => {
if (this.providers.get(manifest.id) === stored) this.providers.delete(manifest.id)
}
}
/**
* Replace the mirrored Client provider directory.
* @param providers - complete Client manifest snapshot.
*/
syncClientManifest(providers: readonly CordisInspectProviderManifest[]): void {
const ids = new Set<string>()
const validated = providers.map((provider) => {
const manifest = validateManifest(provider)
if (ids.has(manifest.id)) throw new Error(`Client Cordis inspect manifest repeats provider "${manifest.id}"`)
ids.add(manifest.id)
return manifest
})
this.clientManifest = Object.freeze(validated)
}
/**
* Return the complete known Host and Client provider directory.
* @returns Host providers followed by the Client providers.
*/
list(): CordisInspectProviderView[] {
return [
...[...this.providers.values()].map(provider => view('host', provider.manifest)),
...(this.clientManifest ?? []).map(provider => view('client', provider)),
]
}
/**
* Execute one provider query on its owning platform.
* @param platform - Host or Client runtime.
* @param providerId - provider selected from {@link list}.
* @param methodName - declared method name.
* @param input - optional lossless JSON input.
* @param agent - requesting Agent and scope.
* @param signal - tool-call cancellation.
* @returns provider JSON data.
*/
async query(
platform: CordisInspectPlatform,
providerId: string,
methodName: string,
input: JsonValue | undefined,
agent: Agent,
signal: AbortSignal,
): Promise<JsonValue> {
if (platform === 'host') {
const registration = this.providers.get(providerId)
if (registration === undefined) throw new Error(`Host Cordis inspect provider "${providerId}" is not registered`)
const method = findMethod(registration.manifest, methodName)
validateInput('Host', providerId, method, input)
signal.throwIfAborted()
const data = await registration.query(methodName, input, { agent, signal })
signal.throwIfAborted()
return validateOutput('Host', providerId, method, data)
}
return await this.queryClient(providerId, methodName, input, agent, signal)
}
/**
* Accept the first valid Client response for a pending query.
* @param agent - Agent whose Session owns the query.
* @param requestId - Pending Client query identity.
* @param resolution - Client provider result or failure.
* @returns whether this response settled the still-pending query.
*/
resolveClientQuery(
agent: Agent,
requestId: CordisInspectRequestId,
resolution: CordisInspectQueryResolution,
): CordisInspectResolveAck {
const pending = this.pending.get(requestId)
if (pending === undefined || pending.request.agentId !== agent.id) return { accepted: false }
if (!resolution.ok) return { accepted: false }
try {
resolution = {
ok: true,
data: validateOutput('Client', pending.request.provider, pending.method, resolution.data),
}
} catch {
return { accepted: false }
}
this.pending.delete(requestId)
pending.settle(resolution)
this.ctx.emit('cordis/inspect-query-resolved', { requestId })
return { accepted: true }
}
private async queryClient(
providerId: string,
methodName: string,
input: JsonValue | undefined,
agent: Agent,
signal: AbortSignal,
): Promise<JsonValue> {
const provider = this.clientManifest?.find(candidate => candidate.id === providerId)
if (provider === undefined) throw new Error(`Client Cordis inspect provider "${providerId}" is not registered`)
const method = findMethod(provider, methodName)
validateInput('Client', providerId, method, input)
signal.throwIfAborted()
const requestId = `inspect-${this.nextRequest++}` as CordisInspectRequestId
const request: CordisInspectQueryRequest = {
requestId,
agentId: agent.id,
provider: providerId,
method: methodName,
...input === undefined ? {} : { input },
}
const result = new Promise<CordisInspectQueryResolution>((resolve) => {
this.pending.set(requestId, { request, method, settle: resolve })
})
const onAbort = (): void => {
const pending = this.pending.get(requestId)
if (pending === undefined) return
this.pending.delete(requestId)
pending.settle({ ok: false, reason: 'cancelled', message: `Client inspect query ${providerId}.${methodName} was cancelled` })
this.ctx.emit('cordis/inspect-query-resolved', { requestId })
}
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
else this.ctx.emit('cordis/inspect-query', request)
try {
const resolution = await result
if (!resolution.ok) throw new Error(`${providerId}.${methodName}: ${resolution.message}`)
return resolution.data
} finally {
signal.removeEventListener('abort', onAbort)
}
}
}
function view(platform: CordisInspectPlatform, manifest: CordisInspectProviderManifest): CordisInspectProviderView {
return { platform, ...manifest, methods: [...manifest.methods] }
}
function validateManifest(manifest: CordisInspectProviderManifest): CordisInspectProviderManifest {
if (manifest.id.trim() === '') throw new Error('Cordis inspect provider id must not be empty')
if (manifest.description.trim() === '') throw new Error(`Cordis inspect provider "${manifest.id}" needs a description`)
const names = new Set<string>()
const methods = manifest.methods.map((method) => {
if (method.name.trim() === '') throw new Error(`Cordis inspect provider "${manifest.id}" has an empty method name`)
if (names.has(method.name)) throw new Error(`Cordis inspect provider "${manifest.id}" repeats method "${method.name}"`)
if (method.description.trim() === '') throw new Error(`Cordis inspect method ${manifest.id}.${method.name} needs a description`)
assertSupportedJsonSchema(method.inputSchema)
assertSupportedJsonSchema(method.outputSchema)
names.add(method.name)
return Object.freeze({ ...method })
})
return Object.freeze({ ...manifest, methods: Object.freeze(methods) })
}
function findMethod(manifest: CordisInspectProviderManifest, name: string): CordisInspectMethodManifest {
const method = manifest.methods.find(candidate => candidate.name === name)
if (method === undefined) throw new Error(`Cordis inspect provider "${manifest.id}" has no method "${name}"`)
return method
}
function validateInput(
platform: 'Host' | 'Client',
provider: string,
method: CordisInspectMethodManifest,
input: JsonValue | undefined,
): void {
const violations = validateJsonSchemaValue(method.inputSchema as JsonSchemaNode, input ?? {}, 'input')
if (violations.length > 0) throw new Error(`${platform} Cordis inspect ${provider}.${method.name} rejected input: ${violations.join('; ')}`)
}
function validateOutput(
platform: 'Host' | 'Client',
provider: string,
method: CordisInspectMethodManifest,
data: JsonValue,
): JsonValue {
const snapshot = snapshotJsonValue(data)
if (snapshot === undefined) throw new Error(`${platform} Cordis inspect ${provider}.${method.name} returned a non-JSON value`)
const violations = validateJsonSchemaValue(method.outputSchema as JsonSchemaNode, snapshot, 'output')
if (violations.length > 0) throw new Error(`${platform} Cordis inspect ${provider}.${method.name} returned invalid output: ${violations.join('; ')}`)
return snapshot
}
@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-cordis-host-runner`.
* @module @deepseek-ai/dsh-cordis-host-runner/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-cordis-host-runner'
/** Cordis companion plugin name. */
export const name = 'cordis-host-runner-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the definition registry is process memory with no event
* stream to observe, and its one owned relation (a running definition owns a
* settled host-half fiber and its handler table) is established and unwound
* inside single awaited verbs, so package tests assert it directly.
*/
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 */
@@ -0,0 +1,57 @@
/**
* Host-half fiber lifecycle over the `cordis-dynamic` group: 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. Stopping needs no helper — a host half unwinds through an ordinary
* awaited `fiber.dispose()`, because everything the plugin registered is an
* effect on its fiber.
* @module @deepseek-ai/dsh-cordis-host-runner/lifecycle
*/
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis'
import { guardedPlugin } from './guard.ts'
/**
* Await the group, start and settle one guarded child, and dispose it before rethrowing any
* startup failure so a failed run never lingers. A valid unresolved inject may remain pending.
* @param group - the `cordis-dynamic` group fiber every host half hangs under.
* @param plugin - the plugin the sandbox returned; wrapped with the registration guard before starting.
* @param reportGuardFailure - reports post-activation Host guard rejections to the owning Agent.
* @returns the settled child fiber (possibly pending on unsatisfied `inject`).
*/
export async function startHostHalf(
group: Fiber,
plugin: Plugin,
reportGuardFailure: (error: Error) => void,
): Promise<Fiber> {
await group.await()
const fiber = group.ctx.plugin(guardedPlugin(plugin, reportGuardFailure))
try {
await fiber.await()
} catch (error) {
await fiber.dispose()
const message = error instanceof Error ? error.message : String(error)
// The commonest startup collision is running a NEW version of a package
// while the old run still holds the name — teach the replace recipe.
if (message.includes('already registered')) {
throw new Error(
`${message} — to REPLACE something an earlier dynamic package registered, first cordis_stop that package's id `
+ '(find it with cordis_runtime_inspect what:"temporary"), then run 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 host-half 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)
}
@@ -0,0 +1,226 @@
/**
* Process-local dynamic Plugin registry and its opaque identity mints.
* @module @deepseek-ai/dsh-cordis-host-runner/registry
*/
import type { Fiber } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
CordisDynamicRunMode, DynamicCordisRenderFailure, DynamicCordisRunAttempt,
} from './types.ts'
/** One Host method exposed to this package's Client half. */
export type DynamicCordisHandler = (args: unknown) => Promise<unknown>
/** One live activation and everything its teardown owns. */
export interface DynamicCordisRun {
/** Exact activation identity. */
pluginRunId: CordisDynamicPluginRunId
/** Immutable package version being run. */
packageId: CordisDynamicPackageId
/** Host-half Fiber, absent for Client-only packages. */
fiber?: Fiber
/** Active Host methods. */
handlers: Map<string, DynamicCordisHandler>
/** Method registration cleanup. */
handlerDisposers: (() => void)[]
/** Runtime failures already sent to the owning Agent during this activation. */
reportedRuntimeErrors: Set<string>
/** Last render failure observed for this version's current run. */
renderFailure?: DynamicCordisRenderFailure
/** Approval whose transition started this run, when model-driven. */
startedForRequest?: ApprovalRequestId
}
/** One immutable package version. */
export interface DynamicCordisDefinition {
/** Package identity. */
packageId: CordisDynamicPackageId
/** Package label. */
name: string
/** User-facing purpose. */
purpose: string
/** Host source. */
hostCode?: string
/** Client source. */
clientCode?: string
}
/** Stable plugin instance containing immutable package versions. */
export interface DynamicCordisPlugin {
/** Stable identity. */
pluginId: CordisDynamicPluginId
/** Owning session. */
sessionId: SessionId
/** Versions in define order. */
packages: Map<CordisDynamicPackageId, DynamicCordisDefinition>
/** Client-bearing Packages individually authorized by the user. */
approvedClientPackages: Set<CordisDynamicPackageId>
/** Whether one user decision authorized future Package versions of this Plugin. */
clientVersionUpdatesApproved: boolean
/** Last successfully activated version. */
currentPackageId?: CordisDynamicPackageId
/** Failed or in-progress target version. */
nextPackageId?: CordisDynamicPackageId
/** Current activation. */
run?: DynamicCordisRun
/** Latest activation attempt, including approval and asynchronous failure state. */
latestRun?: DynamicCordisRunAttempt
}
/** One suspended model-driven activation. */
export interface DynamicCordisPendingRequest {
/** Session whose model requested this activation. */
agentId: SessionId
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
pluginRunId: CordisDynamicPluginRunId
mode: CordisDynamicRunMode
/** Whether this request must wait for an explicit user decision. */
requiresApproval: boolean
}
/** Request accepted by `define`; it never crosses the Remote transport. */
export interface DynamicCordisDefineRequest {
/** Session that owns the plugin. */
sessionId: SessionId
/** Create a plugin or append to an existing one. */
plugin:
| { kind: 'new'; idPrefix: string }
| { kind: 'existing'; pluginId: CordisDynamicPluginId }
/** Package label. */
name: string
/** User-facing purpose. */
purpose: string
/** At least one source half. */
code: { host?: string; client?: string }
}
/** Successful `define` result. */
export interface DynamicCordisDefineReceipt {
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
name: string
purpose: string
hasHostHalf: boolean
hasClientHalf: boolean
}
/** Source-free modification context for an explicit `@pluginId` reference. */
export interface DynamicCordisReference {
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
name: string
purpose: string
currentPackageId?: CordisDynamicPackageId
nextPackageId?: CordisDynamicPackageId
activeRun?: { pluginRunId: CordisDynamicPluginRunId; packageId: CordisDynamicPackageId }
latestRun?: DynamicCordisRunAttempt
}
/** Source-free Plugin summary returned by layered self inspection. */
export interface DynamicCordisPluginInspection extends DynamicCordisReference {
/** Immutable Package summaries in define order. */
packages: Array<{
packageId: CordisDynamicPackageId
name: string
purpose: string
hasHostHalf: boolean
hasClientHalf: boolean
}>
}
/** Exact immutable Package metadata and source returned by explicit inspection. */
export interface DynamicCordisPackageInspection extends DynamicCordisReference {
/** Host and Client function bodies stored for this Package. */
code: { host?: string; client?: string }
}
/** Registry, identity mints, and pending approval index. */
export class DynamicCordisRegistry {
private readonly plugins = new Map<CordisDynamicPluginId, DynamicCordisPlugin>()
private readonly pendingRequests = new Map<ApprovalRequestId, DynamicCordisPendingRequest>()
private nextPlugin = 1
private nextPackage = 1
private nextRun = 1
private nextApproval = 1
/** Mint a semantic plugin ID without reusing a prior suffix. */
mintPluginId(prefix: string): string {
let id: CordisDynamicPluginId
do id = `${prefix}-${this.nextPlugin++}` as CordisDynamicPluginId
while (this.plugins.has(id))
return id
}
/** Mint an immutable package ID. */
mintPackageId(): string {
return `pkg-${this.nextPackage++}`
}
/** Mint an activation ID. */
mintPluginRunId(): string {
return `run-${this.nextRun++}`
}
/** Mint an approval ID. */
mintApprovalRequestId(): string {
return `approval-${this.nextApproval++}`
}
/** Add one stable plugin. */
add(plugin: DynamicCordisPlugin): void {
this.plugins.set(plugin.pluginId, plugin)
}
/** Read one plugin. */
get(id: CordisDynamicPluginId): DynamicCordisPlugin | undefined {
return this.plugins.get(id)
}
/** Delete one plugin and all package versions. */
delete(id: CordisDynamicPluginId): boolean {
return this.plugins.delete(id)
}
/** All plugins in creation order. */
all(): DynamicCordisPlugin[] {
return [...this.plugins.values()]
}
/** One session's plugins in creation order. */
ofSession(sessionId: SessionId): DynamicCordisPlugin[] {
return this.all().filter(plugin => plugin.sessionId === sessionId)
}
/** Publish one pending approval. */
armRequest(id: ApprovalRequestId, pending: DynamicCordisPendingRequest): void {
this.pendingRequests.set(id, pending)
}
/** Read one pending approval without claiming it. */
peekRequest(id: ApprovalRequestId): DynamicCordisPendingRequest | undefined {
return this.pendingRequests.get(id)
}
/** Claim one pending approval; first answer wins. */
claimRequest(id: ApprovalRequestId): DynamicCordisPendingRequest | undefined {
const pending = this.pendingRequests.get(id)
if (pending !== undefined) this.pendingRequests.delete(id)
return pending
}
/** Cancel one pending approval. */
disarmRequest(id: ApprovalRequestId): void {
this.pendingRequests.delete(id)
}
/** Pending approval for one plugin, if any. */
pendingRequestFor(pluginId: CordisDynamicPluginId): ApprovalRequestId | undefined {
for (const [requestId, request] of this.pendingRequests) {
if (request.pluginId === pluginId) return requestId
}
return undefined
}
}
@@ -0,0 +1,238 @@
/**
* The `node:vm` sandbox a dynamic package's HOST half 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 packages inspectable and disposable but
* is not containment: host-realm helper functions remain an escape route.
*
* The browser half never reaches this module — it is evaluated by the client-side runner in a
* closure, with its own facade.
* @module @deepseek-ai/dsh-cordis-host-runner/sandbox
*/
import { createContext, runInContext, Script } from 'node:vm'
import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
/** Exact Host closure symbols exposed by the sandbox and guarded Context. */
export const HOST_BUILTIN_INSPECTION = [
{
name: 'ctx',
description: 'Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject for hard dependencies.',
signatures: [
'ctx.get(name: string): unknown | undefined',
'ctx.on(name: string, listener: Function): () => void',
'ctx.provide(name: string, value: unknown): () => void',
'ctx.effect(callback: Function, label?: string): () => void',
],
},
{
name: 'harness',
description: 'Host helpers for Package-private Client RPC and model-visible dynamic Tools.',
signatures: [
'harness.handle(method: string, handler: (args: JsonValue) => JsonValue | Promise<JsonValue>): () => void',
'harness.defineTool(definition: ToolDefinition): ToolDefinition',
'harness.registerTool(ctx: Context, tool: ToolDefinition): () => void',
],
},
{ name: 'console', description: 'Package-tagged Host logging.', signatures: ['console.log(...values): void', 'console.error(...values): void'] },
{ name: 'btoa', description: 'Encode UTF-8 text as base64.', signatures: ['btoa(value: string): string'] },
{ name: 'atob', description: 'Decode base64 as UTF-8 text.', signatures: ['atob(value: string): string'] },
{ name: 'TextEncoder', description: 'Standard UTF-8 encoder constructor.', signatures: ['new TextEncoder()'] },
{ name: 'TextDecoder', description: 'Standard text decoder constructor.', signatures: ['new TextDecoder(label?: string)'] },
] as const
/**
* A write-through console for one package, tagging every line with the package
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
* a registered listener fires long after the run call returned, and its output
* must land somewhere the user can see — for a terminal entry point, 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.timeout / ctx.interval after querying Host Service.listService for the exact overloads. '
+ 'Those calls are fiber effects, cleaned up automatically when stopped.'
/**
* The callable Node APIs the sandbox deliberately disables, each mapped to the
* cordis alternative its trap error names. Only function-valued globals are
* trapped; a data-valued global such as `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; query Service.listService with cordis_inspect_query first.',
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 '
+ '(query Host Service.listService with cordis_inspect_query 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 dynamic package sandbox — ${redirect}`)
}
}
return traps
}
/**
* Build the vm context one host half 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 package id (`dyn-<n>`), used as the console tag and filename stem.
* @param harnessExtras - per-package `harness` verbs beyond the registration pair (`handle`).
* @returns the contextified sandbox object to pass to {@link evaluateHostCode}.
*/
export function createSandbox(id: string, harnessExtras: Record<string, unknown> = {}): object {
const sandbox = {
...nodeApiTraps(),
console: taggedConsole(id),
harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool, ...harnessExtras },
// 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 package 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')
}
/**
* The teaching text one parse failure produces, shared by the define-time
* precheck and the run-time evaluation so a model reads the same diagnosis
* whichever verb caught it.
* @param half - which half failed to parse, named as the define argument that carried it.
* @param context - the {@link syntaxErrorContext} of the failure.
* @returns the model-facing error message.
*/
export function parseErrorMessage(half: 'code.host' | 'code.client', context: string): string {
// 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)) {
return `dynamic package \`${half}\` 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 }'
}
return `dynamic package \`${half}\` failed to parse:\n${context}\n`
+ 'Note: it 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 `)`.'
}
/**
* Parse one half's source without running it: the define-time precheck that
* keeps unparseable code out of the registry, so a model fixes it and defines
* again instead of discovering the failure at run time. Compiling through `vm`
* rather than `new Function` is what makes the two agree — same wrapper, same
* compiler, and the same source-line-and-caret prelude in the failure.
* @param code - the model-written function body.
* @param half - which define argument carried it, for the error text.
* @throws when the body does not parse, with the offending line and a teaching hint.
*/
export function precheckCode(code: string, half: 'code.host' | 'code.client'): void {
try {
// Compile-only: constructing the Script parses the source and runs nothing.
new Script(`(async () => {\n${code}\n})()`, { filename: `cordis-dyn-${half}.js` })
} catch (error) {
if (!isSyntaxError(error)) throw error
throw new Error(parseErrorMessage(half, syntaxErrorContext(error)))
}
}
/**
* Evaluate a host half 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 package id, used as the vm filename (`cordis-dyn-<id>.js`).
* @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
* @returns whatever the code returned, still un-narrowed (the run lifecycle checks plugin shape).
*/
export async function evaluateHostCode(sandbox: object, code: string, id: string, vmTimeoutMs: number): Promise<unknown> {
try {
return await runInContext(
`(async () => {\n${code}\n})()`,
sandbox,
{ filename: `cordis-dyn-${id}.js`, timeout: vmTimeoutMs },
)
} catch (error) {
if (!isSyntaxError(error)) throw error
throw new Error(parseErrorMessage('code.host', syntaxErrorContext(error)))
}
}
@@ -0,0 +1,399 @@
/**
* Client-safe wire vocabulary of the dynamic Cordis plugin runner.
* @module @deepseek-ai/dsh-cordis-host-runner/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session/types'
/** Stable identity of one dynamic plugin instance. */
export type CordisDynamicPluginId = Branded<'CordisDynamicPluginId'>
/** Identity of one immutable package version belonging to a dynamic plugin. */
export type CordisDynamicPackageId = Branded<'CordisDynamicPackageId'>
/** Identity of one successful activation attempt. */
export type CordisDynamicPluginRunId = Branded<'CordisDynamicPluginRunId'>
/** Identity of one human approval request. */
export type ApprovalRequestId = Branded<'ApprovalRequestId'>
/** Identity of one cross-page inspect query. */
export type CordisInspectRequestId = Branded<'CordisInspectRequestId'>
/** Runtime plane that owns an inspect provider. */
export type CordisInspectPlatform = 'host' | 'client'
/** One model-callable read-only query exposed by an inspect provider. */
export interface CordisInspectMethodManifest {
/** Method name, unique within its provider. */
name: string
/** What the query returns and when to use it. */
description: string
/** JSON Schema accepted by the query. */
inputSchema: JsonValue
/** JSON Schema produced by the query. */
outputSchema: JsonValue
}
/** Serializable directory entry for one inspect provider. */
export interface CordisInspectProviderManifest {
/** Provider identity, unique within one platform. */
id: string
/** Capability described by this provider. */
description: string
/** Explicit read-only queries. */
methods: readonly CordisInspectMethodManifest[]
}
/** Provider directory row returned by `cordis_inspect_list`. */
export interface CordisInspectProviderView extends CordisInspectProviderManifest {
/** Runtime plane that executes these methods. */
platform: CordisInspectPlatform
}
/** Host broadcast requesting one live Client inspect result. */
export interface CordisInspectQueryRequest {
/** Correlation identity. */
requestId: CordisInspectRequestId
/** Session whose model requested the query. */
agentId: SessionId
/** Provider selected from the Client manifest. */
provider: string
/** Method selected from the provider manifest. */
method: string
/** JSON query input, omitted when the method has no fields. */
input?: JsonValue
}
/** Result sent from a Client provider to the waiting Host query. */
export type CordisInspectQueryResolution =
| { ok: true; data: JsonValue }
| {
ok: false
reason: 'provider-missing' | 'method-missing' | 'invalid-input' | 'provider-error' | 'cancelled'
message: string
}
/** Notification that a Client inspect request can no longer be answered. */
export interface CordisInspectQueryResolved {
/** Query that left the pending state. */
requestId: CordisInspectRequestId
}
/** Whether a Client answer claimed the still-pending query. */
export interface CordisInspectResolveAck {
/** False for unknown, cancelled, stale, or late answers. */
accepted: boolean
}
/** Whether a package starts the current version or replaces it. */
export type CordisDynamicRunMode = 'run' | 'update'
/** How a model-driven Client activation request left the pending state. */
export type RequestRunOutcome = 'approved' | 'completed' | 'rejected' | 'cancelled' | 'failed'
/** Error fields preserved across the Host/Client transport. */
export interface CordisErrorDetails {
/** Original error message. */
message: string
/** Original stack when the thrown value supplied one. */
stack?: string
}
/** Persisted state of the latest activation attempt. */
export type CordisRunStatus =
| 'awaiting-approval'
| 'starting-host'
| 'client-pending'
| 'running'
| 'waiting'
| 'rejected'
| 'failed'
| 'cancelled'
| 'stopped'
/** One platform half within an activation attempt. */
export interface CordisHalfState {
/** Lifecycle state of this half. */
status: 'absent' | 'pending' | 'stopped' | 'running' | 'waiting' | 'failed'
/** Services still needed by a successfully created Fiber. */
waitingFor: readonly string[]
/** Failure text for this half. */
error?: string
}
/** Structured failure associated with an exact activation attempt. */
export interface CordisRunDiagnostic {
/** Stage that failed. */
phase: 'approval' | 'host-load' | 'host-apply' | 'client-load' | 'client-apply' | 'client-render'
/** Original failure text. */
message: string
/** Original failure stack when available. */
stack?: string
/** Stable Plugin identity. */
pluginId: CordisDynamicPluginId
/** Immutable Package identity. */
packageId: CordisDynamicPackageId
/** Exact attempt identity. */
pluginRunId: CordisDynamicPluginRunId
}
/** Latest activation attempt retained independently from the physical run. */
export interface DynamicCordisRunAttempt {
/** Exact attempt identity. */
pluginRunId: CordisDynamicPluginRunId
/** Target Package. */
packageId: CordisDynamicPackageId
/** Explicit run/update intent. */
mode: CordisDynamicRunMode
/** Current attempt state. */
status: CordisRunStatus
/** Pending Client activation request; it represents approval only when `requiresApproval` is true. */
approvalRequestId?: ApprovalRequestId
/** Whether the pending Client activation requires a user decision. */
requiresApproval?: boolean
/** Host-half state. */
host: CordisHalfState
/** Client-half state. */
client: CordisHalfState
/** Most recent failure. */
error?: CordisRunDiagnostic
}
/** One running package announced to browser pages. */
export interface DynamicCordisPackage {
/** Stable plugin instance. */
pluginId: CordisDynamicPluginId
/** Immutable package version currently active. */
packageId: CordisDynamicPackageId
/** This activation's identity. */
pluginRunId: CordisDynamicPluginRunId
/** Package label. */
name: string
}
/** One pending model-driven Client activation forwarded to browser pages. */
export interface DynamicCordisRunRequest {
/** Correlation identity of the activation request. */
requestId: ApprovalRequestId
/** Session whose plugin and tool call own the request. */
agentId: SessionId
/** Stable plugin instance being acted on. */
pluginId: CordisDynamicPluginId
/** Package version the request will activate. */
packageId: CordisDynamicPackageId
/** Explicit lifecycle intent. */
mode: CordisDynamicRunMode
/** Package label. */
name: string
/** User-facing reason supplied at define time. */
purpose: string
/** Whether a page must wait for an explicit user decision before activation. */
requiresApproval: boolean
}
/** One settled model-driven Client activation request broadcast to all pages. */
export interface DynamicCordisRequestResolved {
/** Request that left the pending state. */
requestId: ApprovalRequestId
/** How the request settled. */
outcome: RequestRunOutcome
}
/** One activation withdrawn from every page. */
export interface DynamicCordisRetracted {
/** Stable plugin instance. */
pluginId: CordisDynamicPluginId
/** Package version that was active. */
packageId: CordisDynamicPackageId
/** Exact activation being withdrawn. */
pluginRunId: CordisDynamicPluginRunId
}
/** Package metadata exposed by the inventory without source code. */
export interface DynamicCordisInventoryPackage {
/** Immutable package version. */
packageId: CordisDynamicPackageId
/** Package label. */
name: string
/** User-facing purpose. */
purpose: string
/** Whether this version contains Host code. */
hasHostHalf: boolean
/** Whether this version contains Client code. */
hasClientHalf: boolean
}
/** One stable plugin row in the frame-wide inventory. */
export interface DynamicCordisInventoryRow {
/** Stable plugin instance. */
pluginId: CordisDynamicPluginId
/** Session that owns this plugin. */
agentId: SessionId
/** Immutable versions in define order. */
packages: readonly DynamicCordisInventoryPackage[]
/** Last package that completed activation successfully. */
currentPackageId?: CordisDynamicPackageId
/** Package selected for a failed or in-progress transition. */
nextPackageId?: CordisDynamicPackageId
/** Current activation, absent while stopped. */
activeRun?: {
pluginRunId: CordisDynamicPluginRunId
packageId: CordisDynamicPackageId
}
/** Latest activation attempt, including pending approval and diagnostics. */
latestRun?: DynamicCordisRunAttempt
}
/** Answer to removing a plugin and all of its package versions. */
export type DynamicCordisUndefineReceipt =
| { ok: true; wasRunning: boolean }
| { ok: false; reason: 'plugin-missing'; message: string }
/** One render failure observed after a Client half loaded. */
export interface DynamicCordisRenderFailure {
/** Slot whose component failed. */
slot: string
/** Render failure text. */
message: string
/** Original render failure stack when available. */
stack?: string
/** Whether the failing contribution relinquished its slot. */
abdicated: boolean
}
/** Result shared by model-driven and panel-driven activation. */
export type DynamicCordisRunResponse =
| {
ok: true
/** Whether activation completed synchronously, is starting in a Client, or awaits user approval. */
status: 'awaiting-approval' | 'starting' | 'running'
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
pluginRunId: CordisDynamicPluginRunId
/** Missing Host services; a parked Fiber is a successful activation. */
waitingFor: readonly string[]
/** Missing Client services reported by the approving page. */
clientWaitingFor?: readonly string[]
/** Last fully successful Package. */
currentPackageId?: CordisDynamicPackageId
/** Selected transition target. */
nextPackageId?: CordisDynamicPackageId
/** Explicit lifecycle intent. */
mode: CordisDynamicRunMode
}
| {
ok: false
reason:
| 'plugin-missing'
| 'package-missing'
| 'invalid-mode'
| 'transition-in-flight'
| 'host-half-failed'
| 'client-half-failed'
| 'rejected'
| 'cancelled'
| 'not-running'
message: string
/** Original failure stack when available. */
stack?: string
}
/** Result of stopping a Plugin without deleting its Packages. */
export type DynamicCordisStopResponse =
| { ok: true }
| { ok: false; reason: 'plugin-missing' | 'not-running'; message: string }
/** Result of bringing up the Host half before loading the Client half. */
export type DynamicCordisHostHalfResult =
| {
ok: true
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
pluginRunId: CordisDynamicPluginRunId
waitingFor: readonly string[]
/** False when a panel merely attaches this page to an already active run. */
startedHere: boolean
}
| ({ ok: false } & CordisErrorDetails)
/** Client-half source for one exact activation. */
export interface DynamicCordisClientSource {
/** Browser JavaScript body. */
code: string
/** Package label. */
name: string
/** Stable plugin instance. */
pluginId: CordisDynamicPluginId
/** Immutable source version. */
packageId: CordisDynamicPackageId
/** Exact activation the source belongs to. */
pluginRunId: CordisDynamicPluginRunId
}
/** Browser verdict used for both approved tool runs and panel runs. */
export type DynamicCordisRunResolution =
| { ok: true; pluginRunId: CordisDynamicPluginRunId; waitingFor?: readonly string[] }
| {
ok: false
reason: 'rejected' | 'host-half-failed' | 'client-half-failed'
/** Activation that failed; absent for a refusal before activation. */
pluginRunId?: CordisDynamicPluginRunId
/** Whether this page created the failed activation instead of attaching to it. */
startedHere?: boolean
message?: string
stack?: string
}
/** Whether a Client activation resolution reached the still-pending request. */
export interface DynamicCordisResolveAck {
/** False for late, unknown, or stale answers. */
accepted: boolean
}
/** Result of routing one Client call to the active Host half. */
export type DynamicCordisInvokeResult =
| { ok: true; value: JsonValue }
| ({ ok: false; code: 'plugin-not-running' | 'stale-run' | 'method-not-found' | 'handler-error' } & CordisErrorDetails)
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A Client-bearing activation needs a browser page, and may require a user decision.
* @param request - correlation identity, owner, target version, mode, and approval requirement.
* @mode emit
*/
'cordis/request-run'(request: DynamicCordisRunRequest): void
/**
* A pending Client activation request left the answerable state.
* @param resolved - request identity and outcome.
* @mode emit
*/
'cordis/request-run-resolved'(resolved: DynamicCordisRequestResolved): void
/**
* One exact Plugin/Package activation is now live in the Host.
* @param pkg - stable plugin, immutable package, run identity, and label.
* @mode emit
*/
'cordis/dynamic-package'(pkg: DynamicCordisPackage): void
/**
* One exact activation was withdrawn.
* @param retracted - plugin, package, and run identity.
* @mode emit
*/
'cordis/dynamic-retract'(retracted: DynamicCordisRetracted): void
/**
* Request a live read-only query from the Client inspect registry.
* @param request - correlation, Session, provider, method, and JSON input.
* @mode emit
*/
'cordis/inspect-query'(request: CordisInspectQueryRequest): void
/**
* Notify every Client that an inspect query has settled or been cancelled.
* @param resolved - exact query identity that is no longer answerable.
* @mode emit
*/
'cordis/inspect-query-resolved'(resolved: CordisInspectQueryResolved): void
}
}