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(宿主契约部分仍有参考价值)→ 源码。

改代码前必须知道的三条不变量:

  1. 跨 realm 对象规则(§8.1)——违反会导致凭据写入报错或静默失败;
  2. RPC 方法名与 payload 是 Client / Host 两个半区之间的契约,改名必须两侧同步(§5);
  3. 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.ymlbundle 补丁: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-toolsdefineTool(参数 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.callstyles.insertctx.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:

methodargs返回说明
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 恒为 0lossy 恒为 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 提交——每一段回车都走 submitEnterevaluateFor,良性段照常执行、危险段照常挂起,与逐键输入语义等价。

6.2 求值顺序(evaluateFor)

  1. 服务器 rulesaction: 'deny' 命中 → deny;
  2. 服务器 rules 其余(allow / ask)命中 → 按其 action;
  3. 内置 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.connectspawnSession)

  1. resolveExecutable('ssh') 定位二进制;
  2. auth: 'password' 时:credentials.resolve(ref) 取密码 → makeAskpass 写 askpass 脚本;
  3. 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 不支持 -- 分隔符,脚本里不要加;
  4. 异步消费输出流写 onOutput(检测错误特征设置中文 hint);首个输出块即置 status: 'live';
  5. 退出时 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):

    1. resolveServerensureSession(复用或新建交互 PTY 会话,与面板共享,waitLive 最长 20s);
    2. Guard 评估:deny → 直接返回 blocked;allowrunRemote(ssh -T … <cmd> 独立一次性执行,同样使用私有 known_hosts;stdout 上限 200K/spill 400K);
    3. 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_LIVE fail closed 是当年误诊为"宿主平面无法弹卡"的根因;ASK_ABORTED → aborted,NO_PROVIDER / DELEGATED_CALLER 等 → 路径四;
      • 路径四(面板回退):共享会话置 pending(from: 'tool', token)(面板横幅可见)+ 签发一次性令牌返回 blocked;同一会话同时只允许一条挂起;TOOL_ASK_TTL 后 sweep 自动撤销挂起并作废令牌;
    4. 结果(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 出身的对象为底,只有三个安全来源:

  1. credentials.describe() 返回的 CredentialInfo(宿主对象)→ hostBlank() 删掉字段后当空白对象;
  2. readRecord 返回的 rec.payload.servers(宿主 YAML 解析产物)→ 原地修改;
  3. RPC args 里的 server / rules(宿主 JSON 解析产物)→ 原地修改

数组判断用 Array.isArray(跨 realm 安全);原始值无 realm 概念,随意。新建 server 的标准流程:先 hostBlank 拿宿主空白对象 → push 进旧数组 → 原地填字段 → modifyRecord 返回整体。decide('always') 写永久放行规则时同理。

8.2 死代码与预留字段

  • runOnSessionwaitShellReadycollectOutput(仅被前者调用)、makeVirtualSession 目前没有调用方——为将来「工具命令直接走交互 PTY」预留;
  • netshell.pollnextCursor / 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 组合忽略;命中即 preventDefaultnetshell.input 转发。
  • 样式:CSS 模板串经 styles.insert 一次性注入;颜色全部走 --dsw-* 设计令牌(TK 映射表),自动适配深浅主题。

10. 修改检查单

改代码前过一遍:

  • 只改 src/ 源码,改完 pnpm test(build + check + smoke),提交时包含重新生成的 lib/;
  • 新对象要进 credentials payload?→ 必须宿主 realm 出身(§8.1);
  • 动了 RPC 名 / payload?→ src/nsh-client.jssrc/nsh-host.js 两侧同步 + 更新本文 §5;
  • 动了 Guard 求值顺序或内置规则?→ 更新 §6、README「权限等级」、CHANGELOG;
  • 动了 askpass / 凭据路径?→ 确认密码不出现在任何 RPC 返回、console、outAll;
  • 新增用户可见行为?→ README + CHANGELOG 同步;
  • 新增子资源(临时文件、子进程)?→ 在 onExitctx.effect dispose 中回收;
  • 动态源码保持 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.jsonversion 完全一致;否则工作流会在发布前失败。
  • 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 的预留代码。