refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
6
packages/extensions/README.i18n.yaml
Normal file
6
packages/extensions/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/extensions/README.md
|
||||
README.md: 1a09693e76291eee1b34c152dbd106226a02f468
|
||||
README.zh.md: ad485e78fef89ba3b2f3f7db84f1ba526dffc358
|
||||
9
packages/extensions/README.md
Normal file
9
packages/extensions/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# self-modification/ — the agent modifies its own runtime
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service API, mount model-written plugins, and dispose them again. The group is the landing zone for future self-modification packages. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`tool-cordis/`](tool-cordis/README.md) | Model-facing runtime inspection and temporary-plugin tools | registers on `ctx.tools` |
|
||||
9
packages/extensions/README.zh.md
Normal file
9
packages/extensions/README.zh.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# self-modification/:agent(智能体)修改自身运行时
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
agent 修改自身运行时:检查已加载的插件与服务接口、挂载模型编写的插件并再次 dispose。该组是未来自我修改类包的落点。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
|
||||
| 包 | 角色 | ctx 键 |
|
||||
|---|---|---|
|
||||
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` |
|
||||
6
packages/extensions/tool-cordis/README.i18n.yaml
Normal file
6
packages/extensions/tool-cordis/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/extensions/tool-cordis/README.md
|
||||
README.md: 839963b3b611f35389f8e4a424746ad0fcf5d02e
|
||||
README.zh.md: a4dd0686702c18fb329bdea42478ca74ca3a1502
|
||||
90
packages/extensions/tool-cordis/README.md
Normal file
90
packages/extensions/tool-cordis/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# @deepseek-ai/dsh-tool-cordis
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The self-referential Cordis toolset: three model-facing tools over the live runtime in the current DSH process. Design home — sandbox semantics, temporary-plugin lifecycle and composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
## What it does
|
||||
|
||||
- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, the `cordis_mount` temporary-Plugin subset, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc.
|
||||
- `cordis_mount` — evaluates model-written JavaScript now and saves it nowhere; the code must return an in-memory temporary Plugin tracked as `dyn-<n>`.
|
||||
- `cordis_unmount` — unmounts one `dyn-<n>` temporary Plugin and returns only after its owned effects reach quiescence. It cannot remove Loader, configured, or installed Plugins.
|
||||
|
||||
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
|
||||
|
||||
Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until unmounted or DSH restarts; unmount confirms that it was removed.
|
||||
|
||||
Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement an Harness Plugin or installable profile bundle through the regular development workflow.
|
||||
|
||||
## Trust stance
|
||||
|
||||
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.shell`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Dynamic tool schemas and annotations cross the realm through iterative JSON cloning and schema normalization, so valid deep declarations are memory-bounded rather than call-stack-bounded; records with JSON-invisible keys and subclassed or decorated schema arrays reject before normalization. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of temporary-Plugin code evaluation; an async body escapes it |
|
||||
|
||||
## The generated API catalog
|
||||
|
||||
`src/api-catalog.ts` is generated from the same Typert `FaceModel` projection as the [subsystem pages' generated regions](../../../docs/subsystems/core.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `scripts/gen-cordis-api.ts` is a compatibility entry point for that unified projection, not a second collector. `cordis_inspect` intersects the committed catalog with the live service store at call time; it has no runtime Typert dependency. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud.
|
||||
|
||||
## Rendering
|
||||
|
||||
All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the temporary-Plugin code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering.
|
||||
|
||||
## Export shape
|
||||
|
||||
Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no default export ([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request in that tool view.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle changes that hide these definitions may invalidate reuse from the first changed schema token.
|
||||
|
||||
### Tool-call history and results
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Temporary Plugins` heading. Each temporary-Plugin row reports running/pending state, provided and awaited services, and its lifetime until unmounted or DSH restart. The empty state explains that `cordis_mount` Plugins disappear on restart. Broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `Temporary Plugin <id> is running (...)` or `Temporary Plugin <id> is pending (...)`; unmount returns `Temporary Plugin <id> was unmounted and removed.` The submitted program remains in assistant tool-call history.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Later requests after cordis_mount
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A temporary Plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_unmount` removes those contributions after quiescence.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Indirect token impact equals the temporary Plugin's contributions and lasts only for its process-local lifetime.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged temporary-Plugin set remains prefix-stable.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance).
|
||||
- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths.
|
||||
- **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code.
|
||||
- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. Several sessions running one preset concurrently is where that becomes observable. Per-session temporary plugins would need the group and table keyed by the calling agent.
|
||||
90
packages/extensions/tool-cordis/README.zh.md
Normal file
90
packages/extensions/tool-cordis/README.zh.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# @deepseek-ai/dsh-tool-cordis
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
自引用 Cordis 工具集:三个面向模型的工具,操作当前 DSH 进程中的实时运行时。沙箱语义、临时插件生命周期与组合、生成的 API 目录及既定决策详见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
|
||||
## 功能
|
||||
|
||||
- `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活插件 fiber、已注册工具、`cordis_mount` 临时插件子集,以及目录支持的 `api`/`events` 参考。精确的 `name` 配合 `what: "api"` 或 `what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。
|
||||
- `cordis_mount`:立即求值模型编写的 JavaScript 且不保存到任何位置;代码必须返回一个仅存于内存、以 `dyn-<n>` 为标识进行跟踪的临时插件。
|
||||
- `cordis_unmount`:卸载一个 `dyn-<n>` 临时插件,并只在其拥有的 effect 完全停稳后返回;它不能移除 Loader 插件、已配置插件或已安装插件。
|
||||
|
||||
面向模型的确切 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。
|
||||
|
||||
规范成功结果分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生渲染会说明临时插件正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除。
|
||||
|
||||
临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent(智能体)通过常规开发流程实现 SDK 插件或可安装的 profile 组合包。
|
||||
|
||||
## 信任立场
|
||||
|
||||
该沙箱隔离全局变量,但不是安全边界。Node 全局变量不存在,或会重定向到 `ctx.fs`、`ctx.web`、`ctx.shell` 等 Cordis 服务;写入 `globalThis` 的内容保持局部,但 host realm helper 使逃逸成为可能。已挂载插件收到不含框架内部机制的 façade,但获准服务仍会影响存活运行时。动态工具 schema 与 annotation 通过迭代式 JSON 克隆和 schema 规范化跨越 realm,因此有效的深层声明受内存而非调用栈限制;含 JSON 不可见 key 的 record,以及子类化或装饰过的 schema array,会在规范化前被拒绝。应当像对待 bash 访问一样对待该工具集;参见[设计与信任立场](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
|
||||
## 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `vmTimeoutMs` | `5000` | 临时插件代码求值中同步部分的边界;async 主体可逃出该边界 |
|
||||
|
||||
## 生成的 API 目录
|
||||
|
||||
`src/api-catalog.ts` 与[子系统页面的生成区块](../../../docs/subsystems/core.md)由同一个 Typert `FaceModel` 投影生成,并由 `pnpm run verify-cordis-api`(位于 `doc-sync` 中)实施新鲜度门禁,绝不可手工编辑。`scripts/gen-cordis-api.ts` 是该统一投影的兼容入口,而非第二套收集器。`cordis_inspect` 在调用时把已提交的目录与存活服务 store 取交集;它在运行时不依赖 Typert。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会高声失败。
|
||||
|
||||
## 渲染
|
||||
|
||||
三个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_mount` 以 `rawInput` 携带临时插件代码。presenter 是 args 的纯函数;结果保留默认文本渲染。
|
||||
|
||||
## 导出形式
|
||||
|
||||
Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默认导出([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_mount` 和 `cordis_unmount` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
该工具视图中的每次请求承担固定 schema 成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要该工具视图不变,前缀就保持稳定。隐藏这些定义的 scope 或插件生命周期变更,可能使从第一个变化的 schema token 起的复用失效。
|
||||
|
||||
### 工具调用历史与结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
检查会精确地用 `## <section>` 加换行及取决于数据的正文来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时插件行都会报告 running/pending 状态,以及其提供和等待的服务,以及持续至卸载或 DSH 重启的生命周期;空状态说明 `cordis_mount` 插件会在重启时消失。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"` 或 `what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `Temporary Plugin <id> is running (...)` 或 `Temporary Plugin <id> is pending (...)`;卸载返回 `Temporary Plugin <id> was unmounted and removed.`。提交的程序保留在 assistant 工具调用历史中。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
检查输出与挂载代码取决于数据,并在压缩(compaction)前重复发送;生命周期确认文本很短。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
### cordis_mount 后的后续请求
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
临时插件可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_unmount` 会在完全停稳后移除这些贡献。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
间接 token 影响等于临时插件的贡献,且只在其进程内生命周期内持续。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时插件集合不变时,前缀保持稳定。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。
|
||||
- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。
|
||||
- **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。
|
||||
- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。多个会话并发运行同一 preset 时这一点才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。
|
||||
57
packages/extensions/tool-cordis/package.json
Normal file
57
packages/extensions/tool-cordis/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-cordis",
|
||||
"description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/extensions/tool-cordis"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
3577
packages/extensions/tool-cordis/src/api-catalog.ts
Normal file
3577
packages/extensions/tool-cordis/src/api-catalog.ts
Normal file
File diff suppressed because it is too large
Load Diff
31
packages/extensions/tool-cordis/src/fiber-state.ts
Normal file
31
packages/extensions/tool-cordis/src/fiber-state.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Runtime mirror and labels for Cordis's `FiberState` const enum. A const enum has no runtime
|
||||
* object to import, so these values mirror the pinned vendored definition while retaining its
|
||||
* type.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/fiber-state
|
||||
*/
|
||||
|
||||
import type { FiberState as FiberStateEnum } from '@deepseek-ai/cordis'
|
||||
|
||||
/** Value mirror of the cordis `FiberState` const enum (see the module doc for why a mirror exists). */
|
||||
export const FiberState = {
|
||||
PENDING: 0 as FiberStateEnum.PENDING,
|
||||
LOADING: 1 as FiberStateEnum.LOADING,
|
||||
ACTIVE: 2 as FiberStateEnum.ACTIVE,
|
||||
FAILED: 3 as FiberStateEnum.FAILED,
|
||||
DISPOSED: 4 as FiberStateEnum.DISPOSED,
|
||||
UNLOADING: 5 as FiberStateEnum.UNLOADING,
|
||||
} as const
|
||||
|
||||
/** The cordis `FiberState` enum type, re-exported so mirror consumers need one import. */
|
||||
export type FiberState = FiberStateEnum
|
||||
|
||||
/** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */
|
||||
export const STATE_LABELS = {
|
||||
[FiberState.PENDING]: 'pending',
|
||||
[FiberState.LOADING]: 'loading',
|
||||
[FiberState.ACTIVE]: 'active',
|
||||
[FiberState.FAILED]: 'failed',
|
||||
[FiberState.DISPOSED]: 'disposed',
|
||||
[FiberState.UNLOADING]: 'unloading',
|
||||
} as const satisfies Record<FiberState, string>
|
||||
780
packages/extensions/tool-cordis/src/guard.ts
Normal file
780
packages/extensions/tool-cordis/src/guard.ts
Normal file
@@ -0,0 +1,780 @@
|
||||
/**
|
||||
* The registration boundary between sandboxed mount code and the real runtime: ParameterSchemaSpec
|
||||
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
|
||||
* `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives
|
||||
* in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
|
||||
* return values with. The façade is a whitelist of lifecycle-safe verbs and declared services;
|
||||
* framework internals and context-valued service returns are denied.
|
||||
*
|
||||
* VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and
|
||||
* presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they
|
||||
* have one meaning; invalid vocabulary fails during registration with a teaching error.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/guard
|
||||
*/
|
||||
|
||||
import { Context } from '@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('tool-cordis.dynamic-tool')
|
||||
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json'])
|
||||
const VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\''
|
||||
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
|
||||
|
||||
type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
|
||||
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
return prototype === null
|
||||
|| typeof prototype === 'object'
|
||||
&& Object.getPrototypeOf(prototype) === null
|
||||
&& hasIntrinsicConstructor(prototype, 'Object')
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */
|
||||
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
|
||||
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||
const constructor: unknown = descriptor?.value
|
||||
if (typeof constructor !== 'function') return false
|
||||
try {
|
||||
return constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
|
||||
function hasPlainArrayPrototype(value: unknown[]): boolean {
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
|
||||
const objectPrototype: unknown = Object.getPrototypeOf(prototype)
|
||||
return typeof objectPrototype === 'object'
|
||||
&& objectPrototype !== null
|
||||
&& Object.getPrototypeOf(objectPrototype) === null
|
||||
&& hasIntrinsicConstructor(objectPrototype, 'Object')
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Whether a schema list is a dense intrinsic array with no JSON-invisible decorations. */
|
||||
function isDensePlainArray(value: unknown): value is unknown[] {
|
||||
if (!Array.isArray(value) || !hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!Object.hasOwn(value, index)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Reject schema records whose declarations would disappear from object enumeration. */
|
||||
function assertSchemaContainerKeys(value: Record<string, unknown>, path: string): void {
|
||||
if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) {
|
||||
throw new Error(`harness.defineTool ${path} must contain only own enumerable string keys`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where one cloned JSON value is installed. */
|
||||
type CloneDestination =
|
||||
| { kind: 'root' }
|
||||
| { kind: 'array'; target: unknown[]; index: number }
|
||||
| { kind: 'object'; target: Record<string, unknown>; key: string }
|
||||
|
||||
/** Deferred work for stack-safe cross-realm JSON cloning. */
|
||||
type CloneTask =
|
||||
| { kind: 'visit'; value: unknown; path: string; destination: CloneDestination }
|
||||
| { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] }
|
||||
| { kind: 'leave'; source: object }
|
||||
|
||||
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
|
||||
function cloneJson(value: unknown, path: string): unknown {
|
||||
const ancestors = new Set<object>()
|
||||
let root: unknown
|
||||
const assign = (destination: CloneDestination, item: unknown): void => {
|
||||
if (destination.kind === 'root') {
|
||||
root = item
|
||||
return
|
||||
}
|
||||
if (destination.kind === 'array') {
|
||||
destination.target[destination.index] = item
|
||||
return
|
||||
}
|
||||
Object.defineProperty(destination.target, destination.key, {
|
||||
value: item,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
const reject = (at: string): never => {
|
||||
throw new Error(`harness.defineTool ${at} must be lossless JSON data`)
|
||||
}
|
||||
|
||||
const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
ancestors.delete(task.source)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'array-item') {
|
||||
if (!Object.hasOwn(task.source, task.index)) reject(task.path)
|
||||
tasks.push({
|
||||
kind: 'visit',
|
||||
value: task.source[task.index],
|
||||
path: `${task.path}[${task.index}]`,
|
||||
destination: { kind: 'array', target: task.target, index: task.index },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const current = task.value
|
||||
if (current === null || typeof current === 'string' || typeof current === 'boolean') {
|
||||
assign(task.destination, current)
|
||||
continue
|
||||
}
|
||||
if (typeof current === 'number') {
|
||||
if (!Number.isFinite(current) || Object.is(current, -0)) reject(task.path)
|
||||
assign(task.destination, current)
|
||||
continue
|
||||
}
|
||||
if (typeof current !== 'object' || ancestors.has(current)) reject(task.path)
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
if (!hasPlainArrayPrototype(current) || Reflect.ownKeys(current).length !== current.length + 1) reject(task.path)
|
||||
const output: unknown[] = []
|
||||
assign(task.destination, output)
|
||||
ancestors.add(current)
|
||||
tasks.push({ kind: 'leave', source: current })
|
||||
for (let index = current.length - 1; index >= 0; index--) {
|
||||
tasks.push({ kind: 'array-item', source: current, index, path: task.path, target: output })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!isPlainRecord(current)) reject(task.path)
|
||||
const record = current as Record<string, unknown>
|
||||
if (Reflect.ownKeys(record).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(record, key))) {
|
||||
reject(task.path)
|
||||
}
|
||||
const output: Record<string, unknown> = {}
|
||||
assign(task.destination, output)
|
||||
ancestors.add(record)
|
||||
tasks.push({ kind: 'leave', source: record })
|
||||
const entries = Object.entries(record)
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const entry = entries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({
|
||||
kind: 'visit',
|
||||
value: entry[1],
|
||||
path: `${task.path}.${entry[0]}`,
|
||||
destination: { kind: 'object', target: output, key: entry[0] },
|
||||
})
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Copy and realm-materialize the shared annotation vocabulary. */
|
||||
function copyAnnotations(value: Record<string, unknown>, output: Record<string, unknown>, path: string): void {
|
||||
if (Object.hasOwn(value, 'description')) output.description = value.description
|
||||
if (Object.hasOwn(value, 'title')) output.title = value.title
|
||||
if (Object.hasOwn(value, 'default')) output.default = cloneJson(value.default, `${path}.default`)
|
||||
if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `${path}.examples`)
|
||||
}
|
||||
|
||||
/** Reject sandbox schema keys that the unified DSL would otherwise ignore. */
|
||||
function assertSchemaKeys(value: Record<string, unknown>, path: string, allowed: readonly string[]): void {
|
||||
assertSchemaContainerKeys(value, path)
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a sandbox-provided `parameters` value into a fresh host-realm
|
||||
* ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root
|
||||
* default, while the direct DSL is already an implicit open property map.
|
||||
*/
|
||||
function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): {
|
||||
spec: Record<string, unknown>
|
||||
rootAnnotations?: Record<string, unknown>
|
||||
} {
|
||||
if (!isPlainRecord(value)) {
|
||||
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec object`)
|
||||
}
|
||||
if (value.type === 'object') {
|
||||
assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS])
|
||||
if (!isPlainRecord(value.properties)) {
|
||||
throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
|
||||
}
|
||||
if (Object.hasOwn(value, 'additionalProperties') && value.additionalProperties !== true) {
|
||||
throw new Error(`harness.defineTool ${path}.additionalProperties must be true or omitted because the implicit parameter root is open`)
|
||||
}
|
||||
if (Object.hasOwn(value, 'required') && value.required === undefined) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
|
||||
}
|
||||
const required = normalizeRequiredNames(value.required, value.properties, `${path}.required`)
|
||||
const rootAnnotations: Record<string, unknown> = {}
|
||||
copyAnnotations(value, rootAnnotations, path)
|
||||
return {
|
||||
spec: normalizePropertyMap(value.properties, path, required, true),
|
||||
...(Object.keys(rootAnnotations).length === 0 ? {} : { rootAnnotations }),
|
||||
}
|
||||
}
|
||||
return { spec: normalizePropertyMap(value, path, new Set(), false) }
|
||||
}
|
||||
|
||||
/** Validate raw required names and return their lookup set. */
|
||||
function normalizeRequiredNames(value: unknown, properties: Record<string, unknown>, path: string): Set<string> {
|
||||
if (value === undefined) return new Set()
|
||||
if (!isDensePlainArray(value)) {
|
||||
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
|
||||
}
|
||||
const names = new Set<string>()
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const name = value[index]
|
||||
if (typeof name !== 'string') {
|
||||
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
|
||||
}
|
||||
names.add(name)
|
||||
if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** Mutable holder used only while one normalized property-map root is unresolved. */
|
||||
interface NormalizeRoot {
|
||||
value?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Where a normalized value node is installed. */
|
||||
type NormalizeValueDestination =
|
||||
| { kind: 'property'; target: Record<string, unknown>; key: string }
|
||||
| { kind: 'item'; target: Record<string, unknown> }
|
||||
| { kind: 'one-of'; target: Record<string, unknown>[]; index: number }
|
||||
|
||||
/** Where a normalized property map is installed. */
|
||||
type NormalizeMapDestination =
|
||||
| { kind: 'root'; holder: NormalizeRoot }
|
||||
| { kind: 'properties'; target: Record<string, unknown> }
|
||||
|
||||
/** Deferred work for stack-safe sandbox schema normalization. */
|
||||
type NormalizeTask =
|
||||
| {
|
||||
kind: 'map'
|
||||
entries: Record<string, unknown>
|
||||
path: string
|
||||
requiredNames: ReadonlySet<string>
|
||||
raw: boolean
|
||||
destination: NormalizeMapDestination
|
||||
}
|
||||
| {
|
||||
kind: 'value'
|
||||
value: unknown
|
||||
path: string
|
||||
forceRequired: boolean
|
||||
raw: boolean
|
||||
parameterProperty: boolean
|
||||
destination: NormalizeValueDestination
|
||||
}
|
||||
| { kind: 'leave'; value: object }
|
||||
|
||||
/** Install one normalized node without `__proto__` assignment semantics. */
|
||||
function assignNormalizedValue(destination: NormalizeValueDestination, value: Record<string, unknown>): void {
|
||||
if (destination.kind === 'property') {
|
||||
Object.defineProperty(destination.target, destination.key, {
|
||||
value,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
} else if (destination.kind === 'item') {
|
||||
destination.target.items = value
|
||||
} else {
|
||||
destination.target[destination.index] = value
|
||||
}
|
||||
}
|
||||
|
||||
/** Install one normalized property map at its root or containing object. */
|
||||
function assignNormalizedMap(destination: NormalizeMapDestination, value: Record<string, unknown>): void {
|
||||
if (destination.kind === 'root') destination.holder.value = value
|
||||
else destination.target.properties = value
|
||||
}
|
||||
|
||||
/** Normalize one implicit property map and all descendants with explicit work frames. */
|
||||
function normalizePropertyMap(
|
||||
entries: Record<string, unknown>,
|
||||
path: string,
|
||||
requiredNames: ReadonlySet<string>,
|
||||
raw: boolean,
|
||||
): Record<string, unknown> {
|
||||
const holder: NormalizeRoot = {}
|
||||
const ancestors = new Set<object>()
|
||||
const tasks: NormalizeTask[] = [{
|
||||
kind: 'map',
|
||||
entries,
|
||||
path,
|
||||
requiredNames,
|
||||
raw,
|
||||
destination: { kind: 'root', holder },
|
||||
}]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
ancestors.delete(task.value)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'map') {
|
||||
if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`)
|
||||
assertSchemaContainerKeys(task.entries, task.path)
|
||||
ancestors.add(task.entries)
|
||||
const spec: Record<string, unknown> = {}
|
||||
assignNormalizedMap(task.destination, spec)
|
||||
tasks.push({ kind: 'leave', value: task.entries })
|
||||
const mapEntries = Object.entries(task.entries)
|
||||
for (let index = mapEntries.length - 1; index >= 0; index--) {
|
||||
const entry = mapEntries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
value: entry[1],
|
||||
path: `${task.path}.${entry[0]}`,
|
||||
forceRequired: task.requiredNames.has(entry[0]),
|
||||
raw: task.raw,
|
||||
parameterProperty: true,
|
||||
destination: { kind: 'property', target: spec, key: entry[0] },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const { value, path } = task
|
||||
if (!isPlainRecord(value)) {
|
||||
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
|
||||
}
|
||||
assertSchemaContainerKeys(value, path)
|
||||
if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`)
|
||||
ancestors.add(value)
|
||||
const requiredKey = task.parameterProperty && !task.raw ? ['required'] : []
|
||||
if (task.parameterProperty && task.raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
|
||||
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
|
||||
}
|
||||
if (task.parameterProperty && !task.raw && Object.hasOwn(value, 'required') && value.required !== true) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be true when present`)
|
||||
}
|
||||
const prop: Record<string, unknown> = {}
|
||||
assignNormalizedValue(task.destination, prop)
|
||||
tasks.push({ kind: 'leave', value })
|
||||
if (task.forceRequired || value.required === true) prop.required = true
|
||||
copyAnnotations(value, prop, path)
|
||||
|
||||
if (Object.hasOwn(value, 'oneOf')) {
|
||||
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
if (!isDensePlainArray(value.oneOf) || value.oneOf.length < 2) {
|
||||
throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
|
||||
}
|
||||
const oneOf: Record<string, unknown>[] = []
|
||||
prop.oneOf = oneOf
|
||||
for (let index = value.oneOf.length - 1; index >= 0; index--) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
value: value.oneOf[index],
|
||||
path: `${path}.oneOf[${index}]`,
|
||||
forceRequired: false,
|
||||
raw: task.raw,
|
||||
parameterProperty: false,
|
||||
destination: { kind: 'one-of', target: oneOf, index },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.raw && !Object.hasOwn(value, 'type')) {
|
||||
assertSchemaKeys(value, path, ANNOTATION_KEYS)
|
||||
prop.type = 'json'
|
||||
continue
|
||||
}
|
||||
if (!SCHEMA_TYPES.has(value.type) || task.raw && value.type === 'json') {
|
||||
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
|
||||
}
|
||||
const type = value.type
|
||||
prop.type = type
|
||||
|
||||
switch (type) {
|
||||
case 'object': {
|
||||
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(task.raw ? ['required'] : []), ...ANNOTATION_KEYS])
|
||||
if (!task.raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
|
||||
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
|
||||
}
|
||||
if (task.raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
|
||||
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
|
||||
}
|
||||
if (task.raw && Object.hasOwn(value, 'required') && value.required === undefined) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
|
||||
}
|
||||
prop.additionalProperties = task.raw ? value.additionalProperties ?? true : value.additionalProperties
|
||||
if (Object.hasOwn(value, 'properties')) {
|
||||
const properties = value.properties
|
||||
if (!isPlainRecord(properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
|
||||
const nestedRequired = task.raw
|
||||
? normalizeRequiredNames(value.required, properties, `${path}.required`)
|
||||
: new Set<string>()
|
||||
tasks.push({
|
||||
kind: 'map',
|
||||
entries: properties,
|
||||
path: `${path}.properties`,
|
||||
requiredNames: nestedRequired,
|
||||
raw: task.raw,
|
||||
destination: { kind: 'properties', target: prop },
|
||||
})
|
||||
} else if (task.raw && value.required !== undefined) {
|
||||
normalizeRequiredNames(value.required, {}, `${path}.required`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'array':
|
||||
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
if (Object.hasOwn(value, 'items')) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
value: value.items,
|
||||
path: `${path}.items`,
|
||||
forceRequired: false,
|
||||
raw: task.raw,
|
||||
parameterProperty: false,
|
||||
destination: { kind: 'item', target: prop },
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
case 'null':
|
||||
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
if (Object.hasOwn(value, 'enum')) {
|
||||
if (!isDensePlainArray(value.enum) || value.enum.length === 0) {
|
||||
throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`)
|
||||
}
|
||||
prop.enum = cloneJson(value.enum, `${path}.enum`)
|
||||
}
|
||||
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
|
||||
break
|
||||
case 'json':
|
||||
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
break
|
||||
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
|
||||
default:
|
||||
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- the root map task assigns before scheduling descendants. */
|
||||
return holder.value ?? {}
|
||||
}
|
||||
|
||||
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
|
||||
Object.defineProperty(tool, DYNAMIC_TOOL, { value: true })
|
||||
return tool as DynamicToolDefinition
|
||||
}
|
||||
|
||||
function assertDynamicTool(tool: unknown): asserts tool is DynamicToolDefinition {
|
||||
if (!isPlainRecord(tool) || (tool as DynamicToolMarker)[DYNAMIC_TOOL] !== true) {
|
||||
throw new Error('dynamic tool registration must use a tool returned by harness.defineTool(...)')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally a content block, checked AFTER the JSON round-trip: a plain
|
||||
* object carrying a string `type` tag. Deliberately nothing deeper — the
|
||||
* ContentBlock union is merge-extensible (an unknown tag must pass), and every
|
||||
* downstream consumer dispatches on `type` and falls through unknowns.
|
||||
*/
|
||||
function isContentBlockShape(value: unknown): boolean {
|
||||
return isPlainRecord(value) && typeof value.type === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of an invalid execute return the teaching error echoes back — a
|
||||
* huge blob would burn the model turn the error is trying to save.
|
||||
*/
|
||||
const RETURN_PREVIEW_LIMIT = 120
|
||||
|
||||
/**
|
||||
* Compact JSON preview of an invalid execute return for the teaching error
|
||||
* (`String(…)` for the un-stringifiable undefined case), truncated to
|
||||
* {@link RETURN_PREVIEW_LIMIT}.
|
||||
*/
|
||||
function describeReturn(value: JsonValue): string {
|
||||
// The caller has already crossed cloneJson, so this value is lossless JSON
|
||||
// and serialization cannot produce undefined.
|
||||
const json = JSON.stringify(value)
|
||||
return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and host-materialize a sandbox renderer's content blocks.
|
||||
*/
|
||||
function assertRenderedContent(value: JsonValue): ContentBlock[] {
|
||||
if (Array.isArray(value) && value.every(isContentBlockShape)) {
|
||||
return value as unknown as ContentBlock[]
|
||||
}
|
||||
throw new Error(
|
||||
`output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n`
|
||||
+ ' ✓ return [{ type: \'text\', text: String(value) }]',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
|
||||
* into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped,
|
||||
* required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm
|
||||
* via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning
|
||||
* the session log.
|
||||
* @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper.
|
||||
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
|
||||
*/
|
||||
export function sandboxDefineTool(options: unknown): ToolDefinition {
|
||||
if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object')
|
||||
const normalized = normalizeParameterSchemaSpec(options.parameters)
|
||||
if (!isPlainRecord(options.output)) {
|
||||
throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }')
|
||||
}
|
||||
const output = options.output
|
||||
if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function')
|
||||
if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') {
|
||||
throw new Error('harness.defineTool output.presentationMeta must be a function when present')
|
||||
}
|
||||
if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function')
|
||||
const schema = cloneJson(output.schema, 'output.schema')
|
||||
const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown>
|
||||
const rawRender = output.render as (args: unknown, value: unknown) => unknown
|
||||
const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined
|
||||
const erasedDefineTool = defineTool as unknown as (definition: unknown) => ToolDefinition
|
||||
const tool = erasedDefineTool({
|
||||
...options,
|
||||
parameters: normalized.spec,
|
||||
output: {
|
||||
schema,
|
||||
render(args: unknown, value: unknown): ContentBlock[] {
|
||||
return assertRenderedContent(cloneJson(rawRender(args, value), 'output.render result') as JsonValue)
|
||||
},
|
||||
...rawPresentationMeta !== undefined ? {
|
||||
presentationMeta(args: unknown, value: unknown): JsonValue {
|
||||
return cloneJson(rawPresentationMeta(args, value), 'output.presentationMeta result') as JsonValue
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
async execute(args: unknown, exec: unknown): Promise<JsonValue> {
|
||||
return cloneJson(await rawExecute(args, exec), 'execute result') as JsonValue
|
||||
},
|
||||
})
|
||||
const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
|
||||
assertSupportedJsonSchema(parameters)
|
||||
return markDynamicTool({
|
||||
...tool,
|
||||
parameters,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `harness.registerTool` handed into the sandbox: registers a
|
||||
* marker-verified dynamic tool on the given context's registry.
|
||||
* @param ctx - the (guarded) context whose `tools` service receives the tool.
|
||||
* @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
|
||||
* @returns the registry disposer for the registration.
|
||||
*/
|
||||
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
|
||||
assertDynamicTool(tool)
|
||||
return ctx.tools.register(tool)
|
||||
}
|
||||
|
||||
/**
|
||||
* The verbs a mounted plugin may reach through the sandbox `ctx` façade, beyond its injected
|
||||
* services. `on`/`once` observe events, `provide` exposes a service to other mounts, and the
|
||||
* timer helpers schedule work — each a fiber effect that unwinds on unmount.
|
||||
*/
|
||||
const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
|
||||
|
||||
/**
|
||||
* The tool-registry façade: `register` (marker-guarded) plus READ-ONLY
|
||||
* metadata (`schemas`, and `get` returning a schema view, never the live
|
||||
* `ToolDefinition`). Exposing the raw definition would hand mount code the
|
||||
* tool's `execute` function, letting it call another tool directly and bypass
|
||||
* `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 mount's own scope.
|
||||
return {
|
||||
register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
|
||||
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
|
||||
get: (name: string) => ctx.tools.schemas(scopeOf(ctx)).find(schema => schema.name === name),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject any injected-service return that is a cordis `Context`. Harness
|
||||
* services return data, never a context; a value that is one would be a
|
||||
* fresh, unguarded handle back into the runtime — the exact escape the façade
|
||||
* exists to close — so it fails loud instead of reaching sandbox code.
|
||||
*/
|
||||
function denyContext(value: unknown, service: string): unknown {
|
||||
if (value instanceof Context) {
|
||||
throw new Error(
|
||||
`service "${service}" returned a cordis Context, which the sandbox does not expose. `
|
||||
+ 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
|
||||
+ 'and the services you inject — never another context.',
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an injected service so its methods forward to the real instance but
|
||||
* their return values pass through {@link denyContext}. Non-function members
|
||||
* (plain data) pass through as-is; a returned Promise is guarded on resolve.
|
||||
*/
|
||||
function guardedService(service: object, name: string): unknown {
|
||||
return new Proxy(service, {
|
||||
get(target, prop) {
|
||||
const value = Reflect.get(target, prop, target) as unknown
|
||||
if (typeof value !== 'function') return denyContext(value, name)
|
||||
return (...args: unknown[]): unknown => {
|
||||
const result = Reflect.apply(value, target, args) as unknown
|
||||
if (result instanceof Promise) return result.then(v => denyContext(v, name))
|
||||
return denyContext(result, name)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The service names a plugin declared in `inject`, as a lookup set. Whatever
|
||||
* declaration style the plugin used — an `inject: ['bash', 'tools']` array or
|
||||
* the `{ required, optional }` object form — cordis resolves it into a single
|
||||
* name-keyed map on the fiber before `apply` runs (`{ bash: null, tools: null }`),
|
||||
* so the gate just reads that map's keys. A mount may reach only the services
|
||||
* it declared — that is what lets cordis park the mount when a declared
|
||||
* provider unmounts.
|
||||
*/
|
||||
function declaredInjects(ctx: Context): Set<string> {
|
||||
return new Set(Object.keys(ctx.fiber.inject))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist context for mounted plugins: lifecycle-safe verbs, guarded tools, and only declared
|
||||
* injected services. Framework plumbing is denied, and service methods cannot return a Context.
|
||||
*/
|
||||
function sandboxContext(ctx: Context): Context {
|
||||
const tools = sandboxTools(ctx)
|
||||
const declared = declaredInjects(ctx)
|
||||
// A framework member or an undeclared service — distinguish the two so the
|
||||
// error teaches the right fix (declare it in inject vs it is withheld).
|
||||
const denyRead = (prop: string): never => {
|
||||
if (ctx.get(prop) !== undefined) {
|
||||
throw new Error(
|
||||
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
|
||||
+ 'so cordis parks this temporary Plugin if the provider is later unmounted.',
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
|
||||
+ 'the timer helpers (ctx.setTimeout, ctx.interval, …) and any service you declared in inject. '
|
||||
+ 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
|
||||
)
|
||||
}
|
||||
// Read a service for either access path (property or `get`). `tools` is the façade's own
|
||||
// API.
|
||||
const readService = (name: string): unknown => {
|
||||
if (name === 'tools') return tools
|
||||
if (!declared.has(name)) return denyRead(name)
|
||||
const service = denyContext(ctx.get(name), name)
|
||||
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
|
||||
return guardedService(service, name)
|
||||
}
|
||||
const get = (name: string): unknown => readService(name)
|
||||
return new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'tools') return tools
|
||||
if (prop === 'get') return get
|
||||
if (typeof prop !== 'string') return undefined
|
||||
// Lazy verb forwarder — reads `ctx[verb]` only when called, so a plugin
|
||||
// that never uses a timer never triggers the timer mixin's inject check
|
||||
// (cordis raises its own "without inject" error there for undeclared timer use).
|
||||
if (CTX_VERBS.has(prop)) {
|
||||
return (...args: unknown[]): unknown => {
|
||||
const method = ctx[prop as keyof Context]
|
||||
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
|
||||
}
|
||||
}
|
||||
return readService(prop)
|
||||
},
|
||||
// A façade is not the real ctx; block writes rather than let mount code
|
||||
// stash state on a throwaway object and think it persisted.
|
||||
set(_target, prop) {
|
||||
throw new Error(`sandbox ctx is read-only; cannot assign "${String(prop)}"`)
|
||||
},
|
||||
// `in` reflects reachability: the façade 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) || declared.has(prop))),
|
||||
}) as unknown as Context
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an arbitrary sandbox return value to a mountable cordis plugin: a
|
||||
* function, or an object with an `apply` function. (A bare function passes the
|
||||
* first arm, so the object arm never sees `Function.prototype.apply`.)
|
||||
* @param value - whatever the mount code returned.
|
||||
* @returns whether the value is mountable via `ctx.plugin`.
|
||||
*/
|
||||
export function isPlugin(value: unknown): value is Plugin {
|
||||
if (typeof value === 'function') return true
|
||||
return typeof value === 'object' && value !== null
|
||||
&& typeof (value as { apply?: unknown }).apply === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a plugin so `apply` receives the sandbox context while preserving injection metadata.
|
||||
* @param plugin - the plugin the mount code returned.
|
||||
* @returns an equivalent plugin whose `apply` sees the sandbox context façade.
|
||||
*/
|
||||
export function guardedPlugin(plugin: Plugin): Plugin {
|
||||
if (typeof plugin === 'function') {
|
||||
const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
|
||||
return {
|
||||
name: pluginName(plugin),
|
||||
apply(ctx: Context, config?: unknown) {
|
||||
return functionPlugin(sandboxContext(ctx), config)
|
||||
},
|
||||
}
|
||||
}
|
||||
const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
|
||||
return {
|
||||
...plugin,
|
||||
apply(ctx: Context, config?: unknown) {
|
||||
return objectPlugin.apply(sandboxContext(ctx), config)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for a mounted plugin: its `name` property, else anonymous.
|
||||
* @param plugin - the plugin the mount code returned.
|
||||
* @returns the human-readable name used in mount results and inspect output.
|
||||
*/
|
||||
export function pluginName(plugin: Plugin): string {
|
||||
const named = (plugin as { name?: unknown }).name
|
||||
if (typeof named === 'string' && named.length > 0) return named
|
||||
return '<anonymous>'
|
||||
}
|
||||
266
packages/extensions/tool-cordis/src/index.ts
Normal file
266
packages/extensions/tool-cordis/src/index.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Self-referential runtime tools: inspect live services/plugins/tools, mount a returned temporary
|
||||
* plugin under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects,
|
||||
* so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent
|
||||
* accidental misuse, not hostile code: an allowed service such as `ctx.shell` reaches the real
|
||||
* runtime. Named exports preserve loader injection metadata.
|
||||
* @module @deepseek-ai/dsh-tool-cordis
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { STATE_LABELS } from './fiber-state.ts'
|
||||
import { isPlugin, pluginName } from './guard.ts'
|
||||
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
|
||||
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts'
|
||||
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts'
|
||||
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
|
||||
import { createSandbox, evaluateMountCode } from './sandbox.ts'
|
||||
|
||||
export const name = 'tool-cordis'
|
||||
export const inject = ['tools']
|
||||
|
||||
/** Config for the tool-cordis plugin: the sandbox evaluation bound. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Milliseconds the SYNCHRONOUS portion of mount code may run in the vm
|
||||
* before evaluation is aborted (default 5000). An async body escapes this
|
||||
* bound — see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
|
||||
*/
|
||||
vmTimeoutMs?: number
|
||||
}
|
||||
|
||||
/** Schemastery validator for {@link Config}: `vmTimeoutMs` must be at least 1 (defaults to 5000). */
|
||||
export const Config: z<Config> = z.object({
|
||||
vmTimeoutMs: z.number().min(1).default(5000),
|
||||
})
|
||||
|
||||
/** {@link Config} with every defaulted field present, as schemastery resolves it at load. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* Register the three cordis tools and own every temporary plugin under one
|
||||
* `cordis-dynamic` group fiber.
|
||||
* @param ctx - the plugin context (`tools` injected).
|
||||
* @param config - the schemastery-resolved {@link Config}.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const { vmTimeoutMs } = config as ResolvedConfig
|
||||
// The one group fiber every dynamic mount hangs under.
|
||||
const group = ctx.plugin({ name: 'cordis-dynamic', apply: () => {} })
|
||||
|
||||
const mounts = new Map<string, DynamicMount>()
|
||||
let nextId = 1
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect',
|
||||
description:
|
||||
'Inspect the live Cordis runtime in the current DSH process. Read-only. '
|
||||
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
|
||||
+ '`plugins` (all live plugin fibers with their lifecycle states), '
|
||||
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
|
||||
+ '`temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), '
|
||||
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
|
||||
+ '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). '
|
||||
+ 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. '
|
||||
+ 'The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. '
|
||||
+ 'With `what:"api"` or `what:"events"`, pass an exact `name` '
|
||||
+ 'to narrow to one service/event and include its original source JSDoc.',
|
||||
parameters: {
|
||||
what: {
|
||||
type: 'string',
|
||||
enum: ['services', 'plugins', 'tools', 'temporary', 'api', 'events'],
|
||||
description: 'Limit the report to one section. Omit for all sections.',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute(args, exec): Promise<string> {
|
||||
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') {
|
||||
throw new Error('name is valid only with what:"api" or what:"events"')
|
||||
}
|
||||
const sections: [key: string, heading: string, body: () => string[]][] = [
|
||||
['services', 'services', () => describeServices(ctx)],
|
||||
['plugins', 'plugins', () => describePlugins(ctx)],
|
||||
// The calling agent's view: scoped/shadowed tools included, restricted
|
||||
// globals absent — "what you can call", not the global registry.
|
||||
['tools', 'tools', () => describeTools(ctx, exec.agent)],
|
||||
['temporary', 'Temporary Plugins', () => describeDynamic(ctx, mounts)],
|
||||
['api', 'api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
|
||||
['events', 'events', () => describeEvents(EVENT_API, args.name)],
|
||||
]
|
||||
const selected = sections.filter(([key]) => args.what === undefined || args.what === key)
|
||||
const text = selected
|
||||
.map(([, heading, body]) => `## ${heading}\n${body().join('\n')}`)
|
||||
.join('\n\n')
|
||||
return Promise.resolve(text)
|
||||
},
|
||||
presentCall: presentInspectCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_mount',
|
||||
description:
|
||||
'Mount a temporary Cordis Plugin in the current DSH process. '
|
||||
+ 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. '
|
||||
+ 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. '
|
||||
+ 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. '
|
||||
+ 'To keep it, ask the Agent to implement an Harness Plugin or installable profile bundle through the regular development workflow. '
|
||||
+ 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. '
|
||||
+ '`code` runs now as the body of an async JavaScript function '
|
||||
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: '
|
||||
+ 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register '
|
||||
+ 'tools, listen to events, and provide services, but reaching ANY service (e.g. '
|
||||
+ 'ctx.shell) throws; use it only when you need no services. '
|
||||
+ 'OBJECT form `return { name?, inject: [\'bash\', \'llm\', …], apply(ctx) { … } }` '
|
||||
+ '— declares dependencies, and cordis activates the plugin only after the '
|
||||
+ 'services exist; PREFER this form. You may reach ONLY the services you list in '
|
||||
+ 'inject: an undeclared service throws even if it exists, because an undeclared '
|
||||
+ 'dependency would not be cleaned up if its provider is unmounted. '
|
||||
+ 'BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists '
|
||||
+ 'method signatures AND the type shapes of their arguments/returns (do not guess a '
|
||||
+ 'field\'s type; e.g. a bash run\'s stdout is an object, not a string). '
|
||||
+ 'Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe '
|
||||
+ 'events (see cordis_inspect what:"events"), or call '
|
||||
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
|
||||
+ '{ text: { type: \'string\', required: true } }, output: { schema: { type: \'string\' }, '
|
||||
+ 'render(_args, value) { return [{ type: \'text\', text: value }] } }, async execute(args) { return args.text } }))` '
|
||||
+ 'to give yourself a new tool — it becomes callable on your NEXT step. '
|
||||
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', '
|
||||
+ 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and '
|
||||
+ 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A '
|
||||
+ 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; '
|
||||
+ '`output.render(args, value)` separately returns Native/model content blocks. '
|
||||
+ 'Temporary Plugins can COMPOSE: one Plugin may `ctx.provide(\'name\', value)` a service and '
|
||||
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
|
||||
+ 'until the provider exists and returns to pending when the provider is unmounted. '
|
||||
+ 'Everything registered inside `apply` is cleaned up automatically by cordis_unmount. '
|
||||
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
|
||||
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
|
||||
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
|
||||
+ 'Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, '
|
||||
+ 'never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect '
|
||||
+ 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for '
|
||||
+ 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.shell for processes, '
|
||||
+ 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
|
||||
+ 'auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. '
|
||||
+ 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). '
|
||||
+ 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a '
|
||||
+ 'trailing `next` callback which MUST be called — returning without `next()` '
|
||||
+ 'SHORT-CIRCUITS the call; prefer plain notification events unless you intend to '
|
||||
+ 'intercept. (2) Never await something that only resolves after the current '
|
||||
+ 'turn (your code runs INSIDE a tool call of that turn — it would deadlock). '
|
||||
+ '(3) Your `ctx` is a restricted façade: you can register tools, observe '
|
||||
+ 'events, provide/consume services, and use timers, but framework internals '
|
||||
+ '(ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a '
|
||||
+ 'security boundary though — the services you inject (e.g. ctx.shell) reach the '
|
||||
+ 'real runtime.',
|
||||
parameters: {
|
||||
code: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'JavaScript body returning a temporary Plugin; evaluated now and saved nowhere.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
pluginName: { type: 'string', required: true },
|
||||
state: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'],
|
||||
},
|
||||
provides: { type: 'array', required: true, items: { type: 'string' } },
|
||||
waitingFor: { type: 'array', required: true, items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => {
|
||||
const status = value.waitingFor.length > 0
|
||||
? `is pending (plugin "${value.pluginName}"; missing services: ${value.waitingFor.join(', ')}`
|
||||
: `is running (plugin "${value.pluginName}"`
|
||||
return [{
|
||||
type: 'text',
|
||||
text: `Temporary Plugin ${value.id} ${status}; available until unmounted or DSH restarts).`,
|
||||
}]
|
||||
},
|
||||
},
|
||||
async execute(args) {
|
||||
const id = `dyn-${nextId++}`
|
||||
const sandbox = createSandbox(id)
|
||||
const evaluated = await evaluateMountCode(sandbox, args.code, id, vmTimeoutMs)
|
||||
if (!isPlugin(evaluated)) {
|
||||
if (evaluated === undefined) {
|
||||
throw new Error(
|
||||
'temporary Plugin code returned `undefined` — did you forget `return`?\n'
|
||||
+ ' ✓ return (ctx) => { … }\n'
|
||||
+ ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }',
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
'temporary Plugin code must `return` a Plugin: a function, or an object with an `apply(ctx)` method',
|
||||
)
|
||||
}
|
||||
const fiber = await mountDynamic(group, evaluated)
|
||||
mounts.set(id, { fiber, pluginName: pluginName(evaluated) })
|
||||
// A settled fiber that is not ACTIVE is waiting on unsatisfied inject —
|
||||
// legal cordis semantics (it activates when the service appears), so keep
|
||||
// it mounted but tell the model what it is waiting for.
|
||||
const missing = missingServices(ctx, fiber)
|
||||
const state = STATE_LABELS[fiber.state]
|
||||
return {
|
||||
id,
|
||||
pluginName: pluginName(evaluated),
|
||||
state,
|
||||
provides: providedServices(ctx, fiber),
|
||||
waitingFor: missing,
|
||||
}
|
||||
},
|
||||
presentCall: presentMountCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_unmount',
|
||||
description:
|
||||
'Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. '
|
||||
+ 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.',
|
||||
parameters: {
|
||||
id: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
pluginName: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was unmounted and removed.` }],
|
||||
},
|
||||
async execute(args) {
|
||||
const mount = mounts.get(args.id)
|
||||
if (!mount) {
|
||||
throw new Error(`no temporary Plugin with id "${args.id}" (list them with cordis_inspect what:"temporary")`)
|
||||
}
|
||||
await mount.fiber.dispose()
|
||||
mounts.delete(args.id)
|
||||
return { id: args.id, pluginName: mount.pluginName }
|
||||
},
|
||||
presentCall: presentUnmountCall,
|
||||
}))
|
||||
}
|
||||
229
packages/extensions/tool-cordis/src/inspect.ts
Normal file
229
packages/extensions/tool-cordis/src/inspect.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
|
||||
* plugin list, the registered tools, the temporary-plugin table (with per-plugin provides/waits),
|
||||
* and the catalog-backed `api` / `events` sections. Exact-name lookups add the
|
||||
* original source JSDoc without inflating the default reports.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/inspect
|
||||
*/
|
||||
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
|
||||
import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, TypeApiEntry } from './api-catalog.ts'
|
||||
import { FiberState, STATE_LABELS } from './fiber-state.ts'
|
||||
import { missingServices } from './mount.ts'
|
||||
import type { DynamicMount } from './mount.ts'
|
||||
|
||||
/** The live service registrations from `ctx.reflect.store` (map + filter keeps the possibly-undefined index read branch-free). */
|
||||
function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
|
||||
const store = ctx.reflect.store
|
||||
return Object.getOwnPropertySymbols(store)
|
||||
.map(key => store[key])
|
||||
.filter((impl): impl is NonNullable<typeof impl> => impl !== undefined)
|
||||
}
|
||||
|
||||
/** Whether `fiber` is `root` itself or mounted anywhere inside `root`'s subtree. */
|
||||
function withinFiber(fiber: Fiber, root: Fiber): boolean {
|
||||
let current = fiber
|
||||
while (true) {
|
||||
if (current === root) return true
|
||||
const parent = current.parent.fiber
|
||||
if (parent === current) return false
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the service names provided by a mount's fiber subtree.
|
||||
* @param ctx - the runtime whose service registrations are inspected.
|
||||
* @param fiber - the root of the mounted fiber subtree.
|
||||
* @returns the provided service names in lexical order.
|
||||
*/
|
||||
export function providedServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return liveImpls(ctx)
|
||||
.filter(impl => withinFiber(impl.fiber, fiber))
|
||||
.map(impl => impl.name)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `services` section: every provided ctx service with its owning fiber,
|
||||
* annotating non-active owners with their lifecycle state.
|
||||
* @param ctx - the runtime to enumerate.
|
||||
* @returns one line per service, or a single placeholder line when none are provided.
|
||||
*/
|
||||
export function describeServices(ctx: Context): string[] {
|
||||
const lines = liveImpls(ctx).map((impl) => {
|
||||
const active = impl.fiber.state === FiberState.ACTIVE
|
||||
return `- ${impl.name} (provided by ${impl.fiber.name}${active ? '' : `, ${STATE_LABELS[impl.fiber.state]}`})`
|
||||
})
|
||||
return lines.length > 0 ? lines : ['(no services provided)']
|
||||
}
|
||||
|
||||
/**
|
||||
* The `plugins` section: a flat list of every fiber the registry knows, one
|
||||
* line per fiber with its lifecycle state, sorted by plugin name (a plugin
|
||||
* mounted more than once repeats — one line per instance). Temporary plugins are
|
||||
* listed like any other plugin; their ids live in the `temporary` section.
|
||||
* @param ctx - the runtime whose registry is enumerated.
|
||||
* @returns one line per loaded plugin fiber.
|
||||
*/
|
||||
export function describePlugins(ctx: Context): string[] {
|
||||
const fibers: Fiber[] = []
|
||||
for (const runtime of ctx.registry.values()) {
|
||||
for (const fiber of runtime.fibers) fibers.push(fiber)
|
||||
}
|
||||
return fibers
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `tools` section: the model-facing tool names the CALLING agent can see
|
||||
* (its scoped layer shadowing/joining the restricted global tool set) — the
|
||||
* honest answer to the tool description's "what you can call".
|
||||
* @param ctx - the runtime whose tool registry is read.
|
||||
* @param scope - the calling agent (the viewing scope); omitted = global view.
|
||||
* @returns one line per visible tool.
|
||||
*/
|
||||
export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
|
||||
return ctx.tools.schemas(scope).map(schema => `- ${schema.name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `temporary` section: one line per temporary plugin with id, plugin name, lifecycle
|
||||
* state, the services its subtree provides, and — for a pending mount — the
|
||||
* services it waits for.
|
||||
* @param ctx - the runtime the mounts live in.
|
||||
* @param mounts - the tracked mounts, in mount order.
|
||||
* @returns one line per mount, or a single placeholder line when none exist.
|
||||
*/
|
||||
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
|
||||
if (mounts.size === 0) {
|
||||
return ['No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.']
|
||||
}
|
||||
return [...mounts].map(([id, mount]) => {
|
||||
const provides = providedServices(ctx, mount.fiber)
|
||||
const waiting = missingServices(ctx, mount.fiber)
|
||||
const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state]
|
||||
return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until unmounted or DSH restarts`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The transitive closure of catalogued type shapes referenced (word-bounded)
|
||||
* by the seed texts — the runtime scoping that keeps the `api` section to the
|
||||
* shapes the LIVE signatures actually mention.
|
||||
*/
|
||||
function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEntry[] {
|
||||
const included = new Map<string, TypeApiEntry>()
|
||||
let frontier = seeds
|
||||
while (frontier.length > 0) {
|
||||
const next: string[] = []
|
||||
for (const entry of types) {
|
||||
if (included.has(entry.name)) continue
|
||||
const pattern = new RegExp(`\\b${entry.name}\\b`)
|
||||
if (frontier.some(text => pattern.test(text))) {
|
||||
included.set(entry.name, entry)
|
||||
next.push(entry.declaration)
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return [...included.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/** Render one catalogued service, optionally including source-owned method JSDoc. */
|
||||
function serviceLines(entry: ServiceApiEntry, detailed: boolean): string[] {
|
||||
const lines = [`- ${entry.key} — ${entry.summary}`]
|
||||
for (const method of entry.methods) {
|
||||
if (detailed) {
|
||||
for (const docLine of method.jsDoc.split('\n')) lines.push(` ${docLine}`)
|
||||
}
|
||||
lines.push(` ${method.signature}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generated catalog against the live runtime: live catalogued services with methods,
|
||||
* uncatalogued live services with owners, absent loadable services, referenced type shapes, and
|
||||
* inherited Context APIs.
|
||||
* @param ctx - the runtime to intersect the catalog with.
|
||||
* @param api - generated service entries, replaceable in tests.
|
||||
* @param inherited - inherited `ctx` entries, replaceable in tests.
|
||||
* @param types - public type shapes, replaceable in tests.
|
||||
* @param name - exact live service key whose methods should include original JSDoc; omitted for the compact catalog.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeApi(
|
||||
ctx: Context,
|
||||
api: readonly ServiceApiEntry[] = SERVICE_API,
|
||||
inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API,
|
||||
types: readonly TypeApiEntry[] = TYPE_API,
|
||||
name?: string,
|
||||
): string[] {
|
||||
const live = new Map<string, string>()
|
||||
for (const impl of liveImpls(ctx)) live.set(impl.name, impl.fiber.name)
|
||||
const lines: string[] = []
|
||||
const liveMethodTexts: string[] = []
|
||||
let selected = api.filter(entry => live.has(entry.key))
|
||||
if (name !== undefined) {
|
||||
const entry = api.find(candidate => candidate.key === name)
|
||||
if (!entry) throw new Error(`no catalogued service named "${name}"`)
|
||||
if (!live.has(name)) throw new Error(`catalogued service "${name}" is not running`)
|
||||
selected = [entry]
|
||||
}
|
||||
for (const entry of selected) {
|
||||
lines.push(...serviceLines(entry, name !== undefined))
|
||||
for (const method of entry.methods) {
|
||||
liveMethodTexts.push(method.signature)
|
||||
}
|
||||
}
|
||||
if (name === undefined) {
|
||||
const catalogued = new Set(api.map(entry => entry.key))
|
||||
for (const [liveName, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (!catalogued.has(liveName)) lines.push(`- ${liveName} (provided by ${fiber}, no catalog entry)`)
|
||||
}
|
||||
const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key)
|
||||
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
|
||||
}
|
||||
const shapes = typeClosure(liveMethodTexts, types)
|
||||
if (shapes.length > 0) {
|
||||
lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):')
|
||||
for (const shape of shapes) {
|
||||
for (const declLine of shape.declaration.split('\n')) lines.push(` ${declLine}`)
|
||||
}
|
||||
}
|
||||
if (name === undefined) {
|
||||
lines.push('inherited ctx API:')
|
||||
for (const entry of inherited) lines.push(`- ${entry.name} — ${entry.summary}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The `events` section: every harness event with its dispatch mode, one-line
|
||||
* summary, and exact signature, closed by the waterfall caution.
|
||||
* @param events - the event catalog (the generated one by default; injectable for tests).
|
||||
* @param name - exact event name whose signature should include original JSDoc; omitted for the compact catalog.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] {
|
||||
let selected = events
|
||||
if (name !== undefined) {
|
||||
const event = events.find(candidate => candidate.name === name)
|
||||
if (!event) throw new Error(`no catalogued event named "${name}"`)
|
||||
selected = [event]
|
||||
}
|
||||
const lines = selected.flatMap((event) => {
|
||||
const entry = [`- ${event.name} [${event.mode}] — ${event.summary}`]
|
||||
if (name !== undefined) {
|
||||
for (const docLine of event.jsDoc.split('\n')) entry.push(` ${docLine}`)
|
||||
}
|
||||
entry.push(` ${event.signature}`)
|
||||
return entry
|
||||
})
|
||||
lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.')
|
||||
return lines
|
||||
}
|
||||
30
packages/extensions/tool-cordis/src/invariant.ts
Normal file
30
packages/extensions/tool-cordis/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-cordis`.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-cordis-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
||||
* relations are owned by the capability seam it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
61
packages/extensions/tool-cordis/src/mount.ts
Normal file
61
packages/extensions/tool-cordis/src/mount.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Dynamic-mount lifecycle over the `cordis-dynamic` group fiber: settle a
|
||||
* sandbox-produced plugin as a child fiber (never leaving a failed fiber
|
||||
* mounted), and report the services a settled-but-pending fiber still waits
|
||||
* for. Disposal needs no helper — a mount unwinds through an ordinary awaited
|
||||
* `fiber.dispose()`, because everything the plugin registered is an effect on
|
||||
* its fiber.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-cordis/mount
|
||||
*/
|
||||
|
||||
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis'
|
||||
import { guardedPlugin } from './guard.ts'
|
||||
|
||||
/** One tracked dynamic mount: the fiber plus the display name captured at mount time. */
|
||||
export interface DynamicMount {
|
||||
/** The child fiber under the `cordis-dynamic` group. */
|
||||
fiber: Fiber
|
||||
/** The plugin's display name at mount time (its `name`, else `<anonymous>`). */
|
||||
pluginName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Await the group, mount and settle one guarded child, and dispose it before rethrowing any
|
||||
* startup failure so a failed mount never lingers. A valid unresolved inject may remain pending.
|
||||
* @param group - the `cordis-dynamic` group fiber every mount hangs under.
|
||||
* @param plugin - the plugin the sandbox returned; wrapped with the registration guard before mounting.
|
||||
* @returns the settled child fiber (possibly pending on unsatisfied `inject`).
|
||||
*/
|
||||
export async function mountDynamic(group: Fiber, plugin: Plugin): Promise<Fiber> {
|
||||
await group.await()
|
||||
const fiber = group.ctx.plugin(guardedPlugin(plugin))
|
||||
try {
|
||||
await fiber.await()
|
||||
} catch (error) {
|
||||
await fiber.dispose()
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// The commonest startup collision is remounting a NEW version of a tool
|
||||
// while the old mount still holds the name — teach the replace recipe.
|
||||
if (message.includes('already registered')) {
|
||||
throw new Error(
|
||||
`${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_unmount that Plugin's id `
|
||||
+ '(find it with cordis_inspect what:"temporary"), then mount the new version.',
|
||||
)
|
||||
}
|
||||
throw error instanceof Error ? error : new Error(message)
|
||||
}
|
||||
return fiber
|
||||
}
|
||||
|
||||
/**
|
||||
* The services a fiber declared in `inject` that do not exist yet — a settled
|
||||
* fiber that is not active is waiting on exactly these (legal cordis
|
||||
* semantics: it activates when the service appears).
|
||||
* @param ctx - the context to resolve service existence against.
|
||||
* @param fiber - the mount fiber whose `inject` declarations are checked.
|
||||
* @returns the missing service names, in declaration order.
|
||||
*/
|
||||
export function missingServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
}
|
||||
52
packages/extensions/tool-cordis/src/present.ts
Normal file
52
packages/extensions/tool-cordis/src/present.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* UI render intents for the three cordis tools — all `generic` cards, decided
|
||||
* up front as part of the tool design. Presenters are pure functions of the
|
||||
* call arguments (they run on replay too): no I/O, no session state, no clock.
|
||||
* No `presentResult` overrides exist — the tools' text results are their
|
||||
* correct completed rendering.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-cordis/present
|
||||
*/
|
||||
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* The `cordis_inspect` call card: a read, titled with the requested section.
|
||||
* @param args - the validated call arguments.
|
||||
* @returns the generic call card.
|
||||
*/
|
||||
export function presentInspectCall(args: { what?: string; name?: string }): GenericCallView {
|
||||
const target = args.name === undefined ? args.what : `${args.what}: ${args.name}`
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: target === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${target}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `cordis_mount` call card: an execute carrying the temporary-plugin code as raw input.
|
||||
* @param args - the validated call arguments.
|
||||
* @returns the generic call card.
|
||||
*/
|
||||
export function presentMountCall(args: { code: string }): GenericCallView {
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'execute',
|
||||
title: 'Mount temporary Cordis Plugin',
|
||||
rawInput: { code: args.code },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `cordis_unmount` call card: a delete, titled with the temporary-plugin id.
|
||||
* @param args - the validated call arguments.
|
||||
* @returns the generic call card.
|
||||
*/
|
||||
export function presentUnmountCall(args: { id: string }): GenericCallView {
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'delete',
|
||||
title: `Unmount temporary Cordis Plugin ${args.id}`,
|
||||
}
|
||||
}
|
||||
179
packages/extensions/tool-cordis/src/sandbox.ts
Normal file
179
packages/extensions/tool-cordis/src/sandbox.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a
|
||||
* tagged write-through console, the `harness` registration helpers, the encoding primitives a
|
||||
* bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
|
||||
* withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
|
||||
* `ctx.shell`, and Cordis timers. This keeps cooperative mounts inspectable and disposable but
|
||||
* is not containment: host-realm helper functions remain an escape route.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/sandbox
|
||||
*/
|
||||
|
||||
import { createContext, runInContext } from 'node:vm'
|
||||
import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
|
||||
|
||||
/**
|
||||
* A write-through console for one sandbox, tagging every line with the mount
|
||||
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
|
||||
* a mounted listener fires long after the mount call returned, and its output
|
||||
* must land somewhere the user can see — for a terminal 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.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when unmounted.'
|
||||
|
||||
/**
|
||||
* The callable Node APIs the sandbox deliberately disables, each mapped to the
|
||||
* cordis alternative its trap error names. Only function-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; cordis_inspect what:"api" lists what THIS runtime provides.',
|
||||
setTimeout: TIMER_REDIRECT,
|
||||
setInterval: TIMER_REDIRECT,
|
||||
setImmediate: TIMER_REDIRECT,
|
||||
clearTimeout: TIMER_REDIRECT,
|
||||
clearInterval: TIMER_REDIRECT,
|
||||
fetch:
|
||||
'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
|
||||
+ '(see cordis_inspect what:"api" for its methods).',
|
||||
}
|
||||
|
||||
/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */
|
||||
function nodeApiTraps(): Record<string, () => never> {
|
||||
const traps: Record<string, () => never> = {}
|
||||
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
|
||||
traps[name] = () => {
|
||||
throw new Error(`${name} is not available in the temporary Plugin sandbox — ${redirect}`)
|
||||
}
|
||||
}
|
||||
return traps
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the vm context one `cordis_mount` call evaluates in: the tagged
|
||||
* console, the `harness` registration helpers, the encoding primitives, the
|
||||
* Node-API traps, and the dual-realm `instanceof` patch, already
|
||||
* `createContext`-ed.
|
||||
* @param id - the mount id (`dyn-<n>`), used as the console tag and filename stem.
|
||||
* @returns the contextified sandbox object to pass to {@link evaluateMountCode}.
|
||||
*/
|
||||
export function createSandbox(id: string): object {
|
||||
const sandbox = {
|
||||
...nodeApiTraps(),
|
||||
console: taggedConsole(id),
|
||||
harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool },
|
||||
// Web APIs absent from fresh vm contexts — made available so the model
|
||||
// can encode/decode base64 without Buffer (which is also absent). Host
|
||||
// closures over Buffer, never Buffer itself.
|
||||
btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'),
|
||||
atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'),
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
}
|
||||
createContext(sandbox)
|
||||
patchDualRealmInstanceof(sandbox)
|
||||
return sandbox
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-realm SyntaxError detection: a compile failure inside `runInContext`
|
||||
* constructs its error in the SANDBOX realm, so a host `instanceof
|
||||
* SyntaxError` is silently false — the `name` property is the realm-safe tag.
|
||||
*/
|
||||
function isSyntaxError(error: unknown): error is Error {
|
||||
return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'SyntaxError'
|
||||
}
|
||||
|
||||
/**
|
||||
* The parse-failure context a vm `SyntaxError` carries: the vm prints the
|
||||
* offending source line and a caret before the message, which is exactly what
|
||||
* a model needs to self-correct — surface it instead of the bare message.
|
||||
* Falls back to `String(error)` when the stack carries no such prelude.
|
||||
* @param error - the `SyntaxError` (host- or sandbox-realm) thrown while compiling mount code.
|
||||
* @returns the stack prefix up to and including the `SyntaxError: …` line.
|
||||
*/
|
||||
export function syntaxErrorContext(error: Error): string {
|
||||
const lines = (error.stack ?? '').split('\n')
|
||||
const messageIndex = lines.findIndex(line => line.startsWith('SyntaxError'))
|
||||
if (messageIndex === -1) return String(error)
|
||||
return lines.slice(0, messageIndex + 1).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate mount code as the body of an async function inside the sandbox. `vmTimeoutMs` only
|
||||
* bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's
|
||||
* trust stance. Parse errors include the offending line and a TypeScript-removal or bracket-
|
||||
* balance hint.
|
||||
* @param sandbox - the contextified object from {@link createSandbox}.
|
||||
* @param code - the model-written function body; must `return` a plugin.
|
||||
* @param id - the mount id, used as the vm filename (`cordis-mount-<id>.js`).
|
||||
* @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
|
||||
* @returns whatever the code returned, still un-narrowed (the mount lifecycle checks plugin shape).
|
||||
*/
|
||||
export async function evaluateMountCode(sandbox: object, code: string, id: string, vmTimeoutMs: number): Promise<unknown> {
|
||||
try {
|
||||
return await runInContext(
|
||||
`(async () => {\n${code}\n})()`,
|
||||
sandbox,
|
||||
{ filename: `cordis-mount-${id}.js`, timeout: vmTimeoutMs },
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isSyntaxError(error)) throw error
|
||||
const context = syntaxErrorContext(error)
|
||||
// Scope the TypeScript heuristic to the OFFENDING line, not the whole
|
||||
// code: an ` as ` inside an ordinary description string must not turn a
|
||||
// plain syntax error into a misleading remove-annotations message.
|
||||
const offendingLine = context.split('\n')[1] ?? ''
|
||||
if (/\bas\b/.test(offendingLine)) {
|
||||
throw new Error(
|
||||
`temporary Plugin code failed to parse:\n${context}\n`
|
||||
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
|
||||
+ ' ✗ { type: \'text\' as const, text: x }\n'
|
||||
+ ' ✓ { type: \'text\', text: x }',
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`temporary Plugin code failed to parse:\n${context}\n`
|
||||
+ 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
|
||||
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
|
||||
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.',
|
||||
)
|
||||
}
|
||||
}
|
||||
287
packages/extensions/tool-cordis/tests/cordis-lifecycle.spec.ts
Normal file
287
packages/extensions/tool-cordis/tests/cordis-lifecycle.spec.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Context, CordisError, FiberState, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Direct regressions for the vendored Cordis ownership substrate used by
|
||||
* tool-cordis's dynamic plugin tree and every other harness plugin.
|
||||
*/
|
||||
|
||||
describe('Cordis effect ownership', () => {
|
||||
it('makes an effect visible to a reentrant owner restart and awaits setup plus cleanup', async () => {
|
||||
const ctx = new Context()
|
||||
const setupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let restarted!: Promise<void>
|
||||
let setupFinished = false
|
||||
let cleanupFinished = false
|
||||
|
||||
ctx.effect(async () => {
|
||||
restarted = ctx.fiber.restart()
|
||||
await setupGate.promise
|
||||
setupFinished = true
|
||||
return async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
cleanupFinished = true
|
||||
}
|
||||
}, 'reentrant-restart')
|
||||
|
||||
let settled = false
|
||||
void restarted.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
setupGate.resolve(undefined)
|
||||
await cleanupStarted.promise
|
||||
expect(setupFinished).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await restarted
|
||||
expect(cleanupFinished).toBe(true)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls back collected cleanup and its owner-list entry when setup throws synchronously', () => {
|
||||
const ctx = new Context()
|
||||
let cleanups = 0
|
||||
|
||||
expect(() => ctx.effect(function* () {
|
||||
yield () => { cleanups += 1 }
|
||||
throw new Error('setup failed')
|
||||
}, 'throwing-setup')).toThrow('setup failed')
|
||||
|
||||
expect(cleanups).toBe(1)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('makes a reentrant owner restart await asynchronous rollback after synchronous setup failure', async () => {
|
||||
const ctx = new Context()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let restarted!: Promise<void>
|
||||
|
||||
expect(() => ctx.effect(function* () {
|
||||
yield async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
}
|
||||
restarted = ctx.fiber.restart()
|
||||
throw new Error('setup failed after restart')
|
||||
}, 'reentrant-throw')).toThrow('setup failed after restart')
|
||||
|
||||
await cleanupStarted.promise
|
||||
let settled = false
|
||||
void restarted.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await restarted
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps ordinary teardown synchronous and the public disposer single-shot', () => {
|
||||
const ctx = new Context()
|
||||
let cleanups = 0
|
||||
const dispose = ctx.effect(() => () => { cleanups += 1 }, 'sync-effect')
|
||||
|
||||
expect(dispose()).toBeUndefined()
|
||||
expect(cleanups).toBe(1)
|
||||
expect(dispose()).toBeUndefined()
|
||||
expect(cleanups).toBe(1)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects cleanup-time registration while a restart is unloading', async () => {
|
||||
const ctx = new Context()
|
||||
let registrationError: unknown
|
||||
|
||||
ctx.effect(() => () => {
|
||||
try {
|
||||
ctx.effect(() => () => {}, 'too-late')
|
||||
} catch (error) {
|
||||
registrationError = error
|
||||
}
|
||||
}, 'restart-cleanup')
|
||||
|
||||
await ctx.fiber.restart()
|
||||
expect(registrationError).toBeInstanceOf(CordisError)
|
||||
expect((registrationError as CordisError).code).toBe('INACTIVE_EFFECT')
|
||||
expect(ctx.fiber.state).toBe(FiberState.ACTIVE)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps effect registration legal while child fibers are PENDING and LOADING', async () => {
|
||||
const ctx = new Context()
|
||||
let pendingCleanup = false
|
||||
let loadingCleanup = false
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'state-probe' || fiber.uid === null) return
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
fiber.ctx.effect(() => () => { pendingCleanup = true }, 'pending-effect')
|
||||
})
|
||||
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'state-probe',
|
||||
apply(inner) {
|
||||
expect(inner.fiber.state).toBe(FiberState.LOADING)
|
||||
inner.effect(() => () => { loadingCleanup = true }, 'loading-effect')
|
||||
},
|
||||
})
|
||||
await fiber.dispose()
|
||||
|
||||
expect(pendingCleanup).toBe(true)
|
||||
expect(loadingCleanup).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves dependencies that internal/plugin adds before child activation', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('late-inject', {})
|
||||
let applyCalls = 0
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'loader-shaped' || fiber.uid === null) return
|
||||
fiber.inject['late-inject'] = {}
|
||||
})
|
||||
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'loader-shaped',
|
||||
apply() {
|
||||
applyCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(applyCalls).toBe(1)
|
||||
expect(fiber.state).toBe(FiberState.ACTIVE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis child publication ownership', () => {
|
||||
it('rolls back parent and runtime ownership when internal/plugin publication throws', () => {
|
||||
const ctx = new Context()
|
||||
const plugin = { name: 'publication-failure', apply() {} }
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === plugin.name) throw new Error('publication failed')
|
||||
})
|
||||
|
||||
expect(() => ctx.plugin(plugin)).toThrow('publication failed')
|
||||
expect(ctx.registry.has(plugin)).toBe(false)
|
||||
})
|
||||
|
||||
it('contains teardown notification failures so ownership cleanup and peers complete', async () => {
|
||||
const ctx = new Context()
|
||||
const errors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
|
||||
const observed: string[] = []
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === 'contained-teardown' && fiber.uid === null) {
|
||||
throw new Error('broken teardown observer')
|
||||
}
|
||||
})
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === 'contained-teardown' && fiber.uid === null) observed.push('disposed')
|
||||
})
|
||||
const child = await ctx.plugin({ name: 'contained-teardown', apply() {} })
|
||||
|
||||
await expect(child.dispose()).resolves.toBeUndefined()
|
||||
expect(observed).toEqual(['disposed'])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toEqual(expect.objectContaining({ message: 'broken teardown observer' }))
|
||||
expect(child.uid).toBeNull()
|
||||
})
|
||||
|
||||
it('makes a LOADING parent join child cleanup started before its unload snapshot', async () => {
|
||||
const ctx = new Context()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let ownerFiber!: Fiber
|
||||
let ownerDisposal!: Promise<void>
|
||||
let childDisposal!: Promise<void>
|
||||
let childFiber!: Fiber
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'loading-child' || fiber.uid === null) return
|
||||
childFiber = fiber
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
}, 'loading-child-cleanup')
|
||||
ownerDisposal = ownerFiber.dispose()
|
||||
childDisposal = Promise.resolve(fiber.dispose())
|
||||
})
|
||||
|
||||
const ownerMount = ctx.plugin({
|
||||
name: 'loading-owner',
|
||||
apply(inner) {
|
||||
ownerFiber = inner.fiber
|
||||
inner.plugin({ name: 'loading-child', apply() {} })
|
||||
},
|
||||
})
|
||||
|
||||
await cleanupStarted.promise
|
||||
let ownerSettled = false
|
||||
void ownerDisposal.then(() => { ownerSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(ownerSettled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await Promise.all([ownerDisposal, childDisposal, ownerMount])
|
||||
expect(childFiber.uid).toBeNull()
|
||||
expect(ownerFiber.uid).toBeNull()
|
||||
})
|
||||
|
||||
it('lets parent disposal during internal/plugin await the unpublished child to quiescence', async () => {
|
||||
const ctx = new Context()
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin({
|
||||
name: 'owner',
|
||||
apply(inner) {
|
||||
ownerCtx = inner
|
||||
},
|
||||
})
|
||||
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
let childApplyCalls = 0
|
||||
let parentDisposal!: Promise<void>
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'child' || fiber.uid === null) return
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
cleanupFinished = true
|
||||
}, 'pending-child-cleanup')
|
||||
})
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'child' || fiber.uid === null) return
|
||||
parentDisposal = owner.dispose()
|
||||
})
|
||||
|
||||
const child = ownerCtx.plugin({
|
||||
name: 'child',
|
||||
apply() {
|
||||
childApplyCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
await cleanupStarted.promise
|
||||
let settled = false
|
||||
void parentDisposal.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await parentDisposal
|
||||
expect(cleanupFinished).toBe(true)
|
||||
expect(childApplyCalls).toBe(0)
|
||||
expect(child.uid).toBeNull()
|
||||
expect(child.state).toBe(FiberState.DISPOSED)
|
||||
})
|
||||
})
|
||||
144
packages/extensions/tool-cordis/tests/cross-mount.spec.ts
Normal file
144
packages/extensions/tool-cordis/tests/cross-mount.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Cross-mount composition through ordinary cordis provide/inject semantics:
|
||||
* one mount provides a service, another injects it, and mount ids stay the
|
||||
* lifecycle handles. Every assertion is against the WORLD — the registry, the
|
||||
* service store, real tool dispatch — not the tool's own summary line.
|
||||
*/
|
||||
|
||||
describe('cross-mount provide/inject', () => {
|
||||
it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
|
||||
const ctx = await setup()
|
||||
const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
expect(text(provider)).toContain('is running')
|
||||
|
||||
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
|
||||
expect(consumer.isError).toBe(false)
|
||||
expect(text(consumer)).toContain('is running')
|
||||
|
||||
// The vm-realm service value is callable across mounts, and the result
|
||||
// normalizes into the host realm like any dynamic tool result.
|
||||
const greeted = await call(ctx, 'greet', { name: 'harness' })
|
||||
expect(greeted.isError).toBe(false)
|
||||
expect(text(greeted)).toBe('hi harness')
|
||||
})
|
||||
|
||||
it('consumer first: stays pending naming the missing service, then activates when the provider mounts', async () => {
|
||||
const ctx = await setup()
|
||||
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
|
||||
expect(consumer.isError).toBe(false)
|
||||
expect(text(consumer)).toContain('is pending')
|
||||
expect(text(consumer)).toContain('missing services: greeter')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('waiting for: greeter')
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
expect(ctx.tools.get('greet')).toBeDefined()
|
||||
expect(text(await call(ctx, 'greet', { name: 'late' }))).toBe('hi late')
|
||||
})
|
||||
|
||||
it('unmounting the provider sends the consumer back to pending and unwinds its registrations', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
|
||||
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
|
||||
expect(ctx.tools.get('greet')).toBeDefined()
|
||||
|
||||
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(unmounted.isError).toBe(false)
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
|
||||
expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until unmounted or DSH restarts')
|
||||
})
|
||||
|
||||
it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
|
||||
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
|
||||
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-3
|
||||
expect(ctx.tools.get('greet')).toBeDefined()
|
||||
expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-2: greeter-consumer [running]')
|
||||
})
|
||||
|
||||
it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
expect(duplicate.isError).toBe(true)
|
||||
expect(text(duplicate)).toContain('has been registered')
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
|
||||
expect(report).toContain('Temporary Plugin dyn-1: greeter-provider')
|
||||
expect(report).not.toContain('dyn-2')
|
||||
})
|
||||
|
||||
it('inspect surfaces the linkage: provides on the provider row, the service in services and api sections', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
|
||||
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
|
||||
|
||||
const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
|
||||
expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until unmounted or DSH restarts')
|
||||
|
||||
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
|
||||
expect(services).toContain('- greeter (provided by greeter-provider)')
|
||||
|
||||
const api = text(await call(ctx, 'cordis_inspect', { what: 'api' }))
|
||||
expect(api).toContain('- greeter (provided by greeter-provider, no catalog entry)')
|
||||
})
|
||||
|
||||
it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
|
||||
const ctx = await setup()
|
||||
const provider = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'answer-provider',
|
||||
apply(ctx) {
|
||||
ctx.provide('answer', 42)
|
||||
ctx.provide('nothing', null)
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(provider.isError).toBe(false)
|
||||
|
||||
const consumer = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'answer-consumer',
|
||||
inject: ['answer', 'nothing', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'answer',
|
||||
description: 'Read the provided primitive services.',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(consumer.isError).toBe(false)
|
||||
expect(text(consumer)).toContain('is running')
|
||||
expect(text(await call(ctx, 'answer', {}))).toBe('42/42/null')
|
||||
})
|
||||
|
||||
it('unmounting the consumer leaves the provider and its service intact', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
|
||||
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
|
||||
await call(ctx, 'cordis_unmount', { id: 'dyn-2' })
|
||||
|
||||
expect(ctx.tools.get('greet')).toBeUndefined()
|
||||
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
|
||||
expect(services).toContain('- greeter (provided by greeter-provider)')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-1: greeter-provider [running]')
|
||||
})
|
||||
})
|
||||
126
packages/extensions/tool-cordis/tests/helpers.ts
Normal file
126
packages/extensions/tool-cordis/tests/helpers.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import * as tool from '../src/index.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/**
|
||||
* Shared spec helpers: a real `SystemPrompt` + `ToolRuntime` + timer +
|
||||
* tool-cordis tree (only the model is absent — the code strings below stand in
|
||||
* for what it would write), plus the canonical mount-code fixtures the suites
|
||||
* share.
|
||||
*/
|
||||
|
||||
/** Mount the plugin on a fresh context with a real ToolRuntime and the timer service. */
|
||||
export async function setup(config?: tool.Config): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(tool, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
|
||||
/** Execute a registered tool through the real registry pipeline. */
|
||||
export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args })
|
||||
}
|
||||
|
||||
/** Concatenated text blocks of one tool result. */
|
||||
export function text(result: ToolExecutionResult): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
/** Mount code for a listener plugin: logs on every `tools/change`. */
|
||||
export const LISTENER_CODE = `
|
||||
return {
|
||||
name: 'change-logger',
|
||||
apply(ctx) {
|
||||
ctx.on('tools/change', () => console.log('tools changed'))
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */
|
||||
export const CONTENT_OUTPUT_CODE = `
|
||||
output: {
|
||||
schema: { type: 'array', items: { type: 'json' } },
|
||||
render(_args, value) { return value },
|
||||
},`
|
||||
|
||||
/** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
|
||||
export const REVERSE_TOOL_CODE = `
|
||||
return {
|
||||
name: 'reverse-text',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'reverse_text',
|
||||
description: 'Reverse a string.',
|
||||
parameters: { text: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) {
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
},
|
||||
async execute(args) {
|
||||
return args.text.split('').reverse().join('')
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** Mount code providing a `greeter` service other mounts can inject. */
|
||||
export const PROVIDER_CODE = `
|
||||
return {
|
||||
name: 'greeter-provider',
|
||||
apply(ctx) {
|
||||
ctx.provide('greeter', { greet: (name) => 'hi ' + name })
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** Mount code consuming the `greeter` service through inject, exposing it as a tool. */
|
||||
export const CONSUMER_CODE = `
|
||||
return {
|
||||
name: 'greeter-consumer',
|
||||
inject: ['greeter', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'greet',
|
||||
description: 'Greet someone via the greeter service.',
|
||||
parameters: { name: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) {
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
},
|
||||
async execute(args) {
|
||||
return ctx.greeter.greet(args.name)
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** A registrable no-op tool the tests use to trigger a real `tools/change`. */
|
||||
export function dummyTool(name: string): ToolDefinition {
|
||||
return {
|
||||
name,
|
||||
description: 'test trigger',
|
||||
parameters: { type: 'object' as const, properties: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
async execute(): Promise<null> {
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
177
packages/extensions/tool-cordis/tests/inspect.spec.ts
Normal file
177
packages/extensions/tool-cordis/tests/inspect.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import { FiberState } from '../src/fiber-state.ts'
|
||||
import { describeApi, describeEvents, describePlugins, describeServices } from '../src/inspect.ts'
|
||||
import { call, LISTENER_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The `cordis_inspect` sections: rendered against the real runtime through the
|
||||
* tool, plus direct renderer calls for the states a minimal harness cannot
|
||||
* reach (empty service store, same-named sibling fibers, a fully-live catalog).
|
||||
*/
|
||||
|
||||
describe('cordis_inspect', () => {
|
||||
it('reports all six sections by default', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_inspect', {})
|
||||
expect(result.isError).toBe(false)
|
||||
const report = text(result)
|
||||
if (result.isError) throw new Error('expected cordis_inspect success')
|
||||
expect(result.value).toBe(report)
|
||||
for (const heading of ['services', 'plugins', 'tools', 'Temporary Plugins', 'api', 'events']) {
|
||||
expect(report).toContain(`## ${heading}`)
|
||||
}
|
||||
// The services section sees the real providers; the plugins list shows
|
||||
// this plugin and its dynamic group flat; the tools section lists the
|
||||
// cordis tools.
|
||||
expect(report).toContain('- tools (provided by ToolRuntime)')
|
||||
expect(report).toContain('- tool-cordis [active]')
|
||||
expect(report).toContain('- cordis-dynamic [active]')
|
||||
expect(report).toContain('- cordis_mount')
|
||||
expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.')
|
||||
})
|
||||
|
||||
it('limits the report to one section via `what`', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_inspect', { what: 'tools' })
|
||||
const report = text(result)
|
||||
expect(report).toContain('## tools')
|
||||
expect(report).not.toContain('## services')
|
||||
expect(report).not.toContain('## plugins')
|
||||
})
|
||||
|
||||
it('shows a temporary Plugin in its exact section and in the flat plugins list', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
const report = text(await call(ctx, 'cordis_inspect', {}))
|
||||
expect(report).toContain('## Temporary Plugins')
|
||||
expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts')
|
||||
expect(report).toContain('- change-logger [active]')
|
||||
})
|
||||
|
||||
it('renders the api section from the generated catalog intersected with the LIVE runtime', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'api' }))
|
||||
// Live catalogued services render summary + signatures.
|
||||
expect(report).toContain('- tools — ')
|
||||
expect(report).toContain('register(definition: ToolDefinition)')
|
||||
expect(report).toContain('- systemPrompt — ')
|
||||
// Catalogued services with no live provider are listed tersely.
|
||||
expect(report).toMatch(/not running \(loadable services with no live provider\): .*shell/)
|
||||
// The type shapes the LIVE signatures reference follow (closure over the
|
||||
// generated TYPE_API — a consumer can see field types, not just names).
|
||||
expect(report).toContain('type shapes (referenced by the signatures above')
|
||||
expect(report).toContain('export interface ToolExecution')
|
||||
expect(report).toContain('export class Session')
|
||||
expect(report).toContain('export interface SessionSurface')
|
||||
// A type only reachable through a NOT-live service (e.g. bash) is scoped out.
|
||||
expect(report).not.toContain('export interface ShellRunResult')
|
||||
// The inherited ctx API closes the section.
|
||||
expect(report).toContain('inherited ctx API:')
|
||||
expect(report).toContain('- ctx.effect — ')
|
||||
// The broad report stays compact; exact-name lookup owns full JSDoc.
|
||||
expect(report).not.toContain('/**')
|
||||
expect(report).not.toContain('@param definition')
|
||||
})
|
||||
|
||||
it('adds original method JSDoc only for an exact live api name', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'api', name: 'tools' }))
|
||||
expect(report).toContain('## api')
|
||||
expect(report).toContain('- tools — Tool registry and execution pipeline.')
|
||||
expect(report).toContain('/**')
|
||||
expect(report).toContain('Register globally or in the calling agent scope.')
|
||||
expect(report).toContain('@param definition - tool schema, execution, and optional finalization/presentation callbacks')
|
||||
expect(report).toContain('@returns the exact disposer')
|
||||
expect(report).toContain('register(definition: ToolDefinition)')
|
||||
expect(report).toContain('type shapes (referenced by the signatures above')
|
||||
expect(report).not.toContain('not running (loadable services')
|
||||
expect(report).not.toContain('inherited ctx API:')
|
||||
})
|
||||
|
||||
it('renders the events section with mode badges, signatures, and the waterfall caution', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'events' }))
|
||||
expect(report).toContain('- tools/change [emit]')
|
||||
expect(report).toContain('- tools/pre-execute [waterfall]')
|
||||
expect(report).toMatch(/'agent\/status'\(/)
|
||||
expect(report).toContain('returning without next() short-circuits the chain')
|
||||
expect(report).not.toContain('/**')
|
||||
expect(report).not.toContain('@mode waterfall')
|
||||
})
|
||||
|
||||
it('adds original event JSDoc only for an exact event name', async () => {
|
||||
const ctx = await setup()
|
||||
const report = text(await call(ctx, 'cordis_inspect', { what: 'events', name: 'tools/pre-execute' }))
|
||||
expect(report).toContain('## events')
|
||||
expect(report).toContain('- tools/pre-execute [waterfall]')
|
||||
expect(report).toContain('/**')
|
||||
expect(report).toContain('Allow, deny, or ask before dispatch.')
|
||||
expect(report).toContain('@param exec - the pending call')
|
||||
expect(report).toContain('@mode waterfall')
|
||||
expect(report).not.toContain('- tools/change [emit]')
|
||||
})
|
||||
|
||||
it('fails loud for incompatible, unknown, and non-running names', async () => {
|
||||
const ctx = await setup()
|
||||
const incompatible = await call(ctx, 'cordis_inspect', { what: 'tools', name: 'tools' })
|
||||
expect(incompatible.isError).toBe(true)
|
||||
expect(text(incompatible)).toContain('name is valid only with what:"api" or what:"events"')
|
||||
|
||||
const unknownService = await call(ctx, 'cordis_inspect', { what: 'api', name: 'not-a-service' })
|
||||
expect(unknownService.isError).toBe(true)
|
||||
expect(text(unknownService)).toContain('no catalogued service named "not-a-service"')
|
||||
|
||||
const nonRunning = await call(ctx, 'cordis_inspect', { what: 'api', name: 'shell' })
|
||||
expect(nonRunning.isError).toBe(true)
|
||||
expect(text(nonRunning)).toContain('catalogued service "shell" is not running')
|
||||
|
||||
const unknownEvent = await call(ctx, 'cordis_inspect', { what: 'events', name: 'not/an-event' })
|
||||
expect(unknownEvent.isError).toBe(true)
|
||||
expect(text(unknownEvent)).toContain('no catalogued event named "not/an-event"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('inspect renderers (direct)', () => {
|
||||
it('describeServices reports an empty store as such, and labels a non-active provider', () => {
|
||||
const empty = { reflect: { store: {} } } as unknown as Context
|
||||
expect(describeServices(empty)).toEqual(['(no services provided)'])
|
||||
|
||||
const pendingFiber = { state: FiberState.PENDING, name: 'half-loaded' } as unknown as Fiber
|
||||
const store: Record<symbol, unknown> = {}
|
||||
store[Symbol('impl')] = { name: 'thing', fiber: pendingFiber }
|
||||
const ctx = { reflect: { store } } as unknown as Context
|
||||
expect(describeServices(ctx)).toEqual(['- thing (provided by half-loaded, pending)'])
|
||||
})
|
||||
|
||||
it('describePlugins lists every fiber flat, sorted by name, one line per instance', () => {
|
||||
const fiber = (name: string): Fiber => ({ name, state: FiberState.ACTIVE }) as unknown as Fiber
|
||||
const ctx = {
|
||||
registry: { values: () => [{ fibers: [fiber('beta'), fiber('alpha')] }, { fibers: [fiber('alpha')] }] },
|
||||
} as unknown as Context
|
||||
expect(describePlugins(ctx)).toEqual([
|
||||
'- alpha [active]',
|
||||
'- alpha [active]',
|
||||
'- beta [active]',
|
||||
])
|
||||
})
|
||||
|
||||
it('describeApi omits the not-running line and type shapes when nothing applies', async () => {
|
||||
const ctx = await setup()
|
||||
const lines = describeApi(ctx, [{
|
||||
key: 'tools',
|
||||
summary: 'The registry.',
|
||||
methods: [{ signature: 'register(x): void', jsDoc: '/** Register x. */' }],
|
||||
}], [], [])
|
||||
expect(lines[0]).toBe('- tools — The registry.')
|
||||
expect(lines[1]).toBe(' register(x): void')
|
||||
expect(lines.join('\n')).not.toContain('not running')
|
||||
expect(lines.join('\n')).not.toContain('type shapes')
|
||||
})
|
||||
|
||||
it('describeEvents renders an empty catalog as just the waterfall caution', () => {
|
||||
expect(describeEvents([])).toEqual([
|
||||
'waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.',
|
||||
])
|
||||
})
|
||||
})
|
||||
101
packages/extensions/tool-cordis/tests/integration.spec.ts
Normal file
101
packages/extensions/tool-cordis/tests/integration.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as ToolCordis from '../src/index.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { call, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model mounts a plugin that registers
|
||||
* a NEW tool, calls that tool on the very next step (tool schemas are
|
||||
* reassembled per step — the real loop proves the self-extension contract),
|
||||
* and unmounts it again. Only the model is mocked; the sandbox, the fiber
|
||||
* tree, and the session log are real.
|
||||
*/
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(ToolCordis)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('cordis tools through the agent loop', () => {
|
||||
it('mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'),
|
||||
toolCallResponse('call-2', 'reverse_text', { text: 'harness' }),
|
||||
toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }),
|
||||
textResponse('Done.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = agent.session.events
|
||||
const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name)
|
||||
expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount'])
|
||||
|
||||
const results = log.filter(event => event.type === 'tool/result')
|
||||
expect(results.map(event => event.data.message.content[0].isError)).toEqual([false, false, false])
|
||||
const reversed = results[1]!.data.message.content[0].content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
expect(reversed).toBe('ssenrah')
|
||||
|
||||
// After the unmount the self-made tool is gone from the registry.
|
||||
expect(ctx.tools.get('reverse_text')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a temporary Plugin across turns, unmounts it, and does not restore it in a new runtime', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('mount-1', 'cordis_mount', { code: 'return { name: \'turn-marker\', apply() {} }' }),
|
||||
toolCallResponse('inspect-1', 'cordis_inspect', { what: 'temporary' }),
|
||||
textResponse('Turn one complete.'),
|
||||
toolCallResponse('inspect-2', 'cordis_inspect', { what: 'temporary' }),
|
||||
toolCallResponse('unmount-1', 'cordis_unmount', { id: 'dyn-1' }),
|
||||
toolCallResponse('inspect-3', 'cordis_inspect', { what: 'temporary' }),
|
||||
textResponse('Turn two complete.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Mount the marker and inspect it.' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'On this later turn, inspect the marker, unmount it, then inspect again.' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const resultText = new Map(
|
||||
agent.session.events
|
||||
.filter(event => event.type === 'tool/result')
|
||||
.map(event => [event.data.message.source.callId, event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text).join('')]),
|
||||
)
|
||||
expect(resultText.get(CallId('inspect-1'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
|
||||
expect(resultText.get(CallId('inspect-2'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
|
||||
expect(resultText.get(CallId('unmount-1'))).toBe('Temporary Plugin dyn-1 was unmounted and removed.')
|
||||
expect(resultText.get(CallId('inspect-3'))).toContain('No temporary Plugins are running.')
|
||||
|
||||
const restarted = await setup()
|
||||
expect(text(await call(restarted, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
})
|
||||
897
packages/extensions/tool-cordis/tests/mount.spec.ts
Normal file
897
packages/extensions/tool-cordis/tests/mount.spec.ts
Normal file
@@ -0,0 +1,897 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { sandboxDefineTool } from '../src/guard.ts'
|
||||
import { syntaxErrorContext } from '../src/sandbox.ts'
|
||||
import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The `cordis_mount` success/failure family: real plugins land on a genuine
|
||||
* cordis fiber tree, their registrations are observable through the real
|
||||
* registry/event bus, and every rejection path teaches the fix.
|
||||
*/
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('cordis_mount', () => {
|
||||
it.each([
|
||||
[42, 'options must be an object'],
|
||||
[{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'],
|
||||
[{ parameters: {}, output: { schema: { type: 'json' } }, execute: async (): Promise<null> => null }, 'output.render must be a function'],
|
||||
[{ parameters: {}, output: { schema: { type: 'json' }, render: () => [] }, execute: true }, 'execute must be a function'],
|
||||
[{
|
||||
parameters: {},
|
||||
output: { schema: { type: 'json' }, render: () => [], presentationMeta: true },
|
||||
execute: async (): Promise<null> => null,
|
||||
}, 'output.presentationMeta must be a function'],
|
||||
])('rejects an invalid dynamic tool declaration before registration: %j', (definition, message) => {
|
||||
expect(() => sandboxDefineTool(definition)).toThrow(message)
|
||||
})
|
||||
|
||||
it('bounds the preview of an invalid dynamic renderer return', () => {
|
||||
const definition = sandboxDefineTool({
|
||||
name: 'invalid-renderer',
|
||||
description: 'invalid renderer',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: () => ['x'.repeat(500)],
|
||||
},
|
||||
execute: async () => 'ok',
|
||||
})
|
||||
expect(() => definition.output.render({}, 'ok')).toThrow(/output\.render returned \["x+…/)
|
||||
})
|
||||
|
||||
it('mounts a listener plugin that observes real events, tagged-logging through to the host console', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected cordis_mount success')
|
||||
expect(result.value).toEqual({
|
||||
id: 'dyn-1',
|
||||
pluginName: 'change-logger',
|
||||
state: 'active',
|
||||
provides: [],
|
||||
waitingFor: [],
|
||||
})
|
||||
expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until unmounted or DSH restarts).')
|
||||
|
||||
// Fire a REAL tools/change by registering a tool; the mounted listener logs.
|
||||
ctx.tools.register(dummyTool('trigger_a'))
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tools changed')
|
||||
})
|
||||
|
||||
it('mounts a bare-function plugin as <anonymous>, and a named function under its name', async () => {
|
||||
const ctx = await setup()
|
||||
const anonymous = await call(ctx, 'cordis_mount', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' })
|
||||
expect(anonymous.isError).toBe(false)
|
||||
expect(text(anonymous)).toContain('plugin "<anonymous>"')
|
||||
const named = await call(ctx, 'cordis_mount', { code: 'return function watcher(ctx) {}' })
|
||||
expect(text(named)).toContain('plugin "watcher"')
|
||||
})
|
||||
|
||||
it('lets the agent give ITSELF a new tool, immediately callable through the registry', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
|
||||
expect(result.isError).toBe(false)
|
||||
|
||||
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text')
|
||||
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
|
||||
expect(reversed.isError).toBe(false)
|
||||
if (reversed.isError) throw new Error('expected dynamic tool success')
|
||||
expect(reversed.value).toBe('ssenrah')
|
||||
expect(text(reversed)).toBe('ssenrah')
|
||||
})
|
||||
|
||||
it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => {
|
||||
// VM-realm objects fail the session prototype-identity check; normalize them into host JSON.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
|
||||
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
|
||||
expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true)
|
||||
})
|
||||
|
||||
it('projects presentation metadata from a dynamic canonical value', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'meta-return',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'meta_tool',
|
||||
description: 'attaches a private presentation payload',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) { return [{ type: 'text', text: value }] },
|
||||
presentationMeta() { return { kind: 'demo' } },
|
||||
},
|
||||
async execute() {
|
||||
return 'ok'
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const result = await call(ctx, 'meta_tool', {})
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected dynamic tool success')
|
||||
expect(result.value).toBe('ok')
|
||||
expect(text(result)).toBe('ok')
|
||||
expect(result.meta).toEqual({ kind: 'demo' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a bare string', 'return \'ok\'', 'returned invalid output: "value" must be an array'],
|
||||
['an object whose content is a string', 'return { content: \'ok\' }', 'returned invalid output: "value" must be an array'],
|
||||
['an array of non-objects', 'return [\'ok\']', 'output.render returned ["ok"]'],
|
||||
['blocks missing the type tag', 'return [{ text: \'hi\' }]', 'output.render returned [{"text":"hi"}]'],
|
||||
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', 'returned invalid output: "value" must be an array'],
|
||||
['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'],
|
||||
])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'bad-return',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'bad_return_tool',
|
||||
description: 'returns a wrong shape',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { ${returnStatement} },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const result = await call(ctx, 'bad_return_tool', {})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toHaveLength(1)
|
||||
expect(result.content[0]!.type).toBe('text')
|
||||
expect(text(result)).toContain(diagnostic)
|
||||
})
|
||||
|
||||
it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'huge-return',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'huge_return_tool',
|
||||
description: 'returns a huge wrong shape',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return 'x'.repeat(500) },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const result = await call(ctx, 'huge_return_tool', {})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('returned invalid output')
|
||||
expect(text(result)).not.toContain('x'.repeat(200))
|
||||
})
|
||||
|
||||
it('accepts a JSON-Schema-style parameters wrapper and normalizes it to the DSL', async () => {
|
||||
// These common JSON-Schema spellings each have one DSL meaning, so normalize rather than
|
||||
// consume another model turn with a rejection.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'json-schema-tool',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'json_schema_tool',
|
||||
description: 'written in the JSON-Schema dialect',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
title: 'Raw parameters',
|
||||
default: { text: 'default' },
|
||||
examples: [{ text: 'example' }],
|
||||
properties: {
|
||||
text: { type: 'string', description: 'the text' },
|
||||
count: { type: 'integer', default: 1 },
|
||||
mode: { type: 'string', enum: ['fast', 'slow'] },
|
||||
extra: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
|
||||
// The registered schema is canonical JSON Schema derived from the DSL:
|
||||
// the required array survived, integer stayed integer, extra is optional.
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
|
||||
const parameters = schema.parameters as {
|
||||
properties: Record<string, { type: string; enum?: string[]; default?: unknown }>
|
||||
required?: string[]
|
||||
}
|
||||
expect(parameters.required).toEqual(['text'])
|
||||
expect(parameters).toMatchObject({
|
||||
title: 'Raw parameters',
|
||||
default: { text: 'default' },
|
||||
examples: [{ text: 'example' }],
|
||||
})
|
||||
expect(parameters.properties.count!.type).toBe('integer')
|
||||
expect(parameters.properties.count!.default).toBe(1)
|
||||
expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
|
||||
// Arg validation enforces the normalized spec: text required, extra not.
|
||||
expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true)
|
||||
expect(text(await call(ctx, 'json_schema_tool', { text: 'ok', count: 2 }))).toBe('ok:2')
|
||||
})
|
||||
|
||||
it('normalizes a nested object property carrying a JSON-Schema required array', async () => {
|
||||
// On an object PROPERTY, a JSON-Schema-style `required` array names the
|
||||
// required children — the nested unwrap converts it just like the top level.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'nested-json-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'nested_json_schema_tool',
|
||||
description: 'nested dialect',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
|
||||
},
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'nested_json_schema_tool')!
|
||||
const cfg = (schema.parameters as { properties: { cfg: { required?: string[] } } }).properties.cfg
|
||||
expect(cfg.required).toEqual(['label'])
|
||||
expect(text(await call(ctx, 'nested_json_schema_tool', { cfg: { label: 'hi' } }))).toBe('hi')
|
||||
})
|
||||
|
||||
it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'unified-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'unified_schema_tool',
|
||||
description: 'all unified nodes',
|
||||
parameters: {
|
||||
any: {
|
||||
type: 'json',
|
||||
title: 'Any JSON',
|
||||
default: { nested: [1, 'x', null] },
|
||||
examples: [{ ok: true }],
|
||||
},
|
||||
choice: {
|
||||
oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }],
|
||||
required: true,
|
||||
},
|
||||
flags: { type: 'array' },
|
||||
closed: { type: 'object', additionalProperties: false },
|
||||
count: { type: 'number', enum: [1, 2], const: 1 },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: String(args.choice) }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'unified_schema_tool')!
|
||||
expect(schema.parameters).toMatchObject({
|
||||
properties: {
|
||||
any: { title: 'Any JSON', default: { nested: [1, 'x', null] }, examples: [{ ok: true }] },
|
||||
choice: { oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }] },
|
||||
flags: { type: 'array' },
|
||||
closed: { type: 'object', additionalProperties: false },
|
||||
count: { type: 'number', enum: [1, 2], const: 1 },
|
||||
},
|
||||
required: ['choice'],
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => {
|
||||
const ctx = await setup()
|
||||
const depth = 5_000
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'deep-unified-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
let choice = { type: 'string' }
|
||||
let example = 'leaf'
|
||||
for (let index = 0; index < ${depth}; index++) {
|
||||
choice = { oneOf: [choice, { type: 'null' }] }
|
||||
example = [example]
|
||||
}
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'deep_unified_schema_tool',
|
||||
description: 'deep unified nodes',
|
||||
parameters: {
|
||||
choice: { ...choice, required: true },
|
||||
any: { type: 'json', default: example },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
|
||||
const parameters = ctx.tools.schemas().find(s => s.name === 'deep_unified_schema_tool')!.parameters as {
|
||||
properties: Record<string, Record<string, unknown>>
|
||||
}
|
||||
let choice = parameters.properties.choice!
|
||||
let choiceDepth = 0
|
||||
while (Array.isArray(choice.oneOf)) {
|
||||
choice = choice.oneOf[0] as Record<string, unknown>
|
||||
choiceDepth++
|
||||
}
|
||||
let example: unknown = parameters.properties.any!.default
|
||||
let exampleDepth = 0
|
||||
while (Array.isArray(example)) {
|
||||
example = example[0]
|
||||
exampleDepth++
|
||||
}
|
||||
expect({ choiceDepth, choice, exampleDepth, example }).toEqual({
|
||||
choiceDepth: depth,
|
||||
choice: { type: 'string' },
|
||||
exampleDepth: depth,
|
||||
example: 'leaf',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'raw-unified-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'raw_unified_schema_tool',
|
||||
description: 'raw unified nodes',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
any: { description: 'unconstrained' },
|
||||
cfg: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { label: { type: 'string' } },
|
||||
required: ['label'],
|
||||
},
|
||||
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
|
||||
},
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(ctx.tools.schemas().find(s => s.name === 'raw_unified_schema_tool')!.parameters).toMatchObject({
|
||||
properties: {
|
||||
any: {},
|
||||
cfg: { additionalProperties: false, required: ['label'] },
|
||||
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['parameters: 42', 'must be a ParameterSchemaSpec object'],
|
||||
['parameters: Object.defineProperty({}, \'text\', { value: { type: \'string\' } })', 'parameters must contain only own enumerable string keys'],
|
||||
['parameters: { text: 42 }', 'parameters.text must be a ParameterSchemaSpec property object'],
|
||||
['parameters: { text: Object.defineProperty({ type: \'string\' }, \'minimum\', { value: 1 }) }', 'parameters.text must contain only own enumerable string keys'],
|
||||
['parameters: { text: { type: \'string\', [Symbol(\'hidden\')]: true } }', 'parameters.text must contain only own enumerable string keys'],
|
||||
['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\' (got "str")'],
|
||||
['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be true when present'],
|
||||
['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is not supported by the unified schema DSL'],
|
||||
['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is not supported by the unified schema DSL'],
|
||||
['parameters: { text: { type: \'object\', properties: {} } }', 'parameters.text.additionalProperties must be explicitly true or false'],
|
||||
['parameters: { text: { type: \'object\', additionalProperties: \'no\' } }', 'parameters.text.additionalProperties must be explicitly true or false'],
|
||||
['parameters: { type: \'object\' }', 'parameters.properties must be an object of schemas'],
|
||||
['parameters: { type: \'object\', properties: {}, additionalProperties: false }', 'parameters.additionalProperties must be true or omitted'],
|
||||
['parameters: { type: \'object\', properties: {}, required: \'text\' }', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: { type: \'object\', properties: {}, required: undefined }', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: { type: \'object\', properties: {}, required: [42] }', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: (() => { const required = []; required.length = 1; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: (() => { const required = []; required.length = 1; required.extra = true; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: (() => { class Names extends Array { *[Symbol.iterator]() {} }; const required = new Names(); required[0] = \'text\'; required.length = 1; return { type: \'object\', properties: { text: { type: \'string\' } }, required } })()', 'parameters.required must be an array of declared property names'],
|
||||
['parameters: { type: \'object\', properties: {}, required: [\'text\'] }', 'parameters.required names undeclared property "text"'],
|
||||
['parameters: { type: \'object\', properties: { text: { type: \'string\', required: true } } }', 'parameters.text.required belongs to the containing raw object schema'],
|
||||
['parameters: { type: \'object\', properties: { text: { oneOf: \'bad\' } } }', 'parameters.text.oneOf must contain at least two schemas'],
|
||||
['parameters: { type: \'object\', properties: { text: { type: \'json\' } } }', 'parameters.text must declare a valid type'],
|
||||
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', additionalProperties: \'no\' } } }', 'parameters.cfg.additionalProperties must be a boolean'],
|
||||
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', properties: 42 } } }', 'parameters.cfg.properties must be an object of schemas'],
|
||||
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: [\'label\'] } } }', 'parameters.cfg.required names undeclared property "label"'],
|
||||
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: undefined } } }', 'parameters.cfg.required must be an array of declared property names'],
|
||||
['parameters: { value: { oneOf: \'bad\' } }', 'parameters.value.oneOf must contain at least two schemas'],
|
||||
['parameters: { value: { oneOf: new (class Branches extends Array {})({ type: \'string\' }, { type: \'null\' }) } }', 'parameters.value.oneOf must contain at least two schemas'],
|
||||
['parameters: { value: { oneOf: Object.assign([{ type: \'string\' }, { type: \'null\' }], { extra: true }) } }', 'parameters.value.oneOf must contain at least two schemas'],
|
||||
['parameters: { value: { type: \'string\', enum: \'bad\' } }', 'enum must be a non-empty array'],
|
||||
['parameters: { value: { type: \'string\', enum: new (class Values extends Array {})(\'a\', \'b\') } }', 'parameters.value.enum must be a non-empty array'],
|
||||
['parameters: { value: { type: \'json\', default: -0 } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: Infinity } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: () => 1 } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: (() => { const v = {}; v.self = v; return v })() } }', 'parameters.value.default.self must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: Array(2) } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: Object.assign([1], { extra: true }) } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: (() => { const v = Array(1); v.extra = true; return v })() } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: Object.defineProperty({}, \'hidden\', { value: true }) } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: { [Symbol(\'hidden\')]: true } } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: new (class DefaultValue { constructor() { this.ok = true } })() } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: new (class DefaultList extends Array {})() } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'],
|
||||
['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; Object.defineProperty(p, \'constructor\', { value: C }); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
|
||||
['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; const r = Proxy.revocable(C, {}); Object.defineProperty(p, \'constructor\', { value: r.proxy }); r.revoke(); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
|
||||
['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'],
|
||||
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'bad-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'bad_schema_tool',
|
||||
description: 'bad',
|
||||
${parameters},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
`
|
||||
const parameters = {}
|
||||
const item = { type: 'array' }
|
||||
item.items = item
|
||||
parameters.item = item
|
||||
`,
|
||||
'parameters.item.items is circular',
|
||||
],
|
||||
[
|
||||
`
|
||||
const parameters = {}
|
||||
const item = { type: 'object', additionalProperties: true, properties: parameters }
|
||||
parameters.item = item
|
||||
`,
|
||||
'parameters.item.properties is circular',
|
||||
],
|
||||
])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'circular-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
${declaration}
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'circular_schema_tool',
|
||||
description: 'circular',
|
||||
parameters,
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(message)
|
||||
})
|
||||
|
||||
it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'proto-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'proto_schema_tool',
|
||||
description: 'literal JSON keys',
|
||||
parameters: {
|
||||
['__proto__']: { type: 'string', required: true },
|
||||
value: { type: 'json', default: { ['__proto__']: { safe: true } } },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const parameters = ctx.tools.schemas().find(schema => schema.name === 'proto_schema_tool')!.parameters as {
|
||||
properties: Record<string, { default?: unknown }>
|
||||
required?: string[]
|
||||
}
|
||||
expect(Object.hasOwn(parameters.properties, '__proto__')).toBe(true)
|
||||
expect(parameters.required).toContain('__proto__')
|
||||
const defaultValue = parameters.properties.value!.default as Record<string, unknown>
|
||||
expect(Object.hasOwn(defaultValue, '__proto__')).toBe(true)
|
||||
expect(defaultValue.__proto__).toEqual({ safe: true })
|
||||
})
|
||||
|
||||
it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'nested-schema',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'nested_schema_tool',
|
||||
description: 'nested',
|
||||
parameters: {
|
||||
item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: args.item.label }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
const echoed = await call(ctx, 'nested_schema_tool', { item: { label: 'ok' }, tags: ['a'] })
|
||||
expect(text(echoed)).toBe('ok')
|
||||
})
|
||||
|
||||
it('rejects raw dynamic ctx.tools.register calls that bypass harness helpers', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'raw-register',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
ctx.tools.register({
|
||||
name: 'raw_dynamic_tool',
|
||||
description: 'raw',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('dynamic tool registration must use a tool returned by harness.defineTool')
|
||||
expect(ctx.tools.get('raw_dynamic_tool')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('guards the registry reached through ctx.get(\'tools\') identically', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'raw-register-get',
|
||||
apply(ctx) {
|
||||
ctx.get('tools').register({ name: 'raw_via_get', description: 'raw', parameters: {}, async execute() { return [] } })
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('dynamic tool registration must use a tool returned by harness.defineTool')
|
||||
expect(ctx.tools.get('raw_via_get')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes non-register registry members through the guard with correct binding', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'schema-reader',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_mount'))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'sees', 3, 'tools; mount is', 'object')
|
||||
})
|
||||
|
||||
it('keeps a plugin with unsatisfied inject mounted as pending and names what it waits for', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }',
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected pending cordis_mount success')
|
||||
expect(result.value).toEqual({
|
||||
id: 'dyn-1',
|
||||
pluginName: 'waiter',
|
||||
state: 'pending',
|
||||
provides: [],
|
||||
waitingFor: ['no-such-service'],
|
||||
})
|
||||
expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until unmounted or DSH restarts).')
|
||||
// Unmounting a pending mount works like any other.
|
||||
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(unmounted.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects code that throws, leaving nothing mounted', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('boom in sandbox')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => {
|
||||
const ctx = await setup()
|
||||
const primitive = await call(ctx, 'cordis_mount', { code: 'throw \'plain-string-throw\'' })
|
||||
expect(primitive.isError).toBe(true)
|
||||
expect(text(primitive)).toContain('plain-string-throw')
|
||||
const nullish = await call(ctx, 'cordis_mount', { code: 'throw null' })
|
||||
expect(nullish.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects code that does not return a plugin', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'return 42' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('must `return` a Plugin')
|
||||
})
|
||||
|
||||
it('answers a missing return with the two valid plugin forms', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'const plugin = (ctx) => {}' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('did you forget `return`?')
|
||||
})
|
||||
|
||||
it('disposes a plugin whose apply throws, and reports the error', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('apply exploded')
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'usurper',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'cordis_mount',
|
||||
description: 'dup',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('already registered')
|
||||
expect(text(result)).toContain('first cordis_unmount')
|
||||
// The original cordis_mount still dispatches — the failed fiber is gone.
|
||||
const retry = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
expect(retry.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
globalThis.__cordis_tool_leak = 'leaked'
|
||||
return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} }
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('plugin "probe-undefined-undefined"')
|
||||
expect((globalThis as Record<string, unknown>).__cordis_tool_leak).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['require(\'fs\')', 'require is not available in the temporary Plugin sandbox', 'inject: [\'fs\']'],
|
||||
['setTimeout(() => {}, 5)', 'setTimeout is not available in the temporary Plugin sandbox', 'ctx.setTimeout'],
|
||||
['fetch(\'https://example.com\')', 'fetch is not available in the temporary Plugin sandbox', 'ctx.web'],
|
||||
])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(trapMessage)
|
||||
expect(text(result)).toContain(redirect)
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'ticker',
|
||||
inject: ['timer'],
|
||||
apply(ctx) {
|
||||
ctx.setTimeout(() => console.log('tick'), 10)
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('is running')
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick')
|
||||
})
|
||||
|
||||
it('provides btoa/atob and the tagged console variants inside the sandbox', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
console.warn('warned')
|
||||
console.error('errored')
|
||||
const round = atob(btoa('hi'))
|
||||
const bytes = new TextEncoder().encode(round)
|
||||
return { name: 'codec-' + new TextDecoder().decode(bytes), apply(ctx) { console.log('applied', typeof ctx.on) } }
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('plugin "codec-hi"')
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'warned')
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'applied', 'function')
|
||||
expect(error).toHaveBeenCalledWith('[cordis:dyn-1]', 'errored')
|
||||
})
|
||||
|
||||
it('answers TypeScript syntax in the plain-JS sandbox with the fix', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'ts\' as const, apply(ctx) {} }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('plain JavaScript, not TypeScript')
|
||||
})
|
||||
|
||||
it('surfaces the offending line + caret and the bracket-balance hint on a syntax error', async () => {
|
||||
const ctx = await setup()
|
||||
// The canonical model mistake: closing the returned object with `});` as
|
||||
// if it were a callback argument. The word "as" in a STRING elsewhere must
|
||||
// not trigger the TypeScript hint — the heuristic reads the failing line.
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'const note = \'treat pattern as regex\'\nreturn {\n name: \'oops\',\n apply(ctx) {}\n});',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
const message = text(result)
|
||||
expect(message).toContain('failed to parse')
|
||||
expect(message).toContain('});')
|
||||
expect(message).toContain('^')
|
||||
expect(message).toContain('BODY of an async function')
|
||||
expect(message).not.toContain('TypeScript')
|
||||
})
|
||||
|
||||
it('syntaxErrorContext falls back to String(error) when the stack has no vm prelude', () => {
|
||||
const doctored = new SyntaxError('boom')
|
||||
delete (doctored as { stack?: string }).stack
|
||||
expect(syntaxErrorContext(doctored)).toBe('SyntaxError: boom')
|
||||
const plain = new SyntaxError('bang')
|
||||
plain.stack = 'not-a-vm-stack'
|
||||
expect(syntaxErrorContext(plain)).toBe('SyntaxError: bang')
|
||||
})
|
||||
|
||||
it('handles a runtime-thrown SyntaxError (no source-line prelude) with the generic hint', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'throw new SyntaxError(\'user-crafted\')' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('failed to parse')
|
||||
expect(text(result)).toContain('user-crafted')
|
||||
})
|
||||
|
||||
it('honors the configured vmTimeoutMs for the synchronous portion', async () => {
|
||||
const ctx = await setup({ vmTimeoutMs: 50 })
|
||||
const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toMatch(/timed? ?out/i)
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => {
|
||||
// The args a tool's execute receives are HOST-realm objects; without the dual-realm
|
||||
// Symbol.hasInstance prelude, `args.items instanceof Array` in sandbox code is silently
|
||||
// false.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'probe-instanceof',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'probe_instanceof',
|
||||
description: 'report instanceof checks across realms',
|
||||
parameters: { items: { type: 'array', required: true, items: { type: 'string' } } },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) {
|
||||
const checks = {
|
||||
hostArray: args.items instanceof Array,
|
||||
hostObject: args instanceof Object,
|
||||
vmArray: [] instanceof Array,
|
||||
vmObject: ({}) instanceof Object,
|
||||
}
|
||||
return [{ type: 'text', text: JSON.stringify(checks) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const probed = await call(ctx, 'probe_instanceof', { items: ['a'] })
|
||||
expect(probed.isError).toBe(false)
|
||||
expect(JSON.parse(text(probed))).toEqual({ hostArray: true, hostObject: true, vmArray: true, vmObject: true })
|
||||
// The host realm's constructors keep their default instanceof: no own
|
||||
// Symbol.hasInstance was added to them.
|
||||
expect(Object.getOwnPropertySymbols(Object)).not.toContain(Symbol.hasInstance)
|
||||
expect(Object.getOwnPropertySymbols(Array)).not.toContain(Symbol.hasInstance)
|
||||
})
|
||||
})
|
||||
49
packages/extensions/tool-cordis/tests/present.spec.ts
Normal file
49
packages/extensions/tool-cordis/tests/present.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts'
|
||||
import { setup } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Render-intent presenters: pure functions of the call args (no I/O, no
|
||||
* session state — they run on replay too), wired onto the registered tools.
|
||||
*/
|
||||
|
||||
describe('presenters', () => {
|
||||
it('cordis_inspect renders a generic read card titled with the section', () => {
|
||||
expect(presentInspectCall({})).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime' })
|
||||
expect(presentInspectCall({ what: 'api' })).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime: api' })
|
||||
expect(presentInspectCall({ what: 'events', name: 'tools/change' })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: 'Inspect cordis runtime: events: tools/change',
|
||||
})
|
||||
})
|
||||
|
||||
it('cordis_mount renders a generic execute card carrying the code as raw input', () => {
|
||||
expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'execute',
|
||||
title: 'Mount temporary Cordis Plugin',
|
||||
rawInput: { code: 'return (ctx) => {}' },
|
||||
})
|
||||
})
|
||||
|
||||
it('cordis_unmount renders a generic delete card titled with the id', () => {
|
||||
expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount temporary Cordis Plugin dyn-1' })
|
||||
})
|
||||
|
||||
it('is wired onto the registered definitions through the defineTool soft-validation path', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'tools' })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: 'Inspect cordis runtime: tools',
|
||||
})
|
||||
expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'api', name: 'tools' })).toMatchObject({
|
||||
title: 'Inspect cordis runtime: api: tools',
|
||||
})
|
||||
expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
|
||||
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount temporary Cordis Plugin dyn-2' })
|
||||
// Soft validation: presenter args that fail the schema render as no card, never a throw.
|
||||
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
289
packages/extensions/tool-cordis/tests/sandbox-context.spec.ts
Normal file
289
packages/extensions/tool-cordis/tests/sandbox-context.spec.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The sandbox context façade is a whitelist, not a pass-through proxy. Mounted code reaches only
|
||||
* registration/eventing verbs, timer helpers, guarded tools, and injected services. Framework
|
||||
* members that expose an unguarded context are denied because they could bypass marker checks and
|
||||
* host-realm normalization; these tests pin that escape class.
|
||||
*/
|
||||
|
||||
/** Mount a plugin whose `apply` touches one framework member, and report the error text. */
|
||||
async function mountTouching(ctx: Awaited<ReturnType<typeof setup>>, expr: string): Promise<string> {
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
return text(result)
|
||||
}
|
||||
|
||||
describe('sandbox context façade — escape surface is closed', () => {
|
||||
it.each([
|
||||
['ctx.root', 'const c = ctx.root'],
|
||||
['ctx.parent', 'const c = ctx.parent'],
|
||||
['ctx.scope', 'const c = ctx.scope'],
|
||||
['ctx.fiber', 'const f = ctx.fiber'],
|
||||
['ctx.reflect', 'const r = ctx.reflect'],
|
||||
['ctx.registry', 'const r = ctx.registry'],
|
||||
['ctx.events', 'const e = ctx.events'],
|
||||
['ctx.extend()', 'ctx.extend({})'],
|
||||
['ctx.isolate()', 'ctx.isolate("x")'],
|
||||
['ctx.intercept()', 'ctx.intercept("x", {})'],
|
||||
['ctx.plugin()', 'ctx.plugin({ apply() {} })'],
|
||||
['ctx.set()', 'ctx.set("tools", 1)'],
|
||||
['ctx.mixin()', 'ctx.mixin("x", [])'],
|
||||
])('denies %s with a teaching error', async (_label, expr) => {
|
||||
const ctx = await setup()
|
||||
const message = await mountTouching(ctx, expr)
|
||||
expect(message).toContain('sandbox ctx does not expose')
|
||||
expect(message).toContain('withheld by design')
|
||||
})
|
||||
|
||||
it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'root-bypass',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
ctx.root.tools.register({
|
||||
name: 'smuggled',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('sandbox ctx does not expose "root"')
|
||||
// The whole point: the bypass never reaches the registry.
|
||||
expect(ctx.tools.get('smuggled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects assignment to the façade rather than silently dropping it', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('sandbox ctx is read-only')
|
||||
})
|
||||
|
||||
it('denies a service whose method returns a Context (the .ctx escape), registering nothing', async () => {
|
||||
// A cordis Service instance carries `.ctx` (a real Context), so
|
||||
// `ctx.systemPrompt.ctx.root.tools.register(…)` would escape the façade; service-return
|
||||
// guards reject that Context before the registration lands.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'svc-ctx-escape',
|
||||
inject: ['systemPrompt', 'tools'],
|
||||
apply(ctx) {
|
||||
ctx.systemPrompt.ctx.root.tools.register({
|
||||
name: 'smuggled_via_service',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('returned a cordis Context, which the sandbox does not expose')
|
||||
expect(ctx.tools.get('smuggled_via_service')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('guards an async injected-service method: a host-realm Promise resolves through the guard', async () => {
|
||||
// The return guard's Promise arm only fires for a HOST-realm Promise (a vm-realm one is not
|
||||
// `instanceof` the host `Promise`).
|
||||
const ctx = await setup()
|
||||
ctx.plugin({
|
||||
name: 'host-async-svc',
|
||||
apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) },
|
||||
})
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'async-consumer',
|
||||
inject: ['hostAsync', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'do_fetch',
|
||||
description: 'awaits the host async service',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
const value = await ctx.hostAsync.grab()
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const result = await call(ctx, 'do_fetch', {})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('host-fetched')
|
||||
})
|
||||
|
||||
it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'introspector',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
const sym = ctx[Symbol.iterator]
|
||||
console.log('probe', sym === undefined, 'tools' in ctx, 'on' in ctx, 'root' in ctx)
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox context façade — inject gate on services', () => {
|
||||
it('denies an undeclared live service (property access), naming the inject fix', async () => {
|
||||
// `systemPrompt` is a live global service in the setup harness, but this
|
||||
// mount does not declare it — reaching it would let the mount depend on a
|
||||
// provider cordis does not know about, so it is refused.
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'undeclared\', inject: [\'tools\'], apply(ctx) { const s = ctx.systemPrompt } }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('service "systemPrompt" is not injected')
|
||||
expect(text(result)).toContain('inject: [\'systemPrompt\', …]')
|
||||
})
|
||||
|
||||
it('denies an undeclared live service reached through ctx.get too', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'undeclared-get\', inject: [\'tools\'], apply(ctx) { ctx.get(\'systemPrompt\') } }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('service "systemPrompt" is not injected')
|
||||
})
|
||||
|
||||
it('allows a service the mount DID declare in inject', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'declared',
|
||||
inject: ['systemPrompt', 'tools'],
|
||||
apply(ctx) { console.log('has systemPrompt:', typeof ctx.systemPrompt) }
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('is running')
|
||||
})
|
||||
|
||||
it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => {
|
||||
// Without declared inject, Cordis cannot park the consumer when its provider unmounts. The
|
||||
// façade refuses access up front instead of leaving a zombie tool.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }',
|
||||
})
|
||||
const undeclared = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'sloppy-consumer',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'greet_undeclared',
|
||||
description: 'uses greeter without declaring it',
|
||||
parameters: { n: { type: 'string', required: true } },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
// The tool registers (its execute is lazy), but calling it hits the gate:
|
||||
// `ctx.greeter` is undeclared, so it fails with the teaching error rather
|
||||
// than silently working and later stranding.
|
||||
expect(undeclared.isError).toBe(false)
|
||||
const called = await call(ctx, 'greet_undeclared', { n: 'x' })
|
||||
expect(called.isError).toBe(true)
|
||||
expect(text(called)).toContain('service "greeter" is not injected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox tools façade — get is a read-only schema view', () => {
|
||||
it('ctx.tools.get returns a schema, not the live ToolDefinition with execute', async () => {
|
||||
// The finding: returning the raw ToolDefinition hands mount code the tool's execute
|
||||
// function, letting it bypass ToolRuntime.execute (and its pre/post hooks). get now
|
||||
// returns the same name/description/parameters view as schemas(), with no execute.
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'reporter',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'report_view',
|
||||
description: 'reports the shape of a tool view',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
const view = ctx.tools.get('cordis_mount')
|
||||
return [{ type: 'text', text: JSON.stringify({
|
||||
hasExecute: 'execute' in view,
|
||||
hasPresentCall: 'presentCall' in view,
|
||||
name: view.name,
|
||||
keys: Object.keys(view).sort(),
|
||||
}) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const reported = await call(ctx, 'report_view', {})
|
||||
expect(reported.isError).toBe(false)
|
||||
const shape = JSON.parse(text(reported)) as { hasExecute: boolean; hasPresentCall: boolean; name: string; keys: string[] }
|
||||
expect(shape.hasExecute).toBe(false)
|
||||
expect(shape.hasPresentCall).toBe(false)
|
||||
expect(shape.name).toBe('cordis_mount')
|
||||
expect(shape.keys).toEqual(['description', 'name', 'parameters'])
|
||||
})
|
||||
|
||||
it('ctx.tools.get returns undefined for an unknown tool', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'unknown-probe',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'probe_unknown',
|
||||
description: 'reports whether an unknown tool resolves',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(text(await call(ctx, 'probe_unknown', {}))).toBe('true')
|
||||
})
|
||||
})
|
||||
51
packages/extensions/tool-cordis/tests/tool-cordis.spec.ts
Normal file
51
packages/extensions/tool-cordis/tests/tool-cordis.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import * as tool from '../src/index.ts'
|
||||
import { setup } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Export shape and registration API: the namespace-plugin contract the
|
||||
* real Loader path depends on, the registered tool set, and the Config
|
||||
* validator's defaults and rejections.
|
||||
*/
|
||||
|
||||
describe('export shape', () => {
|
||||
it('has no default export, and survives the real Loader unwrapExports', () => {
|
||||
// A stray `export default` would make `unwrapExports` (`exports.default ??
|
||||
// exports`) collapse the module to the bare function and DROP `inject`,
|
||||
// crashing at real load (docs/postmortem/0001). Assert directly AND through
|
||||
// the real unwrap so adding `export default apply` fails here.
|
||||
expect('default' in tool).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tool)
|
||||
expect(unwrapped.name).toBe('tool-cordis')
|
||||
expect(unwrapped.inject).toEqual(['tools'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
expect(typeof unwrapped.Config).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('registers the three cordis tools with the documented schemas', async () => {
|
||||
const ctx = await setup()
|
||||
const names = ctx.tools.schemas().map(schema => schema.name)
|
||||
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
|
||||
expect(names).not.toEqual(expect.arrayContaining(['cordis_try', 'cordis_stop']))
|
||||
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
|
||||
const props = (inspect.parameters as { properties: Record<string, { enum?: string[]; type?: string }> }).properties
|
||||
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events'])
|
||||
expect(props.name?.type).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Config', () => {
|
||||
it('defaults vmTimeoutMs to 5000', () => {
|
||||
expect(new tool.Config()).toEqual({ vmTimeoutMs: 5000 })
|
||||
})
|
||||
|
||||
it('rejects a non-positive vmTimeoutMs at validation time (misconfiguration fails loud)', () => {
|
||||
expect(() => new tool.Config({ vmTimeoutMs: 0 })).toThrow()
|
||||
expect(() => new tool.Config({ vmTimeoutMs: -1 })).toThrow()
|
||||
})
|
||||
})
|
||||
84
packages/extensions/tool-cordis/tests/unmount-hmr.spec.ts
Normal file
84
packages/extensions/tool-cordis/tests/unmount-hmr.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import * as tool from '../src/index.ts'
|
||||
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Disposal semantics: `cordis_unmount` reaches quiescence before returning,
|
||||
* and disposing the tool-cordis fiber itself (the HMR path) cascades over the
|
||||
* whole dynamic subtree through the ordinary parent→child fiber lifecycle.
|
||||
*/
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('cordis_unmount', () => {
|
||||
it('disposes the mount and its registrations have stopped by the time it returns (quiescence)', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
|
||||
ctx.tools.register(dummyTool('trigger_before'))
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
|
||||
const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected cordis_unmount success')
|
||||
expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' })
|
||||
expect(text(result)).toBe('Temporary Plugin dyn-1 was unmounted and removed.')
|
||||
|
||||
// Immediately after the awaited unmount, the listener must be gone — no
|
||||
// grace period, no eventual consistency.
|
||||
ctx.tools.register(dummyTool('trigger_after'))
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
|
||||
})
|
||||
|
||||
it('unregisters a self-made tool on unmount', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
|
||||
expect(ctx.tools.get('reverse_text')).toBeDefined()
|
||||
|
||||
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(ctx.tools.get('reverse_text')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an unknown id, and a second unmount of the same id', async () => {
|
||||
const ctx = await setup()
|
||||
const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' })
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(text(unknown)).toContain('no temporary Plugin with id "dyn-99"')
|
||||
|
||||
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
const again = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(again.isError).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR safety', () => {
|
||||
it('disposing the tool-cordis fiber cascades over the dynamic subtree and its registrations', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
const fiber = await ctx.plugin(tool)
|
||||
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
|
||||
expect(ctx.tools.get('reverse_text')).toBeDefined()
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
// The whole subtree is gone: the self-made tool, the cordis tools, and the
|
||||
// mounted listener (no log on a fresh tools/change).
|
||||
expect(ctx.tools.get('reverse_text')).toBeUndefined()
|
||||
expect(ctx.tools.get('cordis_mount')).toBeUndefined()
|
||||
const calls = log.mock.calls.length
|
||||
ctx.tools.register(dummyTool('trigger_post_dispose'))
|
||||
expect(log).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
})
|
||||
33
packages/extensions/tool-cordis/tsconfig.json
Normal file
33
packages/extensions/tool-cordis/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user