From 1190b34af400fe5f6453e06ff7905730acd06d29 Mon Sep 17 00:00:00 2001 From: gone Date: Mon, 7 Sep 2026 16:09:18 +0800 Subject: [PATCH 1/6] feat(netshell): retain approval across unexpected disconnects --- CHANGELOG.md | 1 + README.en.md | 2 + README.md | 2 + TECHNICAL.md | 10 +-- UPDATES.md | 1 + lib/index.js | 160 ++++++++++++++++++++++++++++++++++++++++++++-- src/nsh-host.js | 160 ++++++++++++++++++++++++++++++++++++++++++++-- test/ask-flow.mjs | 83 ++++++++++++++++++++++-- 8 files changed, 399 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62290d7..53dffe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### 新增 +- **模型远程连接确认**:模型首次使用服务器,或用户手动断开后再次使用时,必须先经过真人确认;授权在当前插件生命周期内按服务器保留,网络故障、SSH 退出或连接等待超时只会自动重连,不会再次询问;确认服务不可用时不会自动建立 SSH。 - **服务器规则编辑区重做(设置 → 远程终端)**:规则不再是一排裸输入框——标题行改为「服务器规则 + 按顺序匹配 · 优先于内置规则库 · 只约束 AI 执行的命令」,正文补充匹配语义说明(完整命令行通配、`*` / `?` 通配符、忽略大小写、自动剥除 sudo / nohup 等前缀、从上到下先命中先生效、deny / ask / allow 三种动作含义);新增四个**可点击示例**(放行日志清理 / 禁止强推 / systemctl 先确认 / docker rm 先确认),点击即作为一条规则填入列表;规则列表带行号,动作用中英双语下拉(deny 拦截 / ask 确认 / allow 放行);空规则列表显示占位说明。保存时校验规则模式不能为空(此前空模式行被宿主静默丢弃,用户无从得知)。 ### 修复 diff --git a/README.en.md b/README.en.md index c08cb19..93952a3 100644 --- a/README.en.md +++ b/README.en.md @@ -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 model use of a server, or after the user manually disconnects that server, `netshell_run` asks for remote-connection approval before starting SSH. Approval is retained per server for the current plugin lifetime; a network failure, SSH exit, or connection timeout only triggers automatic reconnection and does not ask again. 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. diff --git a/README.md b/README.md index 8fc14e1..8b015b6 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ dsh plugin --profile web add link:/path/to/dsh-netshell AI 和人工输入共用同一套 Guard。`deny` 命令不会执行,`ask` 命令必须由你在 DSH 的确认卡或终端面板中裁决;AI 看不到密码,也不能伪造确认结果。 +模型首次使用某台服务器,或用户手动断开该服务器后再次使用时,`netshell_run` 会先弹出远程连接确认;确认前不会启动 SSH。确认在当前插件生命周期内按服务器保留,网络故障、SSH 异常退出或连接等待超时只会触发自动重连,不会再次询问。GUI 中已有的人工连接不会替代模型确认;确认服务不可用时插件会拒绝自动建连。 + ## 安全边界 这是命令操作护栏,不是完整的安全沙箱。它只保护通过本插件终端和 `netshell_run` 进入的命令;其他插件或通用 Shell 工具建立的 SSH 连接不在拦截范围内。对敏感环境建议使用 `locked`,并审查交互式程序内部执行的操作。 diff --git a/TECHNICAL.md b/TECHNICAL.md index e8bfb77..7628eff 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -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 … ` 独立一次性执行,同样使用私有 known_hosts;stdout 上限 200K/spill 400K); - 3. `ask` → 依次尝试四条路径: + 1. Guard 先评估硬拒绝规则:`deny` → 直接返回 blocked,不建立 SSH;其他命令进入模型连接确认流程; + 2. 模型首次使用服务器,或用户主动断开后服务器授权已被清除时,经 `userQuestions.ask` 请求真人确认。授权在当前插件生命周期内按服务器保留;网络/SSH 异常退出或连接等待超时只会使当前会话失效,下一次调用沿用授权自动重连。确认服务不可用时 fail closed,不自动连接。并发调用共享同一条确认/建连任务; + 3. 确认通过后 `resolveServer` → `ensureSession`(**复用或新建交互 PTY 会话**,与面板共享,waitLive 最长 20s); + 4. Guard 评估:`deny` → 直接返回 blocked;`allow` → `runRemote`(`ssh -T … ` 独立一次性执行,同样使用私有 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)与 `$ ` 一起**回写共享会话的 `outAll` 与事件流**,面板全程可见模型做了什么。 + 6. 结果(stdout/stderr/exitCode)与 `$ ` 一起**回写共享会话的 `outAll` 与事件流**,面板全程可见模型做了什么。 授权凭证只会来自真人操作(确认卡答案 / 面板 `netshell.decide` 点击),模型的 `choice` 参数不参与授权——这是机制性绑定,不依赖模型自觉。 diff --git a/UPDATES.md b/UPDATES.md index 2f37568..01e5db2 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -16,4 +16,5 @@ - 本地终端不会读取远程服务器档案或 SSH 密码。 - 本地和远程会话继续使用统一的命令 Guard;`allow`、`ask` 和 `deny` 的行为保持一致。 +- **模型远程连接确认**:模型首次使用服务器,或用户手动断开后再次使用时,必须先经过用户确认;授权在当前插件生命周期内按服务器保留,网络故障、SSH 退出或连接等待超时只会触发自动重连,不会再次询问。确认服务不可用时安全拒绝,不会自动建连。 - README 明确说明插件的安全边界:命令护栏不是完整沙箱,其他 SSH 通道不受本插件拦截。 diff --git a/lib/index.js b/lib/index.js index 4d0d7af..10ce515 100644 --- a/lib/index.js +++ b/lib/index.js @@ -208,6 +208,12 @@ return { // (无 answerer / 子代理上下文 / 动态加载无服务)时回退「面板挂起 + 一次性令牌」—— // 令牌只有在面板真实裁决(allow/always/deny)后才可兑现,choice 参数不作授权依据。 var askTokens = new Map() + // 模型建连确认的在途请求:同一服务器同时只允许一条确认/建连流程, + // 避免并发工具调用在用户回答前各自启动 SSH。 + var modelConnectJobs = new Map() + // 模型授权按服务器保留在当前插件生命周期内;只有用户主动断开才清除。 + // SSH 异常退出、网络断开和连接等待超时只会使当前会话失效,不会撤销这项授权。 + var modelAuthorizedServers = new Set() // 宿主 realm 空白对象工厂:describe 返回的 CredentialInfo 是宿主 realm // 出身,delete 掉字段后就是干净的宿主 plain object。 @@ -412,6 +418,75 @@ return { return { uq: uq, diag: diag.join(',') } } + function modelConnectionResult(server, action, detail) { + var name = (server && (server.name || server.host)) || '目标服务器' + var suffix = detail ? ' ' + detail : '' + return { + ok: false, + blocked: true, + action: action, + needsConfirmation: true, + server: server && server.id, + output: '', + message: '未建立到「' + name + '」的远程连接。' + suffix + } + } + + // 模型首次使用服务器,或用户主动断开后授权已被清除时,必须先取得真人确认。 + // 确认服务不可用时 fail closed:不建立 SSH,也不把连接确认降级成模型可伪造的参数。 + function requestModelConnectionApproval(server, cmd, exec) { + var rq = resolveUserQuestions(exec && exec.agent) + var uq = rq.uq + if (!uq || !exec || !exec.agent) { + return Promise.resolve({ + approved: false, + result: modelConnectionResult(server, 'connect-confirmation-unavailable', '当前无法弹出用户确认卡,为安全起见未自动连接。[' + rq.diag + ']') + }) + } + var question = { + id: 'netshell-connect', + header: '远程连接确认', + question: '模型请求连接「' + (server.name || server.host) + '」并执行远程命令,是否允许?' + NL + '$ ' + String(cmd || ''), + detail: '这是首次使用该服务器,或用户之前手动断开过该服务器,需要重新建立 SSH 连接。', + options: [ + { label: '允许本次连接', description: '仅允许当前模型调用建立或重新建立 SSH 会话' }, + { label: '拒绝', description: '不建立远程连接,本次命令不执行' } + ] + } + function ask(withAgent) { + var req = { questions: [question], signal: exec.signal } + if (withAgent) req.agent = exec.agent + return Promise.resolve().then(function () { return uq.ask(req) }) + } + function parseAnswer(ans) { + var selected = ans && ans.answers && ans.answers[0] && ans.answers[0].selected + var pick = Array.isArray(selected) && selected.length > 0 ? selected[0] : '' + if (pick === '允许本次连接') return { approved: true } + return { + approved: false, + result: modelConnectionResult(server, 'connect-deny', '用户拒绝了本次连接确认。') + } + } + function failed(e, diag) { + var code = (e && e.code) || '?' + if (code === 'ASK_ABORTED') { + return { approved: false, result: modelConnectionResult(server, 'aborted', '用户中止了连接确认。') } + } + return { + approved: false, + result: modelConnectionResult(server, 'connect-confirmation-unavailable', '当前无法完成用户确认,为安全起见未自动连接。[' + diag + ' code=' + code + ']') + } + } + return ask(true).then(parseAnswer, function (e) { + // 与危险命令确认一致:带 agent 的作用域没有 answerer 时, + // 退一次全局 waterfall,但授权仍然只来自真人答案。 + if (e && e.code === 'NO_PROVIDER') { + return ask(false).then(parseAnswer, function (e2) { return failed(e2, rq.diag + ',global') }) + } + return failed(e, rq.diag) + }) + } + // 查找「面板已裁决、尚未被兑现」的工具令牌:(服务器, 命令) 精确匹配。 function findDecided(serverId, cmd) { var found = null @@ -612,7 +687,9 @@ function makeAskpass(s) { harness.handle('netshell.profiles.delete', function (args) { var id = args && args.id if (!id) return Promise.reject(new Error('缺少 id')) + modelAuthorizedServers.delete(id) sessions.forEach(function (s) { + if (s.server && s.server.id === id) s.modelApproved = false if (s.server && s.server.id === id && s.status !== 'closed') { try { void s.handle.terminate() } catch (e) {} } @@ -661,7 +738,8 @@ function makeAskpass(s) { id: 'ns' + Date.now().toString(36) + (++nonce), server: server, status: 'connecting', outAll: '', outBase: 0, dropped: 0, events: [], evSeq: 0, line: '', hist: [], histIdx: undefined, - pending: null, tail: '', atPwPrompt: false, hint: null, closedReason: null, askpassPath: null + pending: null, tail: '', atPwPrompt: false, hint: null, closedReason: null, askpassPath: null, + modelApproved: false } var envJob = Promise.resolve(null) if (server.auth === 'password') { @@ -766,6 +844,48 @@ function makeAskpass(s) { return spawnSession(server) } + function findApprovedModelSession(serverId) { + var found = null + sessions.forEach(function (s) { + if (!found && s.server && s.server.id === serverId && s.modelApproved && s.status !== 'closed') found = s + }) + return found + } + + function ensureModelSession(server, cmd, exec) { + var existing = findApprovedModelSession(server.id) + if (existing) return Promise.resolve({ approved: true, session: existing }) + var active = modelConnectJobs.get(server.id) + + if (active) return active + + var job = Promise.resolve().then(function () { + var current = findApprovedModelSession(server.id) + if (current) return { approved: true, session: current } + var approval = modelAuthorizedServers.has(server.id) + ? Promise.resolve({ approved: true }) + : requestModelConnectionApproval(server, cmd, exec) + return approval.then(function (decision) { + if (!decision.approved) return decision + // 真人批准后,授权保留到插件生命周期结束或用户主动断开该服务器。 + modelAuthorizedServers.add(server.id) + return ensureSession(server).then(function (session) { + session.modelApproved = true + return { approved: true, session: session } + }, function (e) { + return { + approved: false, + result: modelConnectionResult(server, 'connect-failed', 'SSH 会话建立失败:' + String((e && e.message) || e).slice(0, 180)) + } + }) + }) + }) + modelConnectJobs.set(server.id, job) + var clear = function () { if (modelConnectJobs.get(server.id) === job) modelConnectJobs.delete(server.id) } + job.then(clear, clear) + return job + } + function waitLive(s, timeoutMs, signal) { var deadline = Date.now() + (timeoutMs || 20000) return new Promise(function (resolve) { @@ -920,11 +1040,30 @@ function makeAskpass(s) { if (!cmd || typeof cmd !== 'string') return Promise.reject(new Error('缺少 command 参数')) return resolveServer(serverId).then(function (server) { if (!server) return Promise.reject(new Error('找不到服务器档案: ' + serverId)) - return ensureSession(server).then(function (disp) { + // 硬拒绝命令无需建立 SSH,避免连上服务器后才发现命令本来就不允许。 + var initial = evaluateFor(server, cmd) + if (initial.action === 'deny') { + var initialRule = initial.rule && (initial.rule.note || initial.rule.pattern) + return { + ok: false, blocked: true, action: 'deny', rule: initialRule || '内置规则', output: '', command: cmd + } + } + return ensureModelSession(server, cmd, exec).then(function (connection) { + if (!connection.approved) return connection.result + var disp = connection.session disp.agentBusy = true var toolBody = function () { return waitLive(disp, 20000, exec && exec.signal).then(function (ready) { - if (!ready) return { ok: false, blocked: false, error: '会话未就绪或已断开:' + (disp.closedReason || disp.hint || 'unknown'), output: '' } + if (!ready) { + // 连接等待超时或被取消时,销毁尚未就绪的会话,但保留服务器级模型授权; + // 下一次模型调用可以自动重连,不复用这条半连接状态。 + if (disp.status === 'connecting') { + disp.hint = '连接等待超时或已取消' + try { void disp.handle.terminate() } catch (e) {} + onExit(disp, { exitCode: null, signal: 'timeout' }) + } + return { ok: false, blocked: false, error: '会话未就绪或已断开:' + (disp.closedReason || disp.hint || 'unknown'), output: '' } + } var v = evaluateFor(server, cmd) var ruleNote = v.rule && (v.rule.note || v.rule.pattern) function appendOut(r, addCmd) { @@ -1127,7 +1266,16 @@ harness.handle('netshell.input', function (args) { if (s.handle) { try { void s.handle.terminate() } catch (e) {} } // 退出即移除:之前只 terminate 不移除,会话残留在 sessions Map, // 客户端 discoverSessions 下一轮又会把它加回来,导致「点了删除却删不掉」。 - sessions.delete(args && args.id) + var serverId = s.server && s.server.id + if (serverId) { + // 只有用户主动断开才撤销服务器级模型授权; + // 网络/SSH 退出走 onExit,不会触碰这个集合。 + modelAuthorizedServers.delete(serverId) + sessions.forEach(function (other) { + if (other.server && other.server.id === serverId) other.modelApproved = false + }) + } + sessions.delete(args && args.id) return Promise.resolve({ ok: true }) }) @@ -1158,7 +1306,7 @@ harness.handle('netshell.input', function (args) { var runTool = harness.defineTool({ name: 'netshell_run', - description: '在指定的远程服务器(NetShell 终端)上执行一条 shell 命令并返回输出。命令通过真实 SSH 终端执行,支持权限控制:命中 deny 规则(如 rm -rf /)直接拦截;命中 ask 规则(如 rm -rf *)时,本工具会直接在对话窗口弹出原生确认卡并原地等待用户选择(执行一次 / 永久放行该命令 / 拒绝),无需你再调用 ask_user_question;仅当当前环境无法弹卡(子代理上下文或无界面)时,才返回 blocked + confirmToken 并在终端面板挂起,此时请提醒用户到「远程终端」面板裁决,再携 confirmToken 重跑本工具。用 netshell_servers 查询服务器 id 作为 server 参数。', + description: '仅当用户明确要求在指定远程服务器上执行命令时使用;本机问题优先使用本地工具,不要根据“网络问题”或“服务异常”等模糊描述自行选择远程服务器。在指定服务器(NetShell 终端)上执行一条 shell 命令并返回输出。模型首次使用服务器或用户主动断开后,插件会先要求用户确认远程连接,确认前不会建立 SSH。命令通过真实 SSH 终端执行,支持权限控制:命中 deny 规则(如 rm -rf /)直接拦截;命中 ask 规则(如 rm -rf *)时,本工具会直接在对话窗口弹出原生确认卡并原地等待用户选择(执行一次 / 永久放行该命令 / 拒绝),无需你再调用 ask_user_question;仅当当前环境无法弹卡(子代理上下文或无界面)时,连接确认和危险命令确认都会安全拒绝,不会自动建连。用 netshell_servers 查询服务器 id 作为 server 参数。', parameters: { server: { type: 'string', description: '服务器 id,来自 netshell_servers 返回的 id 字段', required: true }, command: { type: 'string', description: '要在服务器上执行的 shell 命令', required: true }, @@ -1188,6 +1336,8 @@ harness.handle('netshell.input', function (args) { ctx.effect(function () { return function () { askTokens.clear() + modelConnectJobs.clear() + modelAuthorizedServers.clear() sessions.forEach(function (s) { if (s.handle) { try { void s.handle.terminate() } catch (e) {} } }) diff --git a/src/nsh-host.js b/src/nsh-host.js index 273d0ec..fdde4a4 100644 --- a/src/nsh-host.js +++ b/src/nsh-host.js @@ -130,6 +130,12 @@ return { // (无 answerer / 子代理上下文 / 动态加载无服务)时回退「面板挂起 + 一次性令牌」—— // 令牌只有在面板真实裁决(allow/always/deny)后才可兑现,choice 参数不作授权依据。 var askTokens = new Map() + // 模型建连确认的在途请求:同一服务器同时只允许一条确认/建连流程, + // 避免并发工具调用在用户回答前各自启动 SSH。 + var modelConnectJobs = new Map() + // 模型授权按服务器保留在当前插件生命周期内;只有用户主动断开才清除。 + // SSH 异常退出、网络断开和连接等待超时只会使当前会话失效,不会撤销这项授权。 + var modelAuthorizedServers = new Set() // 宿主 realm 空白对象工厂:describe 返回的 CredentialInfo 是宿主 realm // 出身,delete 掉字段后就是干净的宿主 plain object。 @@ -334,6 +340,75 @@ return { return { uq: uq, diag: diag.join(',') } } + function modelConnectionResult(server, action, detail) { + var name = (server && (server.name || server.host)) || '目标服务器' + var suffix = detail ? ' ' + detail : '' + return { + ok: false, + blocked: true, + action: action, + needsConfirmation: true, + server: server && server.id, + output: '', + message: '未建立到「' + name + '」的远程连接。' + suffix + } + } + + // 模型首次使用服务器,或用户主动断开后授权已被清除时,必须先取得真人确认。 + // 确认服务不可用时 fail closed:不建立 SSH,也不把连接确认降级成模型可伪造的参数。 + function requestModelConnectionApproval(server, cmd, exec) { + var rq = resolveUserQuestions(exec && exec.agent) + var uq = rq.uq + if (!uq || !exec || !exec.agent) { + return Promise.resolve({ + approved: false, + result: modelConnectionResult(server, 'connect-confirmation-unavailable', '当前无法弹出用户确认卡,为安全起见未自动连接。[' + rq.diag + ']') + }) + } + var question = { + id: 'netshell-connect', + header: '远程连接确认', + question: '模型请求连接「' + (server.name || server.host) + '」并执行远程命令,是否允许?' + NL + '$ ' + String(cmd || ''), + detail: '这是首次使用该服务器,或用户之前手动断开过该服务器,需要重新建立 SSH 连接。', + options: [ + { label: '允许本次连接', description: '仅允许当前模型调用建立或重新建立 SSH 会话' }, + { label: '拒绝', description: '不建立远程连接,本次命令不执行' } + ] + } + function ask(withAgent) { + var req = { questions: [question], signal: exec.signal } + if (withAgent) req.agent = exec.agent + return Promise.resolve().then(function () { return uq.ask(req) }) + } + function parseAnswer(ans) { + var selected = ans && ans.answers && ans.answers[0] && ans.answers[0].selected + var pick = Array.isArray(selected) && selected.length > 0 ? selected[0] : '' + if (pick === '允许本次连接') return { approved: true } + return { + approved: false, + result: modelConnectionResult(server, 'connect-deny', '用户拒绝了本次连接确认。') + } + } + function failed(e, diag) { + var code = (e && e.code) || '?' + if (code === 'ASK_ABORTED') { + return { approved: false, result: modelConnectionResult(server, 'aborted', '用户中止了连接确认。') } + } + return { + approved: false, + result: modelConnectionResult(server, 'connect-confirmation-unavailable', '当前无法完成用户确认,为安全起见未自动连接。[' + diag + ' code=' + code + ']') + } + } + return ask(true).then(parseAnswer, function (e) { + // 与危险命令确认一致:带 agent 的作用域没有 answerer 时, + // 退一次全局 waterfall,但授权仍然只来自真人答案。 + if (e && e.code === 'NO_PROVIDER') { + return ask(false).then(parseAnswer, function (e2) { return failed(e2, rq.diag + ',global') }) + } + return failed(e, rq.diag) + }) + } + // 查找「面板已裁决、尚未被兑现」的工具令牌:(服务器, 命令) 精确匹配。 function findDecided(serverId, cmd) { var found = null @@ -534,7 +609,9 @@ function makeAskpass(s) { harness.handle('netshell.profiles.delete', function (args) { var id = args && args.id if (!id) return Promise.reject(new Error('缺少 id')) + modelAuthorizedServers.delete(id) sessions.forEach(function (s) { + if (s.server && s.server.id === id) s.modelApproved = false if (s.server && s.server.id === id && s.status !== 'closed') { try { void s.handle.terminate() } catch (e) {} } @@ -583,7 +660,8 @@ function makeAskpass(s) { id: 'ns' + Date.now().toString(36) + (++nonce), server: server, status: 'connecting', outAll: '', outBase: 0, dropped: 0, events: [], evSeq: 0, line: '', hist: [], histIdx: undefined, - pending: null, tail: '', atPwPrompt: false, hint: null, closedReason: null, askpassPath: null + pending: null, tail: '', atPwPrompt: false, hint: null, closedReason: null, askpassPath: null, + modelApproved: false } var envJob = Promise.resolve(null) if (server.auth === 'password') { @@ -688,6 +766,48 @@ function makeAskpass(s) { return spawnSession(server) } + function findApprovedModelSession(serverId) { + var found = null + sessions.forEach(function (s) { + if (!found && s.server && s.server.id === serverId && s.modelApproved && s.status !== 'closed') found = s + }) + return found + } + + function ensureModelSession(server, cmd, exec) { + var existing = findApprovedModelSession(server.id) + if (existing) return Promise.resolve({ approved: true, session: existing }) + var active = modelConnectJobs.get(server.id) + + if (active) return active + + var job = Promise.resolve().then(function () { + var current = findApprovedModelSession(server.id) + if (current) return { approved: true, session: current } + var approval = modelAuthorizedServers.has(server.id) + ? Promise.resolve({ approved: true }) + : requestModelConnectionApproval(server, cmd, exec) + return approval.then(function (decision) { + if (!decision.approved) return decision + // 真人批准后,授权保留到插件生命周期结束或用户主动断开该服务器。 + modelAuthorizedServers.add(server.id) + return ensureSession(server).then(function (session) { + session.modelApproved = true + return { approved: true, session: session } + }, function (e) { + return { + approved: false, + result: modelConnectionResult(server, 'connect-failed', 'SSH 会话建立失败:' + String((e && e.message) || e).slice(0, 180)) + } + }) + }) + }) + modelConnectJobs.set(server.id, job) + var clear = function () { if (modelConnectJobs.get(server.id) === job) modelConnectJobs.delete(server.id) } + job.then(clear, clear) + return job + } + function waitLive(s, timeoutMs, signal) { var deadline = Date.now() + (timeoutMs || 20000) return new Promise(function (resolve) { @@ -842,11 +962,30 @@ function makeAskpass(s) { if (!cmd || typeof cmd !== 'string') return Promise.reject(new Error('缺少 command 参数')) return resolveServer(serverId).then(function (server) { if (!server) return Promise.reject(new Error('找不到服务器档案: ' + serverId)) - return ensureSession(server).then(function (disp) { + // 硬拒绝命令无需建立 SSH,避免连上服务器后才发现命令本来就不允许。 + var initial = evaluateFor(server, cmd) + if (initial.action === 'deny') { + var initialRule = initial.rule && (initial.rule.note || initial.rule.pattern) + return { + ok: false, blocked: true, action: 'deny', rule: initialRule || '内置规则', output: '', command: cmd + } + } + return ensureModelSession(server, cmd, exec).then(function (connection) { + if (!connection.approved) return connection.result + var disp = connection.session disp.agentBusy = true var toolBody = function () { return waitLive(disp, 20000, exec && exec.signal).then(function (ready) { - if (!ready) return { ok: false, blocked: false, error: '会话未就绪或已断开:' + (disp.closedReason || disp.hint || 'unknown'), output: '' } + if (!ready) { + // 连接等待超时或被取消时,销毁尚未就绪的会话,但保留服务器级模型授权; + // 下一次模型调用可以自动重连,不复用这条半连接状态。 + if (disp.status === 'connecting') { + disp.hint = '连接等待超时或已取消' + try { void disp.handle.terminate() } catch (e) {} + onExit(disp, { exitCode: null, signal: 'timeout' }) + } + return { ok: false, blocked: false, error: '会话未就绪或已断开:' + (disp.closedReason || disp.hint || 'unknown'), output: '' } + } var v = evaluateFor(server, cmd) var ruleNote = v.rule && (v.rule.note || v.rule.pattern) function appendOut(r, addCmd) { @@ -1049,7 +1188,16 @@ harness.handle('netshell.input', function (args) { if (s.handle) { try { void s.handle.terminate() } catch (e) {} } // 退出即移除:之前只 terminate 不移除,会话残留在 sessions Map, // 客户端 discoverSessions 下一轮又会把它加回来,导致「点了删除却删不掉」。 - sessions.delete(args && args.id) + var serverId = s.server && s.server.id + if (serverId) { + // 只有用户主动断开才撤销服务器级模型授权; + // 网络/SSH 退出走 onExit,不会触碰这个集合。 + modelAuthorizedServers.delete(serverId) + sessions.forEach(function (other) { + if (other.server && other.server.id === serverId) other.modelApproved = false + }) + } + sessions.delete(args && args.id) return Promise.resolve({ ok: true }) }) @@ -1080,7 +1228,7 @@ harness.handle('netshell.input', function (args) { var runTool = harness.defineTool({ name: 'netshell_run', - description: '在指定的远程服务器(NetShell 终端)上执行一条 shell 命令并返回输出。命令通过真实 SSH 终端执行,支持权限控制:命中 deny 规则(如 rm -rf /)直接拦截;命中 ask 规则(如 rm -rf *)时,本工具会直接在对话窗口弹出原生确认卡并原地等待用户选择(执行一次 / 永久放行该命令 / 拒绝),无需你再调用 ask_user_question;仅当当前环境无法弹卡(子代理上下文或无界面)时,才返回 blocked + confirmToken 并在终端面板挂起,此时请提醒用户到「远程终端」面板裁决,再携 confirmToken 重跑本工具。用 netshell_servers 查询服务器 id 作为 server 参数。', + description: '仅当用户明确要求在指定远程服务器上执行命令时使用;本机问题优先使用本地工具,不要根据“网络问题”或“服务异常”等模糊描述自行选择远程服务器。在指定服务器(NetShell 终端)上执行一条 shell 命令并返回输出。模型首次使用服务器或用户主动断开后,插件会先要求用户确认远程连接,确认前不会建立 SSH。命令通过真实 SSH 终端执行,支持权限控制:命中 deny 规则(如 rm -rf /)直接拦截;命中 ask 规则(如 rm -rf *)时,本工具会直接在对话窗口弹出原生确认卡并原地等待用户选择(执行一次 / 永久放行该命令 / 拒绝),无需你再调用 ask_user_question;仅当当前环境无法弹卡(子代理上下文或无界面)时,连接确认和危险命令确认都会安全拒绝,不会自动建连。用 netshell_servers 查询服务器 id 作为 server 参数。', parameters: { server: { type: 'string', description: '服务器 id,来自 netshell_servers 返回的 id 字段', required: true }, command: { type: 'string', description: '要在服务器上执行的 shell 命令', required: true }, @@ -1110,6 +1258,8 @@ harness.handle('netshell.input', function (args) { ctx.effect(function () { return function () { askTokens.clear() + modelConnectJobs.clear() + modelAuthorizedServers.clear() sessions.forEach(function (s) { if (s.handle) { try { void s.handle.terminate() } catch (e) {} } }) diff --git a/test/ask-flow.mjs b/test/ask-flow.mjs index 751f6ff..8bdd3f4 100644 --- a/test/ask-flow.mjs +++ b/test/ask-flow.mjs @@ -38,6 +38,8 @@ function sshOut(text) { async function* termOut() { yield 'deploy@dev-box:~$ ' } const ptyWrites = [] const sweeps = [] // TOOL_ASK_TTL 级 timer 的手动触发句柄 +let spawnTerminalCalls = 0 +const terminalDoneResolvers = [] const timer = { timeout: (ms) => ms >= 600000 ? new Promise((res) => { sweeps.push(res) }) @@ -46,7 +48,13 @@ const timer = { } const subprocess = { resolveExecutable: async () => '/usr/bin/ssh', - spawnTerminal: async () => ({ pid: 123, output: termOut(), write: (d) => { ptyWrites.push(d) }, terminate() {}, done: new Promise(() => {}) }), + spawnTerminal: async () => { + spawnTerminalCalls += 1 + let finish + const done = new Promise((resolve) => { finish = resolve }) + terminalDoneResolvers.push(finish) + return { pid: 123 + spawnTerminalCalls, output: termOut(), write: (d) => { ptyWrites.push(d) }, terminate() {}, done } + }, spawn: () => sshOut('ok-output'), } @@ -55,15 +63,30 @@ const AGENT = { id: 'a1', name: 'root' } const fakeUQ = { mode: 'answer', // 'answer' | 'no-provider' | 'aborted' answer: ['执行一次'], + connectionAnswer: '允许本次连接', + holdConnection: false, + releaseConnection: null, custom: undefined, lastRequest: null, + requests: [], askCalls: 0, ask(request) { this.lastRequest = request + this.requests.push(request) this.askCalls += 1 if (this.mode === 'no-provider') return Promise.reject({ code: 'NO_PROVIDER', message: 'no answerer' }) if (this.mode === 'aborted') return Promise.reject({ code: 'ASK_ABORTED', message: 'aborted' }) - const sel = Array.isArray(this.answer) ? this.answer : [] + const isConnection = request.questions[0].id === 'netshell-connect' + const selected = isConnection ? this.connectionAnswer : this.answer + if (isConnection && this.holdConnection) { + return new Promise((resolve) => { + this.releaseConnection = (pick = this.connectionAnswer) => { + this.holdConnection = false + resolve({ answers: [{ id: request.questions[0].id, selected: [pick] }] }) + } + }) + } + const sel = Array.isArray(selected) ? selected : [selected] return Promise.resolve({ answers: [{ id: request.questions[0].id, selected: sel, ...(this.custom ? { custom: this.custom } : {}) }] }) }, } @@ -90,11 +113,29 @@ const run = (args) => registeredTools.netshell_run.execute({ server: 'srv1', ... const CMD = 'shutdown -h now' const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) -// ── A1. 主路径:ask 命中 → 直调 userQuestions.ask,参数形态正确 ── +// ── A0. 首次模型建连必须先确认,确认前不得启动 SSH ── fakeUQ.mode = 'answer' fakeUQ.answer = ['执行一次'] -let r = await run({ command: CMD }) -ok(fakeUQ.askCalls === 1, 'A1: ask 命中 → 插件直调 userQuestions.ask') +fakeUQ.connectionAnswer = '允许本次连接' +fakeUQ.holdConnection = true +const beforeFirstConnect = spawnTerminalCalls +const firstRun = run({ command: 'echo first' }) +await sleep(5) +const concurrentRun = run({ command: 'echo parallel' }) +await sleep(5) +ok(fakeUQ.requests.some((x) => x.questions[0].id === 'netshell-connect'), 'A0: 首次模型建连弹出连接确认卡') +ok(spawnTerminalCalls === beforeFirstConnect, 'A0: 用户确认前没有启动 SSH') +ok(fakeUQ.askCalls === 1, 'A0: 并发模型调用共享同一条连接确认') +ok(fakeUQ.requests.at(-1).agent === AGENT, 'A0: 连接确认原样透传 live agent') +fakeUQ.releaseConnection('允许本次连接') +let r = await firstRun +const concurrentResult = await concurrentRun +ok(r.ok === true && String(r.output).includes('ok-output'), 'A0: 允许连接后才执行远程命令') +ok(concurrentResult.ok === true && String(concurrentResult.output).includes('ok-output'), 'A0: 并发调用复用已确认会话') + +// ── A1. 主路径:ask 命中 → 直调 userQuestions.ask,参数形态正确 ── +r = await run({ command: CMD }) +ok(fakeUQ.askCalls === 2, 'A1: 首次连接确认后,危险命令再次弹出确认') const req = fakeUQ.lastRequest ok(req && req.agent === AGENT, 'A1: agent 为 exec.agent 原样透传(全等)') ok(req && req.questions.length === 1 && req.questions[0].question.includes(CMD), 'A1: 问题文本包含命令') @@ -214,8 +255,38 @@ host2.apply({ effect(fn) { fn(); return () => {} }, get: () => undefined, }, { enabled: true, routePath: '/netshell/rpc2' }) +const noUqSpawnBefore = spawnTerminalCalls r = await registered2.netshell_run.execute({ server: 'srv1', command: CMD }, { agent: AGENT, signal: undefined }) -ok(r.ok === false && r.blocked === true && typeof r.confirmToken === 'string', 'C: 无 userQuestions 服务 → 面板回退 + 令牌') +ok(r.ok === false && r.blocked === true && r.action === 'connect-confirmation-unavailable' && !r.confirmToken, 'C: 无 userQuestions 服务 → 拒绝自动建连') +ok(spawnTerminalCalls === noUqSpawnBefore, 'C: 无法确认时没有启动 SSH') + +// ── C1. 会话被用户断开后,下一次模型调用必须重新确认 ── +fakeUQ.mode = 'answer' +fakeUQ.answer = ['执行一次'] +fakeUQ.connectionAnswer = '允许本次连接' +const liveBeforeUserDisconnect = (await sessionsList()).find((s) => s.serverName === 'dev-box' && s.status === 'live') +ok(!!liveBeforeUserDisconnect, 'C1: 断开前存在存活远程会话') +await rpc('netshell.disconnect', { id: liveBeforeUserDisconnect.id }) +const userReconnectSpawnBefore = spawnTerminalCalls +fakeUQ.holdConnection = true +const userReconnect = run({ command: 'echo after-user-disconnect' }) +await sleep(5) +ok(spawnTerminalCalls === userReconnectSpawnBefore, 'C1: 用户断开后确认前不自动重连') +ok(fakeUQ.requests.at(-1).questions[0].id === 'netshell-connect', 'C1: 用户断开后再次弹出连接确认') +fakeUQ.releaseConnection('允许本次连接') +r = await userReconnect +ok(r.ok === true && String(r.output).includes('ok-output'), 'C1: 重新确认后恢复执行') + +// ── C2. SSH 会话异常退出后,沿用服务器授权自动重连 ── +const doneAfterUserReconnect = terminalDoneResolvers[terminalDoneResolvers.length - 1] +doneAfterUserReconnect({ exitCode: 255, signal: null }) +await sleep(5) +const networkReconnectSpawnBefore = spawnTerminalCalls +const askCallsBeforeNetworkReconnect = fakeUQ.askCalls +r = await run({ command: 'echo after-network-drop' }) +ok(spawnTerminalCalls === networkReconnectSpawnBefore + 1, 'C2: SSH 断开后自动建立新的 SSH 会话') +ok(fakeUQ.askCalls === askCallsBeforeNetworkReconnect, 'C2: SSH 断开后沿用已有模型授权') +ok(r.ok === true && String(r.output).includes('ok-output'), 'C2: 沿用授权后恢复执行') // ── D. netshell.input 多字符防绕过 ── const conn = await rpc('netshell.connect', { serverId: 'srv1' }) From d84afe2ff47c94b581fd7b36bef929ec884ba78f Mon Sep 17 00:00:00 2001 From: gone Date: Mon, 7 Sep 2026 17:43:45 +0800 Subject: [PATCH 2/6] fix(netshell): isolate connection approval per agent --- CHANGELOG.md | 2 +- README.en.md | 2 +- README.md | 2 +- TECHNICAL.md | 2 +- UPDATES.md | 2 +- lib/index.js | 102 ++++++++++++++++++++++++++-------------------- src/nsh-host.js | 102 ++++++++++++++++++++++++++-------------------- test/ask-flow.mjs | 14 ++++++- 8 files changed, 132 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53dffe0..f3309fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ ### 新增 -- **模型远程连接确认**:模型首次使用服务器,或用户手动断开后再次使用时,必须先经过真人确认;授权在当前插件生命周期内按服务器保留,网络故障、SSH 退出或连接等待超时只会自动重连,不会再次询问;确认服务不可用时不会自动建立 SSH。 +- **模型远程连接确认**:当前模型会话首次使用服务器,或用户手动断开后再次使用时,必须先经过真人确认;授权在当前插件生命周期内按模型会话和服务器分别保留,不同模型会话即使复用同一个 SSH 会话也必须各自确认;网络故障、SSH 退出或连接等待超时只会自动重连,不会再次询问;确认服务不可用时不会自动建立 SSH。 - **服务器规则编辑区重做(设置 → 远程终端)**:规则不再是一排裸输入框——标题行改为「服务器规则 + 按顺序匹配 · 优先于内置规则库 · 只约束 AI 执行的命令」,正文补充匹配语义说明(完整命令行通配、`*` / `?` 通配符、忽略大小写、自动剥除 sudo / nohup 等前缀、从上到下先命中先生效、deny / ask / allow 三种动作含义);新增四个**可点击示例**(放行日志清理 / 禁止强推 / systemctl 先确认 / docker rm 先确认),点击即作为一条规则填入列表;规则列表带行号,动作用中英双语下拉(deny 拦截 / ask 确认 / allow 放行);空规则列表显示占位说明。保存时校验规则模式不能为空(此前空模式行被宿主静默丢弃,用户无从得知)。 ### 修复 diff --git a/README.en.md b/README.en.md index 93952a3..dc98787 100644 --- a/README.en.md +++ b/README.en.md @@ -93,7 +93,7 @@ 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 model use of a server, or after the user manually disconnects that server, `netshell_run` asks for remote-connection approval before starting SSH. Approval is retained per server for the current plugin lifetime; a network failure, SSH exit, or connection timeout only triggers automatic reconnection and does not ask again. 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. +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 diff --git a/README.md b/README.md index 8b015b6..9d1c3c5 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ dsh plugin --profile web add link:/path/to/dsh-netshell AI 和人工输入共用同一套 Guard。`deny` 命令不会执行,`ask` 命令必须由你在 DSH 的确认卡或终端面板中裁决;AI 看不到密码,也不能伪造确认结果。 -模型首次使用某台服务器,或用户手动断开该服务器后再次使用时,`netshell_run` 会先弹出远程连接确认;确认前不会启动 SSH。确认在当前插件生命周期内按服务器保留,网络故障、SSH 异常退出或连接等待超时只会触发自动重连,不会再次询问。GUI 中已有的人工连接不会替代模型确认;确认服务不可用时插件会拒绝自动建连。 +当前模型会话首次使用某台服务器,或用户手动断开该服务器后再次使用时,`netshell_run` 会先弹出远程连接确认;确认前不会启动 SSH。确认在当前插件生命周期内按模型会话和服务器分别保留;不同模型会话即使复用同一个 SSH 会话,也必须各自确认。网络故障、SSH 异常退出或连接等待超时只会触发当前模型会话的自动重连,不会再次询问。GUI 中已有的人工连接不会替代模型确认;确认服务不可用时插件会拒绝自动建连。 ## 安全边界 diff --git a/TECHNICAL.md b/TECHNICAL.md index 7628eff..f40f844 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -161,7 +161,7 @@ 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. Guard 先评估硬拒绝规则:`deny` → 直接返回 blocked,不建立 SSH;其他命令进入模型连接确认流程; - 2. 模型首次使用服务器,或用户主动断开后服务器授权已被清除时,经 `userQuestions.ask` 请求真人确认。授权在当前插件生命周期内按服务器保留;网络/SSH 异常退出或连接等待超时只会使当前会话失效,下一次调用沿用授权自动重连。确认服务不可用时 fail closed,不自动连接。并发调用共享同一条确认/建连任务; + 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 … ` 独立一次性执行,同样使用私有 known_hosts;stdout 上限 200K/spill 400K); 5. `ask` → 依次尝试四条路径: diff --git a/UPDATES.md b/UPDATES.md index 01e5db2..fec4ef6 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -16,5 +16,5 @@ - 本地终端不会读取远程服务器档案或 SSH 密码。 - 本地和远程会话继续使用统一的命令 Guard;`allow`、`ask` 和 `deny` 的行为保持一致。 -- **模型远程连接确认**:模型首次使用服务器,或用户手动断开后再次使用时,必须先经过用户确认;授权在当前插件生命周期内按服务器保留,网络故障、SSH 退出或连接等待超时只会触发自动重连,不会再次询问。确认服务不可用时安全拒绝,不会自动建连。 +- **模型远程连接确认**:当前模型会话首次使用服务器,或用户手动断开后再次使用时,必须先经过用户确认;授权在当前插件生命周期内按模型会话和服务器分别保留,不同模型会话即使复用同一个 SSH 会话也必须各自确认。网络故障、SSH 退出或连接等待超时只会触发已授权模型会话的自动重连,不会再次询问。确认服务不可用时安全拒绝,不会自动建连。 - README 明确说明插件的安全边界:命令护栏不是完整沙箱,其他 SSH 通道不受本插件拦截。 diff --git a/lib/index.js b/lib/index.js index 10ce515..91795a3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -208,12 +208,40 @@ return { // (无 answerer / 子代理上下文 / 动态加载无服务)时回退「面板挂起 + 一次性令牌」—— // 令牌只有在面板真实裁决(allow/always/deny)后才可兑现,choice 参数不作授权依据。 var askTokens = new Map() - // 模型建连确认的在途请求:同一服务器同时只允许一条确认/建连流程, - // 避免并发工具调用在用户回答前各自启动 SSH。 + // 模型建连确认的在途请求:同一 Agent + 服务器同时只允许一条确认/建连流程, + // 避免并发工具调用在用户回答前各自启动 SSH;底层 SSH 会话仍由所有 Agent 共享。 var modelConnectJobs = new Map() - // 模型授权按服务器保留在当前插件生命周期内;只有用户主动断开才清除。 + // 模型授权按 Agent + 服务器保留在当前插件生命周期内;只有用户主动断开才清除。 // SSH 异常退出、网络断开和连接等待超时只会使当前会话失效,不会撤销这项授权。 - var modelAuthorizedServers = new Set() + var modelAuthorizedServers = new Map() + + function modelAgentId(agent) { + return agent && agent.id != null ? String(agent.id) : null + } + + function modelAuthorizationKey(agent, serverId) { + var agentId = modelAgentId(agent) + return agentId == null ? null : agentId + '\u0000' + String(serverId) + } + + function hasModelAuthorization(agent, serverId) { + var users = modelAuthorizedServers.get(String(serverId)) + var agentId = modelAgentId(agent) + return !!(users && agentId != null && users.has(agentId)) + } + + function grantModelAuthorization(agent, serverId) { + var agentId = modelAgentId(agent) + if (agentId == null) return + var key = String(serverId) + var users = modelAuthorizedServers.get(key) + if (!users) { users = new Set(); modelAuthorizedServers.set(key, users) } + users.add(agentId) + } + + function revokeModelAuthorizations(serverId) { + modelAuthorizedServers.delete(String(serverId)) + } // 宿主 realm 空白对象工厂:describe 返回的 CredentialInfo 是宿主 realm // 出身,delete 掉字段后就是干净的宿主 plain object。 @@ -687,9 +715,8 @@ function makeAskpass(s) { harness.handle('netshell.profiles.delete', function (args) { var id = args && args.id if (!id) return Promise.reject(new Error('缺少 id')) - modelAuthorizedServers.delete(id) + revokeModelAuthorizations(id) sessions.forEach(function (s) { - if (s.server && s.server.id === id) s.modelApproved = false if (s.server && s.server.id === id && s.status !== 'closed') { try { void s.handle.terminate() } catch (e) {} } @@ -713,14 +740,6 @@ function makeAskpass(s) { }) } - function findLiveSession(serverId) { - var found = null - sessions.forEach(function (s) { - if (!found && s.server && s.server.id === serverId && s.status === 'live') found = s - }) - return found - } - function makeVirtualSession(server) { var s = { id: 'ns' + Date.now().toString(36) + (++nonce), @@ -738,8 +757,7 @@ function makeAskpass(s) { id: 'ns' + Date.now().toString(36) + (++nonce), server: server, status: 'connecting', outAll: '', outBase: 0, dropped: 0, events: [], evSeq: 0, line: '', hist: [], histIdx: undefined, - pending: null, tail: '', atPwPrompt: false, hint: null, closedReason: null, askpassPath: null, - modelApproved: false + pending: null, tail: '', atPwPrompt: false, hint: null, closedReason: null, askpassPath: null } var envJob = Promise.resolve(null) if (server.auth === 'password') { @@ -838,39 +856,36 @@ function makeAskpass(s) { }) } - function ensureSession(server) { - var existing = findLiveSession(server.id) - if (existing) return Promise.resolve(existing) - return spawnSession(server) - } - - function findApprovedModelSession(serverId) { + function findOpenSession(serverId) { var found = null sessions.forEach(function (s) { - if (!found && s.server && s.server.id === serverId && s.modelApproved && s.status !== 'closed') found = s + if (!found && s.server && s.server.id === serverId && s.status !== 'closed') found = s }) return found } - function ensureModelSession(server, cmd, exec) { - var existing = findApprovedModelSession(server.id) - if (existing) return Promise.resolve({ approved: true, session: existing }) - var active = modelConnectJobs.get(server.id) + function ensureSession(server) { + var existing = findOpenSession(server.id) + if (existing) return Promise.resolve(existing) + return spawnSession(server) + } + function ensureModelSession(server, cmd, exec) { + var agent = exec && exec.agent + var jobKey = modelAuthorizationKey(agent, server.id) || String(server.id) + var active = modelConnectJobs.get(jobKey) if (active) return active var job = Promise.resolve().then(function () { - var current = findApprovedModelSession(server.id) - if (current) return { approved: true, session: current } - var approval = modelAuthorizedServers.has(server.id) + var approval = hasModelAuthorization(agent, server.id) ? Promise.resolve({ approved: true }) : requestModelConnectionApproval(server, cmd, exec) return approval.then(function (decision) { if (!decision.approved) return decision - // 真人批准后,授权保留到插件生命周期结束或用户主动断开该服务器。 - modelAuthorizedServers.add(server.id) + // 真人批准后,授权保留到插件生命周期结束或用户主动断开该服务器; + // 授权只对当前 Agent 生效,底层 SSH 会话仍由所有 Agent 共享。 + grantModelAuthorization(agent, server.id) return ensureSession(server).then(function (session) { - session.modelApproved = true return { approved: true, session: session } }, function (e) { return { @@ -880,8 +895,8 @@ function makeAskpass(s) { }) }) }) - modelConnectJobs.set(server.id, job) - var clear = function () { if (modelConnectJobs.get(server.id) === job) modelConnectJobs.delete(server.id) } + modelConnectJobs.set(jobKey, job) + var clear = function () { if (modelConnectJobs.get(jobKey) === job) modelConnectJobs.delete(jobKey) } job.then(clear, clear) return job } @@ -1268,14 +1283,11 @@ harness.handle('netshell.input', function (args) { // 客户端 discoverSessions 下一轮又会把它加回来,导致「点了删除却删不掉」。 var serverId = s.server && s.server.id if (serverId) { - // 只有用户主动断开才撤销服务器级模型授权; - // 网络/SSH 退出走 onExit,不会触碰这个集合。 - modelAuthorizedServers.delete(serverId) - sessions.forEach(function (other) { - if (other.server && other.server.id === serverId) other.modelApproved = false - }) - } - sessions.delete(args && args.id) + // 只有用户主动断开才撤销服务器级模型授权; + // 网络/SSH 退出走 onExit,不会触碰这个集合。 + revokeModelAuthorizations(serverId) + } + sessions.delete(args && args.id) return Promise.resolve({ ok: true }) }) @@ -1306,7 +1318,7 @@ harness.handle('netshell.input', function (args) { var runTool = harness.defineTool({ name: 'netshell_run', - description: '仅当用户明确要求在指定远程服务器上执行命令时使用;本机问题优先使用本地工具,不要根据“网络问题”或“服务异常”等模糊描述自行选择远程服务器。在指定服务器(NetShell 终端)上执行一条 shell 命令并返回输出。模型首次使用服务器或用户主动断开后,插件会先要求用户确认远程连接,确认前不会建立 SSH。命令通过真实 SSH 终端执行,支持权限控制:命中 deny 规则(如 rm -rf /)直接拦截;命中 ask 规则(如 rm -rf *)时,本工具会直接在对话窗口弹出原生确认卡并原地等待用户选择(执行一次 / 永久放行该命令 / 拒绝),无需你再调用 ask_user_question;仅当当前环境无法弹卡(子代理上下文或无界面)时,连接确认和危险命令确认都会安全拒绝,不会自动建连。用 netshell_servers 查询服务器 id 作为 server 参数。', + description: '仅当用户明确要求在指定远程服务器上执行命令时使用;本机问题优先使用本地工具,不要根据“网络问题”或“服务异常”等模糊描述自行选择远程服务器。在指定服务器(NetShell 终端)上执行一条 shell 命令并返回输出。当前模型会话首次使用服务器或用户主动断开后,插件会先要求用户确认远程连接,确认前不会建立 SSH。命令通过真实 SSH 终端执行,支持权限控制:命中 deny 规则(如 rm -rf /)直接拦截;命中 ask 规则(如 rm -rf *)时,本工具会直接在对话窗口弹出原生确认卡并原地等待用户选择(执行一次 / 永久放行该命令 / 拒绝),无需你再调用 ask_user_question;仅当当前环境无法弹卡(子代理上下文或无界面)时,连接确认和危险命令确认都会安全拒绝,不会自动建连。用 netshell_servers 查询服务器 id 作为 server 参数。', parameters: { server: { type: 'string', description: '服务器 id,来自 netshell_servers 返回的 id 字段', required: true }, command: { type: 'string', description: '要在服务器上执行的 shell 命令', required: true }, diff --git a/src/nsh-host.js b/src/nsh-host.js index fdde4a4..df7f79e 100644 --- a/src/nsh-host.js +++ b/src/nsh-host.js @@ -130,12 +130,40 @@ return { // (无 answerer / 子代理上下文 / 动态加载无服务)时回退「面板挂起 + 一次性令牌」—— // 令牌只有在面板真实裁决(allow/always/deny)后才可兑现,choice 参数不作授权依据。 var askTokens = new Map() - // 模型建连确认的在途请求:同一服务器同时只允许一条确认/建连流程, - // 避免并发工具调用在用户回答前各自启动 SSH。 + // 模型建连确认的在途请求:同一 Agent + 服务器同时只允许一条确认/建连流程, + // 避免并发工具调用在用户回答前各自启动 SSH;底层 SSH 会话仍由所有 Agent 共享。 var modelConnectJobs = new Map() - // 模型授权按服务器保留在当前插件生命周期内;只有用户主动断开才清除。 + // 模型授权按 Agent + 服务器保留在当前插件生命周期内;只有用户主动断开才清除。 // SSH 异常退出、网络断开和连接等待超时只会使当前会话失效,不会撤销这项授权。 - var modelAuthorizedServers = new Set() + var modelAuthorizedServers = new Map() + + function modelAgentId(agent) { + return agent && agent.id != null ? String(agent.id) : null + } + + function modelAuthorizationKey(agent, serverId) { + var agentId = modelAgentId(agent) + return agentId == null ? null : agentId + '\u0000' + String(serverId) + } + + function hasModelAuthorization(agent, serverId) { + var users = modelAuthorizedServers.get(String(serverId)) + var agentId = modelAgentId(agent) + return !!(users && agentId != null && users.has(agentId)) + } + + function grantModelAuthorization(agent, serverId) { + var agentId = modelAgentId(agent) + if (agentId == null) return + var key = String(serverId) + var users = modelAuthorizedServers.get(key) + if (!users) { users = new Set(); modelAuthorizedServers.set(key, users) } + users.add(agentId) + } + + function revokeModelAuthorizations(serverId) { + modelAuthorizedServers.delete(String(serverId)) + } // 宿主 realm 空白对象工厂:describe 返回的 CredentialInfo 是宿主 realm // 出身,delete 掉字段后就是干净的宿主 plain object。 @@ -609,9 +637,8 @@ function makeAskpass(s) { harness.handle('netshell.profiles.delete', function (args) { var id = args && args.id if (!id) return Promise.reject(new Error('缺少 id')) - modelAuthorizedServers.delete(id) + revokeModelAuthorizations(id) sessions.forEach(function (s) { - if (s.server && s.server.id === id) s.modelApproved = false if (s.server && s.server.id === id && s.status !== 'closed') { try { void s.handle.terminate() } catch (e) {} } @@ -635,14 +662,6 @@ function makeAskpass(s) { }) } - function findLiveSession(serverId) { - var found = null - sessions.forEach(function (s) { - if (!found && s.server && s.server.id === serverId && s.status === 'live') found = s - }) - return found - } - function makeVirtualSession(server) { var s = { id: 'ns' + Date.now().toString(36) + (++nonce), @@ -660,8 +679,7 @@ function makeAskpass(s) { id: 'ns' + Date.now().toString(36) + (++nonce), server: server, status: 'connecting', outAll: '', outBase: 0, dropped: 0, events: [], evSeq: 0, line: '', hist: [], histIdx: undefined, - pending: null, tail: '', atPwPrompt: false, hint: null, closedReason: null, askpassPath: null, - modelApproved: false + pending: null, tail: '', atPwPrompt: false, hint: null, closedReason: null, askpassPath: null } var envJob = Promise.resolve(null) if (server.auth === 'password') { @@ -760,39 +778,36 @@ function makeAskpass(s) { }) } - function ensureSession(server) { - var existing = findLiveSession(server.id) - if (existing) return Promise.resolve(existing) - return spawnSession(server) - } - - function findApprovedModelSession(serverId) { + function findOpenSession(serverId) { var found = null sessions.forEach(function (s) { - if (!found && s.server && s.server.id === serverId && s.modelApproved && s.status !== 'closed') found = s + if (!found && s.server && s.server.id === serverId && s.status !== 'closed') found = s }) return found } - function ensureModelSession(server, cmd, exec) { - var existing = findApprovedModelSession(server.id) - if (existing) return Promise.resolve({ approved: true, session: existing }) - var active = modelConnectJobs.get(server.id) + function ensureSession(server) { + var existing = findOpenSession(server.id) + if (existing) return Promise.resolve(existing) + return spawnSession(server) + } + function ensureModelSession(server, cmd, exec) { + var agent = exec && exec.agent + var jobKey = modelAuthorizationKey(agent, server.id) || String(server.id) + var active = modelConnectJobs.get(jobKey) if (active) return active var job = Promise.resolve().then(function () { - var current = findApprovedModelSession(server.id) - if (current) return { approved: true, session: current } - var approval = modelAuthorizedServers.has(server.id) + var approval = hasModelAuthorization(agent, server.id) ? Promise.resolve({ approved: true }) : requestModelConnectionApproval(server, cmd, exec) return approval.then(function (decision) { if (!decision.approved) return decision - // 真人批准后,授权保留到插件生命周期结束或用户主动断开该服务器。 - modelAuthorizedServers.add(server.id) + // 真人批准后,授权保留到插件生命周期结束或用户主动断开该服务器; + // 授权只对当前 Agent 生效,底层 SSH 会话仍由所有 Agent 共享。 + grantModelAuthorization(agent, server.id) return ensureSession(server).then(function (session) { - session.modelApproved = true return { approved: true, session: session } }, function (e) { return { @@ -802,8 +817,8 @@ function makeAskpass(s) { }) }) }) - modelConnectJobs.set(server.id, job) - var clear = function () { if (modelConnectJobs.get(server.id) === job) modelConnectJobs.delete(server.id) } + modelConnectJobs.set(jobKey, job) + var clear = function () { if (modelConnectJobs.get(jobKey) === job) modelConnectJobs.delete(jobKey) } job.then(clear, clear) return job } @@ -1190,14 +1205,11 @@ harness.handle('netshell.input', function (args) { // 客户端 discoverSessions 下一轮又会把它加回来,导致「点了删除却删不掉」。 var serverId = s.server && s.server.id if (serverId) { - // 只有用户主动断开才撤销服务器级模型授权; - // 网络/SSH 退出走 onExit,不会触碰这个集合。 - modelAuthorizedServers.delete(serverId) - sessions.forEach(function (other) { - if (other.server && other.server.id === serverId) other.modelApproved = false - }) - } - sessions.delete(args && args.id) + // 只有用户主动断开才撤销服务器级模型授权; + // 网络/SSH 退出走 onExit,不会触碰这个集合。 + revokeModelAuthorizations(serverId) + } + sessions.delete(args && args.id) return Promise.resolve({ ok: true }) }) @@ -1228,7 +1240,7 @@ harness.handle('netshell.input', function (args) { var runTool = harness.defineTool({ name: 'netshell_run', - description: '仅当用户明确要求在指定远程服务器上执行命令时使用;本机问题优先使用本地工具,不要根据“网络问题”或“服务异常”等模糊描述自行选择远程服务器。在指定服务器(NetShell 终端)上执行一条 shell 命令并返回输出。模型首次使用服务器或用户主动断开后,插件会先要求用户确认远程连接,确认前不会建立 SSH。命令通过真实 SSH 终端执行,支持权限控制:命中 deny 规则(如 rm -rf /)直接拦截;命中 ask 规则(如 rm -rf *)时,本工具会直接在对话窗口弹出原生确认卡并原地等待用户选择(执行一次 / 永久放行该命令 / 拒绝),无需你再调用 ask_user_question;仅当当前环境无法弹卡(子代理上下文或无界面)时,连接确认和危险命令确认都会安全拒绝,不会自动建连。用 netshell_servers 查询服务器 id 作为 server 参数。', + description: '仅当用户明确要求在指定远程服务器上执行命令时使用;本机问题优先使用本地工具,不要根据“网络问题”或“服务异常”等模糊描述自行选择远程服务器。在指定服务器(NetShell 终端)上执行一条 shell 命令并返回输出。当前模型会话首次使用服务器或用户主动断开后,插件会先要求用户确认远程连接,确认前不会建立 SSH。命令通过真实 SSH 终端执行,支持权限控制:命中 deny 规则(如 rm -rf /)直接拦截;命中 ask 规则(如 rm -rf *)时,本工具会直接在对话窗口弹出原生确认卡并原地等待用户选择(执行一次 / 永久放行该命令 / 拒绝),无需你再调用 ask_user_question;仅当当前环境无法弹卡(子代理上下文或无界面)时,连接确认和危险命令确认都会安全拒绝,不会自动建连。用 netshell_servers 查询服务器 id 作为 server 参数。', parameters: { server: { type: 'string', description: '服务器 id,来自 netshell_servers 返回的 id 字段', required: true }, command: { type: 'string', description: '要在服务器上执行的 shell 命令', required: true }, diff --git a/test/ask-flow.mjs b/test/ask-flow.mjs index 8bdd3f4..c050264 100644 --- a/test/ask-flow.mjs +++ b/test/ask-flow.mjs @@ -60,6 +60,7 @@ const subprocess = { // userQuestions 桩:验证插件的调用形态并按 mode 决定回答/拒绝 const AGENT = { id: 'a1', name: 'root' } +const AGENT2 = { id: 'a2', name: 'second-session' } const fakeUQ = { mode: 'answer', // 'answer' | 'no-provider' | 'aborted' answer: ['执行一次'], @@ -109,7 +110,8 @@ const rpc = async (method, args) => { return res.json() } const sessionsList = async () => (await rpc('netshell.sessions.list', {})).sessions -const run = (args) => registeredTools.netshell_run.execute({ server: 'srv1', ...args }, { agent: AGENT, signal: undefined }) +const runAs = (agent, args) => registeredTools.netshell_run.execute({ server: 'srv1', ...args }, { agent, signal: undefined }) +const run = (args) => runAs(AGENT, args) const CMD = 'shutdown -h now' const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) @@ -144,6 +146,16 @@ ok(req && req.questions[0].options.length === 3 ok(r.ok === true && r.blocked === false && String(r.output).includes('ok-output'), 'A1: 「执行一次」→ 执行并回传输出') ok(r.action === 'allow' && !('confirmToken' in r), 'A1: 主路径不签发令牌') +// ── A1b. 第二个模型会话复用 SSH,但必须独立确认 ── +const secondAgentAskBefore = fakeUQ.askCalls +const secondAgentSpawnBefore = spawnTerminalCalls +r = await runAs(AGENT2, { command: 'echo second-agent' }) +ok(fakeUQ.askCalls === secondAgentAskBefore + 1, 'A1b: 第二个 Agent 首次使用同一服务器仍需确认') +ok(fakeUQ.lastRequest && fakeUQ.lastRequest.questions[0].id === 'netshell-connect', 'A1b: 第二个 Agent 弹出连接确认') +ok(fakeUQ.lastRequest && fakeUQ.lastRequest.agent === AGENT2, 'A1b: 确认卡绑定第二个 live Agent') +ok(spawnTerminalCalls === secondAgentSpawnBefore, 'A1b: 第二个 Agent 复用共享 SSH 会话') +ok(r.ok === true && String(r.output).includes('ok-output'), 'A1b: 第二个 Agent 确认后执行成功') + // ── A2. 永久放行 ── fakeUQ.answer = ['永久放行该命令'] r = await run({ command: 'reboot now' }) From 66e0109f78c2c14c350f886e1e96847dc9733934 Mon Sep 17 00:00:00 2001 From: gone Date: Mon, 7 Sep 2026 18:06:51 +0800 Subject: [PATCH 3/6] fix(netshell): smooth terminal cursor movement --- CHANGELOG.md | 1 + TECHNICAL.md | 2 +- lib/client.js | 39 ++++++++++++++++++++++++++++++--------- package.json | 2 +- src/nsh-client.js | 39 ++++++++++++++++++++++++++++++--------- test/terminal-render.mjs | 29 +++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 test/terminal-render.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f3309fc..1baad57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### 修复 +- **终端左右移动光标卡顿与右侧空白**: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` 允许收缩,窄窗口下整行可正常压缩;删除按钮从整颗「删除」胶囊改为紧凑 ✕ 图标按钮,行内布局不再拥挤。 diff --git a/TECHNICAL.md b/TECHNICAL.md index f40f844..8aa4572 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -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 侧) diff --git a/lib/client.js b/lib/client.js index af4b95c..de11c5a 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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 } } @@ -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 } } @@ -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) @@ -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 @@ -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) @@ -1172,7 +1193,7 @@ return { void discoverSessions() var id = store.st.activeId if (id) void pollOne(id) - }, 150) + }, 80) }) void refreshServers() slots.inject('conversation.view', function () { diff --git a/package.json b/package.json index bae9ad8..ab20760 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "scripts": { "build": "node scripts/build.mjs", "check": "node --check lib/index.js && node --check lib/client.js", - "test": "pnpm build && pnpm check && node test/smoke.mjs && node test/ask-flow.mjs", + "test": "pnpm build && pnpm check && node test/smoke.mjs && node test/ask-flow.mjs && node test/terminal-render.mjs", "prepublishOnly": "pnpm test" }, "license": "MIT", diff --git a/src/nsh-client.js b/src/nsh-client.js index 9bd54e1..0ebffd3 100644 --- a/src/nsh-client.js +++ b/src/nsh-client.js @@ -264,9 +264,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 } } @@ -279,7 +282,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 } } @@ -463,7 +467,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) @@ -495,13 +504,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 @@ -632,7 +651,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) @@ -1127,7 +1148,7 @@ return { void discoverSessions() var id = store.st.activeId if (id) void pollOne(id) - }, 150) + }, 80) }) void refreshServers() slots.inject('conversation.view', function () { diff --git a/test/terminal-render.mjs b/test/terminal-render.mjs new file mode 100644 index 0000000..0939f7a --- /dev/null +++ b/test/terminal-render.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const source = readFileSync(join(root, 'src/nsh-client.js'), 'utf8') +const start = source.indexOf('function applySgr') +const end = source.indexOf('function newScreen') +if (start < 0 || end < 0 || end <= start) throw new Error('terminal renderer source boundary not found') +const renderFns = new Function(source.slice(start, end) + '\nreturn { lineSpans };')() +const ok = (condition, label) => { + if (!condition) throw new Error('FAIL: ' + label) + console.log('ok', label) +} + +let parsed = renderFns.lineSpans('abc\u001b[3C') +ok(parsed.col === 6, 'cursor column advances across CSI C') +ok(parsed.spans.map((x) => x.t).join('') === 'abc ', 'trailing cursor space is preserved') + +parsed = renderFns.lineSpans('abc\u001b[2D\u001b[C') +ok(parsed.col === 2, 'cursor column follows left/right movement') +ok(parsed.spans.map((x) => x.t).join('') === 'abc', 'existing text remains aligned after movement') + +parsed = renderFns.lineSpans('a\u001b[3Cx') +ok(parsed.col === 5, 'writing after a cursor gap advances from the absolute column') +ok(parsed.spans.map((x) => x.t).join('') === 'a x', 'middle cursor gap is rendered as spaces') + +console.log('TERMINAL-RENDER-OK') From 56e9f050981ca82378aeb26b1d76f6e3b6ccce91 Mon Sep 17 00:00:00 2001 From: gone Date: Mon, 7 Sep 2026 18:25:52 +0800 Subject: [PATCH 4/6] fix(netshell): render terminal cursor as full cell --- lib/client.js | 2 +- src/nsh-client.js | 2 +- test/terminal-render.mjs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/client.js b/lib/client.js index de11c5a..69f069c 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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}' diff --git a/src/nsh-client.js b/src/nsh-client.js index 0ebffd3..1f07392 100644 --- a/src/nsh-client.js +++ b/src/nsh-client.js @@ -98,7 +98,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}' diff --git a/test/terminal-render.mjs b/test/terminal-render.mjs index 0939f7a..7bde261 100644 --- a/test/terminal-render.mjs +++ b/test/terminal-render.mjs @@ -14,6 +14,8 @@ const ok = (condition, label) => { console.log('ok', label) } +ok(source.includes(".nsh-cursor{display:inline-block") && source.includes("background:var(--nsh-cursor)"), 'cursor uses a full-cell block style') + let parsed = renderFns.lineSpans('abc\u001b[3C') ok(parsed.col === 6, 'cursor column advances across CSI C') ok(parsed.spans.map((x) => x.t).join('') === 'abc ', 'trailing cursor space is preserved') From 68fae7b8c32861171ce8b4f582b0c684290f199f Mon Sep 17 00:00:00 2001 From: gone Date: Mon, 7 Sep 2026 18:35:57 +0800 Subject: [PATCH 5/6] fix(netshell): reuse terminal output iterators --- CHANGELOG.md | 1 + lib/index.js | 10 ++++++---- src/nsh-host.js | 10 ++++++---- test/ask-flow.mjs | 18 +++++++++++++++++- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1baad57..15822c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### 修复 +- **终端输出 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` 允许收缩,窄窗口下整行可正常压缩;删除按钮从整颗「删除」胶囊改为紧凑 ✕ 图标按钮,行内布局不再拥挤。 diff --git a/lib/index.js b/lib/index.js index 91795a3..d18c61b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -794,8 +794,9 @@ function makeAskpass(s) { }).then(function (handle) { s.handle = handle sessions.set(s.id, s) - var consume = function () { - return handle.output[Symbol.asyncIterator]().next().then(function (r) { + var outputIterator = handle.output[Symbol.asyncIterator]() + var consume = function () { + return outputIterator.next().then(function (r) { if (!r.done) { onOutput(s, r.value); return consume() } }) } @@ -844,8 +845,9 @@ function makeAskpass(s) { }).then(function (handle) { s.handle = handle sessions.set(s.id, s) - var consume = function () { - return handle.output[Symbol.asyncIterator]().next().then(function (r) { + var outputIterator = handle.output[Symbol.asyncIterator]() + var consume = function () { + return outputIterator.next().then(function (r) { if (!r.done) { onOutput(s, r.value); return consume() } }) } diff --git a/src/nsh-host.js b/src/nsh-host.js index df7f79e..273006b 100644 --- a/src/nsh-host.js +++ b/src/nsh-host.js @@ -716,8 +716,9 @@ function makeAskpass(s) { }).then(function (handle) { s.handle = handle sessions.set(s.id, s) - var consume = function () { - return handle.output[Symbol.asyncIterator]().next().then(function (r) { + var outputIterator = handle.output[Symbol.asyncIterator]() + var consume = function () { + return outputIterator.next().then(function (r) { if (!r.done) { onOutput(s, r.value); return consume() } }) } @@ -766,8 +767,9 @@ function makeAskpass(s) { }).then(function (handle) { s.handle = handle sessions.set(s.id, s) - var consume = function () { - return handle.output[Symbol.asyncIterator]().next().then(function (r) { + var outputIterator = handle.output[Symbol.asyncIterator]() + var consume = function () { + return outputIterator.next().then(function (r) { if (!r.done) { onOutput(s, r.value); return consume() } }) } diff --git a/test/ask-flow.mjs b/test/ask-flow.mjs index c050264..e6b50f1 100644 --- a/test/ask-flow.mjs +++ b/test/ask-flow.mjs @@ -35,7 +35,22 @@ function sshOut(text) { collected: { stdout: { readFrom: () => ({ text }) }, stderr: { readFrom: () => ({ text: '' }) } }, } } -async function* termOut() { yield 'deploy@dev-box:~$ ' } +let outputIteratorCalls = 0 +function termOut() { + let emitted = false + return { + [Symbol.asyncIterator]() { + outputIteratorCalls += 1 + return { + next: async () => { + if (emitted) return { done: true, value: undefined } + emitted = true + return { done: false, value: 'deploy@dev-box:~$ ' } + } + } + } + } +} const ptyWrites = [] const sweeps = [] // TOOL_ASK_TTL 级 timer 的手动触发句柄 let spawnTerminalCalls = 0 @@ -134,6 +149,7 @@ let r = await firstRun const concurrentResult = await concurrentRun ok(r.ok === true && String(r.output).includes('ok-output'), 'A0: 允许连接后才执行远程命令') ok(concurrentResult.ok === true && String(concurrentResult.output).includes('ok-output'), 'A0: 并发调用复用已确认会话') +ok(outputIteratorCalls === spawnTerminalCalls, 'A0: 每个终端只创建一个输出迭代器') // ── A1. 主路径:ask 命中 → 直调 userQuestions.ask,参数形态正确 ── r = await run({ command: CMD }) From 37f8ca67389bf7dc24939e80da3e313e8b323c18 Mon Sep 17 00:00:00 2001 From: gone Date: Mon, 7 Sep 2026 18:42:13 +0800 Subject: [PATCH 6/6] fix(netshell): overlay cursor on active character --- lib/client.js | 11 +++++++---- src/nsh-client.js | 11 +++++++---- test/terminal-render.mjs | 1 + 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/client.js b/lib/client.js index 69f069c..bd6ec9b 100644 --- a/lib/client.js +++ b/lib/client.js @@ -720,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) } diff --git a/src/nsh-client.js b/src/nsh-client.js index 1f07392..5cbf55d 100644 --- a/src/nsh-client.js +++ b/src/nsh-client.js @@ -675,18 +675,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) } diff --git a/test/terminal-render.mjs b/test/terminal-render.mjs index 7bde261..531a200 100644 --- a/test/terminal-render.mjs +++ b/test/terminal-render.mjs @@ -15,6 +15,7 @@ const ok = (condition, label) => { } ok(source.includes(".nsh-cursor{display:inline-block") && source.includes("background:var(--nsh-cursor)"), 'cursor uses a full-cell block style') +ok(source.includes("curStyle.backgroundColor = 'var(--nsh-cursor)'"), 'cursor highlights the character cell instead of inserting a glyph') let parsed = renderFns.lineSpans('abc\u001b[3C') ok(parsed.col === 6, 'cursor column advances across CSI C')