1. 从零理解 dsh 插件:为什么“一切皆插件”值得你动手
DeepSeek Harness(下称 dsh)是一个把“一切皆插件”落到实处的智能体运行时。它构建在开源插件框架 Cordis 之上,内核极小,工具、LLM 适配器、沙箱、终端、UI、命令、skill、权限策略全部以插件形式存在,通过配置组合成完整能力。换句话说,你想给 dsh 加任何能力,路径只有一条:写一个插件,然后在配置里点亮它。
这篇教程面向想让 dsh 自动触发 Codex 完成代码任务的开发者。我会带你从初始化 TypeScript 工程开始,定义插件清单与生命周期钩子,封装 Codex 调用并处理流式返回,最后在本地加载插件、触发一次真实调用、核对返回结果。整个过程基于 dsh 0.1.0-rc.5 源码实测,示例是一个真实可用的插件:用户一提到 “codex”,dsh 就自动把任务交给 WSL 里的 Codex CLI 完成。
先明确一个概念:dsh 插件不是一次性的扩展点,而是 Citizen(一等公民),和官方内置插件共享同一套机制。一个插件就是一个导出apply(ctx)函数的 TypeScript 模块,框架加载时调用apply,把上下文对象ctx交给你,你通过它注册一切能力。没有框架启动代码,插件只描述自己的贡献,组合方式是配置的事。
插件有三种形态:函数形态最常用,导出apply(ctx);对象形态导出default { name, apply };类形态用于对外提供服务,继承Service。所有通过ctx做的注册——事件监听、工具注册、定时器——在插件卸载时都会被自动清理,你不需要手动removeListener或clearInterval。需要依赖其他服务时,声明inject,框架会等依赖就绪后再加载你的插件。
理解这些之后,你会发现 dsh 插件开发的门槛比想象中低。接下来我们从工程初始化开始,一步步把插件跑起来。
2. 初始化 TypeScript 工程与插件清单配置
2.1 创建工程与安装依赖
先建目录并初始化:
mkdir wsl-codex && cd wsl-codex pnpm init pnpm add -D typescript @types/node pnpm add @deepseek-ai/cordis @deepseek-ai/schemastery @deepseek-ai/dsh-tools @deepseek-ai/dsh-llm这里的关键依赖有四个:@deepseek-ai/cordis提供Context与Service类型;@deepseek-ai/schemastery用于定义配置 schema;@deepseek-ai/dsh-tools提供defineTool;@deepseek-ai/dsh-llm提供createUserMessage,用于自动触发时注入上下文。
2.2 可复制的 tsconfig.json
dsh 插件运行在 Node ESM 环境,tsconfig 需要开启module: NodeNext,并确保strict打开,因为 schema 推导依赖严格类型:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true, "outDir": "dist", "rootDir": "src", "types": ["node"] }, "include": ["src/**/*.ts"] }moduleResolution: NodeNext是必须的,否则@deepseek-ai/*的子路径导出会解析失败。skipLibCheck建议打开,避免第三方类型定义拖慢编译。
2.3 插件入口与 manifest 配置
插件入口src/index.ts的最小骨架如下,包含名称、配置 schema、依赖声明和apply:
import type { Context } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' export const name = 'wsl-codex' export interface Config { distro: string keyword: string codexBin: string timeoutMs: number skipGitRepoCheck: boolean } export const Config = Schema.object({ distro: Schema.string().default('Ubuntu-22.04'), keyword: Schema.string().default('codex'), codexBin: Schema.string().default('codex'), timeoutMs: Schema.number().default(600_000), skipGitRepoCheck: Schema.boolean().default(true), }) export const inject = ['tools', 'subprocess', 'agents'] export function apply(ctx: Context, config: Config) { // 工具注册与自动触发逻辑在下一节展开 }凡是“不同部署可能需要不同取值”的参数,都必须定义成配置字段,这是 dsh 的硬性约定。默认值直接写在 schema 里,框架会在加载时校验并填充默认值。inject声明了插件依赖的服务,框架会等ctx.tools、ctx.subprocess、ctx.agents就绪后再调用apply。
如果你要打包分发给别人,还需要一个带dsh.bundlemanifest 的package.json:
{ "name": "dsh-wsl-codex", "version": "0.1.0", "type": "module", "main": "dist/index.js", "files": ["dist", "cordis.patch.yml"], "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } }cordis.patch.yml是这个包贡献的配置层,用户安装后dsh plugin --profile web add ./wsl-codex会把它追加到 profile 的dsh.profile.bundles列表。
3. 封装 Codex 调用:工具注册与流式返回处理
3.1 用 defineTool 注册工具
工具是模型能调用的函数。用defineTool定义,schema 自动推导出类型化参数并做运行时校验:
import { defineTool } from '@deepseek-ai/dsh-tools' ctx.tools.register(defineTool({ name: 'wsl_codex', description: 'Run OpenAI Codex inside WSL on a self-contained task and return its final answer. ' + 'Use this whenever the user mentions Codex or asks to delegate work to Codex.', parameters: { task: { type: 'string', required: true, description: 'The task to hand to Codex.' }, cwd: { type: 'string', description: 'Working dir (WSL path or Windows path, auto-mapped).' }, }, output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args, exec) { // 调用逻辑见下 }, }))几个要点:execute(args)返回的是规范 JSON 值,人类可读的文案放在output.render里,不要混在一起;遵守exec.signal及时取消;长任务走run_in_background+ctx.jobs;部署策略(允许/拒绝/审批)不要写进工具,用tools/pre-execute等钩子实现。
3.2 路径映射与 HOME 重设
Windows 路径要转成 WSL 路径,否则cd会失败:
function winToWslPath(winPath: string): string { const normalized = winPath.replaceAll('\\', '/') const drive = /^([a-zA-Z]):\//.exec(normalized) if (drive) return `/mnt/${drive[1].toLowerCase()}/${normalized.slice(3)}` return normalized.startsWith('/') ? normalized : normalized }关键坑:wsl.exe会把 Windows 的HOME传进 WSL,导致 codex 读到 Windows 侧配置。必须在 bash 里重设 HOME:
const wslCwd = winToWslPath(args.cwd ?? exec.agent?.session.header.cwd ?? process.cwd()) const script = [ 'export HOME="$(getent passwd "$(id -un)" | cut -d: -f6)"', `cd "${wslCwd}"`, `${config.codexBin} exec${config.skipGitRepoCheck ? ' --skip-git-repo-check' : ''}`, ].join(' && ')3.3 流式返回与超时处理
用ctx.subprocess.spawn启动进程,stdin 写入任务,stdout 收集流式输出:
const controller = new AbortController() exec.signal.addEventListener('abort', () => controller.abort(exec.signal.reason), { once: true }) const timer = setTimeout(() => controller.abort(new Error(`timeout ${config.timeoutMs}ms`)), config.timeoutMs) try { const handle = ctx.subprocess.spawn({ argv: ['wsl.exe', '-d', config.distro, '--cd', wslCwd, '--', 'bash', '-lc', script], cwd: process.cwd(), stdio: { stdin: { data: args.task }, stdout: { maxBytes: 200_000 }, stderr: { maxBytes: 50_000 }, }, graceMs: 5_000, signal: controller.signal, }) const outcome = await handle.done const stdout = handle.collected.stdout?.readFrom(0).text ?? '' if (outcome.exitCode !== 0) throw new Error(`codex exited ${outcome.exitCode}`) return stdout.trim() } finally { clearTimeout(timer) exec.signal.removeEventListener('abort', () => controller.abort(exec.signal.reason)) }maxBytes限制防止内存爆掉,graceMs给进程优雅退出时间,signal让取消能传导到子进程。
3.4 自动触发钩子
用户消息命中关键词时,注入上下文让模型调用wsl_codex:
const injectedFor = new Set<string>() ctx.on('session/event', (session, event) => { if (event.type !== 'user/message') return if (event.data.source.kind !== 'user') return const text = event.data.content .map(b => (b.type === 'text' ? b.text : '')) .join('\n') if (!text.toLowerCase().includes(config.keyword.toLowerCase())) return const agent = ctx.agents.get(session.id) if (!agent || injectedFor.has(session.id)) return injectedFor.add(session.id) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'The user mentioned Codex. Use the wsl_codex tool to delegate the work.' }], source: { kind: 'plugin', plugin: name }, })) })每个会话只提醒一次,避免重复注入。
4. 本地加载插件与验证 Codex 调用结果
4.1 在 profile 中点亮插件
插件写好后不会自动加载,必须显式声明在配置层里。在 profile 的cordis.patch.yml里插入一行:
- insert: - id: wsl-codex name: 'file:///F:/my-project/wsl-codex/src/index.ts' config: distro: Ubuntu-22.04 keyword: codex timeoutMs: 600000注意 Windows 上路径必须是file:///URL,写F:/...会被当成f:协议抛ERR_UNSUPPORTED_ESM_URL_SCHEME。
4.2 用 --dump-config 核对配置来源
重启 dsh web 前,先用--dump-config看每一行来自哪个文件、被谁修改过:
pnpm dsh --profile web --dump-config输出里应该能看到wsl-codex这一行,以及它的config对象。如果没出现,说明 patch 文件路径或层级不对。
4.3 触发一次真实调用
重启 dsh web 后,在会话里输入包含 “codex” 的消息,比如:
用 codex 帮我把 src/utils.ts 里的日期格式化函数改成支持时区参数预期行为:dsh 自动注入提醒,模型调用wsl_codex工具,WSL 里的 Codex CLI 执行任务,最终答案返回会话。终端里能看到[wsl-codex] plugin loaded!说明插件已生效。
4.4 核对返回结果
检查三点:一是工具调用记录里wsl_codex的task参数是否是你输入的任务;二是stdout是否包含 Codex 的最终答案;三是exitCode是否为 0。如果exitCode非 0,看stderr里的错误信息。
5. 常见报错排查:401、local proxy failed 与 OAuth
5.1 401 Unauthorized
如果 Codex CLI 返回 401,通常是认证信息没读到。检查 WSL 里的~/.codex配置是否存在,以及 HOME 是否被正确重设。可以在 WSL 里手动跑一次codex exec --skip-git-repo-check确认认证正常。
5.2 local proxy failed
这个报错通常出现在网络请求层。检查 WSL 的网络配置是否能访问 Codex 服务,以及codexBin路径是否正确。如果 Codex 需要走特定端点,确认环境变量已传入。
5.3 reading choices 报错
reading choices一般出现在流式返回解析阶段,说明 stdout 格式不符合预期。检查maxBytes是否太小导致输出被截断,或者 Codex 版本不兼容。可以先把maxBytes调大再试。
5.4 OAuth 相关错误
如果 Codex 使用 OAuth 认证,WSL 里的浏览器回调可能无法完成。建议在 WSL 里先手动完成一次 OAuth 登录,确认 token 已缓存,再让插件调用。
5.5 配置层按行整替
patch 覆盖一行是替换整个config对象,不是深合并。覆盖内置插件时要重述它需要的全部键,否则会丢配置。
5.6 插件路径协议错误
Windows 上写F:/...会抛ERR_UNSUPPORTED_ESM_URL_SCHEME,必须写成file:///F:/...。
6. 接入 TaoToken 与长期编码方案
如果你希望 Codex 调用走更稳定的模型接入层,可以把 Codex 的 Base URL 指向 TaoToken 的 API 端点。TaoToken 提供兼容 OpenAI 的接口,适合作为长期编码与 Agent 场景的模型入口。
配置时三件套要写全:Base URL 填https://taotoken.net/api,Key 在控制台创建,Model ID 按你使用的模型填写。在 Codex 的配置里对应设置:
# ~/.codex/config.toml model = "your-model-id" model_provider = "taotoken" [model_providers.taotoken] base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY"然后在 WSL 里导出环境变量:
export TAOTOKEN_API_KEY="sk-你的key"验证请求是否成功,可以直接用 curl 测一次:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"your-model-id","messages":[{"role":"user","content":"ping"}]}'返回choices字段说明接入正常。如果要在 dsh 里长期跑编码任务,建议用 Coding Plan 管理额度与模型切换;需要调试模型行为时用模型对话页面快速验证;创建和管理 Key 在 API Keys 页面;接入细节看接入文档。
插件给了 dsh 无限的可能性。把它写成一个目录、一行配置、一个工具,剩下的交给组合。