• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user