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

This commit is contained in:
imccyu
2026-08-12 23:51:31 +08:00
parent 0367506471
commit 4064198560
147 changed files with 20904 additions and 2412 deletions

View File

@@ -0,0 +1,68 @@
# Agent Note: Cordis Host/Client Dynamic Plugin Runtime
Status: proposed
English | [中文](2026-08-08-cordis-web-dynamic-packages.zh.md)
## Problem
The model needs to extend the current DSH process temporarily without modifying repository source, rebuilding the application, or refreshing the browser. An extension may run in the Host Node.js process, in a Client browser page, or as one plugin whose Host half retrieves data and whose Client half presents it.
This capability cannot be limited to “execute some code.” Before writing code, the model needs to discover the Services, Events, Builtins, Slots, and theme tokens available on both platforms. The user needs to preview the code before deciding whether Client code may enter the page. A single plugin needs immutable versions, retries after failure, and rollback. Asynchronous runtime errors need to return to the model instead of remaining only in server logs or the browser console.
Combining definition, approval, execution, version switching, capability discovery, and UI state into one action creates states that cannot be explained consistently: whether a successful definition also means a successful run; which version remains successful after a failed update; how long a Tool should wait when no page responds; which historical card owns the business UI after the same Package runs multiple times; and whether page-local Client load state can represent process-wide Host state.
## Proposal
### Core principles
- The Host is the sole process-wide authority for Plugins, Packages, Runs, approvals, and version pointers.
- The Client stores only the current page's approval interaction, load results, Slot contributions, business views, and page-local errors.
- Define creates only immutable code versions; Run activates only a defined version.
- A version switch commits `currentPackageId` only after the target Package completes its required Host/Client activation.
- Before writing code, the model queries capabilities through Inspect Providers. Inspect results assist coding and are not plugin runtime business data.
- Dynamic Host and Client code both use restricted plain JavaScript contexts and attach reversible side effects to the Cordis lifecycle.
- Client code requires user authorization before entering a page. Authorization may cover one Package or future versions of the same Plugin.
- Tool calls do not wait for approval or browser operations that may occur only after the current turn ends. State stores and model steering report asynchronous outcomes.
### Package responsibilities and dependency direction
Four packages under `packages/self-modification/` implement the dynamic runtime:
| Package | npm package | Responsibility |
| --- | --- | --- |
| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | Registers the System Prompt, seven model-facing Tools, Host Inspect Providers, `@pluginId` context injection, and Tool presentation metadata |
| `cordis-host-runner` | `@deepseek-ai/dsh-cordis-host-runner` | Stores the authoritative Registry, allocates IDs, executes Host code, and manages versions, approvals, Runs, private handlers, Inspect routing, and model feedback |
| `cordis-client-runner` | `@deepseek-ai/dsh-cordis-client-runner` | Synchronizes Inspect manifests in the browser, orchestrates approved Host→Client activation, evaluates Client code, and manages the Guard, Loader/Fiber, timer, styles, and teardown |
| `ui-cordis` | `@deepseek-ai/dsh-client-ui-cordis` | Renders Define/Run Tool cards, the global Cordis panel, approval controls, version selection, runtime status, and Package-specific business views |
`tool-cordis` depends only on the Host Runner's in-process service and does not import the Client implementation. `ui-cordis` consumes only the Client Runner face and Client-safe wire types and does not import the Host implementation. Existing generated Remote APIs and forwarded events connect Host and Client runtime control; the gateway owns no dynamic Plugin domain logic.
### Domain objects
#### Plugin
A Plugin is a dynamic plugin instance that can be modified over time. It is identified by the branded type `CordisDynamicPluginId`, for example `clock-1`. When creating a Plugin, the model submits only a semantic prefix of 3 to 6 lowercase English letters; the Host appends a process-unique numeric suffix. The model cannot specify the complete `pluginId`.
A Plugin belongs to the Session that defined it. Model-facing Tools can read and operate only Plugins from the current Session. The global Client panel can list Plugins from all Sessions, but each action still executes under the owner Session carried by that row.
#### Package
A Package is an immutable code version under a Plugin. It is identified by `CordisDynamicPackageId`, for example `pkg-2`. It contains a name, a purpose, optional Host code, and optional Client code, with at least one code half present. Every `cordis_define` creates a new Package; an existing Package cannot be modified in place.
One Plugin may own multiple Packages, but at most one physical Run may exist at a time. Whether a Package contains a Host or Client half affects only its activation steps, not its version identity.
#### Plugin Run
A Plugin Run is one concrete activation attempt. It is identified by `CordisDynamicPluginRunId`, for example `run-3`. Every new activation attempt receives a new ID, including an attempt that fails after approval, a retry of the same Package, and a version update. `pluginRunId` associates approval, Host activation, Client loading, private RPC, Tool cards, and errors with the same attempt.
The Host stores the current physical Run separately from `latestRun`. The physical Run is the activation that can currently receive calls and be torn down. `latestRun` records the approval, phase, status of both halves, and diagnostics for the most recent attempt. A failed attempt may leave no live physical Run while remaining available for inspection.
#### Version pointers
- `currentPackageId` is the most recent Package to complete its required activation flow. Stopping the plugin, beginning an update, or failing an update does not clear it.
- `nextPackageId` is the target Package that is awaiting approval, activating, awaiting a Client, or most recently failed. It is cleared after the target succeeds and is committed as current.
A Host-only Package commits current after the Host successfully establishes its Fiber. A Client-bearing Package commits current after Host activation succeeds and at least one Client successfully establishes the corresponding load. A Fiber that Cordis parks as waiting because a hard dependency is absent is still a successfully established lifecycle object; it is not equivalent to a parse or `apply` failure.
If an update target fails, the old physical Run is not restarted automatically. The previous `currentPackageId` continues to identify the last successful version, and the failed target remains `nextPackageId`. The user or model can retry next, or reactivate current with `mode: "run"` to roll back.

View File

@@ -0,0 +1,270 @@
# Agent Note: Cordis Host/Client 动态插件运行体系
Status: proposed
[English](2026-08-08-cordis-web-dynamic-packages.md) | 中文
## Problem
模型需要在不修改仓库源码、不重新构建应用、不刷新浏览器的前提下,临时扩展当前 DSH 进程。扩展既可能运行在 Host 的 Node.js 进程,也可能运行在 Client 浏览器页面,还可能由 Host 取数、Client 展示,共同组成一个插件。
这项能力不能只是“执行一段代码”。模型需要在写代码前发现两端允许使用的 Service、Event、Builtin、Slot 和主题 token用户需要先预览代码再决定是否允许 Client 代码进入页面;同一个插件需要追加不可变版本、失败后重试或回退;运行后的异步错误需要回到模型,而不是只留在服务端日志或浏览器控制台。
如果把定义、审批、运行、版本切换、能力发现和 UI 状态塞进一个动作,会产生无法稳定解释的状态:定义成功是否等于运行成功,升级失败后哪个版本仍是成功版本,页面没有响应时 Tool 应等待多久,同一个 Package 多次运行时哪张历史卡片承载业务 UI以及 Client 页面局部装载状态是否能代表 Host 的进程级状态。
## Proposal
### 核心原则
- Host 保存 Plugin、Package、Run、审批和版本指针的唯一进程级权威状态。
- Client 只保存当前页面的审批交互、装载结果、Slot 贡献、业务视图和页面局部错误。
- Define 只创建不可变代码版本Run 只激活一个已定义版本。
- 版本切换只有在目标 Package 完成要求的 Host/Client 激活后才提交 `currentPackageId`
- 模型写代码前通过 Inspect Provider 查询能力Inspect 结果只辅助编码,不作为插件运行时业务数据。
- Host 与 Client 动态代码都使用受限的 plain JavaScript 上下文,并把可撤销副作用挂到 Cordis 生命周期。
- Client 代码进入页面前需要用户授权;授权范围可以是单个 Package也可以是同一 Plugin 的后续版本。
- Tool 调用不等待当前轮结束后才可能发生的审批或浏览器操作;异步结局通过状态存储和模型 steering 反馈。
### 包职责与依赖方向
动态运行体系由 `packages/self-modification/` 下四个包组成:
| 包 | npm 包名 | 职责 |
| --- | --- | --- |
| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | 注册 System Prompt、七个模型 Tool、Host Inspect Provider、`@pluginId` 上下文注入和 Tool 展示元数据 |
| `cordis-host-runner` | `@deepseek-ai/dsh-cordis-host-runner` | 保存权威 Registry分配 ID执行 Host 代码管理版本、审批、Run、私有 handler、Inspect 路由和模型反馈 |
| `cordis-client-runner` | `@deepseek-ai/dsh-cordis-client-runner` | 在浏览器同步 Inspect manifest编排审批后的 Host→Client 激活,求值 Client 代码,管理 Guard、Loader/Fiber、timer、样式和 teardown |
| `ui-cordis` | `@deepseek-ai/dsh-client-ui-cordis` | 展示 Define/Run Tool 卡片、全局 Cordis 面板、审批控件、版本选择、运行状态和 Package 自定义业务视图 |
`tool-cordis` 只依赖 Host Runner 的进程内服务,不导入 Client 实现。`ui-cordis` 只消费 Client Runner face 和 Client-safe wire 类型,不导入 Host 实现。Host 与 Client 的运行控制通过已有生成 Remote 面和转发事件连接,网关不拥有动态 Plugin 的领域逻辑。
### 领域对象
#### Plugin
Plugin 是可持续修改的动态插件实例,由品牌类型 `CordisDynamicPluginId` 标识,例如 `clock-1`。新建 Plugin 时,模型只提交 3 至 6 位小写英文语义前缀Host 添加进程内唯一数字后缀。完整 `pluginId` 不能由模型指定。
Plugin 属于定义它的 Session。模型 Tool 只能读取和操作当前 Session 的 Plugin全局 Client 面板可以列出所有 Session 的 Plugin但每个动作仍使用该行携带的 owner Session 执行。
#### Package
Package 是 Plugin 下的不可变代码版本,由 `CordisDynamicPackageId` 标识,例如 `pkg-2`。它包含名称、用途、可选 Host 代码和可选 Client 代码,且至少包含一侧。每次 `cordis_define` 都创建新 Package已有 Package 不允许原地修改。
同一个 Plugin 可以拥有多个 Package但同一时刻最多只有一个物理 Run。Package 是否含 Host 或 Client 半只决定激活步骤,不改变版本身份。
#### Plugin Run
Plugin Run 是一次具体激活尝试,由 `CordisDynamicPluginRunId` 标识,例如 `run-3`。每次新的激活尝试都会分配新 ID包括审批后失败、重试同一 Package 和版本更新。`pluginRunId` 把审批、Host 激活、Client 装载、私有 RPC、Tool 卡片和错误关联到同一次尝试。
Host 分开保存当前物理 Run 与 `latestRun`。物理 Run 表示此刻仍可调用和撤销的激活;`latestRun` 表示最近一次尝试的审批、阶段、两侧状态和诊断。一次失败可以没有存活的物理 Run但仍留下可查询的 attempt。
#### 版本指针
- `currentPackageId` 是最近一次完成要求的激活流程的 Package。停止插件、开始更新或更新失败都不清除它。
- `nextPackageId` 是正在等待审批、正在激活、等待 Client、或最近失败的目标 Package。目标成功提交为 current 后清除。
Host-only Package 在 Host 成功建立 Fiber 后提交 current。包含 Client 的 Package 在 Host 激活成功且至少一个 Client 成功建立对应装载后提交 current。因硬依赖缺失而被 Cordis park 为 waiting 的 Fiber仍是成功建立的生命周期对象不等同于解析或 `apply` 失败。
更新目标失败时不自动重启旧物理 Run。旧 `currentPackageId` 继续表示最后成功版本,失败目标保留为 `nextPackageId`。用户或模型可以重试 next也可以以 `mode: "run"` 重新激活 current 完成回退。
### Host 权威状态与持久性
`DynamicCordisRunnerService` 及其内部 Registry 是当前 DSH 进程内的唯一权威,保存:
- Plugin 的 Session 归属和不可变 Package 集合;
- `currentPackageId``nextPackageId`、物理 Run 和 `latestRun`
- 单 Package 授权与 Plugin 跨版本授权;
- 待处理的 Client 激活请求;
- Host Fiber、Package 私有 handler、等待中的 Service 和最近诊断;
- Host 与 Client Inspect Registry 的目录和查询路由。
这些对象不写入配置或磁盘也不在进程重启后恢复。Session Log 可以保留 Tool 调用、结果和卡片所需元数据,但不会重放动态代码来恢复 Registry。进程重启后历史卡片仍可作为对话记录存在`pluginId``packageId` 不再可运行。
运行态不作为可恢复状态写入 Session projection。页面刷新或新页面打开不会自动恢复 Client 半;自动恢复会重新引入连接身份、启动期 baseline 和跨页面一致性协议,不属于当前设计。
### Define、Run 与版本切换
`cordis_define` 有两种模式:新建 Plugin 时提交 `idPrefix`;修改现有 Plugin 时提交精确 `pluginId`。代码统一为 `code: { host?, client? }`。Define 只校验参数和 plain JavaScript 语法,记录不可变源码并返回最终 ID。它不执行 `apply`、不产生审批、不改变版本指针,也不隐式运行。
不提供独立 `cordis_update``cordis_run` 通过 `mode` 表达激活意图:
| 版本关系 | `mode` |
| --- | --- |
| 尚无 `currentPackageId` | `run` |
| 目标等于 current包括重启、重试或回退 | `run` |
| 目标与已有 current 不同 | `update` |
| 更新失败后重试 `nextPackageId` | `update` |
Run 先验证 Plugin/Package 归属、版本关系和是否已有转换在进行,再创建 `pluginRunId`、写入 `latestRun``nextPackageId`
Host-only Package 在 Tool 调用内完成 Host 激活,并同步返回 `running` 或失败。包含 Client 的 Package不在 Tool 调用内等待浏览器终局:未授权时登记审批并返回 `awaiting-approval`;已授权时登记自动 Client 激活并返回 `starting`。这两种返回都表示请求已建立,不表示完整激活成功。
目标真正开始激活时Host 先停止旧物理 Run再执行目标 Host 半。Host 成功后才允许 Client 获取精确 `pluginRunId` 对应的源码并装载。Client 成功后 Host 提交版本指针;任何阶段失败都记录到该 attempt不把旧版本重新启动伪装成目标成功。
`cordis_stop` 撤销当前 Host/Client Run 及待审批请求,但保留 Plugin、Package、授权和版本指针。`cordis_undefine` 先停止,再删除 Plugin、Package、授权和版本指针删除后历史卡片只显示“插件已移除”。
### Client 审批与授权
包含 Client 代码的 Package 在第一次激活前需要用户授权,因为它将在用户页面中运行模型生成的代码。审批面板提供三个动作:
- 单勾允许当前 Package同一 Package 后续重跑不再审批,新 Package 仍需审批。
- 双勾允许当前 Plugin 的后续版本;新 Package、更新、重试和回退不再逐版本审批。
- 拒绝结束当前请求,不执行 Host 或 Client 代码;模型不得在用户没有新要求时立即重复申请。
授权在用户允许时写入 Host Registry即使随后发生技术失败也保留。面板直接运行 Package 时,用户点击本身授权该 Package。
待审批行只显示单次允许、跨版本允许和拒绝,不同时提供运行、停止或删除。发现新审批时面板自动展开;自动展开失败或被收起时,固定入口和行状态仍显示待审批数量与状态。
### Client 激活编排
Host 通过 `cordis/request-run` 发送 Client 激活请求。请求只包含请求身份、Session、Plugin、Package、mode、名称、用途和是否需要审批不广播源码。
获得授权的页面按固定顺序执行:
1. 调用 `runHostHalf`,启动目标 Host 半或绑定同一次 attempt 已启动的 Host Run。
2. Host 成功后,以 `pluginId + pluginRunId` 调用 `getClientCode`,只取得当前精确 Run 的 Client 源码。
3. Client Runner 在页面求值插件,建立 Loader entry/Fiber安装 Guard、样式、Slot 和页面局部状态。
4. 页面调用 `resolveRequestRun``settleUserRun` 回报成功、waiting 或失败。
5. Host 接受仍有效的精确 Run 回报,提交 current 或保存诊断,并广播请求结束,其他页面清理活动。
Host 激活先于 Client避免 Client 在所需 Host handler 尚未存在时启动。只有本次请求实际创建的 Host Run 才能因本页 Client 失败而撤销;只是绑定既有 Run 的页面没有其所有权。
Client Orchestrator 按 `pluginId` 保存待审批和正在编排的活动,同一个 Plugin 不并发执行两次页面激活。Host inventory 可重建遗漏的待审批项和无需审批的自动激活请求。
Client 装载状态是页面局部事实。Host active 不代表当前页面已装载 Client 半。UI 使用三种主要状态:无物理 Run为灰色“待激活”Host 已运行但当前页面 Client 未成功装载为黄色“Client 待激活”,当前页面两侧可用为绿色“运行中”。审批中和失败作为额外状态显示。
当前版本不建立 per-connection 身份或多页面法定人数。第一个仍有效的 Client 成功回报可以提交进程级 current其他页面是否装载由各自页面 store 表示。
### Package 私有 Client→Host 通信
动态 Package 通过私有 JSON 通道从 Client 调用 HostHost 使用 `harness.handle(method, handler)` 注册当前 Run 的方法Client 使用 `host.call(method, args)` 调用。每次调用关联 `pluginId + pluginRunId`Host 拒绝已停止或过期 Run。参数和返回值必须是无损 JSON不允许函数、React 元素、Context、Service 实例或类对象。
该通道只服务同一 Package 的 Client→Host 调用,不使用公开 Remote Service 或动态代码中的 `ctx.remote`。公开 Remote 面只承载 Runner 自己的控制协议,不向动态 Package 暴露。
### 动态代码、Guard 与生命周期
Host 和 Client 都只执行 plain JavaScript 函数体,不经过 TypeScript、JSX 或 bundler 转译。Host 运行在 `node:vm`Client 在受限闭包中求值。两端上下文用于减少误用并提供教学错误,不是恶意代码安全边界。
模型默认通过 `ctx.get('serviceName')` 读取可选 Service 并判断 `undefined`。只有 Service 是硬依赖、缺失时 Package 必须 waiting 并在 Service 出现后重新激活时,才在插件对象声明 `inject`。直接访问 `ctx.serviceName` 只在同一插件声明对应 inject 时允许。
Host 与 Client 的 `timer` 都是同名 Cordis Service使用一致接口不是全局 Builtin。需要 timer 的插件必须声明 `inject: ['timer']`React effect 中创建的 timer 把 disposer 作为 cleanup 返回。
所有注册和可撤销副作用由当前 Fiber 拥有。Event listener、Service、Tool、handler、timer、Slot、样式和主题覆盖通过 `ctx.effect()``ctx.on()` 或返回 disposer 的官方 API 注册。停止、更新、失败回滚或 undefine 时撤销两端贡献。Theme override 必须按 source 分层并返回 disposer使卸载后恢复此前主题值。
宿主、DSH、Cordis 及其 Service、Event payload、Slot props、Session/Conversation Snapshot、Tool 状态和其他运行时对象是内部 live data。动态代码不得对这些对象或其子对象执行 `JSON.stringify``structuredClone`、递归枚举、全量复制或整体展示;只能读取当前任务所需叶子字段,构造不含宿主引用的最小自有数据。
### Inspect Provider 与 Catalog
能力发现分为三个 Tool`cordis_inspect_list` 列 Host/Client Provider manifest`cordis_inspect_query` 执行指定平台的显式只读查询;`cordis_inspect_self` 查询当前 Session 的 Plugin、Package、源码、版本指针和运行诊断。
Host 和 Client 各自拥有 `CordisInspectRegistry`。Provider 注册平台内唯一 ID、说明、method、输入 schema 和输出 schema。Provider method 是显式白名单查询,不是任意 Service 方法透传Registry 不维护分层 target也不自动把业务 Service 方法变成可执行 Inspect method。
首批 Provider 为:
| Platform | Provider.method | 数据来源 |
| --- | --- | --- |
| Host / Client | `Service.listService` | 各平台 Service 静态 Catalog |
| Host / Client | `Event.listEvents` | 各平台 Event 静态 Catalog |
| Host / Client | `Builtin.listBuiltins` | evaluator/Guard 附近的手工定义 |
| Host | `Tool.listTools` | 当前 Agent 真正可见的 Tool Registry |
| Client | `Slots.listSubTree` | Slot 静态 Catalog与页面 live subtree/occupants |
| Client | `Theme.listTokens` | ThemeService 的只读 inspect export |
Client Registry 变化后向 Host 同步完整 manifest不按 Session 保存重复目录。Host query 本地执行Client query 由 Host 广播 request ID页面调用本地 Provider 后回送。Host 只接受第一个通过输出 schema 校验的成功结果;失败页面不抢占请求。没有页面成功回答时 Tool 保持 pending直到后续成功或 Tool call 取消。
Inspect 数据只用于写代码前确认能力、签名、类型和挂载协议。插件运行时需要业务数据时必须调用实际 Service 或监听实际 Event不能缓存、展示或依赖 Inspect/Catalog 返回值。
`CordisCatalogProjector` 使用 TypeRT 分别生成 Host/Client Service 与 Event CatalogSlot AST 生成器扫描 `SlotMap`、注册选项、standard props、owner props 和引用类型Slots Provider 查询时合并静态 Catalog 与 live tree。Theme token 由 ThemeService 导出Builtin 在 evaluator/Guard 附近手工维护Tool schema 来自 Registry。
Catalog 扫描真实源码签名,再应用 model-visible 白名单。白名单可以隐藏 Service、成员、`@deprecated` API、Runner 自身服务和 `cordis/*` 控制 Event但不能改写剩余 API 的方法名、参数和返回类型。Guard 可以拒绝参数、固定来源或屏蔽成员,但必须尊重源码签名。
模型可见 owner JSDoc 只要求完整 description、每个参数的 `@param`、非 void 返回的 `@returns`、Event 的 `@mode`,以及 Slot/props 字段说明。调用推荐、反例和跨能力选择放入 Skill不在 Catalog 增加重复 example 字段。
### 模型指导分层
模型指导分为四层:
- System Prompt 保存稳定运行模型、两端限制、生命周期、审批、版本指针、最低代码规范和七个 Tool 的使用地图。Skill 不可用时它仍须支持最低限度正确实现。
- `cordis-plugin-development` Skill 保存需求导航、能力组合、推荐和反例,不复制完整 schema。
- 每个 Tool description 只说明该动作的前置条件、参数语义、同步/异步结果和下一步。
- Provider/Catalog 返回当前精确名称、签名、参数、Slot props、token 和运行时查询结果。
System Prompt 要求先加载 Skill再 list/query之后 define/run。Skill 中 React 示例必须注册到 Slot不能从 `apply()` 直接返回 React Element示例使用 `React.createElement`、正确 `ctx.get()`/`inject`、可逆 effect 和最小 JSON RPC。
### `@pluginId` 与 Tool UI
输入系统为当前 Session 注册 `@pluginId` mention。选择后只注入 Plugin 身份、默认基准 Package、版本指针、活动 Run 和最近状态,不注入源码。默认基准依次选择 next、current、最近定义的 Package。模型必须先用 `cordis_inspect_self` 读取源码,再以 existing 模式追加 Package引用失效时不能静默创建替代 Plugin。
`cordis_define` 卡片以 Host/Client 两个子页签展示代码。`cordis_run` 卡片由 `pluginRunId` 关联精确 attempt并读取 Client store 显示待审批、Client 待激活、运行中、失败、已被后续 Run 替代或 Plugin 已移除。
Package 可以向 `tool.view.cordis` 注册 `key: "self"`。运行时把 self 绑定为 `pluginId + packageId`;业务 Slot key 不含 `pluginRunId`,但 owner props 仍提供精确 Run 身份。同一 Package 最新 Run 卡片承载业务 UI更早卡片显示已有更新运行。卡片通过 store 响应变化,不扫描后续 Session Log也不互相通知。
全局 Cordis 面板使用一个固定入口,按当前会话和其他会话分组。面板标题和收起操作固定,只有列表滚动。普通行可选择 Package并运行、停止或删除失败更新可重试 next 或选择 current 回退;待审批行只提供两个允许动作和拒绝。
### 错误与模型反馈
跨 Host/Client 的技术错误保留原始 `message`,并在错误对象提供时保留 `stack`。结构化诊断包含 `pluginId``packageId``pluginRunId` 和阶段approval、host-load、host-apply、client-load、client-apply 或 client-render。
Host/Client Guard、Host 求值与 handler、Client 求值与 apply、Slot `onEntryError` 和 React ErrorBoundary 都把错误回到 owning Agent。Client 控制台同时以 `console.error` 打印原始 error 对象。渲染错误属于精确 Run不污染不可变 Package。
模型发起的异步 Run 在成功、拒绝或技术失败后使用 `agent.steer` 唤醒 owning Agent。技术失败要求读取诊断、在同一 Plugin 修正并自主重试;用户拒绝则禁止自动重复申请。用户在面板手动运行、停止或移除通过 context injection 告知下一 step但不主动唤醒模型。
## Alternatives considered
**Define 与 Run 合并。** 这会失去“已定义但未运行”的可预览状态,把语法错误、审批、运行错误和重试混成一个动作,因此拆为不可变 Define 和独立 Run。
**Package ID 同时作为 Plugin ID。** 单层 ID 无法表达稳定实例下追加不可变版本,更新只能 stop、undefine、重新 define历史卡片和 `@` 引用也无法保持同一对象,因此采用 Plugin、Package、Run 三层身份。
**提供独立 `cordis_update`。** Update 的装载、审批、UI、诊断和 Run 相同,独立 Tool 只复制协议,因此合并到 `cordis_run mode:"update"`
**更新失败后自动恢复旧物理 Run。** 自动恢复会把“目标失败”和“旧版本重新成功”混成一个结果。当前设计保留旧 current 指针但不自动重启,让用户明确选择重试 next 或 run current。
**让 `cordis_run` 阻塞到用户审批和 Client 终局。** 审批或页面操作可能只能在当前模型轮结束后发生,阻塞会形成死锁,并在无页面时无限占用 Tool。当前设计立即返回通过 store、Inspect 和 steering 报告终局。
**Host 广播源码并用超时等待 Client ack。** 广播会在授权前把代码发给所有页面超时无法区分没有页面、页面慢和用户未操作Host 还要维护补偿式回滚。当前协议只广播元数据,由获准页面按精确 Run 拉取源码。
**页面启动时自动恢复所有 Host active Package。** 这要求连接身份、启动期 baseline 和跨页面一致性。当前设计接受页面局部 Client 状态,用户可在面板重新装载。
**通过公开 Remote Service 或 `ctx.remote` 连接 Package 两半。** 这会把动态 Package 暴露到产品级 RPC 面。Package 私有 `harness.handle`/`host.call` 足以承载 Client→Host JSON 调用,并能按 `pluginRunId` 拒绝陈旧请求。
**把所有 Service 方法自动暴露成 Inspect query。** 这会把能力发现变成业务调用代理绕过插件审批和生命周期。Provider 只暴露策展的只读查询Service Catalog 只描述业务方法签名。
**把完整 API 写进 System Prompt 或 Skill。** 固化文本会漂移并占用上下文。System Prompt 保留稳定规则Skill 负责需求导航,精确签名和运行时目录由 Provider/Catalog 返回。
**要求 Slot owner 在运行时注册 props schema。** Slot props 已存在于 TypeScript 类型和 JSDoc 中,重复注册会制造第二份权威。当前设计用 Slot AST Catalog 提取静态协议,只在查询时合并 live tree。
**把运行态写入 Session Log 并在 replay 恢复。** 动态代码和 Fiber 是进程局部对象恢复要求重新执行历史代码并重新解释审批。Session 只保留模型可见记录Registry 和页面 Run 不恢复。
**让历史 Run 卡片扫描后续 Session Log。** 这会让 Tool view 依赖全量日志顺序和后续消息结构。页面 card index/store 已能按 Package 告知旧卡片被替代或 Plugin 被删除。
## Acceptance criteria
- 新 Plugin 只能由 3 至 6 位小写英文前缀创建,最终 Plugin、Package 和 Run ID 由 Host 分配并使用品牌类型。
- `cordis_define` 只做参数和 plain JavaScript 语法检查,返回不可变 Package同一 Plugin 可以追加版本,旧源码保持可 inspect。
- `cordis_run` 严格校验 run/updateHost-only 同步完成Client-bearing 返回 `awaiting-approval``starting`,不等待页面终局。
- 单勾只授权当前 Package双勾授权同一 Plugin 后续版本;授权在技术失败后仍保留,拒绝不执行两侧代码。
- Host 先激活Client 后取精确 Run 源码Client 成功前不提交 Client-bearing Package 的 current失败后 current/next 可用于重试和回退。
- 一个 Plugin 同时最多一个物理 Runstop 撤销两端贡献但保留定义和指针undefine 删除全部 Package、授权和状态。
- 当前页面能区分“待激活”“Client 待激活”和“运行中”,待审批时只显示审批动作。
- `tool.view.cordis` 的 self 绑定 Plugin + Package同 Package 最新 Run 卡片独占业务 UI旧卡片和已删除 Plugin 有明确退化状态。
- Host/Client Guard 拒绝 import、JSX、未声明 Service 和不可用全局Service、timer、Slot、样式、Tool、handler 和主题覆盖随 Run teardown。
- Package 私有 RPC 只允许 Client→Host 无损 JSON并拒绝陈旧 `pluginRunId`
- Inspect list 一次返回 Host/Client manifestquery 只调用显式只读方法Client 查询等待首个 schema-valid 成功结果或取消。
- Service/Event Catalog 分 Host/Client 生成并应用白名单,`@deprecated` API、Runner 自身服务和 `cordis/*` 控制 Event 不向模型暴露Slot query 合并静态 props 与 live subtree。
- `cordis_inspect_self` 分层返回列表、Package 摘要和精确源码/诊断;`@pluginId` 不直接注入源码且更新留在同一 Plugin。
- 异步技术失败、Host handler、Client Guard 和 React 渲染错误保留 message/stack 并 steering owning Agent用户面板操作只注入下一 step context。
- System Prompt、Skill、Tool description 和 Provider/Catalog 按本 Note 分层Skill 不可用时 Prompt 仍足以生成最低限度正确的插件。
- 相关工作区 `pnpm run build` 通过;实现阶段补齐 Host/Client lifecycle、版本、审批、Inspect、Guard、Tool 卡片与真实应用快照覆盖。
## Risks
- **进程重启丢失全部动态对象。** 历史 Tool 卡片仍在,但 Registry 不恢复;用户必须重新 define。
- **多页面状态不是强一致系统。** 第一个有效 Client 成功结果可以提交 current各页面的 Client 装载和渲染状态仍可能不同;当前不引入连接身份、法定人数或页面聚合。
- **Client Inspect 可能长期 pending。** Host 保存最近 manifest但没有页面成功执行 Provider 时不能用旧数据伪装 live 结果;多个页面都失败时请求等待到取消。
- **跨版本授权扩大信任范围。** 双勾允许同一 Plugin 后续 Package 无需再次审批UI 必须清楚区分单次和跨版本授权。
- **失败更新可能留下 current 指向旧版本但旧版本未运行。** current 表示最后成功版本,不表示当前物理 RunUI、Inspect 和提示必须同时展示 active、current 和 next。
- **受限上下文不是安全沙箱。** Host Service、文件、命令、网络和 Client UI 都是真实能力;白名单与审批降低误用,不隔离恶意代码。
- **Catalog、Guard 和源码可能漂移。** 生成器、白名单和 owner JSDoc 必须共同维护Guard 的隐藏策略不能产生另一套签名。
- **Builtin 依赖手工声明。** React、harness、host、styles 和 Context 方法没有统一可扫描入口,注入实现与 Provider 定义必须放在同一维护位置。
- **Provider 输出 schema 当前允许较宽的 JSON。** 首版优先完成 Provider 所有权、输入校验和 Host/Client 路由;更窄的输出 schema 后续再收紧。
- **Host 与 Client Guard 存在平行实现。** 两侧开放环境和 Cordis 类型面不同,当前保留各自实现;公共规格只有在能减少代码且不隐藏安全策略时再提取。

View File

@@ -244,6 +244,10 @@
# trust boundary, not a sandbox — see this file's header. # trust boundary, not a sandbox — see this file's header.
- id: tool-cordis - id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis' name: '@deepseek-ai/dsh-tool-cordis'
- id: cordis-client-runner
name: '@deepseek-ai/dsh-cordis-client-runner'
- id: ui-cordis
name: '@deepseek-ai/dsh-client-ui-cordis'
# The composition-authoring skill travels with this preset rather than living # The composition-authoring skill travels with this preset rather than living
# in the user's skill root: it documents THIS deployment's two planes, and a # in the user's skill root: it documents THIS deployment's two planes, and a

View File

@@ -0,0 +1,417 @@
---
name: cordis-plugin-development
description: Create, modify, debug, or extend dynamic Cordis Plugins, including Host Services and Events, Client Slot and theme UI, Package-private Client-to-Host calls, dynamic Tools, version updates, approval failures, and runtime diagnostics. Use this Skill to route a user request to the correct platform and Inspect Provider, then define, run, repair, or roll back the Plugin.
---
# Develop Dynamic Cordis Plugins
First determine whether a capability belongs on Host or Client, then query the real interface before writing code. Never infer a complete API from a Service name, Event payload, Slot props, theme token, or example.
## Standard workflow
1. Call `cordis_inspect_list` to obtain the Providers, methods, and schemas currently registered on Host and Client.
2. Select the smallest set of `cordis_inspect_query` calls needed to read the exact Services, Events, Builtins, Slots, Theme tokens, or Tools that the implementation will use.
3. For a new Plugin, design its first Package. To modify an existing Plugin, first use `cordis_inspect_self(pluginId, packageId)` to read the base source and diagnostics.
4. Write plain JavaScript in `code.host`, `code.client`, or both, then call `cordis_define`.
5. Call `cordis_run` with the final `pluginId` and `packageId` returned by define.
6. Handle approval, waiting, Client loading, and render failures from the Run card, steering messages, or `cordis_inspect_self`.
7. Use `cordis_stop` to disable the Plugin temporarily. Use `cordis_undefine` only when it is no longer needed.
Do not wait in the same turn for user approval or asynchronous browser results. After `cordis_run` returns `awaiting-approval` or `starting`, end the current Tool flow and wait for the system to report the final outcome through state updates and steering.
## Tool usage guidance
| Tool | Use it when | Do not |
| --- | --- | --- |
| `cordis_inspect_list` | Discover current Host/Client Providers and method schemas in one call; refresh after the runtime capability directory changes | Hard-code Provider names and skip list; treat a manifest as business data |
| `cordis_inspect_query` | Confirm exact Service methods, Event modes, Builtins, Slots, tokens, or Tool schemas before writing code | Use it instead of calling a real Service from the Plugin; assume a Client query will finish without a responding page |
| `cordis_inspect_self` | List current Plugins, inspect version pointers, or read exact Package source and runtime diagnostics | Fetch all source just to build a list; use it to modify or start a Plugin |
| `cordis_define` | Create a Plugin's first version or append an immutable Package to an existing Plugin; let the user preview the code first | Expect define to execute `apply`, request approval, or update current |
| `cordis_run` | Activate an exact Package; use `run` for first activation, restart, or rollback, and `update` to switch versions | Use `run` to switch versions implicitly; treat pending or starting as success |
| `cordis_stop` | Pause current effects while preserving Packages, grants, and version pointers for later use | Use stop to mean permanent deletion |
| `cordis_undefine` | Permanently remove a Plugin and all of its Packages and clear historical business views | Call it while rollback, inspection, or restart is still needed |
## Choose a platform
| Requirement | Preferred platform | Inspect first |
| --- | --- | --- |
| Files, commands, processes, or networking | Host | `fs`, `bash`, `subprocess`, `pty`, and `web` in `Service.listService` |
| Agents, durable Session data, or Host lifecycle | Host | The relevant Service and `Event.listEvents` |
| Register a dynamic Tool callable in the next model step | Host | `harness` in `Builtin.listBuiltins`, plus `Tool.listTools` |
| Page theme, layout, or current page state | Client | `Theme.listTokens` and Client `Service.listService` |
| Conversation Snapshot or session/workspace lists | Client | The target Slot's standard props and owner props |
| Settings pages, sidebars, input areas, overlays, or Tool cards | Client | `Slots.listSubTree` |
| Fetch on Host and display on Client | Both | Host Service + `harness.handle`; Client Slot + `host.call` |
Prefer the capability closest to the data owner. If Slot props already provide the Conversation Snapshot, do not fetch it again through Host. If only the Package's own styles need to change, do not override the global theme. If only a small entry point is needed, do not replace an entire product UI region.
## Provider navigation
Select methods from the actual `cordis_inspect_list` result. Common initial methods include:
- `Service.listService`: without `service`, returns every callable Service with its purpose and exact method signatures. Query the selected `service` again for access rules, structured method descriptions/parameters/returns, and only its referenced types.
- `Event.listEvents`: without `event`, returns every Event with its purpose, dispatch mode, and exact listener signature. Query the selected `event` again for its structured listener contract and only its referenced types; a Waterfall listener must call `next()`.
- `Builtin.listBuiltins`: returns evaluator-provided symbols and signatures that cannot be obtained through `ctx.get()`.
- `Slots.listSubTree`: without `root`, returns compact live trees with each Slot's purpose, kind, scope, registration keys, replacement risk, and children. With an exact `root`, it also returns that selected Slot's full contract, props, and current occupants while keeping descendants compact.
- `Theme.listTokens`: returns theme tokens that may currently be queried and overridden; it does not modify the theme.
- `Tool.listTools`: returns Tool schemas actually visible to the current Agent, including dynamically registered Tools.
Provider names, methods, and inputs must come from the current list result. The Service/Event Catalog describes which interfaces this version permits; it does not guarantee that a Service is currently mounted. At runtime, use real Services and Events rather than caching or displaying Catalog query results.
## Execution environment
Both `code.host` and `code.client` are plain JavaScript function bodies that return a Cordis Plugin. They are not compiled by TypeScript, JSX, or a bundler.
Do not use:
- `import`, `require`, TypeScript types, `as`, decorators, or JSX;
- globals not confirmed by `Builtin.listBuiltins`;
- guessed access to `window`, `document`, `process`, `Buffer`, `fetch`, or native timers.
Client React code must use `React.createElement(...)`.
Correct:
```js
return {
apply(ctx) {
const slots = ctx.get('slots')
if (slots === undefined) return
slots.inject('tool.view.cordis', () => slots.register(
{ name: 'tool.view.cordis', key: 'self' },
() => React.createElement('div', null, 'Hello'),
))
},
}
```
Incorrect:
```jsx
return {
apply(ctx) {
return <div>Hello</div>
},
}
```
JSX is not the only problem in this example. `apply()` registers lifecycle contributions and cannot return a React Element as the Plugin result. UI must be registered in a queried Slot.
## Access Services
Read optional capabilities with `ctx.get(name)` by default and handle their absence:
```js
return {
apply(ctx) {
const service = ctx.get('serviceName')
if (service === undefined) return
service.someMethod()
},
}
```
Declare `inject` only when a Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears:
```js
return {
inject: ['requiredService'],
apply(ctx) {
ctx.requiredService.someMethod()
},
}
```
Do not overuse `inject` merely to avoid an `undefined` check. Do not access `ctx.requiredService` without declaring the injection; the Guard rejects undeclared dependencies.
## Manage side effects
Every contribution must be removed after the Plugin is stopped, updated, or removed. Prefer Cordis lifecycle APIs:
- Use `ctx.on()` to register Event listeners.
- Use `ctx.effect()` to own an external subscription that returns a disposer.
- Retain disposers returned by Cordis Service, Tool, Slot, timer, and theme APIs.
- Do not create process-wide or page-wide side effects at module scope or outside `apply()`.
Recommended:
```js
return {
apply(ctx) {
const service = ctx.get('serviceName')
if (service === undefined) return
ctx.effect(() => service.subscribe((value) => {
console.log(value)
}))
},
}
```
If `subscribe()` does not return a disposer, first query whether the Service provides a supported cleanup mechanism. Do not assume unload automatically removes arbitrary third-party callbacks.
## Host and Client timers
On both platforms, the timer is a Service named `timer` with the same interface; it is not a Builtin. Query `{ "service": "timer" }` through the corresponding platform's `Service.listService` before using it. Declare `inject: ['timer']` before using the timer mixin.
One-shot delay:
```js
return {
inject: ['timer'],
apply(ctx) {
const onClick = () => {
ctx.timeout(() => console.log('done'), 300)
}
// Pass onClick to a queried Slot UI.
},
}
```
Periodic work in a React component:
```js
return {
inject: ['timer'],
apply(ctx) {
function Clock() {
React.useEffect(() => ctx.interval(() => console.log('tick'), 1000), [])
return React.createElement('div', null, 'Running')
}
// Register Clock in a queried Slot.
},
}
```
Incorrect:
```js
return {
apply(ctx) {
ctx.timeout(() => console.log('invalid'), 300)
},
}
```
```js
setTimeout(() => console.log('invalid'), 300)
```
The first example does not declare the timer hard dependency. The second uses a global timer that does not exist.
## Listen to Events
Query the Event Provider first to confirm the platform, parameter order, return value, and `mode`.
Ordinary emit Event:
```js
return {
apply(ctx) {
ctx.on('some/event', (payload) => {
console.log(payload)
})
},
}
```
The last parameter of a Waterfall Event is `next`. Unless the listener intentionally stops downstream processing, it must call and return it:
```js
return {
apply(ctx) {
ctx.on('some/waterfall', (payload, next) => {
console.log(payload)
return next()
})
},
}
```
## Register Client UI
Query `Slots.listSubTree` without `root` to choose a target from the compact purpose and topology tree, then query the exact Slot with `root` before writing its registration. The exact result determines:
- the Slot's purpose in the layout;
- whether its registration protocol is `single`, `list`, `keyed`, or `chain`;
- registration options;
- scope standard props and business owner props;
- current occupants, replacement risks, and descendant Slots.
Use `ctx.get('slots')` and handle its absence. Then use `slots.inject` to wait for the Slot declaration and call `slots.register` inside the callback:
```js
return {
apply(ctx) {
const slots = ctx.get('slots')
if (slots === undefined) return
slots.inject('target.slot', () => slots.register(
{ name: 'target.slot', id: 'my-view' },
(props) => React.createElement('div', null, String(props.someValue)),
))
},
}
```
`ctx.get('slots')` does not require an injection. Do not rewrite it as `ctx.slots` unless `inject: ['slots']` is declared:
```js
return {
apply(ctx) {
ctx.slots.register({ name: 'target.slot' }, () => null)
},
}
```
Do not guess an `id`, `key`, selector, or props before querying the Slot protocol. Do not default to root-level `root`, `sidebar`, `conversation`, or `details` Slots; replacing an entire occupant also removes the descendant Slots it declares.
### Settings pages
A full settings UI should usually register its own section through `settings.section` to obtain a complete content area. `settings.general.item` is only appropriate for one compact, general-purpose preference. Query the actual subtree, options, and props for both, then select the narrowest entry point that is still sufficient.
Dynamic Plugins are temporary and process-local, so their settings UI does not need persistent storage. Do not add durable settings or another persistence mechanism for it. Register the UI in the appropriate settings Slot and keep any transient interaction state in memory for the lifetime of the Plugin.
### Session and page data
A session-scoped Slot may provide `useSession`, `useSessions`, `useWorkspaces`, `useProjection`, input state, or actions through standard props. Follow the query result and prefer owner or standard props directly; do not add a Host RPC for data already present there.
Select only the fields that the UI actually needs. Do not copy or render an entire Conversation Snapshot, Session, Tool call, or Slot props object.
### Cordis Run-specific panel
To place interactive UI in the latest `cordis_run` card, register `tool.view.cordis` with `key: 'self'`:
```js
return {
apply(ctx) {
const slots = ctx.get('slots')
if (slots === undefined) return
slots.inject('tool.view.cordis', () => slots.register(
{ name: 'tool.view.cordis', key: 'self' },
(props) => React.createElement('div', null, `Package ${props.packageId}`),
))
},
}
```
At runtime, `self` binds to `pluginId + packageId`. Do not include `pluginRunId` in the key. When the same Package runs multiple times, the latest Run card hosts the UI and older cards automatically degrade.
### Ordinary Tool cards
To customize the call card for an ordinary model Tool, query `tool.call.toolview`. Its key is the Tool name; registering an existing key may replace the product's default card. When customizing only a newly added Tool, first verify its schema with `Tool.listTools`, then query the complete `ToolCallOwnerProps`.
### Overlays and local entry points
- For toasts, status notices, and frame-wide overlays, query `shell.overlay` first; observe its pointer-events and ordering rules.
- For small sidebar actions, prefer additive inner Slots such as `sidebar.footer.action`; do not replace the entire sidebar.
- For supplementary content after a conversation turn, query `conversation.chat.turnTail` and register according to its returned chain selector and fallback rules.
## Themes and styles
Determine the scope of the change first:
1. Global theme: first query `Theme.listTokens`, then query `{ "service": "theme" }` through Client `Service.listService`. Supply light and dark values for each override as required by the query, and retain the returned disposer.
2. The Package's own components: use `styles.insert(css)` and prefer theme CSS variables for colors.
3. New visible content: choose a Slot first, then decide between local CSS and global tokens.
Do not manipulate `document.body`, `window`, or hard-coded product DOM selectors. The theme Service changes tokens but does not create UI. Slots create UI but do not replace the theme system.
## Call Host from Client
Host registers a Package-private method with `harness.handle(method, handler)`, and Client invokes it with `host.call(method, args)`. This is Client→Host JSON RPC.
Host:
```js
return {
apply(ctx) {
harness.handle('read-state', async (args) => {
return { value: args.key }
})
},
}
```
Client:
```js
return {
async apply(ctx) {
const result = await host.call('read-state', { key: 'demo' })
console.log(result.value)
},
}
```
Arguments and return values must be lossless JSON. Do not pass functions, React elements, class instances, Contexts, Services, or other runtime objects; return `null` when there is no response data. Do not register a public Remote Service or use `ctx.remote` for Package-private communication.
## Register a dynamic model Tool
Host can use `harness` to register a Tool callable in the next model step. First query the current `harness` signature with Host `Builtin.listBuiltins`, then inspect existing Tool names and schemas with `Tool.listTools` to avoid conflicts.
Tool arguments and return values must be JSON-compatible. `execute` owns the business result; render and presentation own only what the model and native UI see. Tool registration must belong to the current Plugin Fiber so it is automatically removed after stop or update.
## Handle internal live data
Service instances, Event payloads, Slot props, Session and Conversation Snapshots, Tool state, and other DSH/Cordis objects are internal live data.
Do not:
- call `JSON.stringify` or `structuredClone` on these objects or their descendants;
- recursively enumerate, fully copy, or display them as a whole;
- place Host objects in the Package's long-lived state or RPC return values.
Read only the leaf fields required by the current feature. Extract the minimum strings, numbers, booleans, and other scalar values before constructing owned JSON.
## Versions, approval, and repair
- A Plugin is the stable instance identified by `pluginId`.
- A Package is an immutable code version identified by `packageId`.
- Every activation attempt has its own `pluginRunId`.
- `currentPackageId` is the latest successful version; it does not imply that the Plugin is currently running.
- `nextPackageId` is the target awaiting approval, activating, awaiting Client activation, or most recently failed.
Choose the `cordis_run` mode as follows:
| Current state | Target | mode |
| --- | --- | --- |
| No current | Any Package under the Plugin | `run` |
| Has current | The same Package | `run` |
| Has current | A different Package | `update` |
| Update failed | `nextPackageId` | `update` to retry |
| Update failed | `currentPackageId` | `run` to roll back |
An unauthorized Client Package returns `awaiting-approval`. A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains after a technical runtime failure. An authorized Package returns `starting` and completes asynchronously in the browser.
After a technical failure:
1. Use `cordis_inspect_self(pluginId, packageId)` to read the failed version's source and exact diagnostics.
2. If the error involves an unknown capability, list and query the corresponding Provider again.
3. Define a new Package under the same Plugin; do not overwrite the failed Package.
4. Run again with the new `packageId` and the correct mode.
Do not retry automatically after the user rejects approval. A failed update does not automatically restore the old physical Run; explicitly run current when recovery is required.
## Modify @pluginId
When the user identifies a target with `@pluginId`, do not create another Plugin. The injected context contains only identity, version pointers, and the default base Package, not source code.
Modify it as follows:
1. Read the base Package with `cordis_inspect_self(pluginId, packageId)`.
2. Preserve the Host or Client half that does not need to change and modify only the target code.
3. Call `cordis_define` with `plugin.kind: 'existing'` and the original `pluginId`.
4. Use the returned `packageId`; when current exists, activate the new version with `update` in the usual case.
If the reference is unavailable, explain that the Plugin was removed, belongs to another Session, or was lost on process restart. Do not create a same-named replacement.
## Common failure checks
| Failure | Check first |
| --- | --- |
| `service "x" is not declared` | Whether code uses `ctx.x` without declaring `inject: ['x']` on the Plugin object; switch to `ctx.get('x')` with an absence check or declare a true hard dependency |
| `cannot get property "timer" without inject` | Query the timer Service and declare `inject: ['timer']` |
| Client parse failure | Whether the code uses JSX, TypeScript, import, or an unavailable global |
| Slot registration failure | Whether the live subtree was queried, the Slot exists, and options, key, or selector satisfy the returned protocol |
| UI loads but the page reports an error | Inspect the `client-render` diagnostic and stack; the error belongs to an exact Run, so define a new Package to repair it |
| `host.call` failure | The Host handler name, current `pluginRunId`, JSON arguments, and real Service dependencies inside the handler |
| Update failure | Preserve current/next semantics; repair next and update, or run current to roll back |

View File

@@ -27,6 +27,9 @@
"@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-base": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
"@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-basic": "workspace:^",

View File

@@ -1,7 +1,14 @@
// Web e2e scenario for the opt-in Cordis tools. Record mode drives a real // Web e2e scenario for the opt-in Cordis tools. Record mode drives a real
// model through inspect, mount, and unmount; replay pins the same shipped Web // model through inspect, define, run, and stop; replay pins the same shipped Web
// composition, durable calls, generic rows, highlighted Plugin source, and // composition, durable calls, generic rows, the define card's own source view,
// conversation accessibility tree. // and conversation accessibility tree.
//
// The approval is never in the fixture. The fixture pins what the MODEL said;
// tools execute for real, so `cordis_run` genuinely blocks on a person and this
// test is that person — which is what lets the run/approve boundary be asserted
// instead of assumed. The package therefore carries a browser half whose only
// job is to be visible (`[data-snapshot-probe]`): its absence before the answer
// and presence after it is the v3 user gate, proven rather than described.
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright' import type { Browser, Page } from 'playwright'
@@ -17,12 +24,21 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode() const MODE = webSnapshotMode()
const CORDIS_TOOLS = ['cordis_inspect', 'cordis_mount', 'cordis_unmount'] as const const CORDIS_TOOLS = ['cordis_runtime_inspect', 'cordis_package_inspect', 'cordis_define', 'cordis_run', 'cordis_stop'] as const
const MOUNT_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }' const PACKAGE_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }'
const PROMPT = 'Use only Cordis tools. First call cordis_inspect with what "temporary". ' // The browser half is the PROBE this scenario turns on: it renders a marker into
+ `Then call cordis_mount with this exact code: ${JSON.stringify(MOUNT_CODE)}. ` // the frame-wide overlay, so "did the plugin actually run in this page" becomes a
+ 'Read its returned id and call cordis_unmount with that exact id. ' // DOM fact. A host-only package would sidestep the approval round trip entirely
+ 'After all three calls succeed, reply exactly CORDIS_UI_DONE and stop.' // (the host runs those immediately), which would drop the v3 user gate out of
// coverage — the one thing this scenario exists to prove.
const CLIENT_CODE = 'return { inject: ["slots"], apply(ctx) { ctx.slots.register('
+ '{ name: "shell.overlay", id: "snapshot-probe" }, '
+ '() => React.createElement("div", { "data-snapshot-probe": "loaded" })) } }'
const PROMPT = 'Use only Cordis tools. First call cordis_runtime_inspect with what "temporary". '
+ 'Then call cordis_define with name "snapshot noop", purpose "does nothing, for the snapshot", '
+ `code exactly ${JSON.stringify(PACKAGE_CODE)} and client exactly ${JSON.stringify(CLIENT_CODE)}. `
+ 'Read its returned id and call cordis_run with that exact id, then cordis_stop with the same id. '
+ 'After all four calls succeed, reply exactly CORDIS_UI_DONE and stop.'
function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void { function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
const turnEnd = events.findLast( const turnEnd = events.findLast(
@@ -82,6 +98,22 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
const settled = scaffold.whenTurnSettled() const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT) await input.fill(PROMPT)
await input.press('Enter') await input.press('Enter')
// `cordis_run` blocks host-side on a person's answer — no timer, no default.
// The approval is the TEST's action in every mode: the fixture pins what the
// model said, and the gate is a real round trip through the real panel.
const badge = page.locator('[data-cordis-badge]')
await expect.poll(() => badge.getAttribute('data-cordis-awaiting'), { timeout: 90_000 }).toBe('true')
await badge.click()
const approve = page.locator('[data-cordis-approve]').first()
await approve.waitFor({ timeout: 10_000 })
// The one assertion this scenario cannot give up: the model asking to run is
// NOT the plugin running. Until a person answers, the browser half has not
// been fetched, evaluated, or mounted anywhere on this page.
expect(await page.locator('[data-snapshot-probe]').count()).toBe(0)
await approve.click()
await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 30_000 }).toBe(1)
const sessionId = await settled const sessionId = await settled
if (MODE === 'record') { if (MODE === 'record') {
assertCompleteCordisLifecycle(sessionEvents) assertCompleteCordisLifecycle(sessionEvents)
@@ -100,20 +132,30 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 }) await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
.toBeGreaterThanOrEqual(1) .toBeGreaterThanOrEqual(1)
const inspectRow = page.locator('[data-tool="cordis_inspect"]').filter({ hasText: 'Inspect' }).first() const inspectRow = page.locator('[data-tool="cordis_runtime_inspect"]').filter({ hasText: 'Inspect' }).first()
await inspectRow.waitFor({ timeout: 10_000 }) await inspectRow.waitFor({ timeout: 10_000 })
const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first() // cordis_define does NOT go through the generic row: ui-cordis registers a
await mountRow.waitFor({ timeout: 10_000 }) // keyed toolview for it, and a keyed hit replaces the generic card. So the
// title here is the CARD's ("Cordis Plugin"), and the expanded body is the
// card's own two code sections rather than a generic args dump.
const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first()
await defineRow.waitFor({ timeout: 10_000 })
// The whole summary row is the expand toggle (unified tool-row interaction). // The whole summary row is the expand toggle (unified tool-row interaction).
await mountRow.locator('[aria-expanded]').first().click() await defineRow.locator('[aria-expanded]').first().click()
await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 }) await expect.poll(() => defineRow.textContent(), { timeout: 10_000 }).toContain(PACKAGE_CODE)
.toContain(MOUNT_CODE) await expect.poll(() => defineRow.textContent()).toContain('data-snapshot-probe')
const unmountRow = page.locator('[data-tool="cordis_unmount"]').filter({ hasText: 'Unmount temporary Plugin' }).first() const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run dynamic package' }).first()
await unmountRow.waitFor({ timeout: 10_000 }) await runRow.waitFor({ timeout: 10_000 })
await expect.poll(() => unmountRow.textContent()).toContain('dyn-') await expect.poll(() => runRow.textContent()).toContain('dyn-')
await expect(unmountRow.getAttribute('data-state')).resolves.toBe('ok')
const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop dynamic package' }).first()
await stopRow.waitFor({ timeout: 10_000 })
await expect.poll(() => stopRow.textContent()).toContain('dyn-')
await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok')
// Stopping withdraws the browser half from every page, probe included.
await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 15_000 }).toBe(0)
}) })
it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => { it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => {

View File

@@ -70,6 +70,7 @@ import SessionStore, {
type SessionHeader, type SessionHeader,
} from '@deepseek-ai/dsh-session' } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
// Empty type imports carry the webServer/agents/sessionPersistence Context merges. // Empty type imports carry the webServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -460,8 +461,19 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// be able to change a golden, whatever roots a scenario asks for. // be able to change a golden, whatever roots a scenario asks for.
: [{ id: 'agent-presets', config: { ...options.agentPresets, includeUserRoot: false } }], : [{ id: 'agent-presets', config: { ...options.agentPresets, includeUserRoot: false } }],
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }], ...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
// The host halves ride Loader builtins (below) so the shipped CLI keeps no
// dependency on this opt-in package, but the two browser rows must carry
// their real package names: the modules node half reads `dshClient` from a
// row's resolved package root, and a `cordis:` builtin has none — it is
// permanently not a client row, so a builtin here would silently drop the
// browser half from the roster.
...options.cordisTools === true ...options.cordisTools === true
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }] ? [{ insert: [
{ id: 'cordis-host-runner', name: 'cordis:cordis-host-runner' },
{ id: 'tool-cordis', name: 'cordis:tool-cordis' },
{ id: 'cordis-client-runner', name: '@deepseek-ai/dsh-cordis-client-runner' },
{ id: 'ui-cordis', name: '@deepseek-ai/dsh-client-ui-cordis' },
] }]
: [], : [],
...options.deepSeekSearch === undefined ...options.deepSeekSearch === undefined
? [] ? []
@@ -515,7 +527,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
ctx.loader.builtins.group = Group ctx.loader.builtins.group = Group
// The shipped CLI deliberately has no dependency on this opt-in package. // The shipped CLI deliberately has no dependency on this opt-in package.
// Keep the Loader row real without broadening the product installation. // Keep the Loader row real without broadening the product installation.
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis if (options.cordisTools === true) {
ctx.loader.builtins['cordis-host-runner'] = CordisHostRunner
ctx.loader.builtins['tool-cordis'] = ToolCordis
}
await ctx.loader.create({ await ctx.loader.create({
name: 'cordis:include', name: 'cordis:include',
config: { path: pathToFileURL(rootConfig).href, patches }, config: { path: pathToFileURL(rootConfig).href, patches },

View File

@@ -25,6 +25,8 @@
- insert: - insert:
- id: code-runtime - id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker-thread' name: '@deepseek-ai/dsh-code-runtime-worker-thread'
- id: cordis-host-runner
name: '@deepseek-ai/dsh-cordis-host-runner'
- id: tool-cordis - id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis' name: '@deepseek-ai/dsh-tool-cordis'
- id: llm-replay - id: llm-replay

View File

@@ -23,5 +23,7 @@
- insert: - insert:
- id: code-runtime - id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker-thread' name: '@deepseek-ai/dsh-code-runtime-worker-thread'
- id: cordis-host-runner
name: '@deepseek-ai/dsh-cordis-host-runner'
- id: tool-cordis - id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis' name: '@deepseek-ai/dsh-tool-cordis'

View File

@@ -6,5 +6,7 @@
path: ./cordis.yml path: ./cordis.yml
patches: patches:
- insert: - insert:
- id: cordis-host-runner
name: '@deepseek-ai/dsh-cordis-host-runner'
- id: tool-cordis - id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis' name: '@deepseek-ai/dsh-tool-cordis'

View File

@@ -491,8 +491,9 @@ const SCENARIOS: Scenario[] = [
// child runs as a spawn subagent under the worker-thread engine (its session is the // child runs as a spawn subagent under the worker-thread engine (its session is the
// child fixture), and the tool result carries the script's return value. // child fixture), and the tool result carries the script's return value.
{ name: 'workflow-run', hasModelTurn: true, recorded: true }, { name: 'workflow-run', hasModelTurn: true, recorded: true },
// Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it // Authored counterpart to the packaged Python SDK snapshot: define a host-half marker package and
// through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and // run it, inspect this session's dynamic packages through Code Mode, run direct and workflow
// children, then undefine it. The extra Code Mode and
// Cordis plugins require their own request-header pin; the fixture tests deterministic composition. // Cordis plugins require their own request-header pin; the fixture tests deterministic composition.
{ {
name: 'advanced-toolchain', name: 'advanced-toolchain',

View File

@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-goal-round-driver": "workspace:*", "@deepseek-ai/dsh-goal-round-driver": "workspace:*",
"@deepseek-ai/dsh-hooks-claude-code": "workspace:*", "@deepseek-ai/dsh-hooks-claude-code": "workspace:*",
"@deepseek-ai/dsh-hooks-codex": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:*",
"@deepseek-ai/dsh-invariants": "workspace:*", "@deepseek-ai/dsh-invariants": "workspace:*",
"@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*",

View File

@@ -13,5 +13,7 @@
port: 3081 port: 3081
- insert: - insert:
- id: cordis-host-runner
name: '@deepseek-ai/dsh-cordis-host-runner'
- id: tool-cordis - id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis' name: '@deepseek-ai/dsh-tool-cordis'

View File

@@ -97,6 +97,18 @@
"@deepseek-ai/dsh-client-ui-directory-picker-native" "@deepseek-ai/dsh-client-ui-directory-picker-native"
] ]
}, },
"packages/extensions/cordis-host-runner": {
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreDependencies": [
"zod"
]
},
"packages/host/directory-picker-native": { "packages/host/directory-picker-native": {
"entry": [ "entry": [
"tests/**/*.spec.{ts,tsx}", "tests/**/*.spec.{ts,tsx}",

View File

@@ -102,6 +102,9 @@
"verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
"gen-cordis-api": "tsx scripts/gen-cordis-api.ts", "gen-cordis-api": "tsx scripts/gen-cordis-api.ts",
"verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check", "verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check",
"gen-client-catalog": "tsx scripts/gen-client-catalog.ts",
"gen-cordis-inspect-catalog": "tsx scripts/gen-cordis-inspect-catalog.ts",
"verify-client-catalog": "tsx scripts/gen-client-catalog.ts --check",
"verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts", "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts",
"gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts",
"verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check",

View File

@@ -64,6 +64,7 @@
"@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^",
@@ -80,6 +81,7 @@
"@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^",

View File

@@ -3,6 +3,7 @@
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import commandsRemote from '@deepseek-ai/dsh-commands/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
@@ -20,6 +21,7 @@ export type { ApiRemoteForwardedEvent } from '../types.ts'
// signatures `$on` hands to a listener, so a consumer reads the very // signatures `$on` hands to a listener, so a consumer reads the very
// declaration the Host emits rather than a flattened restatement of it. // declaration the Host emits rather than a flattened restatement of it.
export type {} from '@deepseek-ai/dsh-commands/types' export type {} from '@deepseek-ai/dsh-commands/types'
export type {} from '@deepseek-ai/dsh-cordis-host-runner/types'
export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-credentials/types'
export type {} from '@deepseek-ai/dsh-llm/types' export type {} from '@deepseek-ai/dsh-llm/types'
export type {} from '@deepseek-ai/dsh-agent-presets/types' export type {} from '@deepseek-ai/dsh-agent-presets/types'
@@ -40,6 +42,50 @@ export type {
SubagentAddress, SubagentCatalog, JobView, ToolCallView, ToolEventView, ToolResultView, SubagentAddress, SubagentCatalog, JobView, ToolCallView, ToolEventView, ToolResultView,
WorkspaceId, WorkspaceView, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-client-connection/client'
export type {} from '@deepseek-ai/dsh-api-gateway/client'
export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'
// The payload vocabulary of the selected namespaces, re-exported so a Client
// contribution can name what it sends and receives without importing a Host
// package: this assembly is the one place both planes legitimately meet.
export type {
ApprovalRequestId,
CordisHalfState,
CordisDynamicPackageId,
CordisDynamicPluginId,
CordisDynamicPluginRunId,
CordisDynamicRunMode,
CordisInspectMethodManifest,
CordisInspectPlatform,
CordisInspectProviderManifest,
CordisInspectProviderView,
CordisInspectQueryRequest,
CordisInspectQueryResolution,
CordisInspectQueryResolved,
CordisInspectRequestId,
CordisInspectResolveAck,
CordisRunDiagnostic,
CordisRunStatus,
DynamicCordisClientSource,
DynamicCordisHostHalfResult,
DynamicCordisInventoryRow,
DynamicCordisInvokeResult,
DynamicCordisPackage,
DynamicCordisRequestResolved,
DynamicCordisResolveAck,
DynamicCordisRetracted,
DynamicCordisRunRequest,
DynamicCordisRunResolution,
DynamicCordisRunAttempt,
DynamicCordisRunResponse,
DynamicCordisStopResponse,
DynamicCordisUndefineReceipt,
RequestRunOutcome,
} from '@deepseek-ai/dsh-cordis-host-runner/types'
// The JSON vocabulary those payloads are built from, re-exported for the same
// reason: a Client contribution names what it sends without importing a Host
// package, and this assembly is where both planes legitimately meet.
export type { JsonValue } from '@deepseek-ai/dsh-session/types'
declare module '@deepseek-ai/cordis' { declare module '@deepseek-ai/cordis' {
interface Context { interface Context {
@@ -59,13 +105,17 @@ export const inject = ['remote']
export async function apply(ctx: Context): Promise<() => Promise<void>> { export async function apply(ctx: Context): Promise<() => Promise<void>> {
const disposers: Array<() => Promise<void>> = [] const disposers: Array<() => Promise<void>> = []
try { try {
for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote, messageFeedbackRemote]) { for (const contribution of [
commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote,
]) {
disposers.push(await ctx.remote.$mount(contribution)) disposers.push(await ctx.remote.$mount(contribution))
} }
} catch (error) { } catch (error) {
for (const dispose of disposers.reverse()) await dispose() for (const dispose of disposers.reverse()) await dispose()
throw error throw error
} }
// Unwound in reverse mount order, so a namespace never outlives one mounted
// after it.
return async () => { return async () => {
for (const dispose of disposers.reverse()) await dispose() for (const dispose of disposers.reverse()) await dispose()
} }

View File

@@ -8,6 +8,7 @@ import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
// makes the shape assertion below judge real signatures rather than an empty // makes the shape assertion below judge real signatures rather than an empty
// event vocabulary. // event vocabulary.
import type {} from '@deepseek-ai/dsh-commands/types' import type {} from '@deepseek-ai/dsh-commands/types'
import type {} from '@deepseek-ai/dsh-cordis-host-runner/types'
import type {} from '@deepseek-ai/dsh-credentials/types' import type {} from '@deepseek-ai/dsh-credentials/types'
import type {} from '@deepseek-ai/dsh-llm/types' import type {} from '@deepseek-ai/dsh-llm/types'
import type {} from '@deepseek-ai/dsh-agent-presets/types' import type {} from '@deepseek-ai/dsh-agent-presets/types'

View File

@@ -18,6 +18,12 @@ export const API_REMOTE_FORWARDED_EVENTS = [
'agent-preset/selected', 'agent-preset/selected',
'commands/change', 'commands/change',
'credentials/updated', 'credentials/updated',
'cordis/request-run',
'cordis/request-run-resolved',
'cordis/dynamic-package',
'cordis/dynamic-retract',
'cordis/inspect-query',
'cordis/inspect-query-resolved',
'llm/adapters-updated', 'llm/adapters-updated',
'settings/document-updated', 'settings/document-updated',
] as const ] as const

View File

@@ -22,6 +22,10 @@
}, },
{ {
"path": "../../credentials/credentials" "path": "../../credentials/credentials"
},
{
"path": "../../self-modification/cordis-host-runner"
}, },
{ {
"path": "../../goal/goal" "path": "../../goal/goal"

View File

@@ -37,6 +37,9 @@
{ {
"path": "../../session/session-persistence" "path": "../../session/session-persistence"
}, },
{
"path": "../../self-modification/cordis-host-runner"
},
{ {
"path": "../../settings/settings" "path": "../../settings/settings"
}, },

View File

@@ -98,6 +98,9 @@
- id: api-gateway - id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy' name: '@deepseek-ai/dsh-host-apiproxy'
- id: cordis-host-runner
name: '@deepseek-ai/dsh-cordis-host-runner'
# Ordinary provider for the parsed Web flags. Its plugin-level injection # Ordinary provider for the parsed Web flags. Its plugin-level injection
# waits for cmdlineArgs; no launcher metadata or special row kind is needed. # waits for cmdlineArgs; no launcher metadata or special row kind is needed.
- id: web-startup - id: web-startup
@@ -164,6 +167,9 @@
- id: client-runtime - id: client-runtime
name: '@deepseek-ai/dsh-client-runtime' name: '@deepseek-ai/dsh-client-runtime'
- id: cordis-client-runner
name: '@deepseek-ai/dsh-cordis-client-runner'
- id: ui-theme - id: ui-theme
name: '@deepseek-ai/dsh-client-ui-theme' name: '@deepseek-ai/dsh-client-ui-theme'
@@ -195,6 +201,9 @@
- id: ui-tool - id: ui-tool
name: '@deepseek-ai/dsh-client-ui-tool' name: '@deepseek-ai/dsh-client-ui-tool'
- id: ui-cordis
name: '@deepseek-ai/dsh-client-ui-cordis'
# Durable workflow lifecycle as an independent Chat node after the # Durable workflow lifecycle as an independent Chat node after the
# existing generic workflow tool row. # existing generic workflow tool row.
- id: ui-workflow-run - id: ui-workflow-run

View File

@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
@@ -82,6 +83,8 @@
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-web-frontend": "workspace:^",
"@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^",

View File

@@ -18,13 +18,26 @@ import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type { import type {
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike, SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots' } from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' { declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap { interface SlotMap {
/** The built-in render-tree root hole (seeded by SlotCore): rendered only by the shell, occupied by a layout entry. */ /**
* The built-in render-tree root hole (seeded by SlotCore): the one slot the
* shell itself renders, and the ancestor of every other seat. OCCUPIED by
* ui-layout's AppFrame, which declares the sidebar, conversation, details,
* and shell.overlay seats inside it.
*
* DO NOT register here. This is a single slot, so a second entry does not
* sit beside the frame — it shadows it, and a dynamically registered entry
* is assigned a lower priority than the shipped one, which makes it the
* winner: the page would render your component alone, with every seat the
* frame declares gone. For a surface of your own that floats over the whole
* app, register into `shell.overlay` instead (a list slot: additive, and
* click-through until your entry opts into pointer events).
*/
'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps } 'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps }
} }
} }
@@ -274,6 +287,43 @@ export class SlotRegistry extends Service {
return this._core.entries(key) return this._core.entries(key)
} }
/**
* Shadowing winners per cell for a key: the first live (non-abdicated)
* entry of each cell in priority order — what outlets render; chain keys
* pass through unchanged (election consumes every entry). The raw
* {@link SlotsService.entries} view stays the inspection surface. Fresh
* array per call, not a uSES getSnapshot source.
* @param key - SlotMap key.
* @returns the winning entry per occupied cell.
*/
entriesOfSlot(key: keyof SlotMap & string): readonly StoredEntry[] {
return this._core.entriesOfSlot(key)
}
/**
* Export the current JSON-safe Slot declaration tree for read-only inspection.
* @param root - exact live Slot root; omitted returns all roots.
* @returns selected Slot trees.
*/
snapshot(root?: string): LiveSlotNode[] {
return this._core.snapshot(root)
}
/**
* Observe entry boundary crashes (every render-time entry failure the
* boundaries contain, abdicating or not) — the supervision seam for
* plugins mirroring contribution health. Fires synchronously per report,
* after the registry mutated for abdicating crashes. Callers own the
* disposer (wire it through ctx.effect for fiber-lifetime cleanup, as with
* {@link SlotsService.subscribe}).
* @param fn - called with the slot key, the crashed entry, the crash
* cause, and `abdicated`: whether the crash retired the entry from its cell.
* @returns unsubscribe.
*/
onEntryError(fn: (key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void): () => void {
return this._core.onEntryError(fn)
}
/** /**
* Look up a declared spec (register-declared or the built-in 'root'). * Look up a declared spec (register-declared or the built-in 'root').
* @param key - SlotMap key. * @param key - SlotMap key.
@@ -353,6 +403,8 @@ export class SlotRegistry extends Service {
subscribe: (key, fn) => this._core.subscribe(key, fn), subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key), getVersion: key => this._core.getVersion(key),
entriesOf: key => this._core.entries(key), entriesOf: key => this._core.entries(key),
entriesOfSlot: key => this._core.entriesOfSlot(key),
reportEntryError: (key, entry, error, info) => { this._core.reportEntryError(key, entry, error, info) },
specOf: key => this._core.specDynamic(key), specOf: key => this._core.specDynamic(key),
isLive: entry => this._core.isLive(entry), isLive: entry => this._core.isLive(entry),
storeOf: (entry, scopeKey) => storeOf: (entry, scopeKey) =>

View File

@@ -33,16 +33,32 @@ export interface ComposerAttachment {
declare module '@deepseek-ai/dsh-client-ui-slots' { declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap { interface SlotMap {
/** /**
* Strict-session body inside the resident conversation scrollport. It * The entire body of one session: taking this seat means rendering that
* owns the per-session draft mirror and active view ring. * session's conversation yourself. The occupant also owns the per-session
* draft mirror and the active view ring, so a replacement inherits both
* duties and an empty one leaves a blank session pane — nothing here
* degrades gracefully. To ADD rather than replace, take a seat inside the
* flow instead: `conversation.view` for a whole tab, the input regions for
* composer chrome.
*/ */
'conversation.session': { kind: 'single'; scope: 'session' } 'conversation.session': { kind: 'single'; scope: 'session' }
/** Strict-session header above the resident conversation scrollport. */ /**
* The strip above the session's scrollport: title, view tabs, and the
* action row. Taking this seat means rendering all three yourself, and it
* also collapses `conversation.session.header.actions` — that additive
* seat is declared by whoever occupies this one, so replacing the header
* takes every action entry down with it.
*/
'conversation.session.header': { kind: 'single'; scope: 'session' } 'conversation.session.header': { kind: 'single'; scope: 'session' }
/** /**
* Session-header actions contributed by feature plugins. Entries render * One button in the session header's action row — the additive way to put
* by ascending `order`; negative values are reserved for static session * a per-session control beside the title without replacing the header.
* context that precedes interactive actions. * Entries render by ascending `order`; negative values are reserved for
* static session context that precedes interactive actions. The owner
* passes nothing: everything a control needs comes from the framework
* session kit (`sessionId`, `useSession`, `useInput`, `inputActions`) and
* from the registrant's own inject face, so an empty owner share means
* self-sufficient, not starved.
*/ */
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } 'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/** /**
@@ -95,7 +111,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
scope: 'session' scope: 'session'
owner: AssistantActionOwnerProps owner: AssistantActionOwnerProps
} }
/** Selected Tool call output inside the details panel. */ /**
* The body of the details panel for the tool call the user selected —
* one occupant, so taking it means rendering every tool's output, not just
* the ones you know. The owner passes a frozen `block` whose two lifecycle
* forms must both be handled: branch on `'kind' in block` (a settled
* `ToolResultNode` has it, a still-running call does not), and treat
* `cwd` as display-only, for shortening workspace-rooted paths.
* A per-tool renderer belongs in the keyed `tool.call.toolview` seat
* instead; this one is the whole panel.
*/
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps } 'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
/** /**
* The composer takeover chain: entries are selector-routed replacements * The composer takeover chain: entries are selector-routed replacements
@@ -124,15 +149,41 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
// ui-input-trigger, so the type arrives transitively). The runtime declaration // ui-input-trigger, so the type arrives transitively). The runtime declaration
// (children table in apply.ts) stays here with the other input slots. // (children table in apply.ts) stays here with the other input slots.
/** /**
* Stacked strip above the input (queue rows / GoalBar / attachments; * A full-width row of its own, stacked above the composer card — the seat
* entries coexist in fixed order). * for anything that needs a line to itself (queue rows, a todo strip, a
* goal bar). Pick this over the three seats below when your content wraps
* or carries prose; pick `conversation.composer.dock` for an ambient
* readout under the card, and `conversation.input.left` /
* `.right` for a small control INSIDE the card's tool row.
* Read only `session`/`input` off the owner share ({@link InputZone}) —
* both are point-in-time snapshots re-rendered for you, never subscribe.
*/ */
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone } 'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */ /**
* The band under the composer card, inside the bar's width column — the
* seat for an ambient readout about the conversation (the shipped stats
* line lives here). Same {@link InputZone} owner share as the other
* regions. Anything the user must click belongs in the tool row instead
* (`conversation.input.left` / `.right`); anything needing its own line
* above the card belongs in `conversation.input.dock`.
*/
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone } 'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** Tool-row left region inside the input card (existing chrome stays in place beside entries). */ /**
* The left end of the tool row INSIDE the composer card, after the
* resident chrome (access mode, plan, attach) — the seat for a small
* always-visible control. Entries sit beside that chrome, never replace
* it. Same {@link InputZone} owner share; use `.right` for a control that
* belongs next to the send button, and the docks for anything taller than
* one row.
*/
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone } 'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
/** Tool-row right region inside the input card. */ /**
* The right end of the same tool row, before the primary send button —
* the seat for a control the user reaches on the way to sending (the
* model select sits in its own named seat just left of here). Same
* {@link InputZone} owner share and the same one-row height budget as
* `conversation.input.left`.
*/
'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone } 'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone }
/** /**
* The default composer body: a single slot rendered as the composer * The default composer body: a single slot rendered as the composer
@@ -149,15 +200,23 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/ */
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/** /**
* The Plan-mode status seat in the composer tool row (left group, * The named plan-status seat in the composer tool row, immediately right
* right of the access-mode control). Declared by the composer-bar * of the access-mode control — one occupant, so taking it means rendering
* entry; empty until a plan plugin registers (no placeholder * the plan affordance yourself. The owner passes only `locked` (see
* fallback). * {@link InputControlOwnerProps}): honour it by refusing interaction, and
* take everything else from the framework session kit or your own inject.
* Unoccupied, the seat renders nothing at all — the bar paints no
* placeholder, so an absent plan plugin costs no layout.
*/ */
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } 'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
/** /**
* The model-select seat in the composer tool row (right group). Same * The named model-select seat at the right end of the composer tool row,
* empty-until-registered contract as the plan seat. * left of the send button — one occupant, so taking it means rendering the
* whole model affordance yourself. Same `locked`-only owner share and same
* renders-nothing-while-empty contract as the plan seat. Note the composer
* deliberately keeps this seat LIVE while it refuses text for a
* model-related block: every such block is one the user clears by picking
* a model here.
*/ */
'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } 'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
} }

View File

@@ -10,8 +10,16 @@ import type { InputTriggerController } from './controller.ts'
/** The `ctx.inputTriggers` service face. */ /** The `ctx.inputTriggers` service face. */
export interface InputTriggerServiceContract { export interface InputTriggerServiceContract {
/** Register one trigger source; effect disposer. Duplicate (trigger, name) throws. */ /**
* Register one trigger source; duplicate trigger/name pairs throw.
* @param src - source that discovers and resolves slash or reference candidates.
* @returns effect disposer removing this source.
*/
registerSource(src: InputTriggerSource): () => void registerSource(src: InputTriggerSource): () => void
/** Resolve the per-session controller for one session scope (lazy; dies with the scope). */ /**
* Resolve the lazy controller owned by one session scope.
* @param actx - session-scoped Client context.
* @returns controller that dies with that scope.
*/
sessionOf(actx: ClientContext): InputTriggerController sessionOf(actx: ClientContext): InputTriggerController
} }

View File

@@ -36,11 +36,51 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
// there); these four are the frame's children, declared by the same // there); these four are the frame's children, declared by the same
// register() call that contributes AppFrame. Session owners never pass // register() call that contributes AppFrame. Session owners never pass
// sessionId: the framework injects it as a standard prop. // sessionId: the framework injects it as a standard prop.
/**
* The whole left column. OCCUPIED by ui-sidebar's SidebarRoot, which
* declares the workspace and settings seats inside it — registering here
* replaces the navigation column outright rather than adding to it, and
* the seats it declares disappear with it. To add something to the
* sidebar, register into one of those inner seats instead.
*
* The occupant receives the frame's live column state (collapsed, width)
* and is expected to render the compact control rail while collapsed.
*/
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps } 'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
// Current-session-optional: the occupant owns both the no-session hero /**
// and live conversation states without changing its React identity. * The whole center column, across both the no-session hero and a live
* conversation. OCCUPIED by ui-conversation's ConversationRoot, which
* declares the session body, composer, and input seats inside it —
* registering here replaces the entire conversation surface (and removes
* every seat it declares) rather than adding to it.
*
* Current-session-optional: the occupant owns both states without
* changing its React identity, so it keeps its own state across a session
* switch. It receives no owner props; session facts arrive through the
* framework hooks of the `session-maybe` scope.
*/
'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps } 'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps }
/**
* The right details column, shown when the layout opens it. OCCUPIED by
* ui-conversation's DetailsPanel, which declares the tool-details seat
* inside it — registering here replaces the column and takes that seat
* with it. Absent an occupant the column renders nothing.
*
* No owner props: the framework injects the session id and hooks for the
* `session` scope, and `ctx.layout` owns whether the column is open.
*/
'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps } 'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps }
/**
* Frame-wide floating layer, above every column and outside their scroll
* containers. Deliberately generic and unowned by any feature: a badge, a
* toast stack or a status pill all belong here, and entries order among
* themselves. The layer itself is click-through — entries opt back into
* pointer events — so an occupant never blocks the app underneath.
*
* This is the additive seat for a frame-wide surface of your own: a fresh
* `id` is added beside the shipped entries instead of replacing them.
*/
'shell.overlay': { kind: 'list'; scope: 'root' }
} }
} }
@@ -83,6 +123,7 @@ export function apply(ctx: ClientContext): void {
'sidebar': { kind: 'single', scope: 'root' }, 'sidebar': { kind: 'single', scope: 'root' },
'conversation': { kind: 'single', scope: 'session-maybe' }, 'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' }, 'details': { kind: 'single', scope: 'session' },
'shell.overlay': { kind: 'list', scope: 'root' },
}, },
// Exclusive store: the factory itself — the framework instantiates per // Exclusive store: the factory itself — the framework instantiates per
// entry and delivers useStore/actions to AppFrame as standard props. // entry and delivers useStore/actions to AppFrame as standard props.

View File

@@ -601,6 +601,24 @@ export const IconCodeOutline16 = ({ size = 16, className }: IconProps) => (
</svg> </svg>
) )
/** ic_ds_cordis_plugin_outline_14 */
export const IconCordisPluginOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_1840_45990)">
<path
d="M3.03426 5.66661L1.70084 7.00003L3.0315 8.33069L2.14762 9.21457L-0.0669245 7.00003L2.15038 4.78273L3.03426 5.66661ZM7 14.067L4.77924 11.8462L5.66313 10.9623L7 12.2992L8.33342 10.9658L9.2173 11.8496L7 14.067ZM11.8489 9.21803L10.965 8.33414L12.2992 7.00003L10.9623 5.66316L11.8462 4.77927L14.0669 7.00003L11.8489 9.21803ZM8.33066 3.03153L7 1.70087L5.66589 3.03498L4.782 2.1511L7 -0.0668945L9.21454 2.14765L8.33066 3.03153Z"
fill="currentColor"
/>
<rect x="5.98535" y="5.98535" width="2.02942" height="2.02942" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_1840_45990">
<rect width="14" height="14" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
/** ic_ds_api_outline (figma extract) */ /** ic_ds_api_outline (figma extract) */
export const IconApiOutline14 = ({ size = 14, className }: IconProps) => ( export const IconApiOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none"> <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none">

View File

@@ -74,14 +74,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/ */
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps } 'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
/** /**
* One preference row inside the General section, contributed by the * One preference row inside the General section — the additive seat for a
* feature plugin that owns the preference (locale → Language, ui-theme → * single setting that needs no page of its own (a whole page is
* Appearance, ui-conversation → Composer Enter). Options: `id` (row key), * `settings.section`), contributed by the feature plugin that owns the
* `order` (row position). Rows draw their own internals; the section * preference (locale → Language, ui-theme → Appearance, ui-conversation
* column only stacks them. Declared at runtime by ui-settings-general's * Composer Enter). Options: `id` (row key), `order` (row position). The
* General entry — the type lives here with every other settings slot type, * section column only stacks rows, so a row draws its own internals,
* because this package is the settings domain's base layer and every * including its label: nothing projects a `label` here and the owner passes
* registrant already depends on it for `ctx.settingsScope`. * no props at all — copy, current value, and the write path are all yours,
* through your own inject face and `host.call`. Declared at runtime by
* ui-settings-general's General entry; the type lives here with every other
* settings slot type, because this package is the settings domain's base
* layer and every registrant already depends on it for `ctx.settingsScope`.
*/ */
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps } 'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
} }

View File

@@ -227,39 +227,34 @@
padding-left: 0; padding-left: 0;
} }
/* Footer seats: Settings fills the left side and additive actions sit on the /* Footer seats: additive actions stack above Settings. Each occupant owns its
right. Each occupant owns its button geometry and hover chrome. */ button geometry and hover chrome. */
.footArea { .footArea {
flex: none; flex: none;
display: flex; display: flex;
align-items: flex-end; flex-direction: column;
gap: 8px;
} }
.settingsArea { .settingsArea,
flex: 1; .footerActions {
flex: none;
min-width: 0; min-width: 0;
width: 100%;
} }
.footerActions { .footerActions {
flex: none;
display: flex; display: flex;
align-items: flex-end;
} }
/* The 56px rail cannot hold two controls side by side. Keep both reachable in
the same footer, stacked in their original order. */
.collapsed .footArea { .collapsed .footArea {
flex-direction: column;
align-items: center; align-items: center;
gap: 0;
} }
.collapsed .settingsArea, .collapsed .settingsArea,
.collapsed .footerActions { .collapsed .footerActions {
flex: none;
display: flex; display: flex;
justify-content: center; justify-content: center;
width: auto;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@@ -177,14 +177,14 @@ export function SidebarRoot({
})} })}
</div> </div>
{/* Footer: Settings stays on the left; optional actions sit beside it. */} {/* Footer actions stack above Settings in both sidebar widths. */}
<div className={css.footArea}> <div className={css.footArea}>
<div className={css.settingsArea}>
{renderSlot('sidebar.settings', { wide })}
</div>
<div className={css.footerActions}> <div className={css.footerActions}>
{renderSlot('sidebar.footer.action', { wide })} {renderSlot('sidebar.footer.action', { wide })}
</div> </div>
<div className={css.settingsArea}>
{renderSlot('sidebar.settings', { wide })}
</div>
</div> </div>
</div> </div>
) )

View File

@@ -4,7 +4,8 @@
* owns column geometry (fold state machine, brand row, New Session); * owns column geometry (fold state machine, brand row, New Session);
* everything between the section header and the list bottom is the * everything between the section header and the list bottom is the
* `sidebar.workspaces` registrant's (ui-workspace), and the foot is the * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the
* `sidebar.settings` registrant's (ui-settings). * `sidebar.settings` registrant's (ui-settings), followed by optional footer
* actions in `sidebar.footer.action`.
*/ */
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
@@ -27,6 +28,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* The sidebar passes only its column state — it holds no settings state. * The sidebar passes only its column state — it holds no settings state.
*/ */
'sidebar.settings': { kind: 'single'; scope: 'root'; owner: SidebarSettingsOwnerProps } 'sidebar.settings': { kind: 'single'; scope: 'root'; owner: SidebarSettingsOwnerProps }
/**
* Optional actions beside Settings at the sidebar foot. Declared by this
* package's 'sidebar' entry; each action receives only the column state.
*/
'sidebar.footer.action': { kind: 'list'; scope: 'root'; owner: SidebarFooterActionOwnerProps }
} }
} }

View File

@@ -42,6 +42,21 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
export type ThemeTokens = Record<string, string> export type ThemeTokens = Record<string, string>
/**
* One override-layer token value: both palette modes are mandatory (repeat
* the same value when the token is scheme-invariant) so an override never
* goes illegible when the user switches to the other scheme.
*/
export interface ThemeTokenModes {
/** Value applied while the light base palette is active. */
light: string
/** Value applied while the dark base palette is active. */
dark: string
}
/** Override-layer dictionary: token names to per-mode value pairs. */
export type ThemeTokenOverrides = Record<string, ThemeTokenModes>
/** One selectable theme: id, dark/light semantics, and alias-token overrides. */ /** One selectable theme: id, dark/light semantics, and alias-token overrides. */
export interface ThemeDefinition { export interface ThemeDefinition {
/** Theme id (the setTheme argument for concrete themes). */ /** Theme id (the setTheme argument for concrete themes). */
@@ -59,7 +74,11 @@ export interface ThemeDefinition {
export interface ThemeSnapshot { export interface ThemeSnapshot {
/** The persisted preference (may be `system`). */ /** The persisted preference (may be `system`). */
preference: ThemePreference preference: ThemePreference
/** The resolved active theme (`system` resolved via prefers-color-scheme). */ /**
* The resolved active theme (`system` resolved via prefers-color-scheme)
* with override layers folded into its tokens (seq order, later layers win
* per-token; each value picked for the active color scheme).
*/
active: ThemeDefinition active: ThemeDefinition
/** Registered themes in registration order. */ /** Registered themes in registration order. */
themes: readonly ThemeDefinition[] themes: readonly ThemeDefinition[]
@@ -67,6 +86,20 @@ export interface ThemeSnapshot {
revision: number revision: number
} }
/** One theme token exposed to pre-definition Cordis inspection. */
export interface ThemeTokenInspection {
/** Token name accepted by {@link ThemeService.overrideTokens}. */
name: string
/** Intended visual role. */
description: string
/** CSS value category. */
valueType: string
/** Whether override layers must supply both palette modes. */
requiresLightAndDark: boolean
/** CSS custom property consumed by UI styles. */
cssVariable?: string
}
declare module '@deepseek-ai/cordis' { declare module '@deepseek-ai/cordis' {
interface Context { interface Context {
theme: ThemeRuntime theme: ThemeRuntime
@@ -87,11 +120,29 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }), Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }),
]) ])
const BUILTIN_INSPECT_TOKENS: readonly ThemeTokenInspection[] = Object.freeze([
{ name: '--dsw-alias-bg-base', description: 'Application base background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-base' },
{ name: '--dsw-alias-bg-layer-1', description: 'Primary raised surface background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-layer-1' },
{ name: '--dsw-alias-bg-layer-2', description: 'Secondary nested surface background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-layer-2' },
{ name: '--dsw-alias-bg-overlay', description: 'Overlay and popover background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-overlay' },
{ name: '--dsw-alias-border-l1', description: 'Primary subtle border.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-border-l1' },
{ name: '--dsw-alias-border-l2', description: 'Secondary stronger border.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-border-l2' },
{ name: '--dsw-alias-brand-primary', description: 'Primary brand accent.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-brand-primary' },
{ name: '--dsw-alias-label-primary', description: 'Primary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-primary' },
{ name: '--dsw-alias-label-secondary', description: 'Secondary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-secondary' },
{ name: '--dsw-alias-state-error-primary', description: 'Primary error state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-error-primary' },
{ name: '--dsw-alias-state-success-primary', description: 'Primary success state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-success-primary' },
{ name: '--dsw-alias-state-warn-primary', description: 'Primary warning state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-warn-primary' },
{ name: '--dsw-specific-sidebar-fill', description: 'Sidebar column and title-row background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-specific-sidebar-fill' },
])
/** /**
* Theme registry and preference owner. `light`/`dark` are built in (the base * Theme registry and preference owner. `light`/`dark` are built in (the base
* stylesheets carry both palettes); third-party themes register alias-layer * stylesheets carry both palettes); third-party themes register alias-layer
* overrides. Reads go through {@link getTheme}; writes only through * overrides. Reads go through {@link getTheme}; preference writes only
* {@link setTheme}; continuous sync only through the `theme/change` event. * through {@link setTheme}; continuous sync only through the `theme/change`
* event. {@link overrideTokens} stacks partial token layers over the active
* theme without touching the registry.
* The service holds the `prefers-color-scheme` media query (environment * The service holds the `prefers-color-scheme` media query (environment
* sensing, not presentation) and re-emits when the OS scheme flips while the * sensing, not presentation) and re-emits when the OS scheme flips while the
* preference is `system`. * preference is `system`.
@@ -104,6 +155,9 @@ export class ThemeRuntime {
private revision = 0 private revision = 0
private snapshot: ThemeSnapshot private snapshot: ThemeSnapshot
private readonly media: MediaQueryList | undefined private readonly media: MediaQueryList | undefined
/** Override layers by source; seq (monotonic) is the stacking order. */
private readonly overrides = new Map<string, { seq: number; tokens: ThemeTokenOverrides }>()
private overrideSeq = 0
/** /**
* @param ctx - owning context (change events are emitted on it; the * @param ctx - owning context (change events are emitted on it; the
@@ -140,6 +194,25 @@ export class ThemeRuntime {
return this.snapshot return this.snapshot
} }
/**
* Export the current token directory without reading DOM or computed styles.
* @returns stable JSON-safe token descriptions, including registered and override-only names.
*/
exportInspectTokens(): ThemeTokenInspection[] {
const tokens = new Map(BUILTIN_INSPECT_TOKENS.map(token => [token.name, token]))
for (const theme of this.themes) {
for (const name of Object.keys(theme.tokens)) {
if (!tokens.has(name)) tokens.set(name, dynamicToken(name))
}
}
for (const layer of this.overrides.values()) {
for (const name of Object.keys(layer.tokens)) {
if (!tokens.has(name)) tokens.set(name, dynamicToken(name))
}
}
return [...tokens.values()].map(token => ({ ...token })).sort((left, right) => left.name.localeCompare(right.name))
}
/** /**
* Switch the theme preference — the only user preference write entry. * Switch the theme preference — the only user preference write entry.
* Built-in preferences are written through the settings scope and every * Built-in preferences are written through the settings scope and every
@@ -189,6 +262,33 @@ export class ThemeRuntime {
} }
} }
/**
* Stack a token override layer on top of the active theme — the token-level
* analogue of slot shading: the base theme stays untouched, layers compose
* in seq order with later layers winning per-token, and removing a layer
* restores whatever it covered. Calling again with the same source replaces
* that source's whole layer and restacks it on top (effect re-registration
* semantics). Emits `theme/change` with the recomposed snapshot.
* @param source - layer identity; one layer per source (dynamic packages
* pass their package id — the façade pins it, so it also names the layer's
* origin for inspection).
* @param tokens - token-name → `{ light, dark }` value pairs. Validated at
* runtime (model-authored callers reach this boundary with untyped JS);
* a bare string value throws a teaching error.
* @returns disposer removing exactly the layer this call created; a no-op
* once the source has re-overridden (the newer layer is not torn down).
*/
overrideTokens(source: string, tokens: ThemeTokenOverrides): () => void {
const layer = { seq: this.overrideSeq++, tokens: validateOverrides(source, tokens) }
this.overrides.set(source, layer)
this.publish()
return () => {
if (this.overrides.get(source) !== layer) return
this.overrides.delete(source)
this.publish()
}
}
private buildSnapshot(): ThemeSnapshot { private buildSnapshot(): ThemeSnapshot {
const resolvedId = this.preference === 'system' const resolvedId = this.preference === 'system'
? (this.media?.matches === true ? 'dark' : 'light') ? (this.media?.matches === true ? 'dark' : 'light')
@@ -200,12 +300,29 @@ export class ThemeRuntime {
if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`) if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`)
return Object.freeze({ return Object.freeze({
preference: this.preference, preference: this.preference,
active, active: this.composeActive(active),
themes: Object.freeze([...this.themes]), themes: Object.freeze([...this.themes]),
revision: this.revision, revision: this.revision,
}) })
} }
/**
* Fold the override layers into the active definition: seq order, later
* layers win per-token, each value picked for the active color scheme (the
* presenter consumes the composed snapshot and needs no override awareness).
* Without layers the registered definition passes through by identity.
*/
private composeActive(active: ThemeDefinition): ThemeDefinition {
if (this.overrides.size === 0) return active
const tokens: ThemeTokens = { ...active.tokens }
for (const layer of [...this.overrides.values()].sort((a, b) => a.seq - b.seq)) {
for (const [name, modes] of Object.entries(layer.tokens)) {
tokens[name] = modes[active.colorScheme]
}
}
return Object.freeze({ ...active, tokens: Object.freeze(tokens) })
}
private publish(): void { private publish(): void {
this.revision += 1 this.revision += 1
this.snapshot = this.buildSnapshot() this.snapshot = this.buildSnapshot()
@@ -213,6 +330,44 @@ export class ThemeRuntime {
} }
} }
/**
* Runtime shape check for one override layer (model-authored callers pass
* untyped JS through the dynamic-package façade, so the static type cannot
* enforce the pair shape there). Returns a defensive per-token copy so later
* caller mutation cannot reach the stored layer.
*/
function validateOverrides(source: string, tokens: ThemeTokenOverrides): ThemeTokenOverrides {
const validated: ThemeTokenOverrides = {}
for (const [name, value] of Object.entries<unknown>(tokens)) {
if (typeof value === 'string') {
throw new TypeError(
`theme override "${name}" from "${source}" is a bare string — pass { light: ${JSON.stringify(value)}, dark: ${JSON.stringify(value)} } `
+ '(repeat the value when it is the same in both palettes); a single value goes illegible when the user switches color scheme',
)
}
if (typeof value !== 'object' || value === null
|| typeof (value as { light?: unknown }).light !== 'string'
|| typeof (value as { dark?: unknown }).dark !== 'string') {
throw new TypeError(
`theme override "${name}" from "${source}" must map to a { light, dark } pair of strings — one value per color scheme`,
)
}
const modes = value as ThemeTokenModes
validated[name] = { light: modes.light, dark: modes.dark }
}
return validated
}
function dynamicToken(name: string): ThemeTokenInspection {
return {
name,
description: 'Theme token registered by the current Client composition.',
valueType: 'CSS value',
requiresLightAndDark: true,
...(name.startsWith('--') ? { cssVariable: name } : {}),
}
}
/** /**
* Required services: settings transport plus slots/locale for the Appearance * Required services: settings transport plus slots/locale for the Appearance
* row. `remote` carries the forwarded settings invalidation that * row. `remote` carries the forwarded settings invalidation that

View File

@@ -6,7 +6,20 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
declare module '@deepseek-ai/dsh-client-ui-slots' { declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap { interface SlotMap {
/** Keyed atomic Tool call view, dispatched by the wire Tool name. */ /**
* Keyed atomic Tool call view, dispatched by the wire Tool name. Register
* with `key: '<tool name>'` to own how one tool's calls render inside a
* turn — the key domain is open (any wire tool name, including a tool your
* own package registered), so there is no compile-time key set to pick
* from and a typo simply never renders.
*
* A key the shipped composition already covers is replaced, not shared;
* an unclaimed key falls back to the generic tool row, so registering is
* additive for your own tool and a takeover for a shipped one. The owner
* passes the call's identity, its frozen running-or-settled node, and the
* expansion state (see ToolCallOwnerProps), so the view stays a pure
* function of what the turn already knows.
*/
'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps } 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
} }
} }

View File

@@ -25,7 +25,15 @@ export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call', write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
} }
/** Known tool name -> variant. */ /**
* Known tool name -> variant.
*
* `cordis_define` is deliberately absent: ui-cordis registers a keyed
* `tool.call.toolview` entry for it, and a keyed hit REPLACES the generic row
* (this table is only reached through GenericToolCard, the dispatch fallback in
* ToolCallTree). An entry here would be unreachable, and a second title for the
* same call would be a second answer to a question the card already owns.
*/
const TOOL_VARIANTS: Record<string, ToolRowVariant> = { const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
bash: 'bash', bash: 'bash',
// The PowerShell twin is a shell tool: the bash row family (icon, colors) // The PowerShell twin is a shell tool: the bash row family (icon, colors)
@@ -39,16 +47,24 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
write: 'write', write: 'write',
edit: 'edit', edit: 'edit',
run_code: 'code', run_code: 'code',
cordis_inspect: 'read', cordis_package_inspect: 'read',
cordis_mount: 'code', cordis_runtime_inspect: 'read',
cordis_unmount: 'others', // The three run-control verbs take one package id and produce a receipt, so
// the generic row is the decided intent, not an unclassified default: there is
// no program to show (that is `cordis_define`'s card) and no file to open. The
// id lands in the summary slot, and the titles below name the act.
cordis_run: 'others',
cordis_stop: 'others',
cordis_undefine: 'others',
} }
/** Tool-owned titles that refine a generic row variant without replacing it. */ /** Tool-owned titles that refine a generic row variant without replacing it. */
const TOOL_TITLES: Record<string, string> = { const TOOL_TITLES: Record<string, string> = {
cordis_inspect: 'Inspect', cordis_package_inspect: 'Inspect',
cordis_mount: 'Mount temporary Plugin', cordis_runtime_inspect: 'Inspect',
cordis_unmount: 'Unmount temporary Plugin', cordis_run: 'Run dynamic package',
cordis_stop: 'Stop dynamic package',
cordis_undefine: 'Discard dynamic package',
pwsh: 'Pwsh', pwsh: 'Pwsh',
} }

View File

@@ -216,24 +216,23 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => { it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
const parent = 'call-cordis' const parent = 'call-cordis'
const code = 'return { name: "audit", apply(ctx) {} }'
const subCalls = [ const subCalls = [
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'), subCall(11, parent, 1, 'cordis_runtime_inspect', { what: 'temporary' }, '## Dynamic Packages'),
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'), subCall(12, parent, 2, 'cordis_run', { id: 'dyn-2' }, 'Dynamic package dyn-2 is running'),
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'), subCall(13, parent, 3, 'cordis_undefine', { id: 'dyn-2' }, 'Dynamic package dyn-2 was discarded.'),
] ]
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots) const view = mountApp(b.slots)
const nest = view.container.querySelector('[data-subcalls]')! const nest = view.container.querySelector('[data-subcalls]')!
expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') // Each run-control verb names its act and shows the package id; without the
const mounted = nest.querySelector('[data-variant="code"]') // owned titles all three would read "Tool call · cordis_run · dyn-2".
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`) expect(nest.querySelector('[data-tool="cordis_runtime_inspect"]')?.textContent).toContain('Inspect')
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent) expect(nest.querySelector('[data-tool="cordis_run"]')?.textContent).toContain('Run dynamic packagedyn-2')
.toContain('Unmount temporary Plugindyn-2') expect(nest.querySelector('[data-tool="cordis_undefine"]')?.textContent).toContain('Discard dynamic packagedyn-2')
// None of them is a code row: the program belongs to cordis_define, whose
fireEvent.click(mounted!.querySelector('[data-expandable]')!) // own keyed card renders it (the next case covers the code row itself).
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) expect(nest.querySelector('[data-variant="code"]')).toBeNull()
}) })
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {

View File

@@ -40,12 +40,44 @@ describe('tool-call-model', () => {
expect(classifyTool('grep')).toBe('search') expect(classifyTool('grep')).toBe('search')
expect(classifyTool('write')).toBe('write') expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit') expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('cordis_inspect')).toBe('read') expect(classifyTool('cordis_runtime_inspect')).toBe('read')
expect(classifyTool('cordis_mount')).toBe('code') // The v3 run-control verbs: `others` is the decided intent, not an
expect(classifyTool('cordis_unmount')).toBe('others') // unclassified default (there is no program to show and no file to open).
expect(classifyTool('cordis_run')).toBe('others')
expect(classifyTool('cordis_stop')).toBe('others')
expect(classifyTool('cordis_undefine')).toBe('others')
expect(classifyTool('todo_write')).toBe('others') expect(classifyTool('todo_write')).toBe('others')
}) })
it('names each cordis verb instead of leaving it a bare tool call', () => {
// Every define/run pair the model makes puts a row in the flow, so the
// generic "Tool call · cordis_run · dyn-1" fallback is user-visible slop.
const titleOf = (name: string) => toolRowModel(name, running({ name, argsRaw: '{"id":"dyn-1"}' }))
expect(titleOf('cordis_run').title).toBe('Run dynamic package')
expect(titleOf('cordis_stop').title).toBe('Stop dynamic package')
expect(titleOf('cordis_undefine').title).toBe('Discard dynamic package')
// An owned title takes the tool name out of the summary slot, leaving the
// package id as the only mutable text.
expect(titleOf('cordis_run').summary).toBe('dyn-1')
})
it('leaves cordis_define to its own keyed toolview', () => {
// ui-cordis registers a keyed `tool.call.toolview` entry for cordis_define,
// and a keyed hit replaces the generic row (this model is only reached
// through the dispatch fallback). A mapping here would be unreachable, and a
// title here would be a second answer to what the card already renders.
const model = toolRowModel('cordis_define', running({ name: 'cordis_define', argsRaw: '{"name":"clock"}' }))
expect(model.variant).toBe('others')
expect(model.title).toBe('Tool call')
})
it('has dropped the v2 mount verbs that no longer exist', () => {
// Keeping them would be a mapping for a tool nothing can call.
expect(classifyTool('cordis_mount')).toBe('others')
expect(toolRowModel('cordis_mount', running({ name: 'cordis_mount', argsRaw: '{}' })).title).toBe('Tool call')
expect(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).title).toBe('Tool call')
})
it('gives the pwsh shell row the bash family treatment with its own title', () => { it('gives the pwsh shell row the bash family treatment with its own title', () => {
const m = toolRowModel('pwsh', running()) const m = toolRowModel('pwsh', running())
expect(m.variant).toBe('bash') expect(m.variant).toBe('bash')
@@ -141,28 +173,27 @@ describe('tool-call-model', () => {
}) })
it('gives Cordis lifecycle tools action titles over their generic variants', () => { it('gives Cordis lifecycle tools action titles over their generic variants', () => {
expect(toolRowModel('cordis_inspect', running({ expect(toolRowModel('cordis_runtime_inspect', running({
name: 'cordis_inspect', name: 'cordis_runtime_inspect',
argsRaw: '{"what":"api","name":"tools"}', argsRaw: '{"what":"api","name":"tools"}',
}))).toMatchObject({ }))).toMatchObject({
variant: 'read', variant: 'read',
title: 'Inspect', title: 'Inspect',
summary: 'api', summary: 'api',
}) })
expect(toolRowModel('cordis_mount', running({ expect(toolRowModel('cordis_run', running({
name: 'cordis_mount', name: 'cordis_run',
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}', argsRaw: '{"id":"dyn-2"}',
}))).toMatchObject({
variant: 'code',
title: 'Mount temporary Plugin',
summary: 'return { name: "audit", apply(ctx) {} }',
body: 'return { name: "audit", apply(ctx) {} }',
})
expect(toolRowModel('cordis_unmount', result({
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
}))).toMatchObject({ }))).toMatchObject({
variant: 'others', variant: 'others',
title: 'Unmount temporary Plugin', title: 'Run dynamic package',
summary: 'dyn-2',
})
expect(toolRowModel('cordis_undefine', result({
call: { name: 'cordis_undefine', argsRaw: '{"id":"dyn-2"}' },
}))).toMatchObject({
variant: 'others',
title: 'Discard dynamic package',
summary: 'dyn-2', summary: 'dyn-2',
}) })
}) })

View File

@@ -10,7 +10,7 @@
// the declaration then land through slots.inject when the chat entry appears. // the declaration then land through slots.inject when the chat entry appears.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent } from '@testing-library/react' import { cleanup } from '@testing-library/react'
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
@@ -106,22 +106,25 @@ describe('keyed toolview hole through the real machinery', () => {
}) })
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => { it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
const code = 'return { name: "audit", apply(ctx) {} }'
const b = await bench([ const b = await bench([
toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'), toolResult(3, 'cordis-1', 'cordis_runtime_inspect', '{"what":"api","name":"tools"}'),
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })), toolResult(4, 'cordis-2', 'cordis_run', '{"id":"dyn-2"}'),
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'), toolResult(5, 'cordis-3', 'cordis_stop', '{"id":"dyn-2"}'),
toolResult(6, 'cordis-4', 'cordis_undefine', '{"id":"dyn-2"}'),
]) ])
const view = b.runtime.renderRoot() const view = b.runtime.renderRoot()
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') // Every one of these rows is user-visible on each model define/run, so each
const mounted = view.container.querySelector('[data-variant="code"]') // names its act and carries the package id rather than falling back to the
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`) // generic "Tool call · <name> · <id>" row.
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent) const rowText = (name: string) => view.container.querySelector(`[data-tool="${name}"]`)?.textContent
.toContain('Unmount temporary Plugindyn-2') expect(rowText('cordis_runtime_inspect')).toContain('Inspect')
expect(rowText('cordis_run')).toContain('Run dynamic packagedyn-2')
fireEvent.click(mounted!.querySelector('[data-expandable]')!) expect(rowText('cordis_stop')).toContain('Stop dynamic packagedyn-2')
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) expect(rowText('cordis_undefine')).toContain('Discard dynamic packagedyn-2')
// No run-control verb is a code row; the program is cordis_define's, and its
// own keyed card owns that rendering.
expect(view.container.querySelector('[data-variant="code"]')).toBeNull()
await b.runtime.dispose() await b.runtime.dispose()
}) })

View File

@@ -2,8 +2,12 @@
English | [中文](README.zh.md) 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). Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service API, define and run model-written dynamic packages, and retract them again — plus the restricted repository Plugin runtime. Both browser-half packages live here rather than under `packages/client/` because they are halves of this subsystem's dual-half packages; the host aggregate excludes them so each face keeps its own compiler program. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
| Package | Role | ctx key | | Package | Role | ctx key |
|---|---|---| |---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | Model-facing runtime inspection and temporary-plugin tools | registers on `ctx.tools` | | [`tool-cordis/`](tool-cordis/README.md) | Model-facing runtime inspection and dynamic-package tools | registers on `ctx.tools` |
| [`cordis-host-runner/`](cordis-host-runner/README.md) | Definition registry, the `node:vm` sandbox for host halves, and the request-run round trip | provides `ctx.dynamicCordisRunner` |
| [`cordis-client-runner/`](cordis-client-runner/README.md) | Browser half of a dual-half package: evaluates the definition into a live browser plugin and answers the run request | client face; provides the browser `ctx.dynamicCordisRunner` |
| [`ui-cordis/`](ui-cordis/README.md) | Browser surfaces: the frame-wide panel that operates every definition, and the read-only define card | client face; registers slots |
| [`repository-plugin/`](repository-plugin/README.md) | Repository skill and MCP composition | registers a Loader builtin |

View File

@@ -2,8 +2,12 @@
[English](README.md) | 中文 [English](README.md) | 中文
agent 修改自身运行时:检查已加载的插件与服务接口、挂载模型编写的插件并再次 dispose。该组是未来自我修改类包的落点。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 agent 修改自身运行时:检查已加载的插件与服务接口、定义并运行模型编写的动态包dynamic package并再次撤下外加受限 repository Plugin 运行时。两个浏览器半的包住在这里而不是 `packages/client/`因为它们是本子系统双半包的其中一半host 聚合把它们排除在外,让两个契约面各自保有独立的编译 program。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
| 包 | 角色 | ctx 键 | | 包 | 角色 | ctx 键 |
|---|---|---| |---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_mount``cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` | | [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_define``cordis_run``cordis_stop``cordis_undefine` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的动态包 | 注册到 `ctx.tools` |
| [`cordis-host-runner/`](cordis-host-runner/README.md) | 定义注册表、host 半的 `node:vm` 沙箱,以及 request-run 往返 | 提供 `ctx.dynamicCordisRunner` |
| [`cordis-client-runner/`](cordis-client-runner/README.md) | 双半包的浏览器半:把定义求值成活的浏览器插件,并应答运行请求 | client 面;提供浏览器侧 `ctx.dynamicCordisRunner` |
| [`ui-cordis/`](ui-cordis/README.md) | 浏览器面:操作全部定义的全局面板,与只读的 define 卡片 | client 面;注册 slot |
| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin |

View 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/cordis-client-runner/README.md
README.md: 530f7d225ea74e34651b54fe94c4211730c6e271
README.zh.md: f78de8e38c37b43d758aec5c285a04c34a7eff22

View File

@@ -0,0 +1,68 @@
# @deepseek-ai/dsh-cordis-client-runner
English | [中文](README.zh.md)
Browser half of dynamic dual-half plugin packages. The host-side runner holds every definition's code in process memory and asks the open pages, over a `cordis/request-run` event, whether to run one; this package answers that request, turns the definition into a live browser plugin, and turns a `dynamicCordisRunner/retract` event back into a clean page.
## What it does
1. **Event subscription** — the four announcements are forwarded host cordis events, so this package consumes `cordis/request-run`, `cordis/request-run-resolved`, and `dynamicCordisRunner/retract` through `ctx.remote.$on`, whose key set IS the api-remotes allowlist.
2. **Closure evaluation** — the browser half's source runs as an async function body whose parameters are its symbol surface (`React`, `console`, `styles`, `host`, plus teaching traps shadowing `setTimeout`/`fetch`/`require`). No JSX, no TypeScript, no module imports.
3. **Guard facade**`apply` receives a whitelisting proxy over the real fiber ctx: lifecycle verbs plus the services the returned plugin declared in its own `inject` (so the object form `{ inject: ['slots'], apply(ctx) {} }` is what reaches a service; a plain function has no declaration site and reaches none). The `slots` seat assigns the shadowing priority (registering IS shadowing, newest run wins); the `theme` seat pins the override layer's source to the package id and hangs its disposer on the fiber.
4. **Loader entries** — the guarded plugin is seated in the module table and mounted through `loader.create`, so a dynamic package rides the same activation gating, fiber-effect cleanup, and status projection as a static one. Unload is entry removal plus factory invalidation plus style removal.
5. **Run orchestration** — a `cordis/request-run` event asks this page whether to run a definition. Whoever answers drives the run in order: the host half first, then the source fetch, then the browser half, then one resolution carrying what happened. A user pressing "run" is itself the authorization and orchestrates the same way with nothing to answer — and for a host-only definition the run ends at the host half, because there is no second half to fetch or load here.
6. **Package-internal RPC** — a package's `host.call` routes to its own host half through the `dynamicCordisRunner` Remote namespace (`invoke`), and each routing failure code becomes its own teaching error. Both directions carry JSON only: an omitted argument travels as `null` (so `host.call('listServices')` is legal and the handler receives `null`), and a payload the generated codec refuses — a function, `undefined`, a class instance — becomes a teaching error naming the call and the contract instead of the codec's bare field name.
7. **Render-failure reflow** — the slot registry's supervision seam (`slots.onEntryError`) fires for every entry-boundary crash on the page; the ones belonging to a package this runner seated go to two outlets from that one observation: upstream to the authoring session (`reportRenderFailure`, for the model) and onto this package's own `renderFailures` face field (for the panel row). Ownership is keyed on component identity, recorded when the guard's `register` proxy seats it, because the registry stores the component verbatim — so no parallel ledger of entries has to be kept in step. This is post-settle diagnosis only: it carries no settle authority, never touches a run resolution, and a failed report is swallowed rather than turning one crash into two.
## Lifecycle
Loads converge by `(id, rev)` against live state: loading a revision this page already runs answers from live state without reloading (so a replayed run does not look unanswered), a newer revision replaces it, and the same revision after a retract loads afresh. Operations serialize per definition.
Nothing loads at activation, and nothing is restored after a refresh — a page runs a dynamic package only when someone answers a run request or asks for it here.
## What a run surface reads and calls
`ctx.dynamicCordisRunner` is the whole face:
- `activeRuns` — each definition's single in-flight activity: `awaiting-approval` (the request id to answer plus the ask's session, package name, and purpose) or `orchestrating` (the session the run is being carried out for). Both arms name the session because grouping belongs to the run, not to its phase; the waiting arm carries the ask's own text because `cordis_define` broadcasts nothing, so a request can name a definition the last registry read does not cover and then this entry is the only source that row has. A surface renders from it and keeps no copy, which is what makes the affordance survive a remount.
- `renderFailures` — this page's last render crash per definition (slot, teaching message, and whether the crash retired the entry from its cell), on the same notification channel as the live set. Page-local and current by construction: it clears when the package stops, is retracted, or loads again, so a row can render it directly. The host keeps its own last-across-pages copy for the model — the two have different owners and lifetimes, and a surface must not read the host's back in place of this one.
- `lastRunError` — why this page's own attempt failed, per definition. It outlives the activity, because the host disposes only the half a failed request started: a page can be looking at a definition the host reports as running while having nothing loaded itself.
- `approve(requestId)` / `decline(requestId)` / `startUserRun({ agentId, id, hasClientHalf })` — the two entries. All three are idempotent (per request id, and per definition for the user's own run), so a double press cannot start two runs. `hasClientHalf` is required: a host-only definition has no source to fetch, so the caller states the shape from the registry row it is acting on rather than the orchestrator learning it from a failed fetch. An answerable request always has a browser half, because the host runs a host-only definition itself instead of asking a page.
- `subscribe()` / `getSnapshot()` / `isLoaded(id)` — what this page has loaded. `isLoaded` is page-local truth, never the host's "it is running".
## Model Experience
### Run resolution, when a model asked for the run
#### What the model sees
This package contributes no tool, prompt, or context of its own; the first thing it authors that reaches a model is the resolution it sends back for a `cordis/request-run` round trip, which the host turns into the blocked `cordis_run` result. A success carries the loaded revision and, for a browser half parked on services this page does not have, their names. A failure carries one reason — `rejected` when the user refused, `host-half-failed`, or `client-half-failed` — and, for the browser half, this package's own text: the failing stage (`evaluate`, `module-import`, or `activate`) followed by the closure's, guard's, or fiber's message. The guard's teaching errors (an undeclared service, a shadowed browser global, a plugin that returned no `apply`) reach the model through exactly that field. A crash that happens later, while React renders the loaded half, travels the separate post-settle path below.
#### Token effect
Conditional and bounded: at most one resolution per run request, spent inside the `cordis_run` tool result the host already emits. The text is data-dependent (a definition's own error message) and this package retains nothing across requests — a page's later load failures are page-local diagnostics with no model-visible carrier.
#### KV Cache effect
Append-only. A resolution reaches the model only as the tool result for the request that was already in flight, extending the history tail; nothing this package authors rewrites or reorders earlier request tokens, so an otherwise reusable prefix stays reusable. Repeated runs of the same definition each produce their own result rather than replacing an earlier one.
### Render failure, after the run settled
#### What the model sees
A browser half that loads cleanly can still crash when React renders it, and that crash lands after the run was answered — so the model would otherwise be told "ok" and never learn. Every entry-boundary crash of a package this page seated is sent to the host (`reportRenderFailure`) naming the slot, whether the crash retired the entry from its cell (`abdicated`: the package's UI is gone, not merely broken), and a message written for the author: the crash text, plus the redirect for a withheld browser global the text names but does not teach — `window.setInterval` around the closure trap crashes as `is not a function`, which explains nothing on its own. The host keeps the last one per package and shows it through `cordis_inspect`; nothing here reaches a run resolution. The same observation also lands on `renderFailures` for the page's own surface — one observer, two outlets, because "the last crash across pages, for the model" and "what this page is showing now" are different facts with different lifetimes.
#### Token effect
Conditional and bounded by the host's retention, not by this page: one report per crash, and the host keeps only the latest per package, so a repeatedly crashing entry costs the model one paragraph rather than a growing list. The report never enters a tool result of its own — the model pays for it only when it asks.
#### KV Cache effect
None of its own. Reports travel over RPC and are stored, not appended to the conversation; the model reads them through an inspection it chose to make, which extends the tail like any other tool result.
## Known Limitations and Deferred Work
- **A refused resolution is not retried.** The acknowledgement of `resolveRequestRun` is not read, so when the host declines a stale success (`accepted: false`, because the definition's revision moved on while this page was loading) the page keeps what it loaded and does not orchestrate again. The request stays answerable — another page's answer or the caller's cancellation settles it — and the stop that bumped the revision retracts the stale load. Retrying was evaluated and deferred: the window is one revision bump inside a single round trip.
- The plugin declares `remote.dynamic`, so it stays parked until the host-side namespace exists rather than loading packages whose host half it could never reach.
- Slot admission (allow/deny lists per deployment) has no carrier: the dispatched row declares services, not target slots.
- Guard whitelists are hand-mirrored twins of the host-side sandbox facade; sharing one specification is deferred.

View File

@@ -0,0 +1,68 @@
# @deepseek-ai/dsh-cordis-client-runner
[English](README.md) | 中文
动态双半插件包的浏览器半。host 侧 runner 把每个定义的代码留在进程内存里,并经一条 `cordis/request-run` 事件向打开的页面发问「要不要运行它」;本包回答这个请求、把定义变成活的浏览器插件,并把 `dynamicCordisRunner/retract` 事件变回干净的页面。
## 它做什么
1. **事件订阅** —— 四条公告是转发的 host cordis 事件,所以本包经 `ctx.remote.$on` 消费 `cordis/request-run``cordis/request-run-resolved``dynamicCordisRunner/retract`,而 `$on` 的键面就是 api-remotes 的白名单。
2. **闭包求值** —— 浏览器半的源码作为一个 async 函数体运行,其参数即符号面(`React``console``styles``host`,外加遮蔽 `setTimeout`/`fetch`/`require` 的教学陷阱)。无 JSX、无 TypeScript、不能 import 模块。
3. **guard 门面** —— `apply` 收到的是真 fiber ctx 之上的白名单代理:生命周期动词,加上**返回的 plugin 自己在 `inject` 里声明**的服务(所以要用对象形态 `{ inject: ['slots'], apply(ctx) {} }` 才拿得到服务;裸函数没有声明位,拿不到任何服务)。`slots` 座位分配遮蔽 priority注册即遮蔽最新一次运行者胜出`theme` 座位把覆盖层的 source 钉成包 id并把它的 disposer 挂到 fiber 上。
4. **loader entry** —— 加了 guard 的插件被塞进模块表,再经 `loader.create` 挂载于是动态包与静态包共享同一套激活门控、fiber effect 清理与状态投影。卸载 = 移除 entry + 失效 factory + 撤下样式。
5. **run 编排** —— 一条 `cordis/request-run` 事件问这一页要不要运行某个定义。回答的那一方按顺序把 run 跑完:先 host 半、再取源码、再浏览器半,最后一次回答带上结果。用户按下「运行」本身就是授权,同样走这条编排,只是没有要回答的对象;而纯 host 定义的 run 到 host 半就结束了 —— 这里没有第二半可取、也没有第二半可装。
6. **包内 RPC** —— 包内的 `host.call``dynamicCordisRunner` Remote namespace`invoke`)转给它自己的 host 半,三种路由失败码各自变成对应的教学错误。两个方向都只驮 JSON省略入参会以 `null` 过线(所以 `host.call('listServices')` 合法handler 收到 `null`),而生成的 codec 拒收的载荷(函数、`undefined`、类实例)会变成一条点明「哪次调用 + 约定是什么」的教学错误,而不是 codec 那个光秃秃的字段名。
7. **渲染期失败回流** —— 槽位注册表的 supervision 接缝(`slots.onEntryError`)对页面上每一次 entry 边界崩溃都会通知;凡属于本 runner 落座过的包,那**一次**观察会分两个出口:一路上行给撰写它的会话(`reportRenderFailure`,给模型看),一路发布到本包 face 上的 `renderFailures`(给面板那一行看)。归属以 component 身份为键,在 guard 的 `register` 代理落座时记下 —— 注册表原样保存 component所以不需要再维护一份与之同步的 entry 台账。这条通道纯属事后诊断:不驮任何 settle 权威、绝不触碰 run 的最终回答,而且报告本身失败时只吞不抛 —— 不让一次崩溃变成两次。
## 生命周期
装载按 `(id, rev)` 对 live 态收敛:装载这一页已在运行的那个 revision 会**直接从 live 态回答**而不重装(所以被重播的 run 不会看起来没人回答),更新的 revision 顶替旧的,同一 revision 在 retract 之后再装则重新装载。同一定义的操作串行执行。
激活时什么都不装,刷新之后也不恢复 —— 一页只在有人回答了一次 run 请求、或有人在这一页主动要求时,才运行动态包。
## run 界面读什么、调什么
`ctx.dynamicCordisRunner` 就是全部的面:
- `activeRuns` —— 每个定义唯一的在途活动:`awaiting-approval`(要回答的 requestId加上这次询问的会话、包名与用途`orchestrating`(这次 run 是为哪个会话在跑)。两条臂都带会话,因为归组属于这次 run 而不属于它的阶段;待确认那条还带着询问自己的文字,因为 `cordis_define` 什么都不播 —— 一个请求可以点名上一次注册表读取没覆盖到的定义,那时这条活动就是那一行唯一的来源。界面从它渲染、自己不留副本 —— 这正是控件能活过 remount 的原因。
- `renderFailures` —— **本页**最后一次渲染崩溃,按定义索引(槽位、教学 message、以及这次崩溃是否已把 entry 从格位上摘掉),与 live 集合共用同一条通知通道。它按构造就是「本页当前」:包 stop、被 retract、或重新装载成功时即清空所以界面可以直接照着渲染。host 那边另存一份「跨页面最后一次」给模型 —— 两份的归属与寿命本来就不同,界面**不要**改成回读 host 那份。
- `lastRunError` —— 本页自己那次尝试为何失败按定义索引。它比活动活得更久host 只拆失败请求自己启动的那半,所以一个页面可能看着 host 报告为「在跑」的定义,而自己什么都没装上。
- `approve(requestId)` / `decline(requestId)` / `startUserRun({ agentId, id, hasClientHalf })` —— 两条入口。三者都幂等(按 requestId用户自发的 run 按定义 id所以连点两次不会起两次 run。`hasClientHalf` 是必填:纯 host 定义没有源码可取,所以由调用方从它正在操作的注册表行里把这个事实说出来,而不是让编排器从一次失败的取码里反推。可回答的请求必然带浏览器半 —— 纯 host 定义是 host 自己起的,它不会去问页面。
- `subscribe()` / `getSnapshot()` / `isLoaded(id)` —— 这一页装了什么。`isLoaded` 是页面本地的事实,永远不等于 host 说的「在跑」。
## 模型体验
### 由模型发起那次 run 的最终回答
#### 模型看到什么
本包自己不贡献任何工具、提示词或上下文;它为一次 `cordis/request-run` 往返发回的回答,是它撰写并到达模型的第一样内容 —— host 把它变成那个被阻塞的 `cordis_run` 的结果。成功时带上已装载的 revision以及当浏览器半挂在这一页没有的服务上时那些服务的名字。失败时带一个 reason用户拒绝的 `rejected``host-half-failed`、或 `client-half-failed`;后者还带上本包自己的文本 —— 出错阶段(`evaluate` / `module-import` / `activate`加上闭包、guard 或 fiber 的消息。guard 的教学错误(未声明的服务、被遮蔽的浏览器全局、返回值里没有 `apply`正是经这个字段到达模型的。而装载之后、React 渲染时才发生的崩溃,走下面那条独立的事后通道。
#### token 影响
有条件且有界:每次 run 请求最多一个回答,花在 host 本来就会发出的那个 `cordis_run` 结果里。文本随数据而定(某个定义自己的错误消息),本包跨请求不留存任何东西 —— 一页后续的装载失败是页面本地诊断,在模型侧没有任何承载物。
#### KV cache 影响
只追加。回答只作为「本来就在途的那次请求」的工具结果到达模型、延长历史尾部;本包撰写的内容不会重写或重排更早的请求 token因此原本可复用的前缀仍然可复用。同一定义的多次运行各自产出各自的结果而不是替换更早那一个。
### run 落定之后的渲染期失败
#### 模型看到什么
一个装载得干干净净的浏览器半,仍可能在 React 渲染时崩溃,而那次崩溃发生在 run 已经被回答之后 —— 否则模型只会被告知「ok」永远学不到。凡是本页落座过的包其 entry 边界的每一次崩溃都会发回 host`reportRenderFailure`):点名槽位、说明这次崩溃是否已把 entry 从格位上摘掉(`abdicated`:包的 UI 是没了、而不只是坏了),以及一条写给作者的 message —— 崩溃文本,外加「文本里点到了某个被摘掉的浏览器全局、但文本自己没教」时补上的那句教学:绕过闭包陷阱的 `window.setInterval` 只会崩成 `is not a function`它自己什么都解释不了。host 每包只留最后一条,经 `cordis_inspect` 透给模型;这条通道上的任何东西都不会进入 run 的最终回答。同一次观察还会落到 `renderFailures` 上给本页界面用 —— 一个观察者、两个出口,因为「跨页面最后一次崩溃(给模型)」与「这一页此刻正在显示什么」是两件寿命不同的事实。
#### token 影响
有条件,且其上界由 host 的留存策略决定、不由这一页决定:每次崩溃一条报告,而 host 每包只留最新一条 —— 所以一个反复崩溃的 entry 对模型的代价是一段话,而不是一张越来越长的清单。报告本身不会自带任何工具结果:模型只在主动去问的时候才为它付费。
#### KV cache 影响
自身没有。报告经 RPC 送出并被存起来,而不是追加进对话;模型是通过自己发起的一次查看读到它的,那次查看与任何工具结果一样只延长尾部。
## 已知限制与欠账
- **被拒绝的回答不会重试。** `resolveRequestRun` 的 ack 不读,所以当 host 拒绝一个陈旧的成功答复(`accepted: false` —— 这一页装载期间定义的 revision 被顶掉了),这一页会保留已装的东西、也不再重新编排。那次请求仍可作答(别的页面作答或调用方取消都能收尾),而顶掉 revision 的那次 stop 会 retract 掉这一页的陈旧装载。重试评估过、延后:竞态窗口只是一次往返内的一次 revision 递增。
- 插件声明了 `remote.dynamic`,因此在 host 侧 namespace 存在之前一直挂起,而不是装载一些永远够不到自己 host 半的包。
- 槽位准入(按部署的允许/拒绝清单)没有载体:下发行声明的是服务,不是目标槽位。
- guard 白名单是 host 侧沙箱门面的手抄孪生;抽取共享规格留待后续。

View File

@@ -0,0 +1,79 @@
{
"name": "@deepseek-ai/dsh-cordis-client-runner",
"description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/extensions/cordis-client-runner"
},
"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"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-modules",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}

View File

@@ -0,0 +1,965 @@
/**
* Generated by scripts/gen-cordis-api.ts — do not edit by hand; run
* `pnpm run gen-cordis-api` to regenerate (freshness-gated by
* `pnpm run verify-cordis-api` in doc-sync).
*
* The machine-readable cordis API catalog `cordis_inspect` serves to the
* model: harness services (summary + structured public method contracts),
* harness events (mode + structured listener contracts), and the inherited `ctx` API. Produced by
* the same AST walk as docs/cordis-catalog, so this data and the rendered
* docs cannot diverge.
*
* @module @deepseek-ai/dsh-cordis-client-runner/client/api-catalog
*/
/** One named parameter in a Service method or Event listener. */
export interface ApiParameter {
/** Parameter name from the exact signature. */
name: string
/** Source-owned parameter contract. */
description: string
}
/** One public service member and its source-owned contract. */
export interface ServiceApiMethod {
/** Public method signature with its body stripped. */
signature: string
/** Method purpose and behavior. */
description: string
/** Named parameters in signature order. */
parameters: readonly ApiParameter[]
/** Non-void result contract when documented. */
returns?: string
/** Documented failure conditions. */
throws?: readonly string[]
}
/** One harness `ctx.<key>` service and its public methods. */
export interface ServiceApiEntry {
/** The `ctx.<key>` name, e.g. `tools`. */
key: string
/** First sentence of the service class JSDoc. */
summary: string
/** Complete service description. */
description: string
/** Public methods, bodies stripped, in source order. */
methods: readonly ServiceApiMethod[]
}
/** One harness event: its dispatch mode, exact signature, and listener contract. */
export interface EventApiEntry {
/** The scoped event name, e.g. `agent/status`. */
name: string
/** The dispatch mode from the declaration's `@mode` tag. */
mode: string
/** The exact listener signature, whitespace-normalized. */
signature: string
/** First sentence of the event JSDoc. */
summary: string
/** Complete event description. */
description: string
/** Named listener parameters in signature order. */
parameters: readonly ApiParameter[]
}
/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */
export interface InheritedApiEntry {
/** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */
name: string
/** One-line summary of what the member does. */
summary: string
}
/** One named type declaration referenced by a Service or Event signature. */
export interface TypeApiEntry {
/** The exported type/interface name, e.g. `BashRunResult`. */
name: string
/** The full declaration text, comments stripped. */
declaration: string
}
/** Every harness `ctx.<key>` service, sorted by key. */
export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'layout',
summary: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply.',
description: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply. The attachPanels wiring hook stays on the concrete class (root-entry assembly only).',
methods: [
{
signature: 'toggleSidebar(): void',
description: 'Toggle the sidebar panel (closed ⟷ contract default width).',
parameters: [],
},
{
signature: 'openDetails(): void',
description: 'Open the details panel (no-op when already open).',
parameters: [],
},
{
signature: 'closeDetails(): void',
description: 'Close the details panel.',
parameters: [],
},
],
},
{
key: 'locale',
summary: 'Dictionary registry plus locale preference.',
description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s zh fallback -> the shared common namespace (active, then zh) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).',
methods: [
{
signature: 'getLocale(): LocaleSnapshot',
description: 'Read the current immutable locale snapshot.',
parameters: [],
returns: 'the current snapshot (stable reference until the next change).',
},
{
signature: 'getSnapshot(): LocaleSnapshot',
description: 'LocaleFace getSnapshot: the current snapshot (carries `revision`; stable reference between changes, uSES-safe).',
parameters: [],
returns: 'the current snapshot.',
},
{
signature: 'subscribe(fn: () => void): () => void',
description: 'LocaleFace subscribe: notified on every snapshot change (locale switch or dictionary registration — registrations bump the revision so already rendered outlets pick up late-arriving dictionaries).',
parameters: [{"name":"fn","description":"change callback."}],
returns: 'unsubscribe.',
},
{
signature: 'setLocale(id: string): void',
description: 'Switch the active locale — the only user preference write entry.',
parameters: [{"name":"id","description":"a registered locale id; unknown ids throw."}],
},
{
signature: 'register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void',
description: 'Register a declared namespace\'s dictionaries, all locales in one call — the typed form: each dictionary is checked against the namespace\'s LocaleNamespaceMap key union (a missing or extra key is a compile error), and every shipped locale is required (bilingual balance enforced at registration). Duplicate (ns, locale) throws (single occupant; a namespace\'s texts have one owner). Registration bumps the revision so mounted outlets pick up late-arriving dictionaries.',
parameters: [{"name":"ns","description":"a namespace merged into LocaleNamespaceMap."},{"name":"dicts","description":"complete dictionaries keyed by locale id."}],
returns: 'disposer removing every locale registered by this call (idempotent).',
},
{
signature: 'register(ns: string, locale: string, dict: LocaleDict): () => void',
description: 'Single-locale untyped form for namespaces outside the merge table (dynamic composition, tests).',
parameters: [{"name":"ns","description":"namespace."},{"name":"locale","description":"locale tag."},{"name":"dict","description":"dictionary."}],
returns: 'disposer (idempotent).',
},
{
signature: 'bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>',
description: 'Bind a declared namespace to a translate function typed to its dictionary key union (plus the shared common vocabulary) — the same key domain the framework-injected `t` seat carries. The returned reference is stable per namespace (repeat binds return the same function), so it can ride inject surfaces without breaking memoization.',
parameters: [{"name":"ns","description":"a namespace merged into LocaleNamespaceMap."}],
returns: 'the typed translate function (reads the active locale at call time).',
},
{
signature: 'bind(ns: string): Translate',
description: 'Untyped form for namespaces outside the merge table (dynamic composition, tests).',
parameters: [{"name":"ns","description":"namespace."}],
returns: 'the translate function.',
},
],
},
{
key: 'sessions',
summary: 'The sessions-service face injected as `ctx.sessions`.',
description: 'The sessions-service face injected as `ctx.sessions`.',
methods: [
{
signature: 'open(id: SessionId): void',
description: 'Select a session as current.',
parameters: [{"name":"id","description":"session id (must exist in the list; unknown ids fail loud)."}],
},
{
signature: 'openSubagent(address: SubagentAddress): void',
description: 'Open a healthy catalog child through its exact direct-parent address.',
parameters: [{"name":"address","description":"catalog-derived parent and child ids."}],
},
{
signature: 'setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void',
description: 'Mark whether a catalog menu is consuming live membership updates.',
parameters: [{"name":"parentSessionId","description":"catalog owner."},{"name":"open","description":"current menu state."}],
},
{
signature: 'refreshSubagents(parentSessionId: SessionId): Promise<void>',
description: 'Refresh one direct-child catalog.',
parameters: [{"name":"parentSessionId","description":"catalog owner."}],
returns: 'completion of the current or newly started refresh.',
},
{
signature: 'search( query: string, signal: AbortSignal, ): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>',
description: 'Search the Host\'s visible message-content index. Results stay request-local; the list snapshot remains the metadata authority.',
parameters: [{"name":"query","description":"non-blank literal phrase."},{"name":"signal","description":"cancellation for a superseded search."}],
returns: 'bounded results, or a business/transport error.',
},
{
signature: 'fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>',
description: 'Fork a session from a completed-turn prefix of the source; on resolution the child is in the list store and `open()` can target it.',
parameters: [{"name":"opts","description":"source session id, the optional event seq anchoring the cut (the boundary is the first turn/end at or after it; an in-log anchor in an open turn is unavailable rather than clipped backward), and whether to increment an inherited durable title before resolving."}],
returns: 'the child session id.',
throws: ["when the fork fails, or when a requested child-title rename fails after creation."],
},
{
signature: 'scope(id: SessionId): AgentContext | undefined',
description: 'Resolve an Agent-scoped context view (use-and-discard).',
parameters: [{"name":"id","description":"session id."}],
returns: 'scoped ctx, or undefined for a session neither listed nor already scoped.',
},
{
signature: 'binding(id: SessionId): SessionBinding | undefined',
description: 'Resolve the stable session binding (scope-addressed assembly feed).',
parameters: [{"name":"id","description":"session id."}],
returns: 'binding, or undefined for a session neither listed nor already scoped.',
},
],
},
{
key: 'slots',
summary: 'cordis Service layer of the slot system; see the module doc for the split with SlotCore.',
description: 'cordis Service layer of the slot system; see the module doc for the split with SlotCore.',
methods: [
{
signature: 'declare readonly register: SlotCore[\'register\']',
description: 'The single registration API. The typed face IS the core\'s register (both overloads reused verbatim — one authority, no structural copy; see SlotCore.register for children declaration, store seat, inject face, load-time validation, and the unload cascade). This layer adds: disposal through the caller\'s ctx.effect (fiber unload = cascade), exclusive-factory minting (`store: createXxxStore` becomes a per-entry handle), the registrant diagnostics stamp, and store-instance lifecycle on the entry axis.\n\nDeclared here, implemented by prototype assignment below the class: it MUST stay a prototype method (never an instance arrow) — the cordis service proxy binds `this.ctx` to the CALLER\'s context at call time, which is what routes the effect (and the unload cascade) into the caller\'s fiber. An arrow property would freeze `this` to the service\'s own root ctx and silently break per-plugin disposal.',
parameters: [],
},
{
signature: 'inject(key: keyof SlotMap & string, callback: () => SlotInjectionEffect): () => void',
description: 'Install an effect for each declaration lifetime of a slot. The callback runs synchronously when the declaration already exists; otherwise it runs inside the declaring `register()` call after the declaration is committed. Collapse disposes the effect and a later declaration runs it again. Callback effects are synchronous disposers; iterable effects install transactionally and dispose in reverse order. The controller belongs to the caller\'s fiber, so plugin unload cancels a pending wait and removes any active contribution.',
parameters: [{"name":"key","description":"declared SlotMap key to depend on."},{"name":"callback","description":"creates one disposer or an iterable of disposers."}],
returns: 'idempotent disposer for the wait and active effect.',
throws: ["callback setup failures synchronously when the slot is already declared."],
},
],
},
{
key: 'theme',
summary: 'Theme registry and preference owner.',
description: 'Theme registry and preference owner. `light`/`dark` are built in (the base stylesheets carry both palettes); third-party themes register alias-layer overrides. Reads go through getTheme; preference writes only through setTheme; continuous sync only through the `theme/change` event. overrideTokens stacks partial token layers over the active theme without touching the registry. The service holds the `prefers-color-scheme` media query (environment sensing, not presentation) and re-emits when the OS scheme flips while the preference is `system`.',
methods: [
{
signature: 'getTheme(): ThemeSnapshot',
description: 'Read the current immutable theme snapshot.',
parameters: [],
returns: 'the current snapshot (stable reference until the next change).',
},
{
signature: 'setTheme(id: string): void',
description: 'Switch the theme preference — the only user preference write entry. Built-in preferences are written through the settings scope and every accepted value emits `theme/change`.',
parameters: [{"name":"id","description":"a registered theme id or `system`; unknown ids throw."}],
},
{
signature: 'register(definition: ThemeDefinition): () => void',
description: 'Register a theme. Duplicate id throws (single occupant per id; the built-in pair counts; `system` is a preference, not a registrable id).',
parameters: [{"name":"definition","description":"theme id, colorScheme, and alias-token overrides."}],
returns: 'disposer. Disposing the theme backing the active preference resets the preference to the default so the UI never keeps tokens of an unregistered theme.',
},
{
signature: 'overrideTokens(source: string, tokens: ThemeTokenOverrides): () => void',
description: 'Stack a token override layer on top of the active theme — the token-level analogue of slot shading: the base theme stays untouched, layers compose in seq order with later layers winning per-token, and removing a layer restores whatever it covered. Calling again with the same source replaces that source\'s whole layer and restacks it on top (effect re-registration semantics). Emits `theme/change` with the recomposed snapshot.',
parameters: [{"name":"source","description":"layer identity; one layer per source (dynamic packages pass their package id — the façade pins it, so it also names the layer's origin for inspection)."},{"name":"tokens","description":"token-name → `{ light, dark }` value pairs. Validated at runtime (model-authored callers reach this boundary with untyped JS); a bare string value throws a teaching error."}],
returns: 'disposer removing exactly the layer this call created; a no-op once the source has re-overridden (the newer layer is not torn down).',
},
],
},
{
key: 'timer',
summary: 'Disposable timer helpers mixed into Cordis contexts.',
description: 'Disposable timer helpers mixed into Cordis contexts.',
methods: [
{
signature: 'timeout(callback: () => void, delay: number): () => void',
description: 'Run a callback once and return its disposer.',
parameters: [],
},
{
signature: 'timeout(delay: number): Promise<void>',
description: 'Resolve after a delay; disposal rejects the pending promise.',
parameters: [],
},
{
signature: 'interval(callback: () => void, delay: number): () => void',
description: 'Run a callback repeatedly and return its disposer.',
parameters: [],
},
{
signature: 'interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>',
description: 'Return an async iterator of timer ticks.',
parameters: [],
},
{
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
description: 'Return a throttled function whose timer is disposed with the current fiber.',
parameters: [],
},
{
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
description: 'Return a debounced function whose timer is disposed with the current fiber.',
parameters: [],
},
],
},
{
key: 'workspaces',
summary: 'The workspaces-service face injected as `ctx.workspaces`.',
description: 'The workspaces-service face injected as `ctx.workspaces`.',
methods: [
{
signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>',
description: 'Connect a Workspace to its reusable or freshly created blank session.',
parameters: [{"name":"workspaceId","description":"target workspace."}],
returns: 'the connected session id.',
},
{
signature: 'startSession(workspaceId?: WorkspaceId): void',
description: 'The New Session flow: connect the target (or recent) Workspace and open the resulting session; failures surface on the session list state.',
parameters: [{"name":"workspaceId","description":"explicit target; omitted uses the recency projection."}],
},
{
signature: 'create(input: { path: string }): Promise<WorkspaceView>',
description: 'Register an existing path as a Workspace.',
parameters: [{"name":"input","description":"the Host create payload."}],
returns: 'the created or idempotently resolved Workspace.',
},
{
signature: 'pickDirectory(): Promise<string | null>',
description: 'Open the Host\'s native directory picker.',
parameters: [],
returns: 'the selected path, or null when the user cancelled.',
},
{
signature: 'listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>',
description: 'List one directory level through the Host\'s `browse` capability.',
parameters: [{"name":"path","description":"absolute directory to list; absent lists the Host home directory."},{"name":"signal","description":"aborts the wire request (and the Host's scan) when the caller supersedes it."}],
returns: 'the level\'s listing with breadcrumb ancestry.',
},
{
signature: 'createDirectory(path: string, name: string): Promise<string>',
description: 'Create one child directory through the Host\'s `browse` capability.',
parameters: [{"name":"path","description":"absolute existing parent directory."},{"name":"name","description":"single non-blank path segment."}],
returns: 'the created directory\'s absolute path.',
},
{
signature: 'openPath(path: string): Promise<void>',
description: 'Open a filesystem path with the Host operating system\'s default application.',
parameters: [{"name":"path","description":"absolute or host-resolvable path."}],
},
{
signature: 'rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>',
description: 'Rename a Workspace.',
parameters: [{"name":"workspaceId","description":"target workspace."},{"name":"title","description":"the new display title."}],
returns: 'the updated Workspace view.',
},
{
signature: 'delete(workspaceId: WorkspaceId): Promise<void>',
description: 'Delete a Workspace (its sessions fall back to the unaccounted group).',
parameters: [{"name":"workspaceId","description":"target workspace."}],
},
{
signature: 'insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>',
description: 'Move an accounted session within/into a Workspace\'s ordered list.',
parameters: [{"name":"workspaceId","description":"target workspace."},{"name":"sessionId","description":"accounted session to move."},{"name":"beforeSessionId","description":"accounted anchor to insert before; omitted appends."}],
returns: 'the updated Workspace view.',
},
{
signature: 'archiveSession(sessionId: SessionId): Promise<void>',
description: 'Archive a session into the registry-global set (hidden from grouping surfaces; session log and accounting slot remain). Archiving the current session clears the selection into the New Session view state.',
parameters: [{"name":"sessionId","description":"session to archive."}],
},
],
},
]
/** Every harness event, sorted by name. */
export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'connection/reset',
mode: 'emit',
signature: '\'connection/reset\'(): void',
summary: 'A connection generation was (re-)established.',
description: 'A connection generation was (re-)established. Wire-derived caches must treat their state as stale and repull (commands directory; the queue mirrors reset themselves through the session resync path).',
parameters: [],
},
{
name: 'locale/change',
mode: 'emit',
signature: '\'locale/change\'(snapshot: LocaleSnapshot): void',
summary: 'The active locale switched.',
description: 'The active locale switched. Dictionary registrations do NOT emit this event (listeners may re-register slots in response, and boot registers one namespace per package); continuous render refresh rides the LocaleFace revision instead.',
parameters: [{"name":"snapshot","description":"Current immutable locale snapshot."}],
},
{
name: 'slots/changed',
mode: 'emit',
signature: '\'slots/changed\'(key: string): void',
summary: 'A slot\'s definition or registration set changed.',
description: 'A slot\'s definition or registration set changed.',
parameters: [{"name":"key","description":"the mutated SlotMap key."}],
},
{
name: 'theme/change',
mode: 'emit',
signature: '\'theme/change\'(snapshot: ThemeSnapshot): void',
summary: 'Theme state changed (preference switched, registry updated, or the OS color scheme changed while the preference is `system`).',
description: 'Theme state changed (preference switched, registry updated, or the OS color scheme changed while the preference is `system`).',
parameters: [{"name":"snapshot","description":"Current immutable theme snapshot."}],
},
]
/** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'ActionsDecl',
declaration: 'export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>;',
},
{
name: 'AgentContext',
declaration: 'export type AgentContext = Omit<Context, \'remote\'> & {\n readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<\'agent\'>;\n};',
},
{
name: 'AssistantBlock',
declaration: 'export type AssistantBlock = {\n kind: \'text\';\n text: string;\n} | {\n kind: \'reasoning\';\n text: string;\n} | {\n kind: \'image\';\n attachment: ImageAttachmentRef;\n} | {\n kind: \'tool-call\';\n callId: string;\n name: string;\n argsRaw: string;\n} | {\n kind: \'other\';\n block: unknown;\n};',
},
{
name: 'AssistantMessageNode',
declaration: 'export interface AssistantMessageNode {\n kind: \'assistant\';\n seq: number;\n time: number;\n turn: number;\n step: number;\n blocks: readonly AssistantBlock[];\n usage?: unknown;\n provenance?: AssistantProvenanceView;\n requestConfig?: AssistantRequestConfig;\n timing?: AssistantTiming;\n interrupted?: true;\n}',
},
{
name: 'AssistantProvenanceView',
declaration: 'export interface AssistantProvenanceView {\n provider: string;\n model: string;\n}',
},
{
name: 'AssistantRequestConfig',
declaration: 'export interface AssistantRequestConfig {\n provider: string;\n model: string;\n purpose?: string;\n thinking?: string;\n reasoningEffort?: string;\n temperature?: number;\n maxTokens?: number;\n stop?: readonly string[];\n}',
},
{
name: 'AssistantTiming',
declaration: 'export interface AssistantTiming {\n stepStartTime: number | null;\n firstTokenTime: number | null;\n completedTime: number;\n}',
},
{
name: 'BakedActions',
declaration: 'export type BakedActions<T, A extends ActionsDecl<T>> = {\n [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never;\n};',
},
{
name: 'BoundActions',
declaration: 'export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never;',
},
{
name: 'ChainKeysOf',
declaration: 'export type ChainKeysOf<S extends keyof SlotMap & string> = S extends unknown ? (SlotMap[S][\'kind\'] extends \'chain\' ? S : never) : never;',
},
{
name: 'ChainRenderOpts',
declaration: 'export interface ChainRenderOpts {\n fallback?: ReactNode;\n overlay?: boolean;\n}',
},
{
name: 'ChatConversationViewNode',
declaration: 'export interface ChatConversationViewNode extends ConversationViewNode {\n readonly target: \'chat\';\n readonly anchorSeq: number;\n readonly location: ConversationLocation;\n readonly visibility: \'visible\' | \'hidden\';\n}',
},
{
name: 'ChatLocationNodeIndex',
declaration: 'export interface ChatLocationNodeIndex {\n getTurn(turn: number): readonly string[];\n getStep(turn: number, step: number): readonly string[];\n}',
},
{
name: 'ChatNodeStore',
declaration: 'export interface ChatNodeStore {\n get(key: string): ChatConversationViewNode | undefined;\n values(): readonly ChatConversationViewNode[];\n}',
},
{
name: 'ChatSnapshot',
declaration: 'export interface ChatSnapshot {\n readonly order: readonly string[];\n readonly nodes: ChatNodeStore;\n readonly locations: ChatLocationNodeIndex;\n readonly timeline: ConversationTimelineSnapshot;\n readonly legacy: LegacyConversationSlice;\n}',
},
{
name: 'ChildrenDecl',
declaration: 'export type ChildrenDecl = {\n [P in keyof SlotMap & string]?: SlotSpec<SlotMap[P]>;\n};',
},
{
name: 'CommandNode',
declaration: 'export interface CommandNode {\n kind: \'command\';\n seq: number;\n time: number;\n commandId: CommandId;\n name: string | null;\n args: string | null;\n outcome: {\n kind: \'success\' | \'error\';\n text?: string;\n sourceEventSeq?: number;\n } | null;\n}',
},
{
name: 'CommonKeyOf',
declaration: 'export type CommonKeyOf = LocaleNamespaceMap extends {\n common: infer C;\n} ? C & string : never;',
},
{
name: 'CompactionSummaryNode',
declaration: 'export interface CompactionSummaryNode {\n kind: \'compaction\';\n seq: number;\n time: number;\n summary: string | null;\n summaryEventSeq: number | null;\n shadowedItemCount: number | null;\n shadowedTokenCount: number | null;\n}',
},
{
name: 'ComposedProps',
declaration: 'export type ComposedProps<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K>, S extends keyof SlotMap & string, H, I extends object, M = never, N = undefined> = PropsRuntime<K, EntryKey> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>;',
},
{
name: 'ComposerPhase',
declaration: 'export type ComposerPhase = \'blank\' | \'engaging\' | \'active\';',
},
{
name: 'ContextMessageNode',
declaration: 'export interface ContextMessageNode {\n kind: \'context\';\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n provenance: ContextProvenanceView;\n form: KnownContextForm | null;\n}',
},
{
name: 'ContextProvenanceView',
declaration: 'export interface ContextProvenanceView {\n role: ContextRole;\n label: string | null;\n}',
},
{
name: 'ContextRole',
declaration: 'export type ContextRole = \'inject\' | \'recall\';',
},
{
name: 'ConversationLocation',
declaration: 'export type ConversationLocation = {\n readonly kind: \'session\';\n} | {\n readonly kind: \'turn\';\n readonly turn: TurnLocation;\n} | {\n readonly kind: \'step\';\n readonly turn: TurnLocation;\n readonly step: StepLocation;\n} | {\n readonly kind: \'unresolved\';\n};',
},
{
name: 'ConversationLocationDataStore',
declaration: 'export interface ConversationLocationDataStore<DataMap extends object> {\n get<Key extends keyof DataMap & string>(key: Key): Readonly<DataMap[Key]> | undefined;\n}',
},
{
name: 'ConversationNode',
declaration: 'export type ConversationNode = UserMessageNode | AssistantMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | TurnErrorNode | ToolResultNode | CommandNode | CompactionSummaryNode | UnknownSurfaceNode;',
},
{
name: 'ConversationSnapshot',
declaration: 'export interface ConversationSnapshot {\n sessionId: SessionId;\n views: ConversationViewSnapshotStore;\n chat: ChatSnapshot;\n nodes: readonly ConversationNode[];\n turnTimings: ReadonlyMap<number, {\n readonly startTime: number;\n readonly endTime?: number;\n }>;\n turnEnds: ReadonlyMap<number, number>;\n partial: PartialAssistant | null;\n runningCalls: readonly RunningToolCall[];\n pending: readonly PendingInteraction[];\n queue: readonly QueuedMessage[];\n running: boolean;\n subagent: {\n address: SubagentAddress;\n parentAvailable: boolean;\n } | null;\n composerPhase: ComposerPhase;\n removed: boolean;\n openState: OpenState;\n openError: RpcError | null;\n hasMore: boolean;\n loadingOlder: boolean;\n promptError: PromptError | null;\n blank: boolean;\n lastAgentError: string | null;\n}',
},
{
name: 'ConversationStepDataMap',
declaration: 'export interface ConversationStepDataMap {\n}',
},
{
name: 'ConversationTimelineSnapshot',
declaration: 'export interface ConversationTimelineSnapshot {\n readonly turnOrder: readonly number[];\n readonly turns: ReadonlyMap<number, TurnLocation>;\n}',
},
{
name: 'ConversationTurnDataMap',
declaration: 'export interface ConversationTurnDataMap {\n}',
},
{
name: 'ConversationViewNode',
declaration: 'export interface ConversationViewNode {\n readonly key: string;\n readonly kind: string;\n readonly id: string;\n readonly target: string;\n readonly data: unknown;\n}',
},
{
name: 'ConversationViewSnapshotMap',
declaration: 'export interface ConversationViewSnapshotMap {\n}',
},
{
name: 'ConversationViewSnapshotStore',
declaration: 'export interface ConversationViewSnapshotStore {\n get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(target: Target): ConversationViewSnapshotMap[Target] | undefined;\n}',
},
{
name: 'EntryKeyOf',
declaration: 'export type EntryKeyOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? keyof P & string : string;',
},
{
name: 'GlobalStandardProps',
declaration: 'export interface GlobalStandardProps {\n}',
},
{
name: 'HandleOf',
declaration: 'export type HandleOf<H> = H extends () => infer R ? R : H;',
},
{
name: 'HooksSources',
declaration: 'export type HooksSources = Record<string, HostObservable<unknown>>;',
},
{
name: 'HostObservable',
declaration: 'export interface HostObservable<T> {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}',
},
{
name: 'InjectFace',
declaration: 'export type InjectFace<I extends object> = I extends {\n hooks: infer HS extends HooksSources;\n} ? Omit<I, \'hooks\'> & PropsHooks<HS> : I;',
},
{
name: 'InjectParams',
declaration: 'export type InjectParams<K extends keyof SlotMap & string, H> = ScopeOf<K> extends \'session\' ? ([\n H\n] extends [\n StoreDecl\n] ? [\n sessionId: SessionIdOf,\n actions: BoundActions<HandleOf<H>>\n] : [\n sessionId: SessionIdOf\n]) : ScopeOf<K> extends \'session-maybe\' ? ([\n H\n] extends [\n StoreDecl\n] ? [\n sessionId: SessionIdOf | undefined,\n actions: BoundActions<HandleOf<H>> | undefined\n] : [\n sessionId: SessionIdOf | undefined\n]) : ([\n H\n] extends [\n StoreDecl\n] ? [\n actions: BoundActions<HandleOf<H>>\n] : [\n]);',
},
{
name: 'ISession',
declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\'): Promise<RpcResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<RpcResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{\n accepted: true;\n }>>;\n cancel(): Promise<RpcResult<{\n accepted: true;\n }>>;\n rename(title: string): Promise<RpcResult<{\n title: string;\n seq: number;\n }>>;\n loadOlder(): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
},
{
name: 'KeyPropsOf',
declaration: 'export type KeyPropsOf<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K>> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? EntryKey extends keyof P ? P[EntryKey] extends object ? P[EntryKey] : never : never : object;',
},
{
name: 'KnownContextForm',
declaration: 'export type KnownContextForm = typeof KNOWN_FORMS[number];',
},
{
name: 'LegacyConversationSlice',
declaration: 'export interface LegacyConversationSlice {\n readonly nodes: readonly ConversationNode[];\n readonly turnTimings: ReadonlyMap<number, {\n readonly startTime: number;\n readonly endTime?: number;\n }>;\n readonly turnEnds: ReadonlyMap<number, number>;\n readonly partial: PartialAssistant | null;\n readonly runningCalls: readonly RunningToolCall[];\n}',
},
{
name: 'LocaleDefinition',
declaration: 'export interface LocaleDefinition {\n id: LocaleId;\n label: string;\n}',
},
{
name: 'LocaleDict',
declaration: 'export type LocaleDict = Record<string, string>;',
},
{
name: 'LocaleDictOf',
declaration: 'export type LocaleDictOf<N extends keyof LocaleNamespaceMap & string> = Record<LocaleNamespaceMap[N] & string, string>;',
},
{
name: 'LocaleId',
declaration: 'export type LocaleId = typeof LOCALE_IDS[number];',
},
{
name: 'LocaleKeysOf',
declaration: 'export type LocaleKeysOf<N extends keyof LocaleNamespaceMap & string> = (LocaleNamespaceMap[N] & string) | CommonKeyOf;',
},
{
name: 'LocaleNamespaceMap',
declaration: 'export interface LocaleNamespaceMap {\n}',
},
{
name: 'LocaleSnapshot',
declaration: 'export interface LocaleSnapshot {\n active: LocaleId;\n locales: readonly LocaleDefinition[];\n revision: number;\n}',
},
{
name: 'MatchedShare',
declaration: 'export type MatchedShare<E extends SlotEntryDef, M> = E[\'kind\'] extends \'chain\' ? {\n matched: M;\n} : object;',
},
{
name: 'ModelRetryNode',
declaration: 'export type ModelRetryNode = LlmRetryEventData & {\n kind: \'model-retry\';\n seq: number;\n time: number;\n retryState: \'scheduled\' | \'started\' | \'cancelled\';\n};',
},
{
name: 'ObservableSnapshot',
declaration: 'export interface ObservableSnapshot<T> {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}',
},
{
name: 'OpenState',
declaration: 'export type OpenState = \'cold\' | \'loading\' | \'open\' | \'error\';',
},
{
name: 'OwnerOf',
declaration: 'export type OwnerOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n owner: infer O extends object;\n} ? O : object;',
},
{
name: 'PartialAssistant',
declaration: 'export interface PartialAssistant {\n turn: number;\n step: number;\n blocks: readonly AssistantBlock[];\n}',
},
{
name: 'PendingInteraction',
declaration: 'export type PendingInteraction = {\n [K in PendingKind]: PendingWait<K>;\n}[PendingKind];',
},
{
name: 'PendingKind',
declaration: 'export type PendingKind = keyof PendingPayloads;',
},
{
name: 'PendingPayloads',
declaration: 'export interface PendingPayloads {\n approval: Omit<Extract<MuxFrame, {\n type: \'approval/requested\';\n }>, \'type\' | \'sessionId\'>;\n question: Omit<Extract<MuxFrame, {\n type: \'question/requested\';\n }>, \'type\' | \'sessionId\'>;\n}',
},
{
name: 'PendingWait',
declaration: 'export class PendingWait<K extends PendingKind = PendingKind> {\n readonly kind: K;\n readonly key: string;\n readonly sessionId: SessionId;\n readonly payload: PendingPayloads[K];\n constructor(kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K], respond: (message: ClientResponse) => Promise<RpcReceipt>);\n respond(result: ClientResponse[\'result\']): Promise<RpcReceipt>;\n markSettled(): void;\n}',
},
{
name: 'ProjectionsFace',
declaration: 'export interface ProjectionsFace {\n faceOf(key: string): ObservableSnapshot<unknown>;\n}',
},
{
name: 'PromptError',
declaration: 'export interface PromptError {\n op: \'send\' | \'stop\';\n error: RpcError;\n}',
},
{
name: 'PropsHooks',
declaration: 'export type PropsHooks<HS extends HooksSources> = {\n [N in keyof HS & string as `use${Capitalize<N>}`]: SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>;\n};',
},
{
name: 'PropsLocale',
declaration: 'export type PropsLocale<N> = N extends keyof LocaleNamespaceMap & string ? {\n t: TranslateNS<N>;\n} : object;',
},
{
name: 'PropsRenderSlots',
declaration: 'export type PropsRenderSlots<S extends keyof SlotMap & string> = {\n renderSlot: RenderSlotFn<Exclude<S, ChainKeysOf<S>>>;\n readonly __renders?: ((key: S) => void) | undefined;\n} & ([\n ChainKeysOf<S>\n] extends [\n never\n] ? object : {\n renderSlotChain: <K extends ChainKeysOf<S>>(key: K, owner: OwnerOf<K>, opts?: ChainRenderOpts) => ReactNode;\n}) & (\'session\' extends ScopeOf<S> ? {\n SessionProvider: SessionProviderComponent;\n} : object);',
},
{
name: 'PropsRuntime',
declaration: 'export type PropsRuntime<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>> = OwnerOf<K> & KeyPropsOf<K, EntryKey> & SlotInjectFace<SlotInjectOf<K>> & (ScopeOf<K> extends \'session\' ? SessionStandardProps : ScopeOf<K> extends \'session-maybe\' ? SessionMaybeStandardProps : object) & GlobalStandardProps;',
},
{
name: 'PropsSlotHooks',
declaration: 'export type PropsSlotHooks<HS extends object> = {\n [N in keyof HS & string as `use${Capitalize<N>}`]: BoundHookOf<HS[N]>;\n};',
},
{
name: 'PropsStore',
declaration: 'export type PropsStore<H> = H extends StoreHandle<infer T, infer A> ? {\n useStore: SnapshotSelectorHook<T>;\n actions: BakedActions<T, A>;\n} : object;',
},
{
name: 'QueueAction',
declaration: 'export type QueueAction = Parameters<SessionFace[\'updateQueue\']>[1];',
},
{
name: 'RunningToolCall',
declaration: 'export interface RunningToolCall {\n callId: string;\n name: string;\n argsRaw: string;\n turn: number;\n step: number;\n time: number;\n callView: ToolCallView | null;\n subCalls: readonly ToolCallBlock[];\n}',
},
{
name: 'ScopeOf',
declaration: 'export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K][\'scope\'];',
},
{
name: 'SessionAreaProps',
declaration: 'export interface SessionAreaProps {\n empty?: (() => ReactNode) | undefined;\n children: (sessionId: SessionIdOf) => ReactNode;\n}',
},
{
name: 'SessionBinding',
declaration: 'export interface SessionBinding {\n readonly sessionId: SessionId;\n readonly session: SessionFace;\n readonly ctx: AgentContext;\n}',
},
{
name: 'SessionFace',
declaration: 'export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>;',
},
{
name: 'SessionIdOf',
declaration: 'export type SessionIdOf = SessionStandardProps extends {\n sessionId: infer S;\n} ? S : string;',
},
{
name: 'SessionMaybeStandardProps',
declaration: 'export interface SessionMaybeStandardProps {\n}',
},
{
name: 'SessionProviderComponent',
declaration: 'export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode;',
},
{
name: 'SessionSearchResultItem',
declaration: 'export interface SessionSearchResultItem {\n sessionId: SessionId;\n snippet: string;\n}',
},
{
name: 'SessionStandardProps',
declaration: 'export interface SessionStandardProps {\n}',
},
{
name: 'SlotComponent',
declaration: 'export type SlotComponent<P> = (props: P) => ReactNode;',
},
{
name: 'SlotCore',
declaration: 'export class SlotCore {\n constructor();\n register<K extends keyof SlotMap & string, const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>, const D extends ChildrenDecl = Record<never, never>, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent<never> = SlotComponent<never>>(options: BaseOptions<K, EntryKey, D, H, M, N> & {\n inject?: undefined;\n }, component: C & SlotComponent<ComposedProps<K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>> & RendersCheck<C, D>): () => void;\n register<K extends keyof SlotMap & string, I extends object, const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>, const D extends ChildrenDecl = Record<never, never>, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent<never> = SlotComponent<never>>(options: BaseOptions<K, EntryKey, D, H, M, N> & {\n inject: (...args: InjectParams<K, H>) => I;\n }, component: C & SlotComponent<ComposedProps<K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>> & RendersCheck<C, D>): () => void;\n register(options: ErasedOptions, component: unknown): () => void;\n isLive(entry: StoredEntry): boolean;\n entries(key: string): readonly StoredEntry[];\n entriesOfSlot(key /* …truncated — full shape in source */',
},
{
name: 'SlotEntryDef',
declaration: 'export interface SlotEntryDef {\n kind: SlotKind;\n scope: SlotScope;\n owner?: object;\n keyProps?: Record<string, object>;\n hookContext?: unknown;\n inject?: object;\n}',
},
{
name: 'SlotInjectFace',
declaration: 'export type SlotInjectFace<I extends object> = I extends {\n hooks: infer HS extends object;\n} ? Omit<I, \'hooks\'> & PropsSlotHooks<HS> : I;',
},
{
name: 'SlotInjectOf',
declaration: 'export type SlotInjectOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n inject: infer Injected extends object;\n} ? Injected : object;',
},
{
name: 'SlotKind',
declaration: 'export type SlotKind = \'single\' | \'list\' | \'keyed\' | \'chain\';',
},
{
name: 'SlotLabel',
declaration: 'export type SlotLabel = string | (() => string);',
},
{
name: 'SlotMap',
declaration: 'export interface SlotMap {\n}',
},
{
name: 'SlotScope',
declaration: 'export type SlotScope = \'root\' | \'session-maybe\' | \'session\';',
},
{
name: 'SlotSpec',
declaration: 'export type SlotSpec<E extends SlotEntryDef> = {\n kind: E[\'kind\'];\n scope: E[\'scope\'];\n} & (\'inject\' extends keyof E ? E extends {\n inject: infer Injected extends object;\n} ? {\n inject: Injected;\n} : {\n inject?: object;\n} : {\n inject?: never;\n});',
},
{
name: 'SnapshotSelectorHook',
declaration: 'export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S;',
},
{
name: 'SteeringMessageNode',
declaration: 'export interface SteeringMessageNode {\n kind: \'steering\';\n messageId: MessageId;\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n}',
},
{
name: 'StepLocation',
declaration: 'export interface StepLocation {\n readonly turn: number;\n readonly step: number;\n readonly start: SessionEvent<\'step/start\'> | undefined;\n readonly end: SessionEvent<\'step/end\'> | undefined;\n readonly status: \'open\' | \'closed\' | \'unknown\';\n readonly data: ConversationLocationDataStore<ConversationStepDataMap>;\n}',
},
{
name: 'StoreDecl',
declaration: 'export type StoreDecl = StoreHandle<any, any> | StoreFactory;',
},
{
name: 'StoredEntry',
declaration: 'export interface StoredEntry {\n component: unknown;\n options: {\n key?: string;\n id?: string;\n order?: number;\n label?: SlotLabel;\n priority?: number;\n };\n select?: ((owner: never) => unknown) | undefined;\n inject?: ((...args: never[]) => Record<string, unknown>) | undefined;\n children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined;\n store?: StoreDecl | undefined;\n locale?: string | undefined;\n registrant?: string | undefined;\n}',
},
{
name: 'StoreFactory',
declaration: 'export type StoreFactory = () => StoreHandle<any, any>;',
},
{
name: 'StoreHandle',
declaration: 'export interface StoreHandle<T, A extends ActionsDecl<T>> {\n readonly spec: StoreSpec<T, A>;\n create(scopeKey?: string): StoreInstance<T, A>;\n}',
},
{
name: 'StoreInstance',
declaration: 'export interface StoreInstance<T, A extends ActionsDecl<T>> {\n readonly actions: BakedActions<T, A>;\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n clearPersisted(): void;\n}',
},
{
name: 'StoreSpec',
declaration: 'export interface StoreSpec<T, A extends ActionsDecl<T>> {\n init: () => T;\n persist?: string;\n actions: A;\n}',
},
{
name: 'ThemeDefinition',
declaration: 'export interface ThemeDefinition {\n id: string;\n colorScheme: \'light\' | \'dark\';\n tokens: ThemeTokens;\n}',
},
{
name: 'ThemePreference',
declaration: 'export type ThemePreference = typeof THEME_PREFERENCES[number];',
},
{
name: 'ThemeSnapshot',
declaration: 'export interface ThemeSnapshot {\n preference: ThemePreference;\n active: ThemeDefinition;\n themes: readonly ThemeDefinition[];\n revision: number;\n}',
},
{
name: 'ThemeTokenModes',
declaration: 'export interface ThemeTokenModes {\n light: string;\n dark: string;\n}',
},
{
name: 'ThemeTokenOverrides',
declaration: 'export type ThemeTokenOverrides = Record<string, ThemeTokenModes>;',
},
{
name: 'ThemeTokens',
declaration: 'export type ThemeTokens = Record<string, string>;',
},
{
name: 'ToolCallBlock',
declaration: 'export type ToolCallBlock = RunningToolCall | ToolResultNode;',
},
{
name: 'ToolResultNode',
declaration: 'export interface ToolResultNode {\n kind: \'tool-result\';\n seq: number;\n time: number;\n callId: string;\n call: {\n name: string;\n argsRaw: string;\n } | null;\n callTime: number | null;\n content: readonly ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n callView: ToolCallView | null;\n resultView: ToolResultView | null;\n subCalls: readonly ToolCallBlock[];\n}',
},
{
name: 'Translate',
declaration: 'export type Translate<K extends string = string> = (key: K, params?: Record<string, unknown>) => string;',
},
{
name: 'TranslateNS',
declaration: 'export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>;',
},
{
name: 'TurnErrorNode',
declaration: 'export interface TurnErrorNode {\n kind: \'turn-error\';\n seq: number;\n time: number;\n turn: number;\n step: number;\n message: string;\n code?: string;\n}',
},
{
name: 'TurnLocation',
declaration: 'export interface TurnLocation {\n readonly turn: number;\n readonly start: SessionEvent<\'turn/start\'> | undefined;\n readonly end: SessionEvent<\'turn/end\'> | undefined;\n readonly status: \'open\' | \'closed\' | \'unknown\';\n readonly steps: readonly StepLocation[];\n readonly data: ConversationLocationDataStore<ConversationTurnDataMap>;\n}',
},
{
name: 'UnknownSurfaceNode',
declaration: 'export interface UnknownSurfaceNode {\n kind: \'unknown\';\n seq: number;\n time: number;\n type: string;\n data: unknown;\n}',
},
{
name: 'UserMessageNode',
declaration: 'export interface UserMessageNode {\n kind: \'user\';\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n}',
},
]
/** The inherited `ctx` API (cordis core + loader/hmr/timer), in curated order. */
export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / short-circuit chain).' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).' },
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.' },
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the four supported helpers are mixed onto ctx directly (declared via Pick).' },
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).' },
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).' },
]
function referencedTypeClosure(seeds: readonly string[]): TypeApiEntry[] {
const included = new Set<string>()
let frontier = [...seeds]
while (frontier.length > 0) {
const next: string[] = []
for (const entry of TYPE_API) {
if (included.has(entry.name)) continue
const pattern = new RegExp(`\b${entry.name}\b`)
if (!frontier.some(text => pattern.test(text))) continue
included.add(entry.name)
next.push(entry.declaration)
}
frontier = next
}
return TYPE_API.filter(entry => included.has(entry.name))
}
function contextProperty(key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key) ? `ctx.${key}` : `ctx[${JSON.stringify(key)}]`
}
/**
* Project the Service Catalog as a compact directory or one exact coding contract.
* @param key - exact Service key; omit it to list all Services and method signatures.
* @param services - platform-specific visible Service entries.
* @returns compact navigation data or one detailed Service with its referenced type closure.
*/
export function queryServiceApi(key?: string, services: readonly ServiceApiEntry[] = SERVICE_API): object {
if (key === undefined) {
return {
mode: 'catalog',
services: services.map(service => ({
key: service.key,
description: service.summary,
methods: service.methods.map(method => ({ signature: method.signature })),
})),
}
}
const service = services.find(candidate => candidate.key === key)
if (service === undefined) throw new Error(`no catalogued Service named "${key}"`)
return {
mode: 'service',
service: {
key: service.key,
description: service.description,
access: {
optional: { expression: `ctx.get(${JSON.stringify(service.key)})`, requiresUndefinedCheck: true },
hardDependency: { inject: [service.key], expression: contextProperty(service.key) },
},
methods: service.methods,
},
referencedTypes: referencedTypeClosure(service.methods.map(method => method.signature)),
}
}
/**
* Project the Event Catalog as a compact directory or one exact listener contract.
* @param name - exact Event name; omit it to list all Events and listener signatures.
* @param events - platform-specific visible Event entries.
* @returns compact navigation data or one detailed Event with its referenced type closure.
*/
export function queryEventApi(name?: string, events: readonly EventApiEntry[] = EVENT_API): object {
if (name === undefined) {
return {
mode: 'catalog',
events: events.map(event => ({
name: event.name,
description: event.summary,
mode: event.mode,
signature: event.signature,
})),
}
}
const event = events.find(candidate => candidate.name === name)
if (event === undefined) throw new Error(`no catalogued Event named "${name}"`)
return {
mode: 'event',
event: {
name: event.name,
description: event.description,
mode: event.mode,
signature: event.signature,
parameters: event.parameters,
},
referencedTypes: referencedTypeClosure([event.signature]),
}
}

View File

@@ -0,0 +1,222 @@
/**
* Browser-half closure evaluation: the package source runs as the body of an
* async function whose parameters ARE the symbol surface. Shadowing parameters
* (setTimeout/fetch/require/…) turn the ambient browser globals into teaching
* redirects without touching the page. The host syntax-prechecked the source at
* define time; SyntaxError handling here is the engine-divergence fallback and
* reaches the model through the load report.
*/
import * as React from 'react'
import type { CordisDynamicPluginId } from '@deepseek-ai/dsh-api-remotes/client'
/** A mountable plugin as the closure must return it (FUNCTION or OBJECT form). */
export interface DynamicCordisEvaluatedPlugin {
/** Optional plugin name; the runner overwrites it with the module id. */
name?: string
/** Services the browser half declares; the runner overwrites it from the dispatched row. */
inject?: string[]
/** Plugin body receiving the guard facade. */
apply: (ctx: unknown, config?: unknown) => unknown
}
/** What the evaluator needs from the runner to build one package's closure. */
export interface DynamicCordisClosureEnv {
/** Route `host.call` to this package's host half over the wire. */
invoke(method: string, args: unknown): Promise<unknown>
/** Mirror one runtime error text into the load report (console.error copies). */
noteError(message: string): void
}
const TIMER_REDIRECT
= 'browser timer globals are unavailable in dynamic packages. Declare inject: [\'timer\'] on the returned plugin, '
+ 'query Client Service.listService for the exact API, and close over that plugin ctx. In React, create timers '
+ 'from an event handler or React.useEffect and return callback-form disposers from the effect cleanup.'
/**
* Where each withheld browser global sends the author instead. One home for two
* consumers: the closure traps below throw these, and a render crash whose
* message names one of them gets the same redirect appended — a package that
* reached the global some other way (`window.setInterval`) crashes with the
* engine's own bare text, and the author needs the redirect either way.
*/
export const DYNAMIC_CLIENT_REDIRECTS: Readonly<Record<string, string>> = {
setTimeout: TIMER_REDIRECT,
setInterval: TIMER_REDIRECT,
clearTimeout: TIMER_REDIRECT,
clearInterval: TIMER_REDIRECT,
fetch:
'network belongs to the HOST half: register a handler there with harness.handle(method, fn) and call it here via host.call(method, args).',
require:
'modules cannot be imported here. React arrives as the `React` closure symbol; everything else goes through ctx services or host.call.',
}
/** Callable teaching traps shadowing the ambient globals the closure must not reach. */
function closureTraps(): Record<string, () => never> {
const traps: Record<string, () => never> = {}
for (const [name, redirect] of Object.entries(DYNAMIC_CLIENT_REDIRECTS)) {
traps[name] = (): never => {
throw new Error(`${name} is not available in a dynamic client half — ${redirect}`)
}
}
return traps
}
/** The `harness` seat exists only host-side; any touch teaches the split. */
function harnessTrap(): unknown {
return new Proxy({}, {
get(_target, prop) {
throw new Error(
`harness.${String(prop)} belongs to the HOST half (\`code\`): register handlers there with harness.handle(method, fn); `
+ 'the browser half calls them via host.call(method, args).',
)
},
})
}
/** Per-package style-tag bookkeeping behind the `styles.insert` symbol. */
export class DynamicCordisStyles {
private readonly tags = new Set<HTMLStyleElement>()
/** @param pluginId - owning Plugin ID, stamped as `data-dyn` on every tag. */
constructor(private readonly pluginId: CordisDynamicPluginId) {}
/**
* Inject one stylesheet, removed automatically on package unload.
* @param css - raw CSS text.
* @returns disposer removing this one tag early.
*/
insert(css: string): () => void {
if (typeof css !== 'string') throw new Error('styles.insert(css) needs a CSS string')
const tag = document.createElement('style')
tag.dataset.dyn = this.pluginId
tag.textContent = css
document.head.append(tag)
this.tags.add(tag)
return () => {
this.tags.delete(tag)
tag.remove()
}
}
/** Live tag count (load-report contribution summary). */
get count(): number {
return this.tags.size
}
/** Remove every tag this package still owns (unload path). */
dispose(): void {
for (const tag of this.tags) tag.remove()
this.tags.clear()
}
}
/** Stringify one console argument for the error mirror. */
function errorText(arg: unknown): string {
if (arg instanceof Error) return arg.message
if (typeof arg === 'string') return arg
if (arg === undefined) return 'undefined'
try {
return JSON.stringify(arg)
} catch {
// A circular or otherwise non-serializable console argument: the mirror
// carries the message, and nothing else here can fail.
return '[unserializable console argument]'
}
}
/** Tagged write-through console; error lines additionally copy into the load report. */
function taggedConsole(pluginId: CordisDynamicPluginId, noteError: (message: string) => void): Console {
const tag = `[cordis:${pluginId}]`
const forward = (level: 'log' | 'info' | 'warn' | 'error' | 'debug') => (...args: unknown[]): void => {
console[level](tag, ...args)
if (level !== 'error') return
noteError(args.map(errorText).join(' ').slice(0, 500))
}
return {
...console,
log: forward('log'),
info: forward('info'),
warn: forward('warn'),
error: forward('error'),
debug: forward('debug'),
}
}
/**
* Narrow a closure return value to a mountable plugin (host guard mirror).
* @param value - whatever the closure returned.
* @returns whether the value is mountable.
*/
export function isDynamicCordisPlugin(value: unknown): value is DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown) {
if (typeof value === 'function') return true
return typeof value === 'object' && value !== null
&& typeof (value as { apply?: unknown }).apply === 'function'
}
/**
* Evaluate one package's browser half and return the (un-guarded) plugin.
* @param pluginId - stable Plugin ID (console tag and style ownership).
* @param clientCode - the browser half's source: an async function body returning a plugin.
* @param env - runner wiring for `host.call` and error mirroring.
* @param styles - the package's style bookkeeping (owned by the caller so unload can dispose it).
* @returns the plugin the closure returned.
* @throws teaching errors for syntax failures and non-plugin returns.
*/
export async function evaluateClientHalf(
pluginId: CordisDynamicPluginId,
clientCode: string,
env: DynamicCordisClosureEnv,
styles: DynamicCordisStyles,
): Promise<DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)> {
const traps = closureTraps()
const parameters = ['React', 'console', 'styles', 'host', 'harness', ...Object.keys(traps), 'process', 'Buffer']
let closure: (...args: unknown[]) => Promise<unknown>
try {
// The wrapper mirrors the host precheck exactly, so line offsets match.
// Evaluating a definition's browser half IS this package's product: the
// source arrived from a host process that accepted and prechecked it.
// oxlint-disable-next-line typescript/no-implied-eval -- see above
const factory = new Function(...parameters, `return (async () => {\n${clientCode}\n})()`)
closure = factory as (...args: unknown[]) => Promise<unknown>
} catch (error) {
if (!(error instanceof SyntaxError)) throw error
// Engine-divergence fallback: the host precheck already carried the
// line/caret teaching; browsers give only the message.
throw new Error(
`client half failed to parse in this browser: ${error.message}\n`
+ 'The browser half is plain JavaScript (no JSX, no TypeScript); build elements with React.createElement.',
)
}
const host = {
/**
* Call a host-half handler of THIS package (harness.handle pairing). A call
* with nothing to pass omits the argument: it arrives at the handler as
* `null`, because the wire carries JSON and `undefined` is not JSON —
* requiring `host.call('m', {})` would be a ritual, and defaulting to `{}`
* would invent an empty argument the caller never wrote.
*/
call: (method: string, args: unknown = null): Promise<unknown> => env.invoke(method, args),
}
const returned = await closure(
React,
taggedConsole(pluginId, (message) => { env.noteError(message) }),
styles,
host,
harnessTrap(),
...Object.values(traps),
undefined, // process: undefined keeps `typeof process` probes safe
undefined, // Buffer
)
if (!isDynamicCordisPlugin(returned)) {
if (returned === undefined) {
throw new Error(
'client half returned `undefined` — did you forget `return`?\n'
+ ' ✓ return (ctx) => { … }\n'
+ ' ✓ return { name: \'…\', inject: [\'slots\'], apply(ctx) { … } }',
)
}
throw new Error('client half must `return` a plugin: a function, or an object with an `apply(ctx)` method')
}
return returned
}

View File

@@ -0,0 +1,239 @@
/**
* The browser twin of the tool-cordis context facade: a whitelist of
* lifecycle-safe verbs plus optional `ctx.get()` lookup and declared-service
* property access, with
* framework internals withheld and Context-valued returns denied. Two seats
* carry extra machinery: `slots`, where the register proxy assigns the
* shadowing priority and ledgers the registration — invoking the service with
* the traced receiver so the effect lands on the CALLING plugin's fiber
* (SlotsService.register must stay a prototype method for exactly that
* reason) — and `theme`, whose override source is pinned to the package id.
*
* This is API discipline, not a security boundary: a dynamic package's code is
* as trusted as the host process that accepted its definition.
*/
import { Context } from '@deepseek-ai/cordis'
import type { DynamicCordisPackage } from '@deepseek-ai/dsh-api-remotes/client'
import type { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
/** Facade verbs beyond declared services (host CTX_VERBS twin). */
const CTX_VERBS = new Set([
'effect', 'on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce',
])
const TIMER_VERBS = new Set(['timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
/** One package's slot-registration ledger row (contribution projection source). */
export interface DynamicCordisSlotLedgerRow {
/** Target slot name. */
slot: string
/** The assigned shadowing priority (globally unique — how winners are matched back to packages). */
priority: number | undefined
}
/** What the facade needs beyond the real ctx to govern one package. */
export interface DynamicCordisGuardEnv {
/** The dispatched Package row. */
pkg: DynamicCordisPackage
/** Ledger sink: every slot registration this package makes. */
ledger: DynamicCordisSlotLedgerRow[]
/**
* Ownership index sink: the component object seated in a slot, so a later
* render crash reported against the stored entry can be attributed back to
* this package. Identity is the key — the registry stores the component
* verbatim — which is why nothing else has to be remembered about the entry.
* @param component - whatever the package passed as its component.
*/
claim(component: unknown): void
/** Allocate one page-local shadowing rank; later registrations sort first. */
allocatePriority(): number
/** Report one post-activation guard rejection to the owning Agent. */
reportFailure(error: Error): void
}
/** Reject any service return that is a cordis Context (host guard twin). */
function denyContext(value: unknown, service: string, env: DynamicCordisGuardEnv): unknown {
if (value instanceof Context) {
return rejectGuard(env,
`service "${service}" returned a cordis Context, which the dynamic facade does not expose. `
+ 'Operate through your own plugin ctx and the services you declared — never another context.',
)
}
return value
}
/**
* Forward service methods with the traced service as receiver — `this.ctx`
* inside prototype methods (slots.register) must stay the CALLER's ctx so
* effects land on the calling plugin's fiber — while denying Context returns.
*/
function guardedService(service: object, name: string, env: DynamicCordisGuardEnv): 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, env)
return (...args: unknown[]): unknown => {
const result = Reflect.apply(value, target, args) as unknown
if (result instanceof Promise) return result.then(resolved => denyContext(resolved, name, env))
return denyContext(result, name, env)
}
},
})
}
/** Erased register options as this facade reads and rewrites them. */
interface ErasedSlotOptions {
name?: string
priority?: number
[key: string]: unknown
}
/**
* The slots seat: automatic shadowing priority and ledger recording around the
* traced service's own register.
*/
function guardedSlots(slots: SlotsService, env: DynamicCordisGuardEnv): unknown {
return new Proxy(slots, {
get(target, prop) {
const value = Reflect.get(target, prop, target) as unknown
if (prop !== 'register') {
if (typeof value !== 'function') return denyContext(value, 'slots', env)
return (...args: unknown[]): unknown => denyContext(Reflect.apply(value, target, args), 'slots', env)
}
return (rawOptions: unknown, component: unknown): unknown => {
if (typeof rawOptions !== 'object' || rawOptions === null) {
return rejectGuard(env, 'slots.register(options, component) needs an options object with a `name`')
}
const options = { ...rawOptions as ErasedSlotOptions }
const slot = options.name
if (typeof slot !== 'string' || slot.length === 0) {
return rejectGuard(env, 'slots.register options need a string `name` (the target slot key)')
}
if (slot === 'tool.view.cordis') {
if (options.key !== 'self') {
return rejectGuard(env, 'tool.view.cordis only accepts key "self"; the runtime binds it to this Package')
}
options.key = `${env.pkg.pluginId}.${env.pkg.packageId}`
}
// Shadowing kinds get a page-local rank. Later registrations sort first;
// chain slots keep their own election (select order) untouched.
const spec = (slots.spec as (key: string) => { kind?: string } | undefined)(slot)
let priority = options.priority
if (spec === undefined || spec.kind !== 'chain') {
priority = env.allocatePriority()
options.priority = priority
}
const register = Reflect.get(target, 'register', target) as unknown as (opts: object, comp: unknown) => () => void
const dispose = register.call(target, options, component)
env.ledger.push({ slot, priority })
// After the registry accepted it: a rejected registration seats no entry,
// so claiming one would index a component no crash can ever name.
env.claim(component)
return dispose
}
},
})
}
/**
* The theme seat: `overrideTokens`' source is FORCED to the package id — a
* dynamic package can never impersonate (or evict) another source's layer, and
* its own layers converge under one identity unload can reason about. The
* layer's disposer is additionally hung on the calling fiber, because the
* documented contract is "unload restores" and model code cannot be trusted to
* keep the returned handle (slots parity — register hangs its own cleanup).
* Everything else forwards through the generic guard.
*/
function guardedTheme(theme: ThemeService, env: DynamicCordisGuardEnv, ctx: Context): unknown {
return new Proxy(theme, {
get(target, prop) {
if (prop !== 'overrideTokens') {
const value = Reflect.get(target, prop, target) as unknown
if (typeof value !== 'function') return denyContext(value, 'theme', env)
return (...args: unknown[]): unknown => {
const result = Reflect.apply(value, target, args) as unknown
if (result instanceof Promise) return result.then(resolved => denyContext(resolved, 'theme', env))
return denyContext(result, 'theme', env)
}
}
return (source: unknown, tokens: unknown): unknown => {
// Two-argument shape preserved so the facade matches the documented
// service signature; the source VALUE is replaced, never trusted.
if (tokens === undefined && typeof source === 'object' && source !== null) {
return rejectGuard(env,
'theme.overrideTokens(source, tokens) takes two arguments; source is replaced with your package id, '
+ 'so pass any string first and the token map second: overrideTokens(\'mine\', { \'--dsw-alias-…\': { light: \'…\', dark: \'…\' } })',
)
}
const method = Reflect.get(target, 'overrideTokens', target)
const dispose = Reflect.apply(method, target, [`${env.pkg.pluginId}.${env.pkg.packageId}`, tokens]) as () => void
// Fiber-owned lifetime; the returned handle stays valid for early
// removal (the service disposer is idempotent per layer identity).
ctx.effect(() => dispose, 'cordis-client-runner: dynamic theme override layer')
return dispose
}
},
})
}
/**
* Build the facade one dynamic plugin's `apply` receives (host sandboxContext
* twin, browser seats). `ctx.get(name)` performs optional lookup; direct
* `ctx.serviceName` access is gated by the fiber's `inject` declaration.
* @param ctx - the plugin's real fiber ctx (loader-created).
* @param env - package row + ledger sink.
* @returns the whitelisting proxy standing in for ctx.
*/
export function dynamicCordisContext(ctx: Context, env: DynamicCordisGuardEnv): Context {
const declared = new Set(Object.keys(ctx.fiber.inject))
const denyRead = (prop: string): never => {
if (ctx.get(prop) !== undefined) {
return rejectGuard(env,
`service "${prop}" is not declared by your plugin. Declare it on the plugin you return: `
+ `{ inject: ['${prop}', …], apply(ctx) { … } } — a plain \`function\` has no declaration site, `
+ 'so use the object form. The runtime then parks the package if the provider unloads.',
)
}
return rejectGuard(env,
`dynamic ctx does not expose "${prop}". Available: ctx.on / ctx.provide / timer helpers after injecting timer, and any service your `
+ 'returned plugin declared in inject (slots and theme are the usual UI seats). Framework internals are withheld '
+ 'by design.',
)
}
const readService = (name: string, requireDeclaration: boolean): unknown => {
if (requireDeclaration && !declared.has(name)) return denyRead(name)
const service = denyContext(ctx.get(name), name, env)
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
if (name === 'slots') return guardedSlots(service as SlotsService, env)
if (name === 'theme') return guardedTheme(service as ThemeService, env, ctx)
return guardedService(service, name, env)
}
return new Proxy({}, {
get(_target, prop) {
if (prop === 'get') return (name: string): unknown => readService(name, false)
if (typeof prop !== 'string') return undefined
// Lazy verb forwarder (host twin): resolve ctx[verb] only when called.
if (CTX_VERBS.has(prop)) {
return (...args: unknown[]): unknown => {
if (TIMER_VERBS.has(prop) && !declared.has('timer')) return denyRead('timer')
const method = ctx[prop as keyof Context]
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
}
}
return readService(prop, true)
},
set(_target, prop) {
return rejectGuard(env, `dynamic ctx is read-only; cannot assign "${String(prop)}"`)
},
has: (_target, prop) => prop === 'get'
|| (typeof prop === 'string'
&& ((CTX_VERBS.has(prop) && (!TIMER_VERBS.has(prop) || declared.has('timer'))) || declared.has(prop))),
}) as unknown as Context
}
function rejectGuard(env: DynamicCordisGuardEnv, message: string): never {
const error = new Error(message)
env.reportFailure(error)
throw error
}

View File

@@ -0,0 +1,308 @@
/**
* Dynamic-package runner, browser half: the load engine that turns one browser
* half's source into a live cordis plugin (closure → guard → module table →
* loader entry, ./runtime.ts), plus the retract announcement that unloads it.
*
* Nothing loads on activation: this page holds no dynamic package until a
* dispatch arrives, and a dispatch only follows a model `cordis_run` or a user
* pressing a card's start control. A refresh therefore starts clean by design —
* host process memory still holds the definition, the page simply does not run
* it until asked again.
*/
import type { Context } from '@deepseek-ai/cordis'
import type {
ApprovalRequestId, CordisDynamicPluginId, DynamicCordisInvokeResult, JsonValue,
DynamicCordisInventoryRow,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
import type { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
// The Client Remote assembly is the one place the two planes meet: it mounts the
// `dynamicCordisRunner` namespace and re-exports its payload vocabulary, so this
// package names what it sends without importing a Host package.
import type { DynamicCordisLivePackage } from './runtime.ts'
import { DynamicCordisPackageRunner } from './runtime.ts'
import { CordisRunOrchestrator } from './orchestrator.ts'
import { ClientCordisInspectRegistry, provideClientCordisInspect } from './inspect-registry.ts'
import { clientInspectProviders } from './providers.ts'
import { provideClientTimer } from './timer.ts'
import type { CordisRunActivity, CordisRunFailure, CordisUserRunRequest } from './orchestrator.ts'
import type { CordisObservable, DynamicCordisRenderFailure } from './runtime.ts'
export { CordisRunOrchestrator } from './orchestrator.ts'
export { ClientCordisInspectRegistry } from './inspect-registry.ts'
export type {
ClientCordisInspectHost, ClientCordisInspectProviderRegistration, ClientCordisInspectQueryContext,
} from './inspect-registry.ts'
export type {
CordisRunActivity, CordisRunFailure, CordisRunHostSeam,
CordisRunOrchestratorEnv, CordisRunRequest, CordisUserRunRequest,
} from './orchestrator.ts'
export { DynamicCordisPackageRunner } from './runtime.ts'
export type {
CordisObservable, DynamicCordisClientHalf, DynamicCordisLivePackage, DynamicCordisLoadErrorCause,
DynamicCordisLoadResult, DynamicCordisRenderFailure, DynamicCordisRunnerEnv,
} from './runtime.ts'
export { DynamicCordisStyles, evaluateClientHalf, isDynamicCordisPlugin } from './evaluator.ts'
export type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from './evaluator.ts'
export { dynamicCordisContext } from './guard.ts'
export type { DynamicCordisGuardEnv, DynamicCordisSlotLedgerRow } from './guard.ts'
export { ClientTimerService } from './timer.ts'
// Re-exported so consumers of the service face and the two events can name
// their subjects without reaching into the wire contract themselves.
export type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
DynamicCordisPackage,
} from '@deepseek-ai/dsh-api-remotes/client'
/**
* What a run surface reads and calls. The activity map is the single home of
* "a run is in flight", so an affordance never keeps its own copy — that is what
* makes it survive a remount.
*/
export interface CordisRunnerFace {
/** Each definition's in-flight run activity. */
readonly activeRuns: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>>
/** The last failure of this page's own run attempt, per definition. */
readonly lastRunError: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunFailure>>
/**
* This page's last render crash per definition: a browser half that loaded
* cleanly and then broke while React rendered it. Page-local and current by
* construction — cleared when the package stops, is retracted, or loads again —
* which is what makes it safe for a row to render directly. The host keeps its
* own last-across-pages copy for the model; the two have different owners and
* lifetimes and neither is derived from the other.
*/
readonly renderFailures: CordisObservable<ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure>>
/**
* Restore pending approvals after a page reconnect or missed event.
* @param rows - current dynamic Plugin inventory.
*/
reconcileApprovals(rows: readonly DynamicCordisInventoryRow[]): void
/**
* Answer one run request with "run it" and drive both halves.
* @param requestId - the request being answered; unknown or settled ids are a no-op.
* @param approveFutureVersions - whether this decision covers later Packages of the same Plugin.
* @returns after the orchestration settled.
*/
approve(requestId: ApprovalRequestId, approveFutureVersions: boolean): Promise<void>
/**
* Answer one run request with "do not run it".
* @param requestId - the request being answered; unknown or settled ids are a no-op.
* @returns after the refusal reached the host.
*/
decline(requestId: ApprovalRequestId): Promise<void>
/**
* Run a definition here at the user's own request (the gesture authorizes it).
* A definition with a browser half also loads onto this page; a host-only one
* only comes up in the host process.
* @param request - the definition to run, its session, and whether it has a browser half.
* @returns after the orchestration settled.
*/
startUserRun(request: CordisUserRunRequest): Promise<void>
/**
* Observe what this page has loaded.
* @param fn - notified after every converged load or unload.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Read what this page currently has loaded.
* @returns immutable rows for live Client halves.
*/
getSnapshot(): readonly DynamicCordisLivePackage[]
/**
* Whether this page loaded a definition's browser half — page-local truth,
* never the host's "it is running".
* @param pluginId - stable Plugin identity.
* @returns true while a load is live here.
*/
isLoaded(pluginId: CordisDynamicPluginId): boolean
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Run orchestration and page-local load state: what run surfaces read and call. */
dynamicCordisRunner: CordisRunnerFace
}
}
/** Teaching text for a routing failure the infrastructure itself reports. */
function invokeFailure(pluginId: CordisDynamicPluginId, method: string, result: Extract<DynamicCordisInvokeResult, { ok: false }>): string {
const where = `host.call("${method}") on ${pluginId}`
if (result.code === 'plugin-not-running') {
return `${where} found no active Host half — the Plugin is stopped or was removed.`
}
if (result.code === 'stale-run') {
return `${where} belongs to an activation that has already been replaced.`
}
if (result.code === 'method-not-found') {
return `${where} is not registered: the host half must declare it with harness.handle("${method}", fn).`
}
return `${where} failed inside the host handler: ${result.message}`
}
/** Preserve a Host handler's stack while adding the Client call site diagnosis. */
function invokeError(
pluginId: CordisDynamicPluginId,
method: string,
result: Extract<DynamicCordisInvokeResult, { ok: false }>,
): Error {
const error = new Error(invokeFailure(pluginId, method, result))
if (result.stack !== undefined) error.stack = `${error.stack ?? error.message}\nHost stack:\n${result.stack}`
return error
}
/**
* Teaching text for a `host.call` the wire itself refused: the generated codec
* rejected the argument before sending, or the result on the way back, or the
* transport broke. The infrastructure's message names the field it refused but
* not the call it belonged to, and the model authored both halves — so this adds
* the call and the contract it has to satisfy.
*/
function wireFailure(id: CordisDynamicPluginId, method: string, error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return `host.call("${method}") on ${id} did not complete: ${message}\n`
+ 'Both directions carry JSON only: pass plain JSON data as the argument — or omit it, and the handler receives '
+ `null — and answer from harness.handle("${method}", fn) with JSON (\`return null\` when there is nothing to report).`
}
/** Stable Cordis plugin name. */
export const name = 'cordis-client-runner'
/**
* Required services: the loader/module chain for entries, the slot registry for
* contributions, and the `dynamicCordisRunner` Remote namespace. Declaring the
* namespace parks this plugin until the host side exists, so a page never loads
* a browser half whose host half it could not reach.
*/
export const inject = ['loader', 'modules', 'slots', 'remote', 'remote.dynamicCordisRunner']
/**
* Client plugin body: build the runner and subscribe the dispatch family.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
provideClientTimer(ctx)
const inspect = new ClientCordisInspectRegistry({
sync: async (providers) => {
const answered = await ctx.remote.dynamicCordisRunner.syncInspectManifest(providers)
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
},
resolve: async (agentId, requestId, resolution) => {
const answered = await ctx.remote.dynamicCordisRunner.resolveInspectQuery(agentId, requestId, resolution)
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
},
})
provideClientCordisInspect(ctx, inspect)
for (const provider of clientInspectProviders(ctx)) {
ctx.effect(() => inspect.register(provider), `cordis-client-runner: inspect ${provider.manifest.id}`)
}
ctx.on('connection/reset', () => { inspect.publish() })
const runner = new DynamicCordisPackageRunner({
ctx,
loader: ctx.loader,
modules: ctx.get('modules') as ClientModuleSystem,
slots: ctx.get('slots') as SlotsService,
invoke: async (pluginId, pluginRunId, method, args) => {
// Model-authored arguments reach this boundary untyped; the namespace's
// generated codec is what validates them as JSON, and its rejection is a
// bare field name — this is the only place that still knows which call it
// belonged to, so the teaching has to be added here.
const answered = await ctx.remote.dynamicCordisRunner.invoke(pluginId, pluginRunId, method, args as JsonValue)
.catch((error: unknown) => { throw new Error(wireFailure(pluginId, method, error)) })
// Two failure layers, and they teach different things: the carrier's error
// branch means the call never reached the host half, while the namespace's
// own `ok: false` is that half answering with a refusal.
if (!answered.ok) throw new Error(wireFailure(pluginId, method, `${answered.error.code}: ${answered.error.message}`))
const result = answered.value
if (result.ok) return result.value
throw invokeError(pluginId, method, result)
},
// Post-settle diagnosis, deliberately fire-and-forget: the run this package
// belongs to was answered before it ever rendered, so nothing waits on this
// and a failed report must not turn one crash into two.
reportRenderFailure: (agentId, pluginId, pluginRunId, failure) => {
void ctx.remote.dynamicCordisRunner.reportRenderFailure(agentId, pluginId, pluginRunId, failure).then((result) => {
if (!result.ok) {
console.error(`[cordis-client-runner] reporting a render failure of ${pluginId} failed:`, result.error)
}
}, (error: unknown) => {
console.error(`[cordis-client-runner] reporting a render failure of ${pluginId} failed:`, error)
})
},
reportGuardFailure: (agentId, pluginId, pluginRunId, failure) => {
void ctx.remote.dynamicCordisRunner.reportClientGuardFailure(agentId, pluginId, pluginRunId, failure).then((result) => {
if (!result.ok) {
console.error(`[cordis-client-runner] reporting a guard failure of ${pluginId} failed:`, result.error)
}
}, (error: unknown) => {
console.error(`[cordis-client-runner] reporting a guard failure of ${pluginId} failed:`, error)
})
},
})
const orchestrator = new CordisRunOrchestrator({
runner,
host: {
// The seam names business payloads only, so a carrier failure is folded
// here into whatever each verb already does with one: the short-circuit
// message for a start, a throw where the caller has a catch of its own.
runHostHalf: async (agentId, pluginId, packageId, mode, requestId, approveFutureVersions) => {
const answered = await ctx.remote.dynamicCordisRunner.runHostHalf(
agentId, pluginId, packageId, mode, requestId, approveFutureVersions,
)
return answered.ok ? answered.value : { ok: false, message: `${answered.error.code}: ${answered.error.message}` }
},
getClientCode: async (agentId, pluginId, pluginRunId) => {
const answered = await ctx.remote.dynamicCordisRunner.getClientCode(agentId, pluginId, pluginRunId)
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
return answered.value
},
resolveRequestRun: async (requestId, resolution) => {
const answered = await ctx.remote.dynamicCordisRunner.resolveRequestRun(requestId, resolution)
// Thrown rather than returned: `answer` logs and drops a failed answer,
// and the host settles the request on its own either way.
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
return answered.value
},
settleUserRun: async (agentId, pluginId, resolution) => {
const answered = await ctx.remote.dynamicCordisRunner.settleUserRun(agentId, pluginId, resolution)
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
return answered.value
},
},
})
const face: CordisRunnerFace = {
activeRuns: orchestrator.activeRuns,
lastRunError: orchestrator.lastRunError,
renderFailures: runner.renderFailures,
reconcileApprovals: rows => { orchestrator.reconcileApprovals(rows) },
approve: (requestId, approveFutureVersions) => orchestrator.approve(requestId, approveFutureVersions),
decline: requestId => orchestrator.decline(requestId),
startUserRun: request => orchestrator.startUserRun(request),
subscribe: fn => runner.subscribe(fn),
getSnapshot: () => runner.getSnapshot(),
isLoaded: id => runner.isLoaded(id),
}
ctx.provide('dynamicCordisRunner', face)
ctx.effect(() => () => { void runner.dispose() }, 'cordis-client-runner: dynamic package runner')
// Forwarded Host events: `$on` hands the listener the Host's own argument list,
// so these read the request itself rather than a transport envelope.
ctx.remote.$on('cordis/request-run', (request) => {
orchestrator.open(request)
})
ctx.remote.$on('cordis/request-run-resolved', (resolved) => { orchestrator.close(resolved.requestId) })
ctx.remote.$on('cordis/dynamic-retract', (retracted) => {
runner.retract(retracted.pluginId, retracted.pluginRunId)
})
ctx.remote.$on('cordis/inspect-query', (request) => {
void inspect.query(request).catch((error: unknown) => {
console.error(`[cordis-client-runner] inspect query ${request.provider}.${request.method} failed:`, error)
})
})
ctx.remote.$on('cordis/inspect-query-resolved', (resolved) => { inspect.close(resolved.requestId) })
}

View File

@@ -0,0 +1,146 @@
/** Browser registry for read-only Cordis capability providers. */
import type { Context } from '@deepseek-ai/cordis'
import type {
CordisInspectProviderManifest, CordisInspectQueryRequest, CordisInspectQueryResolution,
CordisInspectRequestId, JsonValue,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** Context supplied to a Client inspect provider query. */
export interface ClientCordisInspectQueryContext {
/** Cancellation broadcast by the Host. */
signal: AbortSignal
/** Session whose model requested the query. */
sessionId: SessionId
}
/** Client provider registration retained beside its serializable manifest. */
export interface ClientCordisInspectProviderRegistration {
/** Provider and explicit query directory. */
manifest: CordisInspectProviderManifest
/** Execute one declared read-only method. */
query(method: string, input: JsonValue | undefined, context: ClientCordisInspectQueryContext): Promise<JsonValue>
}
/** Remote operations needed by the Client registry. */
export interface ClientCordisInspectHost {
/** Replace the Host's mirrored Client manifest. */
sync(providers: readonly CordisInspectProviderManifest[]): Promise<void>
/** Submit one query result; the first accepted page wins. */
resolve(
sessionId: SessionId,
requestId: CordisInspectRequestId,
resolution: CordisInspectQueryResolution,
): Promise<void>
}
/** Client provider registry, manifest publisher, and live query dispatcher. */
export class ClientCordisInspectRegistry {
private readonly providers = new Map<string, ClientCordisInspectProviderRegistration>()
private readonly active = new Map<CordisInspectRequestId, AbortController>()
private publishQueued = false
private syncChain = Promise.resolve()
/** @param host - folded manifest and query result transport. */
constructor(private readonly host: ClientCordisInspectHost) {}
/**
* Register one Client provider and publish a new complete manifest.
* @param registration - provider manifest and local handler.
* @returns idempotent disposer.
*/
register(registration: ClientCordisInspectProviderRegistration): () => void {
const { manifest } = registration
if (manifest.id.trim() === '') throw new Error('Client Cordis inspect provider id must not be empty')
if (this.providers.has(manifest.id)) throw new Error(`Client Cordis inspect provider "${manifest.id}" is already registered`)
const names = new Set<string>()
for (const method of manifest.methods) {
if (names.has(method.name)) throw new Error(`Client Cordis inspect provider "${manifest.id}" repeats method "${method.name}"`)
names.add(method.name)
}
this.providers.set(manifest.id, registration)
this.publish()
let disposed = false
return () => {
if (disposed) return
disposed = true
if (this.providers.get(manifest.id) === registration) {
this.providers.delete(manifest.id)
this.publish()
}
}
}
/** Publish the current complete manifest, including after reconnect. */
publish(): void {
if (this.publishQueued) return
this.publishQueued = true
queueMicrotask(() => {
this.publishQueued = false
const manifests = [...this.providers.values()].map(provider => provider.manifest)
this.syncChain = this.syncChain.then(async () => {
await this.host.sync(manifests)
}).catch((error: unknown) => {
console.error('[cordis-client-runner] syncing inspect providers failed:', error)
})
})
}
/**
* Execute and answer one Host-broadcast query.
* @param request - exact provider query and Session correlation received from Host.
* @returns after the first local result has been sent back to Host.
*/
async query(request: CordisInspectQueryRequest): Promise<void> {
if (this.active.has(request.requestId)) return
const controller = new AbortController()
this.active.set(request.requestId, controller)
let resolution: CordisInspectQueryResolution
try {
const provider = this.providers.get(request.provider)
if (provider === undefined) {
resolution = { ok: false, reason: 'provider-missing', message: `Client inspect provider "${request.provider}" is unavailable` }
} else if (!provider.manifest.methods.some(method => method.name === request.method)) {
resolution = { ok: false, reason: 'method-missing', message: `Client inspect provider "${request.provider}" has no method "${request.method}"` }
} else {
const data = await provider.query(request.method, request.input, {
signal: controller.signal,
sessionId: request.agentId,
})
resolution = controller.signal.aborted
? { ok: false, reason: 'cancelled', message: 'Client inspect query was cancelled' }
: { ok: true, data }
}
} catch (error) {
resolution = controller.signal.aborted
? { ok: false, reason: 'cancelled', message: 'Client inspect query was cancelled' }
: { ok: false, reason: 'provider-error', message: error instanceof Error ? error.message : String(error) }
} finally {
this.active.delete(request.requestId)
}
if (controller.signal.aborted) return
await this.host.resolve(request.agentId, request.requestId, resolution)
}
/**
* Cancel local work after another page answered or the Tool call ended.
* @param requestId - query correlation that is no longer answerable.
*/
close(requestId: CordisInspectRequestId): void {
this.active.get(requestId)?.abort()
this.active.delete(requestId)
}
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Browser registry for pre-definition Cordis capability discovery. */
cordisInspect: ClientCordisInspectRegistry
}
}
/** Provide the registry as a normal Client service. */
export function provideClientCordisInspect(ctx: Context, registry: ClientCordisInspectRegistry): void {
ctx.provide('cordisInspect', registry)
}

View File

@@ -0,0 +1,442 @@
/**
* Page-side run orchestration for model approvals and direct panel gestures.
* Host activation always precedes Client loading. The same Plugin-keyed state
* drives every surface, so remounting a panel never loses an open approval or
* an in-flight transition.
*/
import type {
ApprovalRequestId,
CordisDynamicPackageId,
CordisDynamicPluginId,
CordisDynamicPluginRunId,
CordisDynamicRunMode,
DynamicCordisClientSource,
DynamicCordisHostHalfResult,
DynamicCordisInventoryRow,
DynamicCordisResolveAck,
DynamicCordisRunResolution,
DynamicCordisRunResponse,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { errorDetails } from './runtime.ts'
import type { CordisErrorDetails, CordisObservable, DynamicCordisPackageRunner } from './runtime.ts'
/** One Plugin's in-flight approval or activation. */
export type CordisRunActivity =
| {
phase: 'awaiting-approval'
requestId: ApprovalRequestId
agentId: SessionId
packageId: CordisDynamicPackageId
mode: CordisDynamicRunMode
name: string
purpose: string
}
| {
phase: 'orchestrating'
agentId: SessionId
packageId: CordisDynamicPackageId
mode: CordisDynamicRunMode
}
/** Why this page's latest activation attempt failed. */
export interface CordisRunFailure {
/** Package the attempt targeted. */
packageId: CordisDynamicPackageId
/** Which half or settlement stage failed. */
reason: 'host-half-failed' | 'client-half-failed'
/** Actionable failure text. */
message: string
/** Original failure stack when available. */
stack?: string
}
/** Host operations consumed by the orchestrator after transport folding. */
export interface CordisRunHostSeam {
/** Start a new Host activation or attach this page to an existing one. */
runHostHalf(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
packageId: CordisDynamicPackageId,
mode: CordisDynamicRunMode,
requestId: ApprovalRequestId | null,
approveFutureVersions: boolean,
): Promise<DynamicCordisHostHalfResult>
/** Fetch Client source for one exact active run. */
getClientCode(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
): Promise<DynamicCordisClientSource>
/** Settle a model-driven approval. */
resolveRequestRun(
requestId: ApprovalRequestId,
resolution: DynamicCordisRunResolution,
): Promise<DynamicCordisResolveAck>
/** Settle a direct panel activation after this page handles its Client half. */
settleUserRun(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
resolution: DynamicCordisRunResolution,
): Promise<DynamicCordisRunResponse>
}
/** Dependencies of one page's orchestrator. */
export interface CordisRunOrchestratorEnv {
/** Page-local Client loader. */
runner: DynamicCordisPackageRunner
/** Folded Host RPC operations. */
host: CordisRunHostSeam
}
/** Forwarded approval request fields used by this page. */
export interface CordisRunRequest {
requestId: ApprovalRequestId
agentId: SessionId
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
mode: CordisDynamicRunMode
name: string
purpose: string
requiresApproval: boolean
}
/** Direct panel activation request. */
export interface CordisUserRunRequest {
agentId: SessionId
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
mode: CordisDynamicRunMode
/** Host-only Packages finish without a Client load or settlement call. */
hasClientHalf: boolean
}
interface RunPlan extends CordisUserRunRequest {
requestId?: ApprovalRequestId
approveFutureVersions?: boolean
}
/** Drives Host → Client activation and publishes Plugin-keyed activity. */
export class CordisRunOrchestrator {
private readonly requests = new Map<ApprovalRequestId, CordisRunRequest>()
private readonly activity = new Map<CordisDynamicPluginId, CordisRunActivity>()
private readonly failures = new Map<CordisDynamicPluginId, CordisRunFailure>()
private readonly inFlight = new Map<CordisDynamicPluginId, Promise<void>>()
private readonly listeners = new Set<() => void>()
private activityCache: ReadonlyMap<CordisDynamicPluginId, CordisRunActivity> | undefined
private failureCache: ReadonlyMap<CordisDynamicPluginId, CordisRunFailure> | undefined
/** @param env - Client loader and folded Host operations. */
constructor(private readonly env: CordisRunOrchestratorEnv) {}
/** Open approvals and current activation attempts, keyed by stable Plugin ID. */
readonly activeRuns: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>> = {
getSnapshot: () => this.activityCache ??= new Map(this.activity),
subscribe: fn => this.observe(fn),
}
/** Latest page-side activation failure for each Plugin. */
readonly lastRunError: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunFailure>> = {
getSnapshot: () => this.failureCache ??= new Map(this.failures),
subscribe: fn => this.observe(fn),
}
/** Register a Client activation request, starting it immediately when the Plugin is already authorized. */
open(request: CordisRunRequest): void {
this.requests.set(request.requestId, request)
if (!request.requiresApproval) {
void this.orchestrate({
agentId: request.agentId,
pluginId: request.pluginId,
packageId: request.packageId,
mode: request.mode,
requestId: request.requestId,
hasClientHalf: true,
}).catch((error: unknown) => {
console.error(`[cordis-client-runner] automatic activation ${request.requestId} failed:`, error)
})
return
}
if (this.activity.get(request.pluginId)?.phase !== 'orchestrating') {
this.activity.set(request.pluginId, {
phase: 'awaiting-approval',
requestId: request.requestId,
agentId: request.agentId,
packageId: request.packageId,
mode: request.mode,
name: request.name,
purpose: request.purpose,
})
}
this.commit()
}
/**
* Rebuild pending approvals and automatic Client activations from an authoritative Host inventory read.
* @param rows - complete process-wide Plugin inventory.
*/
reconcileApprovals(rows: readonly DynamicCordisInventoryRow[]): void {
const expected = new Map<ApprovalRequestId, CordisRunRequest>()
for (const row of rows) {
const attempt = row.latestRun
if (attempt?.approvalRequestId === undefined
|| (attempt.status !== 'awaiting-approval'
&& attempt.status !== 'starting-host'
&& attempt.status !== 'client-pending')) continue
const pkg = row.packages.find(candidate => candidate.packageId === attempt.packageId)
if (pkg === undefined) continue
expected.set(attempt.approvalRequestId, {
requestId: attempt.approvalRequestId,
agentId: row.agentId,
pluginId: row.pluginId,
packageId: attempt.packageId,
mode: attempt.mode,
name: pkg.name,
purpose: pkg.purpose,
requiresApproval: attempt.requiresApproval ?? attempt.status === 'awaiting-approval',
})
}
let changed = false
for (const [requestId, request] of [...this.requests]) {
if (expected.has(requestId)) continue
this.requests.delete(requestId)
const current = this.activity.get(request.pluginId)
if (current?.phase === 'awaiting-approval' && current.requestId === requestId) {
this.activity.delete(request.pluginId)
}
changed = true
}
for (const [requestId, request] of expected) {
const previous = this.requests.get(requestId)
const current = this.activity.get(request.pluginId)
if (!request.requiresApproval && current?.phase === 'orchestrating') continue
if (request.requiresApproval
&& sameRequest(previous, request)
&& current?.phase === 'awaiting-approval'
&& current.requestId === requestId) continue
if (!request.requiresApproval) {
this.open(request)
changed = true
continue
}
this.requests.set(requestId, request)
if (current?.phase !== 'orchestrating') {
this.activity.set(request.pluginId, {
phase: 'awaiting-approval',
requestId,
agentId: request.agentId,
packageId: request.packageId,
mode: request.mode,
name: request.name,
purpose: request.purpose,
})
}
changed = true
}
if (changed) this.commit()
}
/** Close an approval settled by another page or by cancellation. */
close(requestId: ApprovalRequestId): void {
const request = this.requests.get(requestId)
if (request === undefined) return
this.requests.delete(requestId)
const current = this.activity.get(request.pluginId)
if (current?.phase === 'awaiting-approval' && current.requestId === requestId) {
this.activity.delete(request.pluginId)
}
this.commit()
}
/** Approve and execute one still-open model request. */
approve(requestId: ApprovalRequestId, approveFutureVersions: boolean): Promise<void> {
const request = this.requests.get(requestId)
if (request === undefined || !request.requiresApproval) return Promise.resolve()
return this.orchestrate({
agentId: request.agentId,
pluginId: request.pluginId,
packageId: request.packageId,
mode: request.mode,
requestId,
approveFutureVersions,
hasClientHalf: true,
})
}
/** Reject one still-open model request without executing either half. */
async decline(requestId: ApprovalRequestId): Promise<void> {
const request = this.requests.get(requestId)
if (request === undefined || !request.requiresApproval) return
const current = this.activity.get(request.pluginId)
if (current?.phase !== 'awaiting-approval' || current.requestId !== requestId) return
this.requests.delete(requestId)
this.activity.delete(request.pluginId)
this.commit()
await this.answer(requestId, { ok: false, reason: 'rejected' })
}
/** Execute a direct panel run; the user gesture itself authorizes it. */
startUserRun(request: CordisUserRunRequest): Promise<void> {
return this.orchestrate(request)
}
private observe(fn: () => void): () => void {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
private commit(): void {
this.activityCache = undefined
this.failureCache = undefined
for (const fn of [...this.listeners]) fn()
}
private orchestrate(plan: RunPlan): Promise<void> {
const running = this.inFlight.get(plan.pluginId)
if (running !== undefined) return running
this.activity.set(plan.pluginId, {
phase: 'orchestrating',
agentId: plan.agentId,
packageId: plan.packageId,
mode: plan.mode,
})
this.failures.delete(plan.pluginId)
if (plan.requestId !== undefined) this.requests.delete(plan.requestId)
this.commit()
const attempt = this.drive(plan).finally(() => {
this.inFlight.delete(plan.pluginId)
this.activity.delete(plan.pluginId)
this.commit()
})
this.inFlight.set(plan.pluginId, attempt)
return attempt
}
private async drive(plan: RunPlan): Promise<void> {
const started = await this.startHost(plan)
if (!started.ok) {
this.fail(plan, 'host-half-failed', started)
if (plan.requestId !== undefined) {
await this.answer(plan.requestId, { ...started, reason: 'host-half-failed' })
}
return
}
if (!plan.hasClientHalf) return
let source: DynamicCordisClientSource
try {
source = await this.env.host.getClientCode(plan.agentId, plan.pluginId, started.pluginRunId)
} catch (error) {
await this.finishClientFailure(plan, started.pluginRunId, started.startedHere, errorDetails(error), error)
return
}
const loaded = await this.env.runner.load({
pluginId: source.pluginId,
packageId: source.packageId,
pluginRunId: source.pluginRunId,
agentId: plan.agentId,
name: source.name,
code: source.code,
}).catch((error: unknown) => ({ ok: false, cause: 'evaluate', ...errorDetails(error), error }) as const)
if (!loaded.ok) {
await this.finishClientFailure(
plan,
started.pluginRunId,
started.startedHere,
{
message: `${loaded.cause}: ${loaded.message}`,
...loaded.stack === undefined ? {} : { stack: loaded.stack },
},
loaded.error,
)
return
}
const resolution: DynamicCordisRunResolution = {
ok: true,
pluginRunId: loaded.pluginRunId,
...loaded.waitingFor === undefined ? {} : { waitingFor: loaded.waitingFor },
}
if (plan.requestId !== undefined) {
await this.answer(plan.requestId, resolution)
return
}
await this.settleDirect(plan, resolution)
}
private async startHost(plan: RunPlan): Promise<DynamicCordisHostHalfResult> {
try {
return await this.env.host.runHostHalf(
plan.agentId,
plan.pluginId,
plan.packageId,
plan.mode,
plan.requestId ?? null,
plan.approveFutureVersions ?? false,
)
} catch (error) {
return { ok: false, ...errorDetails(error) }
}
}
private async finishClientFailure(
plan: RunPlan,
pluginRunId: CordisDynamicPluginRunId,
startedHere: boolean,
failure: CordisErrorDetails,
originalError?: unknown,
): Promise<void> {
console.error(
`[cordis-client-runner] Client activation ${plan.pluginId}/${plan.packageId} (${pluginRunId}) failed:`,
originalError ?? failure,
)
this.fail(plan, 'client-half-failed', failure)
const resolution: DynamicCordisRunResolution = {
ok: false,
reason: 'client-half-failed',
pluginRunId,
startedHere,
...failure,
}
if (plan.requestId !== undefined) await this.answer(plan.requestId, resolution)
else await this.settleDirect(plan, resolution)
}
private async settleDirect(plan: RunPlan, resolution: DynamicCordisRunResolution): Promise<void> {
try {
const response = await this.env.host.settleUserRun(plan.agentId, plan.pluginId, resolution)
if (!response.ok) this.fail(plan, 'client-half-failed', response)
} catch (error) {
this.fail(plan, 'client-half-failed', errorDetails(error))
}
}
private async answer(requestId: ApprovalRequestId, resolution: DynamicCordisRunResolution): Promise<void> {
try {
await this.env.host.resolveRequestRun(requestId, resolution)
} catch (error) {
console.error(`[cordis-client-runner] answering run request ${requestId} failed:`, error)
}
}
private fail(
plan: Pick<RunPlan, 'pluginId' | 'packageId'>,
reason: CordisRunFailure['reason'],
failure: CordisErrorDetails,
): void {
this.failures.set(plan.pluginId, { packageId: plan.packageId, reason, ...failure })
this.commit()
}
}
function sameRequest(left: CordisRunRequest | undefined, right: CordisRunRequest): boolean {
return left?.requestId === right.requestId
&& left.agentId === right.agentId
&& left.pluginId === right.pluginId
&& left.packageId === right.packageId
&& left.mode === right.mode
&& left.name === right.name
&& left.purpose === right.purpose
&& left.requiresApproval === right.requiresApproval
}

View File

@@ -0,0 +1,219 @@
/** Built-in Client inspect providers over live Client-owned services. */
import type { Context } from '@deepseek-ai/cordis'
import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client'
import type { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import { queryEventApi, queryServiceApi } from './api-catalog.ts'
import type { ClientCordisInspectProviderRegistration } from './inspect-registry.ts'
import { CLIENT_SLOT_API } from './slot-catalog.ts'
import type { ClientSlotEntry } from './slot-catalog.ts'
const EMPTY_INPUT = { type: 'object', properties: {}, additionalProperties: false } as const
const ANY_OUTPUT = { description: 'JSON data owned by this inspect provider.' } as const
const SERVICE_INPUT = exactInput('service', 'Exact Service key. Omit it for the compact Service and method-signature directory.')
const EVENT_INPUT = exactInput('event', 'Exact Event name. Omit it for the compact Event and listener-signature directory.')
const SERVICE_OUTPUT = {
description: 'Compact Service directory, or one exact Service contract with only its referenced type declarations.',
} as const
const EVENT_OUTPUT = {
description: 'Compact Event directory, or one exact Event contract with only its referenced type declarations.',
} as const
const SUBTREE_OUTPUT = {
description: 'Compact purpose/topology trees. With root, selected also contains that Slot\'s full contract and live occupants.',
} as const
const SUBTREE_INPUT = {
type: 'object',
properties: {
root: {
type: 'string',
description: 'Exact live Slot key. When supplied, selected contains the full contract for this Slot.',
},
},
additionalProperties: false,
} as const
/** Exact Client closure symbols exposed by the evaluator and guard. */
export const CLIENT_BUILTIN_INSPECTION: readonly JsonValue[] = [
{
name: 'ctx',
description: 'Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject only for hard dependencies.',
signatures: [
'ctx.get(name: string): unknown | undefined',
'ctx.on(name: string, listener: Function): () => void',
'ctx.provide(name: string, value: unknown): () => void',
'ctx.effect(callback: Function, label?: string): () => void',
],
},
{
name: 'React',
description: 'React runtime exposed without JSX transformation.',
signatures: ['React.createElement(type, props, ...children): ReactElement', 'React.useState(initial)', 'React.useEffect(effect, deps)'],
},
{
name: 'host',
description: 'Package-private JSON RPC from Client to this Package\'s Host half.',
signatures: ['host.call(method: string, args?: JsonValue): Promise<JsonValue>'],
},
{
name: 'styles',
description: 'Package-owned stylesheet insertion cleaned up with the Client run.',
signatures: ['styles.insert(css: string): () => void'],
},
{
name: 'console',
description: 'Package-tagged browser logging.',
signatures: ['console.log(...values): void', 'console.error(...values): void'],
},
]
/** Construct the first-party Client provider registrations. */
export function clientInspectProviders(ctx: Context): ClientCordisInspectProviderRegistration[] {
return [
registration(
'Service',
'Progressive Client Service discovery: compact capability/signature directory, then one exact coding contract.',
'listService',
async input => queryServiceApi(readExact(input, 'service')) as unknown as JsonValue,
SERVICE_INPUT,
SERVICE_OUTPUT,
),
registration(
'Event',
'Progressive Client Event discovery: compact listener directory, then one exact event contract.',
'listEvents',
async input => queryEventApi(readExact(input, 'event')) as unknown as JsonValue,
EVENT_INPUT,
EVENT_OUTPUT,
),
registration('Builtin', 'Plain-JavaScript symbols available to a dynamic Client half.', 'listBuiltins', async () => ({
builtins: [...CLIENT_BUILTIN_INSPECTION],
referencedTypes: [],
})),
{
manifest: {
id: 'Slots',
description: 'Progressive live Slot inspection: compact purpose/topology trees plus one exact Slot contract.',
methods: [{
name: 'listSubTree',
description: 'Return compact live Slot trees for navigation. With root, also return the selected Slot\'s full contract and occupants.',
inputSchema: SUBTREE_INPUT,
outputSchema: SUBTREE_OUTPUT,
}],
},
async query(method, input) {
if (method !== 'listSubTree') throw new Error(`unknown Slots inspect method "${method}"`)
const slots = ctx.get('slots') as SlotsService | undefined
if (slots === undefined) throw new Error('Client Slots service is not running')
const root = typeof input === 'object' && input !== null && !Array.isArray(input)
&& typeof input.root === 'string' ? input.root : undefined
const trees = slots.snapshot(root)
const selected = trees[0]
return {
...root === undefined ? {} : { requestedRoot: { name: root, available: trees.length > 0 } },
trees: trees.map(compactSlotTree),
...root === undefined || selected === undefined ? {} : { selected: inspectLiveSlot(selected) },
referencedTypes: [],
} as unknown as JsonValue
},
},
registration('Theme', 'Current theme token names and light/dark override requirements.', 'listTokens', async () => {
const theme = ctx.get('theme') as ThemeService | undefined
if (theme === undefined) throw new Error('Client Theme service is not running')
return { tokens: theme.exportInspectTokens(), referencedTypes: [] } as unknown as JsonValue
}),
]
}
function registration(
id: string,
description: string,
method: string,
query: (input: JsonValue | undefined) => Promise<JsonValue>,
inputSchema: JsonValue = EMPTY_INPUT,
outputSchema: JsonValue = ANY_OUTPUT,
): ClientCordisInspectProviderRegistration {
return {
manifest: {
id,
description,
methods: [{
name: method,
description,
inputSchema,
outputSchema,
}],
},
async query(requested, input) {
if (requested !== method) throw new Error(`unknown ${id} inspect method "${requested}"`)
return await query(input)
},
}
}
function exactInput(field: string, description: string): JsonValue {
return { type: 'object', properties: { [field]: { type: 'string', description } }, additionalProperties: false }
}
function readExact(input: JsonValue | undefined, field: string): string | undefined {
if (input === undefined || input === null || Array.isArray(input) || typeof input !== 'object') return undefined
const value = input[field]
return typeof value === 'string' ? value : undefined
}
type LiveSlotNode = ReturnType<SlotsService['snapshot']>[number]
const SLOT_CATALOG = new Map(CLIENT_SLOT_API.map(entry => [entry.key, entry]))
function compactSlotTree(node: LiveSlotNode): JsonValue {
const catalog = SLOT_CATALOG.get(node.name)
return {
name: node.name,
kind: node.kind,
scope: node.scope,
...catalog === undefined ? {} : {
purpose: catalog.summary,
replaceRisk: catalog.replaceRisk,
...catalog.registerOptions.length === 0 ? {} : {
registration: catalog.registerOptions.map(option => ({
name: option.name,
type: option.type,
required: option.requirement === 'required',
})),
},
...catalog.keyDomain === '' ? {} : { keyDomain: catalog.keyDomain },
},
children: node.children.map(compactSlotTree),
} as unknown as JsonValue
}
function inspectLiveSlot(node: LiveSlotNode): JsonValue {
const catalog = SLOT_CATALOG.get(node.name)
return {
name: node.name,
kind: node.kind,
scope: node.scope,
...node.declaredBy === undefined ? {} : { declaredBy: node.declaredBy },
occupants: node.occupants.map(occupant => ({ ...occupant })),
...catalog === undefined ? {} : { catalog: inspectSlotCatalog(catalog) },
} as unknown as JsonValue
}
function inspectSlotCatalog(entry: ClientSlotEntry): JsonValue {
return {
description: entry.doc,
registration: entry.registerOptions.map(option => ({
name: option.name,
type: option.type,
required: option.requirement === 'required',
description: option.doc,
})),
ownerProps: [...entry.ownerProps],
ownerPropsReferences: [...entry.ownerPropsReferences],
standardProps: [...entry.standardProps],
keyDomain: entry.keyDomain,
hookContext: entry.hookContext,
slotInject: entry.slotInject,
replaceRisk: entry.replaceRisk,
} as unknown as JsonValue
}

View File

@@ -0,0 +1,506 @@
/**
* Per-package browser lifecycle: evaluate the closure, wrap `apply` in the guard
* facade, seat a ready-made factory in the module table, and create a loader
* entry — so dynamic packages ride the exact machinery static plugins do
* (activation gating on inject, fiber-effect cleanup, status projection). Unload
* = loader entry removal (fiber disposal cascades slot entries and facade
* effects) + factory invalidation + style removal.
*
* The engine answers its caller: `load` resolves with what this page ended up
* with, which is what the run orchestration reports back to the host. Loads
* converge by Plugin Run ID against live state, not history: loading the exact
* activation this page already runs is a no-op that still answers, another run
* replaces it, and the same Package after a retract loads afresh. Per-Plugin
* serialization keeps a second request from interleaving with one in flight.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
import type {
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, DynamicCordisPackage,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
import type { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { DynamicCordisStyles, evaluateClientHalf, DYNAMIC_CLIENT_REDIRECTS } from './evaluator.ts'
import type { DynamicCordisEvaluatedPlugin } from './evaluator.ts'
import { dynamicCordisContext } from './guard.ts'
import type { DynamicCordisSlotLedgerRow } from './guard.ts'
/**
* Snapshot source a surface can subscribe to (the render seam's observable
* shape). Lives here because both this engine and the run orchestration publish
* through it, and the orchestration already depends on this module.
*/
export interface CordisObservable<T> {
/** Current value; the reference is stable between mutations. */
getSnapshot(): T
/**
* Observe mutations.
* @param fn - notified after each committed change.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
}
/** Which stage of a load failed, as the page classified it. */
export type DynamicCordisLoadErrorCause = 'evaluate' | 'module-import' | 'activate'
/** Error fields retained by the page runner and Host transport. */
export interface CordisErrorDetails {
/** Original error message. */
message: string
/** Original stack when the thrown value supplied one. */
stack?: string
}
/** One package's browser half as the host handed it over. */
export interface DynamicCordisClientHalf {
/** Stable Plugin instance. */
pluginId: CordisDynamicPluginId
/** Immutable Package source version. */
packageId: CordisDynamicPackageId
/** Exact activation. */
pluginRunId: CordisDynamicPluginRunId
/** Session the run is carried out for; a later render failure is reported under it. */
agentId: SessionId
/** Label from the define call; also the plugin name. */
name: string
/** Browser-half source: an async function body returning a plugin. */
code: string
}
/**
* One render-time crash of a dynamic package's slot entry, as this page reports
* it. Post-settle diagnosis only: the run it belongs to was answered long before
* (a package that crashes while rendering loaded successfully), so this never
* reaches a run resolution.
*/
export interface DynamicCordisRenderFailure {
/** Slot key the crashed entry rendered under. */
slot: string
/** What the author has to read to fix it: the crash text, plus a redirect when it names a withheld global. */
message: string
/** Original render failure stack when available. */
stack?: string
/** Whether the crash retired the entry from its cell — the package's UI is gone, not merely broken. */
abdicated: boolean
}
/**
* What this page ended up with. A parked package is a success — the browser half
* settled and waits on declared services this page has not got.
*/
export type DynamicCordisLoadResult =
| { ok: true; pluginRunId: CordisDynamicPluginRunId; waitingFor?: string[] }
| ({ ok: false; cause: DynamicCordisLoadErrorCause; error?: unknown } & CordisErrorDetails)
/** The `window.__ModuleLoader__` registration sink (client-modules contract C6). */
interface ModuleLoaderSink {
__ModuleLoader__?: {
load(handoff: { id: string; factory: (require: (spec: string) => unknown) => unknown }): void
}
}
/** One live package's bookkeeping. */
interface LivePackage {
pkg: DynamicCordisPackage
entryId: string
styles: DynamicCordisStyles
ledger: DynamicCordisSlotLedgerRow[]
/** Services the browser half declared and this page has not got (parked, still a success). */
waitingFor: string[]
}
/** Runner dependencies, resolved by the plugin entry at activation. */
export interface DynamicCordisRunnerEnv {
/** The client root context (service reads and the guard's fiber owner). */
ctx: Context
/** Client cordis Loader: dynamic packages become entries under it. */
loader: Loader
/** Module table, for factory invalidation before every (re-)registration. */
modules: ClientModuleSystem
/** Slot registry, for the entry-crash supervision seam. */
slots: SlotsService
/** Route one `host.call` to the package's host half through the Remote namespace. */
invoke(
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
method: string,
args: unknown,
): Promise<unknown>
/**
* Send one render-time crash back to the session that authored the package.
* Fire-and-forget by contract: the crash already happened, and a failed report
* must not become a second failure.
* @param agentId - session the crashed package was run for.
* @param id - the crashed package.
* @param failure - slot, teaching text, and whether the entry was retired.
*/
reportRenderFailure(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
failure: DynamicCordisRenderFailure,
): void
/** Send one post-activation Client guard rejection to the owning Agent. */
reportGuardFailure(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
failure: CordisErrorDetails,
): void
}
/** Module-table id of one package (also its loader entry name and fiber name). */
function moduleIdOf(id: CordisDynamicPluginId): string {
return `dyn/${id}`
}
/** One live package's contribution summary in this page. */
export interface DynamicCordisLivePackage {
/** Stable Plugin instance. */
pluginId: CordisDynamicPluginId
/** Immutable Package source version. */
packageId: CordisDynamicPackageId
/** Exact activation loaded in this page. */
pluginRunId: CordisDynamicPluginRunId
/** Label from the define call. */
name: string
/** Slot names this package registered into here. */
slots: string[]
/** Live injected-style tag count. */
styleCount: number
}
/** The browser-side load engine for dynamic packages. */
export class DynamicCordisPackageRunner {
private readonly live = new Map<CordisDynamicPluginId, LivePackage>()
/** Serializes load/unload per package id (a second request can outrun a slow load). */
private readonly queues = new Map<CordisDynamicPluginId, Promise<unknown>>()
private readonly changeListeners = new Set<() => void>()
/** Page-local shadowing rank. A later registration receives a lower priority. */
private nextPriority = 0
/**
* Which package seated which component, and for whom. Component identity is the
* only attribution key that holds:
* - the registry stores the component verbatim, so a crashed entry carries its
* own way back — no parallel entry ledger to keep in step;
* - `entry.registrant` is `options.registrant ?? fiber.name` and the facade does
* not strip a package-supplied one, so a package could name itself something
* else — attributing by it would let a package impersonate another;
* - the assigned shadowing priority is unique but absent on chain entries (their
* election is deliberately left alone), so it would miss chain crashes;
* - a package torn down between the crash and the report is still attributable,
* because this index does not depend on the live record.
*
* Two packages cannot collide here: each browser half is evaluated in its own
* closure, so no component object reaches two of them. A collision is only
* possible inside ONE package (the same component seated twice), where both
* entries map to the same id and the value is identical.
*/
private readonly owners = new WeakMap<object, {
pluginId: CordisDynamicPluginId
pluginRunId: CordisDynamicPluginRunId
agentId: SessionId
}>()
/** This page's last render crash per package: what a run surface shows on the row. */
private readonly failures = new Map<CordisDynamicPluginId, DynamicCordisRenderFailure>()
private readonly unwatch: () => void
private snapshotCache: readonly DynamicCordisLivePackage[] | undefined
private failureCache: ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure> | undefined
/** @param env - loader/module/slot wiring plus the two host verbs this engine uses. */
constructor(private readonly env: DynamicCordisRunnerEnv) {
// The supervision seam fires for EVERY entry crash on the page, factory UI
// included; only the ones this runner seated are ours to report.
this.unwatch = env.slots.onEntryError((slot, entry, error, info) => {
const component: unknown = (entry as { component?: unknown }).component
const owner = indexable(component) ? this.owners.get(component) : undefined
if (owner === undefined) return
const details = errorDetails(error)
const failure: DynamicCordisRenderFailure = {
slot,
message: renderFailureMessage(slot, details.message),
...details.stack === undefined ? {} : { stack: details.stack },
abdicated: info.abdicated,
}
// One observation, two outlets with different owners and lifetimes: the host
// keeps the last crash ACROSS pages for the model, this map is what THIS page
// currently shows. Neither is derived from the other.
env.reportRenderFailure(owner.agentId, owner.pluginId, owner.pluginRunId, failure)
this.failures.set(owner.pluginId, failure)
this.notify()
})
}
/**
* Observe live-set changes (the run-state surface's re-render seam).
* @param fn - notified after every converged mutation.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void {
this.changeListeners.add(fn)
return () => { this.changeListeners.delete(fn) }
}
/**
* This page's last render crash per package, on the same notification channel as
* the live set — a surface that already subscribed learns about a crash without
* a second mechanism to wire.
*/
readonly renderFailures: CordisObservable<ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure>> = {
getSnapshot: () => this.failureCache ??= new Map(this.failures),
subscribe: fn => this.subscribe(fn),
}
/**
* What this page currently has loaded (stable reference between mutations, so
* it can back a snapshot selector).
* @returns one row per live package.
*/
getSnapshot(): readonly DynamicCordisLivePackage[] {
return this.snapshotCache ??= [...this.live.values()].map(({ pkg, ledger, styles }) => ({
pluginId: pkg.pluginId,
packageId: pkg.packageId,
pluginRunId: pkg.pluginRunId,
name: pkg.name,
slots: [...new Set(ledger.map(row => row.slot))],
styleCount: styles.count,
}))
}
/**
* Whether this page has the browser half loaded — page-local truth, never the
* host's "it is running".
* @param pluginId - stable Plugin identity.
* @returns true while one activation of the Plugin is live here.
*/
isLoaded(pluginId: CordisDynamicPluginId): boolean {
return this.live.has(pluginId)
}
/**
* Load one browser half into this page and answer what happened.
* @param half - source for one exact Host activation.
* @returns the outcome the run orchestration reports to the host.
*/
load(half: DynamicCordisClientHalf): Promise<DynamicCordisLoadResult> {
return this.enqueue(half.pluginId, async () => {
const current = this.live.get(half.pluginId)
if (current !== undefined) {
// Already running this activation here: nothing to load, but the caller
// still needs an answer (a replayed run must not look unacknowledged).
if (current.pkg.pluginRunId === half.pluginRunId) return settled(current)
await this.teardown(current.pkg.pluginId, current.entryId, current.styles)
}
const result = await this.mount(half)
this.notify()
return result
})
}
/**
* Unload one package (`cordis/dynamic-retract`: a stop, or an undefine
* that stops first).
* @param pluginId - stable Plugin identity.
* @param pluginRunId - exact activation being retracted; a newer run survives.
*/
retract(pluginId: CordisDynamicPluginId, pluginRunId: CordisDynamicPluginRunId): void {
void this.enqueue(pluginId, async () => {
const current = this.live.get(pluginId)
if (current === undefined || current.pkg.pluginRunId !== pluginRunId) return
await this.teardown(pluginId, current.entryId, current.styles)
this.notify()
})
}
/** Unload everything (plugin disposal path). */
async dispose(): Promise<void> {
this.unwatch()
for (const current of [...this.live.values()]) {
await this.teardown(current.pkg.pluginId, current.entryId, current.styles)
}
this.notify()
}
private notify(): void {
this.snapshotCache = undefined
this.failureCache = undefined
for (const fn of [...this.changeListeners]) fn()
}
/** Queue one package operation behind that package's previous ones. */
private enqueue<T>(id: CordisDynamicPluginId, op: () => Promise<T>): Promise<T> {
const previous = this.queues.get(id) ?? Promise.resolve()
const next = previous.then(op)
// The queue tail must survive this operation's failure, or one rejection
// would wedge every later operation on the same package.
this.queues.set(id, next.then(() => {}, () => {}))
return next
}
private async mount(half: DynamicCordisClientHalf): Promise<DynamicCordisLoadResult> {
const styles = new DynamicCordisStyles(half.pluginId)
const ledger: DynamicCordisSlotLedgerRow[] = []
let plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)
try {
plugin = await evaluateClientHalf(half.pluginId, half.code, {
invoke: (method, args) => this.env.invoke(half.pluginId, half.pluginRunId, method, args),
noteError: (message) => {
// A loaded package's own console.error: a page-local diagnostic with
// no wire carrier (the run round trip settled long before).
console.error(`[cordis-client-runner] ${half.pluginId} logged an error:`, message)
},
}, styles)
} catch (error) {
styles.dispose()
return { ok: false, cause: 'evaluate', ...errorDetails(error), error }
}
const pkg: DynamicCordisPackage = {
pluginId: half.pluginId,
packageId: half.packageId,
pluginRunId: half.pluginRunId,
name: half.name,
}
const surface = this.guardedSurface(pkg, half.agentId, plugin, ledger)
const moduleId = moduleIdOf(half.pluginId)
// Invalidate-then-register keeps re-loading legal: the module table throws
// loudly on a duplicate factory registration.
this.env.modules.invalidate(moduleId)
const sink = (globalThis as ModuleLoaderSink).__ModuleLoader__
if (sink === undefined) {
throw new Error('cordis-client-runner: window.__ModuleLoader__ is missing (booted outside the web shell?)')
}
sink.load({ id: moduleId, factory: () => surface })
const entryId = await this.env.loader.create({ name: moduleId })
const fiber = this.env.loader.resolve(entryId).fiber
if (fiber === undefined) {
await this.teardown(half.pluginId, entryId, styles)
return { ok: false, cause: 'module-import', message: 'module import failed (see the browser console)' }
}
try {
await fiber.await()
} catch (error) {
await this.teardown(half.pluginId, entryId, styles)
return { ok: false, cause: 'activate', ...errorDetails(error), error }
}
// Settled but not active = legal pending on an unsatisfied declaration. The
// record is seated only now, so an error mirrored during `apply` cannot
// claim the package is already live.
const waitingFor = Object.keys(fiber.inject).filter(name => this.env.ctx.get(name) === undefined)
const record: LivePackage = { pkg, entryId, styles, ledger, waitingFor }
this.live.set(half.pluginId, record)
// A fresh load answers for itself: whatever this page last showed as crashed
// is no longer true of what is mounted now.
this.failures.delete(half.pluginId)
return settled(record)
}
/**
* Wrap the evaluated plugin so `apply` sees the guard facade; the surface
* doubles as the module-table module. The plugin's OWN `inject` survives (the
* object form's declaration is the facade's service gate, mirroring the host
* sandbox reading `ctx.fiber.inject`); the function form has no declaration
* site and therefore reaches no service.
*/
private guardedSurface(
pkg: DynamicCordisPackage,
agentId: SessionId,
plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown),
ledger: DynamicCordisSlotLedgerRow[],
): DynamicCordisEvaluatedPlugin {
const claim = (component: unknown): void => {
if (indexable(component)) {
this.owners.set(component, { pluginId: pkg.pluginId, pluginRunId: pkg.pluginRunId, agentId })
}
}
const guarded = (ctx: unknown): Context => dynamicCordisContext(ctx as Context, {
pkg,
ledger,
claim,
allocatePriority: () => --this.nextPriority,
reportFailure: error => this.env.reportGuardFailure(
agentId,
pkg.pluginId,
pkg.pluginRunId,
errorDetails(error),
),
})
if (typeof plugin === 'function') {
return { name: moduleIdOf(pkg.pluginId), apply: (ctx: unknown) => plugin(guarded(ctx)) }
}
return {
...plugin,
name: moduleIdOf(pkg.pluginId),
apply: (ctx: unknown, config?: unknown) => plugin.apply(guarded(ctx), config),
}
}
/**
* Unload one package's contributions. Takes the pieces rather than the record
* because a load can fail before any record is seated.
*/
private async teardown(
id: CordisDynamicPluginId,
entryId: string,
styles: DynamicCordisStyles,
): Promise<void> {
this.live.delete(id)
// Nothing of this package renders here any more, so a crash row would outlive
// the thing it described.
this.failures.delete(id)
// Entry removal disposes the fiber (slot entries and facade effects
// cascade); the factory invalidation makes a later re-load legal.
await this.env.loader.remove(entryId)
this.env.modules.invalidate(moduleIdOf(id))
styles.dispose()
}
}
/** The success answer for a package that is live here, parked or active. */
function settled(record: { pkg: DynamicCordisPackage; waitingFor: string[] }): DynamicCordisLoadResult {
return {
ok: true,
pluginRunId: record.pkg.pluginRunId,
...record.waitingFor.length > 0 ? { waitingFor: record.waitingFor } : {},
}
}
/**
* Whether a component can key the ownership index. Identity is the key, so only
* objects and functions qualify — a package may register anything, and what it
* registered is what a crash report carries back.
* @param component - whatever a package passed as its component.
* @returns true when the value can be indexed by identity.
*/
function indexable(component: unknown): component is object {
return typeof component === 'object' && component !== null || typeof component === 'function'
}
/**
* Preserve error fields for a load result without fabricating a stack.
* @param error - original thrown value.
* @returns its message and original string stack, when present.
*/
export function errorDetails(error: unknown): CordisErrorDetails {
if (typeof error !== 'object' || error === null) return { message: String(error) }
const message = 'message' in error && typeof error.message === 'string' ? error.message : String(error)
const stack = 'stack' in error && typeof error.stack === 'string' ? error.stack : undefined
return { message, ...stack === undefined ? {} : { stack } }
}
/**
* What the authoring session reads about one render crash. The slot says where it
* happened, the crash message says what broke, and a withheld global named in that
* text pulls in its redirect — a package that reached `window.setInterval` around
* the closure trap crashes with the engine's bare message, which teaches nothing.
*/
function renderFailureMessage(slot: string, message: string): string {
const redirect = Object.entries(DYNAMIC_CLIENT_REDIRECTS)
.find(([name, text]) => message.includes(name) && !message.includes(text))?.[1]
return `your entry in slot "${slot}" crashed while React rendered it: ${message}`
+ (redirect === undefined ? '' : `\n${redirect}`)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
/** Browser implementation of the Cordis timer Service. */
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context extends Pick<ClientTimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
/** Browser timer Service used by the mixed-in Context helpers. */
timer: ClientTimerService
}
}
type WithDispose<T> = T & { dispose: () => void }
// These `any` positions mirror the Host TimerService's overload erasure: generic callback tuples and async-iterator
// return/rejection values must pass through without narrowing them to one caller's invocation.
/** Browser timer Service with the same public API as the Host Cordis TimerService. */
export class ClientTimerService extends Service {
/** Register the Service and mix its lifecycle-safe helpers onto Context. */
constructor(ctx: Context) {
super(ctx, 'timer')
ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
}
/**
* Run a callback once through {@link timeout}.
* @param callback - Work to run after the delay.
* @param delay - Delay in milliseconds.
* @returns Disposer that cancels the pending callback early.
* @deprecated Use `ctx.timeout()` instead.
*/
setTimeout(callback: () => void, delay: number): () => void {
return this.timeout(callback, delay)
}
/**
* Run a callback repeatedly through {@link interval}.
* @param callback - Work to run on each tick.
* @param delay - Interval in milliseconds.
* @returns Disposer that stops the interval early.
* @deprecated Use `ctx.interval()` instead.
*/
setInterval(callback: () => void, delay: number): () => void {
return this.interval(callback, delay)
}
/**
* Run a callback once after a delay.
* @param callback - work to run.
* @param delay - delay in milliseconds.
* @returns disposer that cancels the callback.
*/
timeout(callback: () => void, delay: number): () => void
/**
* Wait for a delay.
* @param delay - delay in milliseconds.
* @returns promise resolved after the delay.
*/
timeout(delay: number): Promise<void>
timeout(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
const delay = args[0] as number
if (callback !== undefined) {
const dispose = this.ctx.effect(() => {
const timer = globalThis.setTimeout(() => {
dispose()
callback()
}, delay)
return () => { globalThis.clearTimeout(timer) }
}, 'ctx.timeout()')
return dispose
}
const { promise, resolve, reject } = Promise.withResolvers<void>()
const dispose = this.ctx.effect(() => {
const timer = globalThis.setTimeout(resolve, delay)
return () => {
globalThis.clearTimeout(timer)
reject(new Error('Context has been disposed'))
}
}, 'ctx.timeout()')
return promise.finally(dispose)
}
/**
* Run a callback repeatedly.
* @param callback - work to run on each tick.
* @param delay - interval in milliseconds.
* @returns disposer that stops the interval.
*/
interval(callback: () => void, delay: number): () => void
/**
* Iterate over timer ticks.
* @param delay - interval in milliseconds.
* @returns async iterator of ticks.
*/
interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
interval(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
const delay = args[0] as number
if (callback !== undefined) {
return this.ctx.effect(() => {
const timer = globalThis.setInterval(callback, delay)
return () => { globalThis.clearInterval(timer) }
}, 'ctx.interval()')
}
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
const dispose = this.ctx.effect(() => {
const timer = globalThis.setInterval(() => {
nextTask?.resolve({ done: false, value: undefined })
}, delay)
return () => {
globalThis.clearInterval(timer)
if (done !== undefined) return
done = { kind: 'throw', reason: new Error('Context has been disposed') }
nextTask?.reject(done.reason)
}
}, 'ctx.interval()')
return {
next: () => {
if (done === undefined) return (nextTask = Promise.withResolvers()).promise
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
return Promise.reject(done.reason)
},
return: (value: any) => {
if (done === undefined) done = { kind: 'return', value }
nextTask?.resolve({ done: true, value })
dispose()
return Promise.resolve({ done: true, value })
},
throw: (reason: any) => {
if (done === undefined) done = { kind: 'throw', reason }
nextTask?.reject(reason)
dispose()
return Promise.resolve({ done: true, value: undefined })
},
[Symbol.asyncIterator]() {
return this
},
} satisfies AsyncIterableIterator<void>
}
/** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */
private schedule(label: string, trigger: (args: any[], disposed: boolean) => number | undefined, disposed = false): any {
let timer: number | undefined
const dispose = this.ctx.effect(() => () => {
disposed = true
globalThis.clearTimeout(timer)
}, label)
const wrapper: any = (...args: any[]): void => {
globalThis.clearTimeout(timer)
timer = trigger(args, disposed)
}
wrapper.dispose = dispose
return wrapper
}
/**
* Return a throttled function whose timer is disposed with the calling Fiber.
* @param callback - Function to throttle.
* @param delay - Minimum interval between calls in milliseconds.
* @param noTrailing - Whether to suppress a delayed trailing call.
* @returns Throttled function with an early disposer.
*/
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
let lastCall = -Infinity
const execute = (...args: Parameters<F>): void => {
lastCall = Date.now()
callback(...args)
}
return this.schedule('ctx.throttle()', (args, disposed) => {
const remaining = delay - Date.now() + lastCall
if (remaining <= 0) {
execute(...args as Parameters<F>)
} else if (!disposed) {
return globalThis.setTimeout(execute, remaining, ...args)
}
}, noTrailing)
}
/**
* Return a debounced function whose timer is disposed with the calling Fiber.
* @param callback - Function to debounce.
* @param delay - Quiet period in milliseconds.
* @returns Debounced function with an early disposer.
*/
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
return this.schedule('ctx.debounce()', (args, disposed) => {
if (disposed) return
return globalThis.setTimeout(callback, delay, ...args)
})
}
}
/**
* Install the browser timer Service on one Client composition.
* @param ctx - Client context that owns the Service and mixed-in helpers.
* @returns Nothing after registering the Service.
*/
export function provideClientTimer(ctx: Context): void {
new ClientTimerService(ctx)
}

View File

@@ -0,0 +1,9 @@
/**
* Dynamic-package runner plugin, node half. Pure browser-side capability: the
* empty apply exists so the row appears in the host cordis.yml / Loader, while
* the browser half ships through exports["./client"], discovered from the
* package.json dshClient declaration.
*/
/** Host plugin body — this package contributes nothing host-side. */
export function apply(): void {}

View File

@@ -0,0 +1,33 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-cordis-client-runner`.
* @module @deepseek-ai/dsh-cordis-client-runner/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-cordis-client-runner'
/** Cordis companion plugin name. */
export const name = 'cordis-client-runner-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the owned relation (a live
* Plugin's loader entry exists exactly while one Plugin Run ID is live) is
* browser-only state reachable through the client half's service, which the
* node-plane companion cannot observe. The relation is asserted by the
* package's own load/teardown coverage instead.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,195 @@
/**
* @vitest-environment jsdom
*
* Closure evaluation account: the symbol surface a browser half receives, the
* teaching traps shadowing ambient globals, the parse/return diagnostics, and
* the style bookkeeping whose disposal the runner owns.
*/
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import type { CordisDynamicPluginId } from '@deepseek-ai/dsh-api-remotes/client'
import {
DynamicCordisStyles,
DYNAMIC_CLIENT_REDIRECTS,
evaluateClientHalf,
isDynamicCordisPlugin,
} from '../src/client/evaluator.ts'
import type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from '../src/client/evaluator.ts'
const ID = 'dyn-1' as CordisDynamicPluginId
function env(overrides: Partial<DynamicCordisClosureEnv> = {}): DynamicCordisClosureEnv {
return {
invoke: () => Promise.resolve(null),
noteError: () => {},
...overrides,
}
}
/** Evaluate one source with fresh style bookkeeping. */
async function run(source: string, closure: DynamicCordisClosureEnv = env()): Promise<{
plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)
styles: DynamicCordisStyles
}> {
const styles = new DynamicCordisStyles(ID)
const plugin = await evaluateClientHalf(ID, source, closure, styles)
return { plugin, styles }
}
describe('evaluateClientHalf', () => {
it('returns the object-form plugin and hands the page React instance to the closure', async () => {
const { plugin } = await run(`
if (React.createElement === undefined) throw new Error('React symbol missing')
return { name: 'ignored', inject: ['slots'], apply(ctx) { return React } }
`)
expect(typeof plugin).toBe('object')
const object = plugin as DynamicCordisEvaluatedPlugin
expect(object.inject).toEqual(['slots'])
// Same instance as the page's React: a second copy would break hooks.
expect(object.apply({})).toBe(React)
})
it('accepts the function form', async () => {
const { plugin } = await run('return (ctx) => "applied"')
expect(typeof plugin).toBe('function')
expect((plugin as (ctx: unknown) => unknown)({})).toBe('applied')
})
it('redirects browser timers to the ctx facade', async () => {
for (const timer of ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] as const) {
const { plugin } = await run(`return () => ${timer}(() => {}, 1)`)
expect(() => (plugin as (ctx: unknown) => unknown)({}))
.toThrow(DYNAMIC_CLIENT_REDIRECTS[timer])
}
})
it('redirects fetch to the host half and require to the closure symbols', async () => {
const { plugin: fetcher } = await run('return () => fetch("/x")')
expect(() => (fetcher as (ctx: unknown) => unknown)({})).toThrow(/network belongs to the HOST half/)
const { plugin: importer } = await run('return () => require("react")')
expect(() => (importer as (ctx: unknown) => unknown)({})).toThrow(/React arrives as the `React` closure symbol/)
})
it('teaches the half split on any harness access', async () => {
const { plugin } = await run('return () => harness.handle("m", () => {})')
expect(() => (plugin as (ctx: unknown) => unknown)({}))
.toThrow(/harness\.handle belongs to the HOST half/)
})
it('routes host.call to the runner invoke seam', async () => {
const invoke = vi.fn(() => Promise.resolve({ ok: 1 }))
const { plugin } = await run('return { apply: (ctx) => host.call("ping", { a: 1 }) }', env({ invoke }))
await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual({ ok: 1 })
expect(invoke).toHaveBeenCalledWith('ping', { a: 1 })
})
it('sends null for a host.call written without arguments', async () => {
const invoke = vi.fn(() => Promise.resolve(['fs', 'web']))
// A handler that takes nothing is the natural case ("list the services"), and
// `undefined` is not JSON — so the omission travels as null rather than
// making the wire refuse the call.
const { plugin } = await run('return { apply: (ctx) => host.call("listServices") }', env({ invoke }))
await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual(['fs', 'web'])
expect(invoke).toHaveBeenCalledWith('listServices', null)
})
it('reports a parse failure as a plain-JavaScript teaching error', async () => {
await expect(run('return (')).rejects.toThrow(/client half failed to parse in this browser/)
await expect(run('return (')).rejects.toThrow(/no JSX, no TypeScript/)
})
it('names the missing return, and rejects a non-plugin value', async () => {
await expect(run('const x = 1')).rejects.toThrow(/did you forget `return`/)
await expect(run('return 42')).rejects.toThrow(/must `return` a plugin/)
})
it('propagates a non-syntax construction failure untouched', async () => {
const boom = new TypeError('engine refused')
// The constructor is the only failure seam before evaluation; a
// non-SyntaxError must not be reinterpreted as a source problem.
vi.stubGlobal('Function', function stub(): never { throw boom })
try {
await expect(run('return () => {}')).rejects.toBe(boom)
} finally {
vi.unstubAllGlobals()
}
expect(typeof Function).toBe('function')
})
})
describe('tagged console', () => {
it('mirrors only error lines, and stringifies every argument shape', async () => {
const seen: string[] = []
const closure = env({ noteError: message => seen.push(message) })
const circular: Record<string, unknown> = {}
circular.self = circular
const { plugin } = await run(`
return { apply: (ctx) => {
console.log('quiet')
console.warn('also quiet')
console.error('text', new Error('boom'), { a: 1 }, undefined, ctx.circular)
console.debug('quiet too')
} }
`, closure)
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'debug').mockImplementation(() => {})
;(plugin as DynamicCordisEvaluatedPlugin).apply({ circular })
vi.restoreAllMocks()
expect(seen).toHaveLength(1)
expect(seen[0]).toBe('text boom {"a":1} undefined [unserializable console argument]')
})
it('truncates a long mirrored error', async () => {
const seen: string[] = []
const { plugin } = await run(
'return { apply: () => console.error("x".repeat(900)) }',
env({ noteError: message => seen.push(message) }),
)
vi.spyOn(console, 'error').mockImplementation(() => {})
;(plugin as DynamicCordisEvaluatedPlugin).apply({})
vi.restoreAllMocks()
expect(seen[0]).toHaveLength(500)
})
})
describe('DynamicCordisStyles', () => {
it('stamps ownership, counts live tags, and disposes one tag or all of them', () => {
const styles = new DynamicCordisStyles(ID)
const first = styles.insert('.a { color: red }')
styles.insert('.b { color: blue }')
expect(styles.count).toBe(2)
const tags = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
expect(tags).toHaveLength(2)
expect(tags[0]?.textContent).toBe('.a { color: red }')
first()
expect(styles.count).toBe(1)
expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(1)
styles.dispose()
expect(styles.count).toBe(0)
expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(0)
})
it('rejects a non-string stylesheet', () => {
const styles = new DynamicCordisStyles(ID)
expect(() => styles.insert(42 as unknown as string)).toThrow(/needs a CSS string/)
})
it('exposes styles.insert to the closure', async () => {
const { plugin, styles } = await run('return { apply: () => styles.insert(".c {}") }')
;(plugin as DynamicCordisEvaluatedPlugin).apply({})
expect(styles.count).toBe(1)
styles.dispose()
})
})
describe('isDynamicCordisPlugin', () => {
it('accepts both mountable forms and rejects everything else', () => {
expect(isDynamicCordisPlugin(() => {})).toBe(true)
expect(isDynamicCordisPlugin({ apply: () => {} })).toBe(true)
expect(isDynamicCordisPlugin({})).toBe(false)
expect(isDynamicCordisPlugin(null)).toBe(false)
expect(isDynamicCordisPlugin(42)).toBe(false)
})
})

View File

@@ -0,0 +1,255 @@
/**
* @vitest-environment jsdom
*
* Guard facade account: the whitelist a dynamic plugin's `apply` sees, the
* automatic shadowing priority on the slots seat, the theme seat's pinned
* override source and fiber-owned disposer, and the Context denial that keeps a
* dynamic package from reaching a foreign context. Registrations ride the
* CALLING fiber, so disposing it must remove them (HMR safety).
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type {
CordisDynamicPackageId,
CordisDynamicPluginId,
CordisDynamicPluginRunId,
DynamicCordisPackage,
} from '@deepseek-ai/dsh-api-remotes/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { dynamicCordisContext } from '../src/client/guard.ts'
import type { DynamicCordisSlotLedgerRow } from '../src/client/guard.ts'
const C: FC<object> = () => null
/** The exact running package carried by a Client dispatch. */
function pkg(): DynamicCordisPackage {
return {
pluginId: 'dyn-1' as CordisDynamicPluginId,
packageId: 'pkg-1' as CordisDynamicPackageId,
pluginRunId: 'run-1' as CordisDynamicPluginRunId,
name: 'demo',
}
}
/** Erased facade view: a dynamic package reads services off plain properties. */
type Facade = Record<string, unknown> & { get(name: string): unknown }
interface Bench {
ctx: Context
slots: SlotsService
facade: Facade
ledger: DynamicCordisSlotLedgerRow[]
/** Components the facade claimed for the package, in registration order. */
claimed: unknown[]
dispose: () => Promise<void>
overrideTokens: ReturnType<typeof vi.fn>
themeLayerDispose: ReturnType<typeof vi.fn>
}
/**
* Mount a dynamic-plugin fiber declaring `inject`, and capture the facade its
* apply receives (the real product path: the facade wraps the fiber's own ctx).
*/
async function boot(inject: string[], extras: Record<string, unknown> = {}): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(SlotsService)
const themeLayerDispose = vi.fn()
const overrideTokens = vi.fn(() => themeLayerDispose)
ctx.reflect.provide('theme', {
overrideTokens,
getTheme: () => ({ preference: 'light' }),
reload: () => Promise.resolve('reloaded'),
revision: 3,
escape: () => new Context(),
escapeLater: () => Promise.resolve(new Context()),
})
for (const [name, value] of Object.entries(extras)) ctx.reflect.provide(name, value)
const ledger: DynamicCordisSlotLedgerRow[] = []
const claimed: unknown[] = []
let nextPriority = 0
let facade: Facade | undefined
const fiber = ctx.plugin({
name: 'dyn/dyn-1',
inject,
apply: (own: Context) => {
facade = dynamicCordisContext(own, {
pkg: pkg(),
ledger,
claim: (component) => { claimed.push(component) },
allocatePriority: () => --nextPriority,
reportFailure: () => {},
}) as unknown as Facade
},
})
await fiber
if (facade === undefined) throw new Error('facade was not captured')
return {
ctx,
slots: ctx.slots,
facade,
ledger,
claimed,
dispose: async () => { await fiber.dispose() },
overrideTokens,
themeLayerDispose,
}
}
describe('facade surface', () => {
it('forwards whitelisted lifecycle verbs to the real ctx', async () => {
const bench = await boot([])
const seen: string[] = []
const on = bench.facade.on as (event: string, listener: (key: string) => void) => void
on('slots/changed', key => seen.push(key))
bench.ctx.emit('slots/changed', 'root')
expect(seen).toEqual(['root'])
})
it('teaches the object form when an existing service was not declared', async () => {
const bench = await boot([])
expect(() => bench.facade.slots).toThrow(/service "slots" is not declared by your plugin/)
expect(() => bench.facade.slots).toThrow(/a plain `function` has no declaration site/)
})
it('withholds framework internals with a teaching list', async () => {
const bench = await boot([])
expect(() => bench.facade.registry).toThrow(/dynamic ctx does not expose "registry"/)
expect(() => bench.facade.registry).toThrow(/any service your returned plugin declared in inject/)
})
it('answers `get` and `has` over the same whitelist, and refuses writes', async () => {
const bench = await boot(['slots'])
expect(typeof bench.facade.get('slots')).toBe('object')
expect('get' in bench.facade).toBe(true)
expect('on' in bench.facade).toBe(true)
expect('slots' in bench.facade).toBe(true)
expect('registry' in bench.facade).toBe(false)
expect(Symbol.iterator in bench.facade).toBe(false)
expect((bench.facade as unknown as Record<symbol, unknown>)[Symbol.iterator]).toBeUndefined()
expect(() => { bench.facade.slots = 1 }).toThrow(/dynamic ctx is read-only/)
})
it('denies a service value or return that is a cordis Context', async () => {
const bench = await boot(['leaky'], {
leaky: { escape: () => new Context(), later: () => Promise.resolve(new Context()), plain: 7 },
})
const leaky = bench.facade.leaky as { escape(): unknown; later(): Promise<unknown>; plain: number }
expect(() => leaky.escape()).toThrow(/returned a cordis Context/)
await expect(leaky.later()).rejects.toThrow(/returned a cordis Context/)
expect(leaky.plain).toBe(7)
})
it('passes a primitive service through untouched', async () => {
const bench = await boot(['flag'], { flag: 'on' })
expect(bench.facade.flag).toBe('on')
})
})
describe('slots seat', () => {
it('assigns a descending shadowing priority per registration and ledgers it', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { register(options: object, component: unknown): () => void }
slots.register({ name: 'root' }, C)
slots.register({ name: 'root' }, C)
expect(bench.ledger).toEqual([
{ slot: 'root', priority: -1 },
{ slot: 'root', priority: -2 },
])
// Newest-wins ordering is what "registering IS shadowing" means.
const priorities = bench.slots.entries('root').map(entry => entry.options.priority)
expect(priorities).toContain(-1)
expect(priorities).toContain(-2)
})
it('keeps an explicit priority when the target elects its own order', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { register(options: object, component: unknown): () => void }
const spec = vi.spyOn(bench.slots, 'spec').mockReturnValue({ kind: 'chain', scope: 'root' })
slots.register({ name: 'root', priority: 5 }, C)
spec.mockRestore()
expect(bench.ledger).toEqual([{ slot: 'root', priority: 5 }])
})
it('rejects a malformed register call before touching the registry', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { register(options: unknown, component: unknown): () => void }
expect(() => slots.register(null, C)).toThrow(/needs an options object with a `name`/)
expect(() => slots.register({}, C)).toThrow(/need a string `name`/)
expect(bench.slots.entries('root')).toHaveLength(0)
})
it('forwards non-register slot methods through the generic guard', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as {
register(options: object, component: unknown): () => void
entries(key: string): readonly unknown[]
}
slots.register({ name: 'root' }, C)
expect(slots.entries('root')).toHaveLength(1)
})
it('denies a non-callable slots member that would hand out a context', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { ctx: unknown }
// The service's own ctx is the classic escape route out of the facade.
expect(() => slots.ctx).toThrow(/service "slots" returned a cordis Context/)
})
it('removes its registrations when the calling fiber unloads (HMR safety)', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { register(options: object, component: unknown): () => void }
slots.register({ name: 'root' }, C)
expect(bench.slots.entries('root')).toHaveLength(1)
await bench.dispose()
expect(bench.slots.entries('root')).toHaveLength(0)
})
})
describe('theme seat', () => {
it('pins the override source to the package id whatever the caller passes', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as { overrideTokens(source: unknown, tokens: unknown): () => void }
const tokens = { '--dsw-alias-x': { light: '#fff', dark: '#000' } }
theme.overrideTokens('pretend-to-be-someone-else', tokens)
expect(bench.overrideTokens).toHaveBeenCalledWith('dyn-1.pkg-1', tokens)
})
it('teaches the two-argument shape when the token map arrives first', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as { overrideTokens(source: unknown, tokens?: unknown): () => void }
expect(() => theme.overrideTokens({ '--x': { light: 'a', dark: 'b' } }))
.toThrow(/takes two arguments; source is replaced with your package id/)
expect(bench.overrideTokens).not.toHaveBeenCalled()
})
it('hangs the layer disposer on the fiber while still returning it', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as { overrideTokens(source: unknown, tokens: unknown): () => void }
const handle = theme.overrideTokens('mine', {})
expect(handle).toBe(bench.themeLayerDispose)
expect(bench.themeLayerDispose).not.toHaveBeenCalled()
// Model code cannot be trusted to keep the handle: unload must restore.
await bench.dispose()
expect(bench.themeLayerDispose).toHaveBeenCalledTimes(1)
})
it('forwards other theme methods, including asynchronous ones', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as {
getTheme(): { preference: string }
reload(): Promise<string>
revision: number
}
expect(theme.getTheme().preference).toBe('light')
await expect(theme.reload()).resolves.toBe('reloaded')
expect(theme.revision).toBe(3)
})
it('denies a Context a theme method hands back, synchronously or awaited', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as { escape(): unknown; escapeLater(): Promise<unknown> }
expect(() => theme.escape()).toThrow(/service "theme" returned a cordis Context/)
await expect(theme.escapeLater()).rejects.toThrow(/service "theme" returned a cordis Context/)
})
})

View File

@@ -0,0 +1,458 @@
/**
* Run-orchestration account: the order the halves run in (and what a host-only
* definition skips), what each failure answers the host, and what a surface can
* read while it happens. The host seam and the load engine are stood in, because
* what is under test is the round trip itself — the engine has its own account in
* runner.spec.
*/
import { describe, expect, it, vi } from 'vitest'
import type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
DynamicCordisClientSource, DynamicCordisHostHalfResult, DynamicCordisResolveAck,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { CordisRunOrchestrator } from '../src/client/orchestrator.ts'
import type { CordisUserRunRequest } from '../src/client/orchestrator.ts'
import type { DynamicCordisLoadResult, DynamicCordisPackageRunner } from '../src/client/runtime.ts'
const PLUGIN = 'dyn-1' as CordisDynamicPluginId
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
const RUN = 'run-1' as CordisDynamicPluginRunId
const AGENT = 's-1' as SessionId
const REQ = 'rr-1' as ApprovalRequestId
const HOST_OK: Extract<DynamicCordisHostHalfResult, { ok: true }> = {
ok: true,
pluginId: PLUGIN,
packageId: PACKAGE,
pluginRunId: RUN,
waitingFor: [],
startedHere: true,
}
/** A user's own run of a two-half definition: the host half, then this page's half. */
const DUAL: CordisUserRunRequest = {
agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run', hasClientHalf: true,
}
/** A user's own run of a host-only definition: nothing for this page to load. */
const HOST_ONLY: CordisUserRunRequest = { ...DUAL, hasClientHalf: false }
interface Bench {
orchestrator: CordisRunOrchestrator
host: {
runHostHalf: ReturnType<typeof vi.fn>
getClientCode: ReturnType<typeof vi.fn>
resolveRequestRun: ReturnType<typeof vi.fn>
settleUserRun: ReturnType<typeof vi.fn>
}
load: ReturnType<typeof vi.fn>
/** Resolutions the host received, in order. */
answers: unknown[]
}
function boot(overrides: {
hostHalf?: () => Promise<DynamicCordisHostHalfResult>
clientCode?: () => Promise<DynamicCordisClientSource>
loaded?: () => Promise<DynamicCordisLoadResult>
resolve?: () => Promise<DynamicCordisResolveAck>
} = {}): Bench {
const answers: unknown[] = []
const host = {
runHostHalf: vi.fn(overrides.hostHalf ?? (() => Promise.resolve(HOST_OK))),
getClientCode: vi.fn(overrides.clientCode ?? (() => Promise.resolve({
code: 'return {}', name: 'demo', pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN,
}))),
resolveRequestRun: vi.fn((_requestId: unknown, resolution: unknown) => {
answers.push(resolution)
return (overrides.resolve ?? (() => Promise.resolve({ accepted: true })))()
}),
settleUserRun: vi.fn((_agentId: SessionId, _pluginId: CordisDynamicPluginId, resolution: unknown) =>
Promise.resolve({
ok: true as const,
status: 'running' as const,
pluginId: PLUGIN,
packageId: PACKAGE,
pluginRunId: (resolution as { pluginRunId: CordisDynamicPluginRunId }).pluginRunId,
waitingFor: [],
mode: 'run' as const,
})),
}
const load = vi.fn(overrides.loaded ?? (() => Promise.resolve({ ok: true as const, pluginRunId: RUN })))
const orchestrator = new CordisRunOrchestrator({
runner: { load } as unknown as DynamicCordisPackageRunner,
host,
})
return { orchestrator, host, load, answers }
}
/** Register one request the way the `cordis/request-run` event does. */
function ask(bench: Bench, requestId: ApprovalRequestId = REQ): void {
bench.orchestrator.open({
requestId,
agentId: AGENT,
pluginId: PLUGIN,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'draw a clock',
requiresApproval: true,
})
}
describe('the waiting affordance', () => {
it('publishes failures on their own observable', async () => {
const bench = boot({ hostHalf: () => Promise.resolve({ ok: false, message: 'nope' }) })
let notified = 0
const unsubscribe = bench.orchestrator.lastRunError.subscribe(() => { notified++ })
const empty = bench.orchestrator.lastRunError.getSnapshot()
expect(bench.orchestrator.lastRunError.getSnapshot()).toBe(empty)
await bench.orchestrator.startUserRun(DUAL)
expect(notified).toBeGreaterThan(0)
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN)?.reason).toBe('host-half-failed')
unsubscribe()
})
it('publishes one activity per definition, carrying the whole ask', () => {
const bench = boot()
ask(bench)
// Everything a surface needs to show and group the row without a registry
// read: the ask names the session, the plugin, and the model's reason.
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'awaiting-approval',
requestId: REQ,
agentId: AGENT,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'draw a clock',
})
})
it('keeps naming the session once the decision is made', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
const running = bench.orchestrator.startUserRun(DUAL)
// A run must not fall out of its session group by advancing past the decision.
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'orchestrating',
agentId: AGENT,
packageId: PACKAGE,
mode: 'run',
})
release()
await running
})
it('keeps a stable snapshot reference between mutations, and notifies on each', () => {
const bench = boot()
let notified = 0
const unsubscribe = bench.orchestrator.activeRuns.subscribe(() => { notified++ })
const empty = bench.orchestrator.activeRuns.getSnapshot()
expect(bench.orchestrator.activeRuns.getSnapshot()).toBe(empty)
ask(bench)
expect(notified).toBe(1)
expect(bench.orchestrator.activeRuns.getSnapshot()).not.toBe(empty)
unsubscribe()
bench.orchestrator.close(REQ)
expect(notified).toBe(1)
})
it('drops only the waiting affordance when the request settles elsewhere', async () => {
const bench = boot()
ask(bench)
bench.orchestrator.close(REQ)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
// Answering a settled request is a no-op, not an error.
await bench.orchestrator.approve(REQ, false)
await bench.orchestrator.decline(REQ)
expect(bench.host.runHostHalf).not.toHaveBeenCalled()
expect(bench.answers).toEqual([])
})
it('leaves an orchestration alone when its own request settles elsewhere', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
ask(bench)
const running = bench.orchestrator.approve(REQ, false)
// The host announced the request settled (this page answered it) — the work
// this page is doing owns its entry until it finishes.
bench.orchestrator.close(REQ)
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'orchestrating', agentId: AGENT, packageId: PACKAGE, mode: 'run',
})
release()
await running
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('refuses to decline a request whose definition is already orchestrating', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
const running = bench.orchestrator.startUserRun(DUAL)
const late = 'rr-late-decline' as ApprovalRequestId
ask(bench, late)
await bench.orchestrator.decline(late)
expect(bench.answers).toEqual([]) // the decision was made; a refusal now would contradict it
release()
await running
})
it('ignores a close for a request it never saw', () => {
const bench = boot()
bench.orchestrator.close('rr-unknown' as ApprovalRequestId)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('closes a request whose activity is already gone', async () => {
const bench = boot()
const second = 'rr-second' as ApprovalRequestId
ask(bench)
ask(bench, second) // same definition asked twice: the first keeps the affordance
await bench.orchestrator.approve(REQ, false) // settles and clears the activity
bench.orchestrator.close(second)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('does not downgrade an orchestration to a waiting decision', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
const started = bench.orchestrator.startUserRun(DUAL)
ask(bench, 'rr-late' as ApprovalRequestId)
// A request arriving mid-orchestration must not offer a decision already made.
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'orchestrating', agentId: AGENT, packageId: PACKAGE, mode: 'run',
})
release()
await started
})
})
describe('approve', () => {
it('runs the host half first, then loads the browser half, then answers', async () => {
const bench = boot()
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.host.runHostHalf).toHaveBeenCalledWith(AGENT, PLUGIN, PACKAGE, 'run', REQ, false)
expect(bench.host.getClientCode).toHaveBeenCalledWith(AGENT, PLUGIN, RUN)
// The load carries the session too: a crash while React renders it is
// reported back to whoever the run was carried out for.
expect(bench.load).toHaveBeenCalledWith({
pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, agentId: AGENT, name: 'demo', code: 'return {}',
})
expect(bench.answers).toEqual([{ ok: true, pluginRunId: RUN }])
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('carries the services a parked browser half waits for', async () => {
const bench = boot({ loaded: () => Promise.resolve({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] }) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.answers).toEqual([{ ok: true, pluginRunId: RUN, waitingFor: ['absent'] }])
})
it('short-circuits when the host half fails: nothing is fetched or loaded', async () => {
const bench = boot({ hostHalf: () => Promise.resolve({ ok: false, message: 'vm exploded' }) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.host.getClientCode).not.toHaveBeenCalled()
expect(bench.load).not.toHaveBeenCalled()
expect(bench.answers).toEqual([{ ok: false, reason: 'host-half-failed', message: 'vm exploded' }])
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'host-half-failed', ok: false, message: 'vm exploded' })
})
it('folds a transport rejection of the host verb into its own failure shape', async () => {
const bench = boot({ hostHalf: () => Promise.reject(new Error('socket closed')) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.answers).toEqual([{
ok: false,
reason: 'host-half-failed',
message: 'socket closed',
stack: expect.any(String),
}])
})
it('reports a source fetch that failed as the browser half failing', async () => {
const bench = boot({ clientCode: () => Promise.reject(new Error('definition vanished')) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.load).not.toHaveBeenCalled()
expect(bench.answers).toEqual([{
ok: false, reason: 'client-half-failed', pluginRunId: RUN, startedHere: true,
message: 'definition vanished', stack: expect.any(String),
}])
})
it('carries the failing load stage into the answer', async () => {
const bench = boot({ loaded: () => Promise.resolve({ ok: false, cause: 'activate', message: 'apply threw' }) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.answers).toEqual([{
ok: false, reason: 'client-half-failed', pluginRunId: RUN, startedHere: true, message: 'activate: apply threw',
}])
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'client-half-failed', message: 'activate: apply threw' })
})
it('treats a load that rejects outright as a browser-half failure', async () => {
const bench = boot({ loaded: () => Promise.reject(new Error('module table missing')) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.answers).toEqual([{
ok: false, reason: 'client-half-failed', pluginRunId: RUN, startedHere: true,
message: 'evaluate: module table missing', stack: expect.any(String),
}])
})
it('joins a second approve into the orchestration already in flight', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
ask(bench)
const first = bench.orchestrator.approve(REQ, false)
const second = bench.orchestrator.approve(REQ, false)
release()
await Promise.all([first, second])
expect(bench.host.runHostHalf).toHaveBeenCalledTimes(1)
expect(bench.answers).toHaveLength(1)
})
it('logs an answer the host refused, and settles anyway', async () => {
const bench = boot({ resolve: () => Promise.reject(new Error('stream gone')) })
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
ask(bench)
await bench.orchestrator.approve(REQ, false)
const complaints = logged.mock.calls.filter(call => String(call[0]).includes('answering run request'))
logged.mockRestore()
expect(complaints).toHaveLength(1)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('clears a previous failure when the same definition is tried again', async () => {
const outcomes: DynamicCordisLoadResult[] = [
{ ok: false, cause: 'activate', message: 'first try' },
{ ok: true, pluginRunId: RUN },
]
const bench = boot({ loaded: () => Promise.resolve(outcomes.shift() ?? { ok: true, pluginRunId: RUN }) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(1)
await bench.orchestrator.startUserRun(DUAL)
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(0)
})
})
describe('decline', () => {
it('answers rejected without touching either half', async () => {
const bench = boot()
ask(bench)
await bench.orchestrator.decline(REQ)
expect(bench.host.runHostHalf).not.toHaveBeenCalled()
expect(bench.load).not.toHaveBeenCalled()
expect(bench.answers).toEqual([{ ok: false, reason: 'rejected' }])
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
// A refusal is not this page failing.
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(0)
})
it('is a no-op once the decision was already made', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
ask(bench)
const running = bench.orchestrator.approve(REQ, false)
await bench.orchestrator.decline(REQ)
expect(bench.answers).toEqual([])
release()
await running
expect(bench.answers).toEqual([{ ok: true, pluginRunId: RUN }])
})
it('ignores an unknown request', async () => {
const bench = boot()
await bench.orchestrator.decline('rr-unknown' as ApprovalRequestId)
expect(bench.answers).toEqual([])
})
})
describe('startUserRun', () => {
it('orchestrates both halves with nothing to answer', async () => {
const bench = boot()
await bench.orchestrator.startUserRun(DUAL)
expect(bench.host.runHostHalf).toHaveBeenCalledWith(AGENT, PLUGIN, PACKAGE, 'run', null, false)
expect(bench.load).toHaveBeenCalledTimes(1)
// No request was asked, so there is no blocked tool call to settle.
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('records its own failure for the surface to show', async () => {
const bench = boot({ hostHalf: () => Promise.resolve({ ok: false, message: 'no definition' }) })
await bench.orchestrator.startUserRun(DUAL)
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'host-half-failed', ok: false, message: 'no definition' })
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
})
it('records a source fetch failure with nothing to answer', async () => {
const bench = boot({ clientCode: () => Promise.reject(new Error('gone')) })
await bench.orchestrator.startUserRun(DUAL)
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({
packageId: PACKAGE,
reason: 'client-half-failed',
message: 'gone',
stack: expect.any(String),
})
})
it('records a load failure, stringifying a non-Error rejection', async () => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the case under test
const bench = boot({ loaded: () => Promise.reject('plain rejection') })
await bench.orchestrator.startUserRun(DUAL)
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'client-half-failed', message: 'evaluate: plain rejection' })
})
it('is idempotent per definition while one attempt is in flight', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
const first = bench.orchestrator.startUserRun(DUAL)
const second = bench.orchestrator.startUserRun(DUAL)
release()
await Promise.all([first, second])
expect(bench.host.runHostHalf).toHaveBeenCalledTimes(1)
})
it('brings a host-only definition up without fetching or loading anything', async () => {
const bench = boot()
await bench.orchestrator.startUserRun(HOST_ONLY)
expect(bench.host.runHostHalf).toHaveBeenCalledWith(AGENT, PLUGIN, PACKAGE, 'run', null, false)
// There is no second half: asking for source that does not exist would be a
// mistake, and folding its error into `client-half-failed` would report a run
// that succeeded as a failure of a half the definition never had.
expect(bench.host.getClientCode).not.toHaveBeenCalled()
expect(bench.load).not.toHaveBeenCalled()
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(0)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('publishes a host-only run while it is in flight, and its own failure', async () => {
let release = (): void => {}
const bench = boot({
hostHalf: () => new Promise((resolve) => {
release = (): void => { resolve({ ok: false, message: 'vm exploded' }) }
}),
})
const running = bench.orchestrator.startUserRun(HOST_ONLY)
// The control a surface disables comes from this entry, and a host half can
// take real time to evaluate — so a host-only run is in flight like any other.
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'orchestrating', agentId: AGENT, packageId: PACKAGE, mode: 'run',
})
release()
await running
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'host-half-failed', ok: false, message: 'vm exploded' })
})
})

View File

@@ -0,0 +1,458 @@
/**
* @vitest-environment jsdom
*
* Plugin composition account: the dispatch family reaches the runner with its
* envelope rpcId, the service face is provided for UI surfaces, a load failure
* always reaches the console, and the fiber owns the runner's teardown. Plus the two plane-level companions: the
* node half's empty apply and the invariant registration.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { DynamicCordisInvokeResult } from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: resolves `ctx.remote` and with it the `$on`/`$dispatch` surface.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import * as NodeHalf from '../src/index.ts'
import * as Invariant from '../src/invariant.ts'
import * as ClientHalf from '../src/client/index.ts'
const PLUGIN = 'dyn-1' as CordisDynamicPluginId
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
const RUN = 'run-1' as CordisDynamicPluginRunId
const AGENT = 's-1' as SessionId
const USER_RUN = {
agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' as const, hasClientHalf: true,
}
/**
* Deliver one forwarded Host event the way the runtime's frame bridge does: the
* bridge hands `host/remote-event` to the Remote service, which fans it out to
* `$on` subscribers with the Host's own argument list.
*/
function forward(ctx: Context, event: string, payload: object): void {
ctx.remote.$dispatch(event, [payload])
}
interface Bench {
ctx: Context
/** Source the host hands over for the next run. */
source: { current: {
code: string
name: string
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
pluginRunId: CordisDynamicPluginRunId
} }
/** Resolutions the host received. */
resolved: { requestId: string; resolution: unknown }[]
/** What the namespace received. */
invoked: { pluginId: CordisDynamicPluginId; pluginRunId: CordisDynamicPluginRunId; method: string; args: unknown }[]
/** Answer of the next invoke call. */
invokeResult: { current: DynamicCordisInvokeResult }
/** Rejection the namespace throws instead of answering (the codec refusing a payload). */
invokeThrow: { current: unknown }
/** Render failures the namespace received, in order. */
renderFailures: {
agentId: string
pluginId: CordisDynamicPluginId
pluginRunId: CordisDynamicPluginRunId
failure: unknown
}[]
/** Whether the namespace refuses the next render-failure report. */
reportRefused: { current: boolean }
/**
* Report one entry crash the way the renderer's boundary does. Production calls
* this from web-react's boundary through the render host; a test has no React
* tree, so it stands in for that caller on the same core seam.
*/
crash: (slot: string, entry: unknown, abdicate: boolean, error: unknown) => void
dispose: () => Promise<void>
settle: () => Promise<void>
}
/** Mount the browser half over a module table and a loader standing on real fibers. */
async function boot(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(SlotsService)
const factories = new Map<string, () => unknown>()
const fibers = new Map<string, { fiber: unknown }>()
let next = 0
;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = {
load: (handoff: { id: string; factory: () => unknown }) => { factories.set(handoff.id, handoff.factory) },
}
ctx.reflect.provide('loader', {
create: (options: { name: string }) => {
const entryId = `entry-${++next}`
const fiber = ctx.plugin(factories.get(options.name)?.() as Parameters<Context['plugin']>[0])
// The runner reads activation failure through fiber.await(); terminate this
// handle too, or a failing package also lands as an unhandled rejection.
void Promise.resolve(fiber).catch(() => {})
fibers.set(entryId, { fiber })
return Promise.resolve(entryId)
},
resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
remove: async (entryId: string) => {
const entry = fibers.get(entryId)
fibers.delete(entryId)
await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
},
})
ctx.reflect.provide('modules', { invalidate: () => {} })
const invoked: Bench['invoked'] = []
const invokeResult: { current: DynamicCordisInvokeResult } = { current: { ok: true, value: 'pong' } }
const invokeThrow: { current: unknown } = { current: undefined }
const source: Bench['source'] = { current: {
code: 'return { apply(ctx) {} }',
name: 'demo',
pluginId: PLUGIN,
packageId: PACKAGE,
pluginRunId: RUN,
} }
const resolved: { requestId: string; resolution: unknown }[] = []
const renderFailures: Bench['renderFailures'] = []
const reportRefused = { current: false }
// Every generated Remote method resolves to a RemoteResult: the carrier folds
// its own failures into the error branch, and only an assembly fault rejects.
const answered = <T>(value: T): Promise<{ ok: true; value: T }> => Promise.resolve({ ok: true as const, value })
const namespace = {
syncInspectManifest: () => answered(null),
resolveInspectQuery: () => answered({ accepted: true }),
runHostHalf: () => answered({
ok: true, pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, waitingFor: [], startedHere: true,
}),
settleUserRun: () => answered({
ok: true, pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, waitingFor: [],
}),
reportRenderFailure: (
agentId: string,
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
failure: unknown,
) => {
renderFailures.push({ agentId, pluginId, pluginRunId, failure })
return reportRefused.current ? Promise.reject(new Error('stream gone')) : answered(undefined)
},
getClientCode: () => answered(source.current),
resolveRequestRun: (requestId: string, resolution: unknown) => {
resolved.push({ requestId, resolution })
return answered({ accepted: true })
},
invoke: (
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
method: string,
args: unknown,
) => {
invoked.push({ pluginId, pluginRunId, method, args })
const refusal = invokeThrow.current
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is a case under test
if (refusal !== undefined) return Promise.reject(refusal)
return answered(invokeResult.current)
},
}
// Minimal stand-in for the gateway's Client Remote: the fan-out under test is
// this plugin's subscriptions, so registration order and delivery are all the
// stub owes (api-gateway covers isolation and disposal on the real one).
const listeners = new Map<string, ((...args: never[]) => void)[]>()
const remote = {
dynamicCordisRunner: namespace,
$on: (event: string, listener: (...args: never[]) => void) => {
const bucket = listeners.get(event) ?? []
bucket.push(listener)
listeners.set(event, bucket)
return () => {
const at = bucket.indexOf(listener)
if (at >= 0) bucket.splice(at, 1)
}
},
$dispatch: (event: string, args: readonly unknown[]) => {
for (const listener of [...listeners.get(event) ?? []]) {
(listener as (...a: readonly unknown[]) => void)(...args)
}
},
}
ctx.reflect.provide('remote', remote)
ctx.reflect.provide('remote.dynamicCordisRunner', namespace)
const fiber = ctx.plugin(ClientHalf)
await fiber
return {
ctx,
source,
resolved,
invoked,
invokeResult,
invokeThrow,
renderFailures,
reportRefused,
crash: (slot, entry, abdicate, error) => {
const core = (ctx.slots as unknown as {
_core: { reportEntryError(key: string, entry: unknown, error: unknown, info: { abdicate: boolean }): void }
})._core
core.reportEntryError(slot, entry, error, { abdicate })
},
dispose: async () => { await fiber.dispose() },
settle: async () => { await new Promise((resolve) => { setTimeout(resolve, 0) }) },
}
}
describe('browser half', () => {
it('provides the load engine as the page run-state face', async () => {
const bench = await boot()
expect(bench.ctx.dynamicCordisRunner.getSnapshot()).toEqual([])
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
})
it('unloads on a forwarded withdrawal event', async () => {
const bench = await boot()
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true)
forward(bench.ctx, 'cordis/dynamic-retract', {
pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN,
})
await bench.settle()
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
})
it('runs a host-only definition through the face without loading anything here', async () => {
const bench = await boot()
await bench.ctx.dynamicCordisRunner.startUserRun({ ...USER_RUN, hasClientHalf: false })
// The host half is up and this page has nothing — and no failure, which is
// what the surface's control promised.
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
expect(bench.ctx.dynamicCordisRunner.lastRunError.getSnapshot().size).toBe(0)
})
it('routes host.call through the namespace and unwraps the result', async () => {
const bench = await boot()
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", { a: 1 })'
+ '.then((value) => value, (error) => error.message) } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
delete (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
await expect(call).resolves.toBe('pong')
expect(bench.invoked).toEqual([{
pluginId: PLUGIN, pluginRunId: RUN, method: 'ping', args: { a: 1 },
}])
})
it('carries an omitted host.call argument to the namespace as null', async () => {
const bench = await boot()
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("listServices") } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
delete (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
await call
// `undefined` is not JSON, so the wire would refuse the call the model wrote
// most naturally; the omission travels as null instead.
expect(bench.invoked).toEqual([{
pluginId: PLUGIN, pluginRunId: RUN, method: 'listServices', args: null,
}])
})
it('teaches the JSON contract when the namespace refuses the payload', async () => {
const bench = await boot()
// What the generated codec throws for a value that is not JSON: a bare field
// name, with no idea which call it belonged to or what to write instead.
bench.invokeThrow.current = new Error('client api: dynamicCordisRunner/invoke rejected "args"')
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", 1)'
+ '.then(() => "resolved", (error) => error.message) } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
await expect(call).resolves.toMatch(/host\.call\("ping"\) on dyn-1 did not complete: client api: .*rejected "args"/)
await expect(call).resolves.toMatch(/omit it, and the handler receives null/)
await expect(call).resolves.toMatch(/`return null` when there is nothing to report/)
})
it('stringifies a non-Error refusal into the same teaching error', async () => {
const bench = await boot()
bench.invokeThrow.current = 'stream gone'
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping")'
+ '.then(() => "resolved", (error) => error.message) } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
await expect(call).resolves.toMatch(/did not complete: stream gone/)
})
it('sends a render crash of its own entry to the host, and survives a refused report', async () => {
const bench = await boot()
bench.source.current = { ...bench.source.current,
code: `return {
inject: ['slots'],
apply(ctx) { ctx.slots.register({ name: 'root' }, () => null) },
}`,
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const [entry] = bench.ctx.slots.entries('root')
bench.crash('root', entry, true, new Error('Cannot read properties of undefined'))
expect(bench.renderFailures).toEqual([{
agentId: AGENT,
pluginId: PLUGIN,
pluginRunId: RUN,
failure: {
slot: 'root',
message: 'your entry in slot "root" crashed while React rendered it: Cannot read properties of undefined',
stack: expect.any(String),
abdicated: true,
},
}])
// The same observation also reaches the page's own surface, so a row can show
// it without reading the host back.
expect(bench.ctx.dynamicCordisRunner.renderFailures.getSnapshot().get(PLUGIN)).toEqual(bench.renderFailures[0]?.failure)
// A report the host refuses is logged and dropped: one crash must not become
// two, and nothing waits on this answer.
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
bench.reportRefused.current = true
bench.crash('root', entry, false, new Error('again'))
await bench.settle()
const complaints = logged.mock.calls.filter(call => String(call[0]).includes('reporting a render failure'))
logged.mockRestore()
expect(complaints).toHaveLength(1)
})
it('turns each routing failure code into its own teaching error', async () => {
const codes = [
['plugin-not-running', /found no active Host half/],
['stale-run', /activation that has already been replaced/],
['method-not-found', /must declare it with harness\.handle\("ping", fn\)/],
['handler-error', /failed inside the host handler: boom/],
] as const
for (const [code, expected] of codes) {
const bench = await boot()
bench.invokeResult.current = { ok: false, code, message: 'boom' }
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", 1)'
+ '.then(() => "resolved", (error) => error.message) } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
await expect(call).resolves.toMatch(expected)
}
})
it('answers a run request after the surface approves it', async () => {
const bench = await boot()
const request = 'rr-1' as ApprovalRequestId
forward(bench.ctx, 'cordis/request-run', {
requestId: request,
agentId: AGENT,
pluginId: PLUGIN,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'show a clock',
requiresApproval: true,
})
await bench.settle()
// The event's own fields reach the activity: a surface groups the row by
// session and shows the reason without a registry read.
expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'awaiting-approval',
requestId: request,
agentId: AGENT,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'show a clock',
})
await bench.ctx.dynamicCordisRunner.approve(request, false)
expect(bench.resolved).toEqual([{
requestId: request, resolution: { ok: true, pluginRunId: RUN },
}])
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true)
expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().size).toBe(0)
})
it('drops the affordance when another page answers the request', async () => {
const bench = await boot()
const request = 'rr-2' as ApprovalRequestId
forward(bench.ctx, 'cordis/request-run', {
requestId: request,
agentId: AGENT,
pluginId: PLUGIN,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'p',
requiresApproval: true,
})
await bench.settle()
forward(bench.ctx, 'cordis/request-run-resolved', {
requestId: request, outcome: 'approved',
})
await bench.settle()
expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().size).toBe(0)
// Answering a settled request is a no-op, not an error.
await bench.ctx.dynamicCordisRunner.approve(request, false)
expect(bench.resolved).toEqual([])
})
it('exposes the refusal and the load observer on the face', async () => {
const bench = await boot()
const request = 'rr-3' as ApprovalRequestId
forward(bench.ctx, 'cordis/request-run', {
requestId: request,
agentId: AGENT,
pluginId: PLUGIN,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'p',
requiresApproval: true,
})
await bench.settle()
let loads = 0
const unsubscribe = bench.ctx.dynamicCordisRunner.subscribe(() => { loads++ })
await bench.ctx.dynamicCordisRunner.decline(request)
expect(bench.resolved).toEqual([{ requestId: request, resolution: { ok: false, reason: 'rejected' } }])
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
expect(loads).toBeGreaterThan(0)
unsubscribe()
})
it('unloads every package when its own fiber goes away', async () => {
const bench = await boot()
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const runner = bench.ctx.dynamicCordisRunner
await bench.dispose()
await bench.settle()
expect(runner.getSnapshot()).toEqual([])
})
})
describe('node half', () => {
it('contributes nothing host-side', () => {
NodeHalf.apply()
expect(typeof NodeHalf.apply).toBe('function')
})
})
describe('invariant companion', () => {
it('reserves package ownership with an explained empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = ctx.plugin(Invariant)
await fiber
expect(Invariant.name).toBe('cordis-client-runner-invariant')
// No relation to audit here: the owned one is browser-local runner state.
// An event this plugin declares nothing about: the bridge must not route it here.
expect(() => { (ctx.emit as (type: string) => void)('unrelated/event') }).not.toThrow()
await fiber.dispose()
})
})

View File

@@ -0,0 +1,515 @@
/**
* @vitest-environment jsdom
*
* Load-engine account: what `load` answers its caller (that answer is what the
* run orchestration reports to the host), Plugin Run convergence against live
* state, per-Plugin serialization, the three-step teardown, and each failing stage.
*
* The loader is stood in by real `ctx.plugin` fibers: entry creation must run the
* guarded surface as a genuine plugin, or neither activation gating nor the
* disposal cascade under test would be real.
*/
import { Context } from '@deepseek-ai/cordis'
import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
import { describe, expect, it, vi } from 'vitest'
import type {
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { DYNAMIC_CLIENT_REDIRECTS } from '../src/client/evaluator.ts'
import { DynamicCordisPackageRunner } from '../src/client/runtime.ts'
import type { DynamicCordisClientHalf, DynamicCordisRenderFailure } from '../src/client/runtime.ts'
const PLUGIN = 'dyn-1' as CordisDynamicPluginId
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
const RUN = 'run-1' as CordisDynamicPluginRunId
const AGENT = 's-1' as SessionId
function runId(value: number): CordisDynamicPluginRunId {
return `run-${value}` as CordisDynamicPluginRunId
}
/** One browser half as the host hands it over. */
function half(overrides: Partial<DynamicCordisClientHalf> = {}): DynamicCordisClientHalf {
return {
pluginId: PLUGIN,
packageId: PACKAGE,
pluginRunId: RUN,
agentId: AGENT,
name: 'demo',
code: 'return { apply(ctx) {} }',
...overrides,
}
}
interface Bench {
ctx: Context
slots: SlotsService
runner: DynamicCordisPackageRunner
invalidated: string[]
removed: string[]
created: string[]
invoke: ReturnType<typeof vi.fn>
/** Render failures the runner sent upstream, in order. */
reported: {
agentId: SessionId
pluginId: CordisDynamicPluginId
pluginRunId: CordisDynamicPluginRunId
failure: DynamicCordisRenderFailure
}[]
/**
* Report one entry crash the way the renderer's boundary does: the runner
* subscribed through the supervision seam, and this calls what it registered.
*/
crash: (slot: string, entry: unknown, error: unknown, abdicated?: boolean) => void
/** Whether the runner released its subscription. */
watching: () => boolean
settle: () => Promise<void>
}
/**
* Terminate the awaitable fiber handle. The runner reads activation failure
* through `fiber.await()`; without a handler on the fiber itself, a deliberately
* failing package would also surface as an unhandled rejection.
*/
function seated<T>(fiber: T): T {
void Promise.resolve(fiber).catch(() => {})
return fiber
}
async function boot(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(SlotsService)
const invalidated: string[] = []
const removed: string[] = []
const created: string[] = []
const factories = new Map<string, () => unknown>()
const fibers = new Map<string, { fiber: unknown }>()
let next = 0
;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = {
load: (handoff: { id: string; factory: () => unknown }) => { factories.set(handoff.id, handoff.factory) },
}
const loader = {
create: (options: { name: string }) => {
created.push(options.name)
const factory = factories.get(options.name)
if (factory === undefined) throw new Error(`no factory for ${options.name}`)
const entryId = `entry-${++next}`
fibers.set(entryId, { fiber: seated(ctx.plugin(factory() as Parameters<Context['plugin']>[0])) })
return Promise.resolve(entryId)
},
resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
remove: async (entryId: string) => {
removed.push(entryId)
const entry = fibers.get(entryId)
fibers.delete(entryId)
await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
},
} as unknown as Loader
const invoke = vi.fn(() => Promise.resolve(null))
const reported: Bench['reported'] = []
// The crash seam is stood in so a test can report an entry failure without a
// React render, exactly as the renderer's boundary would; registrations still
// go through the real service, so the entries are real.
type EntryErrorListener = (slot: string, entry: unknown, error: unknown, info: { abdicated: boolean }) => void
let listener: EntryErrorListener | undefined
const runner = new DynamicCordisPackageRunner({
ctx,
loader,
modules: { invalidate: (id: string) => { invalidated.push(id) } } as unknown as ClientModuleSystem,
slots: {
onEntryError: (fn: EntryErrorListener) => {
listener = fn
return () => { listener = undefined }
},
} as unknown as SlotsService,
invoke,
reportGuardFailure: () => {},
reportRenderFailure: (agentId, pluginId, pluginRunId, failure) => {
reported.push({ agentId, pluginId, pluginRunId, failure })
},
})
return {
ctx,
slots: ctx.slots,
runner,
invalidated,
removed,
created,
invoke,
reported,
crash: (slot, entry, error, abdicated = true) => {
if (listener === undefined) throw new Error('the runner is not watching the crash seam')
listener(slot, entry, error, { abdicated })
},
watching: () => listener !== undefined,
settle: async () => { await new Promise((resolve) => { setTimeout(resolve, 0) }) },
}
}
describe('load', () => {
it('mounts a browser half through the module table and the loader, then answers active', async () => {
const bench = await boot()
await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
expect(bench.invalidated).toEqual(['dyn/dyn-1'])
expect(bench.created).toEqual(['dyn/dyn-1'])
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
expect(bench.runner.getSnapshot()).toEqual([
{ pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, name: 'demo', slots: [], styleCount: 0 },
])
})
it('projects the contributions the package made', async () => {
const bench = await boot()
await bench.runner.load(half({
code: `return {
inject: ['slots'],
apply(ctx) {
styles.insert('.x {}')
ctx.slots.register({ name: 'root' }, () => null)
},
}`,
}))
expect(bench.runner.getSnapshot()).toEqual([
{ pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, name: 'demo', slots: ['root'], styleCount: 1 },
])
})
it('answers from live state when the revision is already loaded here', async () => {
const bench = await boot()
await bench.runner.load(half())
// A replayed run must not look unacknowledged, and must not reload.
await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
expect(bench.created).toEqual(['dyn/dyn-1'])
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
})
it('replays the parked services a live package still waits for', async () => {
const bench = await boot()
const parked = half({ code: "return { inject: ['absent'], apply() {} }" })
await expect(bench.runner.load(parked)).resolves.toEqual({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] })
await expect(bench.runner.load(parked)).resolves.toEqual({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] })
expect(bench.created).toEqual(['dyn/dyn-1'])
})
it('replaces a live load when a newer revision arrives', async () => {
const bench = await boot()
await bench.runner.load(half())
await expect(bench.runner.load(half({ pluginRunId: runId(2) }))).resolves.toEqual({ ok: true, pluginRunId: runId(2) })
expect(bench.removed).toEqual(['entry-1'])
expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1', 'dyn/dyn-1'])
expect(bench.created).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
expect(bench.runner.getSnapshot()[0]?.pluginRunId).toBe(runId(2))
})
it('loads the function form, which declares no services', async () => {
const bench = await boot()
await expect(bench.runner.load(half({ code: 'return (ctx) => { globalThis.__dynFnForm = true }' })))
.resolves.toEqual({ ok: true, pluginRunId: RUN })
expect((globalThis as { __dynFnForm?: boolean }).__dynFnForm).toBe(true)
delete (globalThis as { __dynFnForm?: boolean }).__dynFnForm
})
it('serializes operations of one package id', async () => {
const bench = await boot()
const first = bench.runner.load(half())
const second = bench.runner.load(half({ pluginRunId: runId(2) }))
await expect(first).resolves.toEqual({ ok: true, pluginRunId: RUN })
await expect(second).resolves.toEqual({ ok: true, pluginRunId: runId(2) })
expect(bench.created).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
})
it('keeps the queue usable after a failed operation', async () => {
const bench = await boot()
const sink = (globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__
delete (globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__
await expect(bench.runner.load(half())).rejects.toThrow(/__ModuleLoader__ is missing/)
;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = sink
await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
})
})
describe('failure stages', () => {
it('classifies a closure that will not evaluate, and leaves no styles behind', async () => {
const bench = await boot()
await expect(bench.runner.load(half({ code: 'styles.insert(".leak {}"); return 42' }))).resolves.toEqual({
ok: false,
cause: 'evaluate',
message: expect.stringContaining('must `return` a plugin') as string,
stack: expect.any(String),
error: expect.any(Error),
})
const leaked = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
.filter(tag => tag.textContent === '.leak {}')
expect(leaked).toHaveLength(0)
expect(bench.created).toEqual([])
})
it('classifies an apply that throws, and tears the entry down', async () => {
const bench = await boot()
await expect(bench.runner.load(half({ code: 'return { apply() { throw new Error("apply exploded") } }' })))
.resolves.toEqual({
ok: false,
cause: 'activate',
message: 'apply exploded',
stack: expect.any(String),
error: expect.any(Error),
})
expect(bench.removed).toEqual(['entry-1'])
expect(bench.runner.isLoaded(PLUGIN)).toBe(false)
})
it('stringifies a closure that rejects with a non-Error value', async () => {
const bench = await boot()
await expect(bench.runner.load(half({ code: 'throw "raw rejection"' })))
.resolves.toEqual({ ok: false, cause: 'evaluate', message: 'raw rejection', error: 'raw rejection' })
})
it('classifies a loader entry that produced no fiber', async () => {
const bench = await boot()
const env = bench.runner as unknown as { env: { loader: { resolve: (id: string) => unknown } } }
vi.spyOn(env.env.loader, 'resolve').mockReturnValue({ fiber: undefined })
await expect(bench.runner.load(half())).resolves.toEqual({
ok: false,
cause: 'module-import',
message: 'module import failed (see the browser console)',
})
vi.restoreAllMocks()
expect(bench.removed).toEqual(['entry-1'])
})
it('mirrors a loaded package runtime error to the console without unloading it', async () => {
const bench = await boot()
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
await bench.runner.load(half({
code: 'return { apply: (ctx) => { ctx.on("t/ping", () => console.error("after load")) } }',
}))
;(bench.ctx.emit as (type: string) => void)('t/ping')
const mirrored = logged.mock.calls.filter(call => String(call[0]).includes('logged an error'))
logged.mockRestore()
expect(mirrored).toHaveLength(1)
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
})
})
describe('retract', () => {
it('unloads at the named revision', async () => {
const bench = await boot()
await bench.runner.load(half())
bench.runner.retract(PLUGIN, RUN)
await bench.settle()
expect(bench.removed).toEqual(['entry-1'])
expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
expect(bench.runner.isLoaded(PLUGIN)).toBe(false)
})
it('ignores a retract of a superseded revision', async () => {
const bench = await boot()
await bench.runner.load(half({ pluginRunId: runId(3) }))
bench.runner.retract(PLUGIN, runId(2))
await bench.settle()
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
})
it('ignores a retract of a package this page never loaded', async () => {
const bench = await boot()
bench.runner.retract(PLUGIN, RUN)
await bench.settle()
expect(bench.removed).toEqual([])
})
})
describe('observation and disposal', () => {
it('notifies subscribers and re-derives the snapshot after each convergence', async () => {
const bench = await boot()
let notified = 0
const unsubscribe = bench.runner.subscribe(() => { notified++ })
const empty = bench.runner.getSnapshot()
expect(bench.runner.getSnapshot()).toBe(empty) // stable between mutations
await bench.runner.load(half())
expect(notified).toBe(1)
expect(bench.runner.getSnapshot()).not.toBe(empty)
unsubscribe()
bench.runner.retract(PLUGIN, RUN)
await bench.settle()
expect(notified).toBe(1)
})
it('unloads every live package on disposal', async () => {
const bench = await boot()
await bench.runner.load(half())
await bench.runner.dispose()
expect(bench.removed).toEqual(['entry-1'])
expect(bench.runner.getSnapshot()).toEqual([])
expect(bench.slots.entries('root')).toHaveLength(0)
})
it('routes host.call through the invoke seam it was given', async () => {
const bench = await boot()
await bench.runner.load(half({ code: 'return { apply: () => host.call("ping", 1) }' }))
expect(bench.invoke).toHaveBeenCalledWith(PLUGIN, RUN, 'ping', 1)
})
})
describe('render failures', () => {
/** A package that seats one component in `root`, so a crash has something to name. */
const CONTRIBUTOR = `return {
inject: ['slots'],
apply(ctx) { ctx.slots.register({ name: 'root' }, () => null) },
}`
it('reports a crash of an entry it seated, under the session the run was for', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('Cannot read properties of undefined'))
expect(bench.reported).toEqual([{
agentId: AGENT,
pluginId: PLUGIN,
pluginRunId: RUN,
failure: {
slot: 'root',
message: 'your entry in slot "root" crashed while React rendered it: Cannot read properties of undefined',
stack: expect.any(String),
abdicated: true,
},
}])
})
it('carries the retirement bit as the seam reported it', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
// A chain crash keeps its cell: the package's UI is broken, not gone, and the
// author needs to be able to tell those apart.
bench.crash('root', entry, new Error('boom'), false)
expect(bench.reported[0]?.failure.abdicated).toBe(false)
})
it('ignores a crash of an entry no dynamic package seated', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
// Factory UI crashing is not this runner's business, and neither is an entry
// whose component cannot even be indexed by identity.
bench.crash('root', { component: () => null }, new Error('boom'))
bench.crash('root', { component: 'not-a-component' }, new Error('boom'))
bench.crash('root', { component: null }, new Error('boom'))
expect(bench.reported).toEqual([])
})
it('seats a package that registers an unindexable component without claiming it', async () => {
const bench = await boot()
// A component that is not an object has no identity to key ownership on; the
// registration still stands, and a crash on it simply goes unattributed.
await expect(bench.runner.load(half({
code: `return {
inject: ['slots'],
apply(ctx) {
ctx.slots.register({ name: 'root' }, 'not-a-component')
ctx.slots.register({ name: 'root' }, null)
},
}`,
}))).resolves.toEqual({ ok: true, pluginRunId: RUN })
for (const entry of bench.slots.entries('root')) bench.crash('root', entry, new Error('boom'))
expect(bench.reported).toEqual([])
})
it('appends the redirect a bare crash text is missing, and never twice', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
// Reaching the global around the closure trap (window.setInterval) crashes
// with the engine's own text, which teaches nothing on its own.
bench.crash('root', entry, new TypeError('window.setInterval is not a function'))
const bare = bench.reported[0]?.failure.message ?? ''
expect(bare).toMatch(/is not a function\n/)
const timerRedirect = DYNAMIC_CLIENT_REDIRECTS.setInterval
if (timerRedirect === undefined) throw new Error('setInterval redirect is missing')
expect(bare).toContain(timerRedirect)
// The trap's own error already carries that sentence: appending it again
// would make the model read the same paragraph twice.
bench.crash('root', entry, new Error(
`setInterval is not available in a dynamic client half — ${timerRedirect}`,
))
const trapped = bench.reported[1]?.failure.message ?? ''
expect(trapped.indexOf(timerRedirect)).toBe(trapped.lastIndexOf(timerRedirect))
})
it('stops watching the seam when the engine is disposed', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
expect(bench.watching()).toBe(true)
await bench.runner.dispose()
expect(bench.watching()).toBe(false)
})
it('publishes the crash on the live set\'s own notification channel', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
let notified = 0
let alsoNotified = 0
const unsubscribe = bench.runner.subscribe(() => { notified++ })
const unobserve = bench.runner.renderFailures.subscribe(() => { alsoNotified++ })
const empty = bench.runner.renderFailures.getSnapshot()
expect(bench.runner.renderFailures.getSnapshot()).toBe(empty) // stable between mutations
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('boom'), false)
// A surface already subscribed for load changes learns about a crash too: one
// channel, two derived snapshots — and the observable's own subscribe is that
// same channel, so a surface may take either handle.
expect(notified).toBe(1)
expect(alsoNotified).toBe(1)
const published = bench.runner.renderFailures.getSnapshot().get(PLUGIN)
expect(published?.slot).toBe('root')
expect(published?.abdicated).toBe(false)
expect(published?.message).toMatch(/boom/)
unsubscribe()
unobserve()
})
it('keeps only the latest crash per package', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('first'))
bench.crash('root', entry, new Error('second'))
expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
expect(bench.runner.renderFailures.getSnapshot().get(PLUGIN)?.message).toMatch(/second/)
})
it('clears the crash when the package is retracted', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('boom'))
bench.runner.retract(PLUGIN, RUN)
await bench.settle()
// A row must never show a failure of something that no longer renders here.
expect(bench.runner.renderFailures.getSnapshot().size).toBe(0)
})
it('clears the crash when the package loads again', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('boom'))
expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
await bench.runner.load(half({ code: CONTRIBUTOR, pluginRunId: runId(2) }))
expect(bench.runner.renderFailures.getSnapshot().size).toBe(0)
})
it('keeps the crash when a replayed run loads nothing', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('boom'))
// Same revision: nothing was re-run, so the failure the page is showing is
// still true of what is mounted.
await bench.runner.load(half({ code: CONTRIBUTOR }))
expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../../client/connection/tsconfig.client.json"
},
{
"path": "../../client/modules"
},
{
"path": "../../client/runtime"
},
{
"path": "../../client/ui-slots"
},
{
"path": "../../client/ui-theme"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-cordis-client-runner', ['lib/types/index.js', 'lib/types/invariant.js'])

View 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/cordis-host-runner/README.md
README.md: af84270e549ebc71cb2176f5d1d61531603060f9
README.zh.md: d608426b4b0d6fe11e20cea0cb119740bfcfe100

View File

@@ -0,0 +1,72 @@
# @deepseek-ai/dsh-cordis-host-runner
English | [中文](README.zh.md)
The host half of model-mounted dynamic packages: the definition registry, the `node:vm` sandbox and fiber lifecycle for host halves, the invoke handler table, and the run round trip a browser page carries out. Provided as `ctx.dynamicCordisRunner`. The model-facing tools live in [`@deepseek-ai/dsh-tool-cordis`](../tool-cordis/README.md); the browser half is loaded by [`@deepseek-ai/dsh-cordis-client-runner`](../cordis-client-runner/README.md).
## What it does
Two phases: `define` only records, and everything with an effect hangs off a run.
- `define` / `undefine` own a definition's life. `define` trims and requires the metadata, prechecks each half's syntax by compiling it (running nothing), mints `dyn-<n>`, and records the definition against the session that asked — it has no effect to roll back, so unparseable code is refused before an id exists. `undefine` stops a running definition first, then forgets it. Neither crosses the wire: only the model's own tool call defines.
- `run` answers the model's request to run one definition, and its two shapes differ by whose business the package is. A host-only package is this process's own: the host half is evaluated in the vm under the `cordis-dynamic` group fiber and the call returns. A package with a browser half has to be carried out by a page, so `run` becomes an answerable round trip — it emits `cordis/request-run`, suspends, and is settled by a person allowing or declining it. There is no timer; the caller's `AbortSignal` (the asking turn was cancelled) is the only other way out, and it announces the cancellation so other pages stop offering an answer. Whether any page will answer is not knowable when the request is sent — a page that received it may still never answer, so a deployment with no page connected suspends like any other unanswered request and ends in `cancelled`. `run` has no wire face — `cordis_run` calls it in process.
- `runHostHalf` / `getClientCode` are the steps an allowed page walks, host half first, so a host-half failure short-circuits before the browser has moved. `runHostHalf` is idempotent by contract: a running package is bound rather than evaluated again, concurrent calls for one definition evaluate it once, and `startedHere` names the caller that did. `getClientCode` then hands that one page the browser-half source, refusing a definition that is gone, has no browser half, or is not running. Code never rides an announcement, so this is the only way it reaches a browser.
- `resolveRequestRun` closes the round trip with the answering page's verdict, and broadcasts `cordis/request-run-resolved` so every other page drops the pending affordance. The first answer wins; a later or unknown request id is accepted and ignored. A success naming a revision the registry has moved past is refused rather than applied (`accepted: false`, request still suspended), because the page that answered loaded a dispatch that is no longer live. A failing verdict unwinds the host half only when this same request evaluated it, so a page that cannot load its own half never stops a package the other pages are using.
- `stop` unwinds one live dispatch — handlers dropped, host-half fiber disposed to quiescence, `dynamicCordisRunner/retract` broadcast — and leaves the definition runnable.
- `inventory` answers the whole registry, unaddressed by session and with each row naming the session that owns it, because the run-control surface is global. Listing is not acting: every acting verb still checks that ownership. Each row also names whether the definition has a browser half, so a run-control surface offers loading it into the current page only when there is a half to load. `snapshot` is its session-scoped host-local counterpart, carrying each live host half's fiber so `cordis_inspect` can render provides/waiting/state itself (a fiber cannot cross the wire).
- `reportRenderFailure` records what a page saw a LOADED browser half do wrong at render time. Rendering happens strictly after a load succeeded, so a run has already answered `ok` by then: this report is fire-and-forget, carries no settle authority, and never touches `resolveRequestRun` or any part of the run outcome — **it is not the retired v2 `report`/ack**. The host keeps the last failure per definition across every page (a second page reporting overwrites), and a fresh run, a stop, or an undefine clears it, so the model is never shown a failure from a dispatch that no longer exists. The browser-half face keeps its own "what THIS page is showing now"; the two answer different questions rather than duplicating one. A report for a definition the reporting session does not own is dropped, because the reporting path must never fail a render.
- `invoke` routes one call from a package's browser half to a method its own host half registered with `harness.handle`. The infrastructure only routes — no host-to-browser direction exists.
A refusal from `run` or `stop` names one of `definition-missing`, `host-half-failed`, `client-half-failed`, `rejected`, `cancelled`, or `not-running`; the last three are answers rather than defects — a person declined, the asking turn ended, or there was nothing running to stop.
A definition another session defined reads as absent rather than forbidden, so nothing leaks across sessions. `invoke` and `resolveRequestRun` carry no session at all: a component's call and a page's answer are page-global facts, not one session's.
Four forwarded events belong to this feature, declared by this package on its client-safe [`./types`](src/types.ts) subpath and allowlisted for delivery by [`@deepseek-ai/dsh-api-remotes`](../../api/remotes/README.md), which is what lets a browser reach them through `ctx.remote.$on`: `cordis/request-run` (`{requestId, agentId, id, name, purpose}` — metadata, never code), `cordis/request-run-resolved` (`{requestId, outcome}`), `dynamicCordisRunner/package` (`{id, name, rev}`), and `dynamicCordisRunner/retract` (`{id, rev}`). The last two are a symmetric pair announcing run state — every fresh start and every stop, whether or not the package has a browser half.
## Storage stance
The registry is process memory and the only source of truth. The session log carries a define call's metadata — never its code — so a restarted process legitimately has no definitions, and a card whose id no longer resolves says exactly that rather than pretending it can run. Nothing here is written to disk, and no definition is restored automatically; a reloaded page holds nothing until someone runs a package again, which is what makes it bind the live host half and re-fetch the browser half.
## Trust stance
The vm sandbox isolates globals but is not a security boundary: Node globals are absent or redirect to Cordis services (`ctx.fs`, `ctx.web`, `ctx.bash`, the timer helpers), and a host half receives a façade without framework internals, yet the services it declares reach the live runtime. Treat a dynamic package like bash access — see the [self-referential toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## Config
| Field | Default | Meaning |
|---|---|---|
| `vmTimeoutMs` | `5000` | Milliseconds the synchronous portion of a host half may run in the vm before evaluation is aborted |
One field is all there is: a run request waits for a person, so the round trip has no deadline of its own.
## Export shape
Service package: default-exports `DynamicCordisRunnerService` (service key `dynamicCordisRunner`), with `./types` carrying the payload shapes the `dynamicCordisRunner` remote namespace and its consumers share. The `define` / `undefine` shapes stay inside the package, because they never cross the wire.
## Model Experience
### Refusals and teaching errors relayed by the cordis tools
#### What the model sees
Nothing directly: this package registers no tool and injects no prompt. Its refusals reach the model through the `cordis_*` tool results that call it — an unparseable half names the offending line, a missing definition explains that definitions live in memory only, a `rejected` or `cancelled` run reports that a person declined or the turn ended rather than that anything failed, and a failed browser-half load carries the answering page's own error text.
#### Token effect
None of its own: every message above is carried by the calling tool's result.
#### KV Cache effect
A host half that registers tools changes the next request's tool view, which invalidates prefix reuse from the first changed schema token; running or stopping a package with no tool registrations is prefix-neutral.
## Known Limitations and Deferred Work
- **A successful run does not mean the UI rendered.** `run` returns once the answering page has LOADED the browser half; React renders afterwards, so a component that throws cannot possibly appear in the run receipt. The failure surfaces through `reportRenderFailure` and is read back with `cordis_inspect what:"temporary"`; the run result says so rather than implying success.
- A package with a browser half **suspends where no page is connected** — headless and ACP deployments hold the run until the asking turn is cancelled, because a forwarded event reports nothing about who received it. Host-only packages are unaffected.
- A suspended run request has **no timeout**: it waits for a person until the asking turn is cancelled, so unattended automation cannot use packages with a browser half.
- `vmTimeoutMs` bounds only synchronous evaluation; an async host-half body escapes it, matching the toolset's cooperative trust stance.
- `runHostHalf` carries no request id, so "which request evaluated this host half" is attributed host-side to the most recently armed request for that definition; several concurrent run requests for one definition would need that rule revisited.
- A success answer naming a superseded revision is refused (`accepted: false`) and leaves the request suspended, so the model's call ends only through a valid answer or its own cancellation. Settling it would take a fresh orchestration against the live revision, and no page does that today — the [browser half](../cordis-client-runner/README.md) does not read the ack — so in practice such a request is closed by another page's answer or by the caller's cancellation.
- A browser half's declared `inject` is read from the plugin it returns in the page, so the announcement carries no service-declaration field at all.
- **`zod` is a runtime dependency of the generated TypeRT faces, not of `src`.** `./typert` and `./remote` resolve to `lib/typert.*.js`, which `tsc` emits unbundled with a bare `import { z } from 'zod'`, so the package must declare it (the `@deepseek-ai/dsh-goal` precedent) and `knip.json` must ignore it for this workspace — knip reads source, and these faces are build products. Nothing in `src` imports zod.

View File

@@ -0,0 +1,72 @@
# @deepseek-ai/dsh-cordis-host-runner
[English](README.md) | 中文
由模型挂载的动态包在 host 侧的那一半定义注册表、host 半所用的 `node:vm` 沙箱与 fiber 生命周期、invoke handler 表,以及由某个浏览器页面执行的 run 往返。以 `ctx.dynamicCordisRunner` 提供。面向模型的工具在 [`@deepseek-ai/dsh-tool-cordis`](../tool-cordis/README.md) 中;浏览器半由 [`@deepseek-ai/dsh-cordis-client-runner`](../cordis-client-runner/README.md) 装载。
## 功能
分两个阶段:`define` 只做登记,一切带副作用的动作都挂在一次 run 上。
- `define``undefine` 掌管一个定义的生命周期。`define` 对元数据做首尾去空白与必填校验,通过编译预检每一半的语法(不执行任何代码),铸出 `dyn-<n>`,并把该定义登记在发起调用的会话名下——它没有任何可回滚的副作用,所以无法解析的代码在拿到 id 之前就被拒绝。`undefine` 先停掉正在运行的定义,再把它忘掉。两者都不上 wire只有模型自己的工具调用才会 define。
- `run` 回答模型「运行某个定义」的请求,它的两种形态取决于这个包是谁的事。只有 host 半的包是本进程自己的事host 半在 `cordis-dynamic` group fiber 之下于 vm 中求值,调用随即返回。带浏览器半的包必须由一个页面来执行,于是 `run` 变成一次可作答的往返——它 emit `cordis/request-run`、挂起,并由某个人允许或拒绝来结束。这里没有定时器;调用方的 `AbortSignal`(提问的那一轮次被取消)是唯一的另一条出路,而且它会把这次取消播报出去,让其他页面不再提供作答入口。请求发出时**并不知道**会不会有人作答——收到它的页面也可能永远不答,所以没有页面连接的部署与其他未作答请求一样挂起,最终以 `cancelled` 收场。`run` 没有 wire 面——`cordis_run` 在进程内调用它。
- `runHostHalf``getClientCode` 是获得允许的页面依次走的步骤host 半在先,因此 host 半失败会在浏览器还没动作之前短路。`runHostHalf` 在约定上是幂等的:已在运行的包只做绑定,不再求值;针对同一个定义的并发调用只求值一次,`startedHere` 指出求值的是哪一个调用方。随后 `getClientCode` 把浏览器半的源码交给这一个页面;定义已消失、没有浏览器半、或未在运行时,它会拒绝。代码从不搭乘任何播报,所以这是它到达浏览器的唯一途径。
- `resolveRequestRun` 用作答页面的结论结束这次往返,并 emit `cordis/request-run-resolved`,让其他每个页面撤下待作答的入口。首答即成;更晚的或未知的 request id 会被接受并忽略。命名了注册表已越过的版本的成功结论会被拒绝而非应用(`accepted: false`,请求仍处于挂起),因为作答的那个页面装载的是一个已不再存活的下发。失败的结论只会在 host 半正是由这次请求求值时才将它回退,因此某个页面装不上自己那一半,绝不会把其他页面正在使用的包停掉。
- `stop` 回退一次存活的下发——丢弃 handler、把 host 半 fiber dispose资源释放到完全停稳、emit `dynamicCordisRunner/retract`——并让该定义仍然可运行。
- `inventory` 回答整个注册表,不按会话寻址,且每一行都指明拥有该定义的会话,因为运行控制面是全局的。能列出不等于能操作:每个有实际动作的动词仍会检查这份归属。每一行还会指明该定义有没有浏览器半,因此运行控制面只在确有可装载的半时,才提供「装入当前页面」。`snapshot` 是它按会话限定的 host 本地对侧,携带每个存活 host 半的 fiber`cordis_inspect` 自行渲染 provideswaitingstatefiber 无法跨 wire
- `reportRenderFailure` 记录某个页面看到一个**已装载**的浏览器半在渲染时做错了什么。渲染严格发生在装载成功之后,因此到那时 run 早已回答了 `ok`:这份上报是 fire-and-forget 的,不带任何结算权威,也绝不触碰 `resolveRequestRun` 或 run 结论的任何部分——**它不是那个已退役的 v2 `report`ack**。host 按定义保留跨所有页面的最后一次失败(第二个页面上报即覆盖),而一次全新的 run、一次 stop 或一次 undefine 都会清掉它,因此模型绝不会看到一次已不存在的下发留下的失败。浏览器半的契约面自己保留一份「**这个页面**当前正在显示什么」;两者回答的是不同的问题,不是同一个问题的两份答案。上报的会话若并不拥有该定义,这次上报会被丢弃,因为上报路径绝不能让一次渲染失败。
- `invoke` 把一个包的浏览器半发起的一次调用,路由到它自己的 host 半用 `harness.handle` 注册的方法。这套基础设施只做路由:不存在 host 到浏览器的方向。
`run``stop` 的拒绝会给出 `definition-missing``host-half-failed``client-half-failed``rejected``cancelled``not-running` 之一;后三者是答复而非缺陷——有人拒绝了、提问的那一轮次已结束,或本来就没有在运行的东西可停。
别的会话登记的定义读起来是不存在,而不是被禁止,因此不会跨会话泄漏任何东西。`invoke``resolveRequestRun` 完全不携带会话:组件的一次调用和页面的一次作答都是页面全局的事实,不属于某一个会话。
本功能拥有四条转发事件,由本包在其 client-safe 的 [`./types`](src/types.ts) 子路径上声明,并由 [`@deepseek-ai/dsh-api-remotes`](../../api/remotes/README.md) 的白名单准许投递——正是这一点让浏览器能经 `ctx.remote.$on` 收到它们:`cordis/request-run``{requestId, agentId, id, name, purpose}`——只有元数据,绝无代码)、`cordis/request-run-resolved``{requestId, outcome}`)、`dynamicCordisRunner/package``{id, name, rev}`),以及 `dynamicCordisRunner/retract``{id, rev}`)。后两者是对称的一对运行状态播报:每次全新启动与每次停止都播,与该包有没有浏览器半无关。
## 存储立场
注册表就是进程内存,也是唯一真源。会话日志只承载一次 define 调用的元数据,绝不承载它的代码:因此进程重启后确实没有任何定义,这是合理的;而 id 已无法解析的卡片会如实说明这一点,不会假装自己还能运行。本包不向磁盘写任何东西,也不会自动恢复任何定义;刷新过的页面手上什么都没有,直到有人再次运行某个包——正是这一步让它绑定存活的 host 半并重新取回浏览器半。
## 信任立场
vm 沙箱隔离全局变量但不是安全边界Node 全局变量不存在,或重定向到 Cordis 服务(`ctx.fs``ctx.web``ctx.bash` 以及定时器 helperhost 半收到的是不含框架内部机制的 façade但它声明的服务仍会触达存活运行时。应当像对待 bash 访问一样对待动态包,参见[自引用工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
## 配置
| 字段 | 默认值 | 含义 |
|---|---|---|
| `vmTimeoutMs` | `5000` | host 半在 vm 中同步执行的那部分被中止求值前可运行的毫秒数 |
就这一个字段:一次 run 请求等的是人,所以这趟往返本身没有任何截止期限。
## 导出形状
服务包:默认导出 `DynamicCordisRunnerService`(服务键 `dynamicCordisRunner``./types` 则承载 `dynamicCordisRunner` remote namespace 与其消费方共享的载荷形状。`define``undefine` 的形状留在包内部,因为它们从不跨 wire。
## 模型体验
### 经 cordis 工具转达的拒绝与教学式错误
#### 模型看到的内容
没有直接可见的内容:本包不注册任何工具,也不注入提示词。它的拒绝经调用它的 `cordis_*` 工具结果到达模型——无法解析的半会指出出错的那一行,缺失的定义会解释定义只活在内存里,`rejected``cancelled` 的 run 报告的是有人拒绝或该轮次已结束而非出了故障,浏览器半装载失败则带上作答页面自己的错误文本。
#### Token 影响
本包自身没有:上述每条消息都由调用它的那个工具的结果承载。
#### KV Cache 影响
注册工具的 host 半会改变下一次请求的工具视图,从第一个变化的 schema token 起使前缀复用失效;运行或停止一个不注册任何工具的包对前缀不产生影响。
## 已知限制与暂缓事项
- **run 成功不等于 UI 渲染成功。** 只要作答页面**已装载**浏览器半,`run` 就会返回React 是随后才渲染的,因此一个抛异常的组件根本不可能出现在 run 的回执里。该失败经 `reportRenderFailure` 浮现,并通过 `cordis_inspect what:"temporary"` 读回run 的结果会把这一点说出来,而不是暗示成功。
- 带浏览器半的包在**没有页面连接的地方会挂起**——headless 与 ACPAgent Client Protocol部署会把这次 run 一直挂到提问的轮次被取消,因为转发事件不回报谁收到了它。只有 host 半的包不受影响。
- 挂起的 run 请求**没有超时**:它一直等人,直到提问的那一轮次被取消,因此无人值守的自动化用不了带浏览器半的包。
- `vmTimeoutMs` 只约束同步求值async 的 host 半函数体会逃出该上限,这与该工具集基于协作的信任立场一致。
- `runHostHalf` 不携带 request id因此「这个 host 半是哪次请求求值的」由 host 侧归因到该定义最近一次挂起的请求;若同一个定义出现多个并发 run 请求,这条规则需要重新审议。
- 命名了已被取代版本的成功结论会被拒绝(`accepted: false`)并让该请求继续挂起,因此模型这次调用只能靠一次有效作答或自身被取消才结束。要把它结算掉,需要对着存活版本重新走一遍编排,而当前没有任何页面会这么做——[浏览器半](../cordis-client-runner/README.md)不读这个 ack——所以这类请求实际上由别的页面作答、或由调用方取消来收尾。
- 浏览器半声明的 `inject` 是从它在页面里返回的插件上读出的,因此播报完全不携带服务声明字段。
- **`zod` 是生成的 TypeRT 契约面的运行时依赖,不是 `src` 的依赖。** `./typert``./remote` 解析到 `lib/typert.*.js``tsc` 以不打包的形式产出它们,其中带有裸的 `import { z } from 'zod'`,所以本包必须声明它(沿用 `@deepseek-ai/dsh-goal` 的先例),而 `knip.json` 必须在这个 workspace 里忽略它knip 读的是源码,而这些契约面是构建产物。`src` 里没有任何代码 import zod。

View File

@@ -0,0 +1,80 @@
{
"name": "@deepseek-ai/dsh-cordis-host-runner",
"description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/extensions/cordis-host-runner"
},
"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"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./remote": {
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/typert.host.js",
"lib/typert.host.d.ts",
"lib/typert.remote-client.js",
"lib/typert.remote-client.d.ts",
"lib/typert.remote-client.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^"
}
}

View File

@@ -1,15 +1,16 @@
/** /**
* The registration boundary between sandboxed mount code and the real runtime: ParameterSchemaSpec * The registration boundary between a sandboxed host half and the real runtime: ParameterSchemaSpec
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` / * normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
* `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives * `harness.registerTool` pair, the `harness.handle` invoke-handler normalizer, the SANDBOX CONTEXT
* in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox * FAÇADE a running plugin's `apply` receives in place of the real `ctx`, and the plugin-shape
* return values with. The façade is a whitelist of lifecycle-safe verbs and declared services; * helpers the run lifecycle narrows sandbox return values with. The façade is a whitelist of
* framework internals and context-valued service returns are denied. * 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 * 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 * 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. * have one meaning; invalid vocabulary fails during registration with a teaching error.
* @module @deepseek-ai/dsh-tool-cordis/guard * @module @deepseek-ai/dsh-cordis-host-runner/guard
*/ */
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
@@ -20,7 +21,7 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-session'
const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool') const DYNAMIC_TOOL = Symbol('cordis-host-runner.dynamic-tool')
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json']) 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 VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\''
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
@@ -94,7 +95,7 @@ type CloneTask =
| { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] } | { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] }
| { kind: 'leave'; source: object } | { kind: 'leave'; source: object }
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */ /** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions; `path` carries the caller's own error prefix. */
function cloneJson(value: unknown, path: string): unknown { function cloneJson(value: unknown, path: string): unknown {
const ancestors = new Set<object>() const ancestors = new Set<object>()
let root: unknown let root: unknown
@@ -115,7 +116,12 @@ function cloneJson(value: unknown, path: string): unknown {
}) })
} }
const reject = (at: string): never => { const reject = (at: string): never => {
throw new Error(`harness.defineTool ${at} must be lossless JSON data`) // Naming the executable next step matters more than naming the rule: the
// usual cause is a handler that returns whatever its last call produced,
// and the fix is one keyword.
throw new Error(`${at} must be lossless JSON data (objects, arrays, strings, numbers, booleans, null) — `
+ 'not a class instance, function, Map/Set, Date, or undefined. Return a plain object built from the '
+ 'values you need, or `return null` when the caller needs no value back.')
} }
const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }] const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }]
@@ -187,8 +193,8 @@ function cloneJson(value: unknown, path: string): unknown {
function copyAnnotations(value: Record<string, unknown>, output: Record<string, unknown>, path: string): void { 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, 'description')) output.description = value.description
if (Object.hasOwn(value, 'title')) output.title = value.title 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, 'default')) output.default = cloneJson(value.default, `harness.defineTool ${path}.default`)
if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `${path}.examples`) if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `harness.defineTool ${path}.examples`)
} }
/** Reject sandbox schema keys that the unified DSL would otherwise ignore. */ /** Reject sandbox schema keys that the unified DSL would otherwise ignore. */
@@ -465,9 +471,9 @@ function normalizePropertyMap(
if (!isDensePlainArray(value.enum) || value.enum.length === 0) { if (!isDensePlainArray(value.enum) || value.enum.length === 0) {
throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`) throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`)
} }
prop.enum = cloneJson(value.enum, `${path}.enum`) prop.enum = cloneJson(value.enum, `harness.defineTool ${path}.enum`)
} }
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`) if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `harness.defineTool ${path}.const`)
break break
case 'json': case 'json':
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS]) assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
@@ -554,7 +560,7 @@ export function sandboxDefineTool(options: unknown): ToolDefinition {
throw new Error('harness.defineTool output.presentationMeta must be a function when present') 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') if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function')
const schema = cloneJson(output.schema, 'output.schema') const schema = cloneJson(output.schema, 'harness.defineTool output.schema')
const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown> const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown>
const rawRender = output.render as (args: unknown, value: unknown) => unknown const rawRender = output.render as (args: unknown, value: unknown) => unknown
const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined
@@ -565,16 +571,16 @@ export function sandboxDefineTool(options: unknown): ToolDefinition {
output: { output: {
schema, schema,
render(args: unknown, value: unknown): ContentBlock[] { render(args: unknown, value: unknown): ContentBlock[] {
return assertRenderedContent(cloneJson(rawRender(args, value), 'output.render result') as JsonValue) return assertRenderedContent(cloneJson(rawRender(args, value), 'harness.defineTool output.render result') as JsonValue)
}, },
...rawPresentationMeta !== undefined ? { ...rawPresentationMeta !== undefined ? {
presentationMeta(args: unknown, value: unknown): JsonValue { presentationMeta(args: unknown, value: unknown): JsonValue {
return cloneJson(rawPresentationMeta(args, value), 'output.presentationMeta result') as JsonValue return cloneJson(rawPresentationMeta(args, value), 'harness.defineTool output.presentationMeta result') as JsonValue
}, },
} : {}, } : {},
}, },
async execute(args: unknown, exec: unknown): Promise<JsonValue> { async execute(args: unknown, exec: unknown): Promise<JsonValue> {
return cloneJson(await rawExecute(args, exec), 'execute result') as JsonValue return cloneJson(await rawExecute(args, exec), 'harness.defineTool execute result') as JsonValue
}, },
}) })
const parameters = { ...tool.parameters, ...normalized.rootAnnotations } const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
@@ -585,6 +591,31 @@ export function sandboxDefineTool(options: unknown): ToolDefinition {
}) })
} }
/**
* Normalize one `harness.handle` registration at the sandbox boundary: the
* method name must be a non-empty string and the handler a function whose
* result is host-materialized through the same cross-realm JSON clone as tool
* `execute` returns (a VM-realm object would otherwise escape the wire's
* plain-object contract).
* @param method - handler name the package's browser half calls through `host.call`.
* @param fn - sandbox handler receiving the wire-decoded JSON arguments.
* @returns the validated name and the clone-wrapped handler.
*/
export function normalizeHandler(method: unknown, fn: unknown): { method: string; handler: (args: unknown) => Promise<unknown> } {
if (typeof method !== 'string' || method.length === 0) {
throw new Error('harness.handle(method, fn) needs a non-empty string method name')
}
if (typeof fn !== 'function') {
throw new Error(`harness.handle("${method}") needs a handler function as its second argument`)
}
const rawHandler = fn as (args: unknown) => unknown
return {
method,
handler: async (args: unknown): Promise<unknown> =>
cloneJson(await rawHandler(args), `harness.handle("${method}") result`),
}
}
/** /**
* The `harness.registerTool` handed into the sandbox: registers a * The `harness.registerTool` handed into the sandbox: registers a
* marker-verified dynamic tool on the given context's registry. * marker-verified dynamic tool on the given context's registry.
@@ -598,23 +629,24 @@ export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
} }
/** /**
* The verbs a mounted plugin may reach through the sandbox `ctx` façade, beyond its injected * The verbs a running host half may reach through the sandbox `ctx` façade, beyond its injected
* services. `on`/`once` observe events, `provide` exposes a service to other mounts, and the * services. `on`/`once` observe events, `provide` exposes a service to other packages, and the
* timer helpers schedule work each a fiber effect that unwinds on unmount. * timer helpers schedule work each a fiber effect that unwinds when the package stops.
*/ */
const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce']) const CTX_VERBS = new Set(['effect', 'on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
const TIMER_VERBS = new Set(['timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
/** /**
* The tool-registry façade: `register` (marker-guarded) plus READ-ONLY * The tool-registry façade: `register` (marker-guarded) plus READ-ONLY
* metadata (`schemas`, and `get` returning a schema view, never the live * metadata (`schemas`, and `get` returning a schema view, never the live
* `ToolDefinition`). Exposing the raw definition would hand mount code the * `ToolDefinition`). Exposing the raw definition would hand package code the
* tool's `execute` function, letting it call another tool directly and bypass * tool's `execute` function, letting it call another tool directly and bypass
* `ToolRuntime.execute` identity protection, pre-policy, monotonic guards, * `ToolRuntime.execute` identity protection, pre-policy, monotonic guards,
* around dispatch, post-policy, final observation, and result normalization. So `get` returns the same * around dispatch, post-policy, final observation, and result normalization. So `get` returns the same
* name/description/parameters view as `schemas()`, and nothing invocable. * name/description/parameters view as `schemas()`, and nothing invocable.
*/ */
function sandboxTools(ctx: Context): Record<string, unknown> { function sandboxTools(ctx: Context): Record<string, unknown> {
// Resolve reads and writes through the mount's own scope. // Resolve reads and writes through the package's own scope.
return { return {
register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool), register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
schemas: () => ctx.tools.schemas(scopeOf(ctx)), schemas: () => ctx.tools.schemas(scopeOf(ctx)),
@@ -628,9 +660,15 @@ function sandboxTools(ctx: Context): Record<string, unknown> {
* fresh, unguarded handle back into the runtime the exact escape the façade * 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. * exists to close so it fails loud instead of reaching sandbox code.
*/ */
function denyContext(value: unknown, service: string): unknown { // Twinned with the browser half's guard for the same reason as the ctx façade
// below: this is the rule "a service must never hand sandboxed code a Context",
// and each half must test against the Context class of ITS OWN face. Moving the
// rule into a shared package would move a security invariant out of the halves
// that enforce it, which is a design decision rather than a duplication fix.
/* jscpd:ignore-start */
function denyContext(value: unknown, service: string, reportFailure: (error: Error) => void): unknown {
if (value instanceof Context) { if (value instanceof Context) {
throw new Error( return rejectGuard(reportFailure,
`service "${service}" returned a cordis Context, which the sandbox does not expose. ` `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) ' + 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
+ 'and the services you inject — never another context.', + 'and the services you inject — never another context.',
@@ -644,99 +682,110 @@ function denyContext(value: unknown, service: string): unknown {
* their return values pass through {@link denyContext}. Non-function members * their return values pass through {@link denyContext}. Non-function members
* (plain data) pass through as-is; a returned Promise is guarded on resolve. * (plain data) pass through as-is; a returned Promise is guarded on resolve.
*/ */
function guardedService(service: object, name: string): unknown { function guardedService(service: object, name: string, reportFailure: (error: Error) => void): unknown {
return new Proxy(service, { return new Proxy(service, {
get(target, prop) { get(target, prop) {
const value = Reflect.get(target, prop, target) as unknown const value = Reflect.get(target, prop, target) as unknown
if (typeof value !== 'function') return denyContext(value, name) if (typeof value !== 'function') return denyContext(value, name, reportFailure)
return (...args: unknown[]): unknown => { return (...args: unknown[]): unknown => {
const result = Reflect.apply(value, target, args) as unknown const result = Reflect.apply(value, target, args) as unknown
if (result instanceof Promise) return result.then(v => denyContext(v, name)) if (result instanceof Promise) return result.then(v => denyContext(v, name, reportFailure))
return denyContext(result, name) return denyContext(result, name, reportFailure)
} }
}, },
}) })
} }
/* jscpd:ignore-end */
/** /**
* The service names a plugin declared in `inject`, as a lookup set. Whatever * The service names a plugin declared in `inject`, as a lookup set. Whatever
* declaration style the plugin used an `inject: ['bash', 'tools']` array or * declaration style the plugin used an `inject: ['bash', 'tools']` array or
* the `{ required, optional }` object form cordis resolves it into a single * the `{ required, optional }` object form cordis resolves it into a single
* name-keyed map on the fiber before `apply` runs (`{ bash: null, tools: null }`), * 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 * so the gate just reads that map's keys. A host half may reach only the services
* it declared that is what lets cordis park the mount when a declared * it declared that is what lets cordis park it when a declared provider
* provider unmounts. * goes away.
*/ */
function declaredInjects(ctx: Context): Set<string> { function declaredInjects(ctx: Context): Set<string> {
return new Set(Object.keys(ctx.fiber.inject)) return new Set(Object.keys(ctx.fiber.inject))
} }
/** /**
* Whitelist context for mounted plugins: lifecycle-safe verbs, guarded tools, and only declared * Whitelist context for running host halves: lifecycle-safe verbs, guarded
* injected services. Framework plumbing is denied, and service methods cannot return a Context. * tools, optional `ctx.get()` lookup, and declared-service property access.
* Framework plumbing is denied, and service methods cannot return a Context.
*/ */
function sandboxContext(ctx: Context): Context { function sandboxContext(ctx: Context, reportFailure: (error: Error) => void): Context {
const tools = sandboxTools(ctx) const tools = sandboxTools(ctx)
const declared = declaredInjects(ctx) const declared = declaredInjects(ctx)
// A framework member or an undeclared service — distinguish the two so the // 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). // error teaches the right fix (declare it in inject vs it is withheld).
const denyRead = (prop: string): never => { const denyRead = (prop: string): never => {
if (ctx.get(prop) !== undefined) { if (ctx.get(prop) !== undefined) {
throw new Error( return rejectGuard(reportFailure,
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, ` `service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
+ 'so cordis parks this temporary Plugin if the provider is later unmounted.', + 'so cordis parks this dynamic package if the provider later goes away.',
) )
} }
throw new Error( return rejectGuard(reportFailure,
`sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / ` `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. ' + 'the timer helpers after injecting timer, and any service you declared in inject. '
+ 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.', + '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 // `get` is optional lookup; property access requires a declaration. `tools`
// API. // is the façade's own API on either path.
const readService = (name: string): unknown => { const readService = (name: string, requireDeclaration: boolean): unknown => {
if (name === 'tools') return tools if (name === 'tools') return tools
if (!declared.has(name)) return denyRead(name) if (requireDeclaration && !declared.has(name)) return denyRead(name)
const service = denyContext(ctx.get(name), name) const service = denyContext(ctx.get(name), name, reportFailure)
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
return guardedService(service, name) return guardedService(service, name, reportFailure)
} }
const get = (name: string): unknown => readService(name) const get = (name: string): unknown => readService(name, false)
// The browser half builds the same façade over its own Context
// (`@deepseek-ai/dsh-cordis-client-runner`, whose CTX_VERBS names this one its
// twin), and the sameness is the point: a package author meets ONE contract on
// both halves. Folding them together is not available — the two halves compile
// in separate programs where `Context` merges different service keys — so the
// duplication is declared here instead of hidden behind a config exception.
/* jscpd:ignore-start */
return new Proxy({}, { return new Proxy({}, {
get(_target, prop) { get(_target, prop) {
if (prop === 'tools') return tools if (prop === 'tools') return tools
if (prop === 'get') return get if (prop === 'get') return get
if (typeof prop !== 'string') return undefined if (typeof prop !== 'string') return undefined
// Lazy verb forwarder — reads `ctx[verb]` only when called, so a plugin // Lazy verb forwarder — reads `ctx[verb]` only when called. Timer mixins
// that never uses a timer never triggers the timer mixin's inject check // additionally require the Service declaration before Cordis resolves them.
// (cordis raises its own "without inject" error there for undeclared timer use).
if (CTX_VERBS.has(prop)) { if (CTX_VERBS.has(prop)) {
return (...args: unknown[]): unknown => { return (...args: unknown[]): unknown => {
if (TIMER_VERBS.has(prop) && !declared.has('timer')) return denyRead('timer')
const method = ctx[prop as keyof Context] const method = ctx[prop as keyof Context]
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args) return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
} }
} }
return readService(prop) return readService(prop, true)
}, },
// A façade is not the real ctx; block writes rather than let mount code // A façade is not the real ctx; block writes rather than let package code
// stash state on a throwaway object and think it persisted. // stash state on a throwaway object and think it persisted.
set(_target, prop) { set(_target, prop) {
throw new Error(`sandbox ctx is read-only; cannot assign "${String(prop)}"`) return rejectGuard(reportFailure, `sandbox ctx is read-only; cannot assign "${String(prop)}"`)
}, },
// `in` reflects reachability: the façade API plus DECLARED services // `in` reflects reachability: the façade API plus DECLARED services
// (whether or not currently live). Does not resolve/wrap — no throw. // (whether or not currently live). Does not resolve/wrap — no throw.
has: (_target, prop) => prop === 'tools' || prop === 'get' has: (_target, prop) => prop === 'tools' || prop === 'get'
|| (typeof prop === 'string' && (CTX_VERBS.has(prop) || declared.has(prop))), || (typeof prop === 'string'
&& ((CTX_VERBS.has(prop) && (!TIMER_VERBS.has(prop) || declared.has('timer'))) || declared.has(prop))),
}) as unknown as Context }) as unknown as Context
/* jscpd:ignore-end */
} }
/** /**
* Narrow an arbitrary sandbox return value to a mountable cordis plugin: a * Narrow an arbitrary sandbox return value to a runnable cordis plugin: a
* function, or an object with an `apply` function. (A bare function passes the * function, or an object with an `apply` function. (A bare function passes the
* first arm, so the object arm never sees `Function.prototype.apply`.) * first arm, so the object arm never sees `Function.prototype.apply`.)
* @param value - whatever the mount code returned. * @param value - whatever the host half returned.
* @returns whether the value is mountable via `ctx.plugin`. * @returns whether the value can be started via `ctx.plugin`.
*/ */
export function isPlugin(value: unknown): value is Plugin { export function isPlugin(value: unknown): value is Plugin {
if (typeof value === 'function') return true if (typeof value === 'function') return true
@@ -746,16 +795,17 @@ export function isPlugin(value: unknown): value is Plugin {
/** /**
* Wrap a plugin so `apply` receives the sandbox context while preserving injection metadata. * Wrap a plugin so `apply` receives the sandbox context while preserving injection metadata.
* @param plugin - the plugin the mount code returned. * @param plugin - the plugin the host half returned.
* @param reportFailure - reports a guard rejection to the owning Agent.
* @returns an equivalent plugin whose `apply` sees the sandbox context façade. * @returns an equivalent plugin whose `apply` sees the sandbox context façade.
*/ */
export function guardedPlugin(plugin: Plugin): Plugin { export function guardedPlugin(plugin: Plugin, reportFailure: (error: Error) => void): Plugin {
if (typeof plugin === 'function') { if (typeof plugin === 'function') {
const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
return { return {
name: pluginName(plugin), name: pluginName(plugin),
apply(ctx: Context, config?: unknown) { apply(ctx: Context, config?: unknown) {
return functionPlugin(sandboxContext(ctx), config) return functionPlugin(sandboxContext(ctx, reportFailure), config)
}, },
} }
} }
@@ -763,15 +813,21 @@ export function guardedPlugin(plugin: Plugin): Plugin {
return { return {
...plugin, ...plugin,
apply(ctx: Context, config?: unknown) { apply(ctx: Context, config?: unknown) {
return objectPlugin.apply(sandboxContext(ctx), config) return objectPlugin.apply(sandboxContext(ctx, reportFailure), config)
}, },
} }
} }
function rejectGuard(reportFailure: (error: Error) => void, message: string): never {
const error = new Error(message)
reportFailure(error)
throw error
}
/** /**
* Display name for a mounted plugin: its `name` property, else anonymous. * Display name for a running plugin: its `name` property, else anonymous.
* @param plugin - the plugin the mount code returned. * @param plugin - the plugin the host half returned.
* @returns the human-readable name used in mount results and inspect output. * @returns the human-readable name used in run results and inspect output.
*/ */
export function pluginName(plugin: Plugin): string { export function pluginName(plugin: Plugin): string {
const named = (plugin as { name?: unknown }).name const named = (plugin as { name?: unknown }).name

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,248 @@
/** Host registry for model-visible, read-only Cordis capability queries. */
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session/types'
import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools'
import type {
CordisInspectMethodManifest, CordisInspectPlatform, CordisInspectProviderManifest,
CordisInspectProviderView, CordisInspectQueryRequest, CordisInspectQueryResolution,
CordisInspectRequestId, CordisInspectResolveAck,
} from './types.ts'
/** Context supplied to a Host inspect query. */
export interface HostCordisInspectQueryContext {
/** Tool-call cancellation. */
signal: AbortSignal
/** Agent whose scoped runtime is being inspected. */
agent: Agent
}
/** Local registration paired with its serializable manifest. */
export interface HostCordisInspectProviderRegistration {
/** Provider and explicit method directory. */
manifest: CordisInspectProviderManifest
/** Execute one declared method. */
query(method: string, input: JsonValue | undefined, context: HostCordisInspectQueryContext): Promise<JsonValue>
}
interface PendingClientQuery {
request: CordisInspectQueryRequest
method: CordisInspectMethodManifest
settle(resolution: CordisInspectQueryResolution): void
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host registry for Cordis inspect providers and Client manifest/query routing. */
cordisInspect: CordisInspectRegistryService
}
}
/** Registry and cross-page router behind the two model-facing inspect tools. */
export class CordisInspectRegistryService extends Service {
private readonly providers = new Map<string, HostCordisInspectProviderRegistration>()
private readonly pending = new Map<CordisInspectRequestId, PendingClientQuery>()
private clientManifest: readonly CordisInspectProviderManifest[] | undefined
private nextRequest = 1
/** Register the process-global Host registry. */
constructor(ctx: Context) {
super(ctx, 'cordisInspect')
}
/**
* Register one Host provider.
* @param registration - manifest and local query handler.
* @returns idempotent disposer.
*/
register(registration: HostCordisInspectProviderRegistration): () => void {
const manifest = validateManifest(registration.manifest)
if (this.providers.has(manifest.id)) throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
const stored = { ...registration, manifest }
this.providers.set(manifest.id, stored)
return () => {
if (this.providers.get(manifest.id) === stored) this.providers.delete(manifest.id)
}
}
/**
* Replace the mirrored Client provider directory.
* @param providers - complete Client manifest snapshot.
*/
syncClientManifest(providers: readonly CordisInspectProviderManifest[]): void {
const ids = new Set<string>()
const validated = providers.map((provider) => {
const manifest = validateManifest(provider)
if (ids.has(manifest.id)) throw new Error(`Client Cordis inspect manifest repeats provider "${manifest.id}"`)
ids.add(manifest.id)
return manifest
})
this.clientManifest = Object.freeze(validated)
}
/**
* Return the complete known Host and Client provider directory.
* @returns Host providers followed by the Client providers.
*/
list(): CordisInspectProviderView[] {
return [
...[...this.providers.values()].map(provider => view('host', provider.manifest)),
...(this.clientManifest ?? []).map(provider => view('client', provider)),
]
}
/**
* Execute one provider query on its owning platform.
* @param platform - Host or Client runtime.
* @param providerId - provider selected from {@link list}.
* @param methodName - declared method name.
* @param input - optional lossless JSON input.
* @param agent - requesting Agent and scope.
* @param signal - tool-call cancellation.
* @returns provider JSON data.
*/
async query(
platform: CordisInspectPlatform,
providerId: string,
methodName: string,
input: JsonValue | undefined,
agent: Agent,
signal: AbortSignal,
): Promise<JsonValue> {
if (platform === 'host') {
const registration = this.providers.get(providerId)
if (registration === undefined) throw new Error(`Host Cordis inspect provider "${providerId}" is not registered`)
const method = findMethod(registration.manifest, methodName)
validateInput('Host', providerId, method, input)
signal.throwIfAborted()
const data = await registration.query(methodName, input, { agent, signal })
signal.throwIfAborted()
return validateOutput('Host', providerId, method, data)
}
return await this.queryClient(providerId, methodName, input, agent, signal)
}
/**
* Accept the first valid Client response for a pending query.
* @param agent - Agent whose Session owns the query.
* @param requestId - Pending Client query identity.
* @param resolution - Client provider result or failure.
* @returns whether this response settled the still-pending query.
*/
resolveClientQuery(
agent: Agent,
requestId: CordisInspectRequestId,
resolution: CordisInspectQueryResolution,
): CordisInspectResolveAck {
const pending = this.pending.get(requestId)
if (pending === undefined || pending.request.agentId !== agent.id) return { accepted: false }
if (!resolution.ok) return { accepted: false }
try {
resolution = {
ok: true,
data: validateOutput('Client', pending.request.provider, pending.method, resolution.data),
}
} catch {
return { accepted: false }
}
this.pending.delete(requestId)
pending.settle(resolution)
this.ctx.emit('cordis/inspect-query-resolved', { requestId })
return { accepted: true }
}
private async queryClient(
providerId: string,
methodName: string,
input: JsonValue | undefined,
agent: Agent,
signal: AbortSignal,
): Promise<JsonValue> {
const provider = this.clientManifest?.find(candidate => candidate.id === providerId)
if (provider === undefined) throw new Error(`Client Cordis inspect provider "${providerId}" is not registered`)
const method = findMethod(provider, methodName)
validateInput('Client', providerId, method, input)
signal.throwIfAborted()
const requestId = `inspect-${this.nextRequest++}` as CordisInspectRequestId
const request: CordisInspectQueryRequest = {
requestId,
agentId: agent.id,
provider: providerId,
method: methodName,
...input === undefined ? {} : { input },
}
const result = new Promise<CordisInspectQueryResolution>((resolve) => {
this.pending.set(requestId, { request, method, settle: resolve })
})
const onAbort = (): void => {
const pending = this.pending.get(requestId)
if (pending === undefined) return
this.pending.delete(requestId)
pending.settle({ ok: false, reason: 'cancelled', message: `Client inspect query ${providerId}.${methodName} was cancelled` })
this.ctx.emit('cordis/inspect-query-resolved', { requestId })
}
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
else this.ctx.emit('cordis/inspect-query', request)
try {
const resolution = await result
if (!resolution.ok) throw new Error(`${providerId}.${methodName}: ${resolution.message}`)
return resolution.data
} finally {
signal.removeEventListener('abort', onAbort)
}
}
}
function view(platform: CordisInspectPlatform, manifest: CordisInspectProviderManifest): CordisInspectProviderView {
return { platform, ...manifest, methods: [...manifest.methods] }
}
function validateManifest(manifest: CordisInspectProviderManifest): CordisInspectProviderManifest {
if (manifest.id.trim() === '') throw new Error('Cordis inspect provider id must not be empty')
if (manifest.description.trim() === '') throw new Error(`Cordis inspect provider "${manifest.id}" needs a description`)
const names = new Set<string>()
const methods = manifest.methods.map((method) => {
if (method.name.trim() === '') throw new Error(`Cordis inspect provider "${manifest.id}" has an empty method name`)
if (names.has(method.name)) throw new Error(`Cordis inspect provider "${manifest.id}" repeats method "${method.name}"`)
if (method.description.trim() === '') throw new Error(`Cordis inspect method ${manifest.id}.${method.name} needs a description`)
assertSupportedJsonSchema(method.inputSchema)
assertSupportedJsonSchema(method.outputSchema)
names.add(method.name)
return Object.freeze({ ...method })
})
return Object.freeze({ ...manifest, methods: Object.freeze(methods) })
}
function findMethod(manifest: CordisInspectProviderManifest, name: string): CordisInspectMethodManifest {
const method = manifest.methods.find(candidate => candidate.name === name)
if (method === undefined) throw new Error(`Cordis inspect provider "${manifest.id}" has no method "${name}"`)
return method
}
function validateInput(
platform: 'Host' | 'Client',
provider: string,
method: CordisInspectMethodManifest,
input: JsonValue | undefined,
): void {
const violations = validateJsonSchemaValue(method.inputSchema as JsonSchemaNode, input ?? {}, 'input')
if (violations.length > 0) throw new Error(`${platform} Cordis inspect ${provider}.${method.name} rejected input: ${violations.join('; ')}`)
}
function validateOutput(
platform: 'Host' | 'Client',
provider: string,
method: CordisInspectMethodManifest,
data: JsonValue,
): JsonValue {
const snapshot = snapshotJsonValue(data)
if (snapshot === undefined) throw new Error(`${platform} Cordis inspect ${provider}.${method.name} returned a non-JSON value`)
const violations = validateJsonSchemaValue(method.outputSchema as JsonSchemaNode, snapshot, 'output')
if (violations.length > 0) throw new Error(`${platform} Cordis inspect ${provider}.${method.name} returned invalid output: ${violations.join('; ')}`)
return snapshot
}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-cordis-host-runner`.
* @module @deepseek-ai/dsh-cordis-host-runner/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-cordis-host-runner'
/** Cordis companion plugin name. */
export const name = 'cordis-host-runner-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the definition registry is process memory with no event
* stream to observe, and its one owned relation (a running definition owns a
* settled host-half fiber and its handler table) is established and unwound
* inside single awaited verbs, so package tests assert it directly.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,57 @@
/**
* Host-half fiber lifecycle over the `cordis-dynamic` group: settle a
* sandbox-produced plugin as a child fiber (never leaving a failed fiber
* mounted), and report the services a settled-but-pending fiber still waits
* for. Stopping needs no helper — a host half unwinds through an ordinary
* awaited `fiber.dispose()`, because everything the plugin registered is an
* effect on its fiber.
* @module @deepseek-ai/dsh-cordis-host-runner/lifecycle
*/
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis'
import { guardedPlugin } from './guard.ts'
/**
* Await the group, start and settle one guarded child, and dispose it before rethrowing any
* startup failure so a failed run never lingers. A valid unresolved inject may remain pending.
* @param group - the `cordis-dynamic` group fiber every host half hangs under.
* @param plugin - the plugin the sandbox returned; wrapped with the registration guard before starting.
* @param reportGuardFailure - reports post-activation Host guard rejections to the owning Agent.
* @returns the settled child fiber (possibly pending on unsatisfied `inject`).
*/
export async function startHostHalf(
group: Fiber,
plugin: Plugin,
reportGuardFailure: (error: Error) => void,
): Promise<Fiber> {
await group.await()
const fiber = group.ctx.plugin(guardedPlugin(plugin, reportGuardFailure))
try {
await fiber.await()
} catch (error) {
await fiber.dispose()
const message = error instanceof Error ? error.message : String(error)
// The commonest startup collision is running a NEW version of a package
// while the old run still holds the name — teach the replace recipe.
if (message.includes('already registered')) {
throw new Error(
`${message} — to REPLACE something an earlier dynamic package registered, first cordis_stop that package's id `
+ '(find it with cordis_runtime_inspect what:"temporary"), then run the new version.',
)
}
throw error instanceof Error ? error : new Error(message)
}
return fiber
}
/**
* The services a fiber declared in `inject` that do not exist yet — a settled
* fiber that is not active is waiting on exactly these (legal cordis
* semantics: it activates when the service appears).
* @param ctx - the context to resolve service existence against.
* @param fiber - the host-half fiber whose `inject` declarations are checked.
* @returns the missing service names, in declaration order.
*/
export function missingServices(ctx: Context, fiber: Fiber): string[] {
return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
}

View File

@@ -0,0 +1,226 @@
/**
* Process-local dynamic Plugin registry and its opaque identity mints.
* @module @deepseek-ai/dsh-cordis-host-runner/registry
*/
import type { Fiber } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
CordisDynamicRunMode, DynamicCordisRenderFailure, DynamicCordisRunAttempt,
} from './types.ts'
/** One Host method exposed to this package's Client half. */
export type DynamicCordisHandler = (args: unknown) => Promise<unknown>
/** One live activation and everything its teardown owns. */
export interface DynamicCordisRun {
/** Exact activation identity. */
pluginRunId: CordisDynamicPluginRunId
/** Immutable package version being run. */
packageId: CordisDynamicPackageId
/** Host-half Fiber, absent for Client-only packages. */
fiber?: Fiber
/** Active Host methods. */
handlers: Map<string, DynamicCordisHandler>
/** Method registration cleanup. */
handlerDisposers: (() => void)[]
/** Runtime failures already sent to the owning Agent during this activation. */
reportedRuntimeErrors: Set<string>
/** Last render failure observed for this version's current run. */
renderFailure?: DynamicCordisRenderFailure
/** Approval whose transition started this run, when model-driven. */
startedForRequest?: ApprovalRequestId
}
/** One immutable package version. */
export interface DynamicCordisDefinition {
/** Package identity. */
packageId: CordisDynamicPackageId
/** Package label. */
name: string
/** User-facing purpose. */
purpose: string
/** Host source. */
hostCode?: string
/** Client source. */
clientCode?: string
}
/** Stable plugin instance containing immutable package versions. */
export interface DynamicCordisPlugin {
/** Stable identity. */
pluginId: CordisDynamicPluginId
/** Owning session. */
sessionId: SessionId
/** Versions in define order. */
packages: Map<CordisDynamicPackageId, DynamicCordisDefinition>
/** Client-bearing Packages individually authorized by the user. */
approvedClientPackages: Set<CordisDynamicPackageId>
/** Whether one user decision authorized future Package versions of this Plugin. */
clientVersionUpdatesApproved: boolean
/** Last successfully activated version. */
currentPackageId?: CordisDynamicPackageId
/** Failed or in-progress target version. */
nextPackageId?: CordisDynamicPackageId
/** Current activation. */
run?: DynamicCordisRun
/** Latest activation attempt, including approval and asynchronous failure state. */
latestRun?: DynamicCordisRunAttempt
}
/** One suspended model-driven activation. */
export interface DynamicCordisPendingRequest {
/** Session whose model requested this activation. */
agentId: SessionId
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
pluginRunId: CordisDynamicPluginRunId
mode: CordisDynamicRunMode
/** Whether this request must wait for an explicit user decision. */
requiresApproval: boolean
}
/** Request accepted by `define`; it never crosses the Remote transport. */
export interface DynamicCordisDefineRequest {
/** Session that owns the plugin. */
sessionId: SessionId
/** Create a plugin or append to an existing one. */
plugin:
| { kind: 'new'; idPrefix: string }
| { kind: 'existing'; pluginId: CordisDynamicPluginId }
/** Package label. */
name: string
/** User-facing purpose. */
purpose: string
/** At least one source half. */
code: { host?: string; client?: string }
}
/** Successful `define` result. */
export interface DynamicCordisDefineReceipt {
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
name: string
purpose: string
hasHostHalf: boolean
hasClientHalf: boolean
}
/** Source-free modification context for an explicit `@pluginId` reference. */
export interface DynamicCordisReference {
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
name: string
purpose: string
currentPackageId?: CordisDynamicPackageId
nextPackageId?: CordisDynamicPackageId
activeRun?: { pluginRunId: CordisDynamicPluginRunId; packageId: CordisDynamicPackageId }
latestRun?: DynamicCordisRunAttempt
}
/** Source-free Plugin summary returned by layered self inspection. */
export interface DynamicCordisPluginInspection extends DynamicCordisReference {
/** Immutable Package summaries in define order. */
packages: Array<{
packageId: CordisDynamicPackageId
name: string
purpose: string
hasHostHalf: boolean
hasClientHalf: boolean
}>
}
/** Exact immutable Package metadata and source returned by explicit inspection. */
export interface DynamicCordisPackageInspection extends DynamicCordisReference {
/** Host and Client function bodies stored for this Package. */
code: { host?: string; client?: string }
}
/** Registry, identity mints, and pending approval index. */
export class DynamicCordisRegistry {
private readonly plugins = new Map<CordisDynamicPluginId, DynamicCordisPlugin>()
private readonly pendingRequests = new Map<ApprovalRequestId, DynamicCordisPendingRequest>()
private nextPlugin = 1
private nextPackage = 1
private nextRun = 1
private nextApproval = 1
/** Mint a semantic plugin ID without reusing a prior suffix. */
mintPluginId(prefix: string): string {
let id: CordisDynamicPluginId
do id = `${prefix}-${this.nextPlugin++}` as CordisDynamicPluginId
while (this.plugins.has(id))
return id
}
/** Mint an immutable package ID. */
mintPackageId(): string {
return `pkg-${this.nextPackage++}`
}
/** Mint an activation ID. */
mintPluginRunId(): string {
return `run-${this.nextRun++}`
}
/** Mint an approval ID. */
mintApprovalRequestId(): string {
return `approval-${this.nextApproval++}`
}
/** Add one stable plugin. */
add(plugin: DynamicCordisPlugin): void {
this.plugins.set(plugin.pluginId, plugin)
}
/** Read one plugin. */
get(id: CordisDynamicPluginId): DynamicCordisPlugin | undefined {
return this.plugins.get(id)
}
/** Delete one plugin and all package versions. */
delete(id: CordisDynamicPluginId): boolean {
return this.plugins.delete(id)
}
/** All plugins in creation order. */
all(): DynamicCordisPlugin[] {
return [...this.plugins.values()]
}
/** One session's plugins in creation order. */
ofSession(sessionId: SessionId): DynamicCordisPlugin[] {
return this.all().filter(plugin => plugin.sessionId === sessionId)
}
/** Publish one pending approval. */
armRequest(id: ApprovalRequestId, pending: DynamicCordisPendingRequest): void {
this.pendingRequests.set(id, pending)
}
/** Read one pending approval without claiming it. */
peekRequest(id: ApprovalRequestId): DynamicCordisPendingRequest | undefined {
return this.pendingRequests.get(id)
}
/** Claim one pending approval; first answer wins. */
claimRequest(id: ApprovalRequestId): DynamicCordisPendingRequest | undefined {
const pending = this.pendingRequests.get(id)
if (pending !== undefined) this.pendingRequests.delete(id)
return pending
}
/** Cancel one pending approval. */
disarmRequest(id: ApprovalRequestId): void {
this.pendingRequests.delete(id)
}
/** Pending approval for one plugin, if any. */
pendingRequestFor(pluginId: CordisDynamicPluginId): ApprovalRequestId | undefined {
for (const [requestId, request] of this.pendingRequests) {
if (request.pluginId === pluginId) return requestId
}
return undefined
}
}

View File

@@ -0,0 +1,238 @@
/**
* The `node:vm` sandbox a dynamic package's HOST half evaluates in: a fresh realm whose globals
* are a tagged write-through console, the `harness` registration helpers, the encoding primitives
* a bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
* withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
* `ctx.bash`, and Cordis timers. This keeps cooperative packages inspectable and disposable but
* is not containment: host-realm helper functions remain an escape route.
*
* The browser half never reaches this module — it is evaluated by the client-side runner in a
* closure, with its own facade.
* @module @deepseek-ai/dsh-cordis-host-runner/sandbox
*/
import { createContext, runInContext, Script } from 'node:vm'
import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
/** Exact Host closure symbols exposed by the sandbox and guarded Context. */
export const HOST_BUILTIN_INSPECTION = [
{
name: 'ctx',
description: 'Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject for hard dependencies.',
signatures: [
'ctx.get(name: string): unknown | undefined',
'ctx.on(name: string, listener: Function): () => void',
'ctx.provide(name: string, value: unknown): () => void',
'ctx.effect(callback: Function, label?: string): () => void',
],
},
{
name: 'harness',
description: 'Host helpers for Package-private Client RPC and model-visible dynamic Tools.',
signatures: [
'harness.handle(method: string, handler: (args: JsonValue) => JsonValue | Promise<JsonValue>): () => void',
'harness.defineTool(definition: ToolDefinition): ToolDefinition',
'harness.registerTool(ctx: Context, tool: ToolDefinition): () => void',
],
},
{ name: 'console', description: 'Package-tagged Host logging.', signatures: ['console.log(...values): void', 'console.error(...values): void'] },
{ name: 'btoa', description: 'Encode UTF-8 text as base64.', signatures: ['btoa(value: string): string'] },
{ name: 'atob', description: 'Decode base64 as UTF-8 text.', signatures: ['atob(value: string): string'] },
{ name: 'TextEncoder', description: 'Standard UTF-8 encoder constructor.', signatures: ['new TextEncoder()'] },
{ name: 'TextDecoder', description: 'Standard text decoder constructor.', signatures: ['new TextDecoder(label?: string)'] },
] as const
/**
* A write-through console for one package, tagging every line with the package
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
* a registered listener fires long after the run call returned, and its output
* must land somewhere the user can see — for a terminal entry point, the host terminal.
*/
function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> {
const tag = `[cordis:${id}]`
const log = (...args: unknown[]): void => { console.log(tag, ...args) }
const error = (...args: unknown[]): void => { console.error(tag, ...args) }
return { log, info: log, warn: log, debug: log, error }
}
/**
* Patch only VM constructors so `instanceof` accepts both VM values and host values passed as
* arguments, events, or service results; host intrinsics remain untouched.
*/
const DUAL_REALM_INSTANCEOF_PRELUDE = `
(hostIntrinsics) => {
'use strict'
const ordinary = Function.prototype[Symbol.hasInstance]
for (const name of Object.keys(hostIntrinsics)) {
const VmCtor = globalThis[name]
const HostCtor = hostIntrinsics[name]
if (typeof VmCtor !== 'function' || typeof HostCtor !== 'function') continue
Object.defineProperty(VmCtor, Symbol.hasInstance, {
value: (instance) => ordinary.call(VmCtor, instance) || ordinary.call(HostCtor, instance),
configurable: true,
})
}
}
`
/** Run {@link DUAL_REALM_INSTANCEOF_PRELUDE} in a freshly created sandbox, handing it the host intrinsics to pair up. */
function patchDualRealmInstanceof(sandbox: object): void {
const patch = runInContext(DUAL_REALM_INSTANCEOF_PRELUDE, sandbox) as (intrinsics: Record<string, unknown>) => void
patch({ Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set })
}
const TIMER_REDIRECT
= 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
+ 'and call ctx.timeout / ctx.interval after querying Host Service.listService for the exact overloads. '
+ 'Those calls are fiber effects, cleaned up automatically when stopped.'
/**
* The callable Node APIs the sandbox deliberately disables, each mapped to the
* cordis alternative its trap error names. Only function-valued globals are
* trapped; a data-valued global such as `process` stays `undefined`, because a
* throwing accessor would detonate the common `typeof process` feature probe
* at resolution time.
*/
const NODE_API_REDIRECTS: Record<string, string> = {
require:
'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, '
+ '[\'web\'] for HTTP, [\'bash\'] for processes; query Service.listService with cordis_inspect_query first.',
setTimeout: TIMER_REDIRECT,
setInterval: TIMER_REDIRECT,
setImmediate: TIMER_REDIRECT,
clearTimeout: TIMER_REDIRECT,
clearInterval: TIMER_REDIRECT,
fetch:
'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
+ '(query Host Service.listService with cordis_inspect_query for its methods).',
}
/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */
function nodeApiTraps(): Record<string, () => never> {
const traps: Record<string, () => never> = {}
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
traps[name] = () => {
throw new Error(`${name} is not available in the dynamic package sandbox — ${redirect}`)
}
}
return traps
}
/**
* Build the vm context one host half evaluates in: the tagged console, the
* `harness` registration helpers, the encoding primitives, the Node-API traps,
* and the dual-realm `instanceof` patch, already `createContext`-ed.
* @param id - the package id (`dyn-<n>`), used as the console tag and filename stem.
* @param harnessExtras - per-package `harness` verbs beyond the registration pair (`handle`).
* @returns the contextified sandbox object to pass to {@link evaluateHostCode}.
*/
export function createSandbox(id: string, harnessExtras: Record<string, unknown> = {}): object {
const sandbox = {
...nodeApiTraps(),
console: taggedConsole(id),
harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool, ...harnessExtras },
// Web APIs absent from fresh vm contexts — made available so the model
// can encode/decode base64 without Buffer (which is also absent). Host
// closures over Buffer, never Buffer itself.
btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'),
atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'),
TextEncoder,
TextDecoder,
}
createContext(sandbox)
patchDualRealmInstanceof(sandbox)
return sandbox
}
/**
* Cross-realm SyntaxError detection: a compile failure inside `runInContext`
* constructs its error in the SANDBOX realm, so a host `instanceof
* SyntaxError` is silently false — the `name` property is the realm-safe tag.
*/
function isSyntaxError(error: unknown): error is Error {
return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'SyntaxError'
}
/**
* The parse-failure context a vm `SyntaxError` carries: the vm prints the
* offending source line and a caret before the message, which is exactly what
* a model needs to self-correct — surface it instead of the bare message.
* Falls back to `String(error)` when the stack carries no such prelude.
* @param error - the `SyntaxError` (host- or sandbox-realm) thrown while compiling package code.
* @returns the stack prefix up to and including the `SyntaxError: …` line.
*/
export function syntaxErrorContext(error: Error): string {
const lines = (error.stack ?? '').split('\n')
const messageIndex = lines.findIndex(line => line.startsWith('SyntaxError'))
if (messageIndex === -1) return String(error)
return lines.slice(0, messageIndex + 1).join('\n')
}
/**
* The teaching text one parse failure produces, shared by the define-time
* precheck and the run-time evaluation so a model reads the same diagnosis
* whichever verb caught it.
* @param half - which half failed to parse, named as the define argument that carried it.
* @param context - the {@link syntaxErrorContext} of the failure.
* @returns the model-facing error message.
*/
export function parseErrorMessage(half: 'code.host' | 'code.client', context: string): string {
// Scope the TypeScript heuristic to the OFFENDING line, not the whole code:
// an ` as ` inside an ordinary description string must not turn a plain
// syntax error into a misleading remove-annotations message.
const offendingLine = context.split('\n')[1] ?? ''
if (/\bas\b/.test(offendingLine)) {
return `dynamic package \`${half}\` failed to parse:\n${context}\n`
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
+ ' ✗ { type: \'text\' as const, text: x }\n'
+ ' ✓ { type: \'text\', text: x }'
}
return `dynamic package \`${half}\` failed to parse:\n${context}\n`
+ 'Note: it runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.'
}
/**
* Parse one half's source without running it: the define-time precheck that
* keeps unparseable code out of the registry, so a model fixes it and defines
* again instead of discovering the failure at run time. Compiling through `vm`
* rather than `new Function` is what makes the two agree — same wrapper, same
* compiler, and the same source-line-and-caret prelude in the failure.
* @param code - the model-written function body.
* @param half - which define argument carried it, for the error text.
* @throws when the body does not parse, with the offending line and a teaching hint.
*/
export function precheckCode(code: string, half: 'code.host' | 'code.client'): void {
try {
// Compile-only: constructing the Script parses the source and runs nothing.
new Script(`(async () => {\n${code}\n})()`, { filename: `cordis-dyn-${half}.js` })
} catch (error) {
if (!isSyntaxError(error)) throw error
throw new Error(parseErrorMessage(half, syntaxErrorContext(error)))
}
}
/**
* Evaluate a host half as the body of an async function inside the sandbox. `vmTimeoutMs` only
* bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's
* trust stance. Parse errors include the offending line and a TypeScript-removal or bracket-
* balance hint.
* @param sandbox - the contextified object from {@link createSandbox}.
* @param code - the model-written function body; must `return` a plugin.
* @param id - the package id, used as the vm filename (`cordis-dyn-<id>.js`).
* @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
* @returns whatever the code returned, still un-narrowed (the run lifecycle checks plugin shape).
*/
export async function evaluateHostCode(sandbox: object, code: string, id: string, vmTimeoutMs: number): Promise<unknown> {
try {
return await runInContext(
`(async () => {\n${code}\n})()`,
sandbox,
{ filename: `cordis-dyn-${id}.js`, timeout: vmTimeoutMs },
)
} catch (error) {
if (!isSyntaxError(error)) throw error
throw new Error(parseErrorMessage('code.host', syntaxErrorContext(error)))
}
}

View File

@@ -0,0 +1,399 @@
/**
* Client-safe wire vocabulary of the dynamic Cordis plugin runner.
* @module @deepseek-ai/dsh-cordis-host-runner/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session/types'
/** Stable identity of one dynamic plugin instance. */
export type CordisDynamicPluginId = Branded<'CordisDynamicPluginId'>
/** Identity of one immutable package version belonging to a dynamic plugin. */
export type CordisDynamicPackageId = Branded<'CordisDynamicPackageId'>
/** Identity of one successful activation attempt. */
export type CordisDynamicPluginRunId = Branded<'CordisDynamicPluginRunId'>
/** Identity of one human approval request. */
export type ApprovalRequestId = Branded<'ApprovalRequestId'>
/** Identity of one cross-page inspect query. */
export type CordisInspectRequestId = Branded<'CordisInspectRequestId'>
/** Runtime plane that owns an inspect provider. */
export type CordisInspectPlatform = 'host' | 'client'
/** One model-callable read-only query exposed by an inspect provider. */
export interface CordisInspectMethodManifest {
/** Method name, unique within its provider. */
name: string
/** What the query returns and when to use it. */
description: string
/** JSON Schema accepted by the query. */
inputSchema: JsonValue
/** JSON Schema produced by the query. */
outputSchema: JsonValue
}
/** Serializable directory entry for one inspect provider. */
export interface CordisInspectProviderManifest {
/** Provider identity, unique within one platform. */
id: string
/** Capability described by this provider. */
description: string
/** Explicit read-only queries. */
methods: readonly CordisInspectMethodManifest[]
}
/** Provider directory row returned by `cordis_inspect_list`. */
export interface CordisInspectProviderView extends CordisInspectProviderManifest {
/** Runtime plane that executes these methods. */
platform: CordisInspectPlatform
}
/** Host broadcast requesting one live Client inspect result. */
export interface CordisInspectQueryRequest {
/** Correlation identity. */
requestId: CordisInspectRequestId
/** Session whose model requested the query. */
agentId: SessionId
/** Provider selected from the Client manifest. */
provider: string
/** Method selected from the provider manifest. */
method: string
/** JSON query input, omitted when the method has no fields. */
input?: JsonValue
}
/** Result sent from a Client provider to the waiting Host query. */
export type CordisInspectQueryResolution =
| { ok: true; data: JsonValue }
| {
ok: false
reason: 'provider-missing' | 'method-missing' | 'invalid-input' | 'provider-error' | 'cancelled'
message: string
}
/** Notification that a Client inspect request can no longer be answered. */
export interface CordisInspectQueryResolved {
/** Query that left the pending state. */
requestId: CordisInspectRequestId
}
/** Whether a Client answer claimed the still-pending query. */
export interface CordisInspectResolveAck {
/** False for unknown, cancelled, stale, or late answers. */
accepted: boolean
}
/** Whether a package starts the current version or replaces it. */
export type CordisDynamicRunMode = 'run' | 'update'
/** How a model-driven Client activation request left the pending state. */
export type RequestRunOutcome = 'approved' | 'completed' | 'rejected' | 'cancelled' | 'failed'
/** Error fields preserved across the Host/Client transport. */
export interface CordisErrorDetails {
/** Original error message. */
message: string
/** Original stack when the thrown value supplied one. */
stack?: string
}
/** Persisted state of the latest activation attempt. */
export type CordisRunStatus =
| 'awaiting-approval'
| 'starting-host'
| 'client-pending'
| 'running'
| 'waiting'
| 'rejected'
| 'failed'
| 'cancelled'
| 'stopped'
/** One platform half within an activation attempt. */
export interface CordisHalfState {
/** Lifecycle state of this half. */
status: 'absent' | 'pending' | 'stopped' | 'running' | 'waiting' | 'failed'
/** Services still needed by a successfully created Fiber. */
waitingFor: readonly string[]
/** Failure text for this half. */
error?: string
}
/** Structured failure associated with an exact activation attempt. */
export interface CordisRunDiagnostic {
/** Stage that failed. */
phase: 'approval' | 'host-load' | 'host-apply' | 'client-load' | 'client-apply' | 'client-render'
/** Original failure text. */
message: string
/** Original failure stack when available. */
stack?: string
/** Stable Plugin identity. */
pluginId: CordisDynamicPluginId
/** Immutable Package identity. */
packageId: CordisDynamicPackageId
/** Exact attempt identity. */
pluginRunId: CordisDynamicPluginRunId
}
/** Latest activation attempt retained independently from the physical run. */
export interface DynamicCordisRunAttempt {
/** Exact attempt identity. */
pluginRunId: CordisDynamicPluginRunId
/** Target Package. */
packageId: CordisDynamicPackageId
/** Explicit run/update intent. */
mode: CordisDynamicRunMode
/** Current attempt state. */
status: CordisRunStatus
/** Pending Client activation request; it represents approval only when `requiresApproval` is true. */
approvalRequestId?: ApprovalRequestId
/** Whether the pending Client activation requires a user decision. */
requiresApproval?: boolean
/** Host-half state. */
host: CordisHalfState
/** Client-half state. */
client: CordisHalfState
/** Most recent failure. */
error?: CordisRunDiagnostic
}
/** One running package announced to browser pages. */
export interface DynamicCordisPackage {
/** Stable plugin instance. */
pluginId: CordisDynamicPluginId
/** Immutable package version currently active. */
packageId: CordisDynamicPackageId
/** This activation's identity. */
pluginRunId: CordisDynamicPluginRunId
/** Package label. */
name: string
}
/** One pending model-driven Client activation forwarded to browser pages. */
export interface DynamicCordisRunRequest {
/** Correlation identity of the activation request. */
requestId: ApprovalRequestId
/** Session whose plugin and tool call own the request. */
agentId: SessionId
/** Stable plugin instance being acted on. */
pluginId: CordisDynamicPluginId
/** Package version the request will activate. */
packageId: CordisDynamicPackageId
/** Explicit lifecycle intent. */
mode: CordisDynamicRunMode
/** Package label. */
name: string
/** User-facing reason supplied at define time. */
purpose: string
/** Whether a page must wait for an explicit user decision before activation. */
requiresApproval: boolean
}
/** One settled model-driven Client activation request broadcast to all pages. */
export interface DynamicCordisRequestResolved {
/** Request that left the pending state. */
requestId: ApprovalRequestId
/** How the request settled. */
outcome: RequestRunOutcome
}
/** One activation withdrawn from every page. */
export interface DynamicCordisRetracted {
/** Stable plugin instance. */
pluginId: CordisDynamicPluginId
/** Package version that was active. */
packageId: CordisDynamicPackageId
/** Exact activation being withdrawn. */
pluginRunId: CordisDynamicPluginRunId
}
/** Package metadata exposed by the inventory without source code. */
export interface DynamicCordisInventoryPackage {
/** Immutable package version. */
packageId: CordisDynamicPackageId
/** Package label. */
name: string
/** User-facing purpose. */
purpose: string
/** Whether this version contains Host code. */
hasHostHalf: boolean
/** Whether this version contains Client code. */
hasClientHalf: boolean
}
/** One stable plugin row in the frame-wide inventory. */
export interface DynamicCordisInventoryRow {
/** Stable plugin instance. */
pluginId: CordisDynamicPluginId
/** Session that owns this plugin. */
agentId: SessionId
/** Immutable versions in define order. */
packages: readonly DynamicCordisInventoryPackage[]
/** Last package that completed activation successfully. */
currentPackageId?: CordisDynamicPackageId
/** Package selected for a failed or in-progress transition. */
nextPackageId?: CordisDynamicPackageId
/** Current activation, absent while stopped. */
activeRun?: {
pluginRunId: CordisDynamicPluginRunId
packageId: CordisDynamicPackageId
}
/** Latest activation attempt, including pending approval and diagnostics. */
latestRun?: DynamicCordisRunAttempt
}
/** Answer to removing a plugin and all of its package versions. */
export type DynamicCordisUndefineReceipt =
| { ok: true; wasRunning: boolean }
| { ok: false; reason: 'plugin-missing'; message: string }
/** One render failure observed after a Client half loaded. */
export interface DynamicCordisRenderFailure {
/** Slot whose component failed. */
slot: string
/** Render failure text. */
message: string
/** Original render failure stack when available. */
stack?: string
/** Whether the failing contribution relinquished its slot. */
abdicated: boolean
}
/** Result shared by model-driven and panel-driven activation. */
export type DynamicCordisRunResponse =
| {
ok: true
/** Whether activation completed synchronously, is starting in a Client, or awaits user approval. */
status: 'awaiting-approval' | 'starting' | 'running'
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
pluginRunId: CordisDynamicPluginRunId
/** Missing Host services; a parked Fiber is a successful activation. */
waitingFor: readonly string[]
/** Missing Client services reported by the approving page. */
clientWaitingFor?: readonly string[]
/** Last fully successful Package. */
currentPackageId?: CordisDynamicPackageId
/** Selected transition target. */
nextPackageId?: CordisDynamicPackageId
/** Explicit lifecycle intent. */
mode: CordisDynamicRunMode
}
| {
ok: false
reason:
| 'plugin-missing'
| 'package-missing'
| 'invalid-mode'
| 'transition-in-flight'
| 'host-half-failed'
| 'client-half-failed'
| 'rejected'
| 'cancelled'
| 'not-running'
message: string
/** Original failure stack when available. */
stack?: string
}
/** Result of stopping a Plugin without deleting its Packages. */
export type DynamicCordisStopResponse =
| { ok: true }
| { ok: false; reason: 'plugin-missing' | 'not-running'; message: string }
/** Result of bringing up the Host half before loading the Client half. */
export type DynamicCordisHostHalfResult =
| {
ok: true
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
pluginRunId: CordisDynamicPluginRunId
waitingFor: readonly string[]
/** False when a panel merely attaches this page to an already active run. */
startedHere: boolean
}
| ({ ok: false } & CordisErrorDetails)
/** Client-half source for one exact activation. */
export interface DynamicCordisClientSource {
/** Browser JavaScript body. */
code: string
/** Package label. */
name: string
/** Stable plugin instance. */
pluginId: CordisDynamicPluginId
/** Immutable source version. */
packageId: CordisDynamicPackageId
/** Exact activation the source belongs to. */
pluginRunId: CordisDynamicPluginRunId
}
/** Browser verdict used for both approved tool runs and panel runs. */
export type DynamicCordisRunResolution =
| { ok: true; pluginRunId: CordisDynamicPluginRunId; waitingFor?: readonly string[] }
| {
ok: false
reason: 'rejected' | 'host-half-failed' | 'client-half-failed'
/** Activation that failed; absent for a refusal before activation. */
pluginRunId?: CordisDynamicPluginRunId
/** Whether this page created the failed activation instead of attaching to it. */
startedHere?: boolean
message?: string
stack?: string
}
/** Whether a Client activation resolution reached the still-pending request. */
export interface DynamicCordisResolveAck {
/** False for late, unknown, or stale answers. */
accepted: boolean
}
/** Result of routing one Client call to the active Host half. */
export type DynamicCordisInvokeResult =
| { ok: true; value: JsonValue }
| ({ ok: false; code: 'plugin-not-running' | 'stale-run' | 'method-not-found' | 'handler-error' } & CordisErrorDetails)
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A Client-bearing activation needs a browser page, and may require a user decision.
* @param request - correlation identity, owner, target version, mode, and approval requirement.
* @mode emit
*/
'cordis/request-run'(request: DynamicCordisRunRequest): void
/**
* A pending Client activation request left the answerable state.
* @param resolved - request identity and outcome.
* @mode emit
*/
'cordis/request-run-resolved'(resolved: DynamicCordisRequestResolved): void
/**
* One exact Plugin/Package activation is now live in the Host.
* @param pkg - stable plugin, immutable package, run identity, and label.
* @mode emit
*/
'cordis/dynamic-package'(pkg: DynamicCordisPackage): void
/**
* One exact activation was withdrawn.
* @param retracted - plugin, package, and run identity.
* @mode emit
*/
'cordis/dynamic-retract'(retracted: DynamicCordisRetracted): void
/**
* Request a live read-only query from the Client inspect registry.
* @param request - correlation, Session, provider, method, and JSON input.
* @mode emit
*/
'cordis/inspect-query'(request: CordisInspectQueryRequest): void
/**
* Notify every Client that an inspect query has settled or been cancelled.
* @param resolved - exact query identity that is no longer answerable.
* @mode emit
*/
'cordis/inspect-query-resolved'(resolved: CordisInspectQueryResolved): void
}
}

View File

@@ -0,0 +1,180 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CordisDynamicPackageId, CordisDynamicPluginId } from '../src/types.ts'
import { missingServices } from '../src/lifecycle.ts'
import {
AGENT_A, call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, mount,
PROVIDER_CODE, REVERSE_TOOL_CODE, setup, text,
running,
} from './helpers.ts'
/**
* Cross-package composition through ordinary cordis provide/inject semantics:
* one package's host half provides a service, another injects it, and definition
* ids stay the lifecycle handles across stop and run again. Every assertion is
* against the WORLD — the registry, the service store, real tool dispatch — not
* a rendered summary (that is the tool package's job).
*/
afterEach(() => {
vi.restoreAllMocks()
})
function latestPackage(harness: Awaited<ReturnType<typeof setup>>, pluginId: CordisDynamicPluginId): CordisDynamicPackageId {
const row = harness.runner.inventory().find(candidate => candidate.pluginId === pluginId)
const packageId = row?.packages.at(-1)?.packageId
if (packageId === undefined) throw new Error(`missing package for ${pluginId}`)
return packageId
}
describe('cross-package provide/inject', () => {
it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
const harness = await setup()
await mount(harness, PROVIDER_CODE)
await mount(harness, CONSUMER_CODE)
// The vm-realm service value is callable across packages, and the result
// normalizes into the host realm like any dynamic tool result.
const greeted = await call(harness.ctx, 'greet', { name: 'harness' })
expect(greeted.isError).toBe(false)
expect(text(greeted)).toBe('hi harness')
})
it('consumer first: runs but stays parked on the missing service, then activates when the provider runs', async () => {
const harness = await setup()
const consumer = await mount(harness, CONSUMER_CODE)
// A settled-but-pending host half is a successful run in legal cordis
// semantics; the fiber names what it waits for.
const [row] = harness.runner.snapshot(AGENT_A)
expect(row?.activeRun?.fiber).toBeDefined()
expect(missingServices(harness.ctx, row?.activeRun?.fiber as never)).toEqual(['greeter'])
expect(harness.ctx.tools.get('greet')).toBeUndefined()
expect(running(harness.runner, AGENT_A)).toEqual([{ id: consumer, running: true }])
await mount(harness, PROVIDER_CODE)
expect(harness.ctx.tools.get('greet')).toBeDefined()
expect(text(await call(harness.ctx, 'greet', { name: 'late' }))).toBe('hi late')
})
it('stopping the provider sends the consumer back to pending and unwinds its registrations', async () => {
const harness = await setup()
const provider = await mount(harness, PROVIDER_CODE)
await mount(harness, CONSUMER_CODE)
expect(harness.ctx.tools.get('greet')).toBeDefined()
await expect(harness.runner.stop(AGENT_A, provider)).resolves.toEqual({ ok: true })
expect(harness.ctx.tools.get('greet')).toBeUndefined()
expect(harness.ctx.get('greeter')).toBeUndefined()
})
it('running the provider again re-runs the consumer through a fresh guard (tool back)', async () => {
const harness = await setup()
const provider = await mount(harness, PROVIDER_CODE)
await mount(harness, CONSUMER_CODE)
await harness.runner.stop(AGENT_A, provider)
expect(harness.ctx.tools.get('greet')).toBeUndefined()
// The same definition, a new dispatch: the consumer's apply re-runs through
// a new façade rather than needing its own re-definition.
await expect(harness.runner.run(
AGENT_A, provider, latestPackage(harness, provider), 'run',
)).resolves.toMatchObject({ ok: true })
expect(harness.ctx.tools.get('greet')).toBeDefined()
expect(text(await call(harness.ctx, 'greet', { name: 'again' }))).toBe('hi again')
})
it('a duplicate provide fails loud and leaves the second package not running', async () => {
const harness = await setup()
await mount(harness, PROVIDER_CODE)
await expect(mount(harness, PROVIDER_CODE)).rejects.toThrow('has been registered')
const rows = harness.runner.snapshot(AGENT_A)
expect(rows.map(row => row.activeRun !== undefined)).toEqual([true, false])
// The service still belongs to the first package's fiber.
expect(harness.ctx.get('greeter')).toBeDefined()
})
it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
const harness = await setup()
await mount(harness, `
return {
name: 'answer-provider',
apply(ctx) {
ctx.provide('answer', 42)
ctx.provide('nothing', null)
},
}
`)
await mount(harness, `
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(text(await call(harness.ctx, 'answer', {}))).toBe('42/42/null')
})
it('stopping the consumer leaves the provider and its service intact', async () => {
const harness = await setup()
await mount(harness, PROVIDER_CODE)
const consumer = await mount(harness, CONSUMER_CODE)
await harness.runner.stop(AGENT_A, consumer)
expect(harness.ctx.tools.get('greet')).toBeUndefined()
expect(harness.ctx.get('greeter')).toBeDefined()
})
})
describe('stop reaches quiescence', () => {
it('the host half\'s listeners have stopped by the time stop returns', async () => {
const harness = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const id = await mount(harness, LISTENER_CODE)
harness.ctx.tools.register(dummyTool('trigger_before'))
expect(log).toHaveBeenCalledTimes(1)
await expect(harness.runner.stop(AGENT_A, id)).resolves.toEqual({ ok: true })
// Immediately after the awaited stop, the listener must be gone — no grace
// period, no eventual consistency.
harness.ctx.tools.register(dummyTool('trigger_after'))
expect(log).toHaveBeenCalledTimes(1)
})
it('unregisters a self-made tool on stop, and registers it again on the next run', async () => {
const harness = await setup()
const id = await mount(harness, REVERSE_TOOL_CODE)
expect(harness.ctx.tools.get('reverse_text')).toBeDefined()
await harness.runner.stop(AGENT_A, id)
expect(harness.ctx.tools.get('reverse_text')).toBeUndefined()
await harness.runner.run(AGENT_A, id, latestPackage(harness, id), 'run')
expect(harness.ctx.tools.get('reverse_text')).toBeDefined()
})
it('names the replace recipe when a run collides with a live registration', async () => {
const harness = await setup()
await mount(harness, REVERSE_TOOL_CODE)
// A second package registering the same tool name collides; the teaching
// error points at the stop-then-run recipe rather than a bare conflict.
await expect(mount(harness, REVERSE_TOOL_CODE)).rejects.toThrow('first cordis_stop that package\'s id')
})
})

View File

@@ -0,0 +1,250 @@
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 ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { CordisDynamicPluginId } from '../src/types.ts'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import DynamicCordisRunnerService from '../src/index.ts'
import type { Config } from '../src/index.ts'
/**
* Shared spec harness: a real `SystemPrompt` + `ToolRegistry` + timer tree with
* the runner mounted and a recording stand-in for the web gateway. Only the
* model and the browser are absent — the code strings below stand in for what
* the model would write, and the gateway records (and optionally answers) every
* dispatch.
*/
/** One recorded broadcast plus how the fake browser answers a run request. */
interface Gateway {
/** Every forwarded event the runner emitted, in order, as `[name, payload]`. */
events: [name: string, payload: unknown][]
/**
* How the fake browser answers the next run request, standing in for a person
* at the panel: it orchestrates exactly as the real client runner does (bring
* the host half up, fetch the source, answer), or declines.
*/
answer?: 'approve' | 'reject' | { clientFails: string }
/** Services the fake browser reports its half is parked on. */
clientWaitingFor?: string[]
/** Completion of the fake page's latest asynchronous answer. */
answering?: Promise<void>
}
/** The session that owns every definition these suites define. */
export const AGENT_A = { id: 'S-a' as SessionId, steer() {}, inject() {} } as unknown as Agent
/** A second session, for the authority-scoping cases. */
export const AGENT_B = { id: 'S-b' as SessionId, steer() {}, inject() {} } as unknown as Agent
/** One live tree: the context, the runner, and the recording gateway. */
interface Harness {
ctx: Context
runner: DynamicCordisRunnerService
gateway: Gateway
}
/**
* Build a real tree with the runner mounted and a recording gateway provided.
* @param config - runner config overrides (the vm bound).
* @returns the context, the runner service, and the gateway recorder.
*/
export async function setup(config?: Config): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(Timer)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const gateway: Gateway = { events: [] }
ctx.on('cordis/request-run', (request) => {
gateway.events.push(['cordis/request-run', request])
// The fake browser: a request reaches it, and it answers the way the real
// client runner does — nothing here is a shortcut through the host's own
// verbs, so the round trip under test is the real one.
if (gateway.answer === undefined) return
const answer = gateway.answer
const { requestId, pluginId, packageId, mode } = request
gateway.answering = Promise.resolve().then(async (): Promise<void> => {
if (answer === 'reject') {
await runner.resolveRequestRun(requestId, { ok: false, reason: 'rejected', message: 'not now' })
return
}
const half = await runner.runHostHalf(AGENT_A, pluginId, packageId, mode, requestId, false)
if (!half.ok) {
await runner.resolveRequestRun(requestId, {
ok: false, reason: 'host-half-failed', message: half.message,
})
return
}
if (typeof answer === 'object') {
await runner.resolveRequestRun(requestId, {
ok: false,
reason: 'client-half-failed',
pluginRunId: half.pluginRunId,
startedHere: half.startedHere,
message: answer.clientFails,
})
return
}
const source = runner.getClientCode(AGENT_A, pluginId, half.pluginRunId)
await runner.resolveRequestRun(requestId, {
ok: true,
pluginRunId: source.pluginRunId,
...gateway.clientWaitingFor === undefined ? {} : { waitingFor: gateway.clientWaitingFor },
})
})
})
for (const name of ['cordis/request-run-resolved', 'cordis/dynamic-package', 'cordis/dynamic-retract'] as const) {
ctx.on(name, (payload: unknown) => { gateway.events.push([name, payload]) })
}
await ctx.plugin(DynamicCordisRunnerService, config)
const runner = ctx.dynamicCordisRunner
return { ctx, runner, gateway }
}
/**
* One session's packages and whether each runs, projected from the global
* inventory — the reading a surface takes now that there is no session-scoped
* list verb.
* @param runner - the live runner service.
* @param agent - the session to project.
* @returns id/running pairs in define order.
*/
export function running(runner: DynamicCordisRunnerService, agent: Agent): { id: string; running: boolean }[] {
return runner.inventory()
.filter(row => row.agentId === agent.id)
.map(row => ({ id: String(row.pluginId), running: row.activeRun !== undefined }))
}
let definitionCounter = 0
/**
* Define and run one host half in one step, the way the ported suites exercise
* the sandbox: a failure in either verb rejects with the runner's own
* model-facing message, so a spec asserts teaching text through `rejects`.
* @param harness - the live tree.
* @param code - the host-half source.
* @returns the definition id of the running package.
* @throws the runner's refusal message when define prechecks or the run fails.
*/
export async function mount(harness: Harness, code: string): Promise<CordisDynamicPluginId> {
const { pluginId, packageId } = harness.runner.define({
sessionId: AGENT_A.id,
plugin: { kind: 'new', idPrefix: 'probe' },
name: `probe-${++definitionCounter}`,
purpose: 'spec fixture',
code: { host: code },
})
const receipt = await harness.runner.run(AGENT_A, pluginId, packageId, 'run')
if (!receipt.ok) throw new Error(receipt.message)
return pluginId
}
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: new AbortController().signal,
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('')
}
/** 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 },
},`
/** Browser-half source the fake browser "loads"; its content never runs in these suites. */
export const CLIENT_CODE = 'return () => {}'
/** Host-half source for a listener package: logs on every `tools/change`. */
export const LISTENER_CODE = `
return {
name: 'change-logger',
apply(ctx) {
ctx.on('tools/change', () => console.log('tools changed'))
},
}
`
/** Host-half source registering a self-made tool through the sandbox 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('')
},
}))
},
}
`
/** Host-half source providing a `greeter` service other packages can inject. */
export const PROVIDER_CODE = `
return {
name: 'greeter-provider',
apply(ctx) {
ctx.provide('greeter', { greet: (name) => 'hi ' + name })
},
}
`
/** Host-half source 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 as a schema-view target. */
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
},
}
}

View File

@@ -0,0 +1,578 @@
import { describe, expect, it } from 'vitest'
import { ApprovalRequestId } from '../src/index.ts'
import type {
ApprovalRequestId as ApprovalRequestIdType, CordisDynamicPluginId,
} from '../src/types.ts'
import { AGENT_A, AGENT_B, CLIENT_CODE, setup, running } from './helpers.ts'
/**
* The runner's own chain on a real cordis tree: define records without running,
* run starts a real host-half fiber and broadcasts one request, the first answer
* settles it, and stop/undefine unwind both halves. Only the model and the
* browser are stand-ins (code strings and a recording gateway).
*/
/** A host half that registers one invoke handler and provides a service. */
const HOST_CODE = `
harness.handle('double', async (args) => args.value * 2)
return {
name: 'doubler',
apply(ctx) {
ctx.provide('dynDoubler', { ok: true })
},
}
`
type Runner = Awaited<ReturnType<typeof setup>>['runner']
function define(
runner: Runner,
request: {
sessionId: typeof AGENT_A.id
name: string
purpose: string
host?: string
client?: string
},
) {
return runner.define({
sessionId: request.sessionId,
plugin: { kind: 'new', idPrefix: 'dyn' },
name: request.name,
purpose: request.purpose,
code: {
...request.host === undefined ? {} : { host: request.host },
...request.client === undefined ? {} : { client: request.client },
},
})
}
describe('dynamic runner definitions', () => {
it('lists the whole registry for a global surface, each row carrying its owning session', async () => {
const { runner } = await setup()
const mine = define(runner, { sessionId: AGENT_A.id, name: 'mine', purpose: 'ours', host: HOST_CODE })
const theirs = define(runner, { sessionId: AGENT_B.id, name: 'theirs', purpose: 'not ours', client: CLIENT_CODE })
// Global by design: a run-control surface that is not inside a session can
// still name every package, and each row carries the address later verbs need.
expect(runner.inventory()).toEqual([
{
pluginId: mine.pluginId,
agentId: AGENT_A.id,
packages: [{
packageId: mine.packageId, name: 'mine', purpose: 'ours', hasHostHalf: true, hasClientHalf: false,
}],
},
{
pluginId: theirs.pluginId,
agentId: AGENT_B.id,
packages: [{
packageId: theirs.packageId, name: 'theirs', purpose: 'not ours', hasHostHalf: false, hasClientHalf: true,
}],
},
])
// Authority did not move with the listing: acting still needs the owner.
await expect(runner.run(AGENT_A, theirs.pluginId, theirs.packageId, 'run'))
.resolves.toMatchObject({ ok: false, reason: 'plugin-missing' })
})
it('tells a global surface which definitions even have a browser half to load', async () => {
const { runner } = await setup()
define(runner, { sessionId: AGENT_A.id, name: 'host only', purpose: 'no UI', host: HOST_CODE })
define(runner, {
sessionId: AGENT_A.id,
name: 'both halves',
purpose: 'UI too',
host: HOST_CODE,
client: CLIENT_CODE,
})
// A host-only package cannot be loaded into a page, so the surface must be
// able to tell the two apart from the listing alone.
expect(runner.inventory().map(row => [String(row.pluginId), row.packages[0]?.hasClientHalf])).toEqual([
['dyn-1', false],
['dyn-2', true],
])
})
it('records a definition without running it, and mints ids that are never reused', async () => {
const { runner } = await setup()
const first = define(runner, { sessionId: AGENT_A.id, name: 'first', purpose: 'do a thing', host: HOST_CODE })
const second = define(runner, { sessionId: AGENT_A.id, name: 'second', purpose: 'do another', client: 'return () => {}' })
expect(first).toEqual({
pluginId: 'dyn-1', packageId: 'pkg-1', name: 'first', purpose: 'do a thing',
hasHostHalf: true, hasClientHalf: false,
})
expect(second).toEqual({
pluginId: 'dyn-2', packageId: 'pkg-2', name: 'second', purpose: 'do another',
hasHostHalf: false, hasClientHalf: true,
})
expect(running(runner, AGENT_A)).toEqual([{ id: 'dyn-1', running: false }, { id: 'dyn-2', running: false }])
})
it.each([
[{ name: ' ', purpose: 'p', host: 'return () => {}' }, 'non-empty `name`'],
[{ name: 'n', purpose: '', host: 'return () => {}' }, 'non-empty `purpose`'],
[{ name: 'n', purpose: 'p' }, 'needs `code.host`, `code.client`, or both'],
])('refuses an incomplete define request: %j', async (request, message) => {
const { runner } = await setup()
expect(() => define(runner, { sessionId: AGENT_A.id, ...request })).toThrow(message)
})
it('keeps unparseable code out of the registry, teaching the TypeScript removal', async () => {
const { runner } = await setup()
expect(() => define(runner, {
sessionId: AGENT_A.id,
name: 'broken',
purpose: 'p',
client: 'return { type: \'text\' as const }',
})).toThrow('The sandbox runs plain JavaScript, not TypeScript')
expect(running(runner, AGENT_A)).toEqual([])
})
it('hides another session\'s definition, so only its own card can address it', async () => {
const { runner } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'owned', purpose: 'p', host: HOST_CODE,
})
expect(running(runner, AGENT_B)).toEqual([])
await expect(runner.run(AGENT_B, pluginId, packageId, 'run'))
.resolves.toMatchObject({ ok: false, reason: 'plugin-missing' })
await expect(runner.stop(AGENT_B, pluginId)).resolves.toMatchObject({ ok: false, reason: 'plugin-missing' })
})
})
describe('dynamic runner dispatch', () => {
it('starts a host-only package immediately, with no request and no approval', async () => {
const { ctx, runner, gateway } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
})
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
expect(receipt).toEqual({
ok: true,
status: 'running',
pluginId,
packageId,
pluginRunId: 'run-1',
waitingFor: [],
currentPackageId: packageId,
mode: 'run',
})
expect(ctx.get('dynDoubler')).toEqual({ ok: true })
// Its own business: the only announcement is the run-state one.
expect(gateway.events).toEqual([
['cordis/dynamic-package', { pluginId, packageId, pluginRunId: 'run-1', name: 'doubler' }],
])
await expect(runner.invoke(pluginId, 'run-1' as never, 'double', { value: 21 }))
.resolves.toEqual({ ok: true, value: 42 })
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: true }])
})
it('returns awaiting approval, then records the page activation asynchronously', async () => {
const { ctx, runner, gateway } = await setup()
gateway.answer = 'approve'
gateway.clientWaitingFor = ['slots']
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'both', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
})
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
expect(receipt).toEqual({
ok: true,
status: 'awaiting-approval',
pluginId,
packageId,
pluginRunId: 'run-1',
mode: 'run',
waitingFor: [],
nextPackageId: packageId,
})
await gateway.answering
expect(ctx.get('dynDoubler')).toEqual({ ok: true })
expect(runner.inventory()[0]?.latestRun).toMatchObject({
status: 'waiting',
client: { status: 'waiting', waitingFor: ['slots'] },
})
expect(gateway.events.map(([name]) => name)).toEqual([
'cordis/request-run', 'cordis/dynamic-package', 'cordis/request-run-resolved',
])
expect(gateway.events.at(-1)?.[1]).toMatchObject({ outcome: 'approved' })
})
it('returns awaiting approval, then records a refusal without starting', async () => {
const { ctx, runner, gateway } = await setup()
gateway.answer = 'reject'
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'both', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
})
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
expect(receipt).toMatchObject({ ok: true, status: 'awaiting-approval' })
await gateway.answering
expect(ctx.get('dynDoubler')).toBeUndefined()
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
expect(gateway.events.at(-1)).toMatchObject(['cordis/request-run-resolved', { outcome: 'rejected' }])
})
it('records an asynchronous Client failure and unwinds the Host half it started', async () => {
const { ctx, runner, gateway } = await setup()
gateway.answer = { clientFails: 'createElement is not defined' }
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'both', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
})
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
expect(receipt).toMatchObject({ ok: true, status: 'awaiting-approval' })
await gateway.answering
expect(runner.inventory()[0]?.latestRun).toMatchObject({
status: 'failed',
error: { message: 'createElement is not defined' },
})
// Rollback restores the state the request found: nothing was running before.
expect(ctx.get('dynDoubler')).toBeUndefined()
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
expect(gateway.events.at(-1)?.[1]).toMatchObject({ outcome: 'failed' })
})
it('replaces a prior run and records failure when the repeated run cannot load Client code', async () => {
const { ctx, runner, gateway } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'both', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
})
// A first page ran it; a second request finds it already up.
gateway.answer = 'approve'
await runner.run(AGENT_A, pluginId, packageId, 'run')
await gateway.answering
gateway.answer = { clientFails: 'this page could not load it' }
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
expect(receipt).toMatchObject({ ok: true, status: 'starting' })
await gateway.answering
expect(runner.inventory()[0]?.latestRun).toMatchObject({
status: 'failed',
error: { message: 'this page could not load it' },
})
expect(ctx.get('dynDoubler')).toBeUndefined()
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
})
it('binds a running host half instead of evaluating it twice', async () => {
const { runner } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
})
const first = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
const second = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
expect(first).toEqual({
ok: true, pluginId, packageId, pluginRunId: 'run-1', waitingFor: [], startedHere: true,
})
// Re-evaluating would collide on the provided service; binding is what lets
// a reloaded page take a live package back.
expect(second).toEqual({
ok: true, pluginId, packageId, pluginRunId: 'run-1', waitingFor: [], startedHere: false,
})
})
it('shares one activation when two pages start the same Package concurrently', async () => {
const { runner } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
})
const [first, second] = await Promise.all([
runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false),
runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false),
])
expect(first).toMatchObject({ ok: true, pluginRunId: 'run-1', startedHere: true })
expect(second).toEqual(first)
})
it('hands the browser half\'s source only to the owning session, and only while it runs', async () => {
const { runner } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'ui', purpose: 'p', client: CLIENT_CODE,
})
expect(() => runner.getClientCode(AGENT_A, pluginId, 'run-0' as never)).toThrow('is not running')
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
if (!started.ok) throw new Error(started.message)
expect(runner.getClientCode(AGENT_A, pluginId, started.pluginRunId)).toEqual({
code: CLIENT_CODE, name: 'ui', pluginId, packageId, pluginRunId: started.pluginRunId,
})
expect(() => runner.getClientCode(AGENT_B, pluginId, started.pluginRunId)).toThrow('no dynamic plugin')
})
it('accepts and ignores an answer to a request nobody is waiting for', async () => {
const { runner } = await setup()
await expect(runner.resolveRequestRun(ApprovalRequestId('approval-404'), {
ok: true, pluginRunId: 'run-1' as never,
}))
.resolves.toEqual({ accepted: false })
})
it('refuses an answer after stop cancels the request and allows a fresh direct run', async () => {
const { runner, gateway } = await setup()
// No auto-answer: this suite drives the round trip by hand so the dispatch
// can be replaced underneath the page that is still loading run 1.
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'ui', purpose: 'p', client: CLIENT_CODE,
})
const pending = await runner.run(AGENT_A, pluginId, packageId, 'run')
expect(pending).toMatchObject({ ok: true, status: 'awaiting-approval' })
await Promise.resolve()
const asked = gateway.events.find(([name]) => name === 'cordis/request-run')?.[1]
const requestId = (asked as { requestId: ApprovalRequestIdType }).requestId
const first = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', requestId, false)
if (!first.ok) throw new Error(first.message)
expect(runner.getClientCode(AGENT_A, pluginId, first.pluginRunId).pluginRunId).toBe(first.pluginRunId)
// The user stops it while that page is still loading, cancelling the request.
await runner.stop(AGENT_A, pluginId)
await expect(runner.resolveRequestRun(requestId, { ok: true, pluginRunId: first.pluginRunId }))
.resolves.toEqual({ accepted: false })
expect(gateway.events).toContainEqual([
'cordis/request-run-resolved',
{ requestId, outcome: 'cancelled' },
])
await expect(runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false))
.resolves.toMatchObject({ ok: true, pluginRunId: 'run-2', startedHere: true })
})
it('cancels a pending request after its provisional activation is stopped', async () => {
const { runner, gateway } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'ui', purpose: 'p', client: CLIENT_CODE,
})
const controller = new AbortController()
const pending = await runner.run(AGENT_A, pluginId, packageId, 'run', controller.signal)
expect(pending).toMatchObject({ ok: true, status: 'awaiting-approval' })
await Promise.resolve()
const asked = gateway.events.find(([name]) => name === 'cordis/request-run')?.[1]
const requestId = (asked as { requestId: ApprovalRequestIdType }).requestId
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', requestId, false)
if (!started.ok) throw new Error(started.message)
await runner.stop(AGENT_A, pluginId)
await expect(runner.resolveRequestRun(requestId, { ok: true, pluginRunId: started.pluginRunId }))
.resolves.toEqual({ accepted: false })
controller.abort()
expect(gateway.events).toContainEqual([
'cordis/request-run-resolved',
{ requestId, outcome: 'cancelled' },
])
})
it('keeps a published request answerable after the creating Tool call ends', async () => {
const { runner, gateway } = await setup()
// No answer configured: the request stays pending until the signal fires.
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'ui', purpose: 'p', client: CLIENT_CODE,
})
const controller = new AbortController()
const pending = await runner.run(AGENT_A, pluginId, packageId, 'run', controller.signal)
await Promise.resolve()
controller.abort()
expect(pending).toMatchObject({ ok: true, status: 'awaiting-approval' })
const asked = gateway.events.find(([name]) => name === 'cordis/request-run')?.[1]
const requestId = (asked as { requestId: ApprovalRequestIdType }).requestId
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', requestId, false)
if (!started.ok) throw new Error(started.message)
await expect(runner.resolveRequestRun(requestId, { ok: true, pluginRunId: started.pluginRunId }))
.resolves.toEqual({ accepted: true })
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: true }])
})
it('reports the sandbox failure and starts nothing when the host half throws', async () => {
const { runner, gateway } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id,
name: 'broken',
purpose: 'p',
host: 'harness.handle(\'never\', async () => 1)\nthrow new Error(\'host half exploded\')',
})
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
expect(receipt).toMatchObject({ ok: false, reason: 'host-half-failed' })
expect(gateway.events).toEqual([])
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
await expect(runner.invoke(pluginId, 'run-1' as never, 'never', null))
.resolves.toMatchObject({ code: 'plugin-not-running' })
})
})
describe('dynamic runner teardown', () => {
it('stops both halves while keeping the definition runnable', async () => {
const { ctx, runner, gateway } = await setup()
gateway.answer = 'approve'
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
})
const first = await runner.run(AGENT_A, pluginId, packageId, 'run')
if (!first.ok) throw new Error(first.message)
await gateway.answering
await expect(runner.stop(AGENT_A, pluginId)).resolves.toEqual({ ok: true })
expect(ctx.get('dynDoubler')).toBeUndefined()
await expect(runner.invoke(pluginId, first.pluginRunId, 'double', { value: 1 }))
.resolves.toMatchObject({ code: 'plugin-not-running' })
expect(gateway.events.at(-1)).toEqual(['cordis/dynamic-retract', {
pluginId, packageId, pluginRunId: first.pluginRunId,
}])
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
// Runnable again, with a fresh activation identity.
await expect(runner.run(AGENT_A, pluginId, packageId, 'run'))
.resolves.toMatchObject({ ok: true, pluginRunId: 'run-2' })
})
it('announces the stop of a host-only package too, so a global surface drops its row', async () => {
const { runner, gateway } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
})
await runner.run(AGENT_A, pluginId, packageId, 'run')
await expect(runner.stop(AGENT_A, pluginId)).resolves.toEqual({ ok: true })
// The retract mirrors the start announcement: a run-control surface tracks
// "is it running", which is independent of whether a browser half existed.
expect(gateway.events).toEqual([
['cordis/dynamic-package', { pluginId, packageId, pluginRunId: 'run-1', name: 'doubler' }],
['cordis/dynamic-retract', { pluginId, packageId, pluginRunId: 'run-1' }],
])
})
it('refuses to stop a definition that is not running', async () => {
const { runner } = await setup()
const { pluginId } = define(runner, {
sessionId: AGENT_A.id, name: 'idle', purpose: 'p', host: HOST_CODE,
})
await expect(runner.stop(AGENT_A, pluginId)).resolves.toMatchObject({ ok: false, reason: 'not-running' })
})
it('stops a running definition on undefine and forgets it', async () => {
const { ctx, runner, gateway } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
})
await runner.run(AGENT_A, pluginId, packageId, 'run')
await expect(runner.undefine(AGENT_A, pluginId)).resolves.toEqual({ ok: true, wasRunning: true })
expect(ctx.get('dynDoubler')).toBeUndefined()
expect(running(runner, AGENT_A)).toEqual([])
expect(gateway.events.map(([name]) => name))
.toEqual(['cordis/dynamic-package', 'cordis/dynamic-retract'])
await expect(runner.run(AGENT_A, pluginId, packageId, 'run'))
.resolves.toMatchObject({ ok: false, reason: 'plugin-missing' })
})
it('answers a missing definition with the memory-only explanation', async () => {
const { runner } = await setup()
const receipt = await runner.undefine(AGENT_A, 'dyn-404' as CordisDynamicPluginId)
expect(receipt).toMatchObject({ ok: false, reason: 'plugin-missing' })
expect((receipt as { message: string }).message).toContain('lost on DSH restart')
})
it('unwinds every host half when the runner itself is disposed', async () => {
const { ctx, runner } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
})
await runner.run(AGENT_A, pluginId, packageId, 'run')
expect(ctx.get('dynDoubler')).toEqual({ ok: true })
await ctx.fiber.dispose()
expect(ctx.get('dynDoubler')).toBeUndefined()
})
})
describe('render failure reports', () => {
it('keeps the last report per package and shows it to a snapshot reader', async () => {
const { runner } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'ui', purpose: 'renders', client: CLIENT_CODE,
})
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
if (!started.ok) throw new Error(started.message)
await runner.reportRenderFailure(
AGENT_A, pluginId, started.pluginRunId,
{ slot: 'settings.section', message: 'boom', abdicated: true },
)
// Cross-page and last-writer-wins: a second page reporting overwrites,
// because "did this package's UI fail anywhere" has one answer.
await runner.reportRenderFailure(
AGENT_A, pluginId, started.pluginRunId,
{ slot: 'shell.overlay', message: 'later', abdicated: false },
)
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure)
.toEqual({ slot: 'shell.overlay', message: 'later', abdicated: false })
})
it('drops a report for a definition the reporting session does not own', async () => {
const { runner } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'ui', purpose: 'renders', client: CLIENT_CODE,
})
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
if (!started.ok) throw new Error(started.message)
// The reporting path must never fail a render, so a report it cannot place
// is dropped rather than thrown.
await expect(runner.reportRenderFailure(
AGENT_B, pluginId, started.pluginRunId, { slot: 's', message: 'm', abdicated: true },
))
.resolves.toBeNull()
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure).toBeUndefined()
})
it('clears the report when a fresh dispatch starts and when one stops', async () => {
const { runner } = await setup()
const { pluginId, packageId } = define(runner, {
sessionId: AGENT_A.id, name: 'ui', purpose: 'renders', host: 'return () => {}',
})
const first = await runner.run(AGENT_A, pluginId, packageId, 'run')
if (!first.ok) throw new Error(first.message)
await runner.reportRenderFailure(
AGENT_A, pluginId, first.pluginRunId,
{ slot: 'settings.section', message: 'boom', abdicated: true },
)
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure).toBeDefined()
// Stop clears it: nothing is mounted to have failed any more.
await runner.stop(AGENT_A, pluginId)
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure).toBeUndefined()
await runner.reportRenderFailure(
AGENT_A, pluginId, first.pluginRunId,
{ slot: 'settings.section', message: 'boom', abdicated: true },
)
// A fresh dispatch clears it too: a failure from the previous run would
// describe something that is no longer there.
await runner.run(AGENT_A, pluginId, packageId, 'run')
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure).toBeUndefined()
})
})

View File

@@ -0,0 +1,267 @@
import { describe, expect, it } from 'vitest'
import { call, CONTENT_OUTPUT_CODE, dummyTool, mount, setup, text } from './helpers.ts'
/**
* The sandbox context façade is a whitelist, not a pass-through proxy. A running
* host half 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.
*/
/** Run a host half whose `apply` touches one framework member, and report the error text. */
async function runTouching(harness: Awaited<ReturnType<typeof setup>>, expr: string): Promise<string> {
try {
await mount(harness, `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`)
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
throw new Error('expected the host half to fail')
}
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 harness = await setup()
const message = await runTouching(harness, 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 harness = await setup()
const message = await runTouching(harness, `
ctx.root.tools.register({
name: 'smuggled',
description: 'raw, unguarded',
parameters: { type: 'object', properties: {} },
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
})
`)
expect(message).toContain('sandbox ctx does not expose "root"')
// The whole point: the bypass never reaches the registry.
expect(harness.ctx.tools.get('smuggled')).toBeUndefined()
})
it('rejects assignment to the façade rather than silently dropping it', async () => {
const harness = await setup()
await expect(mount(harness, 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }'))
.rejects.toThrow('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 harness = await setup()
const message = await (async (): Promise<string> => {
try {
await mount(harness, `
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 [] },
})
},
}
`)
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
throw new Error('expected the host half to fail')
})()
expect(message).toContain('returned a cordis Context, which the sandbox does not expose')
expect(harness.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 harness = await setup()
harness.ctx.plugin({
name: 'host-async-svc',
apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) },
})
await mount(harness, `
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(harness.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 harness = await setup()
await expect(mount(harness, `
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)
},
}
`)).resolves.toBeTruthy()
})
})
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
// host half does not declare it — reaching it would let the package depend
// on a provider cordis does not know about, so it is refused.
const harness = await setup()
const message = await runTouching(harness, 'const s = ctx.systemPrompt')
expect(message).toContain('service "systemPrompt" is not injected')
expect(message).toContain('inject: [\'systemPrompt\', …]')
})
it('allows optional undeclared services through ctx.get', async () => {
const harness = await setup()
await expect(mount(harness, `
return {
name: 'optional-reader',
apply(ctx) {
const service = ctx.get('systemPrompt')
if (service !== undefined) console.log('optional service is available')
},
}
`)).resolves.toBeTruthy()
})
it('allows a service the host half DID declare in inject', async () => {
const harness = await setup()
await expect(mount(harness, `
return {
name: 'declared',
inject: ['systemPrompt', 'tools'],
apply(ctx) { console.log('has systemPrompt:', typeof ctx.systemPrompt) }
}
`)).resolves.toBeTruthy()
})
it('a cross-package 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 stops. The
// façade refuses access up front instead of leaving a zombie tool.
const harness = await setup()
await mount(harness, 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }')
await mount(harness, `
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.
const called = await call(harness.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 package code the tool's execute
// function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now
// returns the same name/description/parameters view as schemas(), with no execute.
const harness = await setup()
harness.ctx.tools.register(dummyTool('host_tool'))
await mount(harness, `
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('host_tool')
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(harness.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('host_tool')
expect(shape.keys).toEqual(['description', 'name', 'parameters'])
})
it('ctx.tools.get returns undefined for an unknown tool', async () => {
const harness = await setup()
await mount(harness, `
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(harness.ctx, 'probe_unknown', {}))).toBe('true')
})
})

View File

@@ -0,0 +1,218 @@
import { describe, expect, it, vi } from 'vitest'
import { sandboxDefineTool } from '../src/guard.ts'
import { syntaxErrorContext } from '../src/sandbox.ts'
import { AGENT_A, call, CONTENT_OUTPUT_CODE, mount, setup, text, running } from './helpers.ts'
/**
* The vm sandbox contract a host half runs under: isolated globals, Node-API
* traps that redirect to cordis services, the encoding primitives a bare vm
* context lacks, the dual-realm `instanceof` patch, the synchronous evaluation
* bound, and the teaching text a parse or runtime failure carries. Failures
* leave nothing running.
*/
describe('dynamic tool declaration boundary', () => {
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+…/)
})
})
describe('sandbox isolation and Node-API traps', () => {
it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => {
const harness = await setup()
await mount(harness, `
globalThis.__cordis_runner_leak = 'leaked'
return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} }
`)
expect((globalThis as Record<string, unknown>).__cordis_runner_leak).toBeUndefined()
})
it.each([
['require(\'fs\')', 'require is not available in the dynamic package sandbox', 'inject: [\'fs\']'],
['setTimeout(() => {}, 5)', 'setTimeout is not available in the dynamic package sandbox', 'ctx.timeout / ctx.interval'],
['fetch(\'https://example.com\')', 'fetch is not available in the dynamic package sandbox', 'ctx.web'],
])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
const harness = await setup()
const failure = await mount(harness, `${invocation}\nreturn (ctx) => {}`).catch((error: unknown) =>
error instanceof Error ? error.message : String(error))
expect(failure).toContain(trapMessage)
expect(failure).toContain(redirect)
expect(running(harness.runner, AGENT_A)).toEqual([{ id: 'probe-1', running: false }])
})
it('lets a host half schedule through the cordis timer service (inject: [\'timer\'])', async () => {
const harness = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const id = await mount(harness, `
return {
name: 'ticker',
inject: ['timer'],
apply(ctx) {
ctx.setTimeout(() => console.log('tick'), 10)
},
}
`)
await new Promise(resolve => setTimeout(resolve, 50))
expect(log).toHaveBeenCalledWith(`[cordis:${id}]`, 'tick')
vi.restoreAllMocks()
})
it('provides btoa/atob and the tagged console variants inside the sandbox', async () => {
const harness = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const id = await mount(harness, `
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(log).toHaveBeenCalledWith(`[cordis:${id}]`, 'warned')
expect(log).toHaveBeenCalledWith(`[cordis:${id}]`, 'applied', 'function')
expect(error).toHaveBeenCalledWith(`[cordis:${id}]`, 'errored')
vi.restoreAllMocks()
})
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 harness = await setup()
await mount(harness, `
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(harness.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)
})
it('honors the configured vmTimeoutMs for the synchronous portion', async () => {
const harness = await setup({ vmTimeoutMs: 50 })
await expect(mount(harness, 'while (true) {}')).rejects.toThrow(/timed? ?out/i)
expect(running(harness.runner, AGENT_A)).toEqual([{ id: 'probe-1', running: false }])
})
})
describe('host-half failures leave nothing running', () => {
it.each([
['throw new Error(\'boom in sandbox\')', 'boom in sandbox'],
['throw \'plain-string-throw\'', 'plain-string-throw'],
['return 42', 'must return a Plugin'],
['const plugin = (ctx) => {}', 'did you forget `return`?'],
['return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }', 'apply exploded'],
])('refuses %j with a teaching message', async (code, message) => {
const harness = await setup()
await expect(mount(harness, code)).rejects.toThrow(message)
expect(running(harness.runner, AGENT_A)).toEqual([{ id: 'probe-1', running: false }])
})
it('passes a null throw through untouched (no SyntaxError misclassification)', async () => {
const harness = await setup()
await expect(mount(harness, 'throw null')).rejects.toThrow()
})
})
describe('parse failures teach the fix', () => {
it('answers TypeScript syntax in the plain-JS sandbox with the fix, at define time', async () => {
const harness = await setup()
// The precheck runs inside define, so unparseable code never reaches the registry.
expect(() => harness.runner.define({
sessionId: AGENT_A.id,
plugin: { kind: 'new', idPrefix: 'probe' },
name: 'ts',
purpose: 'p',
code: { host: 'return { name: \'ts\' as const, apply(ctx) {} }' },
})).toThrow('plain JavaScript, not TypeScript')
expect(running(harness.runner, AGENT_A)).toEqual([])
})
it('surfaces the offending line + caret and the bracket-balance hint on a syntax error', async () => {
const harness = 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.
let message = ''
try {
harness.runner.define({
sessionId: AGENT_A.id,
plugin: { kind: 'new', idPrefix: 'probe' },
name: 'oops',
purpose: 'p',
code: { host: 'const note = \'treat pattern as regex\'\nreturn {\n name: \'oops\',\n apply(ctx) {}\n});' },
})
} catch (error) {
message = error instanceof Error ? error.message : String(error)
}
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 harness = await setup()
// Thrown at RUN time (the define precheck compiles fine), so the evaluator's
// own SyntaxError branch classifies it.
await expect(mount(harness, 'throw new SyntaxError(\'user-crafted\')'))
.rejects.toThrow('user-crafted')
})
})

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { AGENT_A, CLIENT_CODE, setup } from './helpers.ts'
const HOST = 'return { apply() {} }'
describe('dynamic Plugin versions', () => {
it('keeps currentPackageId when an update fails and clears nextPackageId after rollback', async () => {
const { runner } = await setup()
const first = runner.define({
sessionId: AGENT_A.id,
plugin: { kind: 'new', idPrefix: 'clock' },
name: 'clock v1',
purpose: 'show time',
code: { host: HOST },
})
await expect(runner.run(AGENT_A, first.pluginId, first.packageId, 'run')).resolves.toMatchObject({ ok: true })
const second = runner.define({
sessionId: AGENT_A.id,
plugin: { kind: 'existing', pluginId: first.pluginId },
name: 'clock v2',
purpose: 'show time',
code: { host: 'throw new Error("broken update")' },
})
await expect(runner.run(AGENT_A, first.pluginId, second.packageId, 'update'))
.resolves.toMatchObject({ ok: false, reason: 'host-half-failed' })
expect(runner.inventory()[0]).toMatchObject({
currentPackageId: first.packageId,
nextPackageId: second.packageId,
})
expect(runner.inventory()[0]?.activeRun).toBeUndefined()
await expect(runner.run(AGENT_A, first.pluginId, first.packageId, 'run')).resolves.toMatchObject({ ok: true })
expect(runner.inventory()[0]).toMatchObject({
currentPackageId: first.packageId,
activeRun: { packageId: first.packageId },
})
expect(runner.inventory()[0]?.nextPackageId).toBeUndefined()
})
it('cancels and retracts a Host activation owned by the pending approval', async () => {
const { runner, gateway } = await setup()
const defined = runner.define({
sessionId: AGENT_A.id,
plugin: { kind: 'new', idPrefix: 'panel' },
name: 'panel',
purpose: 'render a panel',
code: { host: HOST, client: CLIENT_CODE },
})
const controller = new AbortController()
const pending = runner.run(AGENT_A, defined.pluginId, defined.packageId, 'run', controller.signal)
await Promise.resolve()
const request = gateway.events.find(([event]) => event === 'cordis/request-run')?.[1]
expect(request).toBeDefined()
const approval = request as {
requestId: Parameters<typeof runner.runHostHalf>[4]
}
await expect(runner.runHostHalf(
AGENT_A,
defined.pluginId,
defined.packageId,
'run',
approval.requestId,
false,
)).resolves.toMatchObject({ ok: true, startedHere: true })
controller.abort()
await expect(pending).resolves.toMatchObject({ ok: true, status: 'awaiting-approval' })
expect(runner.inventory()[0]?.activeRun).toBeDefined()
await runner.stop(AGENT_A, defined.pluginId)
expect(runner.inventory()[0]?.activeRun).toBeUndefined()
})
it('does not stop an existing Host run when an attaching page fails to load Client code', async () => {
const { runner } = await setup()
const defined = runner.define({
sessionId: AGENT_A.id,
plugin: { kind: 'new', idPrefix: 'panel' },
name: 'panel',
purpose: 'render a panel',
code: { host: HOST, client: CLIENT_CODE },
})
const first = await runner.runHostHalf(AGENT_A, defined.pluginId, defined.packageId, 'run', null, false)
expect(first).toMatchObject({ ok: true, startedHere: true })
if (!first.ok) throw new Error(first.message)
await expect(runner.settleUserRun(AGENT_A, defined.pluginId, {
ok: true,
pluginRunId: first.pluginRunId,
})).resolves.toMatchObject({ ok: true })
const attached = await runner.runHostHalf(AGENT_A, defined.pluginId, defined.packageId, 'run', null, false)
expect(attached).toMatchObject({ ok: true, startedHere: false })
if (!attached.ok) throw new Error(attached.message)
await expect(runner.settleUserRun(AGENT_A, defined.pluginId, {
ok: false,
reason: 'client-half-failed',
pluginRunId: attached.pluginRunId,
startedHere: attached.startedHere,
message: 'this page cannot load it',
})).resolves.toMatchObject({ ok: false, reason: 'client-half-failed' })
expect(runner.inventory()[0]?.activeRun).toEqual({
packageId: defined.packageId,
pluginRunId: first.pluginRunId,
})
})
})

View File

@@ -0,0 +1,48 @@
{
"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": "../../util/brand"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/scope"
},
{
"path": "../../core/session"
},
{
"path": "../../core/tools"
},
{
"path": "../../llm/llm"
},
{
"path": "../../support/invariants"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -2,41 +2,56 @@
English | [中文](README.zh.md) 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). The self-referential Cordis toolset: five model-facing tools over the live runtime in the current DSH process. The registry, the vm sandbox, and the browser broadcast belong to [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md) (`ctx.dynamic`), which this toolset injects — a composition with these tools but no runner never activates them. Design home — sandbox semantics, dynamic-package lifecycle and composition, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## What it does ## 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. Two paired verbs, plus the read-only report.
- `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. - `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, this session's dynamic packages, the reflection-backed `api` / `events` references, and the compile-time `client` slot surface a browser half can contribute UI into. An exact `name` with `what: "api"`, `what: "events"`, or `what: "client"` narrows the report and adds the full contract.
- `cordis_define` — records a package (`name`, `purpose`, and a host half `code` and/or a browser half `client`) after syntax-checking both halves. Nothing runs; the user sees a card for it in the conversation with a start control. The minted `dyn-<n>` id rides the result value AND the durable presentation metadata, which is how that card addresses the run verbs on replay.
- `cordis_run` — evaluates the host half in the sandbox and delivers the browser half to every open web page. Running an already-running package re-delivers the live version instead of failing, which is how a reloaded page gets it back.
- `cordis_stop` — disposes the host half to quiescence and withdraws the browser half; the definition survives and can run again.
- `cordis_undefine` — stops the package if needed and forgets the definition; its card stays in the conversation as an unloaded record.
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md). 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. Dynamic packages 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_stop`/`cordis_undefine`, 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 a normal local, project, or repository Plugin through the regular development workflow. Every verb is session-scoped: a package is visible and controllable only in the session that defined it.
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 ## 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). 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.bash`, 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 ## Config
| Field | Default | Meaning | None. The vm evaluation bound (`vmTimeoutMs`) and the browser acknowledgement window (`ackTimeoutMs`) belong to the runner service that owns the sandbox and the broadcast — see [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md#config).
|---|---|---|
| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of temporary-Plugin code evaluation; an async body escapes it |
## The generated API catalog ## The generated client slot 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. `src/client-catalog.ts` describes the browser half's seats, generated by `scripts/gen-client-catalog.ts` (freshness-gated by `pnpm run verify-client-catalog` in `doc-sync`) from a lexical scan of every `SlotMap` declaration merge and every `slots.register` call site. It carries the one surface a browser half can act on — the slot keys, each register call's options, the props a component receives, who already occupies the seat, and which owner's mount makes the seat exist — as plain data: this package stays host-side and imports no client module, so the strings are the only thing that crosses. The generator fails loud rather than shipping an entry a model cannot act on: a slot with no registrant-facing prose, a non-literal `kind`/`scope`, owner props no export provides, a duplicate key, or a registration into an undeclared slot all break the gate. Owner props expand one level — the owner declaration with its own member documentation, and the names of the shapes its fields reference — and one slot's whole report is budget-capped, because narrowing to a single slot exists to spend less context, not more.
A slot's teaching text is its declaration's JSDoc, so improving what the model reads means editing the contract at its declaring package — not this catalog.
## Where the API report comes from
`cordis_inspect what:"api"` / `what:"events"` renders `src/api-catalog.ts`, the generated projection of the workspace's Cordis declarations: rendered method signatures, source JSDoc, harness events with their dispatch modes, and the type shapes those signatures reference, all produced by the same AST walk as `docs/subsystems`, so the data a model reads and the rendered docs cannot diverge. It is a compile-time fact about the REPOSITORY, so `pnpm run gen-cordis-api` regenerates it and `pnpm run verify-cordis-api` gates its freshness.
`src/inspect.ts` intersects that catalog with the LIVE service store: what is RUNNING comes from the store, what each service CAN DO comes from the catalog, and a live service the catalog does not cover is reported as reachable with no signatures rather than omitted. A package that needs the list in its own code copies it out of a report — the catalog is a compile-time fact about the repository, so a copied list and a freshly read one say the same thing for any one deployment.
Two model-facing judgements live in this package rather than in the artifacts, because reflection data is faithful to the code while a report has to be useful:
- **Only callable methods are shown.** Non-method members are state rather than a verb, and their rendered form carries initializers from the implementation body; symbol-keyed members are internal seams between plugins that a package façade deliberately cannot reach, so naming one would advertise a call that cannot be made.
- **Only keys a host half can reach are named to a model.** The reflection model covers every `ctx.<key>` a package declares, including launcher-supplied boot values (`agent`, `headlessIo`, …) and browser-half services (`connection`). `src/curation.ts` classifies each one's `reach``injectable`, `not-a-service`, or `other-face` — and only `injectable` keys reach a report: naming a key a package cannot reach advertises a call that cannot be made. The classification is carried as data on each catalog entry rather than applied while rendering, so the exclusion is testable on its own, and `verify-cordis-catalog` pins the classified set to exactly the keys the documentation projection does not render — a newly declared key stops the gate instead of quietly inviting a model to `inject` something that will never arrive. A classified key that nonetheless has a live provider is still reported as running and injectable: the service store is the authority on what exists.
The generated `INHERITED_CTX_API` closes the `api` report with the framework-inherited `ctx` surface (`ctx.on`, `ctx.effect`, `ctx.loader`, the timer helpers): those members are the Context itself rather than service keys, and the framework tier lives in pinned vendor packages outside every analyzed face, so the generator curates that one tier and renders it into both this catalog and `docs/cordis-api/inherited.md`. A live service the catalog does not describe is reported as running and still injectable rather than as absent. 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 ## 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. Every tool renders a `generic` card (`read` / `execute` / `delete`); `cordis_define` carries the submitted halves as `rawInput` and titles the card with the label and purpose. Presenters are pure functions of the args, and results keep the default text rendering. A Web client registers its own keyed `cordis_define` row (`@deepseek-ai/dsh-client-ui-cordis`) and reads the label, purpose, and minted id from the call arguments and the result metadata; the generic card is what a surface without that registration falls back to.
## Export shape ## 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)). Namespace plugin: named exports `name` / `inject` / `apply`, no default export ([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). It injects `tools` and `dynamicCordisRunner`.
## Model Experience ## Model Experience
@@ -44,7 +59,7 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau
#### What the model sees #### 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. The conversation model sees the generated [`cordis_inspect`, `cordis_define`, `cordis_run`, `cordis_stop`, and `cordis_undefine` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
#### Token effect #### Token effect
@@ -58,33 +73,32 @@ Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle cha
#### What the model sees #### 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. 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 `## Dynamic Packages` heading. Each row reports the id, label, purpose, which halves exist, run state and revision, provided and awaited services, registered host methods, and the last browser-half load report. The empty state explains that definitions live only in this process's memory. Broad API/event reports omit JSDoc; `name` with `what: "api"`, `what: "events"`, or `what: "client"` returns one exact target with its full contract. The `client` section lists one seat per line with its cardinality, scope, summary, and whether registering there replaces shipped UI, then the cross-cutting registrant rules; the per-seat register options, owner and framework props, and runnable example arrive only under an exact `name`. Define answers that the package is defined and NOT running yet with the id to run; run reports the revision, what the host half provides or waits for, and whether a page acknowledged the browser half; stop and undefine acknowledge in one line. Every refusal is a tool error carrying the runner's teaching text. The submitted program remains in assistant tool-call history.
#### Token effect #### Token effect
Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small. Inspect output and submitted package code are data-dependent and resent until compaction; lifecycle acknowledgements are small. The `client` section is bounded by the shipped slot count (two lines each) and its per-seat detail is opt-in, so the default report grows with the slot surface rather than with its documentation.
#### KV Cache effect #### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Later requests after cordis_mount ### Later requests after cordis_run
#### What the model sees #### 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. A running package may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_stop` and `cordis_undefine` remove those contributions after quiescence.
#### Token effect #### Token effect
Indirect token impact equals the temporary Plugin's contributions and lasts only for its process-local lifetime. Indirect token impact equals the running package's contributions and lasts only for its process-local lifetime.
#### KV Cache effect #### 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. Running or stopping a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged running set remains prefix-stable.
## Known Limitations and Deferred Work ## 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 sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so package 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. - **The `ctx` façade exposes no `effect()`** — package 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. - **The vm and acknowledgement bounds belong to the runner** — see its [Known Limitations](../cordis-host-runner/README.md#known-limitations-and-deferred-work); an async host-half body escapes `vmTimeoutMs`.
- **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.

View File

@@ -2,41 +2,56 @@
[English](README.md) | 中文 [English](README.md) | 中文
自引用 Cordis 工具集:个面向模型的工具,操作当前 DSH 进程中的实时运行时。沙箱语义、临时插件生命周期与组合、生成的 API 目录及既定决策详见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 自引用 Cordis 工具集:个面向模型的工具,操作当前 DSH 进程中的实时运行时。注册表、vm 沙箱与浏览器广播属于 [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md)`ctx.dynamic`),本工具集注入它——只装这些工具而不装 runner 的组合永远不会激活它们。沙箱语义、动态包生命周期与组合及既定决策详见[工具集 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 插件、已配置插件或已安装插件 - `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活插件 fiber、已注册工具、本会话的动态包、反射支持的 `api``events` 参考,以及浏览器半可以向其贡献 UI 的编译期 `client` 槽面。精确的 `name` 配合 `what: "api"``what: "events"``what: "client"` 可缩窄报告,并附上完整约定
- `cordis_define`:在语法预检两个半之后登记一个包(`name``purpose`,以及 host 半 `code` 和/或浏览器半 `client`)。此时不运行任何东西;用户会在会话里看到它的卡片和一个启动控件。铸出的 `dyn-<n>` 标识同时进入结果 value **与**持久的呈现元数据,卡片正是靠后者在 replay 中寻址运行动词。
- `cordis_run`:在沙箱中求值 host 半,并把浏览器半投递给每个打开的网页。对已在运行的包再次运行不会失败,而是重新投递当前版本——这正是被刷新过的页面把包取回来的方式。
- `cordis_stop`:把 host 半 dispose 到完全停稳,并从各页面撤回浏览器半;定义存续,可以再次运行。
- `cordis_undefine`:必要时先停止该包,再忘掉定义;它的卡片作为一条已卸载记录留在会话里。
面向模型的确切 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。 面向模型的确切 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。
规范成功结果分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生渲染会说明临时插件正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除 动态包只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_stop``cordis_undefine`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent智能体通过常规开发流程实现普通的本地、项目或仓库插件。每个动词都以会话为界一个包只在定义它的那个会话里可见、可控
临时插件只存在于共享 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)。 该沙箱隔离全局变量但不是安全边界。Node 全局变量不存在,或会重定向到 `ctx.fs``ctx.web``ctx.bash` 等 Cordis 服务;写入 `globalThis` 的内容保持局部,但 host realm helper 使逃逸成为可能。运行中的 host 半收到不含框架内部机制的 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)。
## 配置 ## 配置
| 字段 | 默认值 | 含义 | 无。vm 求值边界(`vmTimeoutMs`)与浏览器确认窗口(`ackTimeoutMs`)属于拥有沙箱与广播的 runner 服务——见 [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md#config)。
|---|---|---|
| `vmTimeoutMs` | `5000` | 临时插件代码求值中同步部分的边界async 主体可逃出该边界 |
## 生成的 API 目录 ## 生成的 client 槽目录
`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未知或未运行的服务目标会高声失败 `src/client-catalog.ts` 描述浏览器半的座位,由 `scripts/gen-client-catalog.ts` 生成(新鲜度门禁为 `doc-sync` 中的 `pnpm run verify-client-catalog`),数据来自对每一处 `SlotMap` 声明合并与每一个 `slots.register` 调用点的词法扫描。它承载浏览器半唯一能动的那个面——槽键、每个 register 调用的选项、组件会收到的 props、谁已经占着这个座位、以及哪个 owner 挂着这个座位才存在——并且只以纯数据承载:本包始终在 host 侧、不 import 任何 client 模块,跨越两平面的只有这些字符串。生成器宁可高声失败也不吐出一条模型无法照做的条目:槽缺少面向 registrant 的 JSDoc 正文、`kind``scope` 不是字面量、owner props 没有任何导出声明、键重复、或注册进了没人声明的槽都会让门禁变红。owner props 只展开一层——owner 声明本身连它的成员文档,加上其字段所引用的那些形状的名字——而单个槽的整份报告有行数上限:收窄到一个槽的意义是少花上下文,不是多花
一个槽的教学文案就是它声明处的 JSDoc所以要改模型读到的内容改的是声明它的那个包里的约定而不是这份目录。
## API 报告从哪里来
`cordis_inspect what:"api"``what:"events"` 渲染的是 `src/api-catalog.ts`,即工作区 Cordis 声明的生成投影:渲染好的方法签名、源码 JSDoc、带分发模式的 harness 事件,以及这些签名引用到的类型形状——全部由与 `docs/subsystems` 同一次 AST 遍历产出,因此模型读到的数据与渲染出的文档不可能彼此偏离。它是关于**仓库**的编译期事实,所以用 `pnpm run gen-cordis-api` 重新生成、用 `pnpm run verify-cordis-api` 守它的新鲜度。
`src/inspect.ts` 把这份目录与**活的**服务存储取交集:**谁在跑**由存储回答,**每个服务能做什么**由目录回答;目录没覆盖到的活服务会被报成可达但没有签名,而不是被省略。包代码若要在自己源码里用这份清单,就从报告里抄出来——目录是关于仓库的编译期事实,所以对任一个部署而言,抄出来的清单与现读的清单说的是同一件事。
有两项面向模型的判断住在本包里,而不住在产物里,因为反射数据忠于代码,而报告必须有用:
- **只展示可调用的方法。** 非方法成员是状态而不是动词,而它们渲染出来的形式会带上实现体里的初始值;以 symbol 为键的成员是插件之间的内部 seam包的 façade 刻意无法触达,所以点出其中任何一个,都等于宣传一次根本发不出的调用。
- **只有 host 半够得到的键,才会被点名给模型。** 反射模型覆盖包声明的每一个 `ctx.<key>`,其中包括 launcher 提供的 boot 值(`agent``headlessIo` 等)与浏览器半的服务(`connection`)。`src/curation.ts` 会为每一个这样的键归类它的 `reach`——`injectable``not-a-service``other-face`——而只有 `injectable` 的键能进报告:点名一个包够不到的键,就等于宣传一次根本发不出的调用。这份归类是作为每条目录条目上的数据携带的,而不是在渲染时才施加,因此这项排除可以单独测试;同时 `verify-cordis-catalog` 把被归类的集合钉成「文档投影不渲染的键」这个集合本身——新声明一个键会把门禁拦下来,而不是悄悄引诱模型去 `inject` 一个永远不会到来的东西。一个被归类、但确实有存活提供方的键,仍然会被报成在跑且可 inject服务 store 才是「什么存在」的权威。
生成常量 `INHERITED_CTX_API``api` 报告收尾,列出框架继承来的 `ctx` 面(`ctx.on``ctx.effect``ctx.loader`、各 timer 辅助方法):这些成员本身就是 Context不是某个服务键而框架层住在 pinned vendor 包里,位于每一个被分析的契约面之外——所以生成器策展这**一层**,并把它同时渲染进本目录与 `docs/cordis-api/inherited.md`。一个活着、但目录并不描述的服务,会被报成“在跑、且仍可 inject”而不是报成不存在。宽泛的 `api``events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc未知或未运行的服务目标会高声失败。
## 渲染 ## 渲染
个工具都渲染 `generic` 卡片(`read``execute``delete``cordis_mount``rawInput` 携带临时插件代码。presenter 是 args 的纯函数结果保留默认文本渲染。 个工具都渲染 `generic` 卡片(`read``execute``delete``cordis_define``rawInput` 携带提交的两个半,并用标签与用途作为卡片标题。presenter 是 args 的纯函数结果保留默认文本渲染。Web 客户端注册自己的 keyed `cordis_define` 行(`@deepseek-ai/dsh-client-ui-cordis`),从调用参数与结果元数据里取标签、用途和铸出的标识;没有该注册的界面则退回到这张 generic 卡片。
## 导出形式 ## 导出形式
Namespace 插件:命名导出 `name``inject``Config``apply`,无默认导出([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 Namespace 插件:命名导出 `name``inject``apply`,无默认导出([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。它注入 `tools``dynamicCordisRunner`
## 模型体验 ## 模型体验
@@ -44,7 +59,7 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
#### 模型看到的内容 #### 模型看到的内容
该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_mount` 和 `cordis_unmount` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。 该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_define`、`cordis_run`、`cordis_stop` 和 `cordis_undefine` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。
#### Token 影响 #### Token 影响
@@ -58,33 +73,32 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
#### 模型看到的内容 #### 模型看到的内容
检查会精确地用 `## <section>` 加换行及取决于数据的正文来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时插件行都会报告 runningpending 状态,以及其提供和等待的服务,以及持续至卸载或 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 工具调用历史中。 检查会精确地用 `## <section>` 加换行及取决于数据的正文来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Dynamic Packages` 标题。每一行都会报告标识、标签、用途、存在哪些半、运行状态与版本号、提供和等待的服务、已注册的 host 方法,以及最后一次浏览器半装载上报;空状态说明定义只存在于本进程内存中。宽泛的 API事件报告省略 JSDoc`name` 配合 `what: "api"``what: "events"` `what: "client"` 返回一个精确目标及其完整约定。`client` 区段每个座位一行,给出其基数、作用域、摘要,以及注册进去是否会替换出厂 UI随后是跨座位通用的 registrant 纪律;每个座位的 register 选项、owner 与框架 props、可直接运行的示例只在精确 `name` 时才吐出。define 回答该包已定义、尚未运行并给出用于运行的标识run 报告版本号、host 半提供或等待什么以及是否有页面确认了浏览器半stop 与 undefine 各以一行确认。每一次拒绝都是携带 runner 教学文案的工具错误。提交的程序保留在 assistant 工具调用历史中。
#### Token 影响 #### Token 影响
检查输出与挂载代码取决于数据并在压缩compaction前重复发送生命周期确认文本很短。 检查输出与提交的包代码取决于数据并在压缩compaction前重复发送生命周期确认文本很短。`client` 区段的体量由出厂槽数量决定(每座位两行),每座位细节按需索取,因此默认报告随槽面增长,而不是随其文档量增长。
#### KV Cache 影响 #### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
### cordis_mount 后的后续请求 ### cordis_run 后的后续请求
#### 模型看到的内容 #### 模型看到的内容
临时插件可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_unmount` 会在完全停稳后移除这些贡献。 运行中的包可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_stop``cordis_undefine` 会在完全停稳后移除这些贡献。
#### Token 影响 #### Token 影响
间接 token 影响等于临时插件的贡献,且只在其进程内生命周期内持续。 间接 token 影响等于运行中包的贡献,且只在其进程内生命周期内持续。
#### KV Cache 影响 #### KV Cache 影响
挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时插件集合不变时,前缀保持稳定。 运行或停止提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;运行集合不变时,前缀保持稳定。
## 已知限制与暂缓事项 ## 已知限制与暂缓事项
- **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper因此挂载代码可以触达 Node加载该插件时应当像授予 bash 工具一样慎重(见 § 信任立场)。 - **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper因此代码可以触达 Node加载该插件时应当像授予 bash 工具一样慎重(见 § 信任立场)。
- **`ctx` façade 不公开 `effect()`**挂载代码无法注册定制 disposer`on``provide``tools.register` 是受支持的清理路径。 - **`ctx` façade 不公开 `effect()`**代码无法注册定制 disposer`on``provide``tools.register` 是受支持的清理路径。
- **`vmTimeoutMs` 只限制同步求值**async 挂载主体可逃出该边界;挂载代码没有 async 预算。 - **vm 与确认窗口这两个边界属于 runner**:见它的[已知限制](../cordis-host-runner/README.md#known-limitations-and-deferred-work)async 的 host 半主体可逃出 `vmTimeoutMs`
- **临时 Plugin 属于组装,而不属于挂载它的那个会话**group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。多个会话并发运行同一 preset 时这一点才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。

View File

@@ -32,20 +32,23 @@
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^" "@deepseek-ai/cordis": "workspace:^"
}, },
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": { "devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^",

File diff suppressed because it is too large Load Diff

View File

@@ -1,172 +1,197 @@
/** /**
* Self-referential runtime tools: inspect live services/plugins/tools, mount a returned temporary * Model-facing Cordis runtime/package inspection, define, run, stop, and remove tools.
* 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 * @module @deepseek-ai/dsh-tool-cordis
*/ */
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import {
CordisDynamicPackageId, CordisDynamicPluginId,
} from '@deepseek-ai/dsh-cordis-host-runner'
import type { DynamicCordisReference } from '@deepseek-ai/dsh-cordis-host-runner'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools' import { defineTool } from '@deepseek-ai/dsh-tools'
import { STATE_LABELS } from './fiber-state.ts' import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { isPlugin, pluginName } from './guard.ts' import type {} from '@deepseek-ai/dsh-system-prompt'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts' import { missingServices, providedServices } from './inspect.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts' import {
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts' presentDefineCall, presentInspectListCall, presentInspectQueryCall, presentInspectSelfCall, presentRunCall,
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts' presentStopCall, presentUndefineCall,
import { createSandbox, evaluateMountCode } from './sandbox.ts' } from './present.ts'
import { CORDIS_SYSTEM_PROMPT } from './prompt.ts'
import { hostInspectProviders } from './providers.ts'
export const name = 'tool-cordis' export const name = 'tool-cordis'
export const inject = ['tools'] export const inject = ['tools', 'systemPrompt', 'dynamicCordisRunner', 'cordisInspect']
/** Config for the tool-cordis plugin: the sandbox evaluation bound. */ function requireAgent(exec: ToolExecution): Agent {
export interface Config { if (exec.agent === undefined) throw new Error('Cordis dynamic tools require an Agent-backed session')
/** return exec.agent
* 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). */ /** Register the Cordis tools and explicit `@pluginId` context injection. */
export const Config: z<Config> = z.object({ export function apply(ctx: Context): void {
vmTimeoutMs: z.number().min(1).default(5000), ctx.systemPrompt.section({ name: 'tool:cordis', order: 115, text: CORDIS_SYSTEM_PROMPT })
}) for (const provider of hostInspectProviders(ctx)) {
ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`)
/** {@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({ ctx.tools.register(defineTool({
name: 'cordis_inspect', name: 'cordis_inspect_list',
description: description:
'Inspect the live Cordis runtime in the current DSH process. Read-only. ' 'List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest '
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), ' + 'manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and '
+ '`plugins` (all live plugin fibers with their lifecycle states), ' + 'input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and '
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), ' + 'method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business '
+ '`temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), ' + 'Service that Plugin code can call.',
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), ' parameters: {},
+ '`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: { output: {
schema: { type: 'string' }, schema: { type: 'json' },
render: (_args, value) => [{ type: 'text', text: value }], render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
}, },
execute(args, exec): Promise<string> { execute(_args, _exec): Promise<JsonValue> {
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') { return Promise.resolve({ providers: ctx.cordisInspect.list() } as unknown as JsonValue)
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, presentCall: presentInspectListCall,
})) }))
ctx.tools.register(defineTool({ ctx.tools.register(defineTool({
name: 'cordis_mount', name: 'cordis_inspect_query',
description: description:
'Mount a temporary Cordis Plugin in the current DSH process. ' 'Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come '
+ 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. ' + 'from cordis_inspect_list, and input must satisfy that method\'s schema. Use this Tool before cordis_define '
+ 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. ' + 'to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot '
+ 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. ' + 'trees and props. Host queries run locally. A Client query waits for the first valid page response and '
+ 'To keep it, ask the Agent to implement an Harness Plugin or installable profile bundle through the regular development workflow. ' + 'remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service '
+ 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. ' + 'methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate '
+ '`code` runs now as the body of an async JavaScript function ' + 'the compact signature directory, then query the exact service or event for its structured contract and '
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: ' + 'referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the '
+ 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register ' + 'exact root for its complete registration contract and props.',
+ '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: { parameters: {
code: { platform: { type: 'string', required: true, enum: ['host', 'client'], description: 'Runtime platform that owns the Provider.' },
type: 'string', provider: { type: 'string', required: true, description: 'Exact Provider ID returned by cordis_inspect_list.' },
method: { type: 'string', required: true, description: 'Exact method name declared by the Provider manifest.' },
input: { type: 'json', description: 'Optional query input; it must satisfy the method input schema.' },
},
output: {
schema: { type: 'json' },
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
},
async execute(args, exec) {
const data = await ctx.cordisInspect.query(
args.platform,
args.provider,
args.method,
args.input,
requireAgent(exec),
exec.signal,
)
return { platform: args.platform, provider: args.provider, method: args.method, data }
},
presentCall: presentInspectQueryCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_inspect_self',
description:
'Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, '
+ 'list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package '
+ 'summary. Only pluginId plus packageId returns that immutable Package\'s Host/Client source and runtime '
+ 'diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing '
+ 'an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code '
+ 'nor changes version pointers.',
parameters: {
pluginId: { type: 'string', description: 'Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin.' },
packageId: { type: 'string', description: 'Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned.' },
},
output: {
schema: { type: 'json' },
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
},
execute(args, exec): Promise<JsonValue> {
const agent = requireAgent(exec)
if (args.packageId !== undefined && args.pluginId === undefined) {
throw new Error('cordis_inspect_self packageId requires pluginId')
}
if (args.pluginId === undefined) {
return Promise.resolve({
mode: 'plugins',
plugins: ctx.dynamicCordisRunner.listPlugins(agent).map(reference => selfSummary(reference)),
} as unknown as JsonValue)
}
const pluginId = CordisDynamicPluginId(args.pluginId)
if (args.packageId === undefined) {
const plugin = ctx.dynamicCordisRunner.inspectPlugin(agent, pluginId)
return Promise.resolve({
mode: 'plugin',
...selfSummary(plugin),
packages: plugin.packages.map(pkg => ({
...pkg,
packageId: String(pkg.packageId),
isCurrent: pkg.packageId === plugin.currentPackageId,
isNext: pkg.packageId === plugin.nextPackageId,
})),
} as unknown as JsonValue)
}
return Promise.resolve(inspectSelfPackage(
ctx,
agent,
pluginId,
CordisDynamicPackageId(args.packageId),
) as unknown as JsonValue)
},
presentCall: presentInspectSelfCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_define',
description:
'Define an immutable Cordis Package. For a new Plugin, use kind:"new" and provide only a semantic prefix of '
+ '36 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing '
+ 'Plugin, use kind:"existing" with its exact pluginId to append a Package without overwriting older versions. '
+ 'Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns '
+ 'a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a '
+ 'Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it '
+ 'does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the '
+ 'returned IDs.',
parameters: {
plugin: {
required: true, required: true,
description: 'JavaScript body returning a temporary Plugin; evaluated now and saved nowhere.', oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', const: 'new', required: true },
idPrefix: {
type: 'string',
required: true,
description: 'Suggested semantic prefix of 36 lowercase English letters; the Host adds a unique numeric suffix.',
},
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', const: 'existing', required: true },
pluginId: { type: 'string', required: true, description: 'Exact ID of an existing Plugin; the new Package is appended to that instance.' },
},
},
],
},
name: { type: 'string', required: true, description: 'Short, readable Package name.' },
purpose: { type: 'string', required: true, description: 'One-sentence, user-facing description of the Package purpose.' },
code: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
host: { type: 'string', description: 'Plain JavaScript function body that returns the Host-half Cordis Plugin.' },
client: { type: 'string', description: 'Plain JavaScript function body that returns the browser Client-half Cordis Plugin.' },
},
}, },
}, },
output: { output: {
@@ -174,93 +199,332 @@ export function apply(ctx: Context, config: Config): void {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
id: { type: 'string', required: true }, pluginId: { type: 'string', required: true },
pluginName: { type: 'string', required: true }, packageId: { type: 'string', required: true },
state: { name: { type: 'string', required: true },
type: 'string', purpose: { type: 'string', required: true },
required: true, hasHostHalf: { type: 'boolean', required: true },
enum: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'], hasClientHalf: { type: 'boolean', required: true },
},
provides: { type: 'array', required: true, items: { type: 'string' } },
waitingFor: { type: 'array', required: true, items: { type: 'string' } },
}, },
}, },
render: (_args, value) => [{
type: 'text',
text: `Defined ${value.pluginId}/${value.packageId} (${value.name}); it is not running yet. `
+ 'Use cordis_run to activate this Package.',
}],
presentationMeta: (_args, value) => ({ pluginId: value.pluginId, packageId: value.packageId }),
},
execute(args, exec) {
const plugin = args.plugin.kind === 'new'
? { kind: 'new' as const, idPrefix: args.plugin.idPrefix }
: { kind: 'existing' as const, pluginId: CordisDynamicPluginId(args.plugin.pluginId) }
const receipt = ctx.dynamicCordisRunner.define({
sessionId: requireAgent(exec).id,
plugin,
name: args.name,
purpose: args.purpose,
code: {
...args.code.host === undefined ? {} : { host: args.code.host },
...args.code.client === undefined ? {} : { client: args.code.client },
},
})
return Promise.resolve({
...receipt,
pluginId: String(receipt.pluginId),
packageId: String(receipt.packageId),
})
},
presentCall: presentDefineCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_run',
description:
'Activate one exact Package of a dynamic Plugin. Use mode:"run" for the first activation, restarting '
+ 'currentPackageId, or rollback. When current exists, use mode:"update" to switch to a different Package, '
+ 'even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and '
+ 'returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the '
+ 'browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after '
+ 'complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or '
+ 'technical failure is reported through state and steering. After a technical failure, read diagnostics with '
+ 'cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after '
+ 'the user rejects it.',
parameters: {
pluginId: { type: 'string', required: true, description: 'Stable Plugin ID returned by cordis_define.' },
packageId: { type: 'string', required: true, description: 'Exact immutable Package ID to activate under that Plugin.' },
mode: {
type: 'string',
required: true,
enum: ['run', 'update'],
description: 'Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.',
},
},
output: {
schema: { type: 'json' },
render: (_args, value) => { render: (_args, value) => {
const status = value.waitingFor.length > 0 const result = requireJsonObject(value)
? `is pending (plugin "${value.pluginName}"; missing services: ${value.waitingFor.join(', ')}` const pluginId = requireJsonString(result, 'pluginId')
: `is running (plugin "${value.pluginName}"` const packageId = requireJsonString(result, 'packageId')
const pluginRunId = requireJsonString(result, 'pluginRunId')
return [{ return [{
type: 'text', type: 'text',
text: `Temporary Plugin ${value.id} ${status}; available until unmounted or DSH restarts).`, text: result.status === 'awaiting-approval'
? `${pluginId}/${packageId} is awaiting user approval (${pluginRunId}).`
: result.status === 'starting'
? `${pluginId}/${packageId} is starting asynchronously (${pluginRunId}).`
: `${pluginId}/${packageId} is running (${pluginRunId}).`,
}] }]
}, },
}, presentationMeta: (_args, value) => {
async execute(args) { const result = requireJsonObject(value)
const id = `dyn-${nextId++}` return {
const sandbox = createSandbox(id) pluginId: requireJsonString(result, 'pluginId'),
const evaluated = await evaluateMountCode(sandbox, args.code, id, vmTimeoutMs) packageId: requireJsonString(result, 'packageId'),
if (!isPlugin(evaluated)) { pluginRunId: requireJsonString(result, 'pluginRunId'),
if (evaluated === undefined) { }
throw new Error( },
'temporary Plugin code returned `undefined` — did you forget `return`?\n' },
+ ' ✓ return (ctx) => { … }\n' async execute(args, exec) {
+ ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }', const agent = requireAgent(exec)
) const pluginId = CordisDynamicPluginId(args.pluginId)
const packageId = CordisDynamicPackageId(args.packageId)
const receipt = await ctx.dynamicCordisRunner.run(agent, pluginId, packageId, args.mode, exec.signal)
if (!receipt.ok) throw new Error(receipt.message)
if (receipt.status !== 'running') {
return {
status: receipt.status,
pluginId: args.pluginId,
packageId: args.packageId,
pluginRunId: String(receipt.pluginRunId),
mode: receipt.mode,
...receipt.currentPackageId === undefined ? {} : { currentPackageId: String(receipt.currentPackageId) },
nextPackageId: String(receipt.nextPackageId),
} }
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) const row = ctx.dynamicCordisRunner.snapshot(agent).find(candidate => candidate.pluginId === pluginId)
mounts.set(id, { fiber, pluginName: pluginName(evaluated) }) const fiber = row?.activeRun?.pluginRunId === receipt.pluginRunId ? row.activeRun.fiber : undefined
// 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 { return {
id, status: 'running',
pluginName: pluginName(evaluated), pluginId: args.pluginId,
state, packageId: args.packageId,
provides: providedServices(ctx, fiber), pluginRunId: String(receipt.pluginRunId),
waitingFor: missing, currentPackageId: String(receipt.currentPackageId),
...receipt.nextPackageId === undefined ? {} : { nextPackageId: String(receipt.nextPackageId) },
host: {
status: fiber === undefined ? 'absent' : missingServices(ctx, fiber).length === 0 ? 'running' : 'waiting',
provides: fiber === undefined ? [] : providedServices(ctx, fiber),
waitingFor: fiber === undefined ? [] : missingServices(ctx, fiber),
},
client: {
status: receipt.clientWaitingFor === undefined
? 'absent'
: receipt.clientWaitingFor.length === 0 ? 'running' : 'waiting',
waitingFor: [...(receipt.clientWaitingFor ?? [])],
},
} }
}, },
presentCall: presentMountCall, presentCall: presentRunCall,
})) }))
ctx.tools.register(defineTool({ ctx.tools.register(defineTool({
name: 'cordis_unmount', name: 'cordis_stop',
description: 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. ' 'Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the '
+ 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.', + 'Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update '
+ 'directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects '
+ 'temporarily; use cordis_undefine for permanent removal.',
parameters: { parameters: {
id: { pluginId: { type: 'string', required: true, description: 'Stable dynamic Plugin ID to stop.' },
type: 'string', },
required: true, output: {
description: 'The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart.', schema: { type: 'object', additionalProperties: false, properties: { pluginId: { type: 'string', required: true } } },
}, render: (_args, value) => [{ type: 'text', text: `Dynamic Plugin ${value.pluginId} is stopped; its definition and versions remain.` }],
},
async execute(args, exec) {
const receipt = await ctx.dynamicCordisRunner.stop(requireAgent(exec), CordisDynamicPluginId(args.pluginId))
if (!receipt.ok && receipt.reason !== 'not-running') throw new Error(receipt.message)
return { pluginId: args.pluginId }
},
presentCall: presentStopCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_undefine',
description:
'Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, '
+ 'first stop it and cancel the request, then delete every Package, grant, and version pointer. After this '
+ 'returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards '
+ 'retain only a "Plugin removed" record. Do not call this Tool when versions must remain available for restart '
+ 'or rollback; use cordis_stop instead.',
parameters: {
pluginId: { type: 'string', required: true, description: 'Stable dynamic Plugin ID to remove permanently.' },
}, },
output: { output: {
schema: { schema: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
id: { type: 'string', required: true }, pluginId: { type: 'string', required: true },
pluginName: { type: 'string', required: true }, wasRunning: { type: 'boolean', required: true },
}, },
}, },
render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was unmounted and removed.` }], render: (_args, value) => [{ type: 'text', text: `Removed dynamic Plugin ${value.pluginId} and all of its Packages.` }],
}, },
async execute(args) { async execute(args, exec) {
const mount = mounts.get(args.id) const receipt = await ctx.dynamicCordisRunner.undefine(requireAgent(exec), CordisDynamicPluginId(args.pluginId))
if (!mount) { if (!receipt.ok) throw new Error(receipt.message)
throw new Error(`no temporary Plugin with id "${args.id}" (list them with cordis_inspect what:"temporary")`) return { pluginId: args.pluginId, wasRunning: receipt.wasRunning }
}
await mount.fiber.dispose()
mounts.delete(args.id)
return { id: args.id, pluginName: mount.pluginName }
}, },
presentCall: presentUnmountCall, presentCall: presentUndefineCall,
})) }))
ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject') return decision
const ids = referencedPluginIds(messages)
if (ids.length === 0) return decision
signal.throwIfAborted()
const contexts = ids.map((id) => {
const reference = ctx.dynamicCordisRunner.reference(agent, CordisDynamicPluginId(id))
return createUserMessage({
content: [{
type: 'text',
text: reference === undefined ? renderUnavailableReference(id) : renderReference(reference),
}],
source: { kind: 'plugin', plugin: name, form: 'instructions' },
})
})
return { kind: 'enter', messages: [...decision.messages, ...contexts] }
})
}
function requireJsonObject(value: JsonValue): Record<string, JsonValue> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('expected a JSON object')
}
return value
}
function requireJsonString(value: Record<string, JsonValue>, key: string): string {
const field = value[key]
if (typeof field !== 'string') throw new Error(`expected JSON string field "${key}"`)
return field
}
type SelfState = 'defined' | 'awaiting-approval' | 'client-pending' | 'stopped' | 'running' | 'waiting' | 'failed'
function selfSummary(reference: DynamicCordisReference & { packages?: readonly unknown[] }): Record<string, JsonValue> {
const latest = reference.latestRun
const state = selfState(reference)
return {
pluginId: String(reference.pluginId),
name: reference.name,
packageCount: reference.packages?.length ?? 1,
state,
...reference.currentPackageId === undefined ? {} : { currentPackageId: String(reference.currentPackageId) },
...reference.nextPackageId === undefined ? {} : { nextPackageId: String(reference.nextPackageId) },
...reference.activeRun === undefined ? {} : {
activeRun: {
pluginRunId: String(reference.activeRun.pluginRunId),
packageId: String(reference.activeRun.packageId),
},
},
...latest?.status !== 'awaiting-approval' ? {} : {
pendingApproval: {
pluginRunId: String(latest.pluginRunId),
packageId: String(latest.packageId),
mode: latest.mode,
},
},
}
}
function selfState(reference: DynamicCordisReference): SelfState {
const status = reference.latestRun?.status
if (status === 'awaiting-approval') return 'awaiting-approval'
if (status === 'client-pending' || status === 'starting-host') return 'client-pending'
if (status === 'failed' || status === 'rejected' || status === 'cancelled') return 'failed'
if (status === 'waiting') return 'waiting'
if (status === 'running') return 'running'
if (reference.activeRun !== undefined) return 'running'
return reference.currentPackageId === undefined ? 'defined' : 'stopped'
}
function inspectSelfPackage(
ctx: Context,
agent: Agent,
pluginId: ReturnType<typeof CordisDynamicPluginId>,
packageId: ReturnType<typeof CordisDynamicPackageId>,
): Record<string, JsonValue> {
const inspected = ctx.dynamicCordisRunner.inspectPackage(agent, pluginId, packageId)
const row = ctx.dynamicCordisRunner.snapshot(agent).find(candidate => candidate.pluginId === pluginId)
const pkg = row?.packages.find(candidate => candidate.packageId === packageId)
const active = row?.activeRun?.packageId === packageId ? row.activeRun : undefined
const latest = inspected.latestRun?.packageId === packageId ? inspected.latestRun : undefined
const hostWaiting = active?.fiber === undefined ? [...(latest?.host.waitingFor ?? [])] : missingServices(ctx, active.fiber)
const hostStatus = pkg?.hasHostHalf !== true
? 'absent'
: latest?.host.status ?? (active === undefined ? 'stopped' : hostWaiting.length === 0 ? 'running' : 'waiting')
const clientStatus = pkg?.hasClientHalf !== true
? 'absent'
: latest?.client.status ?? 'stopped'
return {
mode: 'package',
plugin: selfSummary(inspected),
packageId: String(packageId),
name: inspected.name,
purpose: inspected.purpose,
code: inspected.code,
runtime: {
state: selfState(inspected),
host: {
status: hostStatus,
provides: active?.fiber === undefined ? [] : providedServices(ctx, active.fiber),
waitingFor: hostWaiting,
handlers: active?.handlers ?? [],
...latest?.host.error === undefined ? {} : { error: latest.host.error },
},
client: {
status: clientStatus,
waitingFor: [...(latest?.client.waitingFor ?? [])],
...latest?.client.error === undefined ? {} : { error: latest.client.error },
...active?.renderFailure === undefined ? {} : { renderFailure: active.renderFailure },
},
},
} as unknown as Record<string, JsonValue>
}
function referencedPluginIds(messages: readonly UserMessage[]): string[] {
const found = new Set<string>()
const pattern = /(?:^|\s)@([a-z]{3,6}-\d+)(?=\s|$)/g
for (const message of messages) {
if (message.source.kind !== 'user') continue
const text = message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
for (const match of text.matchAll(pattern)) if (match[1] !== undefined) found.add(match[1])
}
return [...found]
}
function renderReference(reference: ReturnType<Context['dynamicCordisRunner']['reference']> & {}): string {
const mode = reference.currentPackageId === undefined ? 'run' : 'update'
return [
'<cordis_dynamic_plugin_context>',
JSON.stringify(reference, null, 2),
'',
`The user explicitly referenced @${reference.pluginId}. Use Package ${reference.packageId} as the base for this modification.`,
`Before modifying it, call cordis_inspect_self with pluginId="${reference.pluginId}" and packageId="${reference.packageId}" to read the exact metadata and source.`,
`Use cordis_define with plugin.kind="existing" and the original pluginId="${reference.pluginId}" to append an immutable Package.`,
`Do not create a new Plugin for this request. After cordis_define succeeds, call cordis_run mode="${mode}" with the returned packageId.`,
'</cordis_dynamic_plugin_context>',
].join('\n')
}
function renderUnavailableReference(id: string): string {
return [
'<cordis_dynamic_plugin_context>',
`The user explicitly referenced @${id}, but this Plugin is unavailable in the current Session.`,
'It may have been removed, belong to another Session, or have been lost when the DSH process restarted.',
'Do not claim that it was updated or silently create a replacement Plugin. Tell the user that the reference is currently unavailable.',
'</cordis_dynamic_plugin_context>',
].join('\n')
} }

View File

@@ -1,20 +1,40 @@
/** /**
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat * Text renderers for `cordis_runtime_inspect`. Live facts come from the service store and
* plugin list, the registered tools, the temporary-plugin table (with per-plugin provides/waits), * the plugin registry; what each service CAN DO comes from the generated
* and the catalog-backed `api` / `events` sections. Exact-name lookups add the * `api-catalog.ts`. This module owns the join of the two plus presentation: which
* original source JSDoc without inflating the default reports. * lines a section prints, how compact the default report stays, and what an exact
* `name` adds.
* @module @deepseek-ai/dsh-tool-cordis/inspect * @module @deepseek-ai/dsh-tool-cordis/inspect
*/ */
import type { Context, Fiber } from '@deepseek-ai/cordis' import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import type { Agent } from '@deepseek-ai/dsh-agent'
// Type-only: resolves `ctx.dynamicCordisRunner` (the registry this report reads).
import type {} from '@deepseek-ai/dsh-cordis-host-runner'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts' 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 type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, ServiceApiMethod, TypeApiEntry } from './api-catalog.ts'
import { CLIENT_NOTES, CLIENT_SLOT_API } from './client-catalog.ts'
import type { ClientSlotEntry } from './client-catalog.ts'
import { FiberState, STATE_LABELS } from './fiber-state.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). */ /** One live service joined with what the generated catalog knows about it. */
interface LiveService {
/** The `ctx.<name>` key. */
name: string
/** Plugin fiber providing it. */
owner: string
/** Lifecycle state of that fiber; `active` while it is serving. */
state: string
/** First sentence of the catalog summary; empty when the catalog has no entry. */
summary: string
/** Whether the generated catalog carries signatures for it. */
catalogued: boolean
/** Public method signatures from the catalog, empty for an uncatalogued service. */
methods: readonly string[]
}
/** The live service registrations, read from the reflect store. */
function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] { function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
const store = ctx.reflect.store const store = ctx.reflect.store
return Object.getOwnPropertySymbols(store) return Object.getOwnPropertySymbols(store)
@@ -22,8 +42,52 @@ function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
.filter((impl): impl is NonNullable<typeof impl> => impl !== undefined) .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 { * A summary as prose. JSDoc may name a symbol with an inline `{@link Foo.bar}`
* tag, which the generated catalog retains verbatim; a report is read, not
* compiled, so the link syntax is spent context and the bare symbol says the same
* thing.
*/
function plainSummary(summary: string): string {
return summary.replace(/\{@link\s+([^}]+)\}/g, '$1')
}
/**
* Every service this process provides, joined with the generated catalog: what is
* RUNNING comes from the store, what each service CAN DO comes from the catalog,
* and a live service the catalog does not cover stays in the list as reachable
* with no signatures rather than being dropped.
*/
function liveServices(ctx: Context, api: readonly ServiceApiEntry[]): LiveService[] {
const catalogued = new Map(api.map(entry => [entry.key, entry]))
return liveImpls(ctx)
.map((impl) => {
const entry = catalogued.get(impl.name)
return {
name: impl.name,
owner: impl.fiber.name,
state: STATE_LABELS[impl.fiber.state],
summary: entry === undefined ? '' : plainSummary(entry.summary),
catalogued: entry !== undefined,
methods: entry === undefined ? [] : entry.methods.map(method => method.signature),
}
})
.sort((left, right) => left.name.localeCompare(right.name))
}
/** Catalogued services with no live provider: loadable in principle, absent here. */
function absentServices(ctx: Context, api: readonly ServiceApiEntry[]): string[] {
const live = new Set(liveImpls(ctx).map(impl => impl.name))
return api.filter(entry => !live.has(entry.key)).map(entry => entry.key).sort()
}
/**
* Whether a fiber is `root` itself or mounted anywhere inside `root`'s subtree.
* @param fiber - the fiber to locate.
* @param root - the subtree root to test against.
* @returns true when `fiber` belongs to that subtree.
*/
export function withinFiber(fiber: Fiber, root: Fiber): boolean {
let current = fiber let current = fiber
while (true) { while (true) {
if (current === root) return true if (current === root) return true
@@ -34,7 +98,7 @@ function withinFiber(fiber: Fiber, root: Fiber): boolean {
} }
/** /**
* Return the service names provided by a mount's fiber subtree. * Service names provided by one mount's fiber subtree.
* @param ctx - the runtime whose service registrations are inspected. * @param ctx - the runtime whose service registrations are inspected.
* @param fiber - the root of the mounted fiber subtree. * @param fiber - the root of the mounted fiber subtree.
* @returns the provided service names in lexical order. * @returns the provided service names in lexical order.
@@ -47,24 +111,40 @@ export function providedServices(ctx: Context, fiber: Fiber): string[] {
} }
/** /**
* The `services` section: every provided ctx service with its owning fiber, * Services a fiber declared in `inject` that do not exist yet — a settled fiber
* annotating non-active owners with their lifecycle state. * that is not active is waiting on exactly these (legal cordis semantics: it
* @param ctx - the runtime to enumerate. * activates when the service appears).
* @returns one line per service, or a single placeholder line when none are provided. * @param ctx - the context to resolve service existence against.
* @param fiber - the fiber whose `inject` declarations are checked.
* @returns the missing service names, in declaration order.
*/ */
export function describeServices(ctx: Context): string[] { export function missingServices(ctx: Context, fiber: Fiber): string[] {
const lines = liveImpls(ctx).map((impl) => { return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
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 * The `services` section: every live ctx service with its owning fiber and, when
* line per fiber with its lifecycle state, sorted by plugin name (a plugin * the generated catalog covers it, a one-line summary. The `api` section is the
* mounted more than once repeats — one line per instance). Temporary plugins are * one that carries signatures; this one answers what exists and who provides it.
* listed like any other plugin; their ids live in the `temporary` section. * @param ctx - the runtime to enumerate.
* @param api - the generated service entries whose summaries annotate the live ones.
* @returns one line per service, or a single placeholder line when none are provided.
*/
export function describeServices(ctx: Context, api: readonly ServiceApiEntry[] = SERVICE_API): string[] {
const live = liveServices(ctx, api)
if (live.length === 0) return ['(no services provided)']
return live.map((service) => {
const state = service.state === STATE_LABELS[FiberState.ACTIVE] ? '' : `, ${service.state}`
const summary = service.summary === '' ? '' : `${service.summary}`
return `- ${service.name} (provided by ${service.owner}${state})${summary}`
})
}
/**
* 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. * @param ctx - the runtime whose registry is enumerated.
* @returns one line per loaded plugin fiber. * @returns one line per loaded plugin fiber.
*/ */
@@ -74,7 +154,7 @@ export function describePlugins(ctx: Context): string[] {
for (const fiber of runtime.fibers) fibers.push(fiber) for (const fiber of runtime.fibers) fibers.push(fiber)
} }
return fibers return fibers
.sort((a, b) => a.name.localeCompare(b.name)) .sort((left, right) => left.name.localeCompare(right.name))
.map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`) .map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`)
} }
@@ -91,22 +171,42 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
} }
/** /**
* The `temporary` section: one line per temporary plugin with id, plugin name, lifecycle * The `temporary` section: one line per dynamic package this session defined,
* state, the services its subtree provides, and — for a pending mount — the * with its metadata, which halves exist, the host half's lifecycle state and
* services it waits for. * provides/waits, the invoke methods it registered, and the last browser-half
* @param ctx - the runtime the mounts live in. * load report. Session-scoped like every runner verb.
* @param mounts - the tracked mounts, in mount order. * @param ctx - the runtime the packages live in.
* @returns one line per mount, or a single placeholder line when none exist. * @param agent - the calling agent; without one there is no definition space to report.
* @returns one line per package, or a single placeholder line when none exist.
*/ */
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] { export function describeDynamic(ctx: Context, agent?: Agent): string[] {
if (mounts.size === 0) { const rows = agent === undefined ? [] : ctx.dynamicCordisRunner.snapshot(agent)
return ['No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.'] if (rows.length === 0) {
return ['No dynamic Plugins are defined in this session. Definitions live only in this process\'s memory, so a DSH restart clears them.']
} }
return [...mounts].map(([id, mount]) => { return rows.flatMap((row) => {
const provides = providedServices(ctx, mount.fiber) const head = `- Plugin ${row.pluginId}; current: ${row.currentPackageId ?? 'none'}; next: ${row.nextPackageId ?? 'none'}`
const waiting = missingServices(ctx, mount.fiber) + (row.activeRun === undefined
const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state] ? '; stopped'
return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until unmounted or DSH restarts` : `; active: ${row.activeRun.packageId} as ${row.activeRun.pluginRunId}`)
const packages = row.packages.map((pkg) => {
const halves = [...pkg.hasHostHalf ? ['host'] : [], ...pkg.hasClientHalf ? ['client'] : []].join('+')
const active = row.activeRun?.packageId === pkg.packageId ? row.activeRun : undefined
if (active === undefined) return ` - ${pkg.packageId}: ${pkg.name} (${halves}) — ${pkg.purpose}`
const fiber = active.fiber
const state = fiber === undefined ? 'running' : fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[fiber.state]
const provides = fiber === undefined ? [] : providedServices(ctx, fiber)
const waiting = fiber === undefined ? [] : missingServices(ctx, fiber)
const failure = active.renderFailure
const rendered = failure === undefined
? ''
: `; CLIENT RENDER FAILED at ${failure.slot}: ${failure.message}${failure.abdicated ? ' (entry removed)' : ''}`
return ` - ${pkg.packageId}: ${pkg.name} [${state}, ${active.pluginRunId}] (${halves}) — ${pkg.purpose}`
+ `; provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}`
+ (active.handlers.length === 0 ? '' : `; host methods: ${active.handlers.join(', ')}`)
+ rendered
})
return [head, ...packages]
}) })
} }
@@ -130,17 +230,24 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn
} }
frontier = next frontier = next
} }
return [...included.values()].sort((a, b) => a.name.localeCompare(b.name)) return [...included.values()].sort((left, right) => left.name.localeCompare(right.name))
} }
/** Render one catalogued service, optionally including source-owned method JSDoc. */ /** Render one live catalogued service; `documented` is non-empty only for an exact-name report. */
function serviceLines(entry: ServiceApiEntry, detailed: boolean): string[] { function serviceLines(
const lines = [`- ${entry.key}${entry.summary}`] service: LiveService,
for (const method of entry.methods) { documented: readonly ServiceApiMethod[],
if (detailed) { ): string[] {
for (const docLine of method.jsDoc.split('\n')) lines.push(` ${docLine}`) const lines = [`- ${service.name} ${service.summary}`]
for (const signature of service.methods) {
const contract = documented.find(entry => entry.signature === signature)
if (contract !== undefined) {
lines.push(` ${contract.description}`)
for (const parameter of contract.parameters) lines.push(` @param ${parameter.name}${parameter.description}`)
if (contract.returns !== undefined) lines.push(` @returns ${contract.returns}`)
for (const failure of contract.throws ?? []) lines.push(` @throws ${failure}`)
} }
lines.push(` ${method.signature}`) lines.push(` ${signature}`)
} }
return lines return lines
} }
@@ -151,44 +258,41 @@ function serviceLines(entry: ServiceApiEntry, detailed: boolean): string[] {
* inherited Context APIs. * inherited Context APIs.
* @param ctx - the runtime to intersect the catalog with. * @param ctx - the runtime to intersect the catalog with.
* @param api - generated service entries, replaceable in tests. * @param api - generated service entries, replaceable in tests.
* @param name - exact live service key whose methods should include structured contracts; omitted for the compact catalog.
* @param inherited - inherited `ctx` entries, replaceable in tests. * @param inherited - inherited `ctx` entries, replaceable in tests.
* @param types - public type shapes, 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. * @returns the section lines.
*/ */
export function describeApi( export function describeApi(
ctx: Context, ctx: Context,
api: readonly ServiceApiEntry[] = SERVICE_API, api: readonly ServiceApiEntry[] = SERVICE_API,
name?: string,
inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API, inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API,
types: readonly TypeApiEntry[] = TYPE_API, types: readonly TypeApiEntry[] = TYPE_API,
name?: string,
): string[] { ): string[] {
const live = new Map<string, string>() const live = liveServices(ctx, api)
for (const impl of liveImpls(ctx)) live.set(impl.name, impl.fiber.name) const byKey = new Map(api.map(entry => [entry.key, entry]))
const lines: string[] = [] const lines: string[] = []
const liveMethodTexts: string[] = [] let selected = live.filter(service => service.catalogued)
let selected = api.filter(entry => live.has(entry.key)) let documented: readonly ServiceApiMethod[] = []
if (name !== undefined) { if (name !== undefined) {
const entry = api.find(candidate => candidate.key === name) const entry = byKey.get(name)
if (!entry) throw new Error(`no catalogued service named "${name}"`) if (entry === undefined) throw new Error(`no catalogued service named "${name}"`)
if (!live.has(name)) throw new Error(`catalogued service "${name}" is not running`) const service = live.find(candidate => candidate.name === name)
selected = [entry] if (service === undefined) throw new Error(`catalogued service "${name}" is not running`)
} selected = [service]
for (const entry of selected) { documented = entry.methods
lines.push(...serviceLines(entry, name !== undefined))
for (const method of entry.methods) {
liveMethodTexts.push(method.signature)
}
} }
for (const service of selected) lines.push(...serviceLines(service, documented))
if (name === undefined) { if (name === undefined) {
const catalogued = new Set(api.map(entry => entry.key)) for (const service of live.filter(candidate => !candidate.catalogued)) {
for (const [liveName, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) { lines.push(`- ${service.name} (provided by ${service.owner}) — running, but this catalog has no signature for it;`
if (!catalogued.has(liveName)) lines.push(`- ${liveName} (provided by ${fiber}, no catalog entry)`) + ` inject: ['${service.name}'] still reaches it`)
} }
const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key) const notRunning = absentServices(ctx, api)
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`) if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
} }
const shapes = typeClosure(liveMethodTexts, types) const shapes = typeClosure(selected.flatMap(service => [...service.methods]), types)
if (shapes.length > 0) { if (shapes.length > 0) {
lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):') 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 shape of shapes) {
@@ -206,7 +310,7 @@ export function describeApi(
* The `events` section: every harness event with its dispatch mode, one-line * The `events` section: every harness event with its dispatch mode, one-line
* summary, and exact signature, closed by the waterfall caution. * summary, and exact signature, closed by the waterfall caution.
* @param events - the event catalog (the generated one by default; injectable for tests). * @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. * @param name - exact event name whose signature should include its structured contract; omitted for the compact catalog.
* @returns the section lines. * @returns the section lines.
*/ */
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] { export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] {
@@ -219,7 +323,8 @@ export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, nam
const lines = selected.flatMap((event) => { const lines = selected.flatMap((event) => {
const entry = [`- ${event.name} [${event.mode}] — ${event.summary}`] const entry = [`- ${event.name} [${event.mode}] — ${event.summary}`]
if (name !== undefined) { if (name !== undefined) {
for (const docLine of event.jsDoc.split('\n')) entry.push(` ${docLine}`) entry.push(` ${event.description}`)
for (const parameter of event.parameters) entry.push(` @param ${parameter.name}${parameter.description}`)
} }
entry.push(` ${event.signature}`) entry.push(` ${event.signature}`)
return entry return entry
@@ -227,3 +332,72 @@ export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, nam
lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.') lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.')
return lines return lines
} }
/** One slot's compact listing row: what it is, and whether registering costs shipped UI. */
function slotSummaryLines(entry: ClientSlotEntry): string[] {
const lines = [`- ${entry.key} [${entry.kind}, ${entry.scope}] — ${entry.summary}`]
lines.push(entry.replaceRisk === 'shadows-shipped-ui'
? ` OCCUPIED — registering here REPLACES: ${entry.occupants.join('; ')}`
: ` additive${entry.occupants.length === 0 ? ' (no shipped entries)' : ` (beside: ${entry.occupants.join('; ')})`}`)
return lines
}
/** One slot's full teaching block: how to register, what arrives, what it costs. */
function slotDetailLines(entry: ClientSlotEntry): string[] {
const lines = [`- ${entry.key} [${entry.kind}, ${entry.scope}]`]
for (const docLine of entry.doc.split('\n')) lines.push(` ${docLine}`)
lines.push(` exists: ${entry.declaredBy}`)
lines.push(entry.replaceRisk === 'shadows-shipped-ui'
? ` OCCUPIED — registering here REPLACES: ${entry.occupants.join('; ')}`
: ` additive${entry.occupants.length === 0 ? ' (no shipped entries)' : ` (beside: ${entry.occupants.join('; ')})`}`)
if (entry.keyDomain !== '') lines.push(` key domain: ${entry.keyDomain}`)
lines.push(` register options besides name:${entry.registerOptions.length === 0 ? ' none' : ''}`)
for (const option of entry.registerOptions) {
lines.push(` ${option.name} (${option.requirement}, ${option.type}) — ${option.doc}`)
}
lines.push(entry.ownerProps.length === 0
? ' owner props: none — the owner supplies only the render site'
: ' owner props (the shapes the owner passes down):')
for (const declaration of entry.ownerProps) {
for (const declLine of declaration.split('\n')) lines.push(` ${declLine}`)
}
if (entry.ownerPropsReferences.length > 0) {
lines.push(` shapes those fields reference, not expanded here: ${entry.ownerPropsReferences.join(', ')}`
+ ' — read the field as the contract above describes it rather than the whole shape')
}
lines.push(' framework props for this scope:')
for (const prop of entry.standardProps) lines.push(` ${prop}`)
if (entry.slotInject !== '') lines.push(` slot-level inject face every entry receives: ${entry.slotInject}`)
if (entry.hookContext !== '') lines.push(` per-render-site hook context: ${entry.hookContext}`)
lines.push(' minimal browser half:')
for (const codeLine of entry.example.split('\n')) lines.push(` ${codeLine}`)
return lines
}
/**
* The `client` section: the browser half's slot surface — every seat a dynamic
* package can contribute UI into, whether taking it costs shipped UI, and (with
* an exact `name`) the full register contract for one seat. Compile-time data
* from the shipped web bundle, so it needs no live runtime.
* @param slots - the generated slot catalog (injectable for tests).
* @param notes - the cross-cutting registrant rules (injectable for tests).
* @param name - exact slot key to expand; omitted for the compact catalog.
* @returns the section lines.
* @throws when `name` is not a catalogued slot key.
*/
export function describeClient(
slots: readonly ClientSlotEntry[] = CLIENT_SLOT_API,
notes: readonly string[] = CLIENT_NOTES,
name?: string,
): string[] {
if (name !== undefined) {
const entry = slots.find(candidate => candidate.key === name)
if (!entry) throw new Error(`no catalogued client slot named "${name}"`)
return slotDetailLines(entry)
}
const lines = slots.flatMap(entry => slotSummaryLines(entry))
lines.push('how to contribute:')
for (const note of notes) lines.push(`- ${note}`)
lines.push('pass name:"<slot key>" with what:"client" for one slot\'s register options, owner/framework props, and a minimal browser half.')
return lines
}

View File

@@ -1,61 +0,0 @@
/**
* 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)
}

View File

@@ -1,52 +1,67 @@
/** /** Pure replay-safe render intents for Cordis tools. */
* 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' import type { GenericCallView } from '@deepseek-ai/dsh-tools'
/** /** Render a runtime-inspection call. */
* The `cordis_inspect` call card: a read, titled with the requested section. export function presentRuntimeInspectCall(args: { what?: string; name?: string }): GenericCallView {
* @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}` const target = args.name === undefined ? args.what : `${args.what}: ${args.name}`
return { return { card: 'generic', kind: 'read', title: target === undefined ? 'Inspect Cordis runtime' : `Inspect Cordis runtime: ${target}` }
card: 'generic',
kind: 'read',
title: target === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${target}`,
}
} }
/** /** Render provider-directory inspection. */
* The `cordis_mount` call card: an execute carrying the temporary-plugin code as raw input. export function presentInspectListCall(): GenericCallView {
* @param args - the validated call arguments. return { card: 'generic', kind: 'read', title: 'List Cordis Inspect Providers' }
* @returns the generic call card. }
*/
export function presentMountCall(args: { code: string }): GenericCallView { /** Render one provider query. */
export function presentInspectQueryCall(args: { platform: string; provider: string; method: string }): GenericCallView {
return { card: 'generic', kind: 'read', title: `Query Cordis ${args.platform} ${args.provider}.${args.method}` }
}
/** Render layered self-inspection. */
export function presentInspectSelfCall(args: { pluginId?: string; packageId?: string }): GenericCallView {
const target = args.pluginId === undefined
? 'dynamic Cordis Plugins'
: args.packageId === undefined ? args.pluginId : `${args.pluginId}/${args.packageId}`
return { card: 'generic', kind: 'read', title: `Inspect ${target}` }
}
/** Render an immutable Package source-inspection call. */
export function presentPackageInspectCall(args: { pluginId: string; packageId: string }): GenericCallView {
return { card: 'generic', kind: 'read', title: `Inspect Cordis Package ${args.pluginId}/${args.packageId}` }
}
/** Render a new or appended Package definition. */
export function presentDefineCall(args: {
plugin: { kind: 'new'; idPrefix: string } | { kind: 'existing'; pluginId: string }
name: string
purpose: string
code: { host?: string; client?: string }
}): GenericCallView {
const target = args.plugin.kind === 'new' ? `new ${args.plugin.idPrefix}-*` : args.plugin.pluginId
return { return {
card: 'generic', card: 'generic',
kind: 'execute', kind: 'execute',
title: 'Mount temporary Cordis Plugin', title: `Define Package "${args.name}" for ${target}: ${args.purpose}`,
rawInput: { code: args.code }, rawInput: args.code,
} }
} }
/** /** Render Plugin removal. */
* The `cordis_unmount` call card: a delete, titled with the temporary-plugin id. export function presentUndefineCall(args: { pluginId: string }): GenericCallView {
* @param args - the validated call arguments. return { card: 'generic', kind: 'delete', title: `Remove dynamic Plugin ${args.pluginId}` }
* @returns the generic call card. }
*/
export function presentUnmountCall(args: { id: string }): GenericCallView { /** Render one exact Package activation. */
export function presentRunCall(args: { pluginId: string; packageId: string; mode: 'run' | 'update' }): GenericCallView {
return { return {
card: 'generic', card: 'generic',
kind: 'delete', kind: 'execute',
title: `Unmount temporary Cordis Plugin ${args.id}`, title: `${args.mode === 'update' ? 'Update' : 'Run'} ${args.pluginId} · ${args.packageId}`,
} }
} }
/** Render Plugin stop. */
export function presentStopCall(args: { pluginId: string }): GenericCallView {
return { card: 'generic', kind: 'execute', title: `Stop dynamic Plugin ${args.pluginId}` }
}

View File

@@ -0,0 +1,102 @@
/** Model guidance shared by the Cordis dynamic-plugin tools. */
export const CORDIS_SYSTEM_PROMPT = `# Dynamic Cordis Plugins
Dynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.
- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.
- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.
## Make the user-facing plan clear first
- First decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.
- If a visual, copy, or interaction choice would materially affect the result, ask at most one concise creative-preference question and offer a few candidate directions. Do not conduct a multi-round interview or a complex questionnaire.
- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.
- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.
- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.
- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.
## Recommended workflow and Tools
Before creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.
1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.
2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.
3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.
4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.
5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.
6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.
7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.
- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.
- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.
- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.
## Identity, versions, and approval
- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 36 lowercase English letters; the Host allocates the final ID.
- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.
- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.
- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.
- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.
- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.
- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.
When the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:
1. Call cordis_inspect_self(pluginId, packageId) to read the target source.
2. Use cordis_define in existing mode to append a Package to the same Plugin.
3. Call cordis_run in run or update mode according to the version relationship.
Never silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.
## High-frequency errors that must be avoided
### Services: ctx.get and inject
- Read an optional Service with ctx.get('serviceName') by default and handle undefined.
- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.
- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.
return {
inject: ['requiredService'],
apply(ctx) {
ctx.requiredService.someMethod()
const optionalService = ctx.get('optionalService')
if (optionalService !== undefined) optionalService.someMethod()
},
}
### Code: use plain JavaScript only
- Host and Client code is not transformed by TypeScript, JSX, or a bundler.
- Do not use TypeScript types, as, decorators, import, require, or JSX.
- Client React code must use React.createElement(...); never write <Component />.
- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.
### Data: do not serialize live data
- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.
- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.
- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.
### Lifecycle: every side effect must be reversible
- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.
- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.
- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.
## Host and Client
- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.
- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.
- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.
- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.
- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.
## Asynchronous results and recovery
- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.
- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.
- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.
- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.`

View File

@@ -0,0 +1,97 @@
/** First-party Host inspect providers registered by the Cordis tool package. */
import type { Context } from '@deepseek-ai/cordis'
import { HOST_BUILTIN_INSPECTION } from '@deepseek-ai/dsh-cordis-host-runner'
import type { HostCordisInspectProviderRegistration } from '@deepseek-ai/dsh-cordis-host-runner'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { EVENT_API, queryEventApi, queryServiceApi } from './api-catalog.ts'
const EMPTY_INPUT = { type: 'object', properties: {}, additionalProperties: false } as const
const ANY_OUTPUT = { description: 'JSON data owned by this inspect provider.' } as const
const SERVICE_INPUT = exactInput('service', 'Exact Service key. Omit it for the compact Service and method-signature directory.')
const EVENT_INPUT = exactInput('event', 'Exact Event name. Omit it for the compact Event and listener-signature directory.')
const SERVICE_OUTPUT = {
description: 'Compact Service directory, or one exact Service contract with only its referenced type declarations.',
} as const
const EVENT_OUTPUT = {
description: 'Compact Event directory, or one exact Event contract with only its referenced type declarations.',
} as const
const HOST_EVENTS = EVENT_API.filter(event => !event.name.startsWith('cordis/'))
/** Construct Host providers over generated Catalogs, evaluator declarations, and live Tool scope. */
export function hostInspectProviders(ctx: Context): HostCordisInspectProviderRegistration[] {
return [
registration(
'Service',
'Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.',
'listService',
async input => queryServiceApi(readExact(input, 'service')) as unknown as JsonValue,
SERVICE_INPUT,
SERVICE_OUTPUT,
),
registration(
'Event',
'Progressive Host Event discovery: compact listener directory, then one exact event contract.',
'listEvents',
async input => queryEventApi(readExact(input, 'event'), HOST_EVENTS) as unknown as JsonValue,
EVENT_INPUT,
EVENT_OUTPUT,
),
registration('Builtin', 'Plain-JavaScript symbols available to a dynamic Host half.', 'listBuiltins', async () => ({
builtins: HOST_BUILTIN_INSPECTION,
referencedTypes: [],
} as unknown as JsonValue)),
{
manifest: {
id: 'Tool',
description: 'Tools visible to the requesting Agent, including scoped and dynamic registrations.',
methods: [{
name: 'listTools',
description: 'Return every Tool schema currently callable by this Agent.',
inputSchema: EMPTY_INPUT,
outputSchema: ANY_OUTPUT,
}],
},
query(method, _input, context) {
if (method !== 'listTools') throw new Error(`unknown Tool inspect method "${method}"`)
return Promise.resolve({ tools: ctx.tools.schemas(context.agent) } as unknown as JsonValue)
},
},
]
}
function registration(
id: string,
description: string,
method: string,
query: (input: JsonValue | undefined) => Promise<JsonValue>,
inputSchema: JsonValue = EMPTY_INPUT,
outputSchema: JsonValue = ANY_OUTPUT,
): HostCordisInspectProviderRegistration {
return {
manifest: {
id,
description,
methods: [{
name: method,
description,
inputSchema,
outputSchema,
}],
},
async query(requested, input) {
if (requested !== method) throw new Error(`unknown ${id} inspect method "${requested}"`)
return await query(input)
},
}
}
function exactInput(field: string, description: string): JsonValue {
return { type: 'object', properties: { [field]: { type: 'string', description } }, additionalProperties: false }
}
function readExact(input: JsonValue | undefined, field: string): string | undefined {
if (input === undefined || input === null || Array.isArray(input) || typeof input !== 'object') return undefined
const value = input[field]
return typeof value === 'string' ? value : undefined
}

View File

@@ -1,179 +0,0 @@
/**
* 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 `)`.',
)
}
}

View File

@@ -1,144 +0,0 @@
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]')
})
})

View File

@@ -2,34 +2,81 @@ import { Context } from '@deepseek-ai/cordis'
import Timer from '@deepseek-ai/cordis-plugin-timer' import Timer from '@deepseek-ai/cordis-plugin-timer'
import { CallId } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools' import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
import type { Config as RunnerConfig } from '@deepseek-ai/dsh-cordis-host-runner'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import * as tool from '../src/index.ts' import * as tool from '../src/index.ts'
const testToolSignal = new AbortController().signal const testToolSignal = new AbortController().signal
/** /**
* Shared spec helpers: a real `SystemPrompt` + `ToolRuntime` + timer + * Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer + the
* tool-cordis tree (only the model is absent — the code strings below stand in * dynamic runner + this toolset (only the model and the browser are absent — the
* for what it would write), plus the canonical mount-code fixtures the suites * code strings below stand in for what the model would write, and no gateway is
* share. * composed, so a browser half has nowhere to go).
*
* Every dynamic-package tool is session-scoped, so calls carry a stand-in agent.
*/ */
/** Mount the plugin on a fresh context with a real ToolRuntime and the timer service. */ /** The session every spec call runs as. */
export async function setup(config?: tool.Config): Promise<Context> { export const AGENT = { id: 'S-spec' as SessionId } as Agent
/** Mount the toolset on a fresh context with a real ToolRegistry, the timer service, and the runner. */
export async function setup(config?: RunnerConfig): Promise<Context> {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(Timer) await ctx.plugin(Timer)
await ctx.plugin(SystemPrompt) await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime) await ctx.plugin(ToolRegistry)
await ctx.plugin(tool, config) await ctx.plugin(CordisHostRunner, config)
await ctx.plugin(tool)
return ctx
}
/**
* The same composition plus a stand-in browser: an `apiProxy` whose broadcast
* answers a run request by walking the runner's own verbs, exactly as the real
* client half does. Without it a package with a browser half can only ever be
* refused, so the tool's success reporting for that shape stays untested.
* @param waitingFor - services the answering page reports its half parked on.
* @returns the mounted context.
*/
export async function setupWithBrowser(waitingFor?: readonly string[]): Promise<Context> {
const ctx = await setup()
const runner = ctx.dynamicCordisRunner
// The fake browser subscribes the way a real page does — to the forwarded Host
// event, not to a transport frame — and answers by walking the same verbs.
ctx.on('cordis/request-run', (request) => {
const { requestId, pluginId, packageId, mode } = request
queueMicrotask(() => {
void (async (): Promise<void> => {
const half = await runner.runHostHalf(AGENT, pluginId, packageId, mode, requestId, false)
if (!half.ok) return
const source = runner.getClientCode(AGENT, pluginId, half.pluginRunId)
await runner.resolveRequestRun(requestId, {
ok: true,
pluginRunId: source.pluginRunId,
...waitingFor === undefined ? {} : { waitingFor },
})
})()
})
})
return ctx return ctx
} }
let callCounter = 0 let callCounter = 0
/** Execute a registered tool through the real registry pipeline. */ /** Execute a registered tool through the real registry pipeline, as the spec agent. */
export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> { export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args }) return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
agent: AGENT,
})
} }
/** Concatenated text blocks of one tool result. */ /** Concatenated text blocks of one tool result. */
@@ -37,7 +84,22 @@ export function text(result: ToolExecutionResult): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('') return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
} }
/** Mount code for a listener plugin: logs on every `tools/change`. */ /** Define one host-half package and run it, returning its minted id. */
export async function defineAndRun(ctx: Context, code: string, name = 'spec-package'): Promise<string> {
const defined = await call(ctx, 'cordis_define', {
plugin: { kind: 'new', idPrefix: 'spec' },
name,
purpose: 'spec fixture',
code: { host: code },
})
if (defined.isError) throw new Error(`define failed: ${text(defined)}`)
const { pluginId, packageId } = defined.value as { pluginId: string; packageId: string }
const ran = await call(ctx, 'cordis_run', { pluginId, packageId, mode: 'run' })
if (ran.isError) throw new Error(`run failed: ${text(ran)}`)
return pluginId
}
/** Host-half code for a listener plugin: logs on every `tools/change`. */
export const LISTENER_CODE = ` export const LISTENER_CODE = `
return { return {
name: 'change-logger', name: 'change-logger',
@@ -47,14 +109,7 @@ export const LISTENER_CODE = `
} }
` `
/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */ /** Host-half code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
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 = ` export const REVERSE_TOOL_CODE = `
return { return {
name: 'reverse-text', name: 'reverse-text',
@@ -78,7 +133,7 @@ export const REVERSE_TOOL_CODE = `
} }
` `
/** Mount code providing a `greeter` service other mounts can inject. */ /** Host-half code providing a `greeter` service other packages can inject. */
export const PROVIDER_CODE = ` export const PROVIDER_CODE = `
return { return {
name: 'greeter-provider', name: 'greeter-provider',
@@ -88,7 +143,7 @@ export const PROVIDER_CODE = `
} }
` `
/** Mount code consuming the `greeter` service through inject, exposing it as a tool. */ /** Host-half code consuming the `greeter` service through inject, exposing it as a tool. */
export const CONSUMER_CODE = ` export const CONSUMER_CODE = `
return { return {
name: 'greeter-consumer', name: 'greeter-consumer',
@@ -111,16 +166,3 @@ export const CONSUMER_CODE = `
}, },
} }
` `
/** 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
},
}
}

View File

@@ -1,71 +1,149 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { Context, Fiber } from '@deepseek-ai/cordis' import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { FiberState } from '../src/fiber-state.ts' import { FiberState } from '../src/fiber-state.ts'
import { describeApi, describeEvents, describePlugins, describeServices } from '../src/inspect.ts' import {
import { call, LISTENER_CODE, setup, text } from './helpers.ts' describeApi, describeClient, describeDynamic, describeEvents, describePlugins, describeServices,
} from '../src/inspect.ts'
import type { ClientSlotEntry } from '../src/client-catalog.ts'
import { call, defineAndRun, LISTENER_CODE, setup, text } from './helpers.ts'
/** A single seat the shipped composition already occupies. */
const SEAT: ClientSlotEntry = {
key: 'demo.seat',
kind: 'single',
scope: 'root',
summary: 'A seat.',
doc: 'A seat.',
registerOptions: [],
ownerProps: [],
ownerPropsReferences: [],
standardProps: ['useSessions: Hook'],
keyDomain: '',
hookContext: '',
slotInject: '',
declaredBy: 'the runtime itself (built in; always present)',
occupants: ['client-demo DemoSeat'],
replaceRisk: 'shadows-shipped-ui',
example: 'return {}',
// A hypothetical package: naming a real one would tie this fixture to a
// surface it does not describe, and the real catalog carries the pointer.
source: 'a demo client package, slots.ts:1',
}
/** An empty list seat: the additive-with-no-occupant wording and the detail block. */
const LIST_SEAT: ClientSlotEntry = {
...SEAT,
key: 'demo.list',
kind: 'list',
summary: 'A list.',
doc: 'A list.',
registerOptions: [{ name: 'id', requirement: 'required', type: 'string', doc: 'Your cell key.' }],
declaredBy: "an entry in 'demo.parent' (client-demo), so it exists while that entry is mounted",
occupants: [],
replaceRisk: 'none',
example: "ctx.slots.register({ name: 'demo.list', id: 'mine' }, C)",
}
/** An occupied list seat: additive, but the report still names who is already there. */
const LIST_SEAT_OCCUPIED: ClientSlotEntry = {
...LIST_SEAT,
key: 'demo.list.busy',
occupants: ["client-demo DemoRow id 'shipped'"],
}
/** A keyed seat carrying every optional field, so each one's presence branch renders. */
const KEYED_SEAT: ClientSlotEntry = {
...SEAT,
key: 'demo.keyed',
kind: 'keyed',
summary: 'A keyed seat.',
doc: 'A keyed seat.',
registerOptions: [{ name: 'key', requirement: 'required', type: 'string', doc: 'Your cell key.' }],
ownerProps: ['export interface KeyedOwnerProps {\n block: ToolCallBlock\n}'],
ownerPropsReferences: ['ToolCallBlock'],
keyDomain: 'open: any string the owner dispatches, already taken: bash',
hookContext: 'ChatNodeContext',
slotInject: 'ChatNodeInjected',
occupants: ["client-demo DemoView key 'bash'"],
replaceRisk: 'shadows-shipped-ui',
}
/** /**
* The `cordis_inspect` sections: rendered against the real runtime through the * The `cordis_runtime_inspect` sections: rendered against the real runtime through the
* tool, plus direct renderer calls for the states a minimal harness cannot * tool, plus direct renderer calls for the states a minimal harness cannot
* reach (empty service store, same-named sibling fibers, a fully-live catalog). * reach (empty service store, same-named sibling fibers, a fully-live catalog).
*/ */
describe('cordis_inspect', () => { describe('cordis_runtime_inspect', () => {
it('reports all six sections by default', async () => { it('reports all seven sections by default', async () => {
const ctx = await setup() const ctx = await setup()
const result = await call(ctx, 'cordis_inspect', {}) const result = await call(ctx, 'cordis_runtime_inspect', {})
expect(result.isError).toBe(false) expect(result.isError).toBe(false)
const report = text(result) const report = text(result)
if (result.isError) throw new Error('expected cordis_inspect success') if (result.isError) throw new Error('expected cordis_runtime_inspect success')
expect(result.value).toBe(report) expect(result.value).toBe(report)
for (const heading of ['services', 'plugins', 'tools', 'Temporary Plugins', 'api', 'events']) { for (const heading of ['services', 'plugins', 'tools', 'Dynamic Packages', 'api', 'events', 'client']) {
expect(report).toContain(`## ${heading}`) expect(report).toContain(`## ${heading}`)
} }
// The services section sees the real providers; the plugins list shows // The services section sees the real providers; the plugins list shows
// this plugin and its dynamic group flat; the tools section lists the // this plugin and its dynamic group flat; the tools section lists the
// cordis tools. // cordis tools.
expect(report).toContain('- tools (provided by ToolRuntime)') expect(report).toContain('- tools (provided by ToolRegistry)')
expect(report).toContain('- tool-cordis [active]') expect(report).toContain('- tool-cordis [active]')
expect(report).toContain('- cordis-dynamic [active]') expect(report).toContain('- cordis_define')
expect(report).toContain('- cordis_mount') expect(report).toContain('No dynamic packages are defined in this session.')
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 () => { it('limits the report to one section via `what`', async () => {
const ctx = await setup() const ctx = await setup()
const result = await call(ctx, 'cordis_inspect', { what: 'tools' }) const result = await call(ctx, 'cordis_runtime_inspect', { what: 'tools' })
const report = text(result) const report = text(result)
expect(report).toContain('## tools') expect(report).toContain('## tools')
expect(report).not.toContain('## services') expect(report).not.toContain('## services')
expect(report).not.toContain('## plugins') expect(report).not.toContain('## plugins')
}) })
it('shows a temporary Plugin in its exact section and in the flat plugins list', async () => { it('shows a running dynamic package in its exact section and in the flat plugins list', async () => {
const ctx = await setup() const ctx = await setup()
await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) await defineAndRun(ctx, LISTENER_CODE, 'logger')
const report = text(await call(ctx, 'cordis_inspect', {})) const report = text(await call(ctx, 'cordis_runtime_inspect', {}))
expect(report).toContain('## Temporary Plugins') expect(report).toContain('## Dynamic Packages')
expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts') expect(report).toContain('- dyn-1: logger [running, rev 1] (host) — spec fixture; provides: none; waiting for: none')
// The group fiber and the package's own plugin are both live in the flat list.
expect(report).toContain('- cordis-dynamic [active]')
expect(report).toContain('- change-logger [active]') expect(report).toContain('- change-logger [active]')
}) })
it('shows a defined-but-not-running package, and the invoke methods a running one registered', async () => {
const ctx = await setup()
await call(ctx, 'cordis_define', { name: 'idle', purpose: 'waits to be started', code: 'return () => {}' })
await defineAndRun(ctx, 'harness.handle(\'ping\', async () => \'pong\')\nreturn () => {}', 'handler')
const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'temporary' }))
expect(report).toContain('- dyn-1: idle [defined, not running] (host) — waits to be started')
expect(report).toContain('host methods: ping')
})
it('renders the api section from the generated catalog intersected with the LIVE runtime', async () => { it('renders the api section from the generated catalog intersected with the LIVE runtime', async () => {
const ctx = await setup() const ctx = await setup()
const report = text(await call(ctx, 'cordis_inspect', { what: 'api' })) const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'api' }))
// Live catalogued services render summary + signatures. // Live catalogued services render summary + signatures.
expect(report).toContain('- tools — ') expect(report).toContain('- tools — Tool registry and execution pipeline.')
expect(report).toContain('register(definition: ToolDefinition)') expect(report).toContain('register(definition: ToolDefinition)')
expect(report).toContain('- systemPrompt — ') // The projection carries public METHODS only: state and symbol-keyed seams
// between plugins are not calls a package can make.
expect(report).not.toContain('store: Map<string, ToolDefinition>')
expect(report).not.toContain('TOOL_REGISTRY_SCHEDULER')
// Catalogued services with no live provider are listed tersely. // Catalogued services with no live provider are listed tersely.
expect(report).toMatch(/not running \(loadable services with no live provider\): .*shell/) expect(report).toMatch(/not running \(loadable services with no live provider\): .*bash/)
// The type shapes the LIVE signatures reference follow (closure over the // The type shapes the LIVE signatures reference follow, so a consumer can see
// generated TYPE_API — a consumer can see field types, not just names). // field types rather than only names.
expect(report).toContain('type shapes (referenced by the signatures above') expect(report).toContain('type shapes (referenced by the signatures above')
expect(report).toContain('export interface ToolExecution') expect(report).toContain('export interface ToolDefinition')
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. // A type only reachable through a NOT-live service (e.g. bash) is scoped out.
expect(report).not.toContain('export interface ShellRunResult') expect(report).not.toContain('export interface BashRunResult')
// The inherited ctx API closes the section. // The inherited ctx API closes the section.
expect(report).toContain('inherited ctx API:') expect(report).toContain('inherited ctx API:')
expect(report).toContain('- ctx.effect — ') expect(report).toContain('- ctx.effect — ')
@@ -76,13 +154,13 @@ describe('cordis_inspect', () => {
it('adds original method JSDoc only for an exact live api name', async () => { it('adds original method JSDoc only for an exact live api name', async () => {
const ctx = await setup() const ctx = await setup()
const report = text(await call(ctx, 'cordis_inspect', { what: 'api', name: 'tools' })) const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'api', name: 'tools' }))
expect(report).toContain('## api') expect(report).toContain('## api')
expect(report).toContain('- tools — Tool registry and execution pipeline.') expect(report).toContain('- tools — Tool registry and execution pipeline.')
expect(report).toContain('/**') expect(report).toContain('/**')
expect(report).toContain('Register globally or in the calling agent scope.') 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('@param definition - tool schema, execution, and optional')
expect(report).toContain('@returns the exact disposer') expect(report).toContain('@returns the exact disposer that unregisters the tool.')
expect(report).toContain('register(definition: ToolDefinition)') expect(report).toContain('register(definition: ToolDefinition)')
expect(report).toContain('type shapes (referenced by the signatures above') expect(report).toContain('type shapes (referenced by the signatures above')
expect(report).not.toContain('not running (loadable services') expect(report).not.toContain('not running (loadable services')
@@ -91,10 +169,10 @@ describe('cordis_inspect', () => {
it('renders the events section with mode badges, signatures, and the waterfall caution', async () => { it('renders the events section with mode badges, signatures, and the waterfall caution', async () => {
const ctx = await setup() const ctx = await setup()
const report = text(await call(ctx, 'cordis_inspect', { what: 'events' })) const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'events' }))
expect(report).toContain('- tools/change [emit]') expect(report).toContain('- tools/change [emit]')
expect(report).toContain('- tools/pre-execute [waterfall]') expect(report).toContain('- tools/pre-execute [waterfall]')
expect(report).toMatch(/'agent\/status'\(/) expect(report).toMatch(/'tools\/change'\(/)
expect(report).toContain('returning without next() short-circuits the chain') expect(report).toContain('returning without next() short-circuits the chain')
expect(report).not.toContain('/**') expect(report).not.toContain('/**')
expect(report).not.toContain('@mode waterfall') expect(report).not.toContain('@mode waterfall')
@@ -102,7 +180,7 @@ describe('cordis_inspect', () => {
it('adds original event JSDoc only for an exact event name', async () => { it('adds original event JSDoc only for an exact event name', async () => {
const ctx = await setup() const ctx = await setup()
const report = text(await call(ctx, 'cordis_inspect', { what: 'events', name: 'tools/pre-execute' })) const report = text(await call(ctx, 'cordis_runtime_inspect', { what: 'events', name: 'tools/pre-execute' }))
expect(report).toContain('## events') expect(report).toContain('## events')
expect(report).toContain('- tools/pre-execute [waterfall]') expect(report).toContain('- tools/pre-execute [waterfall]')
expect(report).toContain('/**') expect(report).toContain('/**')
@@ -114,19 +192,19 @@ describe('cordis_inspect', () => {
it('fails loud for incompatible, unknown, and non-running names', async () => { it('fails loud for incompatible, unknown, and non-running names', async () => {
const ctx = await setup() const ctx = await setup()
const incompatible = await call(ctx, 'cordis_inspect', { what: 'tools', name: 'tools' }) const incompatible = await call(ctx, 'cordis_runtime_inspect', { what: 'tools', name: 'tools' })
expect(incompatible.isError).toBe(true) expect(incompatible.isError).toBe(true)
expect(text(incompatible)).toContain('name is valid only with what:"api" or what:"events"') expect(text(incompatible)).toContain('name is valid only with what:"api", what:"events", or what:"client"')
const unknownService = await call(ctx, 'cordis_inspect', { what: 'api', name: 'not-a-service' }) const unknownService = await call(ctx, 'cordis_runtime_inspect', { what: 'api', name: 'not-a-service' })
expect(unknownService.isError).toBe(true) expect(unknownService.isError).toBe(true)
expect(text(unknownService)).toContain('no catalogued service named "not-a-service"') expect(text(unknownService)).toContain('no catalogued service named "not-a-service"')
const nonRunning = await call(ctx, 'cordis_inspect', { what: 'api', name: 'shell' }) const nonRunning = await call(ctx, 'cordis_runtime_inspect', { what: 'api', name: 'bash' })
expect(nonRunning.isError).toBe(true) expect(nonRunning.isError).toBe(true)
expect(text(nonRunning)).toContain('catalogued service "shell" is not running') expect(text(nonRunning)).toContain('catalogued service "bash" is not running')
const unknownEvent = await call(ctx, 'cordis_inspect', { what: 'events', name: 'not/an-event' }) const unknownEvent = await call(ctx, 'cordis_runtime_inspect', { what: 'events', name: 'not/an-event' })
expect(unknownEvent.isError).toBe(true) expect(unknownEvent.isError).toBe(true)
expect(text(unknownEvent)).toContain('no catalogued event named "not/an-event"') expect(text(unknownEvent)).toContain('no catalogued event named "not/an-event"')
}) })
@@ -135,13 +213,16 @@ describe('cordis_inspect', () => {
describe('inspect renderers (direct)', () => { describe('inspect renderers (direct)', () => {
it('describeServices reports an empty store as such, and labels a non-active provider', () => { it('describeServices reports an empty store as such, and labels a non-active provider', () => {
const empty = { reflect: { store: {} } } as unknown as Context const empty = { reflect: { store: {} } } as unknown as Context
expect(describeServices(empty)).toEqual(['(no services provided)']) expect(describeServices(empty, [])).toEqual(['(no services provided)'])
const pendingFiber = { state: FiberState.PENDING, name: 'half-loaded' } as unknown as Fiber const pendingFiber = { state: FiberState.PENDING, name: 'half-loaded' } as unknown as Fiber
const store: Record<symbol, unknown> = {} const store: Record<symbol, unknown> = {}
store[Symbol('impl')] = { name: 'thing', fiber: pendingFiber } store[Symbol('impl')] = { name: 'thing', fiber: pendingFiber }
const ctx = { reflect: { store } } as unknown as Context const ctx = { reflect: { store } } as unknown as Context
expect(describeServices(ctx)).toEqual(['- thing (provided by half-loaded, pending)']) const lines = describeServices(ctx, [])
// A service the catalog does not cover still appears, with its owner and the
// non-active lifecycle label; only the summary is missing.
expect(lines).toEqual(['- thing (provided by half-loaded, pending)'])
}) })
it('describePlugins lists every fiber flat, sorted by name, one line per instance', () => { it('describePlugins lists every fiber flat, sorted by name, one line per instance', () => {
@@ -161,17 +242,156 @@ describe('inspect renderers (direct)', () => {
const lines = describeApi(ctx, [{ const lines = describeApi(ctx, [{
key: 'tools', key: 'tools',
summary: 'The registry.', summary: 'The registry.',
methods: [{ signature: 'register(x): void', jsDoc: '/** Register x. */' }], description: 'The registry.',
}], [], []) methods: [{
signature: 'register(x): void',
description: 'Register x.',
parameters: [{ name: 'x', description: 'Value to register.' }],
}],
}], undefined, [], [])
expect(lines[0]).toBe('- tools — The registry.') expect(lines[0]).toBe('- tools — The registry.')
expect(lines[1]).toBe(' register(x): void') expect(lines[1]).toBe(' register(x): void')
expect(lines.join('\n')).not.toContain('not running') expect(lines.join('\n')).not.toContain('not running')
expect(lines.join('\n')).not.toContain('type shapes') expect(lines.join('\n')).not.toContain('type shapes')
}) })
it('expands the shapes a service names transitively, listing each one once', async () => {
const ctx = await setup()
const lines = describeApi(ctx, [{
key: 'tools',
summary: 'The registry.',
description: 'The registry.',
methods: [{
signature: 'register(definition: ToolDefinition): void',
description: '',
parameters: [],
}],
}], 'tools', [], [
{ name: 'ToolDefinition', declaration: 'export interface ToolDefinition {\n schema: ToolSchema\n}' },
{ name: 'ToolSchema', declaration: 'export interface ToolSchema {\n owner: ToolDefinition\n}' },
]).join('\n')
// The signature names one shape, that shape names the second, and the two
// reference each other back: a reader gets both, each exactly once.
expect(lines.match(/export interface ToolDefinition/g)).toHaveLength(1)
expect(lines.match(/export interface ToolSchema/g)).toHaveLength(1)
})
it('describeEvents renders an empty catalog as just the waterfall caution', () => { it('describeEvents renders an empty catalog as just the waterfall caution', () => {
expect(describeEvents([])).toEqual([ expect(describeEvents([])).toEqual([
'waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.', 'waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.',
]) ])
}) })
it('describeApi reports a live service with no catalogued signature as still injectable', async () => {
const ctx = await setup()
const lines = describeApi(ctx, []).join('\n')
// The framework tier is exactly this case: reachable through inject, but
// with no projected signature — the report must not read as "absent".
expect(lines).toContain('running, but this catalog has no signature for it')
expect(lines).toContain('still reaches it')
})
it('describeClient lists every seat with what registering there costs', () => {
const lines = describeClient([SEAT, LIST_SEAT, LIST_SEAT_OCCUPIED], ['one rule']).join('\n')
expect(lines).toContain('- demo.seat [single, root] — A seat.')
expect(lines).toContain('OCCUPIED — registering here REPLACES: client-demo DemoSeat')
expect(lines).toContain('- demo.list [list, root]')
expect(lines).toContain('additive (no shipped entries)')
// Additive does not mean empty: an id already in use is still a takeover.
expect(lines).toContain("additive (beside: client-demo DemoRow id 'shipped')")
expect(lines).toContain('- one rule')
// The compact listing must not spend context on per-seat detail.
expect(lines).not.toContain('register options besides name:')
expect(lines).not.toContain('framework props for this scope:')
})
it('describeClient expands the optional contract fields only where a seat has them', () => {
const keyed = describeClient([KEYED_SEAT], [], 'demo.keyed').join('\n')
expect(keyed).toContain('key domain: open: any string the owner dispatches, already taken: bash')
expect(keyed).toContain('owner props (the shapes the owner passes down):')
// Owner props expand one level; referenced shapes are named, not inlined.
expect(keyed).toContain('shapes those fields reference, not expanded here: ToolCallBlock')
expect(keyed).toContain('slot-level inject face every entry receives: ChatNodeInjected')
expect(keyed).toContain('per-render-site hook context: ChatNodeContext')
expect(keyed).toContain('key (required, string)')
// A seat without them says nothing about them.
const plain = describeClient([SEAT], [], 'demo.seat').join('\n')
expect(plain).toContain('owner props: none')
expect(plain).toContain('register options besides name: none')
expect(plain).not.toContain('key domain:')
expect(plain).not.toContain('slot-level inject face')
expect(plain).not.toContain('per-render-site hook context')
expect(plain).not.toContain('shapes those fields reference')
})
it('describeClient expands one seat into its full register contract', () => {
const lines = describeClient([SEAT, LIST_SEAT], ['one rule'], 'demo.list').join('\n')
expect(lines).toContain('exists: an entry in \'demo.parent\'')
expect(lines).toContain('id (required, string) — Your cell key.')
expect(lines).toContain('owner props: none')
expect(lines).toContain('useSessions: Hook')
expect(lines).toContain('minimal browser half:')
expect(lines).toContain('ctx.slots.register(')
// A narrowed report is one seat only, and carries no cross-cutting rules.
expect(lines).not.toContain('demo.seat')
expect(lines).not.toContain('one rule')
})
it('describeDynamic tells the model whether a failed browser half is still on the page', () => {
const row = (abdicated: boolean): unknown => ({
id: 'dyn-1',
name: 'panel',
purpose: 'ui',
hasHostHalf: false,
hasClientHalf: true,
run: { rev: 1, handlers: [] },
renderFailure: { slot: 'settings.section', message: 'useX is not a function', abdicated },
})
const ctxFor = (abdicated: boolean): Context => ({
dynamicCordisRunner: { snapshot: () => [row(abdicated)] },
reflect: { store: {} },
get: () => undefined,
} as unknown as Context)
const gone = describeDynamic(ctxFor(true), {} as unknown as Agent).join('\n')
expect(gone).toContain('BROWSER HALF FAILED TO RENDER at slot settings.section: useX is not a function')
// The two states differ in the one fact the author needs: is my UI there?
expect(gone).toContain('that seat was handed back to the shipped UI')
const kept = describeDynamic(ctxFor(false), {} as unknown as Agent).join('\n')
expect(kept).toContain('that seat is still yours, so what the page shows may be incomplete')
})
it('describeClient names the shipped neighbours when an occupied list seat is expanded', () => {
const lines = describeClient([LIST_SEAT_OCCUPIED], [], 'demo.list.busy').join('\n')
expect(lines).toContain("additive (beside: client-demo DemoRow id 'shipped')")
})
it('describeDynamic renders a browser-only row, a half-loaded host half, and a fiberless run', () => {
// Rows a host-only harness cannot produce: the runner's own shapes are the
// contract this renderer reads, so they are supplied directly.
const pending = { state: FiberState.PENDING, name: 'half-loaded', inject: {} } as unknown as Fiber
const rows = [
{ id: 'dyn-1', name: 'browser only', purpose: 'ui', hasHostHalf: false, hasClientHalf: true },
{ id: 'dyn-2', name: 'no fiber', purpose: 'client half only', hasHostHalf: false, hasClientHalf: true, run: { rev: 1, handlers: [] } },
{ id: 'dyn-3', name: 'waiting', purpose: 'both halves', hasHostHalf: true, hasClientHalf: true, run: { rev: 2, fiber: pending, handlers: ['ping'] } },
]
const ctx = {
dynamicCordisRunner: { snapshot: () => rows },
reflect: { store: {} },
get: () => undefined,
} as unknown as Context
// No agent means no definition space to report, not an empty registry.
expect(describeDynamic(ctx)).toEqual([
'No dynamic packages are defined in this session. Definitions live only in this process\'s memory, so a DSH restart clears them.',
])
const lines = describeDynamic(ctx, {} as unknown as Agent)
expect(lines[0]).toBe('- dyn-1: browser only [defined, not running] (browser) — ui')
expect(lines[1]).toBe('- dyn-2: no fiber [running, rev 1] (browser) — client half only; provides: none; waiting for: none')
expect(lines[2]).toBe('- dyn-3: waiting [pending, rev 2] (host+browser) — both halves; provides: none; waiting for: none; host methods: ping')
})
it('describeClient refuses an unknown slot key instead of answering emptily', () => {
expect(() => describeClient([SEAT], [], 'nope.seat')).toThrow('no catalogued client slot named "nope.seat"')
})
}) })

View File

@@ -5,22 +5,24 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop' import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
import * as ToolCordis from '../src/index.ts' import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { call, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' import { call, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/** /**
* Full-loop integration: a scripted mock model mounts a plugin that registers * Full-loop integration: a scripted mock model defines and runs a package that
* a NEW tool, calls that tool on the very next step (tool schemas are * 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), * reassembled per step — the real loop proves the self-extension contract), and
* and unmounts it again. Only the model is mocked; the sandbox, the fiber * undefines it again. Only the model is mocked; the sandbox, the fiber tree, and
* tree, and the session log are real. * the session log are real — including the presentation metadata the card needs.
*/ */
async function harness(adapter: MockAdapter): Promise<Context> { async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context() const ctx = new Context()
await mountAgentLoopTestDependencies(ctx) await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(CordisHostRunner)
await ctx.plugin(ToolCordis) await ctx.plugin(ToolCordis)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
return ctx return ctx
@@ -38,11 +40,12 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
} }
describe('cordis tools through the agent loop', () => { 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 () => { it('defines, runs, calls, and undefines a self-made tool — all as real tool/call events', async () => {
const adapter = new MockAdapter([ const adapter = new MockAdapter([
toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'), toolCallResponse('call-1', 'cordis_define', { name: 'reverser', purpose: 'reverses text', code: REVERSE_TOOL_CODE }, 'Extending myself.'),
toolCallResponse('call-2', 'reverse_text', { text: 'harness' }), toolCallResponse('call-2', 'cordis_run', { id: 'dyn-1' }),
toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }), toolCallResponse('call-3', 'reverse_text', { text: 'harness' }),
toolCallResponse('call-4', 'cordis_undefine', { id: 'dyn-1' }),
textResponse('Done.'), textResponse('Done.'),
]) ])
const ctx = await harness(adapter) const ctx = await harness(adapter)
@@ -53,36 +56,40 @@ describe('cordis tools through the agent loop', () => {
const log = agent.session.events const log = agent.session.events
const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name) const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name)
expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount']) expect(calls).toEqual(['cordis_define', 'cordis_run', 'reverse_text', 'cordis_undefine'])
const results = log.filter(event => event.type === 'tool/result') const results = log.filter(event => event.type === 'tool/result')
expect(results.map(event => event.data.message.content[0].isError)).toEqual([false, false, false]) expect(results.map(event => event.data.message.content[0].isError)).toEqual([false, false, false, false])
const reversed = results[1]!.data.message.content[0].content // The define result's durable metadata carries the minted id — this is what
// a card reads to address run/stop, and replay reproduces it verbatim.
expect(results[0]!.data.meta).toEqual({ id: 'dyn-1' })
const reversed = results[2]!.data.message.content[0].content
.filter(block => block.type === 'text') .filter(block => block.type === 'text')
.map(block => block.text) .map(block => block.text)
.join('') .join('')
expect(reversed).toBe('ssenrah') expect(reversed).toBe('ssenrah')
// After the unmount the self-made tool is gone from the registry. // After the undefine the self-made tool is gone from the registry.
expect(ctx.tools.get('reverse_text')).toBeUndefined() 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 () => { it('keeps a running package across turns, undefines it, and does not restore it in a new runtime', async () => {
const adapter = new MockAdapter([ const adapter = new MockAdapter([
toolCallResponse('mount-1', 'cordis_mount', { code: 'return { name: \'turn-marker\', apply() {} }' }), toolCallResponse('define-1', 'cordis_define', { name: 'marker', purpose: 'marks the turn', code: 'return { name: \'turn-marker\', apply() {} }' }),
toolCallResponse('inspect-1', 'cordis_inspect', { what: 'temporary' }), toolCallResponse('run-1', 'cordis_run', { id: 'dyn-1' }),
toolCallResponse('inspect-1', 'cordis_runtime_inspect', { what: 'temporary' }),
textResponse('Turn one complete.'), textResponse('Turn one complete.'),
toolCallResponse('inspect-2', 'cordis_inspect', { what: 'temporary' }), toolCallResponse('inspect-2', 'cordis_runtime_inspect', { what: 'temporary' }),
toolCallResponse('unmount-1', 'cordis_unmount', { id: 'dyn-1' }), toolCallResponse('undefine-1', 'cordis_undefine', { id: 'dyn-1' }),
toolCallResponse('inspect-3', 'cordis_inspect', { what: 'temporary' }), toolCallResponse('inspect-3', 'cordis_runtime_inspect', { what: 'temporary' }),
textResponse('Turn two complete.'), textResponse('Turn two complete.'),
]) ])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' }) 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' } })) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Define and run the marker, then inspect it.' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent) 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' } })) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'On this later turn, inspect the marker, undefine it, then inspect again.' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const resultText = new Map( const resultText = new Map(
@@ -90,12 +97,14 @@ describe('cordis tools through the agent loop', () => {
.filter(event => event.type === 'tool/result') .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('')]), .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-1'))).toContain('- dyn-1: marker [running, rev 1] (host) — marks the turn')
expect(resultText.get(CallId('inspect-2'))).toContain('Temporary Plugin dyn-1: turn-marker [running]') expect(resultText.get(CallId('inspect-2'))).toContain('- dyn-1: marker [running, rev 1] (host) — marks the turn')
expect(resultText.get(CallId('unmount-1'))).toBe('Temporary Plugin dyn-1 was unmounted and removed.') expect(resultText.get(CallId('undefine-1'))).toBe('Dynamic package dyn-1 is stopped and undefined; its id is now invalid.')
expect(resultText.get(CallId('inspect-3'))).toContain('No temporary Plugins are running.') expect(resultText.get(CallId('inspect-3'))).toContain('No dynamic packages are defined in this session.')
// A fresh runtime restores nothing: definitions never left this process.
const restarted = await setup() const restarted = await setup()
expect(text(await call(restarted, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') expect(text(await call(restarted, 'cordis_runtime_inspect', { what: 'temporary' })))
.toContain('No dynamic packages are defined in this session.')
}) })
}) })

View File

@@ -1,897 +0,0 @@
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)
})
})

View File

@@ -1,49 +1,56 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts' import {
presentDefineCall, presentPackageInspectCall, presentRunCall, presentRuntimeInspectCall,
presentStopCall, presentUndefineCall,
} from '../src/present.ts'
import { setup } from './helpers.ts' import { setup } from './helpers.ts'
/** describe('Cordis tool presenters', () => {
* Render-intent presenters: pure functions of the call args (no I/O, no it('renders runtime and Package inspection as read calls', () => {
* session state — they run on replay too), wired onto the registered tools. expect(presentRuntimeInspectCall({ what: 'api', name: 'tools' })).toEqual({
*/
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', card: 'generic',
kind: 'read', 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', title: 'Inspect cordis runtime: api: tools',
}) })
expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' }) expect(presentPackageInspectCall({ pluginId: 'clock-1', packageId: 'pkg-2' })).toEqual({
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount temporary Cordis Plugin dyn-2' }) card: 'generic',
// Soft validation: presenter args that fail the schema render as no card, never a throw. kind: 'read',
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined() title: 'Inspect Cordis package clock-1/pkg-2',
})
})
it('renders versioned define and lifecycle calls', () => {
expect(presentDefineCall({
plugin: { kind: 'existing', pluginId: 'clock-1' },
name: 'Clock v2',
purpose: 'show seconds',
code: { host: 'HOST', client: 'CLIENT' },
})).toEqual({
card: 'generic',
kind: 'execute',
title: 'Define clock-1 package "Clock v2": show seconds',
rawInput: { host: 'HOST', client: 'CLIENT' },
})
expect(presentRunCall({ pluginId: 'clock-1', packageId: 'pkg-2', mode: 'update' })).toEqual({
card: 'generic', kind: 'execute', title: 'Update clock-1 with pkg-2',
})
expect(presentStopCall({ pluginId: 'clock-1' })).toEqual({
card: 'generic', kind: 'execute', title: 'Stop dynamic plugin clock-1',
})
expect(presentUndefineCall({ pluginId: 'clock-1' })).toEqual({
card: 'generic', kind: 'delete', title: 'Remove dynamic plugin clock-1',
})
})
it('wires the split inspection presenters onto their tools', async () => {
const ctx = await setup()
expect(ctx.tools.get('cordis_runtime_inspect')!.presentCall!({ what: 'tools' })).toMatchObject({
kind: 'read', title: 'Inspect cordis runtime: tools',
})
expect(ctx.tools.get('cordis_package_inspect')!.presentCall!({
pluginId: 'clock-1', packageId: 'pkg-1',
})).toMatchObject({
kind: 'read', title: 'Inspect Cordis package clock-1/pkg-1',
})
}) })
}) })

View File

@@ -1,289 +0,0 @@
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')
})
})

Some files were not shown because too many files have changed in this diff Show More