@linxin666/dsh-ssh 0.2.8 → 0.3.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.i18n.yaml +2 -2
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +4 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +67 -38
- package/lib/types/http.d.ts +42 -0
- package/lib/types/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/panel/SshPanel.tsx +4 -2
- package/src/client/panel/panel.module.css +6 -5
- package/src/http.ts +105 -0
- package/src/index.ts +1 -1
- package/src/routes.ts +9 -36
- package/src/tools.ts +7 -7
package/lib/index.js
CHANGED
|
@@ -2226,6 +2226,63 @@ var SshEngine = class {
|
|
|
2226
2226
|
}
|
|
2227
2227
|
};
|
|
2228
2228
|
//#endregion
|
|
2229
|
+
//#region src/http.ts
|
|
2230
|
+
/** Default body cap for readJsonBody: 64 KiB. */
|
|
2231
|
+
const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
|
|
2232
|
+
/** Family-default JSON response headers; callers may append or override. */
|
|
2233
|
+
const JSON_HEADERS = {
|
|
2234
|
+
"content-type": "application/json; charset=utf-8",
|
|
2235
|
+
"referrer-policy": "no-referrer"
|
|
2236
|
+
};
|
|
2237
|
+
/**
|
|
2238
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
2239
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
2240
|
+
* Overflow destroys the request instead of draining the remainder (no drain
|
|
2241
|
+
* call, matching the current repo-wide behavior); callers must not keep
|
|
2242
|
+
* reading the request afterwards. With objectOnly, non-JSON-object payloads
|
|
2243
|
+
* also yield null.
|
|
2244
|
+
*/
|
|
2245
|
+
async function readJsonBody(req, opts = {}) {
|
|
2246
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES;
|
|
2247
|
+
const chunks = [];
|
|
2248
|
+
let size = 0;
|
|
2249
|
+
for await (const chunk of req) {
|
|
2250
|
+
const buffer = chunk;
|
|
2251
|
+
size += buffer.length;
|
|
2252
|
+
if (size > maxBytes) {
|
|
2253
|
+
req.destroy();
|
|
2254
|
+
return null;
|
|
2255
|
+
}
|
|
2256
|
+
chunks.push(buffer);
|
|
2257
|
+
}
|
|
2258
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
2259
|
+
if (text === "") return null;
|
|
2260
|
+
try {
|
|
2261
|
+
const parsed = JSON.parse(text);
|
|
2262
|
+
if (opts.objectOnly && !isJsonObject(parsed)) return null;
|
|
2263
|
+
return parsed;
|
|
2264
|
+
} catch {
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
/** Whether a value is a JSON object: typeof object, not null, not an array. */
|
|
2269
|
+
function isJsonObject(value) {
|
|
2270
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Write one JSON response. Default headers are the family defaults
|
|
2274
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
2275
|
+
* override them.
|
|
2276
|
+
*/
|
|
2277
|
+
function writeJson(res, status, body, headers = {}) {
|
|
2278
|
+
const payload = JSON.stringify(body);
|
|
2279
|
+
res.writeHead(status, {
|
|
2280
|
+
...JSON_HEADERS,
|
|
2281
|
+
...headers
|
|
2282
|
+
});
|
|
2283
|
+
res.end(payload);
|
|
2284
|
+
}
|
|
2285
|
+
//#endregion
|
|
2229
2286
|
//#region src/loopback.ts
|
|
2230
2287
|
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
2231
2288
|
function isIPv4Loopback(v4) {
|
|
@@ -2294,8 +2351,6 @@ const SSH_API = {
|
|
|
2294
2351
|
* these endpoints execute commands on remote servers, so LAN-exposed dsh web
|
|
2295
2352
|
* deployments must not serve them.
|
|
2296
2353
|
*/
|
|
2297
|
-
/** Cap on JSON request bodies (host entries and exec payloads are small). */
|
|
2298
|
-
const MAX_JSON_BODY_BYTES = 64 * 1024;
|
|
2299
2354
|
/** Cap on declared upload bodies (staged to disk before SFTP). */
|
|
2300
2355
|
const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;
|
|
2301
2356
|
/**
|
|
@@ -2308,32 +2363,6 @@ const terminalWss = new WebSocketServer({ noServer: true });
|
|
|
2308
2363
|
const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
|
|
2309
2364
|
/** …and resume once it drains below this. */
|
|
2310
2365
|
const BACKPRESSURE_LOW_WATER = 512 * 1024;
|
|
2311
|
-
/** One JSON response. */
|
|
2312
|
-
function writeJson(res, status, body) {
|
|
2313
|
-
const payload = JSON.stringify(body);
|
|
2314
|
-
res.writeHead(status, {
|
|
2315
|
-
"content-type": "application/json; charset=utf-8",
|
|
2316
|
-
"referrer-policy": "no-referrer"
|
|
2317
|
-
});
|
|
2318
|
-
res.end(payload);
|
|
2319
|
-
}
|
|
2320
|
-
/** Read a JSON request body (undefined when too large or unparseable). */
|
|
2321
|
-
async function readJsonBody(req) {
|
|
2322
|
-
const chunks = [];
|
|
2323
|
-
let size = 0;
|
|
2324
|
-
for await (const chunk of req) {
|
|
2325
|
-
const buffer = chunk;
|
|
2326
|
-
size += buffer.length;
|
|
2327
|
-
if (size > MAX_JSON_BODY_BYTES) return void 0;
|
|
2328
|
-
chunks.push(buffer);
|
|
2329
|
-
}
|
|
2330
|
-
try {
|
|
2331
|
-
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2332
|
-
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
2333
|
-
} catch {
|
|
2334
|
-
return;
|
|
2335
|
-
}
|
|
2336
|
-
}
|
|
2337
2366
|
/** URL query helper (first value, decoded). */
|
|
2338
2367
|
function queryParam(url, name) {
|
|
2339
2368
|
const value = url.searchParams.get(name);
|
|
@@ -2382,7 +2411,7 @@ function makeRoutes(deps) {
|
|
|
2382
2411
|
}
|
|
2383
2412
|
if (method === "POST") {
|
|
2384
2413
|
const body = await readJsonBody(req);
|
|
2385
|
-
if (body ===
|
|
2414
|
+
if (body === null) {
|
|
2386
2415
|
writeJson(res, 400, { error: "invalid JSON body" });
|
|
2387
2416
|
return;
|
|
2388
2417
|
}
|
|
@@ -2405,7 +2434,7 @@ function makeRoutes(deps) {
|
|
|
2405
2434
|
}
|
|
2406
2435
|
if (method === "PATCH") {
|
|
2407
2436
|
const body = await readJsonBody(req);
|
|
2408
|
-
if (body ===
|
|
2437
|
+
if (body === null) {
|
|
2409
2438
|
writeJson(res, 400, { error: "invalid JSON body" });
|
|
2410
2439
|
return;
|
|
2411
2440
|
}
|
|
@@ -2982,7 +3011,7 @@ function sshListTool(engine) {
|
|
|
2982
3011
|
function sshExecTool(engine) {
|
|
2983
3012
|
return defineTool({
|
|
2984
3013
|
name: "ssh_exec",
|
|
2985
|
-
description: "Execute a command on a
|
|
3014
|
+
description: "Execute a shell command on a REMOTE SSH host by alias; the command runs on the remote host, never on this machine. For commands on this machine, use the local bash tool. Prefer combining independent read-only queries into one command. Triggers: run command on server, deploy, check server/status, service control, view logs, any remote operation.",
|
|
2986
3015
|
parameters: {
|
|
2987
3016
|
alias: {
|
|
2988
3017
|
type: "string",
|
|
@@ -3054,7 +3083,7 @@ function sshExecTool(engine) {
|
|
|
3054
3083
|
function sshUploadTool(engine) {
|
|
3055
3084
|
return defineTool({
|
|
3056
3085
|
name: "ssh_upload",
|
|
3057
|
-
description: "
|
|
3086
|
+
description: "Transfer a file FROM this machine (the dsh host) TO a remote SSH host. Use this only when the file must be copied to the remote host. Files that stay on this machine are handled with the local file tools (read / write / edit), not ssh_upload. Triggers: upload file to server, deploy artifact, copy config to server.",
|
|
3058
3087
|
parameters: {
|
|
3059
3088
|
alias: {
|
|
3060
3089
|
type: "string",
|
|
@@ -3064,12 +3093,12 @@ function sshUploadTool(engine) {
|
|
|
3064
3093
|
localPath: {
|
|
3065
3094
|
type: "string",
|
|
3066
3095
|
required: true,
|
|
3067
|
-
description: "Absolute
|
|
3096
|
+
description: "Absolute path of the source file on THIS machine (the dsh host) — not a path on the remote host."
|
|
3068
3097
|
},
|
|
3069
3098
|
remotePath: {
|
|
3070
3099
|
type: "string",
|
|
3071
3100
|
required: true,
|
|
3072
|
-
description: "
|
|
3101
|
+
description: "Absolute destination path on the remote SSH host (parent dirs are created)."
|
|
3073
3102
|
}
|
|
3074
3103
|
},
|
|
3075
3104
|
output: {
|
|
@@ -3109,7 +3138,7 @@ function sshUploadTool(engine) {
|
|
|
3109
3138
|
function sshDownloadTool(engine) {
|
|
3110
3139
|
return defineTool({
|
|
3111
3140
|
name: "ssh_download",
|
|
3112
|
-
description: "
|
|
3141
|
+
description: "Copy a remote FILE from a configured SSH host to this machine (the dsh host). Use this only when the source is on the remote host; files already on this machine are read with the local file tools (read / write / edit), not ssh_download. Directory download is not supported — download files individually. Triggers: download file from server, fetch remote log/artifact.",
|
|
3113
3142
|
parameters: {
|
|
3114
3143
|
alias: {
|
|
3115
3144
|
type: "string",
|
|
@@ -3119,12 +3148,12 @@ function sshDownloadTool(engine) {
|
|
|
3119
3148
|
remotePath: {
|
|
3120
3149
|
type: "string",
|
|
3121
3150
|
required: true,
|
|
3122
|
-
description: "
|
|
3151
|
+
description: "Absolute path of the source file on the remote SSH host."
|
|
3123
3152
|
},
|
|
3124
3153
|
localPath: {
|
|
3125
3154
|
type: "string",
|
|
3126
3155
|
required: true,
|
|
3127
|
-
description: "Absolute destination path on
|
|
3156
|
+
description: "Absolute destination path on THIS machine (the dsh host)."
|
|
3128
3157
|
}
|
|
3129
3158
|
},
|
|
3130
3159
|
output: {
|
|
@@ -3489,7 +3518,7 @@ const DEFAULT_ANNOUNCE = false;
|
|
|
3489
3518
|
/** Order of the announcement section within the tool-guidance band. */
|
|
3490
3519
|
const SECTION_ORDER = 150;
|
|
3491
3520
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
3492
|
-
const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600
|
|
3521
|
+
const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
|
|
3493
3522
|
/**
|
|
3494
3523
|
* Mount the SSH engine, routes, tools, and announcement.
|
|
3495
3524
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON body/response helpers for the host route families: one strict
|
|
3
|
+
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
4
|
+
* narrow, and one JSON writer. Previously these were copy-pasted across the
|
|
5
|
+
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
6
|
+
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
7
|
+
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
8
|
+
*
|
|
9
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
10
|
+
* edit this shared source and re-run the sync instead of editing a copy.
|
|
11
|
+
* Consumer code is migrated onto it in follow-up waves; no call site changes
|
|
12
|
+
* belong in the same change as its introduction.
|
|
13
|
+
* @module dsh-web-ui-shared/host/http
|
|
14
|
+
*/
|
|
15
|
+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
|
|
16
|
+
/**
|
|
17
|
+
* Strict bounded body reader: parse a request body of at most maxBytes as
|
|
18
|
+
* JSON.
|
|
19
|
+
* @throws 'body too large' past the cap, or the JSON.parse error for an
|
|
20
|
+
* invalid or empty payload.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
25
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
26
|
+
* Overflow destroys the request instead of draining the remainder (no drain
|
|
27
|
+
* call, matching the current repo-wide behavior); callers must not keep
|
|
28
|
+
* reading the request afterwards. With objectOnly, non-JSON-object payloads
|
|
29
|
+
* also yield null.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readJsonBody(req: IncomingMessage, opts?: {
|
|
32
|
+
maxBytes?: number;
|
|
33
|
+
objectOnly?: boolean;
|
|
34
|
+
}): Promise<unknown | null>;
|
|
35
|
+
/** Narrow a value to a JSON object, or undefined when it is not one. */
|
|
36
|
+
export declare function asJsonObject(value: unknown): Record<string, unknown> | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Write one JSON response. Default headers are the family defaults
|
|
39
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
40
|
+
* override them.
|
|
41
|
+
*/
|
|
42
|
+
export declare function writeJson(res: ServerResponse, status: number, body: unknown, headers?: OutgoingHttpHeaders): void;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface Config {
|
|
|
38
38
|
}
|
|
39
39
|
export declare const Config: z<Config>;
|
|
40
40
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
41
|
-
export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B SSH \u8FD0\u7EF4\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CSSH\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-ssh\uFF09\u7EDF\u4E00\u7EF4\u62A4\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 $DSH_HOME/dsh-ssh.json\uFF08\u9ED8\u8BA4 ~/.dsh\uFF09\uFF08\u53EF\u4ECE ~/.ssh/config \u5BFC\u5165\uFF09\uFF1B\u6301\u4E45\u8FDE\u63A5\u6C60\u590D\u7528\u957F\u8FDE\u63A5\uFF08\u7A7A\u95F2 30 \u5206\u949F\u81EA\u52A8\u65AD\u5F00\uFF09\uFF1Bssh_list \u5217\u51FA\u4E3B\u673A\u3001ssh_exec \u6267\u884C\u8FDC\u7A0B\u547D\u4EE4\u3001ssh_upload/ssh_download \u4F20\u8F93\u6587\u4EF6\u3001ssh_tunnel \u672C\u5730\u7AEF\u53E3\u8F6C\u53D1\uFF08\u8BBF\u95EE\u8FDC\u7A0B\u6570\u636E\u5E93/\u5185\u7F51\u670D\u52A1\uFF09\u3001ssh_cluster \u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1B\u652F\u6301\u5BC6\u94A5/\u5BC6\u7801/ssh-agent \u8BA4\u8BC1\u3001passphrase \u5BC6\u94A5\u4E0E ProxyJump \u8DF3\u677F\u673A\uFF1BWeb \u7EC8\u7AEF\u8D70 WebSocket\u3002\u9650\u5236\uFF1A\u4E3B\u673A\u64CD\u4F5C\u7531\u7528\u6237\u5728 GUI \u4E2D\u914D\u7F6E\u540E agent \u65B9\u53EF\u4F7F\u7528\uFF1B\u5BC6\u7801\u4EE5\u660E\u6587\u5B58\u5728\u7528\u6237\u4E3B\u76EE\u5F55\u79C1\u6709\u6587\u4EF6\uFF08\u6743\u9650 0600\uFF09\uFF1B\u547D\u4EE4\u8F93\u51FA\u539F\u6837\u8FD4\u56DE\u3001\u53EF\u80FD\u542B\u654F\u611F\u4FE1\u606F\uFF1B\u65AD\u7EBF\u91CD\u8FDE\u53EF\u80FD\u91CD\u653E\u975E\u5E42\u7B49\u547D\u4EE4\uFF1B\u4F20\u8F93/\u6267\u884C\u6D88\u8017\u771F\u5B9E\u8FDC\u7A0B\u8D44\u6E90\uFF0C\u5148\u786E\u8BA4\u518D\u64CD\u4F5C\u3002\u7528\u6237\u63D0\u5230\u300CSSH / \u8FDC\u7A0B\u670D\u52A1\u5668 / \u670D\u52A1\u5668\u64CD\u4F5C / \u8DF3\u677F\u673A / \u96A7\u9053 / \u90E8\u7F72 / \u4E0A\u4F20\u4E0B\u8F7D\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
|
|
41
|
+
export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B SSH \u8FD0\u7EF4\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CSSH\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-ssh\uFF09\u7EDF\u4E00\u7EF4\u62A4\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 $DSH_HOME/dsh-ssh.json\uFF08\u9ED8\u8BA4 ~/.dsh\uFF09\uFF08\u53EF\u4ECE ~/.ssh/config \u5BFC\u5165\uFF09\uFF1B\u6301\u4E45\u8FDE\u63A5\u6C60\u590D\u7528\u957F\u8FDE\u63A5\uFF08\u7A7A\u95F2 30 \u5206\u949F\u81EA\u52A8\u65AD\u5F00\uFF09\uFF1Bssh_list \u5217\u51FA\u4E3B\u673A\u3001ssh_exec \u6267\u884C\u8FDC\u7A0B\u547D\u4EE4\u3001ssh_upload/ssh_download \u4F20\u8F93\u6587\u4EF6\u3001ssh_tunnel \u672C\u5730\u7AEF\u53E3\u8F6C\u53D1\uFF08\u8BBF\u95EE\u8FDC\u7A0B\u6570\u636E\u5E93/\u5185\u7F51\u670D\u52A1\uFF09\u3001ssh_cluster \u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1B\u652F\u6301\u5BC6\u94A5/\u5BC6\u7801/ssh-agent \u8BA4\u8BC1\u3001passphrase \u5BC6\u94A5\u4E0E ProxyJump \u8DF3\u677F\u673A\uFF1BWeb \u7EC8\u7AEF\u8D70 WebSocket\u3002\u9650\u5236\uFF1A\u4E3B\u673A\u64CD\u4F5C\u7531\u7528\u6237\u5728 GUI \u4E2D\u914D\u7F6E\u540E agent \u65B9\u53EF\u4F7F\u7528\uFF1B\u5BC6\u7801\u4EE5\u660E\u6587\u5B58\u5728\u7528\u6237\u4E3B\u76EE\u5F55\u79C1\u6709\u6587\u4EF6\uFF08\u6743\u9650 0600\uFF09\uFF1B\u547D\u4EE4\u8F93\u51FA\u539F\u6837\u8FD4\u56DE\u3001\u53EF\u80FD\u542B\u654F\u611F\u4FE1\u606F\uFF1B\u65AD\u7EBF\u91CD\u8FDE\u53EF\u80FD\u91CD\u653E\u975E\u5E42\u7B49\u547D\u4EE4\uFF1B\u4F20\u8F93/\u6267\u884C\u6D88\u8017\u771F\u5B9E\u8FDC\u7A0B\u8D44\u6E90\uFF0C\u5148\u786E\u8BA4\u518D\u64CD\u4F5C\u3002\u8DEF\u5F84\u533A\u5206\uFF1A\u672C\u673A\uFF08dsh host\uFF09\u4E0A\u7684\u6587\u4EF6\u4E0E\u547D\u4EE4\u4E00\u5F8B\u7528\u672C\u5730\u5DE5\u5177\uFF08read / write / edit / bash\uFF09\uFF0Cssh_* \u5DE5\u5177\u53EA\u9488\u5BF9\u8FDC\u7A0B\u4E3B\u673A\u4E0A\u7684\u8DEF\u5F84\u3002\u7528\u6237\u63D0\u5230\u300CSSH / \u8FDC\u7A0B\u670D\u52A1\u5668 / \u670D\u52A1\u5668\u64CD\u4F5C / \u8DF3\u677F\u673A / \u96A7\u9053 / \u90E8\u7F72 / \u4E0A\u4F20\u4E0B\u8F7D\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
|
|
42
42
|
/**
|
|
43
43
|
* Mount the SSH engine, routes, tools, and announcement.
|
|
44
44
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-ssh",
|
|
3
3
|
"description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -55,12 +55,14 @@ export function SshPanel({ controller, api, terminalFont }: SshPanelProps) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
return (
|
|
58
|
-
<div className={css.panel}>
|
|
58
|
+
<div className={css.panel} data-dsh-plugin="ssh">
|
|
59
59
|
<div className={css.panelHeader}>
|
|
60
|
+
{/* Shared hook: dsh-web-ui-all offsets center-view back controls beside the collapsed mobile sidebar. */}
|
|
60
61
|
<button
|
|
61
62
|
type="button"
|
|
62
63
|
className={`${css.ghostButton} ${css.backButton}`}
|
|
63
64
|
aria-label={tt('panel.backToConversation')}
|
|
65
|
+
data-dsh-center-view-back=""
|
|
64
66
|
onClick={() => { controller.close() }}
|
|
65
67
|
>
|
|
66
68
|
<span aria-hidden="true">‹</span>
|
|
@@ -70,7 +72,7 @@ export function SshPanel({ controller, api, terminalFont }: SshPanelProps) {
|
|
|
70
72
|
</div>
|
|
71
73
|
<div className={css.tabBar} role="tablist" data-dsh-part="tab-bar">
|
|
72
74
|
{TABS.map(tab => (
|
|
73
|
-
<button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
|
|
75
|
+
<button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} data-dsh-part="tab" className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
|
|
74
76
|
{tab.label()}
|
|
75
77
|
</button>
|
|
76
78
|
))}
|
|
@@ -66,12 +66,12 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
.entry:hover {
|
|
69
|
-
background: var(--dsw-
|
|
69
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
70
70
|
color: var(--dsw-alias-label-primary);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
.entry[data-active] {
|
|
74
|
-
background: var(--dsw-
|
|
74
|
+
background: var(--dsw-alias-interactive-bg-active);
|
|
75
75
|
color: var(--dsw-alias-label-primary);
|
|
76
76
|
font-weight: 600;
|
|
77
77
|
}
|
|
@@ -100,9 +100,10 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
|
|
|
100
100
|
[data-dsh-frame][data-sidebar-collapsed] .entry {
|
|
101
101
|
justify-content: center;
|
|
102
102
|
padding: 0;
|
|
103
|
-
width:
|
|
104
|
-
min-height:
|
|
105
|
-
margin
|
|
103
|
+
width: 36px;
|
|
104
|
+
min-height: 36px;
|
|
105
|
+
margin: 0 auto 12px;
|
|
106
|
+
border-radius: 50%;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
[data-dsh-frame][data-sidebar-collapsed] .entryLabel {
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Shared JSON body/response helpers for the host route families: one strict
|
|
4
|
+
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
5
|
+
* narrow, and one JSON writer. Previously these were copy-pasted across the
|
|
6
|
+
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
7
|
+
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
8
|
+
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
9
|
+
*
|
|
10
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
11
|
+
* edit this shared source and re-run the sync instead of editing a copy.
|
|
12
|
+
* Consumer code is migrated onto it in follow-up waves; no call site changes
|
|
13
|
+
* belong in the same change as its introduction.
|
|
14
|
+
* @module dsh-web-ui-shared/host/http
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
|
|
18
|
+
|
|
19
|
+
/** Default body cap for readJsonBody: 64 KiB. */
|
|
20
|
+
const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
|
|
21
|
+
|
|
22
|
+
/** Family-default JSON response headers; callers may append or override. */
|
|
23
|
+
const JSON_HEADERS = {
|
|
24
|
+
'content-type': 'application/json; charset=utf-8',
|
|
25
|
+
'referrer-policy': 'no-referrer',
|
|
26
|
+
} satisfies OutgoingHttpHeaders
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strict bounded body reader: parse a request body of at most maxBytes as
|
|
30
|
+
* JSON.
|
|
31
|
+
* @throws 'body too large' past the cap, or the JSON.parse error for an
|
|
32
|
+
* invalid or empty payload.
|
|
33
|
+
*/
|
|
34
|
+
export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
|
|
35
|
+
const chunks: Buffer[] = []
|
|
36
|
+
let size = 0
|
|
37
|
+
for await (const chunk of req) {
|
|
38
|
+
const buffer = chunk as Buffer
|
|
39
|
+
size += buffer.length
|
|
40
|
+
if (size > maxBytes) throw new Error('body too large')
|
|
41
|
+
chunks.push(buffer)
|
|
42
|
+
}
|
|
43
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
48
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
49
|
+
* Overflow destroys the request instead of draining the remainder (no drain
|
|
50
|
+
* call, matching the current repo-wide behavior); callers must not keep
|
|
51
|
+
* reading the request afterwards. With objectOnly, non-JSON-object payloads
|
|
52
|
+
* also yield null.
|
|
53
|
+
*/
|
|
54
|
+
export async function readJsonBody(
|
|
55
|
+
req: IncomingMessage,
|
|
56
|
+
opts: { maxBytes?: number; objectOnly?: boolean } = {},
|
|
57
|
+
): Promise<unknown | null> {
|
|
58
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
|
|
59
|
+
const chunks: Buffer[] = []
|
|
60
|
+
let size = 0
|
|
61
|
+
for await (const chunk of req) {
|
|
62
|
+
const buffer = chunk as Buffer
|
|
63
|
+
size += buffer.length
|
|
64
|
+
if (size > maxBytes) {
|
|
65
|
+
req.destroy()
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
chunks.push(buffer)
|
|
69
|
+
}
|
|
70
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
71
|
+
if (text === '') return null
|
|
72
|
+
try {
|
|
73
|
+
const parsed: unknown = JSON.parse(text)
|
|
74
|
+
if (opts.objectOnly && !isJsonObject(parsed)) return null
|
|
75
|
+
return parsed
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a value is a JSON object: typeof object, not null, not an array. */
|
|
82
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Narrow a value to a JSON object, or undefined when it is not one. */
|
|
87
|
+
export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
|
|
88
|
+
return isJsonObject(value) ? value : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write one JSON response. Default headers are the family defaults
|
|
93
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
94
|
+
* override them.
|
|
95
|
+
*/
|
|
96
|
+
export function writeJson(
|
|
97
|
+
res: ServerResponse,
|
|
98
|
+
status: number,
|
|
99
|
+
body: unknown,
|
|
100
|
+
headers: OutgoingHttpHeaders = {},
|
|
101
|
+
): void {
|
|
102
|
+
const payload = JSON.stringify(body)
|
|
103
|
+
res.writeHead(status, { ...JSON_HEADERS, ...headers })
|
|
104
|
+
res.end(payload)
|
|
105
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -64,7 +64,7 @@ const DEFAULT_ANNOUNCE = false
|
|
|
64
64
|
const SECTION_ORDER = 150
|
|
65
65
|
|
|
66
66
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
67
|
-
export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600
|
|
67
|
+
export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* Mount the SSH engine, routes, tools, and announcement.
|
package/src/routes.ts
CHANGED
|
@@ -16,13 +16,11 @@ import { randomBytes } from 'node:crypto'
|
|
|
16
16
|
import { WebSocket, WebSocketServer } from 'ws'
|
|
17
17
|
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
18
18
|
import type { SshEngine, ShellSession } from './engine.ts'
|
|
19
|
+
import { readJsonBody, writeJson } from './http.ts'
|
|
19
20
|
import { isLoopbackRequest } from './loopback.ts'
|
|
20
21
|
import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
|
|
21
22
|
import type { HostStore } from './store.ts'
|
|
22
23
|
|
|
23
|
-
/** Cap on JSON request bodies (host entries and exec payloads are small). */
|
|
24
|
-
const MAX_JSON_BODY_BYTES = 64 * 1024
|
|
25
|
-
|
|
26
24
|
/** Cap on declared upload bodies (staged to disk before SFTP). */
|
|
27
25
|
const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
|
|
28
26
|
|
|
@@ -39,31 +37,6 @@ const BACKPRESSURE_HIGH_WATER = 1024 * 1024
|
|
|
39
37
|
/** …and resume once it drains below this. */
|
|
40
38
|
const BACKPRESSURE_LOW_WATER = 512 * 1024
|
|
41
39
|
|
|
42
|
-
/** One JSON response. */
|
|
43
|
-
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
44
|
-
const payload = JSON.stringify(body)
|
|
45
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
46
|
-
res.end(payload)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Read a JSON request body (undefined when too large or unparseable). */
|
|
50
|
-
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
|
|
51
|
-
const chunks: Buffer[] = []
|
|
52
|
-
let size = 0
|
|
53
|
-
for await (const chunk of req) {
|
|
54
|
-
const buffer = chunk as Buffer
|
|
55
|
-
size += buffer.length
|
|
56
|
-
if (size > MAX_JSON_BODY_BYTES) return undefined
|
|
57
|
-
chunks.push(buffer)
|
|
58
|
-
}
|
|
59
|
-
try {
|
|
60
|
-
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
61
|
-
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
62
|
-
} catch {
|
|
63
|
-
return undefined
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
40
|
/** URL query helper (first value, decoded). */
|
|
68
41
|
function queryParam(url: URL, name: string): string | undefined {
|
|
69
42
|
const value = url.searchParams.get(name)
|
|
@@ -129,8 +102,8 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
129
102
|
return
|
|
130
103
|
}
|
|
131
104
|
if (method === 'POST') {
|
|
132
|
-
const body = await readJsonBody(req)
|
|
133
|
-
if (body ===
|
|
105
|
+
const body = (await readJsonBody(req)) as Record<string, unknown> | null
|
|
106
|
+
if (body === null) {
|
|
134
107
|
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
135
108
|
return
|
|
136
109
|
}
|
|
@@ -152,8 +125,8 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
152
125
|
return
|
|
153
126
|
}
|
|
154
127
|
if (method === 'PATCH') {
|
|
155
|
-
const body = await readJsonBody(req)
|
|
156
|
-
if (body ===
|
|
128
|
+
const body = (await readJsonBody(req)) as Record<string, unknown> | null
|
|
129
|
+
if (body === null) {
|
|
157
130
|
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
158
131
|
return
|
|
159
132
|
}
|
|
@@ -203,7 +176,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
203
176
|
path: SSH_API.test,
|
|
204
177
|
handler: async (req, res) => {
|
|
205
178
|
if (!guard(req, res, 'POST')) return
|
|
206
|
-
const body = await readJsonBody(req)
|
|
179
|
+
const body = (await readJsonBody(req)) as Record<string, unknown> | null
|
|
207
180
|
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
208
181
|
if (alias === '') {
|
|
209
182
|
writeJson(res, 400, { error: 'alias is required' })
|
|
@@ -221,7 +194,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
221
194
|
path: SSH_API.exec,
|
|
222
195
|
handler: async (req, res) => {
|
|
223
196
|
if (!guard(req, res, 'POST')) return
|
|
224
|
-
const body = await readJsonBody(req)
|
|
197
|
+
const body = (await readJsonBody(req)) as Record<string, unknown> | null
|
|
225
198
|
const alias = typeof body?.alias === 'string' ? body.alias : ''
|
|
226
199
|
const command = typeof body?.command === 'string' ? body.command : ''
|
|
227
200
|
if (alias === '' || command === '') {
|
|
@@ -241,7 +214,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
241
214
|
path: SSH_API.cluster,
|
|
242
215
|
handler: async (req, res) => {
|
|
243
216
|
if (!guard(req, res, 'POST')) return
|
|
244
|
-
const body = await readJsonBody(req)
|
|
217
|
+
const body = (await readJsonBody(req)) as Record<string, unknown> | null
|
|
245
218
|
const command = typeof body?.command === 'string' ? body.command : ''
|
|
246
219
|
if (command === '') {
|
|
247
220
|
writeJson(res, 400, { error: 'command is required' })
|
|
@@ -285,7 +258,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
285
258
|
path: SSH_API.tunnel,
|
|
286
259
|
handler: async (req, res) => {
|
|
287
260
|
if (!guard(req, res, 'POST')) return
|
|
288
|
-
const body = await readJsonBody(req)
|
|
261
|
+
const body = (await readJsonBody(req)) as Record<string, unknown> | null
|
|
289
262
|
const action = typeof body?.action === 'string' ? body.action : ''
|
|
290
263
|
if (action === 'list') {
|
|
291
264
|
writeJson(res, 200, { tunnels: engine.listTunnels() })
|
package/src/tools.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function sshListTool(engine: SshEngine) {
|
|
|
109
109
|
export function sshExecTool(engine: SshEngine) {
|
|
110
110
|
return defineTool({
|
|
111
111
|
name: 'ssh_exec',
|
|
112
|
-
description: 'Execute a command on a
|
|
112
|
+
description: 'Execute a shell command on a REMOTE SSH host by alias; the command runs on the remote host, never on this machine. For commands on this machine, use the local bash tool. Prefer combining independent read-only queries into one command. ' +
|
|
113
113
|
'Triggers: run command on server, deploy, check server/status, service control, view logs, any remote operation.',
|
|
114
114
|
parameters: {
|
|
115
115
|
alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
|
|
@@ -154,12 +154,12 @@ export function sshExecTool(engine: SshEngine) {
|
|
|
154
154
|
export function sshUploadTool(engine: SshEngine) {
|
|
155
155
|
return defineTool({
|
|
156
156
|
name: 'ssh_upload',
|
|
157
|
-
description: '
|
|
157
|
+
description: 'Transfer a file FROM this machine (the dsh host) TO a remote SSH host. Use this only when the file must be copied to the remote host. Files that stay on this machine are handled with the local file tools (read / write / edit), not ssh_upload. ' +
|
|
158
158
|
'Triggers: upload file to server, deploy artifact, copy config to server.',
|
|
159
159
|
parameters: {
|
|
160
160
|
alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
|
|
161
|
-
localPath: { type: 'string', required: true, description: 'Absolute
|
|
162
|
-
remotePath: { type: 'string', required: true, description: '
|
|
161
|
+
localPath: { type: 'string', required: true, description: 'Absolute path of the source file on THIS machine (the dsh host) — not a path on the remote host.' },
|
|
162
|
+
remotePath: { type: 'string', required: true, description: 'Absolute destination path on the remote SSH host (parent dirs are created).' },
|
|
163
163
|
},
|
|
164
164
|
output: {
|
|
165
165
|
schema: {
|
|
@@ -191,12 +191,12 @@ export function sshUploadTool(engine: SshEngine) {
|
|
|
191
191
|
export function sshDownloadTool(engine: SshEngine) {
|
|
192
192
|
return defineTool({
|
|
193
193
|
name: 'ssh_download',
|
|
194
|
-
description: '
|
|
194
|
+
description: 'Copy a remote FILE from a configured SSH host to this machine (the dsh host). Use this only when the source is on the remote host; files already on this machine are read with the local file tools (read / write / edit), not ssh_download. Directory download is not supported — download files individually. ' +
|
|
195
195
|
'Triggers: download file from server, fetch remote log/artifact.',
|
|
196
196
|
parameters: {
|
|
197
197
|
alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
|
|
198
|
-
remotePath: { type: 'string', required: true, description: '
|
|
199
|
-
localPath: { type: 'string', required: true, description: 'Absolute destination path on
|
|
198
|
+
remotePath: { type: 'string', required: true, description: 'Absolute path of the source file on the remote SSH host.' },
|
|
199
|
+
localPath: { type: 'string', required: true, description: 'Absolute destination path on THIS machine (the dsh host).' },
|
|
200
200
|
},
|
|
201
201
|
output: {
|
|
202
202
|
schema: {
|