@hyzyn/dsh-tty 0.21.0 → 0.22.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +113 -10
- package/README.md +117 -10
- package/client.js +150 -36
- package/lib/index.d.ts +195 -25
- package/lib/index.js +930 -149
- package/lib/index.js.map +1 -1
- package/lib/probe.d.ts +29 -1
- package/lib/probe.js +98 -19
- package/lib/probe.js.map +1 -1
- package/lib/sftp.js +37 -9
- package/lib/sftp.js.map +1 -1
- package/lib/shell-integration.js +25 -1
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh-config.d.ts +34 -2
- package/lib/ssh-config.js +150 -32
- package/lib/ssh-config.js.map +1 -1
- package/lib/ssh.d.ts +256 -2
- package/lib/ssh.js +637 -6
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.js +47 -5
- package/lib/tunnels.js.map +1 -1
- package/package.json +4 -2
- package/scripts/integration.mjs +183 -20
- package/scripts/jump-smoke.mjs +175 -0
- package/scripts/lib/proxy-bridge.mjs +30 -0
- package/scripts/preview/harness.js +74 -26
- package/scripts/probe-route-smoke.mjs +10 -1
- package/scripts/proxycommand-smoke.mjs +267 -0
- package/scripts/windows-smoke.mjs +123 -1
package/lib/index.js
CHANGED
|
@@ -12,12 +12,13 @@ import WebSocket, { WebSocketServer } from 'ws';
|
|
|
12
12
|
// found),必须默认导入后取 Terminal;类型用 InstanceType 别名保持同名可用
|
|
13
13
|
import xtermHeadless from '@xterm/headless';
|
|
14
14
|
const HeadlessTerminal = xtermHeadless.Terminal;
|
|
15
|
-
import { definePlugin, dshHome as resolveDshHome, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
15
|
+
import { definePlugin, dshHome as resolveDshHome, hasSameOriginProof, isLoopbackRequestStrict, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
16
16
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
17
|
-
import { spawnSsh, sshTarget, expandHome, setCredentialResolver } from './ssh.js';
|
|
17
|
+
import { sanitizeJumpSpec, sanitizeProxyCommand, spawnSsh, sshTarget, expandHome, setCredentialResolver, setProxyCommandPolicy, validateJumpSpec, validateProxyCommand } from './ssh.js';
|
|
18
|
+
import { sharedGrantStore, auditLoadedGrants, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
|
|
18
19
|
import { probeSsh } from './probe.js';
|
|
19
20
|
import { buildCommandSpawn, buildShellSpawn, defaultShellPath } from './shell-integration.js';
|
|
20
|
-
import {
|
|
21
|
+
import { parseSshConfigDetailed } from './ssh-config.js';
|
|
21
22
|
import { parseKnownHostsDetailed } from './known-hosts.js';
|
|
22
23
|
import { TunnelManager } from './tunnels.js';
|
|
23
24
|
import { SftpManager } from './sftp.js';
|
|
@@ -25,6 +26,22 @@ import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions
|
|
|
25
26
|
import { buildRemoteStatsCommand, buildWindowsStatsCommand, hasStatsData, localStatsSampler, parseStatsLine } from './stats.js';
|
|
26
27
|
/** SFTP 传输限制默认值。 */
|
|
27
28
|
const DEFAULT_SFTP_LIMITS = { maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 };
|
|
29
|
+
/**
|
|
30
|
+
* 跳板机(ProxyJump 单跳)的 settings schema。
|
|
31
|
+
*
|
|
32
|
+
* 刻意**不给 `auth` / `keyPath` / `passphrase` / `password` / `username` 默认值**:
|
|
33
|
+
* 缺省的含义是「继承目标那一跳的凭据」,一旦给默认值(如 auth 默认 agent)就会把
|
|
34
|
+
* 「继承」变成「显式 agent」,用户配了密码的跳板机就会认证失败。
|
|
35
|
+
*/
|
|
36
|
+
const SSH_JUMP_SCHEMA = z.object({
|
|
37
|
+
host: z.string(),
|
|
38
|
+
port: z.natural().max(65535).default(22),
|
|
39
|
+
username: z.string().default(''),
|
|
40
|
+
auth: z.union([z.const('agent'), z.const('key'), z.const('password')]),
|
|
41
|
+
keyPath: z.string().default(''),
|
|
42
|
+
passphrase: z.string().default(''),
|
|
43
|
+
password: z.string().default(''),
|
|
44
|
+
});
|
|
28
45
|
const SSH_HOST_SCHEMA = z.object({
|
|
29
46
|
name: z.string(),
|
|
30
47
|
host: z.string(),
|
|
@@ -35,6 +52,13 @@ const SSH_HOST_SCHEMA = z.object({
|
|
|
35
52
|
passphrase: z.string().default(''),
|
|
36
53
|
password: z.string().default(''),
|
|
37
54
|
agentForward: z.boolean().default(false),
|
|
55
|
+
/** 经跳板机连接(ProxyJump 单跳);缺省 = 直连。 */
|
|
56
|
+
jump: SSH_JUMP_SCHEMA,
|
|
57
|
+
/**
|
|
58
|
+
* 代理命令(ProxyCommand):本机执行、stdio 当 SSH 传输。**需要 allowProxyCommand 才生效**。
|
|
59
|
+
* `~/.ssh/config` 导入永不自动带入(那一档信任级要用户自己开开关并手填)。
|
|
60
|
+
*/
|
|
61
|
+
proxyCommand: z.string().default(''),
|
|
38
62
|
/** 该条目的 SSH 标签默认以 tmux 持久会话打开(仅 persistence=tmux 时生效)。 */
|
|
39
63
|
persist: z.boolean().default(false),
|
|
40
64
|
});
|
|
@@ -85,15 +109,69 @@ export const Config = z.object({
|
|
|
85
109
|
maxUploadMb: z.natural().max(1024 * 1024).default(2048),
|
|
86
110
|
maxUploadFiles: z.natural().max(100000).default(1000),
|
|
87
111
|
}).default({ maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 }).volatile(),
|
|
112
|
+
/**
|
|
113
|
+
* 允许 ProxyCommand(本机命令执行):**默认 false**。
|
|
114
|
+
*
|
|
115
|
+
* 这是本插件唯一「由设置字段驱动本机任意命令执行」的开关,与跳板机(只连一跳 TCP)不同档;
|
|
116
|
+
* 关着时携带 proxyCommand 的连接**明确失败**(不退回直连),导入也永不自动带入该字段。
|
|
117
|
+
*/
|
|
118
|
+
allowProxyCommand: z.boolean().default(false).volatile(),
|
|
88
119
|
persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]).volatile(),
|
|
89
120
|
});
|
|
90
121
|
/* ------------------------------------------------------------------ *
|
|
91
122
|
* 常量
|
|
92
123
|
* ------------------------------------------------------------------ */
|
|
93
124
|
const WS_PATH = '/api/dsh-tty/ws';
|
|
125
|
+
/**
|
|
126
|
+
* 代理命令这条能力的宿主侧授权(见 kit 的 capability.js 与 docs/architecture.md)。
|
|
127
|
+
*
|
|
128
|
+
* 两条提权通道(见 kit 的 capability.ts):
|
|
129
|
+
* ① 启动环境变量(最严档,判定源是宿主的启动环境快照);
|
|
130
|
+
* ② 就地提权(免重启):卡片上点开关 → 在宿主文件系统上落地一个随机名确认文件。
|
|
131
|
+
* HTTP 侧**不能凭空打开**它——回环围栏与同源证明都拦不住跨站页面与页内脚本,配置路由若能
|
|
132
|
+
* 凭空提权,这道闸门等于没有;而「关掉」永远可用(紧急刹车不依赖重启)。
|
|
133
|
+
*/
|
|
134
|
+
const CAP_PROXY_COMMAND = { env: 'DSH_TTY_ALLOW_PROXY_COMMAND', label: '代理命令(ProxyCommand)' };
|
|
94
135
|
const DEFAULT_MAX_SESSIONS = 4;
|
|
95
136
|
/** 断线保活默认秒数(reconnectGraceSec;0 = 旧行为,断开立即结束会话)。 */
|
|
96
137
|
const DEFAULT_RECONNECT_GRACE_SEC = 120;
|
|
138
|
+
/**
|
|
139
|
+
* 会话退出后的**只读保留策略**(D77)。
|
|
140
|
+
*
|
|
141
|
+
* 进程没了之后把会话留在 `sessions` 表里:`tty_list` / `tty_capture` / `tty_screen`
|
|
142
|
+
* 照常能读到它最后那些输出(用户上报的痛点原话:「结果明明就在那里但我看不到」——
|
|
143
|
+
* `tty_open` 跑一条命令,跑完会话就退役,AI 一个字符都取不回来,逼得人先开
|
|
144
|
+
* `/bin/sh` 再往里发命令)。
|
|
145
|
+
*
|
|
146
|
+
* 取 **∞ = 保留到显式关闭**(`tty_close` / 面板关标签 / 宿主重启),不由时间淘汰:
|
|
147
|
+
*
|
|
148
|
+
* - 时间上界对用户是**第二重惊喜**——「命令跑完 → 下一轮读结果」之间隔着人离开、
|
|
149
|
+
* 模型排队,多久都有可能;一个到期就消失的输出比「要主动关」更难理解;
|
|
150
|
+
* - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(8 条,
|
|
151
|
+
* 单条几百 KB~一两 MB),而「永久」还有一条天然上界——保留是**内存态**,
|
|
152
|
+
* 宿主 / 插件重启即清空,不会跨天累积;
|
|
153
|
+
* - 连续跑很多短命令时,淘汰节奏变成「超过 8 条按最旧淘汰」(`capExited`),
|
|
154
|
+
* 正是想要的语义:近的才有人读。
|
|
155
|
+
*
|
|
156
|
+
* 需要时间上界的人把这里改成任意毫秒数即可——`reapExited` 那条通路还在
|
|
157
|
+
* (回收器每轮都会调它)。导出仅供单测(test/host-frames.test.ts):到点退役与
|
|
158
|
+
* 条数上限的行为护栏。
|
|
159
|
+
*/
|
|
160
|
+
export const EXITED_RETAIN_MS = Number.POSITIVE_INFINITY;
|
|
161
|
+
/**
|
|
162
|
+
* 只读保留的会话数上限(超出按最旧淘汰,见 SessionManager.capExited)。
|
|
163
|
+
*
|
|
164
|
+
* 与「并发会话上限」(maxSessions,默认 4)是两个口径:那个数**只数活着的会话**
|
|
165
|
+
* (retained 的不占名额,否则跑几条短命令就把面板顶成「会话数已达上限」,
|
|
166
|
+
* 比原缺陷更糟)。这里兜的是内存:每条 retained 约 = 256KB 环形缓冲 + 一块
|
|
167
|
+
* xterm-headless 虚拟屏,16 条仍在 ~20MB 量级。
|
|
168
|
+
*
|
|
169
|
+
* 为什么从 8 抬到 16(D77 补,2026-09-27 真机验收反馈):保留改成「留到显式关闭」
|
|
170
|
+
* 之后,上限就是唯一会**自动**挤掉结果的东西,而 agent 一口气开二十几条一次性
|
|
171
|
+
* 会话是常态——8 条意味着「几分钟前那份结果」被最旧淘汰挤掉,用户只看到「没了」。
|
|
172
|
+
* 淘汰一律 `logger.warn` 留痕(见 finishSession),否则这件事在事后完全不可查。
|
|
173
|
+
*/
|
|
174
|
+
export const MAX_EXITED_SESSIONS = 16;
|
|
97
175
|
/** 下行背压阈值(ws.bufferedAmount 字节)。 */
|
|
98
176
|
const BACKPRESSURE_HIGH = 512 * 1024;
|
|
99
177
|
const BACKPRESSURE_LOW = 128 * 1024;
|
|
@@ -134,7 +212,7 @@ const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
|
|
|
134
212
|
const REAPER_INTERVAL_MS = 10_000;
|
|
135
213
|
/** 服务器状态条的采集/推送间隔(mvp 固定 1s,不做配置项)。 */
|
|
136
214
|
const STATS_INTERVAL_MS = 1000;
|
|
137
|
-
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU
|
|
215
|
+
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎;连接簿条目可配单跳跳板机 ProxyJump),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)用 tty_open 开一个会话跑(或引导用户到终端面板里运行),不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成;它**先回看还没读过的已到达输出**,命令瞬间跑完也不会白等——超时若只返回一句诊断文案,别当成「命令没执行」),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
|
|
138
216
|
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
139
217
|
function wrapLocalPty(handle) {
|
|
140
218
|
let resizeWarned = false;
|
|
@@ -188,6 +266,45 @@ export function killLocalShellTerminal(terminal, platform = process.platform) {
|
|
|
188
266
|
/* 已退出 */
|
|
189
267
|
}
|
|
190
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Windows 本地 PTY 的**输入归一化**(D74,2026-09-27 真机报告):conhost 把 Enter 当
|
|
271
|
+
* **CR**,裸 LF 只把光标下移一格、**不提交命令行**——于是 `tty_send` 按工具描述发
|
|
272
|
+
* `echo X\n` 时,命令停在输入行上:没有输出、没有新提示符,看起来像「发出去了但没执行」。
|
|
273
|
+
*
|
|
274
|
+
* 修法取报告建议里改动最小的一条:win32 上把行尾补成 CRLF(已有 CR 的不重复补),
|
|
275
|
+
* 让「照描述写 `\n`」这条主路径直接可用。非 win32 原样透传(POSIX 的 Enter 就是 LF),
|
|
276
|
+
* SSH 会话也不归一化(远端是什么系统、什么 shell 插件不知道,乱改会破坏 `cat` 之类的原始输入)。
|
|
277
|
+
*
|
|
278
|
+
* 导出仅供单测:平台参数注入,两个分支都能在 macOS/Linux 断言。
|
|
279
|
+
*/
|
|
280
|
+
export function normalizePtyInput(data, platform = process.platform) {
|
|
281
|
+
if (platform !== 'win32')
|
|
282
|
+
return data;
|
|
283
|
+
return data.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n');
|
|
284
|
+
}
|
|
285
|
+
/** 退出事实的一句话描述(工具文案共用同一措辞:`exitCode=3` / `signal=SIGSEGV`)。 */
|
|
286
|
+
function describeExit(exited) {
|
|
287
|
+
if (exited.signal !== null && exited.signal !== '')
|
|
288
|
+
return `signal=${exited.signal}`;
|
|
289
|
+
return exited.code === null ? '退出码未知' : `exitCode=${String(exited.code)}`;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* 只读保留的剩余毫秒(D77);`null` = **不按时间释放**(策略为 ∞)或不是保留态。
|
|
293
|
+
*
|
|
294
|
+
* 工具结果带上它,agent 才知道「这个 sid 还能读多久」——否则它会以为读到的
|
|
295
|
+
* 是一具刚刚咽气的尸体、下次照样能读,而实际上屏与缓冲到点就释放了。
|
|
296
|
+
*
|
|
297
|
+
* 注意这个值会进工具输出:**不能返回 `Infinity`**——`JSON.stringify` 会把它变成
|
|
298
|
+
* `null`,撞上宿主对 `output.schema`(`type: 'number'`)的校验,整个工具调用直接
|
|
299
|
+
* 变成 Error(B33 抓过同一类)。所以「无限期」一律用**省略该字段**表达。
|
|
300
|
+
*/
|
|
301
|
+
function retainLeftMs(session, now = Date.now()) {
|
|
302
|
+
if (session.exited === null)
|
|
303
|
+
return null;
|
|
304
|
+
if (!Number.isFinite(EXITED_RETAIN_MS))
|
|
305
|
+
return null;
|
|
306
|
+
return Math.max(0, session.exited.at + EXITED_RETAIN_MS - now);
|
|
307
|
+
}
|
|
191
308
|
/** TERM/COLORTERM 值白名单校验:不合法回退 fallback(防止破坏 -c 包装层)。 */
|
|
192
309
|
function sanitizeTermValue(value, fallback) {
|
|
193
310
|
const trimmed = value.trim();
|
|
@@ -217,6 +334,8 @@ class LiveConfig {
|
|
|
217
334
|
persistSessions;
|
|
218
335
|
/** SFTP 传输限制(客户端浏览器侧执行)。 */
|
|
219
336
|
sftpLimits;
|
|
337
|
+
/** 允许 ProxyCommand(本机命令执行):默认关(见 Config.allowProxyCommand)。 */
|
|
338
|
+
allowProxyCommand;
|
|
220
339
|
constructor(init) {
|
|
221
340
|
this.shell = init.shell;
|
|
222
341
|
this.term = sanitizeTermValue(init.term, 'xterm-256color');
|
|
@@ -232,6 +351,8 @@ class LiveConfig {
|
|
|
232
351
|
// 只有显式 false 才关(缺省/旧配置一律视为开)
|
|
233
352
|
this.statsEnabled = init.statsEnabled !== false;
|
|
234
353
|
this.sftpLimits = sanitizeSftpLimits(init.sftpLimits);
|
|
354
|
+
// 缺省/旧配置一律视为**关**(这一档是「本机命令执行」,只有显式 true 才开)
|
|
355
|
+
this.allowProxyCommand = init.allowProxyCommand === true;
|
|
235
356
|
this.persistSessions = init.persistSessions ?? [];
|
|
236
357
|
}
|
|
237
358
|
/** 合并部分更新;空字符串/undefined 保持原值;sshHosts/hostKeys/tunnels 传数组即整体替换。 */
|
|
@@ -263,6 +384,8 @@ class LiveConfig {
|
|
|
263
384
|
this.statsEnabled = partial.statsEnabled;
|
|
264
385
|
if (partial.sftpLimits !== undefined)
|
|
265
386
|
this.sftpLimits = sanitizeSftpLimits({ ...this.sftpLimits, ...partial.sftpLimits });
|
|
387
|
+
if (typeof partial.allowProxyCommand === 'boolean')
|
|
388
|
+
this.allowProxyCommand = partial.allowProxyCommand;
|
|
266
389
|
if (Array.isArray(partial.persistSessions))
|
|
267
390
|
this.persistSessions = partial.persistSessions;
|
|
268
391
|
}
|
|
@@ -501,6 +624,144 @@ export function feedShellIntegration(session, text) {
|
|
|
501
624
|
state.cmdBuffer = (state.cmdBuffer + rest).slice(-COMMAND_CAP);
|
|
502
625
|
}
|
|
503
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* 唯一追加点(D72):环形缓冲与单调字符计数一起维护。水位线用绝对值定位
|
|
629
|
+
* 未读区(`buffer 起点 = outputSeq - buffer.length`,裁剪只会前移起点,
|
|
630
|
+
* 公式恒成立),漏掉任何一处追加都会让它错位——notice 注入那两处此前也是
|
|
631
|
+
* 各写各的 `tailFromSafeBoundary`。
|
|
632
|
+
*/
|
|
633
|
+
function appendOutput(session, text) {
|
|
634
|
+
session.outputSeq += text.length;
|
|
635
|
+
session.buffer = tailFromSafeBoundary(session.buffer + text, BUFFER_CAP);
|
|
636
|
+
}
|
|
637
|
+
/** 原始流里最后一个「命令开始」B 标记的结束位置;-1 = 窗口内没有(无 shell 集成 → 降级)。 */
|
|
638
|
+
function lastCommandStart(text) {
|
|
639
|
+
OSC133_RE.lastIndex = 0;
|
|
640
|
+
let end = -1;
|
|
641
|
+
for (const match of text.matchAll(OSC133_RE)) {
|
|
642
|
+
if (match[1] === 'B')
|
|
643
|
+
end = (match.index ?? 0) + match[0].length;
|
|
644
|
+
}
|
|
645
|
+
OSC133_RE.lastIndex = 0; // 归位:这个正则是共用的(feedShellIntegration 也用它)
|
|
646
|
+
return end;
|
|
647
|
+
}
|
|
648
|
+
/** 首次被 agent 工具触达时把水位线落在当下——否则第一次回扫会把用户此前的历史输出当成「还没读过」。 */
|
|
649
|
+
function ensureReadMark(session) {
|
|
650
|
+
if (session.readSeq >= 0)
|
|
651
|
+
return;
|
|
652
|
+
session.readSeq = session.outputSeq;
|
|
653
|
+
session.readMarkAt = Date.now();
|
|
654
|
+
}
|
|
655
|
+
/** 读侧工具返回前推进水位线:返回时刻 = 「AI 真正看到的内容」的上界。 */
|
|
656
|
+
function advanceReadMark(session) {
|
|
657
|
+
session.readSeq = session.outputSeq;
|
|
658
|
+
session.readMarkAt = Date.now();
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* 水位线之后的**未读**原始输出。下界再抬到「最后一个 B 标记之后」:回显落在
|
|
662
|
+
* A(prompt 开始)与 B(命令开始)之间,所以这样能零启发式地排除回显——按
|
|
663
|
+
* 文本比对「跳过与发送内容相同的行」会被折行 / ANSI / 多行粘贴打碎。
|
|
664
|
+
* 没有 B 标记(未开 shell 集成 / Windows PowerShell / 远端未装集成)时无法按边界切片:
|
|
665
|
+
* D75 起改用「最近提交过的输入」这份回显候选把回显行从匹配窗口里剔掉(见 echoOnlyMatch),
|
|
666
|
+
* 不再直接拿回显当命中。
|
|
667
|
+
*/
|
|
668
|
+
function unreadRegion(session) {
|
|
669
|
+
ensureReadMark(session);
|
|
670
|
+
const bufferStart = session.outputSeq - session.buffer.length;
|
|
671
|
+
const text = session.buffer.slice(Math.max(0, session.readSeq - bufferStart));
|
|
672
|
+
const start = lastCommandStart(text);
|
|
673
|
+
return start === -1 ? text : text.slice(start);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* pattern 匹配的统一入口(D72):先对含 ANSI 的原始流试(live 路径原语义),
|
|
677
|
+
* 未命中再对清洗后文本试一次——这样承诺才是「tty_capture 里看得到的,
|
|
678
|
+
* tty_expect 也能匹配到」,而不是回扫命中、返回文案里却看不到。
|
|
679
|
+
*/
|
|
680
|
+
function testPattern(re, text) {
|
|
681
|
+
re.lastIndex = 0;
|
|
682
|
+
if (re.test(text))
|
|
683
|
+
return true;
|
|
684
|
+
re.lastIndex = 0;
|
|
685
|
+
return re.test(cleanAnsiTail(text));
|
|
686
|
+
}
|
|
687
|
+
/* ------------------------------------------------------------------ *
|
|
688
|
+
* 回显命中剔除(D75)
|
|
689
|
+
*
|
|
690
|
+
* 症状:`tty_expect` 会命中**命令回显本身**——命令还没执行(Windows 上裸 LF 不提交,
|
|
691
|
+
* 见 D74;或只是被 shell 集成之外的环境回显),`tty_send` 写进去的那行文本先回到了
|
|
692
|
+
* 输出流里,于是「等就绪标记」立刻返回 matched。报告侧的现场:LF 没提交、命令一次
|
|
693
|
+
* 都没跑,`tty_expect pattern="echo ECHO_DEMO_2"` 秒回 matched(来源标注 buffered)。
|
|
694
|
+
*
|
|
695
|
+
* 判据(不动 B 标记那条零启发式路径,只补它够不着的场景):把**最近提交过的命令行**
|
|
696
|
+
* 从候选文本里削掉再试一次,只有「削掉后不再命中」才算纯回显。这样:
|
|
697
|
+
* - 真实输出里也出现的标记照旧命中(不误杀);
|
|
698
|
+
* - 只在回显行**末尾**匹配才削(cmd 的回显与提示符同行,所以按行尾切);
|
|
699
|
+
* - 有 B 标记 / 命令正在跑(TUI 全屏重画里出现输入文本是正常输出)时不启用。
|
|
700
|
+
* ------------------------------------------------------------------ */
|
|
701
|
+
/** 回显候选的行数上限(最近几条提交的输入)与单行长度上限。 */
|
|
702
|
+
const ECHO_INPUT_CAP = 8;
|
|
703
|
+
const ECHO_LINE_CAP = 512;
|
|
704
|
+
/**
|
|
705
|
+
* 记录一次「提交过的输入」的回显候选(只在 `tty_send` 且带行尾时调用):
|
|
706
|
+
* 单键按键(TUI 的 `q` / 方向键)不是命令行,不记——否则一个 `q` 就能把
|
|
707
|
+
* 之后任何只匹配到 `q` 的等待吞掉。
|
|
708
|
+
*/
|
|
709
|
+
function noteSubmittedInput(session, data) {
|
|
710
|
+
if (!/[\r\n]/.test(data))
|
|
711
|
+
return;
|
|
712
|
+
const lines = data
|
|
713
|
+
.split(/\r\n|\r|\n/)
|
|
714
|
+
.map((line) => line.trim())
|
|
715
|
+
// 控制字符(多为转义序列,如 `vim` 的 `\x1b:wq`)不做行编辑模拟,直接不记
|
|
716
|
+
.filter((line) => line !== '' && line.length <= ECHO_LINE_CAP && !/[\x00-\x1f\x7f]/.test(line));
|
|
717
|
+
if (lines.length === 0)
|
|
718
|
+
return;
|
|
719
|
+
session.recentInputs = [...session.recentInputs, ...lines].slice(-ECHO_INPUT_CAP);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* 这批回显候选在当前现场是否该启用剔除。三条都得成立:
|
|
723
|
+
* - 有候选(没 `tty_send` 过就无从判断,保持原语义);
|
|
724
|
+
* - 命令没在跑(`inCommand`:TUI 重画 / 长任务把输入文本画到屏上是真实输出);
|
|
725
|
+
* - 候选文本里没有 B 标记(有 B 就说明 shell 集成在管边界,回显已被切掉)。
|
|
726
|
+
*/
|
|
727
|
+
function echoStrippable(session, text) {
|
|
728
|
+
if (session.recentInputs.length === 0)
|
|
729
|
+
return false;
|
|
730
|
+
if (session.shellState.inCommand)
|
|
731
|
+
return false;
|
|
732
|
+
return lastCommandStart(text) === -1;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* 把回显候选行从文本里削掉(保行结构)。按**行尾**匹配:cmd 的回显与提示符同行
|
|
736
|
+
* (`C:\>echo X`),所以只削后缀、保留提示符前缀。行尾空白(含 `\r`)先归一。
|
|
737
|
+
*/
|
|
738
|
+
function stripEchoLines(text, echoes) {
|
|
739
|
+
if (echoes.length === 0)
|
|
740
|
+
return text;
|
|
741
|
+
const sorted = [...echoes].sort((a, b) => b.length - a.length);
|
|
742
|
+
return text
|
|
743
|
+
.split('\n')
|
|
744
|
+
.map((line) => {
|
|
745
|
+
const trimmed = line.replace(/[ \t\r]+$/, '');
|
|
746
|
+
for (const echo of sorted) {
|
|
747
|
+
if (trimmed.endsWith(echo))
|
|
748
|
+
return trimmed.slice(0, trimmed.length - echo.length);
|
|
749
|
+
}
|
|
750
|
+
return line;
|
|
751
|
+
})
|
|
752
|
+
.join('\n');
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* 命中是否**只**落在回显上(纯回显 → 不算命中)。
|
|
756
|
+
* 原始流与清洗后文本各削一次:回显行里可能夹着 ANSI(zsh 的 zle / 彩色提示符)。
|
|
757
|
+
*/
|
|
758
|
+
function echoOnlyMatch(re, text, session) {
|
|
759
|
+
if (!echoStrippable(session, text))
|
|
760
|
+
return false;
|
|
761
|
+
const stripped = stripEchoLines(text, session.recentInputs);
|
|
762
|
+
const strippedClean = stripEchoLines(cleanAnsiTail(text), session.recentInputs);
|
|
763
|
+
return !testPattern(re, stripped) && !testPattern(re, strippedClean);
|
|
764
|
+
}
|
|
504
765
|
/** 宽松清洗一份 tunnels 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
|
|
505
766
|
function sanitizeTunnels(input) {
|
|
506
767
|
if (!Array.isArray(input))
|
|
@@ -610,6 +871,14 @@ function sanitizeSshHosts(input) {
|
|
|
610
871
|
agentForward: raw.agentForward === true,
|
|
611
872
|
persist: raw.persist === true,
|
|
612
873
|
});
|
|
874
|
+
// 跳板机:清洗不出可用对象(host 为空)就当没配——不给下游留半个对象
|
|
875
|
+
const jump = sanitizeJumpSpec(raw.jump);
|
|
876
|
+
if (jump !== undefined)
|
|
877
|
+
out[out.length - 1].jump = jump;
|
|
878
|
+
// 代理命令:形状不合法(含换行 / 超长 / 非字符串)就当没配(**失败方向是关**)
|
|
879
|
+
const proxyCommand = sanitizeProxyCommand(raw.proxyCommand);
|
|
880
|
+
if (proxyCommand !== undefined)
|
|
881
|
+
out[out.length - 1].proxyCommand = proxyCommand;
|
|
613
882
|
}
|
|
614
883
|
return out;
|
|
615
884
|
}
|
|
@@ -650,6 +919,16 @@ function validateSshHosts(input) {
|
|
|
650
919
|
if ((raw.auth === 'key') && (typeof raw.keyPath !== 'string' || raw.keyPath.trim() === '')) {
|
|
651
920
|
return { error: `sshHosts「${String(raw.name)}」auth=key 需要 keyPath` };
|
|
652
921
|
}
|
|
922
|
+
if (raw.jump !== undefined) {
|
|
923
|
+
const checked = validateJumpSpec(raw.jump);
|
|
924
|
+
if (checked.error !== undefined)
|
|
925
|
+
return { error: `sshHosts「${String(raw.name)}」${checked.error}` };
|
|
926
|
+
}
|
|
927
|
+
if (raw.proxyCommand !== undefined) {
|
|
928
|
+
const checked = validateProxyCommand(raw.proxyCommand);
|
|
929
|
+
if (checked.error !== undefined)
|
|
930
|
+
return { error: `sshHosts「${String(raw.name)}」${checked.error}` };
|
|
931
|
+
}
|
|
653
932
|
}
|
|
654
933
|
return { hosts: sanitizeSshHosts(input) };
|
|
655
934
|
}
|
|
@@ -788,34 +1067,16 @@ class HostKeyStore {
|
|
|
788
1067
|
this.persist(next);
|
|
789
1068
|
}
|
|
790
1069
|
}
|
|
791
|
-
/**
|
|
1070
|
+
/**
|
|
1071
|
+
* upgrade 路由的 loopback 信任围栏(socket 版):直接用 kit 的加固档(docker D31/D80/D110)。
|
|
1072
|
+
*
|
|
1073
|
+
* 与 HTTP 路由的差别只有一处刻意为之:**不放开 Cookie 例外**。升级请求在浏览器里必带
|
|
1074
|
+
* `Origin`,而加固档「Origin 有就必须与 Host 同源」这条已经等价于同源证明;而长连
|
|
1075
|
+
* (PTY / 隧道帧)一旦建立就一直活着,没必要为桌面壳那种「只带 Cookie」的转发链开口子
|
|
1076
|
+
* ——真要支持,也应该先在桌面壳上验一条真机链路(docker 的 D139 是那样定下来的)。
|
|
1077
|
+
*/
|
|
792
1078
|
function isLoopbackUpgrade(req) {
|
|
793
|
-
|
|
794
|
-
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
|
|
795
|
-
return false;
|
|
796
|
-
const host = req.headers.host;
|
|
797
|
-
if (typeof host !== 'string')
|
|
798
|
-
return false;
|
|
799
|
-
let hostUrl;
|
|
800
|
-
try {
|
|
801
|
-
hostUrl = new URL('http://' + host);
|
|
802
|
-
}
|
|
803
|
-
catch {
|
|
804
|
-
return false;
|
|
805
|
-
}
|
|
806
|
-
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]')
|
|
807
|
-
return false;
|
|
808
|
-
if (req.headers['sec-fetch-site'] === 'cross-site')
|
|
809
|
-
return false;
|
|
810
|
-
const origin = req.headers.origin;
|
|
811
|
-
if (origin === undefined)
|
|
812
|
-
return true;
|
|
813
|
-
try {
|
|
814
|
-
return new URL(origin).host === hostUrl.host;
|
|
815
|
-
}
|
|
816
|
-
catch {
|
|
817
|
-
return false;
|
|
818
|
-
}
|
|
1079
|
+
return isLoopbackRequestStrict(req);
|
|
819
1080
|
}
|
|
820
1081
|
/* ------------------------------------------------------------------ *
|
|
821
1082
|
* 虚拟屏(xterm-headless)——构造与异常兜底(D57)
|
|
@@ -1007,9 +1268,6 @@ export function writeToScreen(screen, heartbeat, text, onStall, stallMs = SCREEN
|
|
|
1007
1268
|
heartbeat.watchdog.unref?.();
|
|
1008
1269
|
}
|
|
1009
1270
|
}
|
|
1010
|
-
/* ------------------------------------------------------------------ *
|
|
1011
|
-
* 会话管理
|
|
1012
|
-
* ------------------------------------------------------------------ */
|
|
1013
1271
|
/** 导出仅供单测(test/host-frames.test.ts):上限 / 孤儿回收 / grace 热改的行为护栏。 */
|
|
1014
1272
|
export class SessionManager {
|
|
1015
1273
|
sessions = new Map();
|
|
@@ -1031,8 +1289,29 @@ export class SessionManager {
|
|
|
1031
1289
|
get count() {
|
|
1032
1290
|
return this.sessions.size;
|
|
1033
1291
|
}
|
|
1292
|
+
/** 活着的会话数(**不含**只读保留的,见 canSpawn)。 */
|
|
1293
|
+
get liveCount() {
|
|
1294
|
+
let live = 0;
|
|
1295
|
+
for (const session of this.sessions.values())
|
|
1296
|
+
if (session.exited === null)
|
|
1297
|
+
live += 1;
|
|
1298
|
+
return live;
|
|
1299
|
+
}
|
|
1300
|
+
/** 只读保留的会话数(D77)。 */
|
|
1301
|
+
get exitedCount() {
|
|
1302
|
+
let exited = 0;
|
|
1303
|
+
for (const session of this.sessions.values())
|
|
1304
|
+
if (session.exited !== null)
|
|
1305
|
+
exited += 1;
|
|
1306
|
+
return exited;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* 名额判据**只数活着的会话**(D77):只读保留的不占名额。不这样分的话,
|
|
1310
|
+
* 「跑几条短命令」就能把面板顶成「会话数已达上限」——用户一条会话都没开,
|
|
1311
|
+
* 比原来那个「AI 取不到结果」的缺陷更糟。
|
|
1312
|
+
*/
|
|
1034
1313
|
canSpawn() {
|
|
1035
|
-
return this.
|
|
1314
|
+
return this.liveCount < this.limit;
|
|
1036
1315
|
}
|
|
1037
1316
|
add(session) {
|
|
1038
1317
|
this.sessions.set(session.id, session);
|
|
@@ -1043,8 +1322,19 @@ export class SessionManager {
|
|
|
1043
1322
|
get(id) {
|
|
1044
1323
|
return this.sessions.get(id);
|
|
1045
1324
|
}
|
|
1046
|
-
/** 会话的只读快照(SSH 会话无本地 pid,该字段省略;tmux 持久会话带 persist
|
|
1325
|
+
/** 会话的只读快照(SSH 会话无本地 pid,该字段省略;tmux 持久会话带 persist;
|
|
1326
|
+
* 只读保留态(D77)额外带 exited/exitCode|signal/retainMs)。 */
|
|
1047
1327
|
snapshotOf(session) {
|
|
1328
|
+
const exitRetainMs = retainLeftMs(session);
|
|
1329
|
+
const exitInfo = session.exited === null
|
|
1330
|
+
? {}
|
|
1331
|
+
: {
|
|
1332
|
+
exited: true,
|
|
1333
|
+
...(session.exited.code === null ? {} : { exitCode: session.exited.code }),
|
|
1334
|
+
...(session.exited.signal === null ? {} : { signal: session.exited.signal }),
|
|
1335
|
+
// 省略 = 不按时间释放(策略 ∞):不能塞 Infinity,理由见 retainLeftMs
|
|
1336
|
+
...(exitRetainMs === null ? {} : { retainMs: exitRetainMs }),
|
|
1337
|
+
};
|
|
1048
1338
|
const base = {
|
|
1049
1339
|
sid: session.id,
|
|
1050
1340
|
cwd: session.cwd,
|
|
@@ -1054,6 +1344,7 @@ export class SessionManager {
|
|
|
1054
1344
|
lastOutputAt: session.lastOutputAt,
|
|
1055
1345
|
owner: session.owner,
|
|
1056
1346
|
...(session.tmuxName !== null ? { persist: true } : {}),
|
|
1347
|
+
...exitInfo,
|
|
1057
1348
|
};
|
|
1058
1349
|
return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
|
|
1059
1350
|
}
|
|
@@ -1065,7 +1356,8 @@ export class SessionManager {
|
|
|
1065
1356
|
listForAttach() {
|
|
1066
1357
|
return [...this.sessions.values()].map((session) => ({
|
|
1067
1358
|
...this.snapshotOf(session),
|
|
1068
|
-
|
|
1359
|
+
// 只读保留态不可 attach(没有活着的 PTY 可接):客户端据此不建幽灵标签
|
|
1360
|
+
attachable: session.clients.size === 0 && !session.closed && session.exited === null,
|
|
1069
1361
|
}));
|
|
1070
1362
|
}
|
|
1071
1363
|
/** 遍历全部会话(状态条采集器的批量收尾等按会话维度的操作)。 */
|
|
@@ -1073,10 +1365,12 @@ export class SessionManager {
|
|
|
1073
1365
|
for (const session of this.sessions.values())
|
|
1074
1366
|
fn(session);
|
|
1075
1367
|
}
|
|
1076
|
-
/** 按 tmux
|
|
1368
|
+
/** 按 tmux 持久会话名查找**活着**的会话(跨窗口共享用);不存在/已关闭/只读保留态返回 undefined。
|
|
1369
|
+
* D77:保留态必须排除——否则「同名 persistName 的新标签」会 rebind 到一具尸体上,
|
|
1370
|
+
* 拿到 ready 却永远没有输出。 */
|
|
1077
1371
|
findByTmuxName(tmuxName) {
|
|
1078
1372
|
for (const session of this.sessions.values()) {
|
|
1079
|
-
if (session.tmuxName === tmuxName && !session.closed)
|
|
1373
|
+
if (session.tmuxName === tmuxName && !session.closed && session.exited === null)
|
|
1080
1374
|
return session;
|
|
1081
1375
|
}
|
|
1082
1376
|
return undefined;
|
|
@@ -1121,6 +1415,11 @@ export class SessionManager {
|
|
|
1121
1415
|
for (const session of [...this.sessions.values()]) {
|
|
1122
1416
|
if (session.owner === 'agent')
|
|
1123
1417
|
continue;
|
|
1418
|
+
// D77:只读保留态不归孤儿回收管——它可能本来就带着 orphanedAt(断线后
|
|
1419
|
+
// 才退出),被这里收掉就等于「退出即退役」,保留期形同虚设。它的到点
|
|
1420
|
+
// 退役由 reapExited 统一负责。
|
|
1421
|
+
if (session.exited !== null)
|
|
1422
|
+
continue;
|
|
1124
1423
|
if (session.orphanedAt === null)
|
|
1125
1424
|
continue;
|
|
1126
1425
|
if (graceMs <= 0 || now - session.orphanedAt >= graceMs) {
|
|
@@ -1128,6 +1427,40 @@ export class SessionManager {
|
|
|
1128
1427
|
}
|
|
1129
1428
|
}
|
|
1130
1429
|
}
|
|
1430
|
+
/**
|
|
1431
|
+
* 只读保留到点退役(D77;回收器每轮调用):超过保留期的会话出表 + 释放屏。
|
|
1432
|
+
*
|
|
1433
|
+
* 默认策略是 ∞(保留到显式关闭)⇒ 本方法是 no-op,条数由 `capExited` 兜;
|
|
1434
|
+
* 把 `EXITED_RETAIN_MS` 改成有限值它就照常工作(策略可调,通路留着)。
|
|
1435
|
+
*
|
|
1436
|
+
* **不 kill 进程**:这里收的全是已经退出的会话(进程早没了),`retire()` 就够;
|
|
1437
|
+
* 真退役(显式 `tty_close` / 面板关标签)走 `killSessionNow`,那条路要处理
|
|
1438
|
+
* tmux teardown 与 forceKill 的兜底。
|
|
1439
|
+
*/
|
|
1440
|
+
reapExited(retainMs) {
|
|
1441
|
+
const now = Date.now();
|
|
1442
|
+
for (const session of [...this.sessions.values()]) {
|
|
1443
|
+
if (session.exited === null)
|
|
1444
|
+
continue;
|
|
1445
|
+
if (retainMs <= 0 || now - session.exited.at >= retainMs)
|
|
1446
|
+
this.retire(session);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* 只读保留的数量上限(超出按最旧淘汰,D77):返回被淘汰的会话,便于单测断言。
|
|
1451
|
+
*
|
|
1452
|
+
* 为什么必须有:`owner:'agent'` 的会话不会走孤儿回收,agent 若不显式 `tty_close`
|
|
1453
|
+
* (它常常不会),保留态就是**永久泄漏**——屏与 256KB 缓冲一直挂着。
|
|
1454
|
+
*/
|
|
1455
|
+
capExited(max) {
|
|
1456
|
+
const exited = [...this.sessions.values()]
|
|
1457
|
+
.filter((session) => session.exited !== null)
|
|
1458
|
+
.sort((a, b) => (a.exited?.at ?? 0) - (b.exited?.at ?? 0));
|
|
1459
|
+
const victims = exited.slice(0, Math.max(0, exited.length - max));
|
|
1460
|
+
for (const session of victims)
|
|
1461
|
+
this.retire(session);
|
|
1462
|
+
return victims;
|
|
1463
|
+
}
|
|
1131
1464
|
async disposeAll() {
|
|
1132
1465
|
const all = [...this.sessions.values()];
|
|
1133
1466
|
this.sessions.clear();
|
|
@@ -1140,6 +1473,9 @@ export class SessionManager {
|
|
|
1140
1473
|
catch {
|
|
1141
1474
|
/* 已释放 */
|
|
1142
1475
|
}
|
|
1476
|
+
// D77:只读保留态(进程已退出)没有要收的进程——`forceKill` 只会对死句柄再戳一遍
|
|
1477
|
+
if (session.exited !== null)
|
|
1478
|
+
return Promise.resolve(true);
|
|
1143
1479
|
return forceKill(session.handle);
|
|
1144
1480
|
}));
|
|
1145
1481
|
}
|
|
@@ -1245,7 +1581,8 @@ export class TtyServer {
|
|
|
1245
1581
|
* 隐藏状态条,PTY 数据路径与终端体验完全不受影响。
|
|
1246
1582
|
*/
|
|
1247
1583
|
startStats(session) {
|
|
1248
|
-
|
|
1584
|
+
// D77:只读保留态没有进程可采(本地会取到宿主、远端 channel 早断了)——直接不起表
|
|
1585
|
+
if (!this.statsOn || session.closed || session.exited !== null || session.stats !== null || session.statsFailed)
|
|
1249
1586
|
return;
|
|
1250
1587
|
if (session.kind === 'local') {
|
|
1251
1588
|
const sampler = localStatsSampler();
|
|
@@ -1361,10 +1698,26 @@ export class TtyServer {
|
|
|
1361
1698
|
}
|
|
1362
1699
|
/** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
|
|
1363
1700
|
handleUpgrade(req, socket, head) {
|
|
1364
|
-
|
|
1701
|
+
const loopback = isLoopbackUpgrade(req);
|
|
1702
|
+
// 字面量环回同步判定(绝大多数请求:握手第一拍就继续,不引入额外时序);
|
|
1703
|
+
// 只有主机名 / /etc/hosts 别名才等一次 DNS 确认(≤500ms),期间不碰 socket
|
|
1704
|
+
if (loopback instanceof Promise) {
|
|
1705
|
+
void loopback.then((ok) => {
|
|
1706
|
+
if (ok)
|
|
1707
|
+
this.finishUpgrade(req, socket, head);
|
|
1708
|
+
else
|
|
1709
|
+
socket.destroy();
|
|
1710
|
+
}).catch(() => { socket.destroy(); });
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
if (!loopback) {
|
|
1365
1714
|
socket.destroy();
|
|
1366
1715
|
return;
|
|
1367
1716
|
}
|
|
1717
|
+
this.finishUpgrade(req, socket, head);
|
|
1718
|
+
}
|
|
1719
|
+
/** 围栏放行之后的实际握手(与上面的异步分支共用)。 */
|
|
1720
|
+
finishUpgrade(req, socket, head) {
|
|
1368
1721
|
if (!this.wsGateOpen) {
|
|
1369
1722
|
socket.destroy();
|
|
1370
1723
|
return;
|
|
@@ -1423,6 +1776,20 @@ export class TtyServer {
|
|
|
1423
1776
|
this.ctx.logger.warn('[dsh-tty] ws error: ' + error.message);
|
|
1424
1777
|
});
|
|
1425
1778
|
}
|
|
1779
|
+
/**
|
|
1780
|
+
* 摘掉同 sid 上残留的**只读保留**会话(D77):spawn / ssh 新建同名会话前调用。
|
|
1781
|
+
*
|
|
1782
|
+
* 不摘会真泄漏:`sessions.add()` 用同一个键把旧对象顶出表,而旧对象的虚拟屏与
|
|
1783
|
+
* 256KB 环形缓冲再没有任何引用能释放它们(`retire` 是唯一的释放口)。只处理
|
|
1784
|
+
* 保留态——活着的同 sid 会话属于「跨连接同名」的既有语义,不在这里动。
|
|
1785
|
+
*/
|
|
1786
|
+
retireStaleExited(sid) {
|
|
1787
|
+
const stale = this.sessions.get(sid);
|
|
1788
|
+
if (stale === undefined || stale.exited === null)
|
|
1789
|
+
return;
|
|
1790
|
+
this.sessionLocals.get(stale)?.delete(stale.id);
|
|
1791
|
+
this.sessions.retire(stale);
|
|
1792
|
+
}
|
|
1426
1793
|
/**
|
|
1427
1794
|
* 解析帧里的 sid。返回:
|
|
1428
1795
|
* { sid } 目标会话;
|
|
@@ -1512,6 +1879,10 @@ export class TtyServer {
|
|
|
1512
1879
|
local: new Map(),
|
|
1513
1880
|
owner: 'agent',
|
|
1514
1881
|
});
|
|
1882
|
+
// D72:agent 自己开的会话从出生起「什么都没读过」(水位线落在 seq 0)——
|
|
1883
|
+
// 包括 `command` 型会话在第一次 expect 之前打印的启动输出。用户开的标签
|
|
1884
|
+
// 相反:历史输出不算未读,首次被 agent 触达时才把水位线落在当下。
|
|
1885
|
+
ensureReadMark(session);
|
|
1515
1886
|
// 面板可见性:新会话推给所有已连接的面板(客户端据此建「agent 开的」标签)
|
|
1516
1887
|
this.broadcastSessions();
|
|
1517
1888
|
return { sid: session.id, persist: session.tmuxName !== null && !degraded };
|
|
@@ -1524,6 +1895,8 @@ export class TtyServer {
|
|
|
1524
1895
|
if (session.owner !== 'agent') {
|
|
1525
1896
|
throw new Error(`会话 ${sid} 是用户在面板里开的(owner=user):请在面板里关闭那个标签,不要由 agent 越权结束`);
|
|
1526
1897
|
}
|
|
1898
|
+
// D77:只读保留态(进程已退出)也走这条路——`killSessionNow` 对死句柄是安全的,
|
|
1899
|
+
// 它做的正是「退役 + 释放屏」。这也是 issue 里要的那个「显式关闭」入口。
|
|
1527
1900
|
this.flushPendingOutput(session);
|
|
1528
1901
|
this.killSessionNow(session);
|
|
1529
1902
|
this.broadcastSessions();
|
|
@@ -1676,6 +2049,7 @@ export class TtyServer {
|
|
|
1676
2049
|
handle,
|
|
1677
2050
|
clients: new Map(),
|
|
1678
2051
|
closed: false,
|
|
2052
|
+
exited: null,
|
|
1679
2053
|
paused: false,
|
|
1680
2054
|
owner,
|
|
1681
2055
|
cwd,
|
|
@@ -1684,6 +2058,10 @@ export class TtyServer {
|
|
|
1684
2058
|
startedAt: Date.now(),
|
|
1685
2059
|
lastOutputAt: Date.now(),
|
|
1686
2060
|
lastInputAt: Date.now(),
|
|
2061
|
+
outputSeq: 0,
|
|
2062
|
+
readSeq: -1,
|
|
2063
|
+
readMarkAt: 0,
|
|
2064
|
+
recentInputs: [],
|
|
1687
2065
|
buffer: '',
|
|
1688
2066
|
decoder: new StringDecoder('utf8'),
|
|
1689
2067
|
screen: this.createScreen(clampInt(cols, 80, 2, 500), clampInt(rows, 24, 2, 200)),
|
|
@@ -1740,6 +2118,14 @@ export class TtyServer {
|
|
|
1740
2118
|
session.statsSubs.clear();
|
|
1741
2119
|
this.stopStats(session);
|
|
1742
2120
|
this.sessions.retire(session);
|
|
2121
|
+
if (session.exited !== null) {
|
|
2122
|
+
// D77:只读保留态(进程已经退出)**只退役、不再碰句柄**。下面那几条收尾
|
|
2123
|
+
// (tmux kill-session / forceKill / done 兜底)语义都是「把一个还活着的 PTY
|
|
2124
|
+
// 收掉」;对一具尸体再戳一遍收益为零,而在个别后端(win-arm64 的 ConPTY)
|
|
2125
|
+
// 恰好是纯风险。tmux 那条也不做:D77 之前「退出」本来就不 teardown(tmux
|
|
2126
|
+
// 会话留存),保持一致。
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
1743
2129
|
const teardown = session.handle.tmuxTeardown;
|
|
1744
2130
|
if (teardown !== undefined) {
|
|
1745
2131
|
let settled = false;
|
|
@@ -1809,6 +2195,7 @@ export class TtyServer {
|
|
|
1809
2195
|
send(ws, { t: 'error', sid, m: 'sid 已存在' });
|
|
1810
2196
|
return;
|
|
1811
2197
|
}
|
|
2198
|
+
this.retireStaleExited(sid); // D77:同 sid 上的只读保留态先摘掉,别让 add() 把它顶成泄漏
|
|
1812
2199
|
// 持久化(0.10.0):配置 persistence=tmux 且帧带 persist 时,spawn 包装层
|
|
1813
2200
|
// 换成 `exec tmux -L dsh-tty -A -s <名>`(tmux 托管);tmux 未安装则降级
|
|
1814
2201
|
// 普通会话并回灰字提示。持久名稳定(客户端生成、随标签规格保存),
|
|
@@ -1869,7 +2256,7 @@ export class TtyServer {
|
|
|
1869
2256
|
send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(next.tmuxName !== null ? { persist: true } : {}) });
|
|
1870
2257
|
if (created.wantsPersist && created.degraded) {
|
|
1871
2258
|
const notice = '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
|
|
1872
|
-
|
|
2259
|
+
appendOutput(next, notice);
|
|
1873
2260
|
send(ws, { t: 'data', sid, d: notice });
|
|
1874
2261
|
}
|
|
1875
2262
|
}
|
|
@@ -1883,6 +2270,7 @@ export class TtyServer {
|
|
|
1883
2270
|
send(ws, { t: 'error', sid, m: 'sid 已存在' });
|
|
1884
2271
|
return;
|
|
1885
2272
|
}
|
|
2273
|
+
this.retireStaleExited(sid); // D77:同上,SSH 分支同规则
|
|
1886
2274
|
// 跨窗口共享(0.10.1):同 tmuxName 的 SSH 持久会话还活着时不重建远程
|
|
1887
2275
|
// 连接,本连接重绑定到现有会话(单 PTY 多客户端扇出)
|
|
1888
2276
|
const parsedCommand = sanitizeCommand(msg.command);
|
|
@@ -1896,7 +2284,7 @@ export class TtyServer {
|
|
|
1896
2284
|
: null;
|
|
1897
2285
|
if (persistName !== null) {
|
|
1898
2286
|
const existing = (this.sessions.findByTmuxName(persistName) ?? (await this.waitPendingTmux(persistName))) ?? null;
|
|
1899
|
-
if (existing !== null && existing.kind === 'ssh' && !existing.closed) {
|
|
2287
|
+
if (existing !== null && existing.kind === 'ssh' && !existing.closed && existing.exited === null) {
|
|
1900
2288
|
if (!conn.open)
|
|
1901
2289
|
return; // 等待在途创建期间连接断了:不往死连接上绑
|
|
1902
2290
|
this.rebindClient(existing, sid, ws, local, conn.id);
|
|
@@ -1944,6 +2332,7 @@ export class TtyServer {
|
|
|
1944
2332
|
handle,
|
|
1945
2333
|
clients: new Map([[conn.id + ':' + sid, { ws, sid }]]),
|
|
1946
2334
|
closed: false,
|
|
2335
|
+
exited: null,
|
|
1947
2336
|
paused: false,
|
|
1948
2337
|
owner: 'user',
|
|
1949
2338
|
cwd: '',
|
|
@@ -1952,6 +2341,10 @@ export class TtyServer {
|
|
|
1952
2341
|
startedAt: Date.now(),
|
|
1953
2342
|
lastOutputAt: Date.now(),
|
|
1954
2343
|
lastInputAt: Date.now(),
|
|
2344
|
+
outputSeq: 0,
|
|
2345
|
+
readSeq: -1,
|
|
2346
|
+
readMarkAt: 0,
|
|
2347
|
+
recentInputs: [],
|
|
1955
2348
|
buffer: '',
|
|
1956
2349
|
decoder: new StringDecoder('utf8'),
|
|
1957
2350
|
screen: this.createScreen(clampInt(msg.cols, 80, 2, 500), clampInt(msg.rows, 24, 2, 200)),
|
|
@@ -1980,7 +2373,7 @@ export class TtyServer {
|
|
|
1980
2373
|
this.trackPersist(tmuxName, true); // 留存:远程 tmux 本机清单看不到
|
|
1981
2374
|
if (handle.startupNotice !== undefined) {
|
|
1982
2375
|
const notice = `\x1b[2m[dsh-tty] ${handle.startupNotice}\x1b[0m\r\n`;
|
|
1983
|
-
|
|
2376
|
+
appendOutput(next, notice);
|
|
1984
2377
|
send(ws, { t: 'data', sid, d: notice });
|
|
1985
2378
|
}
|
|
1986
2379
|
this.attachOutput(next);
|
|
@@ -2014,7 +2407,8 @@ export class TtyServer {
|
|
|
2014
2407
|
if (resolved === undefined || 'unknown' in resolved)
|
|
2015
2408
|
return;
|
|
2016
2409
|
const session = local.get(resolved.sid);
|
|
2017
|
-
|
|
2410
|
+
// D77:保留态是**只读**的——面板那边不该再发输入,真发了也不能写进死 PTY
|
|
2411
|
+
if (session !== undefined && !session.closed && session.exited === null) {
|
|
2018
2412
|
session.lastInputAt = Date.now();
|
|
2019
2413
|
await session.handle.write(data);
|
|
2020
2414
|
}
|
|
@@ -2024,7 +2418,7 @@ export class TtyServer {
|
|
|
2024
2418
|
if (resolved === undefined || 'unknown' in resolved)
|
|
2025
2419
|
return;
|
|
2026
2420
|
const session = local.get(resolved.sid);
|
|
2027
|
-
if (session !== undefined) {
|
|
2421
|
+
if (session !== undefined && session.exited === null) {
|
|
2028
2422
|
const cols = clampInt(msg.cols, 80, 2, 500);
|
|
2029
2423
|
const rows = clampInt(msg.rows, 24, 2, 200);
|
|
2030
2424
|
session.handle.resize(cols, rows);
|
|
@@ -2044,7 +2438,7 @@ export class TtyServer {
|
|
|
2044
2438
|
if (resolved === undefined || 'unknown' in resolved)
|
|
2045
2439
|
return;
|
|
2046
2440
|
const session = local.get(resolved.sid);
|
|
2047
|
-
if (session !== undefined && !session.closed && session.tmuxName !== null) {
|
|
2441
|
+
if (session !== undefined && !session.closed && session.exited === null && session.tmuxName !== null) {
|
|
2048
2442
|
void session.handle.tmuxRefresh?.();
|
|
2049
2443
|
}
|
|
2050
2444
|
}
|
|
@@ -2093,6 +2487,12 @@ export class TtyServer {
|
|
|
2093
2487
|
send(ws, { t: 'error', sid: raw, m: `会话不存在或已结束: ${raw}` });
|
|
2094
2488
|
return;
|
|
2095
2489
|
}
|
|
2490
|
+
if (session.exited !== null) {
|
|
2491
|
+
// D77:保留态没有活着的 PTY 可接回(缓冲里的输出由 tty_capture 读),
|
|
2492
|
+
// 如实说明而不是让客户端拿到一个永远不出输出的「假在线」标签
|
|
2493
|
+
send(ws, { t: 'error', sid: raw, m: `会话已退出(${describeExit(session.exited)}),只读保留中:不能再接回终端;要接着操作请重新打开标签或 tty_open 新会话` });
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2096
2496
|
// 跨连接共享(0.10.1):tmux 持久会话允许多窗口同时绑定(单 PTY 扇出);
|
|
2097
2497
|
// 非 tmux 会话仍独占(两个视图交错输入无意义)
|
|
2098
2498
|
if (session.clients.size > 0 && session.tmuxName === null) {
|
|
@@ -2143,7 +2543,7 @@ export class TtyServer {
|
|
|
2143
2543
|
if (resolved === undefined || 'unknown' in resolved)
|
|
2144
2544
|
return;
|
|
2145
2545
|
const session = local.get(resolved.sid);
|
|
2146
|
-
if (session === undefined || session.closed)
|
|
2546
|
+
if (session === undefined || session.closed || session.exited !== null)
|
|
2147
2547
|
return;
|
|
2148
2548
|
this.setStatsSub(session, connId + ':' + resolved.sid, msg.t === 'statsOn');
|
|
2149
2549
|
}
|
|
@@ -2155,35 +2555,47 @@ export class TtyServer {
|
|
|
2155
2555
|
}).catch(() => { });
|
|
2156
2556
|
}
|
|
2157
2557
|
/**
|
|
2158
|
-
*
|
|
2558
|
+
* 会话终局的**唯一出口**:给所有绑定连接发 exit 帧(恰好一次)+ 转只读保留(D77)。
|
|
2159
2559
|
*
|
|
2160
2560
|
* `outcome` 正常来自 PTY 句柄的 done;显式 kill 的兜底(KILL_EXIT_FALLBACK_MS)
|
|
2161
2561
|
* 也走这里,带 code=null / signal=SIGKILL。exit 广播到所有绑定连接(跨窗口共享),
|
|
2162
2562
|
* 各客户端按自己的 sid 收址。
|
|
2563
|
+
*
|
|
2564
|
+
* **D77 起「进程退出」不再等于「退役」**:会话留在 `sessions` 表里转只读保留态,
|
|
2565
|
+
* 读侧工具(tty_list / tty_capture / tty_screen / tty_expect)照常可用,写侧拒写,
|
|
2566
|
+
* 直到显式关闭(tty_close / 面板关标签)或保留期到点(reapExited)。退役只剩
|
|
2567
|
+
* `SessionManager.retire` 那一处(出表 + 释放屏)。
|
|
2163
2568
|
*/
|
|
2164
2569
|
finishSession(session, outcome) {
|
|
2165
2570
|
if (session.exitSent === true)
|
|
2166
2571
|
return;
|
|
2167
2572
|
session.exitSent = true;
|
|
2168
|
-
session.closed = true;
|
|
2169
2573
|
session.statsSubs.clear();
|
|
2170
2574
|
this.stopStats(session);
|
|
2171
|
-
this.sessionLocals.get(session)?.delete(session.id);
|
|
2172
|
-
this.sessions.remove(session.id);
|
|
2173
2575
|
if (session.kind === 'ssh' && session.tmuxName !== null)
|
|
2174
2576
|
this.trackPersist(session.tmuxName, false);
|
|
2577
|
+
// 心跳停掉,但**屏不 dispose**(D77):保留期内 tty_screen 还要读它。
|
|
2175
2578
|
clearScreenWatchdog(session.screenHeartbeat);
|
|
2176
|
-
|
|
2177
|
-
session.screen?.dispose();
|
|
2178
|
-
}
|
|
2179
|
-
catch {
|
|
2180
|
-
/* 已释放 */
|
|
2181
|
-
}
|
|
2182
|
-
this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
|
|
2579
|
+
this.flushPendingOutput(session, true); // exit 前冲掉合并窗口里的尾巴,保序(D76:必须 force)
|
|
2183
2580
|
for (const client of session.clients.values()) {
|
|
2184
2581
|
send(client.ws, { t: 'exit', sid: client.sid, code: outcome.exitCode, signal: outcome.signal });
|
|
2185
2582
|
}
|
|
2186
2583
|
session.clients.clear();
|
|
2584
|
+
// ── 只读保留 ────────────────────────────────────────────────────────
|
|
2585
|
+
// 刻意**不**置 `closed`:那个位表示「真退役(出表 + 释放屏)」,读侧工具的
|
|
2586
|
+
// `session.closed` 守卫要放保留态过去。终局之后的字节因 clients 已清空而不再
|
|
2587
|
+
// 成帧(`onData` 照常入环形缓冲与屏:读到的是更完整的尾巴,不是更少的)。
|
|
2588
|
+
session.exited = { code: outcome.exitCode, signal: outcome.signal, at: Date.now() };
|
|
2589
|
+
for (const victim of this.sessions.capExited(MAX_EXITED_SESSIONS)) {
|
|
2590
|
+
// 被条数上限淘汰的:连同它在连接侧 local 表里的绑定一起摘掉(否则那条连接
|
|
2591
|
+
// 还拿得到 sid、却指着一个已退役的会话)
|
|
2592
|
+
this.sessionLocals.get(victim)?.delete(victim.id);
|
|
2593
|
+
// D77 补:淘汰必须留痕——否则用户侧只有「刚才那份结果怎么没了」这一种观测,
|
|
2594
|
+
// 排查时既不知道发生过淘汰、也不知道被挤掉的是哪个 sid。
|
|
2595
|
+
this.ctx.logger.warn(`[dsh-tty] 只读保留已达上限(${String(MAX_EXITED_SESSIONS)} 条,MAX_EXITED_SESSIONS):最旧的会话 ${victim.id} 被淘汰,它的输出不再可读;想留住结果请在淘汰前 tty_capture / tty_screen 读走`);
|
|
2596
|
+
}
|
|
2597
|
+
// 面板即时看到保留态(第二个窗口据此不建幽灵标签;agent 标签由 exit 帧标记)
|
|
2598
|
+
this.broadcastSessions();
|
|
2187
2599
|
}
|
|
2188
2600
|
/** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
|
|
2189
2601
|
attachOutput(session) {
|
|
@@ -2219,7 +2631,7 @@ export class TtyServer {
|
|
|
2219
2631
|
// StringDecoder 兜跨 chunk 多字节序列,再喂 shell 集成解析与虚拟屏
|
|
2220
2632
|
const text = session.decoder.write(chunk);
|
|
2221
2633
|
session.lastOutputAt = Date.now();
|
|
2222
|
-
|
|
2634
|
+
appendOutput(session, text);
|
|
2223
2635
|
feedShellIntegration(session, text);
|
|
2224
2636
|
const screen = session.screen;
|
|
2225
2637
|
if (screen !== null) {
|
|
@@ -2247,15 +2659,22 @@ export class TtyServer {
|
|
|
2247
2659
|
};
|
|
2248
2660
|
output.on('data', onData);
|
|
2249
2661
|
}
|
|
2250
|
-
/** 立即冲刷待发的合并输出(exit/kill 前调用,保证 exit 帧永远在最后一帧 data 之后)。
|
|
2251
|
-
|
|
2662
|
+
/** 立即冲刷待发的合并输出(exit/kill 前调用,保证 exit 帧永远在最后一帧 data 之后)。
|
|
2663
|
+
*
|
|
2664
|
+
* D76:`force` 是**终局路径专用**的开关。`finishSession` 必须先置 `closed`(否则终局
|
|
2665
|
+
* 之后到达的字节会继续往合并窗口里塞),可它同时又要交出**已经攒在 `pendingOutput`
|
|
2666
|
+
* 里的**那批输出——两者共用同一个 `closed` 判据时,尾巴会被下面这行自己的守卫整批
|
|
2667
|
+
* 吞掉:进程「打印完就退出」时那正是崩溃堆栈的最后一行 / 命令的结论行。传 `force`
|
|
2668
|
+
* 即「我知道它已 closed,这一批仍然要发」。
|
|
2669
|
+
*/
|
|
2670
|
+
flushPendingOutput(session, force = false) {
|
|
2252
2671
|
if (session.flushTimer !== null) {
|
|
2253
2672
|
clearTimeout(session.flushTimer);
|
|
2254
2673
|
session.flushTimer = null;
|
|
2255
2674
|
}
|
|
2256
2675
|
const pending = session.pendingOutput;
|
|
2257
2676
|
session.pendingOutput = '';
|
|
2258
|
-
if (session.closed || session.clients.size === 0 || pending === '')
|
|
2677
|
+
if ((session.closed && !force) || session.clients.size === 0 || pending === '')
|
|
2259
2678
|
return;
|
|
2260
2679
|
for (const client of session.clients.values()) {
|
|
2261
2680
|
send(client.ws, { t: 'data', sid: client.sid, d: pending });
|
|
@@ -2335,8 +2754,7 @@ function readManagedEnvKeys() {
|
|
|
2335
2754
|
* 值在任何分支都不进响应——SSH 对话框的选择器只需要「我存过哪些名字」。
|
|
2336
2755
|
*/
|
|
2337
2756
|
export async function handleCredentialRefsRoute(req, res) {
|
|
2338
|
-
if (!
|
|
2339
|
-
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
2757
|
+
if (!(await gateRoute(req, res))) {
|
|
2340
2758
|
return;
|
|
2341
2759
|
}
|
|
2342
2760
|
if (req.method !== 'GET') {
|
|
@@ -2448,38 +2866,84 @@ function mergeSshSpec(findSshHost, name, inline) {
|
|
|
2448
2866
|
password: typeof inline.password === 'string' && inline.password !== '' ? inline.password : profile?.password,
|
|
2449
2867
|
agentForward: typeof inline.agentForward === 'boolean' ? inline.agentForward : profile?.agentForward ?? false,
|
|
2450
2868
|
};
|
|
2869
|
+
/*
|
|
2870
|
+
* 跳板机:内联给了就以它为准(清洗不出可用对象则退回连接簿那一份),否则用连接簿的。
|
|
2871
|
+
* `jump` 只在**目标那一跳**之外多一跳,不支持嵌套(单跳),所以这里不做递归解析。
|
|
2872
|
+
*/
|
|
2873
|
+
const inlineJump = inline.jump === undefined ? undefined : sanitizeJumpSpec(inline.jump);
|
|
2874
|
+
const jump = inlineJump ?? profile?.jump;
|
|
2875
|
+
if (jump !== undefined)
|
|
2876
|
+
spec.jump = jump;
|
|
2877
|
+
/*
|
|
2878
|
+
* 代理命令:与跳板机同款「内联优先、否则用连接簿那一份」。两者同时存在**不在这里二选一**:
|
|
2879
|
+
* 优先关系(ProxyJump 优先)在拨号处 `attachSshTransport` 一处决定,并记 warn——否则
|
|
2880
|
+
* 「配了代理命令却走了跳板机」会被这里静默掉。
|
|
2881
|
+
*/
|
|
2882
|
+
const inlineProxyCommand = inline.proxyCommand === undefined ? undefined : sanitizeProxyCommand(inline.proxyCommand);
|
|
2883
|
+
const proxyCommand = inlineProxyCommand ?? profile?.proxyCommand;
|
|
2884
|
+
if (proxyCommand !== undefined)
|
|
2885
|
+
spec.proxyCommand = proxyCommand;
|
|
2451
2886
|
if (spec.host === '' || spec.username === '')
|
|
2452
2887
|
return { error: 'SSH 会话需要 host 与 username(或用 name 引用连接簿)' };
|
|
2453
2888
|
return { spec };
|
|
2454
2889
|
}
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2890
|
+
/*
|
|
2891
|
+
* 回环围栏与同源证明:**实现已收敛到 `@hyzyn/dsh-kit`**(2026-09-25,项目级 ROADMAP 第 1
|
|
2892
|
+
* 项)。本包此前那份只认 `127.0.0.1` 一个字面量的同步围栏已删除,改走 kit 的加固档
|
|
2893
|
+
* (docker D31/D80/D110),并给变更端点补上同源证明(docker D32/D139)——成因与桌面版
|
|
2894
|
+
* 例外(桌面壳转发会删掉 Origin / Sec-Fetch-Site,但必带宿主 Cookie)的唯一归宿在
|
|
2895
|
+
* `packages/kit/src/http.ts`,这里只留指针。
|
|
2896
|
+
*/
|
|
2897
|
+
/**
|
|
2898
|
+
* 数据路由的统一闸门(**导出仅供单测**):回环围栏(加固档)+ 变更端点的同源证明。
|
|
2899
|
+
* 十二处路由此前各抄一遍 403 样板,`mutation` 这条判据一加就会各写各的——收敛成一处后
|
|
2900
|
+
* 「拒绝分支」只有一份,负例也只测这一份。
|
|
2901
|
+
*
|
|
2902
|
+
* 哪些是变更端点(`mutation: true`)——**判据是「这次请求会不会改状态」**:
|
|
2903
|
+
* - `POST /probe`:真的拨号,且连接簿条目测试会当场 TOFU 记录主机指纹;
|
|
2904
|
+
* - `POST /sftp/{mkdir,rename,remove,upload}`、`POST /local-fs/{mkdir,rename,remove,transfer}`:
|
|
2905
|
+
* 写远端 / 写本机 / 起传输任务;
|
|
2906
|
+
* - `POST /config`**刻意不在其列**(与 docker 同口径):它是插件被禁用后唯一的恢复入口
|
|
2907
|
+
* ——卡片靠它渲染、也是重新启用的唯一 UI 入口;跨站 POST 已由上面那条围栏的
|
|
2908
|
+
* `sec-fetch-site: cross-site` 与 Origin 比对拦住。
|
|
2909
|
+
* 只读端点(GET 全家 + `sftp /list` `/download`、`local-fs /list`)维持 loopback-only:
|
|
2910
|
+
* 它们读的是用户自己主动要的东西,读路由加证明只会把旧 Safari / 裸 curl 一起挡在门外。
|
|
2911
|
+
*
|
|
2912
|
+
* **WS upgrade 不走这里**(那是 socket 握手,不是 req/res 路由):见 `handleUpgrade`
|
|
2913
|
+
* 用的 `isLoopbackRequestStrict`——它自带「Origin 有则必须同源」这条判据,等价于给
|
|
2914
|
+
* 升级请求也上了证明,但**刻意不放开 Cookie 例外**:长连的生命周期比一次 POST 长得多。
|
|
2915
|
+
*/
|
|
2916
|
+
export async function gateRoute(req, res, options) {
|
|
2917
|
+
if (!(await isLoopbackRequestStrict(req))) {
|
|
2918
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
2473
2919
|
return false;
|
|
2474
|
-
const origin = req.headers.origin;
|
|
2475
|
-
if (origin === undefined)
|
|
2476
|
-
return true;
|
|
2477
|
-
try {
|
|
2478
|
-
return new URL(origin).host === hostUrl.host;
|
|
2479
2920
|
}
|
|
2480
|
-
|
|
2921
|
+
if (options?.mutation === true && !hasSameOriginProof(req)) {
|
|
2922
|
+
writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):变更端点拒绝无来源请求' });
|
|
2481
2923
|
return false;
|
|
2482
2924
|
}
|
|
2925
|
+
return true;
|
|
2926
|
+
}
|
|
2927
|
+
/** 变更动作的子路由(见 gateRoute 的判据):前缀路由先取 sub、再连 mutation 一起过闸。 */
|
|
2928
|
+
const MUTATION_SUBROUTES = {
|
|
2929
|
+
'/api/dsh-tty/sftp': new Set(['/mkdir', '/rename', '/remove', '/upload']),
|
|
2930
|
+
'/api/dsh-tty/local-fs': new Set(['/mkdir', '/rename', '/remove', '/transfer']),
|
|
2931
|
+
/*
|
|
2932
|
+
* 就地提权:三条子路由**逐条**列(docker D144 的教训)——判据是「精确子路径 + POST」,
|
|
2933
|
+
* 只写一条的话另外两条就是裸的(跨站页面能撤销授权、能反复对着确认挑战试错)。
|
|
2934
|
+
*/
|
|
2935
|
+
'/api/dsh-tty/elevate': new Set(['', '/status', '/revoke']),
|
|
2936
|
+
};
|
|
2937
|
+
/**
|
|
2938
|
+
* 这个前缀路由的子路径是否**会改状态**(导出仅供单测)。
|
|
2939
|
+
*
|
|
2940
|
+
* 为什么单独抽出来:`/list` 与 `/download` 也是 POST(凭证走 body、不进 URL),但它们只是
|
|
2941
|
+
* 读——如果把「POST 就要求证明」一刀切下去,读路由会连带把旧 Safari / 裸 curl 挡在门外,
|
|
2942
|
+
* 而它们本来就没有可被跨站利用的副作用。判据是**动作**不是**方法**,所以名单必须显式。
|
|
2943
|
+
* 没见过的子路径一律 false(几步之后就是 404,不给它额外的信息量)。
|
|
2944
|
+
*/
|
|
2945
|
+
export function isMutationSubroute(prefix, sub) {
|
|
2946
|
+
return MUTATION_SUBROUTES[prefix]?.has(sub) === true;
|
|
2483
2947
|
}
|
|
2484
2948
|
function writeJson(res, status, body) {
|
|
2485
2949
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
|
|
@@ -2586,6 +3050,24 @@ const plugin = definePlugin({
|
|
|
2586
3050
|
// 均拿不到,实测 mcp-client 同款模式),声明后 ctx.get('tools') 才能取到。
|
|
2587
3051
|
inject: ['tools'],
|
|
2588
3052
|
apply(ctx, rawConfig) {
|
|
3053
|
+
/*
|
|
3054
|
+
* 授权来源必须在**第一次 capabilityGranted 之前**绑定(见 kit 的 capability.ts)。两条通道:
|
|
3055
|
+
* ① 启动环境快照(最严档,只认继承来的 `process` 层);
|
|
3056
|
+
* ② 就地提权(免重启):卡片上点开关 → 宿主文件系统上落地一个随机名确认文件 → 授权写进
|
|
3057
|
+
* grant-store(HTTP 写不到)。落点全部来自 kit 的 `capabilityPaths`(路径只在那里拼一次)。
|
|
3058
|
+
* 存储每次 apply 新建一个实例 —— 这就是「宿主重启后重新读盘」的语义。
|
|
3059
|
+
*/
|
|
3060
|
+
const paths = capabilityPaths(resolveDshHome());
|
|
3061
|
+
// **共享实例**(kit D11):tty 与 docker 同装时各 new 一个会各自缓存一份文件快照,
|
|
3062
|
+
// 后绑定的那个看不到另一个后来写进去的授权(实测:tty 授权成功后自己的快照仍是 false)
|
|
3063
|
+
const grantStore = sharedGrantStore(paths.dir);
|
|
3064
|
+
bindCapabilitySources(ctx, grantStore);
|
|
3065
|
+
/*
|
|
3066
|
+
* 启动期审计:盘上已有的带外授权是**持久**的(重启后直接生效、不再有任何一次确认),所以那次
|
|
3067
|
+
* 「静默继承」必须在日志里留下痕迹(kit D09)。刻意放在 `enabled` 判定**之前**:授权是宿主级的,
|
|
3068
|
+
* 与插件这次是否启用无关。
|
|
3069
|
+
*/
|
|
3070
|
+
auditLoadedGrants(grantStore, [CAP_PROXY_COMMAND.env], { info: (msg) => ctx.logger.info(msg), warn: (msg) => ctx.logger.warn(msg) }, '[dsh-tty]');
|
|
2589
3071
|
// volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
|
|
2590
3072
|
const config = plainConfig((rawConfig ?? {}));
|
|
2591
3073
|
if (config?.enabled === false)
|
|
@@ -2609,8 +3091,20 @@ const plugin = definePlugin({
|
|
|
2609
3091
|
endOnPageClose: config?.endOnPageClose === true,
|
|
2610
3092
|
statsEnabled: config?.statsEnabled !== false,
|
|
2611
3093
|
sftpLimits: sanitizeSftpLimits(config?.sftpLimits),
|
|
3094
|
+
allowProxyCommand: config?.allowProxyCommand === true,
|
|
2612
3095
|
persistSessions: sanitizePersistSessions(config?.persistSessions) ?? [],
|
|
2613
3096
|
});
|
|
3097
|
+
/*
|
|
3098
|
+
* 闸门在**挂载时先初始化一次**(tty D70)。
|
|
3099
|
+
*
|
|
3100
|
+
* 它的唯一写入方是 `applyPatch`,而 `applyPatch` 在启动期**只在「settings 里存过东西」时才跑**
|
|
3101
|
+
* (见那里 `if (Object.keys(startup).length > 0)`)。于是「配置里写着 `allowProxyCommand: true`
|
|
3102
|
+
* + 已授权(环境变量或授权文件)+ 重启宿主」这条最平常的路径会停在模块级默认值
|
|
3103
|
+
* `{granted:false, enabled:false}` 上:fail-closed(不是安全问题),但症状正是本仓最忌讳的
|
|
3104
|
+
* 「配了没反应」——卡片显示已授权,代理命令却仍被拒。这条在接入就地提权后才致命:授权是
|
|
3105
|
+
* **持久**的,重启后更要保证「界面上说已授权」与「闸门真的放行」一致。
|
|
3106
|
+
*/
|
|
3107
|
+
setProxyCommandPolicy({ granted: capabilityGranted(CAP_PROXY_COMMAND), enabled: live.allowProxyCommand });
|
|
2614
3108
|
const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS, () => live.endOnPageClose);
|
|
2615
3109
|
/** TOFU 指纹记录持久化:写入 settings 命名空间(合并语义),失败不影响连接。 */
|
|
2616
3110
|
const persistHostKeys = (records) => {
|
|
@@ -2672,6 +3166,18 @@ const plugin = definePlugin({
|
|
|
2672
3166
|
endOnPageClose: live.endOnPageClose,
|
|
2673
3167
|
statsEnabled: live.statsEnabled,
|
|
2674
3168
|
sftpLimits: live.sftpLimits,
|
|
3169
|
+
allowProxyCommand: live.allowProxyCommand,
|
|
3170
|
+
allowProxyCommandGranted: capabilityGranted(CAP_PROXY_COMMAND),
|
|
3171
|
+
/*
|
|
3172
|
+
* 授权来源:'env' = 启动环境变量(界面不给「撤销」按钮,它只能靠改启动环境撤销)、
|
|
3173
|
+
* 'file' = 就地确认写下的带外授权(界面可撤销)、null = 没授权。
|
|
3174
|
+
*/
|
|
3175
|
+
allowProxyCommandGrantSource: capabilityGrantVia(CAP_PROXY_COMMAND) ?? null,
|
|
3176
|
+
/*
|
|
3177
|
+
* 授权时刻(Unix 秒;只有 file 通道有值)。授权是**持久**的:重启后它直接生效、不再确认,
|
|
3178
|
+
* 界面至少要能说出它是什么时候来的(kit D09)。
|
|
3179
|
+
*/
|
|
3180
|
+
allowProxyCommandGrantedAt: capabilityGrantAt(CAP_PROXY_COMMAND) ?? null,
|
|
2675
3181
|
toolsRegistered: stateRef.toolsRegistered,
|
|
2676
3182
|
/**
|
|
2677
3183
|
* 宿主平台(`process.platform`):客户端据此把「Shell 路径 / shell 集成」的说明与候选
|
|
@@ -2695,8 +3201,16 @@ const plugin = definePlugin({
|
|
|
2695
3201
|
endOnPageClose: typeof section.endOnPageClose === 'boolean' ? section.endOnPageClose : undefined,
|
|
2696
3202
|
statsEnabled: typeof section.statsEnabled === 'boolean' ? section.statsEnabled : undefined,
|
|
2697
3203
|
sftpLimits: typeof section.sftpLimits === 'object' && section.sftpLimits !== null ? section.sftpLimits : undefined,
|
|
3204
|
+
allowProxyCommand: typeof section.allowProxyCommand === 'boolean' ? section.allowProxyCommand : undefined,
|
|
2698
3205
|
persistSessions: sanitizePersistSessions(section.persistSessions),
|
|
2699
3206
|
});
|
|
3207
|
+
/*
|
|
3208
|
+
* ProxyCommand 闸门跟着 settings 走(**每次热应用都写一次**):这一档是「设置字段驱动的
|
|
3209
|
+
* 本机任意命令执行」,关掉之后必须**立刻**对四条建连路径全部生效(终端 / SFTP / 隧道 /
|
|
3210
|
+
* 探针共用 ssh.ts 的模块级策略,见 setProxyCommandPolicy)。启动路径也走这里
|
|
3211
|
+
* (settings 就绪时 applyPatch 会被调用一次),所以不存在「忘了初始化 → 意外为开」。
|
|
3212
|
+
*/
|
|
3213
|
+
setProxyCommandPolicy({ granted: capabilityGranted(CAP_PROXY_COMMAND), enabled: live.allowProxyCommand });
|
|
2700
3214
|
if (typeof section.enabled === 'boolean')
|
|
2701
3215
|
stateRef.enabled = section.enabled;
|
|
2702
3216
|
if (typeof section.announceToAgent === 'boolean')
|
|
@@ -2717,12 +3231,12 @@ const plugin = definePlugin({
|
|
|
2717
3231
|
server.setWsGate(stateRef.enabled);
|
|
2718
3232
|
// 状态条开关热生效:关的时候停掉全部采集(本地定时器 + 远端 exec channel)
|
|
2719
3233
|
server.setStatsEnabled(live.statsEnabled);
|
|
2720
|
-
console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length}, enabled=${String(stateRef.enabled)})`);
|
|
3234
|
+
console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length}, allowProxyCommand=${String(live.allowProxyCommand)}, enabled=${String(stateRef.enabled)})`);
|
|
2721
3235
|
};
|
|
2722
3236
|
/** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
|
|
2723
3237
|
const normalizePatch = (input) => {
|
|
2724
3238
|
const patch = {};
|
|
2725
|
-
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'statsEnabled', 'sftpLimits']);
|
|
3239
|
+
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'statsEnabled', 'sftpLimits', 'allowProxyCommand']);
|
|
2726
3240
|
for (const key of Object.keys(input)) {
|
|
2727
3241
|
if (!known.has(key))
|
|
2728
3242
|
return { error: '未知配置项: ' + key };
|
|
@@ -2774,6 +3288,19 @@ const plugin = definePlugin({
|
|
|
2774
3288
|
return { error: 'statsEnabled 必须是布尔值' };
|
|
2775
3289
|
patch.statsEnabled = input.statsEnabled;
|
|
2776
3290
|
}
|
|
3291
|
+
if (input.allowProxyCommand !== undefined) {
|
|
3292
|
+
if (typeof input.allowProxyCommand !== 'boolean')
|
|
3293
|
+
return { error: 'allowProxyCommand 必须是布尔值' };
|
|
3294
|
+
/*
|
|
3295
|
+
* **只能降不能升**:提权只认宿主侧的环境变量(进程启动时采样一次),HTTP 侧给 true
|
|
3296
|
+
* 一律驳回并说清怎么做。这不是「输入不合法」,而是「没获授权」——所以文案必须同时
|
|
3297
|
+
* 给出变量名与「要重启宿主」,否则用户会对着一个点不动的开关反复点。
|
|
3298
|
+
*/
|
|
3299
|
+
if (input.allowProxyCommand && !capabilityGranted(CAP_PROXY_COMMAND)) {
|
|
3300
|
+
return { error: capabilityDeniedMessage(CAP_PROXY_COMMAND, { inPlace: true }) };
|
|
3301
|
+
}
|
|
3302
|
+
patch.allowProxyCommand = input.allowProxyCommand;
|
|
3303
|
+
}
|
|
2777
3304
|
for (const key of ['shell', 'term', 'colorTerm']) {
|
|
2778
3305
|
if (input[key] === undefined)
|
|
2779
3306
|
continue;
|
|
@@ -2847,6 +3374,30 @@ const plugin = definePlugin({
|
|
|
2847
3374
|
setCredentialResolver(credCtx.credentials ?? null);
|
|
2848
3375
|
return () => { setCredentialResolver(null); };
|
|
2849
3376
|
});
|
|
3377
|
+
/*
|
|
3378
|
+
* 就地提权(见 kit 的 elevation.ts):卡片上点开关 → 宿主在确认目录里等一个随机名文件出现
|
|
3379
|
+
* → 授权写进 grant-store。
|
|
3380
|
+
*
|
|
3381
|
+
* `onGrantChange` 是这一段里**最容易漏、也最要紧**的一下:`setProxyCommandPolicy` 是终端 /
|
|
3382
|
+
* SFTP / 隧道 / 探针**四条建连路径共用**的模块级闸门,只写存储不重算就会留下两种半个状态——
|
|
3383
|
+
* - 授权侧:授权到了,代理命令仍然被拒(用户以为没生效,再去点开关);
|
|
3384
|
+
* - 撤销侧:记录没了,本机仍在跑连接簿里那条命令(这一侧是安全问题,不是体验问题)。
|
|
3385
|
+
* `applyPatch({})` 用当前 live 兜底重算一次即可(它本来就每次都写一次策略,见那里的注释)。
|
|
3386
|
+
*/
|
|
3387
|
+
const elevation = createElevationManager({
|
|
3388
|
+
confirmDir: paths.confirmDir,
|
|
3389
|
+
store: grantStore,
|
|
3390
|
+
logger: { info: (msg) => ctx.logger.info(msg), warn: (msg) => ctx.logger.warn(msg) },
|
|
3391
|
+
logPrefix: '[dsh-tty]',
|
|
3392
|
+
onGrantChange: () => {
|
|
3393
|
+
applyPatch({});
|
|
3394
|
+
},
|
|
3395
|
+
});
|
|
3396
|
+
ctx.effect(() => {
|
|
3397
|
+
return () => {
|
|
3398
|
+
elevation.dispose();
|
|
3399
|
+
};
|
|
3400
|
+
}, 'dsh-tty: elevation cleanup');
|
|
2850
3401
|
// webServer:WS upgrade 路由 + 配置读写路由(/api/dsh-tty/config)
|
|
2851
3402
|
ctx.inject(['webServer'], (webCtx) => {
|
|
2852
3403
|
webCtx.effect(() => {
|
|
@@ -2873,8 +3424,7 @@ const plugin = definePlugin({
|
|
|
2873
3424
|
kind: 'exact',
|
|
2874
3425
|
path: '/api/dsh-tty/config',
|
|
2875
3426
|
handler: async (req, res) => {
|
|
2876
|
-
if (!
|
|
2877
|
-
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
3427
|
+
if (!(await gateRoute(req, res))) {
|
|
2878
3428
|
return;
|
|
2879
3429
|
}
|
|
2880
3430
|
if (req.method === 'GET') {
|
|
@@ -2913,13 +3463,74 @@ const plugin = definePlugin({
|
|
|
2913
3463
|
writeJson(res, 200, { ok: true, config: snapshot() });
|
|
2914
3464
|
},
|
|
2915
3465
|
}));
|
|
2916
|
-
|
|
3466
|
+
/*
|
|
3467
|
+
* 就地提权(`/elevate`、`/elevate/status`、`/elevate/revoke`)。
|
|
3468
|
+
*
|
|
3469
|
+
* 三条**全 POST**、**逐条**进 MUTATION_SUBROUTES(判据是「精确子路径 + POST」,只写一条
|
|
3470
|
+
* 另外两条就是裸的 → docker D144),且分发必须在证明检查**之后**——`sub` 一旦拿去分支,
|
|
3471
|
+
* 后面再补证明就晚了。
|
|
3472
|
+
*
|
|
3473
|
+
* 刻意**不走** registerGated:授权是宿主级的,插件当前禁用时也该能在卡片里提权/撤销
|
|
3474
|
+
* (卡片本身是禁用后唯一的恢复入口,与 /config 同一条理由)。
|
|
3475
|
+
*/
|
|
3476
|
+
disposers.push(webServer.register({
|
|
3477
|
+
kind: 'prefix',
|
|
3478
|
+
path: '/api/dsh-tty/elevate',
|
|
3479
|
+
handler: async (req, res) => {
|
|
3480
|
+
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/elevate'.length);
|
|
3481
|
+
if (!(await gateRoute(req, res, { mutation: isMutationSubroute('/api/dsh-tty/elevate', sub) }))) {
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
if (req.method !== 'POST') {
|
|
3485
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
if (sub !== '' && sub !== '/status' && sub !== '/revoke') {
|
|
3489
|
+
writeJson(res, 404, { error: 'not found' });
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
const body = await readJsonBody(req);
|
|
3493
|
+
if (body === undefined) {
|
|
3494
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
// 白名单:别让请求方决定查哪个键(那张表是宿主侧的策略来源)
|
|
3498
|
+
if (body.capability !== undefined && body.capability !== CAP_PROXY_COMMAND.env) {
|
|
3499
|
+
writeJson(res, 400, { error: '未知能力: ' + String(body.capability) });
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
if (sub === '/status') {
|
|
3503
|
+
writeJson(res, 200, elevation.status(CAP_PROXY_COMMAND.env));
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
if (sub === '/revoke') {
|
|
3507
|
+
// 降权零门槛:撤销不要求任何确认(紧急刹车不等重启)。撤销带来的策略重算由
|
|
3508
|
+
// elevation 的 onGrantChange 负责——不重算就会留下「记录没了、本机还在跑命令」
|
|
3509
|
+
writeJson(res, 200, { revoked: elevation.revoke(CAP_PROXY_COMMAND.env) });
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
const result = elevation.begin(CAP_PROXY_COMMAND.env);
|
|
3513
|
+
if (result.status === 'rate-limited') {
|
|
3514
|
+
writeJson(res, 429, { error: '请求过于频繁,请稍后再试', retryAfterMs: result.retryAfterMs });
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
if (result.status === 'error') {
|
|
3518
|
+
writeJson(res, 500, { error: result.error });
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
// 只回状态与一次性命令;nonce 就在命令里,**不进日志**(见 elevation.ts 的不变量)
|
|
3522
|
+
writeJson(res, 200, result);
|
|
3523
|
+
},
|
|
3524
|
+
}));
|
|
3525
|
+
// ~/.ssh/config 导入候选(连接簿):loopback 围栏,只回解析结果不落盘。
|
|
3526
|
+
// 除了候选,还**如实回报丢弃了什么**(依赖跳板机 / 通配 / 无 User / 超上限):
|
|
3527
|
+
// 静默少列是本仓反复出现的一类缺陷,而「导进来的条目注定连不上」更难排——见
|
|
3528
|
+
// 项目级 ROADMAP 第 2 项。
|
|
2917
3529
|
registerGated({
|
|
2918
3530
|
kind: 'exact',
|
|
2919
3531
|
path: '/api/dsh-tty/ssh-config',
|
|
2920
3532
|
handler: async (req, res) => {
|
|
2921
|
-
if (!
|
|
2922
|
-
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
3533
|
+
if (!(await gateRoute(req, res))) {
|
|
2923
3534
|
return;
|
|
2924
3535
|
}
|
|
2925
3536
|
if (req.method !== 'GET') {
|
|
@@ -2928,7 +3539,7 @@ const plugin = definePlugin({
|
|
|
2928
3539
|
}
|
|
2929
3540
|
try {
|
|
2930
3541
|
const text = readFileSync(expandHome('~/.ssh/config'), 'utf8');
|
|
2931
|
-
writeJson(res, 200, { ok: true,
|
|
3542
|
+
writeJson(res, 200, { ok: true, ...parseSshConfigDetailed(text) });
|
|
2932
3543
|
}
|
|
2933
3544
|
catch (error) {
|
|
2934
3545
|
writeJson(res, 200, { ok: false, error: '无法读取 ~/.ssh/config: ' + (error instanceof Error ? error.message : String(error)) });
|
|
@@ -2940,8 +3551,7 @@ const plugin = definePlugin({
|
|
|
2940
3551
|
kind: 'exact',
|
|
2941
3552
|
path: '/api/dsh-tty/env-vars',
|
|
2942
3553
|
handler: async (req, res) => {
|
|
2943
|
-
if (!
|
|
2944
|
-
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
3554
|
+
if (!(await gateRoute(req, res))) {
|
|
2945
3555
|
return;
|
|
2946
3556
|
}
|
|
2947
3557
|
if (req.method !== 'GET') {
|
|
@@ -2965,8 +3575,7 @@ const plugin = definePlugin({
|
|
|
2965
3575
|
kind: 'exact',
|
|
2966
3576
|
path: '/api/dsh-tty/known-hosts',
|
|
2967
3577
|
handler: async (req, res) => {
|
|
2968
|
-
if (!
|
|
2969
|
-
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
3578
|
+
if (!(await gateRoute(req, res))) {
|
|
2970
3579
|
return;
|
|
2971
3580
|
}
|
|
2972
3581
|
if (req.method !== 'GET') {
|
|
@@ -2994,8 +3603,8 @@ const plugin = definePlugin({
|
|
|
2994
3603
|
kind: 'exact',
|
|
2995
3604
|
path: '/api/dsh-tty/probe',
|
|
2996
3605
|
handler: async (req, res) => {
|
|
2997
|
-
|
|
2998
|
-
|
|
3606
|
+
// 变更端点:这次请求真的拨号,且连接簿条目测试会当场记录主机指纹(TOFU)
|
|
3607
|
+
if (!(await gateRoute(req, res, { mutation: true }))) {
|
|
2999
3608
|
return;
|
|
3000
3609
|
}
|
|
3001
3610
|
if (req.method !== 'POST') {
|
|
@@ -3024,6 +3633,22 @@ const plugin = definePlugin({
|
|
|
3024
3633
|
}
|
|
3025
3634
|
const auth = body.auth === 'key' || body.auth === 'password' ? body.auth : 'agent';
|
|
3026
3635
|
const spec = { host, port, username, auth };
|
|
3636
|
+
// 对话框「试连」可能带跳板机(连接簿条目由客户端先行展开;跳板机同理)
|
|
3637
|
+
const probeJump = validateJumpSpec(body.jump);
|
|
3638
|
+
if (body.jump !== undefined && probeJump.error !== undefined) {
|
|
3639
|
+
writeJson(res, 200, { ok: false, error: probeJump.error });
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
if (probeJump.jump !== undefined)
|
|
3643
|
+
spec.jump = probeJump.jump;
|
|
3644
|
+
// 代理命令同理(对话框试连会带上当前填写值):形状非法就明确报错,别静默当没填
|
|
3645
|
+
const probeProxy = validateProxyCommand(body.proxyCommand ?? '');
|
|
3646
|
+
if (probeProxy.error !== undefined) {
|
|
3647
|
+
writeJson(res, 200, { ok: false, error: probeProxy.error });
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
if (probeProxy.proxyCommand !== undefined)
|
|
3651
|
+
spec.proxyCommand = probeProxy.proxyCommand;
|
|
3027
3652
|
if (auth === 'key') {
|
|
3028
3653
|
const keyPath = typeof body.keyPath === 'string' ? body.keyPath.trim() : '';
|
|
3029
3654
|
if (keyPath === '') {
|
|
@@ -3056,8 +3681,7 @@ const plugin = definePlugin({
|
|
|
3056
3681
|
kind: 'exact',
|
|
3057
3682
|
path: '/api/dsh-tty/shells',
|
|
3058
3683
|
handler: async (req, res) => {
|
|
3059
|
-
if (!
|
|
3060
|
-
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
3684
|
+
if (!(await gateRoute(req, res))) {
|
|
3061
3685
|
return;
|
|
3062
3686
|
}
|
|
3063
3687
|
if (req.method !== 'GET') {
|
|
@@ -3072,8 +3696,7 @@ const plugin = definePlugin({
|
|
|
3072
3696
|
kind: 'exact',
|
|
3073
3697
|
path: '/api/dsh-tty/tunnels',
|
|
3074
3698
|
handler: async (req, res) => {
|
|
3075
|
-
if (!
|
|
3076
|
-
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
3699
|
+
if (!(await gateRoute(req, res))) {
|
|
3077
3700
|
return;
|
|
3078
3701
|
}
|
|
3079
3702
|
if (req.method !== 'GET') {
|
|
@@ -3093,11 +3716,11 @@ const plugin = definePlugin({
|
|
|
3093
3716
|
kind: 'prefix',
|
|
3094
3717
|
path: '/api/dsh-tty/sftp',
|
|
3095
3718
|
handler: async (req, res) => {
|
|
3096
|
-
|
|
3097
|
-
|
|
3719
|
+
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/sftp'.length);
|
|
3720
|
+
// 改状态的三个动作 + 上传要同源证明;/list 与 /download 只读(判据见 gateRoute)
|
|
3721
|
+
if (!(await gateRoute(req, res, { mutation: isMutationSubroute('/api/dsh-tty/sftp', sub) }))) {
|
|
3098
3722
|
return;
|
|
3099
3723
|
}
|
|
3100
|
-
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/sftp'.length);
|
|
3101
3724
|
const jsonAction = ['/list', '/mkdir', '/rename', '/remove', '/download'].find((action) => action === sub);
|
|
3102
3725
|
if (jsonAction !== undefined) {
|
|
3103
3726
|
if (req.method !== 'POST') {
|
|
@@ -3253,11 +3876,11 @@ const plugin = definePlugin({
|
|
|
3253
3876
|
kind: 'prefix',
|
|
3254
3877
|
path: '/api/dsh-tty/local-fs',
|
|
3255
3878
|
handler: async (req, res) => {
|
|
3256
|
-
|
|
3257
|
-
|
|
3879
|
+
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/local-fs'.length);
|
|
3880
|
+
// 改状态的四个动作(含起一个传输任务)要同源证明;/list 只读(判据见 gateRoute)
|
|
3881
|
+
if (!(await gateRoute(req, res, { mutation: isMutationSubroute('/api/dsh-tty/local-fs', sub) }))) {
|
|
3258
3882
|
return;
|
|
3259
3883
|
}
|
|
3260
|
-
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/local-fs'.length);
|
|
3261
3884
|
if (req.method !== 'POST') {
|
|
3262
3885
|
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
3263
3886
|
return;
|
|
@@ -3458,7 +4081,9 @@ const plugin = definePlugin({
|
|
|
3458
4081
|
}
|
|
3459
4082
|
activeDisposers.push(tools.register(defineTool({
|
|
3460
4083
|
name: 'tty_list',
|
|
3461
|
-
|
|
4084
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4085
|
+
isConcurrencySafe: () => true,
|
|
4086
|
+
description: '列出当前终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间),**含进程已退出但仍只读保留着的会话**(带 exited:true + 退出码/信号;按时间释放时另带 retainMs):用户开了终端面板后,用 tty_capture 读取某个 sid 的输出、用 tty_send 向该会话发送按键。已退出的会话只能读(写会报错),要接着操作请 tty_open 新开一条。',
|
|
3462
4087
|
parameters: {},
|
|
3463
4088
|
output: {
|
|
3464
4089
|
schema: {
|
|
@@ -3481,6 +4106,10 @@ const plugin = definePlugin({
|
|
|
3481
4106
|
lastOutputAt: { type: 'number', required: true },
|
|
3482
4107
|
persist: { type: 'boolean' },
|
|
3483
4108
|
owner: { type: 'string', required: true },
|
|
4109
|
+
exited: { type: 'boolean' },
|
|
4110
|
+
exitCode: { type: 'number' },
|
|
4111
|
+
signal: { type: 'string' },
|
|
4112
|
+
retainMs: { type: 'number', description: '只读保留的剩余毫秒;省略 = 不按时间释放' },
|
|
3484
4113
|
},
|
|
3485
4114
|
},
|
|
3486
4115
|
},
|
|
@@ -3489,26 +4118,33 @@ const plugin = definePlugin({
|
|
|
3489
4118
|
render: (_args, value) => {
|
|
3490
4119
|
const sessions = value?.sessions ?? [];
|
|
3491
4120
|
const text = sessions.length === 0
|
|
3492
|
-
? '
|
|
4121
|
+
? '当前没有终端面板会话(可用 tty_open 自己开一个,或引导用户打开终端面板)'
|
|
3493
4122
|
: '终端面板会话:' + sessions.map((s) => {
|
|
3494
4123
|
const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
|
|
3495
4124
|
const persist = s.persist === true ? ' [tmux 持久]' : '';
|
|
3496
4125
|
const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
|
|
3497
|
-
|
|
4126
|
+
// D77:只读保留态必须显眼——否则 AI 会对着一个已经死掉的会话发命令
|
|
4127
|
+
const detail = s.signal !== undefined && s.signal !== '' ? `signal=${s.signal}` : s.exitCode === undefined ? '退出码未知' : `exitCode=${String(s.exitCode)}`;
|
|
4128
|
+
const left = s.retainMs === undefined ? '(显式关闭前一直都在)' : ` ${String(Math.ceil(s.retainMs / 60000))} 分钟`;
|
|
4129
|
+
const gone = s.exited === true ? ` [已退出 ${detail}·只读保留${left}——只能读,写会报错]` : '';
|
|
4130
|
+
return `\n- sid=${s.sid} [${s.kind}]${owner}${persist}${gone} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
|
|
3498
4131
|
}).join('');
|
|
3499
4132
|
return [{ type: 'text', text }];
|
|
3500
4133
|
},
|
|
3501
4134
|
},
|
|
3502
4135
|
async execute() {
|
|
4136
|
+
// D72:agent 第一次「看见」这些会话时把水位线落在当下——否则第一次
|
|
4137
|
+
// tty_expect 会把用户早先的历史输出当成「还没读过的输出」回扫过来
|
|
4138
|
+
sessions.forEach((session) => { ensureReadMark(session); });
|
|
3503
4139
|
return { sessions: sessions.list() };
|
|
3504
4140
|
},
|
|
3505
4141
|
})));
|
|
3506
4142
|
activeDisposers.push(tools.register(defineTool({
|
|
3507
4143
|
name: 'tty_open',
|
|
3508
|
-
description: '开一个新的终端会话(本地 shell,或 `command`
|
|
4144
|
+
description: '开一个新的终端会话(本地 shell,或 `command` 直接跑一条命令,如 dev server)。会话出现在用户的终端面板里、用户可见可接管,长驻进程与 watch 类任务应该用它(不要在 bash 工具里挂起等待)。开了之后用 tty_expect 等就绪信号、tty_capture{last:true} 拿结果;用完用 tty_close 关闭。`command` 按 **shell 语法整体**执行(`cd dir && cmd`、`a; b`、`for …; do …; done` 都可以,D78:旧版本里 `exec` 包装只跑第一条);**它跑完退出后会话不会立刻消失**:会转成只读保留(留到显式关闭,最多留 8 条),退出前最后的输出与退出码都还能用 tty_capture / tty_screen 读——所以「跑一条会结束的命令、回头再取结果」不需要套一层 `sh`。cwd 缺省为插件配置的工作目录。',
|
|
3509
4145
|
parameters: {
|
|
3510
4146
|
cwd: { type: 'string', description: '工作目录(必须是已存在的绝对路径);缺省用插件配置的 cwd' },
|
|
3511
|
-
command: { type: 'string', description: '
|
|
4147
|
+
command: { type: 'string', description: '直接执行的命令(非交互):按**整段 shell 代码**执行,`cd x && cmd`、`a; b`、管道、多行脚本都可以(D78);给出时不做 tmux 持久化。省略或全空白 = 交互式 shell' },
|
|
3512
4148
|
persistName: { type: 'string', description: 'tmux 持久会话名(开启「会话持久化」时有效;同名复用既有会话)。适合宿主重启后仍需存活的长任务' },
|
|
3513
4149
|
cols: { type: 'number', description: '列数(2~500,默认 80)' },
|
|
3514
4150
|
rows: { type: 'number', description: '行数(2~200,默认 24)' },
|
|
@@ -3540,7 +4176,7 @@ const plugin = definePlugin({
|
|
|
3540
4176
|
})));
|
|
3541
4177
|
activeDisposers.push(tools.register(defineTool({
|
|
3542
4178
|
name: 'tty_close',
|
|
3543
|
-
description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent
|
|
4179
|
+
description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。对**进程已退出但仍只读保留着**的会话同样可用——那就是它的释放入口(把屏与缓冲还回去)。只读保留默认留到显式关闭(宿主重启也会清空),不按时间释放;同时最多留 8 条,超出按最旧淘汰。',
|
|
3544
4180
|
parameters: {
|
|
3545
4181
|
sid: { type: 'string', required: true, description: '会话 id(tty_open 或 tty_list 提供)' },
|
|
3546
4182
|
},
|
|
@@ -3564,6 +4200,8 @@ const plugin = definePlugin({
|
|
|
3564
4200
|
})));
|
|
3565
4201
|
activeDisposers.push(tools.register(defineTool({
|
|
3566
4202
|
name: 'tty_stats',
|
|
4203
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4204
|
+
isConcurrencySafe: () => true,
|
|
3567
4205
|
description: '读取某个终端会话所在机器的实时指标(CPU / 内存 / 磁盘 / TCP 连接数 / 网速 / 温度 / 在线时长)——本地会话取宿主机,SSH 会话取那台远程主机(另开一条非 PTY 通道,不影响终端)。部署、压测、排查「机器是不是满了」之前先看它。仅 Linux 远端字段齐全,Windows 远端部分字段可采,macOS/BSD 远端取不到。',
|
|
3568
4206
|
parameters: {
|
|
3569
4207
|
sid: { type: 'string', required: true, description: '会话 id(tty_list 提供)' },
|
|
@@ -3616,6 +4254,11 @@ const plugin = definePlugin({
|
|
|
3616
4254
|
const session = sessions.get(input.sid);
|
|
3617
4255
|
if (session === undefined || session.closed)
|
|
3618
4256
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
4257
|
+
if (session.exited !== null) {
|
|
4258
|
+
// D77:进程没了就没有「这台会话所在机器」的此刻指标可言(本地会话会取到
|
|
4259
|
+
// 宿主自己、远端连接早断了)——如实回 available:false,不给过期数据
|
|
4260
|
+
return { sid: input.sid, available: false, reason: `会话的进程已退出(${describeExit(session.exited)}),只读保留中:取不到它的资源指标` };
|
|
4261
|
+
}
|
|
3619
4262
|
const result = await server.sampleStats(session);
|
|
3620
4263
|
if (result.available !== true || result.frame === undefined) {
|
|
3621
4264
|
return { sid: input.sid, available: false, reason: result.reason ?? '未知原因' };
|
|
@@ -3625,7 +4268,9 @@ const plugin = definePlugin({
|
|
|
3625
4268
|
})));
|
|
3626
4269
|
activeDisposers.push(tools.register(defineTool({
|
|
3627
4270
|
name: 'tty_capture',
|
|
3628
|
-
|
|
4271
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4272
|
+
isConcurrencySafe: () => true,
|
|
4273
|
+
description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)——若命令在途(刚发送/未收到完成标记)返回 inProgress:true 且不携带旧结果,请稍后重试或改用 tty_expect。**进程已退出的会话也能读**(结果带 exited:true + 退出码/信号):输出在只读保留期里一直都在(保留到显式关闭或宿主重启,`tty_open command=...` 跑完一条命令后就这么用);这类会话不能再写,要接着操作请 tty_open 新开一条。',
|
|
3629
4274
|
parameters: {
|
|
3630
4275
|
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
3631
4276
|
lines: { type: 'number', description: '读取尾部行数(1~500,默认 60);last:true 时忽略' },
|
|
@@ -3642,6 +4287,8 @@ const plugin = definePlugin({
|
|
|
3642
4287
|
source: { type: 'string' },
|
|
3643
4288
|
exitCode: { type: 'number' },
|
|
3644
4289
|
inProgress: { type: 'boolean' },
|
|
4290
|
+
exited: { type: 'boolean' },
|
|
4291
|
+
signal: { type: 'string' },
|
|
3645
4292
|
},
|
|
3646
4293
|
},
|
|
3647
4294
|
render: (_args, value) => {
|
|
@@ -3652,7 +4299,11 @@ const plugin = definePlugin({
|
|
|
3652
4299
|
const head = v.source === 'last'
|
|
3653
4300
|
? `终端会话 ${v.sid ?? '?'} 上一条命令的输出(exitCode=${String(v.exitCode ?? '?')}):\n\n`
|
|
3654
4301
|
: `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n`;
|
|
3655
|
-
|
|
4302
|
+
// D77:读的是已退出会话时显式说明——免得 AI 把它当成「还在跑的终端」继续发命令
|
|
4303
|
+
const note = v.exited === true
|
|
4304
|
+
? `(⚠️ 该会话的进程已退出${v.signal !== undefined && v.signal !== '' ? `(signal=${v.signal})` : ''},这是只读保留的输出;此会话不能再写入,要接着操作请 tty_open 新开一条)\n\n`
|
|
4305
|
+
: '';
|
|
4306
|
+
return [{ type: 'text', text: note + head + (v.tail ?? '') }];
|
|
3656
4307
|
},
|
|
3657
4308
|
},
|
|
3658
4309
|
async execute(args) {
|
|
@@ -3663,6 +4314,15 @@ const plugin = definePlugin({
|
|
|
3663
4314
|
if (session === undefined || session.closed)
|
|
3664
4315
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
3665
4316
|
const useRaw = input.raw === true;
|
|
4317
|
+
// D77:只读保留态的标记(读得到,但要如实告诉 agent 这是已退出会话的输出)
|
|
4318
|
+
const exitMark = session.exited === null
|
|
4319
|
+
? {}
|
|
4320
|
+
: { exited: true, ...(session.exited.signal === null || session.exited.signal === '' ? {} : { signal: session.exited.signal }) };
|
|
4321
|
+
// D72:读侧工具返回前推进水位线——这里读到的内容算「已读」,后续
|
|
4322
|
+
// tty_expect 不再把它们当未读回扫(想回看更早的内容再用本工具)。
|
|
4323
|
+
// tty_screen 刻意**不**推进:它是「当前可见屏幕」这一种表示,不是
|
|
4324
|
+
// 文本流,推进它会把 AI 从未在文本里看过的输出标记成已读。
|
|
4325
|
+
advanceReadMark(session);
|
|
3666
4326
|
if (input.last === true) {
|
|
3667
4327
|
const state = session.shellState;
|
|
3668
4328
|
const last = state.lastCommand;
|
|
@@ -3680,16 +4340,24 @@ const plugin = definePlugin({
|
|
|
3680
4340
|
// 收一刀也保尾——最近输出才是 agent 要的。
|
|
3681
4341
|
// exitCode 用「键不存在」表达缺失(`?? undefined` 会留下一个
|
|
3682
4342
|
// undefined 键,不是无损 JSON 值,宿主输出校验会判工具错,见 B33)。
|
|
3683
|
-
return { sid: input.sid, source: 'last', ...(last.exitCode === null ? {} : { exitCode: last.exitCode }), tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(-128 * 1024) };
|
|
4343
|
+
return { sid: input.sid, source: 'last', ...exitMark, ...(last.exitCode === null ? {} : { exitCode: last.exitCode }), tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(-128 * 1024) };
|
|
3684
4344
|
}
|
|
3685
4345
|
const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
|
|
3686
4346
|
const rawTail = tailLines(session, lines);
|
|
3687
|
-
return {
|
|
4347
|
+
return {
|
|
4348
|
+
sid: input.sid,
|
|
4349
|
+
source: 'tail',
|
|
4350
|
+
...exitMark,
|
|
4351
|
+
...(session.exited === null || session.exited.code === null ? {} : { exitCode: session.exited.code }),
|
|
4352
|
+
tail: useRaw ? rawTail : cleanAnsiTail(rawTail),
|
|
4353
|
+
};
|
|
3688
4354
|
},
|
|
3689
4355
|
})));
|
|
3690
4356
|
activeDisposers.push(tools.register(defineTool({
|
|
3691
4357
|
name: 'tty_screen',
|
|
3692
|
-
|
|
4358
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4359
|
+
isConcurrencySafe: () => true,
|
|
4360
|
+
description: '读取某个终端面板会话(tty_list 提供 sid)当前可见屏幕的渲染结果(纯文本,等价于用户此刻看到的画面)。适合查看全屏交互程序(vim / htop / 菜单选择)的当前界面状态;要历史滚动输出用 tty_capture。**进程已退出的会话也能读**(结果带 exited:true):屏在只读保留期内不释放,读到的就是它退出那一刻的画面。',
|
|
3693
4361
|
parameters: {
|
|
3694
4362
|
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
3695
4363
|
},
|
|
@@ -3702,11 +4370,16 @@ const plugin = definePlugin({
|
|
|
3702
4370
|
cols: { type: 'number', required: true },
|
|
3703
4371
|
rows: { type: 'number', required: true },
|
|
3704
4372
|
text: { type: 'string', required: true },
|
|
4373
|
+
exited: { type: 'boolean' },
|
|
4374
|
+
signal: { type: 'string' },
|
|
3705
4375
|
},
|
|
3706
4376
|
},
|
|
3707
4377
|
render: (_args, value) => {
|
|
3708
4378
|
const v = value;
|
|
3709
|
-
|
|
4379
|
+
const note = v.exited === true
|
|
4380
|
+
? `(⚠️ 该会话的进程已退出${v.signal !== undefined && v.signal !== '' ? `(signal=${v.signal})` : ''}:这是退出那一刻的屏幕,只读保留中)`
|
|
4381
|
+
: '';
|
|
4382
|
+
return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 当前屏幕(${String(v.cols ?? '?')}×${String(v.rows ?? '?')})${note}:\n\n${v.text ?? ''}` }];
|
|
3710
4383
|
},
|
|
3711
4384
|
},
|
|
3712
4385
|
async execute(args) {
|
|
@@ -3730,12 +4403,21 @@ const plugin = definePlugin({
|
|
|
3730
4403
|
while (lines.length > 0 && lines[lines.length - 1].trim() === '')
|
|
3731
4404
|
lines.pop();
|
|
3732
4405
|
// 保尾截断:屏幕末尾(提示符行)才是有效区,丢头部不丢尾部
|
|
3733
|
-
return {
|
|
4406
|
+
return {
|
|
4407
|
+
sid: input.sid,
|
|
4408
|
+
cols: screen.cols,
|
|
4409
|
+
rows: screen.rows,
|
|
4410
|
+
// D77:只读保留态如实标注(屏不再更新,「当前屏幕」= 退出那一刻)
|
|
4411
|
+
...(session.exited === null ? {} : { exited: true, ...(session.exited.signal === null || session.exited.signal === '' ? {} : { signal: session.exited.signal }) }),
|
|
4412
|
+
text: lines.join('\n').slice(-32 * 1024),
|
|
4413
|
+
};
|
|
3734
4414
|
},
|
|
3735
4415
|
})));
|
|
3736
4416
|
activeDisposers.push(tools.register(defineTool({
|
|
3737
4417
|
name: 'tty_expect',
|
|
3738
|
-
|
|
4418
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4419
|
+
isConcurrencySafe: () => true,
|
|
4420
|
+
description: '在某个终端面板会话(tty_list 提供 sid)等待一个正则出现(如 dev server 的 ready/URL、构建完成标记、交互提示)。**先回溯**还没被读过的输出(含「上一条命令」的完整输出),再等后续输出——所以命令瞬间跑完也不会白等;匹配到立即返回 matched:true(matchedFrom 说明匹配来自哪里:live=本次等待期间新产生 / last=上一条命令的输出 / buffered=此前已到达的缓冲输出)与周边输出。**刚发进去的命令回显不算命中**(命令还没执行时回显先到,命中它等于谎报);若等满超时且 pattern 只命中过回显,结果带 echoOnly:true——那说明命中的只是回显、不是输出(命令可能没被执行),先复核它到底跑没跑。超时不抛错,返回 matched:false + 尾部输出;期间该命令若已结束(shell 集成标记)也会提前返回并带退出码。适合先 tty_send 启动长任务、再 tty_expect 等就绪信号的流程。注意:只对「还没读过的输出」负责——要回看更早的内容用 tty_capture。',
|
|
3739
4421
|
parameters: {
|
|
3740
4422
|
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
3741
4423
|
pattern: { type: 'string', required: true, description: '等待匹配的正则表达式(JavaScript RegExp 语法)' },
|
|
@@ -3750,12 +4432,31 @@ const plugin = definePlugin({
|
|
|
3750
4432
|
timedOut: { type: 'boolean', required: true },
|
|
3751
4433
|
text: { type: 'string', required: true },
|
|
3752
4434
|
exitCode: { type: 'number' },
|
|
4435
|
+
matchedFrom: { type: 'string' },
|
|
4436
|
+
echoOnly: { type: 'boolean' },
|
|
4437
|
+
exited: { type: 'boolean' },
|
|
3753
4438
|
},
|
|
3754
4439
|
},
|
|
3755
4440
|
render: (_args, value) => {
|
|
3756
4441
|
const v = value;
|
|
3757
|
-
if (v.matched === true)
|
|
3758
|
-
|
|
4442
|
+
if (v.matched === true) {
|
|
4443
|
+
const from = v.matchedFrom === 'last'
|
|
4444
|
+
? '(回溯自「上一条命令」的输出)'
|
|
4445
|
+
: v.matchedFrom === 'buffered' ? '(回溯自此前已到达、还没读过的缓冲输出)' : '';
|
|
4446
|
+
return [{ type: 'text', text: `已匹配到等待的模式${from}:\n\n${v.text ?? ''}` }];
|
|
4447
|
+
}
|
|
4448
|
+
if (v.echoOnly === true) {
|
|
4449
|
+
// D75:只命中过回显 —— 这不是「命令跑了但没输出」,而是「等的东西一次都没出现在输出里」
|
|
4450
|
+
return [{ type: 'text', text: `等待超时:pattern **只匹配到刚发进去的命令回显**,没有匹配到任何真正的输出——命令可能没有被执行(回显先到、输出没来),也可能执行了但输出里没有这个 pattern。先复核它到底跑没跑(tty_capture 读尾部、或在面板里看那一行是否还停在输入行),再决定重发命令还是换 pattern。尾部输出:\n\n${v.text ?? ''}` }];
|
|
4451
|
+
}
|
|
4452
|
+
if (v.exited === true) {
|
|
4453
|
+
// D77:会话的进程已经退出(只读保留)——不会再有任何新输出,别让它以为还能等
|
|
4454
|
+
return [{ type: 'text', text: `会话的进程已退出(只读保留中),不会再有新输出:这一轮按现存输出结算,没有匹配到 pattern。要看它最后的输出用 tty_capture(默认读尾部)/ tty_screen(退出那一刻的屏);要接着跑命令请 tty_open 新开一条会话。尾部输出:\n\n${v.text ?? ''}` }];
|
|
4455
|
+
}
|
|
4456
|
+
if (v.timedOut === true && (v.text ?? '').trim() === '') {
|
|
4457
|
+
// D72:这段空白此前被当成「命令没执行」——命令若是瞬间完成的,它早就跑完了
|
|
4458
|
+
return [{ type: 'text', text: '等待超时,且注册之后没有任何新输出。命令若是瞬间完成的,它早已在开始等待之前跑完:用 tty_capture{last:true} 复核那条命令的输出与退出码,别把这段空白当成「没执行」。' }];
|
|
4459
|
+
}
|
|
3759
4460
|
const why = v.timedOut === true ? '等待超时' : `命令已结束(exitCode=${String(v.exitCode ?? '?')})但未出现匹配`;
|
|
3760
4461
|
return [{ type: 'text', text: `${why}。尾部输出:\n\n${v.text ?? ''}` }];
|
|
3761
4462
|
},
|
|
@@ -3787,48 +4488,93 @@ const plugin = definePlugin({
|
|
|
3787
4488
|
expectCounts.set(session, inflight + 1);
|
|
3788
4489
|
return await new Promise((resolve) => {
|
|
3789
4490
|
const startedAt = Date.now();
|
|
3790
|
-
const
|
|
4491
|
+
const state = session.shellState;
|
|
4492
|
+
const startedInCommand = state.inCommand;
|
|
3791
4493
|
// 尾部窗口:匹配只看最近 16KB,acc 全量囤积对刷屏会话可涨到数百 MB
|
|
3792
4494
|
let acc = '';
|
|
3793
4495
|
let settled = false;
|
|
4496
|
+
/** D75:pattern 只命中过回显(被剔除了)——超时文案据此如实说明「命令可能没跑」。 */
|
|
4497
|
+
let sawEchoOnly = false;
|
|
4498
|
+
let timer = null;
|
|
3794
4499
|
const decoder = new StringDecoder('utf8');
|
|
3795
4500
|
const output = session.handle.output;
|
|
3796
|
-
|
|
4501
|
+
/**
|
|
4502
|
+
* 结算时的水位线(D72):
|
|
4503
|
+
* - 匹配成功 / 会话结束 → 推进到当下(这段输出已经交回给 AI 了);
|
|
4504
|
+
* - **超时不动**:那次只把注册之后的增量交回去,注册前就在缓冲里的
|
|
4505
|
+
* 未读输出没被读过,而且下一次换个 pattern 还要靠它回溯——吞掉
|
|
4506
|
+
* 它等于「等一次没等到,这段输出就作废了」。
|
|
4507
|
+
*/
|
|
4508
|
+
const finish = (result, consumeBacklog = true) => {
|
|
3797
4509
|
if (settled)
|
|
3798
4510
|
return;
|
|
3799
4511
|
settled = true;
|
|
3800
|
-
|
|
4512
|
+
if (timer !== null)
|
|
4513
|
+
clearTimeout(timer);
|
|
3801
4514
|
output.off('data', onData);
|
|
3802
4515
|
expectCounts.set(session, Math.max(0, (expectCounts.get(session) ?? 1) - 1));
|
|
4516
|
+
if (consumeBacklog)
|
|
4517
|
+
advanceReadMark(session);
|
|
3803
4518
|
resolve(result);
|
|
3804
4519
|
};
|
|
4520
|
+
// 箭头函数(不是 function 声明):后者会被提升,TS 不保留外层 `const session`
|
|
4521
|
+
// 的收窄,闭包里就得再判一次 undefined。
|
|
3805
4522
|
const onData = (chunk) => {
|
|
3806
4523
|
acc = (acc + decoder.write(chunk)).slice(-64 * 1024);
|
|
3807
4524
|
const hay = acc.length > 16 * 1024 ? acc.slice(-16 * 1024) : acc;
|
|
3808
|
-
if (re
|
|
3809
|
-
|
|
3810
|
-
|
|
4525
|
+
if (testPattern(re, hay)) {
|
|
4526
|
+
// D75:命中的若**只是刚送进去那行命令的回显**,不算命中——继续等真输出
|
|
4527
|
+
if (echoOnlyMatch(re, hay, session))
|
|
4528
|
+
sawEchoOnly = true;
|
|
4529
|
+
else {
|
|
4530
|
+
finish({ matched: true, timedOut: false, matchedFrom: 'live', text: cleanAnsiTail(hay.slice(-6 * 1024)) });
|
|
4531
|
+
return;
|
|
4532
|
+
}
|
|
3811
4533
|
}
|
|
3812
4534
|
// 命令早停:注册时命令在飞(B..D 之间),如今 D 已到仍未匹配
|
|
3813
|
-
const state = session.shellState;
|
|
3814
4535
|
if (startedInCommand && !state.inCommand && state.lastCommand !== null && state.lastCommand.endedAt >= startedAt) {
|
|
3815
|
-
finish({ matched: false, timedOut: false, ...(state.lastCommand.exitCode === null ? {} : { exitCode: state.lastCommand.exitCode }), text: cleanAnsiTail(acc.slice(-6 * 1024)) });
|
|
4536
|
+
finish({ matched: false, timedOut: false, ...(state.lastCommand.exitCode === null ? {} : { exitCode: state.lastCommand.exitCode }), ...(sawEchoOnly ? { echoOnly: true } : {}), text: cleanAnsiTail(acc.slice(-6 * 1024)) });
|
|
3816
4537
|
}
|
|
3817
4538
|
};
|
|
3818
|
-
|
|
3819
|
-
|
|
4539
|
+
// ── 注册**之前**就已到达的输出(D72)─────────────────────────
|
|
4540
|
+
// 这段此前完全不匹配:acc 从空开始,所以「命令瞬间完成」时标记
|
|
4541
|
+
// 早就躺在缓冲区里、acc 里永远没有它 → 白等满超时、还只返回空白。
|
|
4542
|
+
ensureReadMark(session);
|
|
4543
|
+
// ① 上一条命令的完整输出(B..D 窗口:无回显、无提示符、自带退出码)。
|
|
4544
|
+
// 两个闸门保证它「还没被读过」:不晚于最后一次输入(否则是上一条
|
|
4545
|
+
// 命令的旧结果),且晚于水位线时刻(否则 AI 已经用 capture 看过了)。
|
|
4546
|
+
const last = state.lastCommand;
|
|
4547
|
+
if (!state.inCommand && last !== null && last.endedAt > session.readMarkAt && last.endedAt >= session.lastInputAt && testPattern(re, last.output)) {
|
|
4548
|
+
finish({ matched: true, timedOut: false, matchedFrom: 'last', ...(last.exitCode === null ? {} : { exitCode: last.exitCode }), text: cleanAnsiTail(last.output.slice(-6 * 1024)) });
|
|
4549
|
+
return;
|
|
4550
|
+
}
|
|
4551
|
+
// ② 泛化:水位线之后的未读缓冲(长驻输出落在多条命令之间、无 shell 集成……)
|
|
4552
|
+
const backlog = unreadRegion(session);
|
|
4553
|
+
if (backlog !== '' && testPattern(re, backlog)) {
|
|
4554
|
+
// D75:同上——纯回显不算命中(Windows 上 LF 没提交时,这里命中的就只有回显)
|
|
4555
|
+
if (echoOnlyMatch(re, backlog, session))
|
|
4556
|
+
sawEchoOnly = true;
|
|
4557
|
+
else {
|
|
4558
|
+
finish({ matched: true, timedOut: false, matchedFrom: 'buffered', text: cleanAnsiTail(backlog.slice(-6 * 1024)) });
|
|
4559
|
+
return;
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
timer = setTimeout(() => {
|
|
4563
|
+
finish({ matched: false, timedOut: true, ...(sawEchoOnly ? { echoOnly: true } : {}), text: cleanAnsiTail(acc.slice(-6 * 1024)) }, false);
|
|
3820
4564
|
}, timeoutMs);
|
|
3821
4565
|
timer.unref?.();
|
|
3822
4566
|
output.on('data', onData);
|
|
3823
4567
|
void session.handle.done.then(() => {
|
|
3824
|
-
|
|
4568
|
+
// 会话结束(命令跑完 / 进程退出):不能再等——D77 的只读保留态也走这里
|
|
4569
|
+
// (done 早已兑现,所以退出后调用 expect 不再白等满超时)
|
|
4570
|
+
finish({ matched: false, timedOut: false, ...(session.exited === null ? {} : { exited: true }), text: cleanAnsiTail(acc.slice(-6 * 1024)) });
|
|
3825
4571
|
});
|
|
3826
4572
|
});
|
|
3827
4573
|
},
|
|
3828
4574
|
})));
|
|
3829
4575
|
activeDisposers.push(tools.register(defineTool({
|
|
3830
4576
|
name: 'tty_send',
|
|
3831
|
-
description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送按键/文本(命令以 \\n
|
|
4577
|
+
description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送按键/文本(命令以 \\n 结尾;Windows 本地会话上插件会把 `\\n` 归一成 CRLF,照常写 `\\n` 即可)。适合给用户终端里运行的程序发交互输入(如 dev server 的 q 键、menu 选择、回答提示)。操作会实时显示在用户的终端面板里。**对已退出(只读保留)的会话会报错**——那种会话只能读,要接着操作请 tty_open 新开一条。',
|
|
3832
4578
|
parameters: {
|
|
3833
4579
|
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
3834
4580
|
data: { type: 'string', required: true, description: '要发送的文本(含换行则直接发送命令)' },
|
|
@@ -3856,13 +4602,27 @@ const plugin = definePlugin({
|
|
|
3856
4602
|
const session = sessions.get(input.sid);
|
|
3857
4603
|
if (session === undefined || session.closed)
|
|
3858
4604
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
4605
|
+
if (session.exited !== null) {
|
|
4606
|
+
// D77:只读保留态明确拒写——进程已经没了,写进去只会在死 PTY 上静默消失
|
|
4607
|
+
throw new Error(`会话 ${input.sid} 的进程已退出(${describeExit(session.exited)}),只读保留中,写不进去:要接着操作请 tty_open 新开一条会话(它最后的输出仍可用 tty_capture / tty_screen 读)`);
|
|
4608
|
+
}
|
|
4609
|
+
// D74:Windows 本地 PTY 的 Enter 是 CR,裸 LF 不提交命令行——按平台归一化。
|
|
4610
|
+
// SSH 会话不动:远端是什么系统插件不知道。
|
|
4611
|
+
const data = session.kind === 'local' ? normalizePtyInput(input.data) : input.data;
|
|
3859
4612
|
session.lastInputAt = Date.now();
|
|
3860
|
-
|
|
3861
|
-
|
|
4613
|
+
// D72:只初始化水位线,**不推进**——刚发出去的这条命令的输出 AI 还没看见;
|
|
4614
|
+
// 但这一刻之前的积压不该被第一次 expect 当成「未读」回扫。
|
|
4615
|
+
ensureReadMark(session);
|
|
4616
|
+
// D75:记下这行命令的回显候选,供 tty_expect 剔除「命中回显」的假阳性
|
|
4617
|
+
noteSubmittedInput(session, data);
|
|
4618
|
+
await session.handle.write(data);
|
|
4619
|
+
return { ok: true, sent: data.length };
|
|
3862
4620
|
},
|
|
3863
4621
|
})));
|
|
3864
4622
|
activeDisposers.push(tools.register(defineTool({
|
|
3865
4623
|
name: 'tunnel_list',
|
|
4624
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4625
|
+
isConcurrencySafe: () => true,
|
|
3866
4626
|
description: '列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、当前与累计连接数、最近错误)。用户说「隧道连不上 / 转发挂了 / 端口转发不通」时先用它诊断;隧道在 插件配置 → 终端面板 卡片维护。',
|
|
3867
4627
|
parameters: {},
|
|
3868
4628
|
output: {
|
|
@@ -3941,6 +4701,8 @@ const plugin = definePlugin({
|
|
|
3941
4701
|
};
|
|
3942
4702
|
activeDisposers.push(tools.register(defineTool({
|
|
3943
4703
|
name: 'sftp_list',
|
|
4704
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4705
|
+
isConcurrencySafe: () => true,
|
|
3944
4706
|
description: '列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前;isSymlink 区分符号链接与真目录)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。默认最多列 500 项(超限 truncated:true,可按子目录分批)。',
|
|
3945
4707
|
parameters: {
|
|
3946
4708
|
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(插件配置 → 终端面板 维护)' },
|
|
@@ -3992,6 +4754,8 @@ const plugin = definePlugin({
|
|
|
3992
4754
|
})));
|
|
3993
4755
|
activeDisposers.push(tools.register(defineTool({
|
|
3994
4756
|
name: 'sftp_read',
|
|
4757
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4758
|
+
isConcurrencySafe: () => true,
|
|
3995
4759
|
description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB,非法值直接报错);offset 可从指定字节起读(配合 maxBytes 分页拿到大文件尾部);二进制判定用 NUL + 非法 UTF-8 占比双重检测,拒绝时说明原因。',
|
|
3996
4760
|
parameters: {
|
|
3997
4761
|
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
@@ -4199,6 +4963,8 @@ const plugin = definePlugin({
|
|
|
4199
4963
|
})));
|
|
4200
4964
|
activeDisposers.push(tools.register(defineTool({
|
|
4201
4965
|
name: 'sftp_tree',
|
|
4966
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4967
|
+
isConcurrencySafe: () => true,
|
|
4202
4968
|
description: '递归列举 SSH 远程目录结构(book 连接簿条目 + path):深度优先、目录优先,maxDepth(1~8,默认 3)限层、maxEntries(1~2000,默认 500)限条数,超限 truncated:true;符号链接不跟随;读取失败的子目录列入 errors。适合先看远程项目结构再定位文件。',
|
|
4203
4969
|
parameters: {
|
|
4204
4970
|
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
@@ -4325,12 +5091,26 @@ const plugin = definePlugin({
|
|
|
4325
5091
|
text: () => {
|
|
4326
5092
|
const list = sessions.list();
|
|
4327
5093
|
if (list.length === 0)
|
|
4328
|
-
return '
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
5094
|
+
return '当前没有终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。';
|
|
5095
|
+
// D77:只读保留态(进程已退出)**不能冒充活会话**——模型会以为那个长驻
|
|
5096
|
+
// 任务还在跑、或者对它发命令。这里把两者分开:活会话逐条列,保留态压成
|
|
5097
|
+
// 一行汇总(每轮 prompt 的增量是常数,不随条数线性膨胀)。
|
|
5098
|
+
const live = list.filter((s) => s.exited !== true);
|
|
5099
|
+
const gone = list.filter((s) => s.exited === true);
|
|
5100
|
+
const head = live.length === 0
|
|
5101
|
+
? '当前没有活着的终端面板会话。'
|
|
5102
|
+
: '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' + live.map((s) => {
|
|
5103
|
+
const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
|
|
5104
|
+
const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
|
|
5105
|
+
return `- sid=${s.sid} [${s.kind}]${owner}${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
|
|
5106
|
+
}).join('\n');
|
|
5107
|
+
const tail = gone.length === 0
|
|
5108
|
+
? ''
|
|
5109
|
+
: `\n另有 ${String(gone.length)} 条已退出但输出仍可读的会话(只读:tty_send 会报错,要用 tty_capture / tty_screen 读):` + gone.map((s) => {
|
|
5110
|
+
const how = s.signal !== undefined ? `signal=${s.signal}` : s.exitCode === undefined ? '退出码未知' : `exitCode=${String(s.exitCode)}`;
|
|
5111
|
+
return `sid=${s.sid} (${how})`;
|
|
5112
|
+
}).join('、');
|
|
5113
|
+
return head + tail;
|
|
4334
5114
|
},
|
|
4335
5115
|
});
|
|
4336
5116
|
sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
|
|
@@ -4362,6 +5142,7 @@ const plugin = definePlugin({
|
|
|
4362
5142
|
// 断开时立即结束);插件卸载时随 effect 一起停掉
|
|
4363
5143
|
const reaperTimer = setInterval(() => {
|
|
4364
5144
|
void sessions.reapOrphans(live.reconnectGraceMs);
|
|
5145
|
+
sessions.reapExited(EXITED_RETAIN_MS); // D77:保留期策略为有限值时到点退役(∞ 时不动作,条数由 capExited 兜)
|
|
4365
5146
|
}, REAPER_INTERVAL_MS);
|
|
4366
5147
|
reaperTimer.unref?.();
|
|
4367
5148
|
ctx.effect(() => () => clearInterval(reaperTimer), 'dsh-tty: orphan reaper');
|