@hyzyn/dsh-tty 0.5.0 → 0.9.0
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.md +93 -15
- package/client.js +38 -38
- package/lib/index.d.ts +7 -1
- package/lib/index.js +712 -25
- package/lib/index.js.map +1 -1
- package/lib/sftp.d.ts +110 -0
- package/lib/sftp.js +449 -0
- package/lib/sftp.js.map +1 -0
- package/lib/shell-integration.js +51 -11
- package/lib/shell-integration.js.map +1 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { accessSync, constants as fsConstants, existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { mkdir as fsMkdir, readdir as fsReaddir, rename as fsRename, rm as fsRm, stat as fsStat } from 'node:fs/promises';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
6
7
|
import { PassThrough } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
9
|
import { StringDecoder } from 'node:string_decoder';
|
|
8
10
|
import WebSocket, { WebSocketServer } from 'ws';
|
|
9
11
|
// @xterm/headless 是 CJS 包:ESM 具名导入在 Node 运行时会炸(Named export not
|
|
@@ -17,6 +19,7 @@ import { buildShellSpawn } from './shell-integration.js';
|
|
|
17
19
|
import { parseSshConfig } from './ssh-config.js';
|
|
18
20
|
import { parseKnownHosts } from './known-hosts.js';
|
|
19
21
|
import { TunnelManager } from './tunnels.js';
|
|
22
|
+
import { SftpManager } from './sftp.js';
|
|
20
23
|
const SSH_HOST_SCHEMA = z.object({
|
|
21
24
|
name: z.string(),
|
|
22
25
|
host: z.string(),
|
|
@@ -58,6 +61,7 @@ const TTY_SETTINGS_SCHEMA = z.object({
|
|
|
58
61
|
hostKeys: z.array(HOST_KEY_SCHEMA).default([]),
|
|
59
62
|
tunnels: z.array(TUNNEL_SCHEMA).default([]),
|
|
60
63
|
shellIntegration: z.boolean().default(true),
|
|
64
|
+
sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog'),
|
|
61
65
|
});
|
|
62
66
|
/* ------------------------------------------------------------------ *
|
|
63
67
|
* 常量
|
|
@@ -75,7 +79,7 @@ const BUFFER_CAP = 256 * 1024;
|
|
|
75
79
|
const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
|
|
76
80
|
/** 孤儿会话回收器的扫描间隔。 */
|
|
77
81
|
const REAPER_INTERVAL_MS = 10_000;
|
|
78
|
-
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 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
|
|
82
|
+
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 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} 拿结果。';
|
|
79
83
|
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
80
84
|
function wrapLocalPty(handle) {
|
|
81
85
|
let resizeWarned = false;
|
|
@@ -820,26 +824,13 @@ class TtyServer {
|
|
|
820
824
|
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue})` });
|
|
821
825
|
return;
|
|
822
826
|
}
|
|
823
|
-
// name
|
|
824
|
-
const
|
|
825
|
-
if (
|
|
826
|
-
send(ws, { t: 'error', sid, m:
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
const spec = {
|
|
830
|
-
host: typeof msg.host === 'string' && msg.host.trim() !== '' ? msg.host.trim() : profile?.host ?? '',
|
|
831
|
-
port: Number(msg.port) || profile?.port || 22,
|
|
832
|
-
username: typeof msg.username === 'string' && msg.username.trim() !== '' ? msg.username.trim() : profile?.username ?? '',
|
|
833
|
-
auth: msg.auth === 'key' || msg.auth === 'password' || msg.auth === 'agent' ? msg.auth : profile?.auth ?? 'agent',
|
|
834
|
-
keyPath: typeof msg.keyPath === 'string' && msg.keyPath !== '' ? msg.keyPath : profile?.keyPath,
|
|
835
|
-
passphrase: typeof msg.passphrase === 'string' && msg.passphrase !== '' ? msg.passphrase : profile?.passphrase,
|
|
836
|
-
password: typeof msg.password === 'string' && msg.password !== '' ? msg.password : profile?.password,
|
|
837
|
-
agentForward: typeof msg.agentForward === 'boolean' ? msg.agentForward : profile?.agentForward ?? false,
|
|
838
|
-
};
|
|
839
|
-
if (spec.host === '' || spec.username === '') {
|
|
840
|
-
send(ws, { t: 'error', sid, m: 'SSH 会话需要 host 与 username(或用 name 引用连接簿)' });
|
|
827
|
+
// name 引用连接簿条目作基底,内联字段可逐项覆盖(与 SFTP 路由共用 mergeSshSpec)
|
|
828
|
+
const merged = mergeSshSpec((name) => this.options.findSshHost(name), msg.name, msg);
|
|
829
|
+
if (merged.error !== undefined || merged.spec === undefined) {
|
|
830
|
+
send(ws, { t: 'error', sid, m: merged.error ?? 'SSH 连接参数缺失' });
|
|
841
831
|
return;
|
|
842
832
|
}
|
|
833
|
+
const spec = merged.spec;
|
|
843
834
|
const target = sshTarget(spec);
|
|
844
835
|
send(ws, { t: 'data', sid, d: `\x1b[2mConnecting ${target} …\x1b[0m\r\n` });
|
|
845
836
|
let handle;
|
|
@@ -1098,6 +1089,71 @@ function readManagedEnvKeys() {
|
|
|
1098
1089
|
return [];
|
|
1099
1090
|
}
|
|
1100
1091
|
}
|
|
1092
|
+
/**
|
|
1093
|
+
* 设置卡片「Shell 路径」候选(可选可输入的数据源):/etc/shells + $SHELL +
|
|
1094
|
+
* 常见安装路径,去重后过滤「存在且可执行」,$SHELL 排最前。只回路径,
|
|
1095
|
+
* 不做任何执行。
|
|
1096
|
+
*/
|
|
1097
|
+
function listCandidateShells() {
|
|
1098
|
+
const candidates = [];
|
|
1099
|
+
const push = (value) => {
|
|
1100
|
+
const path = value?.trim() ?? '';
|
|
1101
|
+
if (path !== '' && !candidates.includes(path))
|
|
1102
|
+
candidates.push(path);
|
|
1103
|
+
};
|
|
1104
|
+
try {
|
|
1105
|
+
for (const line of readFileSync('/etc/shells', 'utf8').split('\n')) {
|
|
1106
|
+
const path = line.trim();
|
|
1107
|
+
if (path !== '' && !path.startsWith('#'))
|
|
1108
|
+
push(path);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
/* 无 /etc/shells(如 Windows)时跳过 */
|
|
1113
|
+
}
|
|
1114
|
+
push(process.env.SHELL);
|
|
1115
|
+
for (const path of [
|
|
1116
|
+
'/bin/zsh', '/usr/bin/zsh', '/usr/local/bin/zsh', '/opt/homebrew/bin/zsh',
|
|
1117
|
+
'/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash',
|
|
1118
|
+
'/bin/fish', '/usr/bin/fish', '/usr/local/bin/fish', '/opt/homebrew/bin/fish',
|
|
1119
|
+
'/bin/sh', '/bin/dash', '/bin/ksh', '/bin/tcsh', '/bin/csh',
|
|
1120
|
+
])
|
|
1121
|
+
push(path);
|
|
1122
|
+
const usable = candidates.filter((path) => {
|
|
1123
|
+
try {
|
|
1124
|
+
accessSync(path, fsConstants.X_OK);
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
const shell = process.env.SHELL?.trim() ?? '';
|
|
1132
|
+
usable.sort((a, b) => (a === shell ? -1 : b === shell ? 1 : a.localeCompare(b)));
|
|
1133
|
+
return usable;
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* 「连接簿条目作基底 + 内联字段逐项覆盖」的共享解析(WS ssh 帧与 SFTP 路由共用)。
|
|
1137
|
+
* name 指向连接簿缺失条目、或解析结果缺 host/username 时返回 error。
|
|
1138
|
+
*/
|
|
1139
|
+
function mergeSshSpec(findSshHost, name, inline) {
|
|
1140
|
+
const profile = typeof name === 'string' && name !== '' ? findSshHost(name) : undefined;
|
|
1141
|
+
if (typeof name === 'string' && name !== '' && profile === undefined)
|
|
1142
|
+
return { error: `连接簿中不存在: ${name}` };
|
|
1143
|
+
const spec = {
|
|
1144
|
+
host: typeof inline.host === 'string' && inline.host.trim() !== '' ? inline.host.trim() : profile?.host ?? '',
|
|
1145
|
+
port: Number(inline.port) || profile?.port || 22,
|
|
1146
|
+
username: typeof inline.username === 'string' && inline.username.trim() !== '' ? inline.username.trim() : profile?.username ?? '',
|
|
1147
|
+
auth: inline.auth === 'key' || inline.auth === 'password' || inline.auth === 'agent' ? inline.auth : profile?.auth ?? 'agent',
|
|
1148
|
+
keyPath: typeof inline.keyPath === 'string' && inline.keyPath !== '' ? inline.keyPath : profile?.keyPath,
|
|
1149
|
+
passphrase: typeof inline.passphrase === 'string' && inline.passphrase !== '' ? inline.passphrase : profile?.passphrase,
|
|
1150
|
+
password: typeof inline.password === 'string' && inline.password !== '' ? inline.password : profile?.password,
|
|
1151
|
+
agentForward: typeof inline.agentForward === 'boolean' ? inline.agentForward : profile?.agentForward ?? false,
|
|
1152
|
+
};
|
|
1153
|
+
if (spec.host === '' || spec.username === '')
|
|
1154
|
+
return { error: 'SSH 会话需要 host 与 username(或用 name 引用连接簿)' };
|
|
1155
|
+
return { spec };
|
|
1156
|
+
}
|
|
1101
1157
|
/** HTTP 路由的 loopback 信任围栏(与 dsh-mcp 同思路)。 */
|
|
1102
1158
|
function isLoopbackHttp(req) {
|
|
1103
1159
|
const address = req.socket.remoteAddress;
|
|
@@ -1131,6 +1187,31 @@ function writeJson(res, status, body) {
|
|
|
1131
1187
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
|
|
1132
1188
|
res.end(JSON.stringify(body));
|
|
1133
1189
|
}
|
|
1190
|
+
/** 下载响应的 content-disposition:ASCII 兜底 + RFC 5987 UTF-8 扩展(非 ASCII 文件名)。 */
|
|
1191
|
+
function contentDispositionValue(name) {
|
|
1192
|
+
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
1193
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
1194
|
+
}
|
|
1195
|
+
/** 远程路径取末段(下载文件名展示用);空串/根路径退化为 'download'。 */
|
|
1196
|
+
function remoteBasename(path) {
|
|
1197
|
+
const trimmed = path.trim().replace(/\/+$/, '');
|
|
1198
|
+
const index = trimmed.lastIndexOf('/');
|
|
1199
|
+
const base = index >= 0 ? trimmed.slice(index + 1) : trimmed;
|
|
1200
|
+
return base === '' ? 'download' : base;
|
|
1201
|
+
}
|
|
1202
|
+
/** 人类可读文件大小(sftp_list render 用)。 */
|
|
1203
|
+
function humanFileSize(bytes) {
|
|
1204
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
1205
|
+
return '0 B';
|
|
1206
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
1207
|
+
let value = bytes;
|
|
1208
|
+
let index = 0;
|
|
1209
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
1210
|
+
value /= 1024;
|
|
1211
|
+
index += 1;
|
|
1212
|
+
}
|
|
1213
|
+
return `${value >= 100 || index === 0 ? Math.round(value) : Math.round(value * 10) / 10} ${units[index]}`;
|
|
1214
|
+
}
|
|
1134
1215
|
async function readJsonBody(req) {
|
|
1135
1216
|
const chunks = [];
|
|
1136
1217
|
let size = 0;
|
|
@@ -1153,6 +1234,34 @@ async function readJsonBody(req) {
|
|
|
1153
1234
|
return undefined;
|
|
1154
1235
|
}
|
|
1155
1236
|
}
|
|
1237
|
+
/**
|
|
1238
|
+
* 本机目录列表(/api/dsh-tty/local-fs/list):path 空 = 用户 home,返回实际
|
|
1239
|
+
* 绝对路径。stat 失败的条目(悬空符号链接等)按 size/mtime = 0 占位仍列出。
|
|
1240
|
+
*/
|
|
1241
|
+
async function listLocalDir(rawPath) {
|
|
1242
|
+
const root = rawPath !== '' ? expandHome(rawPath) : homedir();
|
|
1243
|
+
const resolved = resolve(root);
|
|
1244
|
+
const dirents = await fsReaddir(resolved, { withFileTypes: true });
|
|
1245
|
+
const entries = [];
|
|
1246
|
+
for (const dirent of dirents) {
|
|
1247
|
+
let isDir = dirent.isDirectory();
|
|
1248
|
+
let isFile = dirent.isFile();
|
|
1249
|
+
let size = 0;
|
|
1250
|
+
let mtime = 0;
|
|
1251
|
+
try {
|
|
1252
|
+
const stats = await fsStat(join(resolved, dirent.name));
|
|
1253
|
+
isDir = stats.isDirectory();
|
|
1254
|
+
isFile = stats.isFile();
|
|
1255
|
+
size = Number(stats.size ?? 0);
|
|
1256
|
+
mtime = Number(stats.mtimeMs ?? 0);
|
|
1257
|
+
}
|
|
1258
|
+
catch {
|
|
1259
|
+
/* stat 失败:保留 dirent 类型判断,占位展示 */
|
|
1260
|
+
}
|
|
1261
|
+
entries.push({ name: dirent.name, isDir, isFile, isSymlink: dirent.isSymbolicLink(), size, mtime });
|
|
1262
|
+
}
|
|
1263
|
+
return { path: resolved, entries };
|
|
1264
|
+
}
|
|
1156
1265
|
const plugin = definePlugin({
|
|
1157
1266
|
name: 'tty',
|
|
1158
1267
|
// 声明 inject:tools 服务只有声明式 inject 才能解析(动态 ctx.inject/ctx.get
|
|
@@ -1184,8 +1293,11 @@ const plugin = definePlugin({
|
|
|
1184
1293
|
};
|
|
1185
1294
|
const hostKeyStore = new HostKeyStore(live, persistHostKeys);
|
|
1186
1295
|
const tunnelManager = new TunnelManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore, (bookName) => live.findSshHost(bookName));
|
|
1296
|
+
// SFTP 文件传输:懒连接池 + TOFU 同源(见 src/sftp.ts);spec 由各请求携带
|
|
1297
|
+
// (连接簿名或内联字段),连接簿凭证热改后天然生效
|
|
1298
|
+
const sftpManager = new SftpManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore);
|
|
1187
1299
|
const server = new TtyServer(ctx, sessions, live, hostKeyStore);
|
|
1188
|
-
const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false };
|
|
1300
|
+
const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false, sftpStyle: config?.sftpStyle === 'dual' ? 'dual' : 'dialog' };
|
|
1189
1301
|
let settingsScope;
|
|
1190
1302
|
const snapshot = () => ({
|
|
1191
1303
|
enabled: stateRef.enabled,
|
|
@@ -1200,6 +1312,7 @@ const plugin = definePlugin({
|
|
|
1200
1312
|
hostKeys: live.hostKeys,
|
|
1201
1313
|
tunnels: live.tunnels,
|
|
1202
1314
|
shellIntegration: live.shellIntegration,
|
|
1315
|
+
sftpStyle: stateRef.sftpStyle,
|
|
1203
1316
|
toolsRegistered: stateRef.toolsRegistered,
|
|
1204
1317
|
});
|
|
1205
1318
|
/** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
|
|
@@ -1224,12 +1337,14 @@ const plugin = definePlugin({
|
|
|
1224
1337
|
stateRef.enabled = section.enabled;
|
|
1225
1338
|
if (typeof section.announceToAgent === 'boolean')
|
|
1226
1339
|
stateRef.announceToAgent = section.announceToAgent;
|
|
1340
|
+
if (section.sftpStyle === 'dialog' || section.sftpStyle === 'dual')
|
|
1341
|
+
stateRef.sftpStyle = section.sftpStyle;
|
|
1227
1342
|
console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length})`);
|
|
1228
1343
|
};
|
|
1229
1344
|
/** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
|
|
1230
1345
|
const normalizePatch = (input) => {
|
|
1231
1346
|
const patch = {};
|
|
1232
|
-
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration']);
|
|
1347
|
+
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle']);
|
|
1233
1348
|
for (const key of Object.keys(input)) {
|
|
1234
1349
|
if (!known.has(key))
|
|
1235
1350
|
return { error: '未知配置项: ' + key };
|
|
@@ -1261,6 +1376,11 @@ const plugin = definePlugin({
|
|
|
1261
1376
|
return { error: 'shellIntegration 必须是布尔值' };
|
|
1262
1377
|
patch.shellIntegration = input.shellIntegration;
|
|
1263
1378
|
}
|
|
1379
|
+
if (input.sftpStyle !== undefined) {
|
|
1380
|
+
if (input.sftpStyle !== 'dialog' && input.sftpStyle !== 'dual')
|
|
1381
|
+
return { error: 'sftpStyle 必须是 dialog 或 dual' };
|
|
1382
|
+
patch.sftpStyle = input.sftpStyle;
|
|
1383
|
+
}
|
|
1264
1384
|
for (const key of ['shell', 'term', 'colorTerm']) {
|
|
1265
1385
|
if (input[key] === undefined)
|
|
1266
1386
|
continue;
|
|
@@ -1416,6 +1536,22 @@ const plugin = definePlugin({
|
|
|
1416
1536
|
}
|
|
1417
1537
|
},
|
|
1418
1538
|
}));
|
|
1539
|
+
// 已安装 shell 候选(设置卡片「Shell 路径」可选可输入):loopback 围栏,只回路径不执行
|
|
1540
|
+
disposers.push(webServer.register({
|
|
1541
|
+
kind: 'exact',
|
|
1542
|
+
path: '/api/dsh-tty/shells',
|
|
1543
|
+
handler: async (req, res) => {
|
|
1544
|
+
if (!isLoopbackHttp(req)) {
|
|
1545
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (req.method !== 'GET') {
|
|
1549
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
writeJson(res, 200, { ok: true, shells: listCandidateShells(), current: process.env.SHELL ?? '' });
|
|
1553
|
+
},
|
|
1554
|
+
}));
|
|
1419
1555
|
// 端口转发隧道实时状态(设置卡片轮询徽标 + tunnel_list 工具数据源)
|
|
1420
1556
|
disposers.push(webServer.register({
|
|
1421
1557
|
kind: 'exact',
|
|
@@ -1432,6 +1568,228 @@ const plugin = definePlugin({
|
|
|
1432
1568
|
writeJson(res, 200, { ok: true, tunnels: tunnelManager.list() });
|
|
1433
1569
|
},
|
|
1434
1570
|
}));
|
|
1571
|
+
// SFTP 文件传输(0.7.0,src/sftp.ts):loopback 围栏;spec 解析与 WS ssh
|
|
1572
|
+
// 帧同款(mergeSshSpec:连接簿条目作基底 + 内联字段覆盖)。list/mkdir/
|
|
1573
|
+
// rename/remove/download 走 JSON 体(凭证不进 URL/查询串);download 响应
|
|
1574
|
+
// 为文件字节流(stat 成功时带 content-length);upload 以 x-dsh-sftp-meta
|
|
1575
|
+
// 头携带 base64url(JSON)(spec + path + append),请求体即原始文件字节,
|
|
1576
|
+
// pipeline 直灌 SFTP 写流——上传下载都不整文件进内存。
|
|
1577
|
+
disposers.push(webServer.register({
|
|
1578
|
+
kind: 'prefix',
|
|
1579
|
+
path: '/api/dsh-tty/sftp',
|
|
1580
|
+
handler: async (req, res) => {
|
|
1581
|
+
if (!isLoopbackHttp(req)) {
|
|
1582
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/sftp'.length);
|
|
1586
|
+
const jsonAction = ['/list', '/mkdir', '/rename', '/remove', '/download'].find((action) => action === sub);
|
|
1587
|
+
if (jsonAction !== undefined) {
|
|
1588
|
+
if (req.method !== 'POST') {
|
|
1589
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const body = await readJsonBody(req);
|
|
1593
|
+
if (body === undefined) {
|
|
1594
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
const parsed = mergeSshSpec((name) => live.findSshHost(name), body.name, body);
|
|
1598
|
+
if (parsed.spec === undefined) {
|
|
1599
|
+
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
const spec = parsed.spec;
|
|
1603
|
+
const strField = (key) => (typeof body[key] === 'string' ? body[key].trim() : '');
|
|
1604
|
+
try {
|
|
1605
|
+
if (jsonAction === '/list') {
|
|
1606
|
+
const result = await sftpManager.list(spec, strField('path'));
|
|
1607
|
+
writeJson(res, 200, { ok: true, path: result.path, entries: result.entries });
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (jsonAction === '/mkdir') {
|
|
1611
|
+
if (strField('path') === '')
|
|
1612
|
+
throw new Error('path 必填');
|
|
1613
|
+
await sftpManager.mkdir(spec, strField('path'), body.parents === true);
|
|
1614
|
+
writeJson(res, 200, { ok: true });
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (jsonAction === '/rename') {
|
|
1618
|
+
if (strField('from') === '' || strField('to') === '')
|
|
1619
|
+
throw new Error('from/to 必填');
|
|
1620
|
+
await sftpManager.rename(spec, strField('from'), strField('to'));
|
|
1621
|
+
writeJson(res, 200, { ok: true });
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (jsonAction === '/remove') {
|
|
1625
|
+
if (strField('path') === '')
|
|
1626
|
+
throw new Error('path 必填');
|
|
1627
|
+
await sftpManager.remove(spec, strField('path'), body.recursive === true);
|
|
1628
|
+
writeJson(res, 200, { ok: true });
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
// download:路径必填(无 home 兜底),流式回包
|
|
1632
|
+
const target = strField('path');
|
|
1633
|
+
if (target === '')
|
|
1634
|
+
throw new Error('path 必填');
|
|
1635
|
+
const { stream, size } = await sftpManager.openDownload(spec, target);
|
|
1636
|
+
res.writeHead(200, {
|
|
1637
|
+
'content-type': 'application/octet-stream',
|
|
1638
|
+
'content-disposition': contentDispositionValue(remoteBasename(target)),
|
|
1639
|
+
...(size !== null ? { 'content-length': String(size) } : {}),
|
|
1640
|
+
});
|
|
1641
|
+
// 客户端中断或写流失败都要回收 SFTP 读流,避免连接池通道悬挂
|
|
1642
|
+
res.on('close', () => {
|
|
1643
|
+
if (res.writableEnded !== true)
|
|
1644
|
+
stream.destroy();
|
|
1645
|
+
});
|
|
1646
|
+
stream.on('error', (error) => {
|
|
1647
|
+
ctx.logger.warn('[dsh-tty] sftp 下载流错误: ' + error.message);
|
|
1648
|
+
res.destroy();
|
|
1649
|
+
});
|
|
1650
|
+
stream.pipe(res);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
catch (error) {
|
|
1654
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (sub === '/upload') {
|
|
1659
|
+
if (req.method !== 'POST') {
|
|
1660
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
const metaRaw = req.headers['x-dsh-sftp-meta'];
|
|
1664
|
+
let meta;
|
|
1665
|
+
if (typeof metaRaw === 'string' && metaRaw !== '') {
|
|
1666
|
+
try {
|
|
1667
|
+
const parsedMeta = JSON.parse(Buffer.from(metaRaw, 'base64url').toString('utf8'));
|
|
1668
|
+
if (typeof parsedMeta === 'object' && parsedMeta !== null && !Array.isArray(parsedMeta))
|
|
1669
|
+
meta = parsedMeta;
|
|
1670
|
+
}
|
|
1671
|
+
catch {
|
|
1672
|
+
/* 落到下面的 400 */
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
if (meta === undefined) {
|
|
1676
|
+
writeJson(res, 400, { error: '缺少或非法的 x-dsh-sftp-meta 头' });
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
const target = typeof meta.path === 'string' ? meta.path.trim() : '';
|
|
1680
|
+
if (target === '') {
|
|
1681
|
+
writeJson(res, 400, { error: 'meta.path 必填' });
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const parsed = mergeSshSpec((name) => live.findSshHost(name), meta.name, meta);
|
|
1685
|
+
if (parsed.spec === undefined) {
|
|
1686
|
+
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
try {
|
|
1690
|
+
const { stream, done } = await sftpManager.openUpload(parsed.spec, target, meta.append === true);
|
|
1691
|
+
let bytes = 0;
|
|
1692
|
+
req.on('data', (chunk) => {
|
|
1693
|
+
bytes += chunk.length;
|
|
1694
|
+
});
|
|
1695
|
+
await pipeline(req, stream);
|
|
1696
|
+
await done;
|
|
1697
|
+
writeJson(res, 200, { ok: true, bytes });
|
|
1698
|
+
}
|
|
1699
|
+
catch (error) {
|
|
1700
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1701
|
+
if (res.headersSent)
|
|
1702
|
+
res.destroy();
|
|
1703
|
+
else
|
|
1704
|
+
writeJson(res, 500, { error: message });
|
|
1705
|
+
}
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
writeJson(res, 404, { error: 'not found: ' + sub });
|
|
1709
|
+
},
|
|
1710
|
+
}));
|
|
1711
|
+
// 本机文件浏览(0.9.0,双栏 SFTP 的本机一侧):loopback 围栏。信任模型
|
|
1712
|
+
// 与终端/SFTP 一致——浏览器仅同源可访问,且本机能做的 SSH 会话也能做;
|
|
1713
|
+
// list/mkdir/rename/remove 操作本机路径;transfer 在服务端把本机路径与
|
|
1714
|
+
// 远程路径流式对拷(凭证不落浏览器,字节不经过浏览器)。
|
|
1715
|
+
disposers.push(webServer.register({
|
|
1716
|
+
kind: 'prefix',
|
|
1717
|
+
path: '/api/dsh-tty/local-fs',
|
|
1718
|
+
handler: async (req, res) => {
|
|
1719
|
+
if (!isLoopbackHttp(req)) {
|
|
1720
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice('/api/dsh-tty/local-fs'.length);
|
|
1724
|
+
if (req.method !== 'POST') {
|
|
1725
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
const body = await readJsonBody(req);
|
|
1729
|
+
if (body === undefined) {
|
|
1730
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
const strField = (key) => (typeof body[key] === 'string' ? body[key].trim() : '');
|
|
1734
|
+
try {
|
|
1735
|
+
if (sub === '/list') {
|
|
1736
|
+
const result = await listLocalDir(strField('path'));
|
|
1737
|
+
writeJson(res, 200, { ok: true, path: result.path, entries: result.entries });
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
if (sub === '/mkdir') {
|
|
1741
|
+
const target = strField('path');
|
|
1742
|
+
if (target === '')
|
|
1743
|
+
throw new Error('path 必填');
|
|
1744
|
+
await fsMkdir(target, { recursive: body.parents === true });
|
|
1745
|
+
writeJson(res, 200, { ok: true });
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
if (sub === '/rename') {
|
|
1749
|
+
const from = strField('from');
|
|
1750
|
+
const to = strField('to');
|
|
1751
|
+
if (from === '' || to === '')
|
|
1752
|
+
throw new Error('from/to 必填');
|
|
1753
|
+
await fsRename(from, to);
|
|
1754
|
+
writeJson(res, 200, { ok: true });
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
if (sub === '/remove') {
|
|
1758
|
+
const target = strField('path');
|
|
1759
|
+
if (target === '')
|
|
1760
|
+
throw new Error('path 必填');
|
|
1761
|
+
await fsRm(target, { recursive: body.recursive === true });
|
|
1762
|
+
writeJson(res, 200, { ok: true });
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
if (sub === '/transfer') {
|
|
1766
|
+
// up = 本机→远程(上传),down = 远程→本机(下载);目录递归
|
|
1767
|
+
const direction = strField('direction');
|
|
1768
|
+
const localPath = strField('localPath');
|
|
1769
|
+
const remotePath = strField('remotePath');
|
|
1770
|
+
if (localPath === '' || remotePath === '')
|
|
1771
|
+
throw new Error('localPath/remotePath 必填');
|
|
1772
|
+
if (direction !== 'up' && direction !== 'down')
|
|
1773
|
+
throw new Error('direction 必须是 up 或 down');
|
|
1774
|
+
const parsed = mergeSshSpec((name) => live.findSshHost(name), body.name, body);
|
|
1775
|
+
if (parsed.spec === undefined) {
|
|
1776
|
+
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if (direction === 'up')
|
|
1780
|
+
await sftpManager.uploadFromLocal(parsed.spec, localPath, remotePath);
|
|
1781
|
+
else
|
|
1782
|
+
await sftpManager.downloadToLocal(parsed.spec, remotePath, localPath);
|
|
1783
|
+
writeJson(res, 200, { ok: true });
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
writeJson(res, 404, { error: 'not found: ' + sub });
|
|
1787
|
+
}
|
|
1788
|
+
catch (error) {
|
|
1789
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1790
|
+
}
|
|
1791
|
+
},
|
|
1792
|
+
}));
|
|
1435
1793
|
return () => {
|
|
1436
1794
|
server.close();
|
|
1437
1795
|
for (const dispose of disposers) {
|
|
@@ -1474,6 +1832,8 @@ const plugin = definePlugin({
|
|
|
1474
1832
|
}
|
|
1475
1833
|
if (stored.shellIntegration === false)
|
|
1476
1834
|
startup.shellIntegration = false;
|
|
1835
|
+
if (stored.sftpStyle === 'dual')
|
|
1836
|
+
startup.sftpStyle = 'dual';
|
|
1477
1837
|
const storedHosts = sanitizeSshHosts(stored.sshHosts);
|
|
1478
1838
|
if (storedHosts !== undefined && storedHosts.length > 0)
|
|
1479
1839
|
startup.sshHosts = storedHosts;
|
|
@@ -1806,8 +2166,334 @@ const plugin = definePlugin({
|
|
|
1806
2166
|
return { tunnels: tunnelManager.list().map((t) => ({ ...t, error: t.error ?? undefined })) };
|
|
1807
2167
|
},
|
|
1808
2168
|
})));
|
|
2169
|
+
// —— SFTP 文件传输工具(0.7.0)——
|
|
2170
|
+
// 只收连接簿条目名(book),不接受内联凭证:agent 上下文不进明文密钥;
|
|
2171
|
+
// 连接与终端/隧道共用同一 HostKeyStore(TOFU 同源)。
|
|
2172
|
+
const sftpBookSpec = (book) => {
|
|
2173
|
+
if (typeof book !== 'string' || book.trim() === '')
|
|
2174
|
+
throw new Error('book 必须是 SSH 连接簿条目名');
|
|
2175
|
+
const entry = live.findSshHost(book.trim());
|
|
2176
|
+
if (entry === undefined)
|
|
2177
|
+
throw new Error(`连接簿中不存在: ${book.trim()}`);
|
|
2178
|
+
return entry;
|
|
2179
|
+
};
|
|
2180
|
+
disposers.push(tools.register(defineTool({
|
|
2181
|
+
name: 'sftp_list',
|
|
2182
|
+
description: '列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。用于查找远程文件、确认上传下载结果。',
|
|
2183
|
+
parameters: {
|
|
2184
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
|
|
2185
|
+
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
2186
|
+
},
|
|
2187
|
+
output: {
|
|
2188
|
+
schema: {
|
|
2189
|
+
type: 'object',
|
|
2190
|
+
additionalProperties: false,
|
|
2191
|
+
properties: {
|
|
2192
|
+
path: { type: 'string', required: true },
|
|
2193
|
+
entries: {
|
|
2194
|
+
type: 'array',
|
|
2195
|
+
required: true,
|
|
2196
|
+
items: {
|
|
2197
|
+
type: 'object',
|
|
2198
|
+
additionalProperties: false,
|
|
2199
|
+
properties: {
|
|
2200
|
+
name: { type: 'string', required: true },
|
|
2201
|
+
isDir: { type: 'boolean', required: true },
|
|
2202
|
+
size: { type: 'number', required: true },
|
|
2203
|
+
mtime: { type: 'number', required: true },
|
|
2204
|
+
},
|
|
2205
|
+
},
|
|
2206
|
+
},
|
|
2207
|
+
},
|
|
2208
|
+
},
|
|
2209
|
+
render: (_args, value) => {
|
|
2210
|
+
const v = value;
|
|
2211
|
+
const entries = v.entries ?? [];
|
|
2212
|
+
if (entries.length === 0)
|
|
2213
|
+
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
2214
|
+
const text = `远程目录 ${v.path ?? '?'}(${String(entries.length)} 项):` + entries.map((e) => `\n- ${e.name}${e.isDir ? '/' : ''} — ${e.isDir ? '目录' : humanFileSize(e.size)}`).join('');
|
|
2215
|
+
return [{ type: 'text', text }];
|
|
2216
|
+
},
|
|
2217
|
+
},
|
|
2218
|
+
async execute(args) {
|
|
2219
|
+
const input = args;
|
|
2220
|
+
const spec = sftpBookSpec(input.book);
|
|
2221
|
+
const result = await sftpManager.list(spec, typeof input.path === 'string' ? input.path : '');
|
|
2222
|
+
return { path: result.path, entries: result.entries.map((e) => ({ name: e.name, isDir: e.isDir, size: e.size, mtime: e.mtime })) };
|
|
2223
|
+
},
|
|
2224
|
+
})));
|
|
2225
|
+
disposers.push(tools.register(defineTool({
|
|
2226
|
+
name: 'sftp_read',
|
|
2227
|
+
description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB),超出截断;检测到 NUL 字节按二进制文件拒绝。适合查看远程配置、小日志。',
|
|
2228
|
+
parameters: {
|
|
2229
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2230
|
+
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
2231
|
+
maxBytes: { type: 'number', description: '最大读取字节数(1~1048576,默认 262144)' },
|
|
2232
|
+
},
|
|
2233
|
+
output: {
|
|
2234
|
+
schema: {
|
|
2235
|
+
type: 'object',
|
|
2236
|
+
additionalProperties: false,
|
|
2237
|
+
properties: {
|
|
2238
|
+
path: { type: 'string', required: true },
|
|
2239
|
+
content: { type: 'string', required: true },
|
|
2240
|
+
truncated: { type: 'boolean', required: true },
|
|
2241
|
+
},
|
|
2242
|
+
},
|
|
2243
|
+
render: (_args, value) => {
|
|
2244
|
+
const v = value;
|
|
2245
|
+
const head = `远程文件 ${v.path ?? '?'}${v.truncated === true ? '(已截断)' : ''}:`;
|
|
2246
|
+
return [{ type: 'text', text: head + '\n' + String(v.content ?? '') }];
|
|
2247
|
+
},
|
|
2248
|
+
},
|
|
2249
|
+
async execute(args) {
|
|
2250
|
+
const input = args;
|
|
2251
|
+
const spec = sftpBookSpec(input.book);
|
|
2252
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2253
|
+
throw new Error('path 必须是非空字符串');
|
|
2254
|
+
const maxBytes = typeof input.maxBytes === 'number' && Number.isInteger(input.maxBytes) && input.maxBytes >= 1 && input.maxBytes <= 1024 * 1024 ? input.maxBytes : 256 * 1024;
|
|
2255
|
+
const { stream } = await sftpManager.openDownload(spec, input.path);
|
|
2256
|
+
const chunks = [];
|
|
2257
|
+
let total = 0;
|
|
2258
|
+
try {
|
|
2259
|
+
for await (const chunk of stream) {
|
|
2260
|
+
const piece = chunk;
|
|
2261
|
+
chunks.push(piece);
|
|
2262
|
+
total += piece.length;
|
|
2263
|
+
if (total > maxBytes)
|
|
2264
|
+
break; // 只多读一段用于判定截断,其余丢弃
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
finally {
|
|
2268
|
+
stream.destroy();
|
|
2269
|
+
}
|
|
2270
|
+
const buf = Buffer.concat(chunks);
|
|
2271
|
+
const truncated = buf.length > maxBytes;
|
|
2272
|
+
const sliced = truncated ? buf.subarray(0, maxBytes) : buf;
|
|
2273
|
+
if (sliced.includes(0))
|
|
2274
|
+
throw new Error('疑似二进制文件(含 NUL 字节),sftp_read 只支持文本内容');
|
|
2275
|
+
return { path: input.path.trim(), content: sliced.toString('utf8'), truncated };
|
|
2276
|
+
},
|
|
2277
|
+
})));
|
|
2278
|
+
disposers.push(tools.register(defineTool({
|
|
2279
|
+
name: 'sftp_write',
|
|
2280
|
+
description: '写 SSH 远程文本文件(book 连接簿条目 + path + content)。默认覆盖写入,append:true 追加到文件尾;单次最多 1MB。适合远程写配置、落结果文件。',
|
|
2281
|
+
parameters: {
|
|
2282
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2283
|
+
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
2284
|
+
content: { type: 'string', required: true, description: '要写入的文本内容(≤1MB)' },
|
|
2285
|
+
append: { type: 'boolean', description: 'true 追加到文件尾(默认覆盖)' },
|
|
2286
|
+
},
|
|
2287
|
+
output: {
|
|
2288
|
+
schema: {
|
|
2289
|
+
type: 'object',
|
|
2290
|
+
additionalProperties: false,
|
|
2291
|
+
properties: {
|
|
2292
|
+
ok: { type: 'boolean', required: true },
|
|
2293
|
+
path: { type: 'string', required: true },
|
|
2294
|
+
bytes: { type: 'number', required: true },
|
|
2295
|
+
append: { type: 'boolean', required: true },
|
|
2296
|
+
},
|
|
2297
|
+
},
|
|
2298
|
+
render: (_args, value) => {
|
|
2299
|
+
const v = value;
|
|
2300
|
+
return [{ type: 'text', text: `已${v.append === true ? '追加' : '写入'}远程文件 ${v.path ?? '?'}(${String(v.bytes ?? 0)} 字节)` }];
|
|
2301
|
+
},
|
|
2302
|
+
},
|
|
2303
|
+
async execute(args) {
|
|
2304
|
+
const input = args;
|
|
2305
|
+
const spec = sftpBookSpec(input.book);
|
|
2306
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2307
|
+
throw new Error('path 必须是非空字符串');
|
|
2308
|
+
if (typeof input.content !== 'string')
|
|
2309
|
+
throw new Error('content 必须是字符串');
|
|
2310
|
+
const bytes = Buffer.byteLength(input.content, 'utf8');
|
|
2311
|
+
if (bytes > 1024 * 1024)
|
|
2312
|
+
throw new Error(`content 超过上限:${String(bytes)} 字节 > 1MB(大文件请用终端 scp 或面板上传)`);
|
|
2313
|
+
const append = input.append === true;
|
|
2314
|
+
const { stream, done } = await sftpManager.openUpload(spec, input.path, append);
|
|
2315
|
+
stream.write(input.content, 'utf8');
|
|
2316
|
+
stream.end();
|
|
2317
|
+
await done;
|
|
2318
|
+
return { ok: true, path: input.path.trim(), bytes, append };
|
|
2319
|
+
},
|
|
2320
|
+
})));
|
|
2321
|
+
// —— SFTP 管理闭环(0.8.0)——
|
|
2322
|
+
// mkdir(可逐级补齐)/ rename(可跨目录,等效移动)/ remove(目录
|
|
2323
|
+
// 递归)/ tree(限深限数的递归列举),与 sftp_list/read/write 一起
|
|
2324
|
+
// 让 agent 不开面板也能完整管理远程文件;同样只收连接簿条目名。
|
|
2325
|
+
disposers.push(tools.register(defineTool({
|
|
2326
|
+
name: 'sftp_mkdir',
|
|
2327
|
+
description: '在 SSH 远程创建目录(book 连接簿条目 + path)。parents:true 时逐级补齐缺失的父目录(等效 mkdir -p,默认 false,父目录缺失直接报错)。',
|
|
2328
|
+
parameters: {
|
|
2329
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
|
|
2330
|
+
path: { type: 'string', required: true, description: '要创建的远程目录路径' },
|
|
2331
|
+
parents: { type: 'boolean', description: 'true 逐级补齐缺失父目录(默认 false)' },
|
|
2332
|
+
},
|
|
2333
|
+
output: {
|
|
2334
|
+
schema: {
|
|
2335
|
+
type: 'object',
|
|
2336
|
+
additionalProperties: false,
|
|
2337
|
+
properties: {
|
|
2338
|
+
ok: { type: 'boolean', required: true },
|
|
2339
|
+
path: { type: 'string', required: true },
|
|
2340
|
+
},
|
|
2341
|
+
},
|
|
2342
|
+
render: (_args, value) => {
|
|
2343
|
+
const v = value;
|
|
2344
|
+
return [{ type: 'text', text: `已创建远程目录 ${v.path ?? '?'}` }];
|
|
2345
|
+
},
|
|
2346
|
+
},
|
|
2347
|
+
async execute(args) {
|
|
2348
|
+
const input = args;
|
|
2349
|
+
const spec = sftpBookSpec(input.book);
|
|
2350
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2351
|
+
throw new Error('path 必须是非空字符串');
|
|
2352
|
+
await sftpManager.mkdir(spec, input.path, input.parents === true);
|
|
2353
|
+
return { ok: true, path: input.path.trim() };
|
|
2354
|
+
},
|
|
2355
|
+
})));
|
|
2356
|
+
disposers.push(tools.register(defineTool({
|
|
2357
|
+
name: 'sftp_rename',
|
|
2358
|
+
description: '在 SSH 远程重命名 / 移动文件或目录(book 连接簿条目 + from + to)。to 与 from 不同目录即为移动(目标目录需已存在);不会覆盖已存在的目标(服务端 rename 语义)。',
|
|
2359
|
+
parameters: {
|
|
2360
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2361
|
+
from: { type: 'string', required: true, description: '原远程路径' },
|
|
2362
|
+
to: { type: 'string', required: true, description: '新远程路径(跨目录即移动)' },
|
|
2363
|
+
},
|
|
2364
|
+
output: {
|
|
2365
|
+
schema: {
|
|
2366
|
+
type: 'object',
|
|
2367
|
+
additionalProperties: false,
|
|
2368
|
+
properties: {
|
|
2369
|
+
ok: { type: 'boolean', required: true },
|
|
2370
|
+
from: { type: 'string', required: true },
|
|
2371
|
+
to: { type: 'string', required: true },
|
|
2372
|
+
},
|
|
2373
|
+
},
|
|
2374
|
+
render: (_args, value) => {
|
|
2375
|
+
const v = value;
|
|
2376
|
+
return [{ type: 'text', text: `已将远程 ${v.from ?? '?'} 重命名/移动为 ${v.to ?? '?'}` }];
|
|
2377
|
+
},
|
|
2378
|
+
},
|
|
2379
|
+
async execute(args) {
|
|
2380
|
+
const input = args;
|
|
2381
|
+
const spec = sftpBookSpec(input.book);
|
|
2382
|
+
if (typeof input.from !== 'string' || input.from.trim() === '')
|
|
2383
|
+
throw new Error('from 必须是非空字符串');
|
|
2384
|
+
if (typeof input.to !== 'string' || input.to.trim() === '')
|
|
2385
|
+
throw new Error('to 必须是非空字符串');
|
|
2386
|
+
await sftpManager.rename(spec, input.from, input.to);
|
|
2387
|
+
return { ok: true, from: input.from.trim(), to: input.to.trim() };
|
|
2388
|
+
},
|
|
2389
|
+
})));
|
|
2390
|
+
disposers.push(tools.register(defineTool({
|
|
2391
|
+
name: 'sftp_remove',
|
|
2392
|
+
description: '删除 SSH 远程文件或目录(book 连接簿条目 + path)。文件直接删除;目录默认走 rmdir(非空明确报错),recursive:true 整目录递归删除(不可恢复,谨慎使用)。',
|
|
2393
|
+
parameters: {
|
|
2394
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2395
|
+
path: { type: 'string', required: true, description: '要删除的远程路径' },
|
|
2396
|
+
recursive: { type: 'boolean', description: '目录 true 时递归删除全部内容(默认 false)' },
|
|
2397
|
+
},
|
|
2398
|
+
output: {
|
|
2399
|
+
schema: {
|
|
2400
|
+
type: 'object',
|
|
2401
|
+
additionalProperties: false,
|
|
2402
|
+
properties: {
|
|
2403
|
+
ok: { type: 'boolean', required: true },
|
|
2404
|
+
path: { type: 'string', required: true },
|
|
2405
|
+
recursive: { type: 'boolean', required: true },
|
|
2406
|
+
},
|
|
2407
|
+
},
|
|
2408
|
+
render: (_args, value) => {
|
|
2409
|
+
const v = value;
|
|
2410
|
+
return [{ type: 'text', text: `已删除远程 ${v.path ?? '?'}${v.recursive === true ? '(含全部内容)' : ''}` }];
|
|
2411
|
+
},
|
|
2412
|
+
},
|
|
2413
|
+
async execute(args) {
|
|
2414
|
+
const input = args;
|
|
2415
|
+
const spec = sftpBookSpec(input.book);
|
|
2416
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
2417
|
+
throw new Error('path 必须是非空字符串');
|
|
2418
|
+
const recursive = input.recursive === true;
|
|
2419
|
+
await sftpManager.remove(spec, input.path, recursive);
|
|
2420
|
+
return { ok: true, path: input.path.trim(), recursive };
|
|
2421
|
+
},
|
|
2422
|
+
})));
|
|
2423
|
+
disposers.push(tools.register(defineTool({
|
|
2424
|
+
name: 'sftp_tree',
|
|
2425
|
+
description: '递归列举 SSH 远程目录结构(book 连接簿条目 + path):深度优先、目录优先,maxDepth(1~8,默认 3)限层、maxEntries(1~2000,默认 500)限条数,超限 truncated:true;符号链接不跟随;读取失败的子目录列入 errors。适合先看远程项目结构再定位文件。',
|
|
2426
|
+
parameters: {
|
|
2427
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
2428
|
+
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
2429
|
+
maxDepth: { type: 'number', description: '最大下钻层数(1~8,默认 3)' },
|
|
2430
|
+
maxEntries: { type: 'number', description: '最大条目数(1~2000,默认 500)' },
|
|
2431
|
+
},
|
|
2432
|
+
output: {
|
|
2433
|
+
schema: {
|
|
2434
|
+
type: 'object',
|
|
2435
|
+
additionalProperties: false,
|
|
2436
|
+
properties: {
|
|
2437
|
+
path: { type: 'string', required: true },
|
|
2438
|
+
entries: {
|
|
2439
|
+
type: 'array',
|
|
2440
|
+
required: true,
|
|
2441
|
+
items: {
|
|
2442
|
+
type: 'object',
|
|
2443
|
+
additionalProperties: false,
|
|
2444
|
+
properties: {
|
|
2445
|
+
path: { type: 'string', required: true },
|
|
2446
|
+
name: { type: 'string', required: true },
|
|
2447
|
+
depth: { type: 'number', required: true },
|
|
2448
|
+
isDir: { type: 'boolean', required: true },
|
|
2449
|
+
size: { type: 'number', required: true },
|
|
2450
|
+
mtime: { type: 'number', required: true },
|
|
2451
|
+
},
|
|
2452
|
+
},
|
|
2453
|
+
},
|
|
2454
|
+
truncated: { type: 'boolean', required: true },
|
|
2455
|
+
errors: {
|
|
2456
|
+
type: 'array',
|
|
2457
|
+
required: true,
|
|
2458
|
+
items: {
|
|
2459
|
+
type: 'object',
|
|
2460
|
+
additionalProperties: false,
|
|
2461
|
+
properties: {
|
|
2462
|
+
path: { type: 'string', required: true },
|
|
2463
|
+
message: { type: 'string', required: true },
|
|
2464
|
+
},
|
|
2465
|
+
},
|
|
2466
|
+
},
|
|
2467
|
+
},
|
|
2468
|
+
},
|
|
2469
|
+
render: (_args, value) => {
|
|
2470
|
+
const v = value;
|
|
2471
|
+
const entries = v.entries ?? [];
|
|
2472
|
+
if (entries.length === 0)
|
|
2473
|
+
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
2474
|
+
const head = `远程目录 ${v.path ?? '?'} 结构(${String(entries.length)} 项${v.truncated === true ? ',已截断' : ''}):`;
|
|
2475
|
+
const lines = entries.map((e) => {
|
|
2476
|
+
const indent = ' '.repeat(Math.max(0, e.depth - 1));
|
|
2477
|
+
const tail = e.isDir ? '/' : ' — ' + humanFileSize(e.size);
|
|
2478
|
+
return `${indent}- ${e.name}${tail}`;
|
|
2479
|
+
});
|
|
2480
|
+
for (const item of v.errors ?? [])
|
|
2481
|
+
lines.push(`! ${item.path}(${item.message})`);
|
|
2482
|
+
return [{ type: 'text', text: head + '\n' + lines.join('\n') }];
|
|
2483
|
+
},
|
|
2484
|
+
},
|
|
2485
|
+
async execute(args) {
|
|
2486
|
+
const input = args;
|
|
2487
|
+
const spec = sftpBookSpec(input.book);
|
|
2488
|
+
const result = await sftpManager.tree(spec, typeof input.path === 'string' ? input.path : '', {
|
|
2489
|
+
maxDepth: typeof input.maxDepth === 'number' && Number.isInteger(input.maxDepth) ? input.maxDepth : undefined,
|
|
2490
|
+
maxEntries: typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) ? input.maxEntries : undefined,
|
|
2491
|
+
});
|
|
2492
|
+
return result;
|
|
2493
|
+
},
|
|
2494
|
+
})));
|
|
1809
2495
|
stateRef.toolsRegistered = true;
|
|
1810
|
-
console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list)');
|
|
2496
|
+
console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
|
|
1811
2497
|
return () => {
|
|
1812
2498
|
stateRef.toolsRegistered = false;
|
|
1813
2499
|
for (const dispose of disposers) {
|
|
@@ -1857,11 +2543,12 @@ const plugin = definePlugin({
|
|
|
1857
2543
|
}, REAPER_INTERVAL_MS);
|
|
1858
2544
|
reaperTimer.unref?.();
|
|
1859
2545
|
ctx.effect(() => () => clearInterval(reaperTimer), 'dsh-tty: orphan reaper');
|
|
1860
|
-
//
|
|
2546
|
+
// 插件卸载时回收全部会话、隧道与 SFTP 连接
|
|
1861
2547
|
ctx.effect(() => {
|
|
1862
2548
|
return () => {
|
|
1863
2549
|
void sessions.disposeAll();
|
|
1864
2550
|
tunnelManager.disposeAll();
|
|
2551
|
+
sftpManager.disposeAll();
|
|
1865
2552
|
};
|
|
1866
2553
|
}, 'dsh-tty: session cleanup');
|
|
1867
2554
|
console.log(`[dsh-tty] mounted (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue})`);
|