夜雨聆风学习资料网

ARTICLE · 1050491

DeepSeek Harness 源码-扩展系统与自修改(extensions)

DeepSeek Harness 源码-扩展系统与自修改(extensions)

DeepSeek Harness 源码解析系列

第 38 讲:扩展系统与自修改(extensions)

基于 DeepSeek Harness 源码 · 2026-09-21

💡 本讲一句话:前 37 讲分析的都是"Agent 怎么干活",这一讲是"Agent 怎么给自己加新工具"——模型现场写 JavaScript,Host 半在 node:vm 沙箱里跑、Client 半在浏览器闭包里跑,版本不可变、升级要审批、失败可回滚。读完你会明白:自修改不是 eval 一把梭,而是一整套身份/版本/状态机设计。

一、"一切皆插件"的最后一块拼图:Agent 修改自己的运行时

DeepSeek Harness 构建在 Cordis 之上,核心信条是 everything is a plugin——工具、服务、UI 全是插件。但前面所有插件都是人写死的。extensions 子系统补上了最后一块:让模型在对话中现场编写插件代码,动态挂载到当前正在运行的 DSH 进程里,用完还能撤掉。

源码位于 packages/extensions/,由四个包组成(仓库 README 原话:"the agent modifies its own runtime"):

角色
挂载点
tool-cordis
模型可见的 7 个工具 + @pluginId 注入
ctx.tools / ctx.systemPrompt
cordis-host-runner
版本注册表、node:vm 沙箱、run 状态机
ctx.dynamicCordisRunner
cordis-client-runner
浏览器半:闭包求值 + host.call RPC
client face(dshClient)
ui-cordis
全局面板 + define/run 卡片(React)
client slots

一个关键设计决策:每个动态包都是"双半"(dual-half)——Host 半跑在 DSH 的 Node.js 进程里,能碰文件、网络、Agent;Client 半跑在浏览器页面里,负责 UI。两半之间只能走 JSON RPC。这个拆分贯穿本讲所有代码。

先立一个预期管理:sandbox.ts 的模块注释写得很直白——这套沙箱"keeps cooperative packages inspectable and disposable but is not containment: host-realm helper functions remain an escape route"。它防的是模型手滑(误用 API、泄漏状态),不是防恶意代码。这个定位决定了后面所有守卫的形态:报错信息都是"教学式"的,告诉模型正确的做法。

二、模型的七件工具:inspect → define → run → stop/undefine

tool-cordis/src/index.ts(530 行)的 apply() 是入口:先注入一段 107 行的系统提示词(CORDIS_SYSTEM_PROMPT,教模型完整工作流和常见错误),再注册 Inspect Provider,最后逐个注册工具。注意注册方式——全部走 ctx.effect() / ctx.tools.register(),符合仓库"registrations are effects"的约定:插件卸载时自动反注册。

📄 packages/extensions/tool-cordis/src/index.ts (第 35-42 行)

export function apply(ctx: Context) {  // Cordis Loader 加载本插件时调用,ctx 是运行时上下文  ctx.systemPrompt.section({ name: 'tool:cordis', order: 115, text: CORDIS_SYSTEM_PROMPT })  // 注入专属提示词段:order=115 决定它在 system prompt 中的位置;内容是完整工作流 + 高频错误清单  for (const provider of hostInspectProviders(ctx)) {  // 遍历第一方 Host inspect provider(Service / Event / Builtin / Tool 四个)    ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`)  // effect() 注册:插件被 dispose 时自动反注册,不留孤儿条目  }  ctx.tools.register(defineTool({  // 开始逐个注册模型可见工具;register 返回 disposer(仓库约定:一切注册都是可撤销的 effect)    name: 'cordis_inspect_list',  // 第一个工具:列出 Host 已知的所有 inspect provider,是写代码前的"能力发现"入口

为什么这样设计:把"提示词 + 工具 + inspect 目录"三件事放在同一个插件里,是因为它们共同构成一个自洽的编程环境——模型看到的 API 文档(inspect)、能调用的动词(tools)、行为约束(prompt)必须来自同一份事实源。如果三者分属不同包,版本漂移时模型就会对着过时的签名写代码。

七个工具按生命周期排列:

cordis_inspect_list / cordis_inspect_query(发现)

职责 列出 Host/Client 的 inspect provider;按 manifest 声明的方法执行只读查询(Service 签名、Event 契约、Slot 树、主题 token)。

关键行为 渐进式发现:不带 input 查"紧凑目录",带精确名字才返回完整契约——控制 token 消耗。Client 侧查询会挂起等待页面应答。

cordis_inspect_self(自省)

职责 三级递进查看本 Session 的动态对象:无 ID 列 Plugin 摘要;给 pluginId 看版本指针 + Package 列表;pluginId+packageId 才返回源码和运行时诊断。

关键行为 只读,不执行代码、不动版本指针。修复失败前必须先查它拿 message/stack。

cordis_define(定义)

职责 定义一个不可变 Package:新 Plugin 给 3-6 字母语义前缀,Host 铸造最终 ID;改已有 Plugin 用 kind:"existing" 追加版本。

关键行为 只做参数校验 + 语法预检 + 记录源码——不请求审批、不执行 apply、不动 currentPackageId。成功返回 pluginId/packageId,下一步才 cordis_run。

cordis_run(激活)

职责 激活某个精确 Package。mode:"run" = 首次/重启当前/回滚;mode:"update" = 切换到另一个版本。

关键行为 含 Client 半时创建审批请求,立即返回 awaiting-approval;工具内绝不等待最终结果——异步结局通过 steering context 回报。

cordis_stop(暂停)

职责 停掉当前 Run、取消未决审批,但保留 Plugin、所有 Package 版本、授权和版本指针。

关键行为 幂等:停一个已停止的 Plugin 也成功。临时禁用用它,永久删除用 undefine。

cordis_undefine(删除)

职责 永久移除 Plugin:先停 Run、取消审批,再删光所有 Package、授权和版本指针。

关键行为 之后 pluginId/packageId/@引用全部失效,历史卡片只留"Plugin removed"记录。需要回滚能力时禁用它。

🔑 设计要点:define 和 run 被刻意拆成两个工具。定义是廉价的(只进内存注册表),激活是有副作用的(要审批、占资源)。拆开之后,模型可以"先定义好几个候选版本再挑一个跑",而不会每试一次都触发用户审批。

三、define:不可变版本 + 编译期预检

cordis-host-runner/src/index.ts(1274 行)的 DynamicCordisRunnerService.define() 是定义入口。核心语义:Package 是不可变的版本单元——改代码 = 追加新 Package,永远不覆盖旧版本。

📄 packages/extensions/cordis-host-runner/src/index.ts (第 151-202 行,节选)

define(request) {  // 模型调 cordis_define:为新 Plugin 建首个 Package,或给已有 Plugin 追加一个版本  const name = request.name.trim()  // 元数据先 trim;空值在下面直接拒绝——fail loud,不静默接受垃圾数据  if (name.length === 0) throw new Error('cordis_define needs a non-empty `name`')  // 名字是审批卡片和 UI 上用户看到的标识,空名会让"这是什么"无从回答  if (request.code.host === undefined && request.code.client === undefined) {  // 至少提供一半代码,否则这个 Package 没有任何可执行内容    throw new Error('cordis_define needs `code.host`, `code.client`, or both')  // 两个半都缺 = 空包,直接拒绝而不是存一个永远跑不起来的版本  }  if (request.code.host !== undefined) precheckCode(request.code.host, 'code.host')  // 编译期语法预检:解析不过的代码根本不进注册表,把错误挡在 define 阶段  if (request.code.client !== undefined) precheckCode(request.code.client, 'code.client')  // Client 半同样预检——两半用同一套包装编译,行号语义一致  if (request.plugin.kind === 'new') {  // 分支 A:新建 Plugin    const prefix = request.plugin.idPrefix.trim()  // 取模型给的语义前缀并 trim——下面用正则严格校验形状    if (!/^[a-z]{3,6}$/.test(prefix)) {  // 前缀形状校验:语义可读(chart)+ 长度受限,防止模型塞进整句英文      throw new Error('cordis_define `plugin.idPrefix` must contain 3–6 lowercase English letters')  // 拒绝时把合法形状写进错误信息——模型改一次就能过    }    const pluginId = CordisDynamicPluginId(this.registry.mintPluginId(prefix))  // 铸造形如 chart-1 的进程内唯一 ID(前缀 + 自增序号)    this.registry.add(plugin)  // 写入进程内存注册表;不碰磁盘——定义不跨重启存活是刻意设计,避免"僵尸扩展"  } else {  // 分支 B:给已有 Plugin 追加版本    const found = this.registry.get(request.plugin.pluginId)    if (found === undefined || found.sessionId !== request.sessionId) {  // Session 归属校验:只能改自己 Session 拥有的 Plugin,跨 Session 引用直接拒绝      throw new Error(missingPluginMessage(request.plugin.pluginId))    }  }  const packageId = CordisDynamicPackageId(this.registry.mintPackageId())  // 每个版本铸造新的不可变 ID(pkg-N)——旧版本永远原样保留,这是回滚能力的来源  plugin.packages.set(packageId, definition)  // 追加进版本 Map。define 到此为止:不请求审批、不执行 apply、不动 currentPackageId

为什么这样设计:"不可变版本 + 追加"是 Git 式思维——currentPackageId(最近一次完全成功的版本)和 nextPackageId(正在尝试的目标)两个指针分离,意味着任何一次失败的 update 都不会破坏可用状态:旧 Run 还在跑,回滚就是再 run 一次 current。对比"覆盖式更新",这套设计把"升级失败"从事故降级成了可恢复事件。

预检(sandbox.ts)的精髓在于:用和运行时完全相同的包装方式编译,这样 define 阶段报的行号和 run 阶段一致;而且报错是"教学式"的——不只说哪错了,还给出可直接抄的修法。

📄 packages/extensions/cordis-host-runner/src/sandbox.ts (第 206-214 行)

export function precheckCode(code: string, half: 'code.host' | 'code.client'): void {  // define 时的编译期检查;与运行时同一个 async 函数包装,保证行号一致  try {  // 只捕获"解析失败"这一类错误,其余异常原样上抛    new Script(`(async () => {\n${code}\n})()`, { filename: `cordis-dyn-${half}.js` })  // 构造 vm.Script 只解析不执行——模型代码被包成 async 函数体,filename 让错误栈可读  } catch (error) {  // 编译期唯一预期失败是 SyntaxError(在沙箱 realm 里构造)    if (!isSyntaxError(error)) throw error  // 非语法错误原样抛出——不伪装成"代码写错了"    throw new Error(parseErrorMessage(half, syntaxErrorContext(error)))  // 转成教学式错误:保留"出错行 + caret"前缀,再附修复提示  }}

📄 packages/extensions/cordis-host-runner/src/sandbox.ts (第 179-194 行,节选)

const offendingLine = context.split('\n')[1] ?? ''  // 只检查"出错那一行"——代码别处的字符串里出现 ` as ` 不能误触发 TS 提示if (/\bas\b/.test(offendingLine)) {  // 启发式:模型写了 TypeScript 类型断言(最高频错误之一)  return ... 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'    + '  ✗ { type: \'text\' as const, text: x}\n'    + '  ✓ { type: \'text\', text: x }'  // 给出可直接复制的修法——错误信息本身就是修复补丁}return ... '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'  // 第二高频错误:对象结尾多写一个右括号

为什么这样设计:这是"把模型当初级工程师带"的工程哲学。沙箱不转译 TypeScript/JSX(prompt.ts 明确禁止),所以错误信息必须承担教学职责:指出根因 + 给正确写法。注释里还藏着一个细节——TS 启发式只作用于出错行,因为整篇代码扫描会把字符串字面量里的 as 误判成类型断言,产生误导性提示。

四、node:vm 沙箱:教学陷阱而非安全边界

sandbox.ts(238 行)为 Host 半构建执行环境。策略是"白名单符号面 + 教学陷阱":沙箱全局只有带标签的 console、harness 注册动词、编码原语,以及一组调用即抛错并教你正确做法的陷阱函数。

📄 packages/extensions/cordis-host-runner/src/sandbox.ts (第 96-119 行,节选)

const NODE_API_REDIRECTS: Record<string, string> = {  // 每个被禁的 Node API 都映射一条"重定向"文案——不只说 no,还告诉模型去哪  require: 'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files...',  // 模块系统换成 Cordis Service:可观测、可策略检查、随 fiber 生命周期清理  setTimeout: TIMER_REDIRECT,  // 原生定时器被禁——必须用 fiber effect(ctx.timeout),这样 stop/update/undefine 时自动清理,不留幽灵任务  fetch: 'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web ...',  // 网络走真实 Service:有超时、可取消、进审计流}function nodeApiTraps(): Record<string, () => never> {  // 把每条重定向变成一个"调用即抛错"的函数  const traps: Record<string, () => never> = {}  // 陷阱表:键是全局名,值是教学错误工厂  for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {  // 逐条生成——新增被禁 API 只需在 NODE_API_REDIRECTS 加一行    traps[name] = () => { throw new Error(`${name} is not available in the dynamic package sandbox — ${redirect}`) }  // fail loud + teach:模型读到错误就能自我修正,而不是静默失败  }  return traps  // 返回给 createSandbox 展开进沙箱全局}

📄 packages/extensions/cordis-host-runner/src/sandbox.ts (第 129-145 行)

export function createSandbox(id: string, harnessExtras = {}): object {  // 为一个 Host 半构建 vm context;id 用作 console 标签和文件名主干  const sandbox = {    ...nodeApiTraps(),  // require/setTimeout/fetch 等变成教学陷阱:调用即抛"正确做法"错误    console: taggedConsole(id),  // [cordis:<id>] 前缀的 write-through console——输出直达宿主终端,不缓冲进工具结果(监听器触发时 run 早已返回)    harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool, ...harnessExtras },  // 暴露给包代码的唯一"注册动词";handle(私有 RPC)按包注入    btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'),  // 裸 vm context 缺 Web 编码 API——用宿主闭包补上,而不是把 Buffer 本体塞进去    atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'),    TextEncoder,  // 同理:模型需要 base64/UTF-8 能力,但拿不到 Node 内建对象本身(闭包是受控的,Buffer 不是)    TextDecoder,  }  createContext(sandbox)  // contextify:这个对象从此成为沙箱的全局 globalThis  patchDualRealmInstanceof(sandbox)  // 修补跨 realm instanceof(见下节)——不补的话 vm 里的 Error 过不了宿主侧的 instanceof 检查  return sandbox}

为什么这样设计:三个值得注意的选择。其一,陷阱是函数而不是 undefined——注释解释得很清楚:数据型全局(如 process)保持 undefined,因为"抛错的访问器会在 typeof process 特性探测时就炸掉";而函数型 API 可以安全地做成"调用才炸"。其二,btoa/atob 用宿主闭包实现而非暴露 Buffer——能力最小化:模型能编码解码,但拿不到 Node 内建对象本身。其三,模块注释开宗明义:这不是 containment(防恶意),host-realm helper 函数本身就是逃逸路径;它的目标是让"合作型包"可检查、可撤销。

五、跨 realm 守卫:服务永远不能把 Context 递进沙箱

guard.ts(836 行)处理最微妙的问题:两个 JS realm 之间的值边界。vm 沙箱里的对象和宿主对象是不同的 Object/Errorinstanceof 会静默失败;更危险的是——如果某个 Service 的返回值里藏着一个 cordis Context,包代码就拿到了一个未经守卫的运行时句柄,整个沙箱形同虚设。

📄 packages/extensions/cordis-host-runner/src/guard.ts (第 669-697 行)

function denyContext(value: unknown, service: string, reportFailure): unknown {  // 一条铁律:Service 绝不能把 Context 递给沙箱代码  if (value instanceof Context) {  // 返回的 Context = 一个未经守卫的运行时句柄——正是这个 façade 要堵住的逃逸路径    return rejectGuard(reportFailure, `service "${service}" returned a cordis Context ...`)  // fail loud,并把失败 steer 给所属 Agent,让模型知道为什么被拒  }  return value  // 普通数据原样通过}function guardedService(service: object, name: string, reportFailure): unknown {  // 每个注入的 Service 都包一层 Proxy  return new Proxy(service, {    get(target, prop) {      const value = Reflect.get(target, prop, target) as unknown      if (typeof value !== 'function') return denyContext(value, name, reportFailure)  // 数据成员立即检查(Service 可能直接返回 Context 对象)      return (...args: unknown[]): unknown => {  // 方法调用转发给真实实例……        const result = Reflect.apply(value, target, args) as unknown        if (result instanceof Promise) return result.then(v => denyContext(v, name, reportFailure))  // ……Promise 在 resolve 时再查——异步返回的逃逸同样堵死        return denyContext(result, name, reportFailure)      }    },  })}

为什么这样设计:守卫点选在"值离开 Service 的那一刻",而不是在包代码使用处——前者是单点强制,后者依赖模型自觉。注释还解释了为什么 denyContext 和浏览器半的孪生实现刻意重复而不抽公共包:"Moving the rule into a shared package would move a security invariant out of the halves that enforce it"——安全不变量必须留在执行它的那一侧,因为两侧的 Context 类根本不是同一个(两个编译程序各自 merge 了不同的 service key)。这是"重复优于错误抽象"的教科书案例。

另一条边界规则:跨 realm 的值必须 JSON 克隆normalizeHandler()(guard.ts 604-617 行)对 harness.handle 的返回值做 cloneJson——"a VM-realm object would otherwise escape the wire's plain-object contract"。工具 execute 的返回、render/presentationMeta 的结果同样全部过一遍 JSON round-trip:非 JSON 或形状不对的输出让那一次调用失败,而不是污染 session log。

六、run:审批 + 双半激活的状态机

run()(index.ts 248-312 行)是整个子系统最复杂的路径。关键约束:工具调用绝不能阻塞等待浏览器——审批和 Client 激活都发生在当前 turn 结束之后,所以 run 只负责"创建请求 + 返回回执",最终结果走 steering context 异步回报。

📄 packages/extensions/cordis-host-runner/src/index.ts (第 248-312 行,节选)

async run(agent, pluginId, packageId, mode, signal?) {  // 模型调 cordis_run:激活一个精确的 Package 版本  const plan = this.resolvePlan(agent, pluginId, packageId, mode)  // 校验归属 + mode 语义(run vs update),非法组合返回可操作的错误信息而非裸异常  if (!plan.ok) return plan.response  // 计划解析失败直接透传结构化拒绝——模型拿到的是 reason + message,能据此改参数重试  if (this.registry.pendingRequestFor(pluginId) !== undefined) {  // 每个 Plugin 同时只允许一个在途转换——杜绝双激活竞态    return { ok: false, reason: 'transition-in-flight', message: `...already has a pending run request` }  // 拒绝第二个并发请求,而不是排队或覆盖  }  const attempt = this.createAttempt(plan)  // 铸造一次"尝试"记录(pluginRunId + host/client 状态)——后续所有状态更新都挂在它上面  plan.plugin.nextPackageId = packageId  // "next" 指向目标版本;current 保持不动,直到完全成功才切换  if (plan.definition.clientCode === undefined) {  // Host-only Package:没有浏览器参与,无需审批,直接激活    const started = await this.activate(plan, undefined, false, attempt)    if (started.ok) return this.runResponse(plan.plugin, started)    this.failAttempt(plan.plugin, attempt, 'host-load', started)  // 失败记录在 attempt 上(phase + message/stack),供 inspect_self 诊断    return { ...started, reason: 'host-half-failed' }  }  const requestId = ApprovalRequestId(this.registry.mintApprovalRequestId())  // 有 Client 半 → 铸造审批请求 ID,UI 凭它渲染审批卡片  const requiresApproval = !plan.plugin.clientVersionUpdatesApproved && !plan.plugin.approvedClientPackages.has(packageId)  // 两级授权:单版本(单勾)或该 Plugin 未来所有版本(双勾),已授权的直接放行  attempt.status = requiresApproval ? 'awaiting-approval' : 'starting-host'  // 状态机入口;工具立即返回,绝不在此等待浏览器  this.ctx.emit('cordis/request-run', { requestId, ..., name: plan.definition.name, purpose: plan.definition.purpose })  // 事件携带用户可见的元数据——审批卡片展示的是"这个包是干什么的",不是 ID  return { ok: true, status: requiresApproval ? 'awaiting-approval' : 'starting', ... }  // 模型拿到回执;最终结局稍后通过 steering context 到达

版本指针的语义是理解状态机的钥匙:

currentPackageId(当前版本)

含义 最近一次完全成功的 Package——回滚锚点。

何时变化 只在 commitActivation(双半全部成功)时前进;stop、update 开始、失败都不清除它。

nextPackageId(目标版本)

含义 等待审批 / 正在尝试 / 等 Client 激活 / 最近失败的版本。

何时变化 run/update 发起时写入;commitActivation 成功后删除(与 current 合并)。

pluginRunId(激活尝试)

含义 一次激活尝试的身份,串起审批、Host/Client 加载、私有 RPC、Run 卡片和错误。

何时变化 每次 activate 铸造新的;stale-run 检查靠它拒绝旧页面的 RPC。

审批授权(grants)

含义 单勾 = 只授权当前 Package;双勾 = 该 Plugin 未来所有版本(clientVersionUpdatesApproved)。

何时变化 用户批准时写入;技术失败不清除授权——修好重试不用再审批一次。

状态机的"提交点"只有一个——commitActivation(),这是全文件最关键的 12 行:

📄 packages/extensions/cordis-host-runner/src/index.ts (第 981-993 行)

private commitActivation(plugin, run) {  // 唯一提交点:只在 Host + Client 双半全部成功后调用  plugin.currentPackageId = run.packageId  // "current" 在这里原子前进——stop/失败从不触碰它,回滚永远有已知良好的版本可退  delete plugin.nextPackageId  // 清除待决目标;下次尝试会重新铸造  delete run.startedForRequest  // 解除与审批请求的绑定(授权本身留在 approvedClientPackages 里)  const attempt = plugin.latestRun  if (attempt?.pluginRunId === run.pluginRunId) {  // 只有当它仍是最新一次尝试时才更新状态——更新的 run 可能已经取代了它    attempt.status = ... 'waiting' : 'running'  // 某一半因 inject 未满足而 parked 时是 waiting 而非 failed——Cordis 会在服务出现后自动重新激活    delete attempt.approvalRequestId  // 审批已消费;attempt 上的临时字段全部清理,状态回到"干净"形态  }}

为什么这样设计:"Publish state only at its commit point"(仓库 packages/AGENTS.md 的约定)在这里体现得最彻底:currentPackageId 是唯一的真相源,所有 UI、steering 消息、inspect_self 都从它派生。失败路径走 failAttempt()——只写 attempt.error(phase + message + stack),不动 current;然后 steerRunOutcome() 把诊断推给模型:"Inspect the failed Package, correct it on the same Plugin when needed, and retry the activation autonomously"。失败不是终点,是模型的下一轮输入。

七、浏览器半:闭包求值 + host.call RPC

Client 半跑在页面里,没有 node:vm。解法(cordis-client-runner/src/client/evaluator.ts):用闭包参数当符号面——把 new Function(...parameters, body) 的参数列表变成包代码能看到的"全局变量",其中一半是真实注入物(React、console、host),另一半是教学陷阱。

📄 packages/extensions/cordis-client-runner/src/client/evaluator.ts (第 166-215 行,节选)

export async function evaluateClientHalf(pluginId, clientCode, env, styles) {  // 浏览器半求值:页面里没有 vm,用闭包参数当符号面  const traps = closureTraps()  // setTimeout/fetch/require/process/Buffer 变成"调用即抛教学错误"的参数——遮蔽掉环境全局而不碰页面本身  const parameters = ['React', 'console', 'styles', 'host', 'harness', ...Object.keys(traps), 'process', 'Buffer']  // 参数列表就是包代码看到的符号面:React 是注入的(JSX 被禁,必须 React.createElement)  let closure: (...args: unknown[]) => Promise<unknown>  // 待求值的闭包工厂;声明在 try 外,catch 分支也能引用  try {    const factory = new Function(...parameters, `return (async () => {\n${clientCode}\n})()`)  // 与 Host 预检完全相同的包装——两半行号一致,错误信息可互换理解    closure = factory  // 编译成功才赋值;SyntaxError 走下面的教学分支  } catch (error) {    if (!(error instanceof SyntaxError)) throw error  // 非语法错误(如内存问题)原样上抛,不伪装成"代码写错了"    throw new Error(`client half failed to parse in this browser: ${error.message}...`)  // 引擎分歧兜底:浏览器只给 message 没有 caret 前缀,补上"纯 JS、无 JSX"的提示  }  const host = {    call: (method: string, args: unknown = null): Promise<unknown> => env.invoke(method, args),  // Client→Host RPC;默认参数是 null 而非 undefined——undefined 不是 JSON,wire 传不过去  }  const returned = await closure(React, taggedConsole(pluginId, ...), styles, host, harnessTrap(), ...Object.values(traps), undefined, undefined)  // 注入符号面:harness 在浏览器侧是 Proxy 陷阱(碰了就教你"那是 Host 半的事");process/Buffer 保持 undefined,typeof 探测安全  if (!isDynamicCordisPlugin(returned)) {  // 必须返回函数或带 apply(ctx) 的对象——与 Host 半同一契约    throw new Error('client half returned `undefined` — did you forget `return`?...')  // 最高频错误:忘了 return,给模型一个明确的自查方向

为什么这样设计:闭包方案比 iframe/worker 轻得多,而且"参数即符号面"让陷阱和注入物走同一条路——模型在两个半遇到的是同一套契约(guard.ts 注释称之为 "the sameness is the point: a package author meets ONE contract on both halves")。harnessTrap() 是个 Proxy,任何属性访问都抛"harness.xxx belongs to the HOST half"——把双半的职责边界直接写进运行时错误里。

RPC 的宿主侧入口是 @Remote('invoke')(index.ts 740-765 行),防御重点是拒绝陈旧页面

📄 packages/extensions/cordis-host-runner/src/index.ts (第 740-765 行,节选)

@Remote('invoke')  // 通过 wire 协议暴露给浏览器async invoke(pluginId, pluginRunId, method, args) {  // Client 半调用"本包自己的" Host handler——跨包不可达  const plugin = this.registry.get(pluginId)  // 按 ID 取 Plugin 记录;查不到说明已被 undefine  if (plugin === undefined || plugin.run === undefined) {    return { ok: false, code: 'plugin-not-running', message: `...is not running` }  // 包已停止 → 类型化拒绝,而不是让异常跨 wire 飞过去  }  const run = plugin.run  // 当前激活的 Run——handlers、pluginRunId 都挂在它上面  if (run.pluginRunId !== pluginRunId) {  // stale-run 检查:旧激活的页面不能调用新 Run——防止跨版本 RPC 泄漏(update 后旧页面的闭包还活着)    return { ok: false, code: 'stale-run', message: `activation "${pluginRunId}" is no longer active` }  }  const handler = run.handlers.get(method)  // 只认本次 startHost 期间 harness.handle 注册过的方法——方法表随 Run 生灭  if (handler === undefined) return { ok: false, code: 'method-not-found', ... }  try {    return { ok: true, value: await handler(args) as JsonValue }  // 返回值在边界处 JSON 克隆(normalizeHandler),vm realm 对象绝不跨 wire  } catch (error) {    this.steerHostHandlerFailure(plugin, run, method, failure)  // handler 崩溃 → 诊断 steer 给模型,附"修好同一 Plugin 再 update"的指引    return { ok: false, code: 'handler-error', ...failure }  }}

为什么这样设计:RPC 的每个拒绝分支都带 code(plugin-not-running / stale-run / method-not-found / handler-error),让浏览器半能区分"包没了"和"方法名拼错",分别给出不同提示。stale-run 检查是双半架构特有的安全问题:update 切换版本后,旧页面的 React 闭包可能还活着并持有 host.call 引用——没有 pluginRunId 校验,它就能调用新版本的 handler。

八、@pluginId:用户点名修改时的上下文注入

用户在消息里输入 @chart-1(Plugin ID)表示"改这个插件"。tool-cordis 用 agent/pre-step 钩子拦截每一步,扫描用户消息里的引用并注入上下文:

📄 packages/extensions/tool-cordis/src/index.ts (第 381-398 行)

ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => {  // 拦截模型每一步:用户消息里出现 @pluginId 时,注入该 Plugin 的上下文  const decision = await next()  // 先跑下游中间件——本钩子只增强不阻断(reject 决策原样放行)  if (decision.kind === 'reject') return decision  // 下游已决定拒绝本步 → 原样放行,注入上下文没有意义  const ids = referencedPluginIds(messages)  // 正则扫描用户消息中的 @<3-6小写字母>-<数字> token——恰好是铸造 ID 的形状,不会误伤普通文本  if (ids.length === 0) return decision  // 无引用 → 零开销,普通对话完全不受影响  const contexts = ids.map((id) => {  // 对每个被引用的 Plugin……    const reference = ctx.dynamicCordisRunner.reference(agent, CordisDynamicPluginId(id))  // ……取身份 + 版本指针(刻意不含源码——token 经济学:先给地图,模型要细节再 inspect_self)    return createUserMessage({ content: [{ type: 'text', text: renderReference(reference) }], ... })  // 渲染成指令块:以该 Package 为基线 → inspect_self 读源码 → define(existing) 追加版本 → run;引用不可用时换成"如实告知用户"的指令  })  return { kind: 'enter', messages: [...decision.messages, ...contexts] }  // 追加在既有上下文之后,模型把它当作用户侧消息看到

为什么这样设计:renderReference()(508-520 行)生成的指令块里有两条硬约束:"Use Package xxx as the base for this modification"和"Do not create a new Plugin for this request"——防止模型把"改 chart-1"理解成"新建一个类似的插件"。如果引用不可用(被删/跨 Session/进程重启丢失),renderUnavailableReference() 明确告诉模型:"Do not claim that it was updated or silently create a replacement Plugin. Tell the user that the reference is currently unavailable."——把"诚实报告失败"写进了注入的指令里。

九、Inspect:渐进式能力发现 + 跨端查询路由

模型写动态包前必须先知道"有哪些 Service/Event/Slot 可用"。CordisInspectRegistryService(inspect-registry.ts,248 行)是查询路由:Host provider 本地应答;Client provider 则把工具调用挂起成 Promise,等浏览器页面应答。

📄 packages/extensions/cordis-host-runner/src/inspect-registry.ts (第 158-198 行,节选)

private async queryClient(providerId, methodName, input, agent, signal) {  // Client 侧 provider:Host 本地答不了,路由到浏览器页面  const requestId = `inspect-${this.nextRequest++}` as CordisInspectRequestId  // 铸造请求 ID;"第一个有效应答获胜"语义靠 pending Map 保证  const result = new Promise<CordisInspectQueryResolution>((resolve) => {  // 把工具调用挂起成 pending——这正是工具描述里"remains pending until a page answers"的实现    this.pending.set(requestId, { request, method, settle: resolve })  })  const onAbort = (): void => {  // 工具调用被取消 → 以 'cancelled' 结算挂起的 Promise,不留悬空 await    ...pending.settle({ ok: false, reason: 'cancelled', message: `...was cancelled` })  }  signal.addEventListener('abort', onAbort, { once: true })  this.ctx.emit('cordis/inspect-query', request)  // 事件跨 wire 到页面;页面的 provider 通过 resolveInspectQuery(@Remote)应答,第一个有效结果 settle 掉 Promise  const resolution = await result  // 工具在此阻塞直到页面应答或取消——查询是只读的,挂起没有副作用风险

为什么这样设计:"渐进式发现"(prompt.ts 反复强调)是 token 经济学:不带 input 查 Service.listService 返回紧凑签名目录,带精确名字才返回完整契约 + 引用类型。模型先浏览目录再精读单个 API,而不是把整个运行时 API 面一次性灌进上下文。Host 侧的四个 provider(providers.ts)分别覆盖 Service / Event / Builtin / Tool——其中 Builtin 直接暴露 HOST_BUILTIN_INSPECTION(sandbox.ts 18-43 行),让模型查到"沙箱里到底有哪些符号",和实际执行环境严格一致。

十、Host 半 vs Client 半:一张对照表

执行环境

Host 半 DSH Node.js 进程内的 node:vm context(独立 realm)。

Client 半 浏览器页面内,new Function 闭包求值(无独立 realm)。

符号面

Host 半 ctx(守卫 façade)+ harness + console + btoa/atob/TextEncoder。

Client 半 React + host.call + styles + console;harness 是教学陷阱 Proxy。

网络

Host 半 fetch 被禁 → inject ['web'] 走 ctx.web Service。

Client 半 fetch 陷阱直接指向 Host:"register a handler there with harness.handle"——网络统一归 Host。

定时器

Host 半 原生定时器被禁 → fiber effect(ctx.timeout),stop 时自动清理。

Client 半 原生定时器被禁 → inject ['timer'],React 里用 useEffect cleanup 返回 disposer。

UI

Host 半 无 UI;可注册模型可见工具(harness.defineTool + registerTool)。

Client 半 React.createElement 构建 UI,注册进查询过的 Slot;apply() 不能直接返回 React Element。

通信方向

Host 半 harness.handle(method, fn) 注册方法,等待被调。

Client 半 host.call(method, args) 发起调用——方向固定 Client→Host,只传无损 JSON。

审批

Host 半 纯 Host Package 无需审批,define 后 run 直接激活。

Client 半 含 Client 的 Package 必须用户审批(单勾/双勾),未授权 run 返回 awaiting-approval。

🔑 设计要点:"网络归 Host、UI 归 Client、定时器必须是 effect"——三条规则把双半的职责切成了正交的两块。模型不需要理解 wire 协议,只需要记住:要数据找 ctx Service,要画面用 React.createElement,要跨半就 harness.handle + host.call。

十一、完整生命周期:从模型写代码到运行中的插件

define → run 全链路(含审批与双半激活)

① 定义:cordis_define → precheckCode

编译期语法预检(不执行),铸造 pluginId/packageId,写入进程内存注册表。无磁盘、无审批。

② 请求:cordis_run → resolvePlan

校验归属与 mode 语义;nextPackageId 指向目标版本,current 保持不动。

③ 审批:cordis/request-run → UI 卡片

🔹 Host-only:跳过此步,直接激活🔹 含 Client:单勾=本版本 / 双勾=该 Plugin 未来所有版本;工具立即返回 awaiting-approval

④ Host 激活:runHostHalf → startHost

createSandbox + evaluateHostCode(vm,同步部分限时)→ startHostHalf 挂到 cordis-dynamic 组 fiber;失败即 dispose,不留残骸。

⑤ Client 激活:getClientCode → evaluateClientHalf

页面拉取源码,闭包求值 + Slot 注册;结果经 resolveRequestRun(@Remote)回报 Host。

⑥ 提交:commitActivation → steering

🔹 成功:currentPackageId 原子前进,状态 running/waiting🔹 失败:旧版本原样保留,诊断 steer 给模型——inspect_self → define(existing) → run update

回头看整个子系统,它回答了一个尖锐的问题:让 LLM 写代码运行在自己的进程里,风险怎么控?DeepSeek Harness 的答案不是"更强的沙箱",而是四层叠加:① 能力最小化(符号面白名单 + 教学陷阱);② 边界强制(denyContext、JSON clone、stale-run 检查——单点拦截而非依赖模型自觉);③ 状态可恢复(不可变版本 + current/next 指针分离,失败永远可回滚);④ 人在环上(Client 半必须审批,授权粒度到"这个 Plugin 的未来版本")。四层里只有第一层是"防手滑",后三层都是工程结构——这正是它和"eval + try/catch"的本质区别。

下一讲进入 Hook 桥接:DeepSeek Harness 如何把 Claude Code / Codex 的 hook 协议接入自己的事件流,让外部 Agent 生态的工具也能挂进来。

📚 系列导航

← 第 37 讲:Web 客户端架构

→ 第 39 讲:Hook 桥接(Claude Code/Codex)

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料