跳到主要内容
路径文档

动态插件运行时:Host/Client 双半部

一句话版@deepseek-ai/dsh-cordis-host-runner 在进程内存里保存不可变 Package、用 node:vm 跑 Host 半部、用一张 handler 表承接浏览器调用;@deepseek-ai/dsh-cordis-client-runner 在页面里求值闭包、用白名单门面把插件挂进 loader;@deepseek-ai/dsh-client-ui-cordis 把定义、运行状态和批准入口渲染成面板、键控卡片与 @pluginId 补全。

审计基线 0.1.5-alpha.1 @ 5dda764ed3:包名、ctx key、事件名、配置键、错误码与沙箱符号均与官方源码逐点核对。

运行时自省与动态 Cordis 插件 讲的是模型看到的那一面tool-cordis 的七个工具、cordis_define 的参数形状、run/update/stop/undefine 的语义。这一篇往下走一层,讲跑起来的机制——谁保存定义、Host 半部在哪执行、Client 半部怎么进浏览器、UI 怎么显示状态。七个工具的目录不在这里重复,直接看那一篇。

一、四个包的分工

ctx key / 注册点职责
@deepseek-ai/dsh-tool-cordisHost注册到 ctx.tools七个模型工具 + @pluginId 引用注入(见 运行时自省
@deepseek-ai/dsh-cordis-host-runnerHostctx.dynamicCordisRunnerctx.cordisInspect定义注册表、vm 沙箱、Host 半部 fiber 生命周期、invoke handler 表
@deepseek-ai/dsh-cordis-client-runnerClient浏览器 ctx.dynamicCordisRunner求值 Client 半部、挂载/卸载、回答运行请求、守卫门面
@deepseek-ai/dsh-client-ui-cordisClient注册 slots 与 input source全帧面板、四个键控工具卡片、@pluginId 补全

Web 组合里前三个由 packages/bundle/web-app/cordis.patch.yml 装载(cordis-host-runner / cordis-client-runner / ui-cordis 三行);tool-cordis 不在任何出厂组合里,是显式 opt-in——packages/preset/agent-presets/presets/cordis/agent.cordis.yml 里的 tool-cordis 行就是启用方式。没有 Host runner,工具即使被装载也永远等不到 ctx.dynamicCordisRunner

二、cordis-host-runner:定义注册表与 Host 半部沙箱

服务是 DynamicCordisRunnerServicestatic inject = ['tools']),在 declare module '@deepseek-ai/cordis' 里挂成 ctx.dynamicCordisRunner。配置只有一个字段:

配置键默认含义
vmTimeoutMs5000一次同步 vm 求值的最长毫秒数(z.number().min(1).default(5000)

定义注册表

DynamicCordisRegistry 是纯进程内存的 Map,没有任何持久化;DSH 重启即清空。身份由四个自增计数器铸造,格式可预测但全局唯一:

身份铸造方法形态
Plugin IDmintPluginId(prefix)<prefix>-<n>,避开已用后缀
Package IDmintPackageId()pkg-<n>
Plugin Run IDmintPluginRunId()run-<n>
Approval IDmintApprovalRequestId()approval-<n>

一个 DynamicCordisPlugin 持有不可变 packagescurrentPackageId(已提交的运行版本)、nextPackageId(目标版本)、当前 run 和最近一次 latestRun 尝试记录。待批准请求单独放在 pendingRequests,由 armRequest / peekRequest / claimRequest / disarmRequest / pendingRequestFor 管理,首个回答者获胜

Host 半部沙箱

Host 半部不是普通 import,而是在 createSandbox(pluginId, { handle }) 建出的 node:vm 新 realm 里,由 evaluateHostCode(sandbox, code, id, vmTimeoutMs) 当作 async 函数体求值。定义阶段 precheckCode(code, 'code.host' | 'code.client') 只编译不执行,提前暴露语法错误。

沙箱暴露的符号是 HOST_BUILTIN_INSPECTION 的固定清单:

符号用途
ctx受限 Cordis Context:ctx.get / ctx.on / ctx.provide / ctx.effect
harnesshandle(method, fn)defineTool(definition)registerTool(ctx, tool)
console[cordis:<id>] 前缀的直写日志
btoa / atobUTF-8 base64 编解码
TextEncoder / TextDecoder标准编解码构造器

Node 全局被刻意关掉,调用即抛教学式重定向(NODE_API_REDIRECTS):require → 用 ctx 上的 cordis 服务;setTimeout / setInterval / setImmediate / clearTimeout / clearInterval → 声明 inject: ['timer']ctx.timeout / ctx.intervalfetch → 声明 inject: ['web']。另外 DUAL_REALM_INSTANCEOF_PRELUDE 只给 vm 里的 Object / Array / Error 等构造器打 Symbol.hasInstance 补丁,让跨 realm 传入的参数 instanceof 仍然成立。

沙箱隔离全局,但不是安全边界:Node 全局缺失或重定向到 Cordis 服务,Host 半部拿到的门面不含框架内部件,但它声明的服务直达真实运行时。按 README 的说法——把动态包当 bash 权限看待。

注册边界(guard)

guard.ts 是"沙箱产物 → 真实运行时"的唯一闸门:

  • isPlugin(value) / pluginName(plugin):插件形状校验。
  • normalizeHandler(method, fn):把 harness.handle 的入参规范化成 { method, handler },写进本次 run 的 handler 表。
  • sandboxDefineTool / sandboxRegisterToolharness.defineTool 走真实 schema DSL 并打 DYNAMIC_TOOL 标记,harness.registerTool 只接受该标记过的定义。
  • sandboxContext(ctx, reportFailure):给 Host 半部的 ctx 门面——CTX_VERBSeffect / on / once / provide / 定时器族)放行,ctx.get(name) 可选查询,ctx.serviceName 直接访问必须由 fiber 的 inject 声明(declaredInjectsctx.fiber.inject 的键),否则抛带修复提示的守卫错误。
  • guardedPlugin(plugin, reportFailure):把注册动作包一层,post-activation 的拒绝经 reportClientGuardFailure 同类路径上报。

fiber 生命周期

startHostHalf(group, plugin, reportGuardFailure) 把沙箱返回的插件挂进 cordis-dynamic 组 fiber(requireGroup() 懒创建 rootCtx.plugin({ name: 'cordis-dynamic', apply: () => {} })),await fiber.await() 后返回;启动失败会先 fiber.dispose() 再重抛,绝不留半挂载的 fiber。missingServices(ctx, fiber)fiber.inject 的键对照 ctx.get(name),得出"已 settle 但仍在等哪些服务"的 waitingFor 列表——这是合法的 cordis 语义(服务出现即激活),不是错误。

停止不需要专门 helper:Host 半部注册的一切都是它自己 fiber 上的 effect,fiber.dispose() 即可全部回滚。retract(plugin) 的顺序是——删除 plugin.run、逐个执行 handlerDisposersawait fiber.dispose()、最后 emit cordis/dynamic-retract

invoke handler 表

Host 半部通过 harness.handle(method, fn) 注册的 handler 存在 run.handlers: Map<string, DynamicCordisHandler> 里,handlerDisposers 记录每个的撤销函数。浏览器侧 host.call(method, args) 经 Typert Remote 打到 @Remote('invoke')

@Remote('invoke')
async invoke(pluginId, pluginRunId, method, args): Promise<DynamicCordisInvokeResult>

返回四类失败码(DynamicCordisInvokeResult):

code含义
plugin-not-running插件没有活动运行
stale-run调用属于已被替换的旧激活
method-not-found该 method 没注册过
handler-errorhandler 内部抛错(同时 steer 拥有者 Session)

事件与 Remote 动词

Host 半部发出的事件(types.tsinterface Events 声明合并):

事件时机
cordis/request-run一个带 Client 半部的激活需要页面(可能需要人批准)
cordis/request-run-resolved待批准请求离开可回答状态
cordis/dynamic-package一次精确激活已在 Host 生效
cordis/dynamic-retract一次精确激活被撤回
cordis/inspect-query请求浏览器上的 Client inspect provider 做一次只读查询
cordis/inspect-query-resolved查询已结算或取消

对浏览器暴露的 @Remote 动词:

动词作用
runHostHalf批准后(或面板直接触发时)先起 Host 半部
getClientCode把某个精确 run 的 Client 源码交给页面
resolveRequestRun结算一次模型驱动的 Client 激活(首个回答者获胜)
settleUserRun结算面板直接触发的运行
stopFromPanel / undefineFromPanel面板停止/删除,并把结果注入拥有者 Session
inventory全帧 inventory(不含源码)
syncInspectManifest替换 Host 侧的 Client provider 目录镜像
resolveInspectQuery用页面结果结算一次跨面 inspect 查询
reportRenderFailure记录 post-load 渲染崩溃
reportClientGuardFailure记录 post-activation 守卫拒绝
invoke路由 Client → Host handler

工具直接调用的非 Remote 方法有 definerunstopundefinesnapshotreferencelistPluginsinspectPlugininspectPackage

Host 侧的 inspect 目录由 CordisInspectRegistryService 提供,挂成 ctx.cordisInspectregister 收 Host provider、syncClientManifest 镜像页面来的 Client 目录、list 合并两端、query 本地执行或转成 cordis/inspect-query第一方 Host provider 不在本包,而在 tool-cordis/src/providers.ts——见 运行时自省

三、cordis-client-runner:浏览器半部

插件名 cordis-client-runnerinject = ['loader', 'modules', 'slots', 'remote', 'remote.dynamicCordisRunner']。声明 remote.dynamicCordisRunner 会让它停在未激活状态直到 Host 命名空间存在,这样页面永远不会加载一个够不到 Host 半部的 Client 半部。

它向浏览器 ctx 提供 ctx.dynamicCordisRunner: CordisRunnerFace

成员作用
activeRuns每个 Plugin 的在途活动(批准或激活),可观察
lastRunError本页最近一次运行失败
renderFailures本页每个包最近一次渲染崩溃
reconcileApprovals(rows)用 Host inventory 校准待批准行
approve(requestId, approveFutureVersions) / decline(requestId)回答模型请求
startUserRun(request)用户手势直接运行(手势本身即授权)
subscribe / getSnapshot / isLoaded本页已加载集合的观察接口

事件订阅

apply 里用 ctx.remote.$on(...) 订阅 Host 事件($on 直接给监听器 Host 的原参数列表):

事件页面动作
cordis/request-runorchestrator.open(request) 记录待批准活动
cordis/request-run-resolvedorchestrator.close(requestId)
cordis/dynamic-retractrunner.retract(pluginId, pluginRunId) 卸载
cordis/inspect-query在本页 provider 上执行查询并回送
cordis/inspect-query-resolved关闭待回答查询

闭包求值

evaluateClientHalf(pluginId, clientCode, env, styles) 把源码当作 async 函数体,参数是固定符号面:

参数内容
ReactReact 运行时(无 JSX 转换,用 React.createElement
console[cordis:<id>] 前缀的 console,console.error 额外抄进加载报告
stylesDynamicCordisStylesinsert(css) 返回撤销函数,卸载自动清理
host{ call(method, args = null) },路由到本包 Host 半部的 handler
harness教学陷阱:提示 harness.* 属于 Host 半部
setTimeout / setInterval / clearTimeout / clearInterval教学陷阱 → 声明 inject: ['timer']
fetch / require教学陷阱 → 走 Host handler 或 ctx 服务
process / Bufferundefined(让 typeof process 探测保持安全)

DYNAMIC_CLIENT_REDIRECTS 是这些重定向文案的唯一出处;闭包必须 return 一个插件——函数形式 (ctx) => {…},或对象形式 { name, inject, apply(ctx) },否则抛教学错误。isDynamicCordisPlugin 做与 Host 侧镜像的形状校验。

挂载:loader + 模块表

DynamicCordisPackageRunner 是页面侧加载引擎:

  1. 先求值闭包,失败 → cause: 'evaluate'
  2. moduleId = 'dyn/<pluginId>'(同时是 loader entry 名和 fiber 名)。
  3. modules.invalidate(moduleId) 后把工厂写进 window.__ModuleLoader__ 的注册槽(缺这个槽说明不在 web shell 里启动)。
  4. loader.create({ name: moduleId });解析不到 fiber → cause: 'module-import'
  5. await fiber.await();抛错 → cause: 'activate'
  6. 记录 waitingFor = Object.keys(fiber.inject).filter(name => ctx.get(name) === undefined)——已 settle 但在等本页没有的服务,算成功(parked)。

卸载 teardownloader.remove(entryId) + modules.invalidate(moduleId) + styles.dispose():entry 移除会级联 dispose fiber,所以 slot 条目和门面 effect 一起消失。每个包 id 有一条串行队列(enqueue),慢加载不会被后来的请求超车;同一 revision 重复加载是幂等的,新 revision 替换旧的,retract 之后同一 revision 可重新加载。

守卫门面

dynamicCordisContext(ctx, env) 是 Host sandboxContext 的浏览器孪生:

  • ctx.get(name):可选查询,返回真实服务(拒绝返回 Context)。
  • ctx.<service>:只有当 fiber 的 inject 声明过才放行,否则抛"declare it in inject"的教学错误。
  • ctx.<verb>CTX_VERBSeffect / on / once / provide / 定时器族)放行;定时器族还要求声明 inject: ['timer']
  • slots 座位:register 自动分配页面内遮蔽优先级并把行记进 ledger,同时登记组件归属(渲染崩溃要靠它归因)。
  • theme 座位:overrideTokens 的 source 被强制替换成 <pluginId>.<packageId>,动态包无法冒充或挤掉别人的主题层。

渲染崩溃经 slots.onEntryError 观察:只有本 runner 挂载的组件才算数,报告带 slotabdicated(崩溃是否让条目退场)和面向作者的 message;一份观察两个出口——reportRenderFailure 上报 Host(给模型和 cordis_inspect_self),本页 renderFailures 给面板。

编排:先 Host 后 Client

CordisRunOrchestrator.drive(plan) 的顺序是固定的:

Host 半部先起,所以 Host 失败会在浏览器还没动之前短路;随后取源码、加载 Client 半部、只发一次结算。成功携带已加载的 pluginRunId 和 parked 服务名;失败只带一个 reason——rejected(用户拒绝)、host-half-failedclient-half-failed,后两者附带失败阶段(evaluate / module-import / activate)和具体 message。同一 Plugin 同时只有一次在途编排(inFlight 去重)。

四、ui-cordis:浏览器表面

inject = ['slots', 'locale', 'inputTriggers', 'remote', 'remote.dynamicCordisRunner', 'dynamicCordisRunner'],注册五处表面:

座位key内容
sidebar.footer.actionid cordis-panel全帧面板:徽标计数 + 每个定义一行
tool.call.toolviewcordis_define只读定义卡(Host/Client 源码页签)
tool.call.toolviewcordis_run运行卡 + 子座位 tool.view.cordiskind: 'keyed', scope: 'session'
tool.call.toolviewcordis_stop / cordis_undefine紧凑动作行
inputTriggers sourcetrigger @,name cordis@pluginId 补全

运行/停止开关在面板行上,不在定义卡上。 CordisDefineRow 是纯记录:模型写的 name、purpose、源码和当前状态,没有开关、没有批准按钮,只指向面板。面板行的 onRun / onStop / onRemove / onApprove / onDecline 才是生命周期操作,经 CordisDynamicPort 打到 stopFromPanel / undefineFromPanel / inventory,运行/批准走浏览器 runner。

每行显示两个独立事实——Host 在跑什么、本页加载了什么。cordisVisibleStatus(row, packageId, loaded) 只产出三种读数:idle(没有该 package 的活动 run)、client-pending(Host 已起、本页还没加载)、running(无 Client 半部,或本页已加载该 pluginRunId)。所以刷新后的页面会先给"重新加载到本页",而纯 Host 定义直接读作 running 并只提供停止。

面板数据来自 createCordisInventory:单个全局 inventory 读、single-flight、按公告重读而非就地打补丁。cordis/dynamic-package / cordis/dynamic-retract / cordis/request-run / cordis/request-run-resolved 都会触发刷新;connection/resetreset() 丢弃在途读取,避免重连后发布旧 Host 的行。卡片视图模型(cordisDefineCard / cordisRunCard / cordisActionCard)从冻结的调用/结果切片派生,所以回放渲染同一张卡。

五、双半部契约:一个 Package 必须导出什么

Host 半部(code.host

  • 普通 JavaScript async 函数体,必须 return 插件:(ctx) => {…}{ name, inject, apply(ctx) {…} }
  • 只能用沙箱符号:ctxharnessconsolebtoa/atobTextEncoder/TextDecoder
  • 对外暴露调用面:harness.handle(method, fn),供 Client 半部 host.call 使用。
  • 需要模型可见工具时:harness.defineTool(definition) + harness.registerTool(ctx, tool)
  • 需要宿主能力时:在返回的插件上声明 inject: ['fs' | 'web' | 'bash' | 'timer' | …],再经 ctx.get / ctx.<service> 使用;未声明的服务读取被门面拒绝。

Client 半部(code.client

  • 同样必须 return 插件;对象形式的 inject服务门禁的唯一声明点,纯函数形式因此够不到任何服务。
  • 闭包符号固定:Reactconsolestyleshostharness / 浏览器定时器 / fetch / require 是教学陷阱,process / Bufferundefined
  • 调 Host 半部:host.call(method, args),省略参数时 handler 收到 null(线上只传 JSON,undefined 不是 JSON)。
  • 贡献 UI:注册进 slots;样式用 styles.insert(css);主题用 theme.overrideTokens(source 被强制替换)。

和静态 cordis 插件的差别

静态 cordis 插件动态 Package
代码位置包里的 src/index.ts,编译进 lib/cordis_define 提交的函数体,只在进程内存
装载方式cordis.yml / patch 行,启动时装载cordis_run 按 Package 版本激活
生命周期随组合装载/卸载,重启恢复stop 只停运行、undefine 永久删除;重启全部丢失
权限边界完整 ctx,可 import 任意模块Host 半部 vm 沙箱 + Node API 重定向;Client 半部闭包 + 白名单门面
作用域进程/组合作用域归属定义它的 Session,其他 Session 读作不存在
版本npm 包版本不可变 Package + currentPackageId / nextPackageId
信任与部署同权视同 bash 权限

两者最终汇入同一条 Cordis 生命周期:静态插件走 ctx.plugin(...),动态 Host 半部走 group.ctx.plugin(guardedPlugin(...)) 挂进 cordis-dynamic 组,Client 半部经 loader.create 挂进模块表——所以 activation gating、fiber effect 清理和状态投影的语义是同一套。插件形态与 patch 应用序见 插件解剖,手写一个静态插件见 写第一个插件,双面边界的传输层见 Web UI 架构

六、源码佐证

位置符号 / 事实
packages/extensions/cordis-host-runner/src/index.tsDynamicCordisRunnerServicestatic inject = ['tools'])、Config.vmTimeoutMs 默认 5000ctx.dynamicCordisRunner 声明合并、define / run / stop / undefine / snapshot / reference / listPlugins / inspectPlugin / inspectPackagerequireGroup()cordis-dynamicretracthandlerDisposers → fiber.dispose → cordis/dynamic-retract
packages/extensions/cordis-host-runner/src/index.ts:226,324,383,412,437,479,497,510,524,683,717,740十二个 @Remote 动词:undefineFromPanelrunHostHalfgetClientCoderesolveRequestRunsettleUserRunstopFromPanelsyncInspectManifestresolveInspectQueryinventoryreportRenderFailurereportClientGuardFailureinvoke
packages/extensions/cordis-host-runner/src/types.tsCordisDynamicPluginId / CordisDynamicPackageId / CordisDynamicPluginRunId / ApprovalRequestId / CordisInspectRequestIdCordisDynamicRunModeCordisRunStatusDynamicCordisInvokeResult 四类 code、interface Events 的六个 cordis/* 事件
packages/extensions/cordis-host-runner/src/registry.tsDynamicCordisRegistrymintPluginId<prefix>-<n>)、mintPackageIdpkg-<n>)、mintPluginRunIdrun-<n>)、mintApprovalRequestIdapproval-<n>)、armRequest / peekRequest / claimRequest / disarmRequest / pendingRequestForDynamicCordisHandler
packages/extensions/cordis-host-runner/src/sandbox.tsHOST_BUILTIN_INSPECTIONctx / harness / console / btoa / atob / TextEncoder / TextDecoder)、createSandboxevaluateHostCodeprecheckCodeNODE_API_REDIRECTSDUAL_REALM_INSTANCEOF_PRELUDE
packages/extensions/cordis-host-runner/src/guard.tsnormalizeHandlersandboxDefineToolsandboxRegisterToolsandboxContextdeclaredInjectsguardedPluginisPluginpluginNameCTX_VERBS / TIMER_VERBSDYNAMIC_TOOL
packages/extensions/cordis-host-runner/src/lifecycle.tsstartHostHalf(组内 guardedPlugin 子 fiber,失败先 dispose)、missingServices
packages/extensions/cordis-host-runner/src/inspect-registry.tsCordisInspectRegistryServicectx.cordisInspect 声明合并、register / syncClientManifest / list / query / resolveClientQuery
packages/extensions/cordis-client-runner/src/client/index.tsname = 'cordis-client-runner'inject = ['loader','modules','slots','remote','remote.dynamicCordisRunner']CordisRunnerFace、浏览器 ctx.dynamicCordisRunner、五个 ctx.remote.$on('cordis/…')
packages/extensions/cordis-client-runner/src/client/evaluator.tsevaluateClientHalf、闭包参数 ['React','console','styles','host','harness', …traps, 'process','Buffer']DYNAMIC_CLIENT_REDIRECTSDynamicCordisStyles.insertisDynamicCordisPlugin
packages/extensions/cordis-client-runner/src/client/runtime.tsDynamicCordisPackageRunnermoduleIdOfdyn/<id>ModuleLoaderSink.__ModuleLoader__loader.create / loader.removemodules.invalidateDynamicCordisLoadErrorCauseevaluate / module-import / activate)、waitingFor
packages/extensions/cordis-client-runner/src/client/guard.tsdynamicCordisContextCTX_VERBS / TIMER_VERBSguardedSlots(优先级 + ledger + claim)、guardedTheme(source 强制为 <pluginId>.<packageId>
packages/extensions/cordis-client-runner/src/client/orchestrator.tsCordisRunOrchestratoractiveRuns / lastRunErrorapprove / decline / startUserRun / reconcileApprovalsdrive() 的 Host→源码→Client→单次结算顺序
packages/extensions/cordis-client-runner/src/client/providers.tsclientInspectProviders 的五个 provider id:Service / Event / Builtin / Slots / ThemeCLIENT_BUILTIN_INSPECTION
packages/extensions/cordis-client-runner/src/client/timer.tsClientTimerServiceprovideClientTimertimeout / interval / throttle / debounce / setTimeout / setInterval
packages/extensions/ui-cordis/src/client/index.tsinject 六项、sidebar.footer.action id cordis-paneltool.call.toolviewcordis_define / cordis_run / cordis_stop / cordis_undefinechildren: { 'tool.view.cordis': { kind: 'keyed', scope: 'session' } }@ input source
packages/extensions/ui-cordis/src/client/slots.tsSlotMap['tool.view.cordis']CordisPanelFaceCordisRunCardFaceCordisToolViewOwnerProps
packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx只读定义卡、SourceTab = 'client' | 'host'、无运行开关
packages/extensions/ui-cordis/src/client/status.tscordisVisibleStatus'idle' | 'client-pending' | 'running'packageOf
packages/extensions/ui-cordis/src/client/dynamic-port.tsCordisDynamicPort.stop / remove / inventory
packages/extensions/ui-cordis/src/client/inventory.tscreateCordisInventoryCordisInventorySnapshot、single-flight refreshresetretire
packages/extensions/ui-cordis/src/client/card-model.tscordisDefineCard / cordisRunCard / cordisActionCardCordisToolState
packages/bundle/web-app/cordis.patch.yml:122,198,269三行装载:@deepseek-ai/dsh-cordis-host-runner@deepseek-ai/dsh-cordis-client-runner@deepseek-ai/dsh-client-ui-cordis
packages/preset/agent-presets/presets/cordis/agent.cordis.yml:246tool-cordis 的显式 opt-in 行

七、验证

# 1. 三个 runner/UI 包是否在 Web 组合里(tool-cordis 不在出厂组合,需 opt-in)
dsh web --dump-config | grep -E "cordis-(host|client)-runner|client-ui-cordis"

# 2. Host 侧的 Remote 动词与事件名
grep -n "@Remote(" packages/extensions/cordis-host-runner/src/index.ts
grep -rn "'cordis/" packages/extensions/cordis-host-runner/src

# 3. 沙箱符号与 Node API 重定向
grep -n "name: '" packages/extensions/cordis-host-runner/src/sandbox.ts
grep -n "NODE_API_REDIRECTS" -A 12 packages/extensions/cordis-host-runner/src/sandbox.ts

# 4. 浏览器侧的闭包符号面与守卫白名单
grep -n "const parameters" packages/extensions/cordis-client-runner/src/client/evaluator.ts
grep -n "DYNAMIC_CLIENT_REDIRECTS" -A 10 packages/extensions/cordis-client-runner/src/client/evaluator.ts
grep -n "CTX_VERBS" packages/extensions/cordis-client-runner/src/client/guard.ts

# 5. 运行时观察:打开 Web UI,侧边栏底部 Cordis 徽标计数 = 运行中 + 待批准;
# 面板里每个定义一行,行上才是 run / stop / remove 开关。
# 刷新页面后,纯 Host 定义仍读作 running,带 Client 半部的定义先显示"加载回本页"。

下一步