NetShell

September 3, 2026 · View on GitHub

状态:P1 已实现(使用说明见 README.md,实现细节与本文差异见 TECHNICAL.md §8.3,变更记录见 CHANGELOG.md) 结论来源:已搜索 DSH 代码库与运行时服务目录,确认不存在现成远程终端插件;本方案基于运行时已确认的真实宿主契约设计。

1. 目标

#需求设计落点
1登录终端时插件接管GUI 发起连接,插件全程托管 SSH 认证与会话
2存储远程服务器 SSH 配置Host 侧服务器档案(host/port/user/auth),经设置页管理
3密码存储且不经 agent 会话复用宿主 credentials 凭据服务,密码只在 Host 内解析注入
4实时预览终端执行的命令Client shell.overlay 浮动终端面板,增量输出流
5拦截危险操作Host 侧 Guard 引擎,回车时先评估后放行
6终端命令权限等级open / guarded / locked 三级 + 逐条规则
7危险命令拦截后询问用户userQuestions 结构化询问,答案回注 Guard 决策

2. 总体架构

一个动态 Cordis 插件(Host + Client 两个半区,Package-private RPC 通信):

┌─ Client(浏览器)──────────────────────┐      ┌─ Host(DSH 进程)────────────────────────┐
│ shell.overlay 浮动终端面板            │      │ ConnectionManager(PTY ssh 会话池)      │
│  · 服务器选择 / 连接状态              │ host.call   │  · subprocess.spawnTerminal('ssh …')   │
│  · 实时输出区(自绘 ANSI 渲染)        │ ────→ │  · askpass 脚本 ← credentials.resolve  │
│  · 键盘输入转发                       │ ←───→ │ GuardEngine(行缓冲 + 规则评估)          │
│  · 命令历史条 / 拦截事件条            │ JSON  │  · 回车提交 → 评估 → 放行/询问/拒绝      │
│ settings.section 远程终端管理页       │ RPC   │ ServerStore(服务器档案 + 规则)           │
│  · 服务器 CRUD / 密码设置            │       │ userQuestions.ask(危险确认)             │
│  · 权限等级 / 规则库                 │       │ (可选 P3) harness.registerTool          │
└──────────────────────────────────────┘      └─────────────────────────────────────────┘

2.1 SSH 通道选型:包装本地 ssh 二进制

Host 端 Builtins 只有 ctx / harness / console / btoa / atob / TextEncoder / TextDecoder,没有 require,无法引入 ssh2 库。因此:

  • 用宿主 subprocess.spawnTerminal(spec) 经 PTY 拉起本地 ssh(-tt 强制伪终端);
  • 密码注入:SSH_ASKPASS 指向插件生成的临时 askpass 脚本(脚本执行时才从 credentials.resolve(ref) 取密码打印到 stdout),SSH_ASKPASS_REQUIRE=force + DISPLAY=dummy,配合 setsid 脱离 tty 触发 askpass 路径;需 OpenSSH ≥ 8.4(macOS 自带版本满足);
  • 密钥认证直接传 -i <keyPath>,agent 认证走本机 ssh-agent,均无需注入;
  • StrictHostKeyChecking=accept-new + 插件私有 known_hosts 文件(如 ~/.dsh/netshell/known_hosts),不污染用户配置。

2.2 为什么不注册 terminals.registerBackend

宿主 terminals 服务支持注册 PTY 后端,注册后 6 个模型工具(tool-terminal)即可操作远程会话——但这会把远程 shell 暴露给模型,与本插件"人机终端 + 拦截保护"的定位冲突。默认不注册后端,远程终端仅用户在 GUI 中可达;模型侧受控接入(netshell_run 工具,经同一 Guard 评估)放在 P3 作为显式选项。

3. 数据模型

3.1 服务器档案(ServerStore)

非敏感字段持久化(宿主 settings 注册 netshell 命名空间或 storage domain record):

{
  id: 'srv_xxx',            // 稳定 id
  name: '生产-web-01',       // 显示名
  host: '10.0.0.5',
  port: 22,
  user: 'deploy',
  auth: 'password',         // 'password' | 'key' | 'agent'
  keyPath: undefined,       // auth=key 时
  credentialRef: 'netshell/ssh/srv_xxx',  // auth=password 时指向凭据记录(只存 ref,不存值)
  level: 'guarded',         // 'open' | 'guarded' | 'locked'
  rules: [                  // 服务器级规则覆盖
    { pattern: 'kubectl delete *', action: 'ask',  note: '删资源要确认' },
    { pattern: 'systemctl restart nginx', action: 'allow' },
  ],
  createdAt: 0, lastUsedAt: 0,
}

3.2 密码(凭据)

  • 存储:宿主 credentials 服务的 record 空间(modifyRecord / deleteRecord),ref 形如 netshell/ssh/<serverId>;加密与落盘(0600)由宿主凭据后端负责(~/.dsh/.credentials.yaml);
  • 使用:仅 Host 半区在建立连接的瞬间 resolve(ref) 取值写入 askpass 脚本(0700,临时目录,连接建立后立即删除);
  • 隔离保证:
    • RPC 面(host.call)任何方法都不返回密码值,设置页也只显示"已设置/未设置";
    • 密码不进入任何模型消息、session log、终端输出流;
    • 插件自身的 Console 打印一律脱敏。

3.3 会话状态

{
  id: 'sess_xxx', serverId, status: 'connecting'|'authenticating'|'live'|'closed',
  startedAt, closedReason?,
  history: [ { seq, command, at, decision: 'allow'|'ask-allow'|'ask-deny'|'deny', matchedRule? } ],
}

会话仅存在于插件 Fiber 生命周期内(进程本地);插件停止/更新时统一 kill 所有 PTY。终端输出流不落 durable session log,只有命令历史(命令文本 + 决策)进入 Run 卡片视图。

4. 命令权限等级与 Guard 引擎

4.1 行缓冲与提交时机

PTY 下键盘逐字符到达。GuardEngine 在 Host 侧维护当前命令行缓冲,处理:Enter(提交评估)、BackspaceCtrl-U(清行)、Ctrl-C(弃行)、Tab(放行给 shell 补全,不评估)、粘贴(整段缓冲,跨行粘贴按行评估)。引号/管道/&&/;; 连接的复合命令整条参与评估,不做逐段放行。

4.2 三级权限语义

等级语义
open默认放行;仅 deny 规则仍硬拦
guarded(默认)ask 规则命中 → 询问;deny → 拦;其余放行
lockedallow 规则直接放行;其余一律询问

4.3 内置危险规则库(可关闭/可自定义)

// deny:不可询问,直接拦截
rm -rf / 与根路径变体、mkfs*、dd of=/dev/*、> /dev/sd*、
fork 炸弹 :(){ :|:& };:、chmod -R 777 /、history -c
// ask:拦截后询问
rm -rf(非根)、shutdown / reboot / halt / init 0|6、
drop database / drop table、git push --force、kill -9 1、
iptables -F、crontab -r、yum/apt remove、docker system prune -a

规则匹配:shell 词法切分后对首词与全串做 glob(*)匹配,另支持用户自定义正则(设置页标注"正则"开关)。规则求值顺序:per-server deny → per-server allow/ask → 全局内置 deny → 全局内置 ask → 等级默认。

4.4 拦截后询问(需求 7)

命中 ask 时,GuardEngine 暂停该次提交(输入不写入 PTY),调用宿主 userQuestions.ask():

⚠ 远程终端 · 生产-web-01
检测到危险命令:rm -rf /var/log/old
匹配规则:rm -rf(非根)
[ 执行一次 ]  [ 本服务器永久放行 ]  [ 拒绝 ]
  • 执行一次:放行写入 PTY,历史记 ask-allow;
  • 永久放行:写入该服务器 rules(action: 'allow')后放行;
  • 拒绝:丢弃该行,向 PTY 写入 Ctrl-C 复位提示符,历史记 deny;
  • 询问走宿主 answerer waterfall,与模型无关;答案只是 JSON,不消耗上下文。

5. Client 实时预览(需求 4)

5.1 Slot 布局(均已查询确认)

Slot协议用途
shell.overlaylist浮动终端面板(可拖动、可最小化到角落 pill)
sidebar.footer.actionlist「远程终端」入口按钮
settings.sectionlist「远程终端」管理页(服务器 CRUD、密码、等级、规则库)
tool.view.cordiskeyed(self)Run 卡片内的连接状态/历史摘要(可选)

5.2 终端渲染

Client Builtins 仅 React / host.call / styles.insert / console,无 xterm.js → 自绘轻量渲染:

  • 输出区 <pre> + white-space: pre + 横向滚动(与 TerminalBlock 的分歧一致:保列对齐);
  • ANSI 处理自写精简版:SGR 前景/背景/常用属性 → 内联 span;\r 重绘按列缓冲结算(进度条正确);剥除 OSC 与无显示意义控制符;光标移动序列首版可只结算 \r,完整重放作为增强项;
  • 增量拉取:Client 每 ~120ms host.call('netshell.poll', { sessionId, cursor }) 取增量文本(有界缓冲,背压丢弃策略:溢出时丢最旧并插提示行),避免长时间高吞吐输出撑爆 RPC;
  • 输入:面板内键盘事件(含功能键映射)直接 host.call('netshell.input', …) 转发,焦点管理避免与页面冲突;
  • 命令历史条与拦截事件条:Guard 的每次决策由 Host 推送(在 poll 响应中携带事件),面板底部时间线展示「命令 + 决策徽章」,即"实时预览终端执行的命令"的结构化部分。

6. Package-private RPC 接口面

method方向说明
netshell.servers.listC→H服务器档案列表(不含密码)
netshell.servers.saveC→H新建/更新档案(密码经 credentials.set 由 Host 写入)
netshell.servers.deleteC→H删档案 + 删凭据
netshell.connectC→H{ serverId } → 建立 PTY,返回 { sessionId }
netshell.inputC→H{ sessionId, data } 键盘输入(经 Guard 行缓冲)
netshell.pollC→H{ sessionId, cursor }{ output, events, guardState }
netshell.resizeC→H{ sessionId, cols, rows }
netshell.disconnectC→H关闭会话
netshell.sessions.listC→H活跃会话 + 历史摘要

全部参数/返回为 lossless JSON;handler 内部使用宿主服务 subprocesscredentialssettingsuserQuestions

7. 分期实施

  • P1 核心闭环:服务器 CRUD + 密码入凭据库 + SSH 连接(密码/密钥/agent)+ overlay 实时终端 + 输入转发 + guarded 等级 + 内置规则库 + 拦截询问(执行一次/拒绝);
  • P2:三级等级完善(open/locked)、自定义规则 UI(含正则)、永久放行、命令历史持久化到档案、多会话并管、resize;
  • P3(可选):netshell_run 模型工具(经同一 Guard)、terminals 后端注册(显式开关)、跳板机 ProxyJump、连接内端口转发状态显示、完整 ANSI 光标重放。

8. 风险与约束

风险对策
askpass 依赖 OpenSSH ≥ 8.4连接前探测 ssh -V,不满足则降级为"仅密钥/agent 认证"并提示
动态插件为进程本地,重启后会话丢失会话本就不跨进程;档案与凭据在宿主持久层,天然可恢复
高吞吐输出(如 cat 大文件)增量拉取 + 有界环形缓冲 + 丢弃计数提示
首连 host key 确认accept-new + 私有 known_hosts;变更告警(冲突时拒绝连接并列出指纹差异)
Guard 误放行(混淆/编码绕过)明示定位:护栏非沙箱;locked 模式提供白名单强约束;危险模式文档提示
宿主 settings 是否允许插件注册 ns实现时先查询 settings 契约确认 register 可用性,不可用则降级 storage domain

9. 契约依据(已验证)

  • Host 服务:subprocess.spawnTerminal(spec): Promise<SubprocessTerminalHandle>credentials.resolve/set/unset/modifyRecord/…userQuestions.ask(request): Promise<AskUserQuestionAnswer>settings.register/installSectionterminals.registerBackend(P3 备用);
  • Host 事件:user-questions/request(waterfall)、credentials/record-updated;
  • Host Builtins:harness.handle / harness.registerToolTextEncoder/TextDecoderbtoa/atob;无 require(决定 ssh 二进制包装方案);
  • Client Builtins:React(useState/useEffect)host.callstyles.insert;无 xterm(决定自绘 ANSI);
  • Client Slots:shell.overlay(list)/ sidebar.footer.action(list)/ settings.section(list)/ tool.view.cordis(keyed, self)。