NetShell 技术细节(Technical Notes)
September 5, 2026 · View on GitHub
读者:参与开发的工程师与 AI agent。目标:读完能安全地修改代码。
- 使用说明见 README.md;原始设计与宿主契约调研见 DESIGN.zh.md(注意:DESIGN 是前期设计,与实现的差异以本文 §8.3 和源码为准)。
- 本仓库即插件本体,没有构建步骤、没有依赖,
nsh-host.js+nsh-client.js两个文件就是全部产物。
1. 给 AI Agent 的导读
阅读顺序:README → 本文 → DESIGN.zh.md(宿主契约部分仍有参考价值)→ 源码。
改代码前必须知道的三条不变量:
- 跨 realm 对象规则(§8.1)——违反会导致凭据写入报错或静默失败;
- RPC 方法名与 payload 是 Client / Host 两个半区之间的契约,改名必须两侧同步(§5);
- Guard 求值顺序(§6)与「密码不出 Host」是安全边界,任何重构不得弱化。
2. 文件结构与双模式架构
分支模型:
master= Loader 发布形态,承载完整打包层(本节描述的全部文件以 master 为准);dev= 动态加载开发形态。两分支文件同构(dev 同样保留打包文件,以保证合并路径干净),差异仅来自文档与源码演进;在 dev 只修改src/源码与文档,打包与版本变更在 master 进行(合并后pnpm test重新生成lib/并提交)。
本插件有两种安装形态,由同一份源码(src/ 目录)驱动:
| 文件 | 说明 |
|---|---|
src/nsh-host.js | 源码 · 宿主半区:SSH 会话管理、Guard 引擎、凭据读写、RPC handlers、模型工具(动态沙箱函数体风格,ES5) |
src/nsh-client.js | 源码 · 浏览器半区:主区域终端 Tab(conversation.view)、设置页、ANSI 渲染、轮询(同上) |
scripts/build.mjs | 构建:把 src/ 源码内联进 lib/ 两个半区(pnpm build) |
lib/index.js | 生成物 · Loader 静态宿主半区(真实 Node ESM + harness shim + /netshell/rpc 分发) |
lib/client.js | 生成物 · Loader 静态浏览器半区(window.__ModuleLoader__ 工厂 + builtin shim) |
cordis.patch.yml | bundle 补丁:loader 树的 netshell 行 |
test/smoke.mjs | 端到端冒烟:桩服务驱动两个生成半区 + 真实 HTTP RPC(12 项断言) |
DESIGN.zh.md | 设计方案,§9 的宿主契约调研仍有效 |
TECHNICAL.md | 本文 |
UPDATES.md | 面向使用者的更新记录 |
CHANGELOG.md | 完整历史与实现变更记录 |
两个 src/ 源文件均以 var …; return { inject: [...], apply(ctx) { … } } 结尾——这是动态沙箱的函数体求值格式;build.mjs 把整份源码原样内联进一个 IIFE,因此业务逻辑只有一份,模式差异全部收敛在生成物的 shim 里:
| 动态沙箱 builtin | 静态模式落点(lib/index.js / lib/client.js) |
|---|---|
harness.handle(method, fn) | 内存 handler 表 + 单一 POST /netshell/rpc JSON 分发(webServer 路由) |
harness.defineTool(def) | @deepseek-ai/dsh-tools 的 defineTool(参数 schema 归一化) |
harness.registerTool(ctx, t) | ctx.tools.register(t) |
客户端 host.call(method, args) | fetch('/netshell/rpc')(与宿主同一分发端点) |
客户端 styles.insert(css) | <style> 注入 document.head |
客户端 ctx.timer | 客户端无 timer 服务,包装层以 setInterval/setTimeout 增广 ctx |
服务名 subprocess / credentials / timer | 同名——静态 loader 的 base bundle 提供同名服务(@deepseek-ai/dsh-subprocess-local / dsh-credentials-local / cordis-plugin-timer),inject 直接声明 |
改代码只改 src/,然后 pnpm test(build + check + smoke)。lib/ 是生成物但入库提交,保证 git clone / link 安装零构建步骤。
3. 运行环境与宿主契约
- 插件运行在 DSH 的 node:vm 沙箱 realm:没有
require,不能引第三方库——这是所有选型(包装 ssh 二进制、自绘 ANSI、手写 store)的根本原因。 - Host
inject: ['subprocess', 'credentials', 'timer'],用到:subprocess.spawnTerminal(spec):PTY 拉起交互式ssh(会话);subprocess.spawn(spec):askpass 脚本落盘 / 清理、ssh -T一次性命令执行(工具路径);subprocess.resolveExecutable('ssh'):定位 ssh 二进制;credentials.readRecord / modifyRecord / describe / resolve / set / unset:档案与密码的持久化(§4);harness.handle(method, fn):注册 RPC;harness.defineTool+harness.registerTool:注册模型工具;timer.timeout / interval:轮询与等待。
- Client 可用:
React(代码风格为var x = React.useState(...)解构前写法)、host.call、styles.insert、ctx.get('slots')、ctx.get('locale')、console;slots:conversation.view(主区域 Tab,list-kind,data-conversation-composer-overlay定高模式)、settings.section(设置页)。 - 交互终端固定 120×32(
COLS/ROWS),resize 未实现。
4. 数据与持久化(为什么没有配置文件)
没有独立配置文件是刻意设计:全部配置走宿主 credentials 服务,由宿主负责加密落盘(0600)、跨重启恢复,且密码与档案同域管理。
| 数据 | 位置 | 形态 |
|---|---|---|
| 服务器档案 + 等级 + 规则 | 凭据记录 netshell/profiles(常量 PKEY) | kind: 'grant',payload { version: 1, servers: [...] } |
| 密码 | 每服务器一条,ref 由 refFor(id) 生成:NETSHELL_PW_<id 去掉非字母数字后大写> | credentials.set(ref, password) |
| 运行时会话 | Host 进程内存 Map | 插件停止 / DSH 重启即清空(会话本就不跨进程) |
| 主机指纹(known_hosts) | 插件私有文件 ~/.dsh/netshell/known_hosts | 普通文件 0600 / 目录 0700,由 ssh 维护;0.1.1 起,与 ~/.ssh/known_hosts 隔离 |
server 档案字段:{ id, name, host, port, user, auth: 'password'|'key'|'agent', keyPath?, level: 'open'|'guarded'|'locked', rules: [{ pattern, action: 'allow'|'ask'|'deny', note? }], createdAt }。
密码隔离保证:列表 / 保存接口返回时只附加 hasPassword(来自 credentials.describe(ref).configured);任何 RPC、任何日志、任何终端输出都不会出现密码值。
5. RPC 接口(Client → Host)
全部经 harness.handle 注册,参数 / 返回为 lossless JSON:
| method | args | 返回 | 说明 |
|---|---|---|---|
netshell.profiles.list | {} | { servers: [server & { hasPassword }] } | 档案列表 |
netshell.profiles.save | { server, password?, clearPassword? } | { server } | 新建 / 更新;password 非空则写凭据,clearPassword 则删除;校验必填项与端口 1–65535 |
netshell.profiles.delete | { id } | { ok: true } | 删档案 + 删凭据 + 终止该服务器活跃会话 |
netshell.connect | { serverId } | { id, pid } | 每次都 spawn 新会话 |
netshell.local.connect | {} | { id, pid } | 自动选择本机可用 shell 并创建独立本地会话 |
netshell.input | { id, data } | { ok: true } | 键盘输入,单次 ≤4096 字符;pending 期间整体丢弃;内嵌 \r/\n 的多字符 data 强制拆段送入 Guard(§6.1),不存在绕过拦截的输入形态 |
netshell.poll | { id } | { status, output, lossy, dropped, nextCursor, events, pending, hint, closedReason, atPwPrompt, cols, rows } | 全量快照(见下) |
netshell.decide | { id, pendingId, action: 'allow'|'always'|'deny' } | { ok: true } | 对挂起命令做裁决;人工挂起(from: 'line')按原行为向 PTY 写 \r/\u0015,工具挂起(from: 'tool')只记账到确认令牌,不动 PTY |
netshell.disconnect | { id } | { ok: true } | 终止会话 |
netshell.sessions.list | {} | { sessions: [{ id, serverName, status, pending }] } | 用于跨面板发现会话 |
注意:
netshell.poll目前是全量快照:output为缓冲全文,nextCursor恒为0、lossy恒为false——增量游标是预留字段,未实现;- Client 以 150ms 固定间隔轮询:
discoverSessions(发现外部 / 模型开的会话与 pending,必要时自动弹出面板)+pollOne(activeId)(仅当前会话)。
6. Guard 引擎(Host 侧)
6.1 行缓冲与提交时机
onInput 维护当前行缓冲 s.line:
- 回车(
\r)→submitEnter:trim 后交给evaluateFor评估;空行与密码提示状态直接透传; - Backspace(
\x7f)删一个字符;Ctrl-U(\x15)/ Ctrl-C(\x03)/ Ctrl-L(\x0c)清行; - ↑/↓(
\x1b[A/\x1b[B)在 Host 侧hist(≤100 条)中回溯:先写\x15清掉远端当前行再回写内容,保证远端显示与缓冲一致; - Tab 与其余 ESC 序列直接透传(补全、vim 等不受 Guard 管);
- 密码提示旁路:
s.tail(最近 60 字符)匹配/password\s*:$/i时置atPwPrompt,此后回车直接透传不评估——否则 sudo / ssh 的密码输入会被当成命令送进 Guard; - pending(待确认)期间所有输入被丢弃;
deny裁决后写\x15复位远端行。 - 多字符防绕过(
feedMultiline):合法浏览器客户端只发单键(回车是单独一条\r),但协议层曾允许一条多字符data内嵌\r直达 PTY 绕过 Guard。现netshell.input对含\r/\n的 data 按[\r\n]拆段(\r\n先归一),逐字符送onInput、段间注入\r提交——每一段回车都走submitEnter→evaluateFor,良性段照常执行、危险段照常挂起,与逐键输入语义等价。
6.2 求值顺序(evaluateFor)
- 服务器
rules中action: 'deny'命中 → deny; - 服务器
rules其余(allow/ask)命中 → 按其 action; - 内置
deny→ 4. 内置ask→ 5. 等级默认(guarded→ allow;locked→ ask,即白名单模式;open不会走到这里)。
6.3 匹配语义
- glob → 正则(
globToRe,带缓存):*→[\s\S]*,?→.,其余字符转义;整串匹配、忽略大小写; variants():最多剥 3 层sudo|doas|nice|nohup|env前缀,所有变体参与匹配;- 规则按数组顺序求值,首个命中生效;
- 仅匹配原始命令串,不做 shell 词法解析(DESIGN 中「词法切分」未实现,见 §8.3)。
6.4 内置规则库
BUILTIN_RULES 共 38 条:9 条 deny(rm -rf 根路径及变体、mkfs*、dd 直写设备、覆写 /dev/sd*、fork 炸弹、chmod -R 777 /*)+ 29 条 ask(递归删除、关机重启、删库删表、强推硬重置、清防火墙、删 crontab、包管理卸载、docker/k8s 清理等)。修改时需同步 CHANGELOG 与 README。
7. 会话生命周期与模型工具
7.1 交互会话(netshell.connect → spawnSession)
resolveExecutable('ssh')定位二进制;auth: 'password'时:credentials.resolve(ref)取密码 →makeAskpass写 askpass 脚本;spawnTerminal拉起ssh -tt -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=<私有文件> -o NumberOfPasswordPrompts=1 -o ServerAliveInterval=15 -o ConnectTimeout=12 …(agent 加BatchMode=yes;key 加-i <keyPath> -o IdentitiesOnly=yes);- 私有 known_hosts(
knownHostsFile()):首次调用经/bin/sh创建~/.dsh/netshell/known_hosts(目录 0700、文件 0600,幂等)并缓存路径,失败可重试、失败则本次连接报错;插件学到的指纹不落入用户~/.ssh/known_hosts,互不污染; BSD 工具链(macOS)的chmod不支持--分隔符,脚本里不要加;
- 私有 known_hosts(
- 异步消费输出流写
onOutput(检测错误特征设置中文hint);首个输出块即置status: 'live'; - 退出时
onExit记录closedReason并删除 askpass 脚本。
askpass 机制(密码认证,需 OpenSSH ≥ 8.4):
- 脚本写到
/tmp/.netshell-askpass-<nsToken>-<n>.sh,内容仅printf "%s\n" "$NETSHELL_PW"; - 用
/bin/sh -c 'umask 077 && printf %s "$NS_SCRIPT" > "\$1" && chmod 700 …'落盘(0700); - ssh 进程 env 注入
SSH_ASKPASS=<脚本>+SSH_ASKPASS_REQUIRE=force+DISPLAY=netshell:0(非空即可触发 askpass 路径)+ 密码本体NETSHELL_PW; - 会话结束
/bin/sh rm -f删除脚本。
7.2 缓冲策略
outAll上限 160K 字符,溢出裁到 120K;outBase记录全局偏移、dropped累计被裁字符数;events上限 500(溢出丢最旧 100);hist(命令历史)上限 100;- Client 侧
lines上限 1200(溢出丢最旧 400 并累计dropped提示),实际渲染最近 400 行 + 当前行。
7.3 模型工具
harness.defineTool 定义,ctx.effect 中注册、dispose 时反注册:
-
netshell_servers:无参数,读PKEY返回{ servers: [{ id, name, host, port, user, auth, level }] }; -
netshell_run:参数server(必填)、command(必填)、timeoutMs(默认 30000);confirmToken仅回退路径使用,choice已废弃(授权只认真人裁决,参数被忽略)。执行流(toolRunExecute):resolveServer→ensureSession(复用或新建交互 PTY 会话,与面板共享,waitLive 最长 20s);- Guard 评估:
deny→ 直接返回 blocked;allow→runRemote(ssh -T … <cmd>独立一次性执行,同样使用私有 known_hosts;stdout 上限 200K/spill 400K); ask→ 依次尝试四条路径:- 路径一(令牌兑现):携
confirmToken重跑时,校验一次性、服务器+命令绑定、TOOL_ASK_TTL(10 分钟)时效;面板已裁决 → 兑现执行,未裁决 → 返回 blocked 且不消耗令牌(模型可提醒用户后再试); - 路径二(漏带令牌兑现):无令牌但存在 (服务器, 命令) 精确匹配且面板已裁决的记录 → 直接兑现;
- 路径三(首选 · 原生弹卡):直调宿主
ctx.get('userQuestions').ask({ questions, agent: exec.agent, signal: exec.signal })——与内置ask_user_question完全同一形态,确认卡原生弹在对话窗口,工具原地等待真人作答;答案由宿主服务返回,选「执行一次」→runRemote、「永久放行该命令」→ 写规则表后runRemote、其余(拒绝/自定义文本/空答案)一律按拒绝。agent 必须原样透传exec.agent(live Agent 对象):服务端做agents.get(agent.id) === agent全等校验,0.5.x 用 id 重建对象导致CALLER_NOT_LIVEfail closed 是当年误诊为"宿主平面无法弹卡"的根因;ASK_ABORTED→ aborted,NO_PROVIDER/DELEGATED_CALLER等 → 路径四; - 路径四(面板回退):共享会话置
pending(from: 'tool', token)(面板横幅可见)+ 签发一次性令牌返回 blocked;同一会话同时只允许一条挂起;TOOL_ASK_TTL后 sweep 自动撤销挂起并作废令牌;
- 路径一(令牌兑现):携
- 结果(stdout/stderr/exitCode)与
$ <cmd>一起回写共享会话的outAll与事件流,面板全程可见模型做了什么。
授权凭证只会来自真人操作(确认卡答案 / 面板
netshell.decide点击),模型的choice参数不参与授权——这是机制性绑定,不依赖模型自觉。
8. 实现要点与坑
8.1 跨 realm 对象身份(最重要的坑,源码顶部有大段注释)
插件跑在 node:vm 沙箱 realm,沙箱里对象字面量的 Object.prototype 与宿主 realm 不同;credentials 服务的 assertJsonValue 用宿主的 Object.prototype 做全等比较,直接传沙箱造的对象会失败。因此一切要写进 GrantRecord payload 的对象,必须以宿主 realm 出身的对象为底,只有三个安全来源:
credentials.describe()返回的 CredentialInfo(宿主对象)→hostBlank()删掉字段后当空白对象;readRecord返回的rec.payload.servers(宿主 YAML 解析产物)→ 原地修改;- RPC args 里的 server / rules(宿主 JSON 解析产物)→ 原地修改。
数组判断用 Array.isArray(跨 realm 安全);原始值无 realm 概念,随意。新建 server 的标准流程:先 hostBlank 拿宿主空白对象 → push 进旧数组 → 原地填字段 → modifyRecord 返回整体。decide('always') 写永久放行规则时同理。
8.2 死代码与预留字段
runOnSession、waitShellReady、collectOutput(仅被前者调用)、makeVirtualSession目前没有调用方——为将来「工具命令直接走交互 PTY」预留;netshell.poll的nextCursor/lossy为增量拉取预留,当前无效果。
8.3 与 DESIGN.zh.md 的差异(以实现为准)
| 设计 | 实现 |
|---|---|
RPC 命名 netshell.servers.*、sessionId / cursor 增量拉取 | netshell.profiles.*、id、全量快照 poll |
拦截询问走宿主 userQuestions.ask() | 插件在工具 ask 分支直调 userQuestions.ask(agent 原样透传 exec.agent),确认卡弹对话窗口;弹卡不可用时回退「面板挂起 + 面板裁决兑现令牌」 |
连接前探测 ssh -V 并降级 | 未做 |
| 规则匹配做 shell 词法切分 | 仅原始串匹配 + sudo 类前缀剥离 |
| 模型工具是 P3 可选项 | 已随首版交付(netshell_servers / netshell_run) |
8.4 安全边界与已知弱点
- 护栏非沙箱:匹配对象是原始命令串,base64 / 变量间接 / heredoc 等可绕过;
locked+allow白名单是唯一强约束,文档需持续向用户明示; - Guard 只守「整行」:进入交互式程序(python / mysql / vim
:!/ 嵌套 ssh)之后的操作不在评估范围,被评估的只有启动它的那一行; - 接管范围 = 本插件两条通道:终端 UI 与
netshell_run之外(如通用 shell 工具直接ssh、其他插件 / MCP),命令不经本插件,插件无法也无处拦截——宿主subprocess服务不提供全局 spawn hook; - ask 的授权凭证机制性绑定真人操作:确认卡答案由宿主
userQuestions.ask返回、面板裁决经netshell.decide记账,choice参数不参与授权; netshell.input已做多字符防绕过(内嵌\r/\n拆段过 Guard),但\x15/\x7f等控制字符在行缓冲与远端 readline 间的语义差异理论上仍可构造出「Guard 看到的串 ≠ 远端执行的串」的混淆输入,归类为混淆变形类已知弱点;atPwPrompt是启发式:远端提示语不含password:时(如自定义 PAM 提示),密码回车可能落入评估路径,通常无副作用但会记一条历史;- host key 变更时由 ssh 自身拒绝连接(指纹记在私有
~/.dsh/netshell/known_hosts),onOutput识别特征文案给出中文 hint 与善后指引; - 模型路径的
waitLive是 120–150ms 轮询,非事件驱动(可接受,但不优雅)。
9. Client 实现速览
- 状态:
store为手写 observable(set()浅拷贝 + 订阅通知),另有screens: Map<sessionId, screen>保存每个会话的渲染状态;组件经useStore()订阅强刷(整树 re-render,当前规模可接受)。 - ANSI 渲染(
lineSpans):逐单元格缓冲(字符 + 样式),处理 SGR(16 色 FG/BG + 粗体/斜体/下划线/删除线/暗淡)、K/J清行清屏、C/D/G光标列移动、\r回车重绘(进度条正确)、\b、\t(8 列制表)、BEL;剥除 OSC 与无显示意义控制符;相邻同样式合并为 span。无 xterm.js,这是精简自绘实现,完整光标重放(全屏 TUI)不支持。 - 按键映射(
keyToData):DOM 键盘事件 → 终端字节流;Ctrl+字母 →\x01..\x1a,方向键 / Home / End / PgUp / PgDn → CSI 序列,Tab/Shift-Tab →\t/\x1b[Z;Alt / Meta 组合忽略;命中即preventDefault并netshell.input转发。 - 样式:CSS 模板串经
styles.insert一次性注入;颜色全部走--dsw-*设计令牌(TK映射表),自动适配深浅主题。
10. 修改检查单
改代码前过一遍:
- 只改
src/源码,改完pnpm test(build + check + smoke),提交时包含重新生成的lib/; - 新对象要进 credentials payload?→ 必须宿主 realm 出身(§8.1);
- 动了 RPC 名 / payload?→
src/nsh-client.js与src/nsh-host.js两侧同步 + 更新本文 §5; - 动了 Guard 求值顺序或内置规则?→ 更新 §6、README「权限等级」、CHANGELOG;
- 动了 askpass / 凭据路径?→ 确认密码不出现在任何 RPC 返回、console、
outAll; - 新增用户可见行为?→ README + CHANGELOG 同步;
- 新增子资源(临时文件、子进程)?→ 在
onExit与ctx.effectdispose 中回收; - 动态源码保持 ES5 函数体风格(顶层
var+return { inject, apply }),不要引入import/export/模板字符串——两种模式都靠「函数体原样内联」。
11. 安装与打包(loader 静态包)
- 包形态:
package.json声明exports["./client"]+dsh.client(platform: web)+dsh.bundle.patch—— 这是 DSH client-modules 系统扫描浏览器半区、以及 profile launcher 识别 bundle 的契约(参照:@deepseek-ai/dsh-client-modules的扫描逻辑与本仓库 cordis.patch.yml)。注意dsh.client.inject的语义是依赖的 client 包名(模块表到达顺序边),而模块自身export const inject = [...]是服务名——两层不要混淆;本包前者为[](slots/react 由既有启动图提供)。 - 安装:本机 profile 是
~/.dsh/profiles/web(pnpm workspace,patch 层为其中的cordis.patch.yml)。dsh plugin --profile web add link:<路径>会 pnpm 安装并自动把 bundle 追加进dsh.profile.bundles(透明转发 pnpm + 按安装状态对账);模块解析是双锚(先 dsh 安装目录、后 profile 目录)。 - 宿主半区服务:
inject: ['subprocess', 'credentials', 'timer', 'tools', 'webServer'](base bundle 提供同名服务行);RPC 走自注册 HTTP 路由(与@xgone/dsh-remote同一模式,auth gate 覆盖所有已注册及后注册路由)。有意未走/api通用平面:namespace/<method>派发要求宿主半区TypertRemoteService+@Remote标记,且浏览器半区必须挂载@deepseek-ai/dsh-typert-generator生成的严格 codec 描述符(./remote产物 +ctx.remote.$mount)—— machinery 过重,对本插件的 10 个 JSON RPC 不划算;若未来需要流式/强 schema,再迁。 - 浏览器半区:工厂形式
window.__ModuleLoader__.load({ id, factory(require) }),依赖经require('react')注入;不使用eval/new Function,源码以真实代码内联(无 CSP 风险)。
12. npm 发布
- 工作流位于
.github/workflows/npm-publish.yml。 - 在 GitHub 仓库的
Settings → Secrets and variables → Actions中配置NPM_TOKEN,只在发布步骤注入。 - 创建 GitHub Release 时,Release tag 去掉可选的
v前缀后必须与package.json的version完全一致;否则工作流会在发布前失败。 workflow_dispatch可手动发布当前分支上的包版本,发布前仍会执行pnpm test。- npm 包的
files白名单包含双语 README、技术文档和assets/,保证 npm 页面中的文档图片可用。
13. 路线图(DESIGN 分期 + 本文暴露的技术债)
- 增量 poll:启用
nextCursor游标,减少带宽与面板 re-render; - 工具命令走交互 PTY:复活
runOnSession,让netshell_run真实回显交互(当前 allow 路径是独立ssh -T); ssh -V探测与降级提示(补齐 DESIGN §8 风险对策);resize、跳板机 ProxyJump、完整 ANSI 光标重放(全屏 TUI);terminals.registerBackend受控接入(显式开关)、清理或落地 §8.2 的预留代码。