ARTICLE · 1050491
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"):
一个关键设计决策:每个动态包都是"双半"(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/Error,instanceof 会静默失败;更危险的是——如果某个 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技术推荐官」获取更多源码解析内容