Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@

### 新增

- **模型远程连接确认**:当前模型会话首次使用服务器,或用户手动断开后再次使用时,必须先经过真人确认;授权在当前插件生命周期内按模型会话和服务器分别保留,不同模型会话即使复用同一个 SSH 会话也必须各自确认;网络故障、SSH 退出或连接等待超时只会自动重连,不会再次询问;确认服务不可用时不会自动建立 SSH。
- **服务器规则编辑区重做(设置 → 远程终端)**:规则不再是一排裸输入框——标题行改为「服务器规则 + 按顺序匹配 · 优先于内置规则库 · 只约束 AI 执行的命令」,正文补充匹配语义说明(完整命令行通配、`*` / `?` 通配符、忽略大小写、自动剥除 sudo / nohup 等前缀、从上到下先命中先生效、deny / ask / allow 三种动作含义);新增四个**可点击示例**(放行日志清理 / 禁止强推 / systemctl 先确认 / docker rm 先确认),点击即作为一条规则填入列表;规则列表带行号,动作用中英双语下拉(deny 拦截 / ask 确认 / allow 放行);空规则列表显示占位说明。保存时校验规则模式不能为空(此前空模式行被宿主静默丢弃,用户无从得知)。

### 修复

- **终端输出 PassThrough 监听器泄漏**:输出消费改为每个会话只创建一个 async iterator,避免每个输出 chunk 重复注册 `end` / `finish` / `error` / `close` 监听器,修复启动或长时间运行时的 `MaxListenersExceededWarning`。
- **终端左右移动光标卡顿与右侧空白**:ANSI 光标移动产生的空白列现在会保留并参与渲染,避免光标被压到行尾;输入完成后立即刷新当前会话,并串行化 poll 快照,减少方向键延迟和旧快照覆盖。
- **AI 危险命令确认卡在部分挂载形态退化为面板兜底(原生弹卡回归修复)**:`netshell_run` 命中 `ask` 规则时的首选路径是直调宿主 `userQuestions.ask` 在对话窗口弹原生确认卡,但 `userQuestions` 服务的可解析性依赖插件 ctx 所处作用域——动态加载宿主半区挂在根组合 `cordis-dynamic` 组下,自身 ctx 取不到该服务时旧代码直接落到面板兜底。现 `resolveUserQuestions(agent)` 依次尝试:自身 ctx → `exec.agent.ctx` → `agents.get(agent.id).ctx` 桥接;带 `agent` 的作用域瀑布若返回 `NO_PROVIDER`(回答者未注册在该作用域),自动退一次**不带 agent 的全局瀑布**重试(真人点卡的机制性授权不变);仍失败才回退面板。所有回退路径的失败码与解析诊断随工具 `message` 带出并打日志(`[未弹原生卡:…]`),便于定位。
- **服务器规则编辑区横向溢出(超出设置面板)**:动作下拉同时设了 `flex: none`(不可收缩)与内联 `maxWidth: 'none'`(移除了 `select.nsh-in` 的 240px 上限),而 `.nsh-in` 自带 `width: 100%`,使下拉以"容器全宽"为基准且不可收缩,把输入框和删除按钮整体挤出面板。现下拉改为固定 118px,模式输入框 `min-width: 0` 允许收缩,窄窗口下整行可正常压缩;删除按钮从整颗「删除」胶囊改为紧凑 ✕ 图标按钮,行内布局不再拥挤。

Expand Down
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ The plugin exposes two model tools:

AI commands and human input use the same Guard. `deny` commands never run, and `ask` commands require a decision from you in the DSH confirmation UI or terminal panel. The AI cannot see passwords or forge approval results.

On the first use of a server by a model session, or after the user manually disconnects that server, `netshell_run` asks for remote-connection approval before starting SSH. Approval is retained per model session and server for the current plugin lifetime; different model sessions must approve independently even when they reuse the same SSH session. A network failure, SSH exit, or connection timeout only triggers reconnection for the already-approved model session. A manually opened GUI session does not replace model approval. If the confirmation service is unavailable, the plugin fails closed and does not connect automatically.

## Security Boundary

This is an operation guard, not a complete security sandbox. It protects commands entering through this plugin's terminal and `netshell_run`; SSH connections created by other plugins or generic shell tools are outside its scope. Use `locked` for sensitive environments and review actions inside interactive programs carefully.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ dsh plugin --profile web add link:/path/to/dsh-netshell

AI 和人工输入共用同一套 Guard。`deny` 命令不会执行,`ask` 命令必须由你在 DSH 的确认卡或终端面板中裁决;AI 看不到密码,也不能伪造确认结果。

当前模型会话首次使用某台服务器,或用户手动断开该服务器后再次使用时,`netshell_run` 会先弹出远程连接确认;确认前不会启动 SSH。确认在当前插件生命周期内按模型会话和服务器分别保留;不同模型会话即使复用同一个 SSH 会话,也必须各自确认。网络故障、SSH 异常退出或连接等待超时只会触发当前模型会话的自动重连,不会再次询问。GUI 中已有的人工连接不会替代模型确认;确认服务不可用时插件会拒绝自动建连。

## 安全边界

这是命令操作护栏,不是完整的安全沙箱。它只保护通过本插件终端和 `netshell_run` 进入的命令;其他插件或通用 Shell 工具建立的 SSH 连接不在拦截范围内。对敏感环境建议使用 `locked`,并审查交互式程序内部执行的操作。
Expand Down
12 changes: 7 additions & 5 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ server 档案字段:`{ id, name, host, port, user, auth: 'password'|'key'|'agent
注意:

- `netshell.poll` 目前是全量快照:`output` 为缓冲全文,`nextCursor` 恒为 `0`、`lossy` 恒为 `false`——增量游标是**预留字段**,未实现;
- Client 以 **150ms** 固定间隔轮询:`discoverSessions`(发现外部 / 模型开的会话与 pending,必要时自动弹出面板)+ `pollOne(activeId)`(仅当前会话)。
- Client 以 **80ms** 固定间隔轮询:`discoverSessions`(发现外部 / 模型开的会话与 pending,必要时自动弹出面板)+ `pollOne(activeId)`(仅当前会话);同一会话的 poll 请求串行化,输入 RPC 完成后额外触发一次即时 poll

## 6. Guard 引擎(Host 侧)

Expand Down Expand Up @@ -160,14 +160,16 @@ server 档案字段:`{ id, name, host, port, user, auth: 'password'|'key'|'agent

- **`netshell_servers`**:无参数,读 `PKEY` 返回 `{ servers: [{ id, name, host, port, user, auth, level }] }`;
- **`netshell_run`**:参数 `server`(必填)、`command`(必填)、`timeoutMs`(默认 30000);`confirmToken` 仅回退路径使用,`choice` 已废弃(授权只认真人裁决,参数被忽略)。执行流(`toolRunExecute`):
1. `resolveServer` → `ensureSession`(**复用或新建交互 PTY 会话**,与面板共享,waitLive 最长 20s);
2. Guard 评估:`deny` → 直接返回 blocked;`allow` → `runRemote`(`ssh -T … <cmd>` 独立一次性执行,同样使用私有 known_hosts;stdout 上限 200K/spill 400K);
3. `ask` → 依次尝试四条路径:
1. Guard 先评估硬拒绝规则:`deny` → 直接返回 blocked,不建立 SSH;其他命令进入模型连接确认流程;
2. 当前模型会话首次使用服务器,或用户主动断开后该会话的服务器授权已被清除时,经 `userQuestions.ask` 请求真人确认。授权在当前插件生命周期内按 Agent + 服务器保留;不同 Agent 即使复用同一个共享 SSH 会话也必须分别确认。网络/SSH 异常退出或连接等待超时只会使当前会话失效,已授权 Agent 下一次调用沿用授权自动重连。确认服务不可用时 fail closed,不自动连接;同一 Agent 的并发调用共享确认任务;
3. 确认通过后 `resolveServer` → `ensureSession`(**复用或新建交互 PTY 会话**,与面板共享,waitLive 最长 20s);
4. Guard 评估:`deny` → 直接返回 blocked;`allow` → `runRemote`(`ssh -T … <cmd>` 独立一次性执行,同样使用私有 known_hosts;stdout 上限 200K/spill 400K);
5. `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` 与事件流**,面板全程可见模型做了什么。
6. 结果(stdout/stderr/exitCode)与 `$ <cmd>` 一起**回写共享会话的 `outAll` 与事件流**,面板全程可见模型做了什么。

授权凭证只会来自真人操作(确认卡答案 / 面板 `netshell.decide` 点击),模型的 `choice` 参数不参与授权——这是机制性绑定,不依赖模型自觉。

Expand Down
1 change: 1 addition & 0 deletions UPDATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@

- 本地终端不会读取远程服务器档案或 SSH 密码。
- 本地和远程会话继续使用统一的命令 Guard;`allow`、`ask` 和 `deny` 的行为保持一致。
- **模型远程连接确认**:当前模型会话首次使用服务器,或用户手动断开后再次使用时,必须先经过用户确认;授权在当前插件生命周期内按模型会话和服务器分别保留,不同模型会话即使复用同一个 SSH 会话也必须各自确认。网络故障、SSH 退出或连接等待超时只会触发已授权模型会话的自动重连,不会再次询问。确认服务不可用时安全拒绝,不会自动建连。
- README 明确说明插件的安全边界:命令护栏不是完整沙箱,其他 SSH 通道不受本插件拦截。
52 changes: 38 additions & 14 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ var CSS = ''
+ '.nsh-term{flex:1;min-height:0;overflow:auto;padding:10px 12px;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre;outline:none;cursor:text;background:var(--nsh-term-bg);color:var(--nsh-term-fg)}'
+ '.nsh-term-dark{--nsh-term-bg:#0d1117;--nsh-term-fg:#c9d1d9;--nsh-cursor:#3fb950}'
+ '.nsh-term-light{--nsh-term-bg:#ffffff;--nsh-term-fg:#24292f;--nsh-cursor:#116329}'
+ '.nsh-cursor{color:var(--nsh-cursor);animation:nsh-blink 1.1s step-end infinite}'
+ '.nsh-cursor{display:inline-block;box-sizing:border-box;width:1ch;height:1.15em;overflow:hidden;vertical-align:-0.15em;color:transparent;background:var(--nsh-cursor);animation:nsh-blink 1.1s step-end infinite}'
+ '@keyframes nsh-blink{0%,49%{opacity:1}50%,100%{opacity:0}}'
+ '@media (prefers-reduced-motion: reduce){.nsh-cursor{animation:none}}'
+ '.nsh-line{min-height:20px}'
Expand Down Expand Up @@ -309,9 +309,12 @@ function lineSpans(raw) {
var spans = []
var buf = ''
var curS = null
for (var m = 0; m < cells.length; m++) {
var cell = cells[m]
if (!cell) { if (buf) { spans.push({ t: buf, s: curS }); buf = ''; curS = null } continue }
var blankS = { fg: null, bg: null, bold: false, dim: false, under: false, it: false, strike: false }
// CSI 光标移动可以在已有内容后留下未写入的列。空洞必须保留为真实空格,
// 否则渲染器只按文本长度计算,光标会被错误压到行尾。
var end = Math.max(cells.length, col)
for (var m = 0; m < end; m++) {
var cell = cells[m] || { t: ' ', s: blankS }
if (curS && styleEq(curS, cell.s)) { buf += cell.t }
else { if (buf) spans.push({ t: buf, s: curS }); buf = cell.t; curS = cell.s }
}
Expand All @@ -324,7 +327,8 @@ function newScreen(serverName) {
return {
serverName: serverName, lines: [], cur: '', lastCursor: 0, lastSeq: 0,
events: [], status: 'connecting', pending: null, closedReason: null,
hint: null, lossy: false, dropped: 0, showHist: false, nExec: 0, nDeny: 0, nAsk: 0
hint: null, lossy: false, dropped: 0, showHist: false, nExec: 0, nDeny: 0, nAsk: 0,
pollInFlight: false, pollAgain: false, pollPromise: null
}
}

Expand Down Expand Up @@ -508,7 +512,12 @@ function refreshServers() {
function pollOne(id) {
var sc = screens.get(id)
if (!sc) return Promise.resolve()
return host.call('netshell.poll', { id: id }).then(function (r) {
if (sc.pollInFlight) {
sc.pollAgain = true
return sc.pollPromise || Promise.resolve()
}
sc.pollInFlight = true
var request = host.call('netshell.poll', { id: id }).then(function (r) {
if (r && r.gone) {
// 宿主已无此会话(断开/移除),清理本地并停止轮询;一并拉黑,避免在途列表加回。
removedIds.add(id)
Expand Down Expand Up @@ -540,13 +549,23 @@ function pollOne(id) {
}
}
store.set({ tick: store.st.tick + 1 })
}).catch(function () {})
}).catch(function () {}).then(function () {
sc.pollInFlight = false
sc.pollPromise = null
if (sc.pollAgain && screens.has(id)) {
sc.pollAgain = false
return pollOne(id)
}
sc.pollAgain = false
})
sc.pollPromise = request
return request
}

function discoverSessions() {
return host.call('netshell.sessions.list', {}).then(function (r) {
var list = (r && r.sessions) || []
// 先清理宿主已不存在的会话(断开/移除):避免残留 id 被每 150ms 轮询,
// 先清理宿主已不存在的会话(断开/移除):避免残留 id 被每 80ms 轮询,
// 也避免 pollOne 对已消失会话报 handler 失败。
var present = {}
for (var p = 0; p < list.length; p++) if (list[p] && list[p].id) present[list[p].id] = true
Expand Down Expand Up @@ -677,7 +696,9 @@ function TermView(props) {
var data = keyToData(e)
if (data !== null && data !== undefined) {
e.preventDefault()
host.call('netshell.input', { id: id, data: data }).catch(function () {})
host.call('netshell.input', { id: id, data: data })
.then(function () { return pollOne(id) })
.catch(function () {})
}
}
var view = sc.lines.slice(-400)
Expand All @@ -699,18 +720,21 @@ function TermView(props) {
var placed = false
for (var k = 0; k < liveSpans.length; k++) {
var sp = liveSpans[k]
if (!placed && used + sp.t.length >= liveCol) {
if (!placed && liveCol >= used && liveCol < used + sp.t.length) {
var off = liveCol - used
if (off > 0) liveEls.push(h('span', { key: 'p' + k, style: spanStyle(sp) }, sp.t.slice(0, off)))
liveEls.push(h('span', { key: 'cur', className: 'nsh-cursor' }, '▌'))
if (off < sp.t.length) liveEls.push(h('span', { key: 'q' + k, style: spanStyle(sp) }, sp.t.slice(off)))
var curStyle = spanStyle(sp)
curStyle.backgroundColor = 'var(--nsh-cursor)'
curStyle.color = 'var(--nsh-term-bg)'
liveEls.push(h('span', { key: 'cur', className: 'nsh-cursor', style: curStyle }, sp.t.charAt(off)))
if (off + 1 < sp.t.length) liveEls.push(h('span', { key: 'q' + k, style: spanStyle(sp) }, sp.t.slice(off + 1)))
placed = true
} else {
liveEls.push(h('span', { key: k, style: spanStyle(sp) }, sp.t))
}
used += sp.t.length
}
if (!placed) liveEls.push(h('span', { key: 'curEnd', className: 'nsh-cursor' }, ''))
if (!placed) liveEls.push(h('span', { key: 'curEnd', className: 'nsh-cursor' }, ' '))
children.push(h('div', { key: 'live', className: 'nsh-line' }, liveEls))
return h('div', { className: 'nsh-term ' + th.cls, tabIndex: 0, ref: setNode, onKeyDown: onKeyDown }, children)
}
Expand Down Expand Up @@ -1172,7 +1196,7 @@ return {
void discoverSessions()
var id = store.st.activeId
if (id) void pollOne(id)
}, 150)
}, 80)
})
void refreshServers()
slots.inject('conversation.view', function () {
Expand Down
Loading
Loading