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 打印一律脱敏。
- RPC 面(
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(提交评估)、Backspace、Ctrl-U(清行)、Ctrl-C(弃行)、Tab(放行给 shell 补全,不评估)、粘贴(整段缓冲,跨行粘贴按行评估)。引号/管道/&&/;; 连接的复合命令整条参与评估,不做逐段放行。
4.2 三级权限语义
| 等级 | 语义 |
|---|---|
open | 默认放行;仅 deny 规则仍硬拦 |
guarded(默认) | ask 规则命中 → 询问;deny → 拦;其余放行 |
locked | 仅 allow 规则直接放行;其余一律询问 |
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.overlay | list | 浮动终端面板(可拖动、可最小化到角落 pill) |
sidebar.footer.action | list | 「远程终端」入口按钮 |
settings.section | list | 「远程终端」管理页(服务器 CRUD、密码、等级、规则库) |
tool.view.cordis | keyed(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.list | C→H | 服务器档案列表(不含密码) |
netshell.servers.save | C→H | 新建/更新档案(密码经 credentials.set 由 Host 写入) |
netshell.servers.delete | C→H | 删档案 + 删凭据 |
netshell.connect | C→H | { serverId } → 建立 PTY,返回 { sessionId } |
netshell.input | C→H | { sessionId, data } 键盘输入(经 Guard 行缓冲) |
netshell.poll | C→H | { sessionId, cursor } → { output, events, guardState } |
netshell.resize | C→H | { sessionId, cols, rows } |
netshell.disconnect | C→H | 关闭会话 |
netshell.sessions.list | C→H | 活跃会话 + 历史摘要 |
全部参数/返回为 lossless JSON;handler 内部使用宿主服务 subprocess、credentials、settings、userQuestions。
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/installSection、terminals.registerBackend(P3 备用); - Host 事件:
user-questions/request(waterfall)、credentials/record-updated; - Host Builtins:
harness.handle/harness.registerTool、TextEncoder/TextDecoder、btoa/atob;无require(决定 ssh 二进制包装方案); - Client Builtins:
React(useState/useEffect)、host.call、styles.insert;无 xterm(决定自绘 ANSI); - Client Slots:
shell.overlay(list)/sidebar.footer.action(list)/settings.section(list)/tool.view.cordis(keyed,self)。