@zhangfengshun/dsh-remote-ssh 1.2.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/lib/index.js ADDED
@@ -0,0 +1,691 @@
1
+ /**
2
+ * dsh-remote-ssh — Host 半边(静态 DSH Web 插件)
3
+ *
4
+ * 能力:
5
+ * 1. 远程连接配置 + 远程工作区持久化到 DSH settings 命名空间 `dsh-remote-ssh`
6
+ * (schemastery schema,密码字段 role('secret') 在描述时脱敏)。
7
+ * 2. 通过 SSH 提供文件列举 / 读取 / 写入 / 执行 / 集成终端(ssh -tt 管道通道)。
8
+ * 3. 暴露 HTTP JSON API(/remote-ssh/api/*)给 Client 半边。
9
+ * 4. 注册 5 个模型工具(remote_ssh_*)。
10
+ */
11
+ import z from "schemastery";
12
+ import { defineTool } from "@deepseek-ai/dsh-tools";
13
+ import { settingsNamespace } from "@deepseek-ai/dsh-settings";
14
+ import { mkdir, rm, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ /** Plugin identity for cordis.yml rows. */
19
+ const name = "dsh-remote-ssh";
20
+ /** Services required before mounting. */
21
+ const inject = ["webServer", "subprocess", "tools"];
22
+ /** Composition config schema(本插件暂无配置项)。 */
23
+ const Config = z.object({});
24
+
25
+ const NS = "dsh-remote-ssh";
26
+ const MAX_BYTES = 4 * 1024 * 1024;
27
+ const API_BASE = "/remote-ssh/api/";
28
+
29
+ /** 连接配置 schema(存于 settings)。 */
30
+ const ProfileSchema = z.object({
31
+ id: z.string(),
32
+ name: z.string(),
33
+ host: z.string(),
34
+ port: z.number().default(22),
35
+ user: z.string(),
36
+ authMethod: z.string().default("key"),
37
+ keyPath: z.string().default(""),
38
+ password: z.string().default("").role("secret"),
39
+ remoteRoot: z.string().default("~")
40
+ });
41
+ /** 远程工作区 schema(连接配置 + 远程根目录 + 本地镜像目录)。 */
42
+ const WorkspaceSchema = z.object({
43
+ id: z.string(),
44
+ profileId: z.string(),
45
+ title: z.string(),
46
+ remotePath: z.string(),
47
+ mirrorPath: z.string().default("")
48
+ });
49
+ /** settings 命名空间的完整 schema。 */
50
+ const PrefsSchema = z.object({
51
+ profiles: z.array(ProfileSchema).default([]),
52
+ workspaces: z.array(WorkspaceSchema).default([])
53
+ });
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // 通用工具
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function shellQuote(s) {
60
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
61
+ }
62
+
63
+ /** 引用远程路径:开头 `~`/`~/` 保留给远程 shell 展开为家目录,其余单引号防注入。 */
64
+ function shellQuotePath(s) {
65
+ const str = String(s);
66
+ if (str === "~") return "~";
67
+ if (str.startsWith("~/")) return "~/" + shellQuote(str.slice(2));
68
+ return shellQuote(str);
69
+ }
70
+
71
+ function sshArgv(p, remoteCmd, tty) {
72
+ const opts = ["ssh"];
73
+ if (tty) opts.push("-tt");
74
+ opts.push("-p", String(p.port || 22));
75
+ opts.push("-o", "StrictHostKeyChecking=accept-new");
76
+ opts.push("-o", "ConnectTimeout=15");
77
+ opts.push("-o", "ServerAliveInterval=30");
78
+ opts.push("-o", "ServerAliveCountMax=3");
79
+ if (p.authMethod === "key" && p.keyPath) opts.push("-i", p.keyPath);
80
+ const target = String(p.user || "") + "@" + String(p.host || "");
81
+ const head = (p.authMethod === "password" && p.password) ? ["sshpass", "-p", String(p.password)] : [];
82
+ const argv = head.concat(opts, [target]);
83
+ if (remoteCmd !== undefined) argv.push(remoteCmd);
84
+ return argv;
85
+ }
86
+
87
+ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes) {
88
+ const max = maxBytes || MAX_BYTES;
89
+ let handle;
90
+ try {
91
+ handle = subprocess.spawn({
92
+ argv: sshArgv(p, remoteCmd, false),
93
+ cwd: process.cwd(),
94
+ stdio: {
95
+ stdin: stdinData !== undefined ? { data: String(stdinData) } : "ignore",
96
+ stdout: { maxBytes: max, spill: { maxBytes: max } },
97
+ stderr: { maxBytes: max, spill: { maxBytes: max } }
98
+ },
99
+ graceMs: 3000
100
+ });
101
+ } catch (e) {
102
+ return { ok: false, error: "spawn 失败: " + String(e && e.message ? e.message : e) };
103
+ }
104
+ let outcome;
105
+ try {
106
+ outcome = await handle.done;
107
+ } catch (e) {
108
+ return { ok: false, error: "执行失败: " + String(e && e.message ? e.message : e) };
109
+ }
110
+ const so = (handle.collected && handle.collected.stdout) ? handle.collected.stdout.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
111
+ const se = (handle.collected && handle.collected.stderr) ? handle.collected.stderr.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
112
+ const outText = so.text;
113
+ const errText = se.text;
114
+ let error = "";
115
+ if (outcome.exitCode !== 0 && !outText && !errText) {
116
+ error = "ssh 退出码 " + outcome.exitCode + (outcome.signal ? " (signal " + outcome.signal + ")" : "");
117
+ }
118
+ return {
119
+ ok: outcome.exitCode === 0,
120
+ exitCode: outcome.exitCode,
121
+ signal: outcome.signal,
122
+ stdout: outText,
123
+ stderr: errText,
124
+ truncated: !!(so.lossy || se.lossy),
125
+ error: error
126
+ };
127
+ }
128
+
129
+ async function remoteListDir(subprocess, p, path) {
130
+ const target = path || p.remoteRoot || "~";
131
+ const script = "cd " + shellQuotePath(target) + " 2>/dev/null || { echo '__DSH_ERR__ cannot cd'; exit 1; }; find . -maxdepth 1 -mindepth 1 -printf '%Y\\t%f\\t%s\\n' 2>/dev/null | sort";
132
+ const r = await runRemote(subprocess, p, script, undefined, MAX_BYTES);
133
+ if (!r.ok) return { ok: false, error: (r.error || r.stderr || "").trim() || "读取目录失败" };
134
+ const entries = [];
135
+ const lines = String(r.stdout || "").split("\n");
136
+ for (let i = 0; i < lines.length; i++) {
137
+ const line = lines[i];
138
+ if (!line) continue;
139
+ const parts = line.split("\t");
140
+ if (parts.length < 2) continue;
141
+ const nm = parts[1];
142
+ if (nm === "." || nm === "..") continue;
143
+ entries.push({ name: nm, type: parts[0] === "d" ? "directory" : "file", size: parts[2] ? (parseInt(parts[2], 10) || 0) : 0 });
144
+ }
145
+ entries.sort(function (a, b) {
146
+ return a.type === b.type ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : (a.type === "directory" ? -1 : 1);
147
+ });
148
+ return { ok: true, path: target, entries: entries };
149
+ }
150
+
151
+ async function remoteReadFile(subprocess, p, path) {
152
+ if (!path) return { ok: false, error: "path 为必填项" };
153
+ const r = await runRemote(subprocess, p, "base64 -w0 " + shellQuotePath(path), undefined, MAX_BYTES);
154
+ if (!r.ok) return r;
155
+ let text = "";
156
+ let binary = false;
157
+ try {
158
+ const bytes = new Uint8Array(Buffer.from(String(r.stdout).trim(), "base64"));
159
+ const first = bytes.subarray(0, 8000);
160
+ for (let i = 0; i < first.length; i++) { if (first[i] === 0) { binary = true; break; } }
161
+ text = new TextDecoder().decode(bytes);
162
+ } catch (e) {
163
+ return { ok: false, error: "解码失败: " + String(e) };
164
+ }
165
+ return { ok: true, path: path, content: text, binary: binary, truncated: r.truncated };
166
+ }
167
+
168
+ async function remoteWriteFile(subprocess, p, path, content) {
169
+ if (!path) return { ok: false, error: "path 为必填项" };
170
+ const c = content !== undefined ? String(content) : "";
171
+ return await runRemote(subprocess, p, "cat > " + shellQuotePath(path), c, MAX_BYTES);
172
+ }
173
+
174
+ function normExec(r) {
175
+ return {
176
+ ok: !!r.ok,
177
+ exitCode: (typeof r.exitCode === "number") ? r.exitCode : -1,
178
+ stdout: r.stdout || "",
179
+ stderr: r.stderr || "",
180
+ error: r.error || "",
181
+ truncated: !!r.truncated
182
+ };
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // HTTP 工具
187
+ // ---------------------------------------------------------------------------
188
+
189
+ function isTrusted(req) {
190
+ const secFetchSite = req.headers["sec-fetch-site"];
191
+ if (secFetchSite === "cross-site") return false;
192
+ const host = req.headers["host"];
193
+ if (!host) return false;
194
+ const hostname = String(host).split(":")[0].replace(/^\[/, "").replace(/\]$/, "");
195
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0") return true;
196
+ return false;
197
+ }
198
+
199
+ async function readJsonBody(req) {
200
+ const chunks = [];
201
+ let total = 0;
202
+ for await (const chunk of req) {
203
+ const buffer = Buffer.from(chunk);
204
+ total += buffer.length;
205
+ if (total > MAX_BYTES) throw new Error("request body too large");
206
+ chunks.push(buffer);
207
+ }
208
+ const text = Buffer.concat(chunks).toString("utf8");
209
+ if (text.trim() === "") return {};
210
+ try {
211
+ return JSON.parse(text);
212
+ } catch {
213
+ throw new Error("request body is not valid JSON");
214
+ }
215
+ }
216
+
217
+ function writeJson(res, status, body) {
218
+ const payload = JSON.stringify(body);
219
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
220
+ res.end(payload);
221
+ }
222
+ function writeOk(res, value) { writeJson(res, 200, { ok: true, value: value }); }
223
+ function writeError(res, error, status) {
224
+ writeJson(res, status || 500, { ok: false, error: { code: "error", message: String(error && error.message ? error.message : error) } });
225
+ }
226
+
227
+ function textRender(fn) {
228
+ return function (args, value) { return [{ type: "text", text: fn(args, value) }]; };
229
+ }
230
+
231
+ function execSchema() {
232
+ return {
233
+ type: "object",
234
+ additionalProperties: false,
235
+ properties: {
236
+ ok: { type: "boolean", required: true },
237
+ exitCode: { type: "number", required: true },
238
+ stdout: { type: "string", required: true },
239
+ stderr: { type: "string", required: true },
240
+ error: { type: "string", required: true },
241
+ truncated: { type: "boolean", required: true }
242
+ }
243
+ };
244
+ }
245
+
246
+ const CONN_PARAMS = {
247
+ profileId: { type: "string", description: "已保存连接配置的 id(见 remote_ssh_profiles)。" },
248
+ host: { type: "string", description: "远程主机(未提供 profileId 时使用)。" },
249
+ user: { type: "string", description: "SSH 用户名。" },
250
+ port: { type: "number", description: "SSH 端口,默认 22。" },
251
+ keyPath: { type: "string", description: "SSH 私钥路径(密钥认证)。" }
252
+ };
253
+
254
+ /** 写入远程工作区镜像目录的说明文件,提示模型使用 remote_ssh_* 工具操作远程环境。 */
255
+ function remoteWorkspaceReadme(profileName, remotePath) {
256
+ return [
257
+ "# 🌐 Remote Workspace / 远程工作区",
258
+ "",
259
+ "This directory is the **local mirror** of a remote workspace (registered as a native DSH",
260
+ "workspace). Remote files are NOT stored here. Use the Remote-SSH tools to operate the remote",
261
+ "environment directly. / 这是远程工作区的本地镜像目录(用于注册原生 DSH 工作区),远程文件并不在本机,",
262
+ "请使用以下 Remote-SSH 工具直接操作远程环境:",
263
+ "",
264
+ "- `remote_ssh_ls` — list remote directories / 列举远程目录",
265
+ "- `remote_ssh_cat` — read a remote file / 读取远程文件",
266
+ "- `remote_ssh_write` — write a remote file / 写入远程文件",
267
+ "- `remote_ssh_exec` — run a command on the remote host (like a terminal) / 在远程执行命令",
268
+ "- `remote_ssh_profiles` — list profiles and the current remote-workspace context / 查看配置与当前上下文",
269
+ "",
270
+ "Workspace info / 工作区信息:",
271
+ "",
272
+ "- Profile / 连接配置:`" + (profileName || "?") + "`",
273
+ "- Remote root / 远程根目录:`" + (remotePath || "~") + "`",
274
+ "",
275
+ "> In this workspace session you can call these tools **without** `profileId`; the workspace's",
276
+ "> profile and directory are used automatically, and relative paths resolve against the remote root. /",
277
+ "> 在本工作区会话中调用上述工具时**无需**提供 `profileId`,会自动使用本工作区的连接与目录,相对路径基于远程根目录解析。"
278
+ ].join("\n");
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // apply
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function apply(ctx, config) {
286
+ const subprocess = ctx.subprocess;
287
+ const workspaceRegistry = ctx.get("workspaceRegistry");
288
+ const terminals = new Map();
289
+ let nextTerminalId = 1;
290
+
291
+ // ---- settings 持久化 ----
292
+ let settingsFace = {
293
+ read: () => ({ profiles: [], workspaces: [] }),
294
+ updateProfiles: async () => {},
295
+ updateWorkspaces: async () => {}
296
+ };
297
+ ctx.inject(["settings"], (sctx) => {
298
+ const scope = sctx.settings.register(settingsNamespace(NS), PrefsSchema);
299
+ settingsFace = {
300
+ read: () => {
301
+ const v = scope.get();
302
+ return {
303
+ profiles: Array.isArray(v && v.profiles) ? v.profiles : [],
304
+ workspaces: Array.isArray(v && v.workspaces) ? v.workspaces : []
305
+ };
306
+ },
307
+ updateProfiles: async (profiles) => { await scope.update({ profiles: profiles }); },
308
+ updateWorkspaces: async (workspaces) => { await scope.update({ workspaces: workspaces }); }
309
+ };
310
+ });
311
+
312
+ function getProfile(id) { return settingsFace.read().profiles.find((p) => p.id === id); }
313
+ function resolveProfile(args) {
314
+ if (args && args.profileId) {
315
+ const p = getProfile(args.profileId);
316
+ if (p) return p;
317
+ }
318
+ if (args && args.host && args.user) {
319
+ return { id: "__inline__", name: args.host, host: args.host, port: args.port || 22, user: args.user, authMethod: "key", keyPath: args.keyPath || "", password: "", remoteRoot: "~" };
320
+ }
321
+ return null;
322
+ }
323
+
324
+ /** 从工具执行上下文推断当前会话所属的远程工作区(镜像目录 == 会话 cwd)。 */
325
+ function remoteContextFor(exec) {
326
+ try {
327
+ const session = exec && exec.agent && exec.agent.session;
328
+ const cwd = session && session.header && session.header.cwd;
329
+ if (!cwd) return null;
330
+ const workspaces = settingsFace.read().workspaces;
331
+ for (let i = 0; i < workspaces.length; i++) {
332
+ const w = workspaces[i];
333
+ if (w.mirrorPath === cwd) return w;
334
+ if (w.mirrorPath && String(w.mirrorPath).toLowerCase() === String(cwd).toLowerCase()) return w;
335
+ }
336
+ } catch (e) {}
337
+ return null;
338
+ }
339
+
340
+ /** 解析工具的连接+工作目录上下文:显式参数优先,其次当前会话的远程工作区。 */
341
+ function toolContext(args, exec) {
342
+ const explicit = resolveProfile(args);
343
+ if (explicit) return { profile: explicit, remotePath: undefined };
344
+ const rc = remoteContextFor(exec);
345
+ if (rc) return { profile: getProfile(rc.profileId), remotePath: rc.remotePath };
346
+ return { profile: null, remotePath: undefined };
347
+ }
348
+
349
+ /** 相对路径拼到远程工作目录下,绝对路径(/、~、盘符)原样返回。 */
350
+ function resolveRemotePath(path, base) {
351
+ if (!base) return path || "";
352
+ if (!path) return base;
353
+ const ch = String(path).charAt(0);
354
+ if (ch === "/" || ch === "~" || /^[A-Za-z]:[\\/]/.test(String(path))) return String(path);
355
+ return base.replace(/\/+$/, "") + "/" + String(path);
356
+ }
357
+
358
+ const api = {
359
+ listProfiles: async () => ({ ok: true, profiles: settingsFace.read().profiles }),
360
+ saveProfile: async (args) => {
361
+ const p = args || {};
362
+ if (!p.host || !p.user) return { ok: false, error: "host 和 user 为必填项" };
363
+ const profiles = settingsFace.read().profiles.slice();
364
+ let id = p.id;
365
+ const existing = id ? profiles.find((x) => x.id === id) : undefined;
366
+ const record = {
367
+ id: existing ? existing.id : ("p" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)),
368
+ name: p.name || p.host,
369
+ host: p.host,
370
+ port: parseInt(p.port, 10) || 22,
371
+ user: p.user,
372
+ authMethod: p.authMethod || "key",
373
+ keyPath: p.keyPath || "",
374
+ password: p.password || "",
375
+ remoteRoot: p.remoteRoot || "~"
376
+ };
377
+ if (existing) {
378
+ const i = profiles.indexOf(existing);
379
+ profiles[i] = record;
380
+ } else {
381
+ profiles.push(record);
382
+ }
383
+ await settingsFace.updateProfiles(profiles);
384
+ return { ok: true, id: record.id, profiles: profiles };
385
+ },
386
+ deleteProfile: async (args) => {
387
+ const id = args && args.id;
388
+ const profiles = settingsFace.read().profiles.filter((p) => p.id !== id);
389
+ const workspaces = settingsFace.read().workspaces.filter((w) => w.profileId !== id);
390
+ await settingsFace.updateProfiles(profiles);
391
+ await settingsFace.updateWorkspaces(workspaces);
392
+ return { ok: true, profiles: profiles, workspaces: workspaces };
393
+ },
394
+ testConnection: async (args) => {
395
+ const p = getProfile(args && args.id);
396
+ if (!p) return { ok: false, error: "未找到连接配置" };
397
+ return await runRemote(subprocess, p, "echo __DSH_OK__; hostname; whoami; pwd; uname -a", undefined, 256 * 1024);
398
+ },
399
+ listWorkspaces: async () => ({ ok: true, workspaces: settingsFace.read().workspaces }),
400
+ createRemoteWorkspace: async (args) => {
401
+ const a = args || {};
402
+ const p = getProfile(a.profileId);
403
+ if (!p) return { ok: false, error: "未找到连接配置" };
404
+ if (!a.remotePath) return { ok: false, error: "remotePath 为必填项" };
405
+ const wsId = "w" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
406
+ const title = a.title || (p.name + ":" + a.remotePath);
407
+ // 本地镜像目录:作为原生工作区注册进 workspaceRegistry,会话 cwd 即指向它。
408
+ const mirrorDir = join(homedir(), ".dsh", "remote-workspaces", wsId);
409
+ try {
410
+ await mkdir(mirrorDir, { recursive: true });
411
+ } catch (e) {
412
+ return { ok: false, error: "创建本地镜像目录失败: " + String(e && e.message ? e.message : e) };
413
+ }
414
+ // 在镜像目录里放一份说明,提示模型本工作区是远程的、应使用 remote_ssh_* 工具。
415
+ try {
416
+ await writeFile(join(mirrorDir, "README.md"), remoteWorkspaceReadme(p.name, a.remotePath), "utf8");
417
+ } catch (e) {}
418
+ let workspaceId = null;
419
+ if (workspaceRegistry) {
420
+ try {
421
+ const native = await workspaceRegistry.create(mirrorDir, "🌐 " + title);
422
+ workspaceId = native.id;
423
+ } catch (e) {
424
+ return { ok: false, error: "注册原生工作区失败: " + String(e && e.message ? e.message : e) };
425
+ }
426
+ }
427
+ const workspaces = settingsFace.read().workspaces.slice();
428
+ const ws = {
429
+ id: wsId,
430
+ profileId: a.profileId,
431
+ title: title,
432
+ remotePath: a.remotePath,
433
+ mirrorPath: mirrorDir
434
+ };
435
+ workspaces.push(ws);
436
+ await settingsFace.updateWorkspaces(workspaces);
437
+ return { ok: true, workspace: ws, workspaceId: workspaceId, mirrorPath: mirrorDir, workspaces: workspaces };
438
+ },
439
+ deleteWorkspace: async (args) => {
440
+ const id = args && args.id;
441
+ const all = settingsFace.read().workspaces;
442
+ const ws = all.find((w) => w.id === id);
443
+ const workspaces = all.filter((w) => w.id !== id);
444
+ await settingsFace.updateWorkspaces(workspaces);
445
+ if (ws && ws.mirrorPath) {
446
+ if (workspaceRegistry) {
447
+ try {
448
+ const entity = await workspaceRegistry.resolveByPath(ws.mirrorPath);
449
+ if (entity) await workspaceRegistry.delete(entity.id);
450
+ } catch (e) {}
451
+ }
452
+ try { await rm(ws.mirrorPath, { recursive: true, force: true }); } catch (e) {}
453
+ }
454
+ return { ok: true, workspaces: workspaces };
455
+ },
456
+ updateWorkspace: async (args) => {
457
+ const a = args || {};
458
+ const id = a.id;
459
+ const workspaces = settingsFace.read().workspaces.slice();
460
+ const ws = workspaces.find((w) => w.id === id);
461
+ if (!ws) return { ok: false, error: "未找到远程工作区" };
462
+ if (a.remotePath !== undefined && a.remotePath !== "") ws.remotePath = String(a.remotePath);
463
+ if (a.title !== undefined && a.title !== "") ws.title = String(a.title);
464
+ await settingsFace.updateWorkspaces(workspaces);
465
+ return { ok: true, workspace: ws, workspaces: workspaces };
466
+ },
467
+ remoteExec: async (args) => {
468
+ const p = resolveProfile(args);
469
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
470
+ if (!args || !args.command) return { ok: false, error: "command 为必填项" };
471
+ return await runRemote(subprocess, p, args.command, args.stdin, undefined);
472
+ },
473
+ listDir: async (args) => {
474
+ const p = resolveProfile(args);
475
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
476
+ return await remoteListDir(subprocess, p, args && args.path);
477
+ },
478
+ readFile: async (args) => {
479
+ const p = resolveProfile(args);
480
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
481
+ return await remoteReadFile(subprocess, p, args && args.path);
482
+ },
483
+ writeFile: async (args) => {
484
+ const p = resolveProfile(args);
485
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
486
+ return await remoteWriteFile(subprocess, p, args && args.path, args && args.content);
487
+ },
488
+ spawnTerminal: async (args) => {
489
+ const p = resolveProfile(args);
490
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
491
+ let handle;
492
+ try {
493
+ handle = subprocess.spawn({
494
+ argv: sshArgv(p, undefined, true),
495
+ cwd: process.cwd(),
496
+ stdio: {
497
+ stdin: "pipe",
498
+ stdout: { maxBytes: MAX_BYTES, spill: { maxBytes: MAX_BYTES } },
499
+ stderr: { maxBytes: 1024 * 1024, spill: { maxBytes: 1024 * 1024 } }
500
+ },
501
+ graceMs: 3000
502
+ });
503
+ } catch (e) {
504
+ return { ok: false, error: "终端启动失败: " + String(e && e.message ? e.message : e) };
505
+ }
506
+ const id = "t" + (nextTerminalId++);
507
+ const session = { id: id, handle: handle, profileId: p.id, stdoutOffset: 0, stderrOffset: 0, status: "running", exitCode: null };
508
+ handle.done.then(
509
+ function (outcome) { session.status = "exited"; session.exitCode = outcome.exitCode; },
510
+ function (err) { session.status = "exited"; session.error = String(err); }
511
+ );
512
+ terminals.set(id, session);
513
+ return { ok: true, id: id };
514
+ },
515
+ terminalWrite: async (args) => {
516
+ const s = terminals.get(args && args.id);
517
+ if (!s) return { ok: false, error: "终端不存在" };
518
+ if (!s.handle.stdin) return { ok: false, error: "stdin 不可用" };
519
+ try { s.handle.stdin.write(String(args && args.data !== undefined ? args.data : "")); } catch (e) { return { ok: false, error: String(e) }; }
520
+ return { ok: true };
521
+ },
522
+ terminalRead: async (args) => {
523
+ const s = terminals.get(args && args.id);
524
+ if (!s) return { ok: false, error: "终端不存在" };
525
+ const out = (s.handle.collected && s.handle.collected.stdout) ? s.handle.collected.stdout.readFrom(s.stdoutOffset) : { text: "", nextOffset: s.stdoutOffset, lossy: false };
526
+ const err = (s.handle.collected && s.handle.collected.stderr) ? s.handle.collected.stderr.readFrom(s.stderrOffset) : { text: "", nextOffset: s.stderrOffset, lossy: false };
527
+ s.stdoutOffset = out.nextOffset;
528
+ s.stderrOffset = err.nextOffset;
529
+ return { ok: true, data: out.text, stderr: err.text, status: s.status, exitCode: s.exitCode, truncated: !!(out.lossy || err.lossy) };
530
+ },
531
+ terminalClose: async (args) => {
532
+ const s = terminals.get(args && args.id);
533
+ if (!s) return { ok: false, error: "终端不存在" };
534
+ try { s.handle.terminate(); } catch (e) {}
535
+ terminals.delete(args.id);
536
+ return { ok: true };
537
+ },
538
+ terminalList: async () => {
539
+ const list = [];
540
+ terminals.forEach(function (s) { list.push({ id: s.id, profileId: s.profileId, status: s.status, exitCode: s.exitCode }); });
541
+ return { ok: true, terminals: list };
542
+ }
543
+ };
544
+
545
+ // ---- HTTP JSON API ----
546
+ ctx.effect(() => ctx.webServer.register({
547
+ kind: "prefix",
548
+ path: "/remote-ssh/api",
549
+ handler: async (req, res) => {
550
+ if (!isTrusted(req)) { writeError(res, new Error("forbidden"), 403); return; }
551
+ if (req.method !== "POST") { writeError(res, new Error("method not allowed"), 405); return; }
552
+ const pathname = new URL(req.url || "/", "http://dsh.internal").pathname;
553
+ if (!pathname.startsWith(API_BASE)) { writeError(res, new Error("not-found"), 404); return; }
554
+ const method = pathname.slice(API_BASE.length);
555
+ if (!method || method.includes("/")) { writeError(res, new Error("not-found"), 404); return; }
556
+ try {
557
+ const handler = api[method];
558
+ if (!handler) throw new Error("unknown api method: " + method);
559
+ const payload = await readJsonBody(req);
560
+ const result = await handler(payload);
561
+ writeOk(res, result);
562
+ } catch (error) {
563
+ writeError(res, error);
564
+ }
565
+ }
566
+ }), "dsh-remote-ssh: /remote-ssh/api routes");
567
+
568
+ // ---- 模型工具 ----
569
+ const register = (tool) => ctx.tools.register(defineTool(tool));
570
+
571
+ register({
572
+ name: "remote_ssh_profiles",
573
+ description: "列出 Remote-SSH 插件中已保存的 SSH 连接配置,并返回当前会话所属的远程工作区上下文(若当前会话是从远程工作区创建的)。List saved SSH connection profiles and return the current session's remote-workspace context (when the session was created from a remote workspace).",
574
+ parameters: {},
575
+ output: {
576
+ schema: {
577
+ type: "object", additionalProperties: false, properties: {
578
+ profiles: { type: "array", required: true, items: { type: "object", additionalProperties: true } },
579
+ currentRemote: { type: "object", required: true, additionalProperties: true }
580
+ }
581
+ },
582
+ render: textRender(function (a, v) { return JSON.stringify(v); })
583
+ },
584
+ execute: async function (args, exec) {
585
+ const rc = remoteContextFor(exec);
586
+ return {
587
+ profiles: settingsFace.read().profiles,
588
+ currentRemote: rc ? { id: rc.id, profileId: rc.profileId, title: rc.title, remotePath: rc.remotePath, mirrorPath: rc.mirrorPath } : {}
589
+ };
590
+ }
591
+ });
592
+
593
+ register({
594
+ name: "remote_ssh_exec",
595
+ description: "通过 SSH 在远程主机(超算/服务器)上执行一条命令。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,自动用该工作区的连接并在其远程目录下执行。返回 stdout/stderr/exitCode。Run a command on a remote host (HPC/server) over SSH; returns stdout/stderr/exitCode.",
596
+ parameters: Object.assign({}, CONN_PARAMS, {
597
+ command: { type: "string", required: true, description: "要执行的远程命令。" },
598
+ stdin: { type: "string", description: "可选,写入远程命令的标准输入。" }
599
+ }),
600
+ output: {
601
+ schema: execSchema(),
602
+ render: textRender(function (a, v) { return v.ok ? (v.stdout || "ok") : (v.error || v.stderr || v.stdout || "failed"); })
603
+ },
604
+ execute: async function (args, exec) {
605
+ const tc = toolContext(args, exec);
606
+ if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
607
+ if (!args.command) return normExec({ ok: false, error: "command 为必填项" });
608
+ let cmd = String(args.command);
609
+ if (tc.remotePath) cmd = "cd " + shellQuotePath(tc.remotePath) + " 2>/dev/null; " + cmd;
610
+ return normExec(await runRemote(subprocess, tc.profile, cmd, args.stdin, undefined));
611
+ }
612
+ });
613
+
614
+ register({
615
+ name: "remote_ssh_ls",
616
+ description: "通过 SSH 列举远程主机上的一个目录。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,相对路径基于该工作区远程目录解析;缺省 path 即列出该目录。List a directory on a remote host over SSH.",
617
+ parameters: Object.assign({}, CONN_PARAMS, { path: { type: "string", description: "远程目录路径,默认使用配置的远程根目录(当前工作区为工作区目录)。" } }),
618
+ output: {
619
+ schema: {
620
+ type: "object", additionalProperties: false, properties: {
621
+ ok: { type: "boolean", required: true },
622
+ path: { type: "string", required: true },
623
+ entries: { type: "array", required: true, items: { type: "object", additionalProperties: true } },
624
+ error: { type: "string", required: true }
625
+ }
626
+ },
627
+ render: textRender(function (a, v) {
628
+ if (!v.ok) return v.error || "failed";
629
+ const names = (v.entries || []).map(function (e) { return (e.type === "directory" ? "[d] " : " ") + e.name; });
630
+ return v.path + "\n" + names.join("\n");
631
+ })
632
+ },
633
+ execute: async function (args, exec) {
634
+ const tc = toolContext(args, exec);
635
+ if (!tc.profile) return { ok: false, path: "", entries: [], error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" };
636
+ const r = await remoteListDir(subprocess, tc.profile, resolveRemotePath(args.path, tc.remotePath));
637
+ return { ok: !!r.ok, path: r.path || "", entries: r.entries || [], error: r.error || "" };
638
+ }
639
+ });
640
+
641
+ register({
642
+ name: "remote_ssh_cat",
643
+ description: "通过 SSH 读取远程主机上的一个文本文件(base64 传输,二进制安全)。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,相对路径基于该工作区远程目录解析。Read a text file from a remote host over SSH (base64 transfer, binary-safe).",
644
+ parameters: Object.assign({}, CONN_PARAMS, { path: { type: "string", required: true, description: "远程文件路径。" } }),
645
+ output: {
646
+ schema: {
647
+ type: "object", additionalProperties: false, properties: {
648
+ ok: { type: "boolean", required: true },
649
+ path: { type: "string", required: true },
650
+ content: { type: "string", required: true },
651
+ binary: { type: "boolean", required: true },
652
+ truncated: { type: "boolean", required: true },
653
+ error: { type: "string", required: true }
654
+ }
655
+ },
656
+ render: textRender(function (a, v) { return v.error || v.content || ""; })
657
+ },
658
+ execute: async function (args, exec) {
659
+ const tc = toolContext(args, exec);
660
+ if (!tc.profile) return { ok: false, path: "", content: "", binary: false, truncated: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" };
661
+ const r = await remoteReadFile(subprocess, tc.profile, resolveRemotePath(args.path, tc.remotePath));
662
+ return { ok: !!r.ok, path: r.path || "", content: r.content || "", binary: !!r.binary, truncated: !!r.truncated, error: r.error || "" };
663
+ }
664
+ });
665
+
666
+ register({
667
+ name: "remote_ssh_write",
668
+ description: "通过 SSH 把内容写入远程主机上的一个文件(覆盖写入)。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,相对路径基于该工作区远程目录解析。Write content to a file on a remote host over SSH (overwrite).",
669
+ parameters: Object.assign({}, CONN_PARAMS, {
670
+ path: { type: "string", required: true, description: "远程文件路径。" },
671
+ content: { type: "string", required: true, description: "要写入的完整内容。" }
672
+ }),
673
+ output: {
674
+ schema: execSchema(),
675
+ render: textRender(function (a, v) { return v.ok ? "已写入" : (v.error || v.stderr || "写入失败"); })
676
+ },
677
+ execute: async function (args, exec) {
678
+ const tc = toolContext(args, exec);
679
+ if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
680
+ return normExec(await remoteWriteFile(subprocess, tc.profile, resolveRemotePath(args.path, tc.remotePath), args.content));
681
+ }
682
+ });
683
+
684
+ // ---- 清理 ----
685
+ ctx.effect(() => () => {
686
+ terminals.forEach(function (s) { try { s.handle.terminate(); } catch (e) {} });
687
+ terminals.clear();
688
+ });
689
+ }
690
+
691
+ export { Config, apply, inject, name };