@linxin666/dsh-ssh 0.2.4 → 0.2.6

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 CHANGED
@@ -1050,9 +1050,10 @@ function validateHostPayload(payload) {
1050
1050
  const auth = p.auth;
1051
1051
  if (auth !== void 0) {
1052
1052
  if (typeof auth !== "object" || auth === null) return "auth must be an object";
1053
- if (auth.kind !== "key" && auth.kind !== "password") return "auth.kind must be key or password";
1053
+ if (auth.kind !== "key" && auth.kind !== "password" && auth.kind !== "agent") return "auth.kind must be key, password or agent";
1054
1054
  if (auth.kind === "key" && (typeof auth.keyPath !== "string" || auth.keyPath.trim() === "")) return "auth.keyPath is required for key auth";
1055
1055
  if (auth.kind === "password" && auth.password !== void 0 && typeof auth.password !== "string") return "auth.password must be a string when provided";
1056
+ if (auth.kind === "agent" && auth.agentPath !== void 0 && typeof auth.agentPath !== "string") return "auth.agentPath must be a string when provided";
1056
1057
  }
1057
1058
  if (p.port !== void 0 && (typeof p.port !== "number" || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) return "port must be an integer in 1..65535";
1058
1059
  if (p.proxyJump !== void 0 && (!Array.isArray(p.proxyJump) || p.proxyJump.some((x) => typeof x !== "string" || x === ""))) return "proxyJump must be an array of alias strings";
@@ -1092,6 +1093,7 @@ var HostStore = class {
1092
1093
  summarize(entry) {
1093
1094
  let keyReady = true;
1094
1095
  if (entry.auth.kind === "key" && entry.auth.keyPath) keyReady = existsSync(expandHome(entry.auth.keyPath));
1096
+ else if (entry.auth.kind === "agent") keyReady = false;
1095
1097
  return {
1096
1098
  alias: entry.alias,
1097
1099
  host: entry.host,
@@ -1129,7 +1131,8 @@ var HostStore = class {
1129
1131
  kind: payload.auth.kind,
1130
1132
  keyPath: payload.auth.kind === "key" ? expandHome(payload.auth.keyPath?.trim() ?? "") : void 0,
1131
1133
  passphrase: payload.auth.kind === "key" ? payload.auth.passphrase ?? void 0 : void 0,
1132
- password: payload.auth.kind === "password" ? payload.auth.password : void 0
1134
+ password: payload.auth.kind === "password" ? payload.auth.password : void 0,
1135
+ agentPath: payload.auth.kind === "agent" ? normalizeAgentPath(payload.auth.agentPath) : void 0
1133
1136
  },
1134
1137
  proxyJump: [...payload.proxyJump ?? []],
1135
1138
  description: payload.description?.trim() || void 0,
@@ -1158,15 +1161,17 @@ var HostStore = class {
1158
1161
  if (patch.user !== void 0) entry.user = patch.user.trim();
1159
1162
  if (patch.auth !== void 0) {
1160
1163
  const auth = patch.auth;
1161
- if (auth.kind !== "key" && auth.kind !== "password") throw new Error("auth.kind must be key or password");
1164
+ if (auth.kind !== "key" && auth.kind !== "password" && auth.kind !== "agent") throw new Error("auth.kind must be key, password or agent");
1162
1165
  if (auth.kind === "key" && (typeof auth.keyPath !== "string" || auth.keyPath.trim() === "")) throw new Error("auth.keyPath is required for key auth");
1163
1166
  if (auth.kind === "password" && auth.password !== void 0 && typeof auth.password !== "string") throw new Error("auth.password must be a string when provided");
1167
+ if (auth.kind === "agent" && auth.agentPath !== void 0 && typeof auth.agentPath !== "string") throw new Error("auth.agentPath must be a string when provided");
1164
1168
  const keyChanged = auth.kind === "key" && auth.keyPath !== void 0 && expandHome(auth.keyPath.trim()) !== entry.auth.keyPath;
1165
1169
  entry.auth = {
1166
1170
  kind: auth.kind,
1167
1171
  keyPath: auth.kind === "key" ? expandHome(auth.keyPath?.trim() ?? "") : void 0,
1168
1172
  passphrase: auth.kind === "key" ? auth.passphrase !== void 0 ? auth.passphrase : keyChanged ? void 0 : entry.auth.passphrase : void 0,
1169
- password: auth.kind === "password" ? auth.password : void 0
1173
+ password: auth.kind === "password" ? auth.password : void 0,
1174
+ agentPath: auth.kind === "agent" ? auth.agentPath !== void 0 ? normalizeAgentPath(auth.agentPath) : entry.auth.agentPath : void 0
1170
1175
  };
1171
1176
  }
1172
1177
  if (patch.proxyJump !== void 0) entry.proxyJump = [...patch.proxyJump];
@@ -1247,9 +1252,10 @@ var HostStore = class {
1247
1252
  port: block.props.port !== void 0 ? Number.parseInt(block.props.port, 10) : 22,
1248
1253
  user: block.props.user ?? process.env.USER ?? "root",
1249
1254
  auth: {
1250
- kind: block.props.identityfile !== void 0 ? "key" : "password",
1255
+ kind: block.props.identityfile !== void 0 ? "key" : block.props.identityagent !== void 0 && block.props.identityagent.toLowerCase() !== "none" ? "agent" : "password",
1251
1256
  keyPath: block.props.identityfile,
1252
- password: block.props.password
1257
+ password: block.props.password,
1258
+ agentPath: block.props.identityagent !== void 0 && block.props.identityagent.toLowerCase() !== "none" ? normalizeAgentPath(block.props.identityagent) : void 0
1253
1259
  },
1254
1260
  proxyJump: block.props.proxyjump !== void 0 ? block.props.proxyjump.split(",").map((hop) => hop.trim()).filter((hop) => hop !== "") : [],
1255
1261
  description: block.props.description,
@@ -1325,6 +1331,16 @@ var HostStore = class {
1325
1331
  this.cache = void 0;
1326
1332
  }
1327
1333
  };
1334
+ /** Normalize an agent endpoint for storage: trim, expand `~`, and resolve the SSH_AUTH_SOCK token. */
1335
+ function normalizeAgentPath(agentPath) {
1336
+ const trimmed = agentPath?.trim();
1337
+ if (trimmed === void 0 || trimmed === "") return void 0;
1338
+ if (trimmed === "SSH_AUTH_SOCK" || trimmed === "$SSH_AUTH_SOCK") {
1339
+ const sock = process.env.SSH_AUTH_SOCK;
1340
+ return sock !== void 0 && sock !== "" ? sock : void 0;
1341
+ }
1342
+ return expandHome(trimmed);
1343
+ }
1328
1344
  /** Expand a leading `~` in a filesystem path. */
1329
1345
  function expandHome(path) {
1330
1346
  if (path === "~") return homedir();
@@ -1359,7 +1375,11 @@ function buildConnectConfig(entry, sock, opts) {
1359
1375
  };
1360
1376
  if (sock !== void 0) config.sock = sock;
1361
1377
  if (entry.auth.kind === "password") config.password = entry.auth.password;
1362
- else {
1378
+ else if (entry.auth.kind === "agent") {
1379
+ const agentPath = resolveAgentPath(entry.auth.agentPath);
1380
+ if (agentPath === void 0) throw new Error("ssh-agent is not available: set SSH_AUTH_SOCK or configure an agent path (use 'pageant' for PuTTY Pageant on Windows)");
1381
+ config.agent = agentPath;
1382
+ } else {
1363
1383
  const keyPath = entry.auth.keyPath === void 0 ? void 0 : expandHome(entry.auth.keyPath);
1364
1384
  if (keyPath === void 0 || !existsSync(keyPath)) throw new Error("private key not found: " + (entry.auth.keyPath ?? "(unset)"));
1365
1385
  config.privateKey = readFileSync(keyPath, "utf8");
@@ -1367,6 +1387,14 @@ function buildConnectConfig(entry, sock, opts) {
1367
1387
  }
1368
1388
  return config;
1369
1389
  }
1390
+ /** Resolve the ssh2 agent path for 'agent' auth. */
1391
+ function resolveAgentPath(agentPath) {
1392
+ const explicit = normalizeAgentPath(agentPath);
1393
+ if (explicit !== void 0) return explicit;
1394
+ const sock = process.env.SSH_AUTH_SOCK;
1395
+ if (sock !== void 0 && sock !== "") return sock;
1396
+ if (process.platform === "win32") return "pageant";
1397
+ }
1370
1398
  /** Connect one ssh2 client (resolve on ready, reject on error/close). */
1371
1399
  function connectClient(config) {
1372
1400
  return new Promise((resolve, reject) => {
@@ -2875,7 +2903,11 @@ function sshListTool(engine) {
2875
2903
  },
2876
2904
  auth: {
2877
2905
  type: "string",
2878
- enum: ["key", "password"],
2906
+ enum: [
2907
+ "key",
2908
+ "password",
2909
+ "agent"
2910
+ ],
2879
2911
  required: true
2880
2912
  },
2881
2913
  keyReady: {
@@ -3425,7 +3457,7 @@ const DEFAULT_ANNOUNCE = true;
3425
3457
  /** Order of the announcement section within the tool-guidance band. */
3426
3458
  const SECTION_ORDER = 150;
3427
3459
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
3428
- const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 ~/.dsh/dsh-ssh.json(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
3460
+ const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 ~/.dsh/dsh-ssh.json(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
3429
3461
  /**
3430
3462
  * Mount the SSH engine, routes, tools, and announcement.
3431
3463
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
@@ -51,12 +51,16 @@ export declare const zh: {
51
51
  readonly 'form.auth': "认证方式";
52
52
  readonly 'form.auth.key': "密钥";
53
53
  readonly 'form.auth.password': "密码";
54
- readonly 'form.authKeepHint': "编辑时密钥/密码留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。";
54
+ readonly 'form.auth.agent': "SSH Agent";
55
+ readonly 'form.agentPath': "Agent 路径";
56
+ readonly 'form.agentPathHint': "留空自动检测(SSH_AUTH_SOCK / Windows Pageant);也可填 socket 路径或 pageant";
57
+ readonly 'form.authKeepHint': "编辑时密钥/密码/Agent 留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。";
55
58
  readonly 'form.keyPath': "私钥路径";
56
59
  readonly 'form.keyPathHint': "如 ~/.ssh/id_ed25519";
57
60
  readonly 'form.passphrase': "密钥口令";
58
61
  readonly 'form.password': "密码";
59
62
  readonly 'form.passwordHint': "明文保存在 ~/.dsh/dsh-ssh.json(本机私有文件)";
63
+ readonly 'form.passwordRequired': "密码认证需要填写密码。";
60
64
  readonly 'form.proxyJump': "跳板机别名";
61
65
  readonly 'form.proxyJumpHint': "逗号分隔的本地主机别名,按顺序连接";
62
66
  readonly 'form.description': "备注";
@@ -48,6 +48,8 @@ export interface PoolEngine {
48
48
  }
49
49
  /** Build the ssh2 connect config for one entry (key read from disk). */
50
50
  export declare function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig;
51
+ /** Resolve the ssh2 agent path for 'agent' auth. */
52
+ export declare function resolveAgentPath(agentPath?: string): string | undefined;
51
53
  /** Connect one ssh2 client (resolve on ready, reject on error/close). */
52
54
  export declare function connectClient(config: ConnectConfig): Promise<Client>;
53
55
  /** Cap captured output at the configured byte budget (marks truncation). */
@@ -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/dsh-ssh.json\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\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/dsh-ssh.json\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";
42
42
  /**
43
43
  * Mount the SSH engine, routes, tools, and announcement.
44
44
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
@@ -4,7 +4,7 @@
4
4
  * each, no runtime identity to share.
5
5
  */
6
6
  /** Authentication flavors a host entry may carry. */
7
- export type SshAuthKind = 'key' | 'password';
7
+ export type SshAuthKind = 'key' | 'password' | 'agent';
8
8
  /** One stored host entry (the ~/.dsh/dsh-ssh.json store shape). */
9
9
  export interface SshHostEntry {
10
10
  /** Stable, user-chosen identifier used by every operation. */
@@ -24,6 +24,12 @@ export interface SshHostEntry {
24
24
  passphrase?: string;
25
25
  /** Password for 'password' auth. */
26
26
  password?: string;
27
+ /**
28
+ * ssh-agent endpoint for 'agent' auth. A socket path, the special value
29
+ * 'pageant' (PuTTY Pageant on Windows), or undefined for auto-detect
30
+ * (SSH_AUTH_SOCK, then Pageant on Windows).
31
+ */
32
+ agentPath?: string;
27
33
  };
28
34
  /** Jump chain: local aliases connected through in order (ProxyJump). */
29
35
  proxyJump: string[];
@@ -56,5 +56,7 @@ export declare class HostStore {
56
56
  private load;
57
57
  private save;
58
58
  }
59
+ /** Normalize an agent endpoint for storage: trim, expand `~`, and resolve the SSH_AUTH_SOCK token. */
60
+ export declare function normalizeAgentPath(agentPath: string | undefined): string | undefined;
59
61
  /** Expand a leading `~` in a filesystem path. */
60
62
  export declare function expandHome(path: string): string;
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.2.4",
4
+ "version": "0.2.6",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
package/src/client/api.ts CHANGED
@@ -34,6 +34,17 @@ export class SshApiError extends Error {
34
34
  }
35
35
  }
36
36
 
37
+ /** Extract the `.error` message from a JSON error body; undefined when absent. */
38
+ function extractError(text: string): string | undefined {
39
+ if (text === '') return undefined
40
+ try {
41
+ const parsed = JSON.parse(text) as { error?: unknown } | null
42
+ return typeof parsed?.error === 'string' ? parsed.error : undefined
43
+ } catch {
44
+ return undefined
45
+ }
46
+ }
47
+
37
48
  /** Parse a JSON response or throw an SshApiError. */
38
49
  async function readJson<T>(response: Response): Promise<T> {
39
50
  let body: unknown
@@ -219,7 +230,9 @@ export class SshApi {
219
230
  body: file,
220
231
  })
221
232
  if (!response.ok || response.body === null) {
222
- throw new SshApiError(`upload failed: HTTP ${response.status}`)
233
+ const text = await response.text().catch(() => '')
234
+ const error = extractError(text)
235
+ throw new SshApiError(error ?? `upload failed: HTTP ${response.status}`)
223
236
  }
224
237
  const reader = response.body.getReader()
225
238
  const decoder = new TextDecoder()
@@ -268,7 +281,8 @@ export class SshApi {
268
281
  const response = await fetch(SSH_API.download + query({ alias, remotePath }))
269
282
  if (!response.ok || response.body === null) {
270
283
  const text = await response.text().catch(() => '')
271
- throw new SshApiError(text !== '' && text.startsWith('{') ? text : `download failed: HTTP ${response.status}`)
284
+ const error = extractError(text)
285
+ throw new SshApiError(error ?? `download failed: HTTP ${response.status}`)
272
286
  }
273
287
  const total = Number(response.headers.get('content-length') ?? '0')
274
288
  const disposition = response.headers.get('content-disposition') ?? ''
@@ -54,12 +54,16 @@ export const zh = {
54
54
  'form.auth': '认证方式',
55
55
  'form.auth.key': '密钥',
56
56
  'form.auth.password': '密码',
57
- 'form.authKeepHint': '编辑时密钥/密码留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。',
57
+ 'form.auth.agent': 'SSH Agent',
58
+ 'form.agentPath': 'Agent 路径',
59
+ 'form.agentPathHint': '留空自动检测(SSH_AUTH_SOCK / Windows Pageant);也可填 socket 路径或 pageant',
60
+ 'form.authKeepHint': '编辑时密钥/密码/Agent 留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。',
58
61
  'form.keyPath': '私钥路径',
59
62
  'form.keyPathHint': '如 ~/.ssh/id_ed25519',
60
63
  'form.passphrase': '密钥口令',
61
64
  'form.password': '密码',
62
65
  'form.passwordHint': '明文保存在 ~/.dsh/dsh-ssh.json(本机私有文件)',
66
+ 'form.passwordRequired': '密码认证需要填写密码。',
63
67
  'form.proxyJump': '跳板机别名',
64
68
  'form.proxyJumpHint': '逗号分隔的本地主机别名,按顺序连接',
65
69
  'form.description': '备注',
@@ -197,12 +201,16 @@ export const en: Record<keyof typeof zh, string> = {
197
201
  'form.auth': 'Authentication',
198
202
  'form.auth.key': 'Key',
199
203
  'form.auth.password': 'Password',
200
- 'form.authKeepHint': 'While editing, empty secret fields keep the stored authentication and credentials; enter new secrets to switch methods.',
204
+ 'form.auth.agent': 'SSH Agent',
205
+ 'form.agentPath': 'Agent path',
206
+ 'form.agentPathHint': 'Leave empty to auto-detect (SSH_AUTH_SOCK / Pageant on Windows), or set a socket path or "pageant"',
207
+ 'form.authKeepHint': 'While editing, empty secret fields keep the stored authentication and credentials; enter new values to switch methods.',
201
208
  'form.keyPath': 'Private key path',
202
209
  'form.keyPathHint': 'e.g. ~/.ssh/id_ed25519',
203
210
  'form.passphrase': 'Key passphrase',
204
211
  'form.password': 'Password',
205
212
  'form.passwordHint': 'Stored in plaintext in ~/.dsh/dsh-ssh.json (private local file)',
213
+ 'form.passwordRequired': 'Password auth requires a password.',
206
214
  'form.proxyJump': 'Jump host aliases',
207
215
  'form.proxyJumpHint': 'Comma-separated local aliases, chained in order',
208
216
  'form.description': 'Description',
@@ -29,6 +29,7 @@ interface FormState {
29
29
  keyPath: string
30
30
  passphrase: string
31
31
  password: string
32
+ agentPath: string
32
33
  proxyJump: string
33
34
  description: string
34
35
  environment: string
@@ -52,6 +53,7 @@ function blankOf(editing: SshHostSummary | null | undefined): FormState {
52
53
  keyPath: '',
53
54
  passphrase: '',
54
55
  password: '',
56
+ agentPath: '',
55
57
  proxyJump: (editing?.proxyJump ?? []).join(', '),
56
58
  description: editing?.description ?? '',
57
59
  environment: editing?.environment ?? '',
@@ -92,17 +94,27 @@ export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialo
92
94
  setError(tt('form.portInvalid'))
93
95
  return
94
96
  }
97
+ // A password-auth host needs a password on create; when editing, a blank
98
+ // secret field preserves the stored credential instead.
99
+ if (editing == null && form.authKind === 'password' && form.password === '') {
100
+ setError(tt('form.passwordRequired'))
101
+ return
102
+ }
95
103
  // Secrets are never echoed back by the API: when editing and the secret
96
104
  // fields are left empty, the auth block is omitted so the stored
97
105
  // authentication is preserved.
98
106
  const secretEmpty = form.authKind === 'password'
99
107
  ? form.password === ''
100
- : form.keyPath.trim() === ''
108
+ : form.authKind === 'key'
109
+ ? form.keyPath.trim() === ''
110
+ : form.agentPath.trim() === ''
101
111
  const auth: HostPayload['auth'] = editing != null && secretEmpty
102
112
  ? undefined
103
113
  : form.authKind === 'password'
104
114
  ? { kind: 'password', password: form.password }
105
- : { kind: 'key', keyPath: form.keyPath.trim(), passphrase: form.passphrase === '' ? undefined : form.passphrase }
115
+ : form.authKind === 'key'
116
+ ? { kind: 'key', keyPath: form.keyPath.trim(), passphrase: form.passphrase === '' ? undefined : form.passphrase }
117
+ : { kind: 'agent', agentPath: form.agentPath.trim() === '' ? undefined : form.agentPath.trim() }
106
118
  const payload: HostPayload = {
107
119
  host,
108
120
  port,
@@ -163,6 +175,10 @@ export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialo
163
175
  <input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'password'} onChange={() => { set('authKind', 'password') }} />
164
176
  {tt('form.auth.password')}
165
177
  </label>
178
+ <label className={css.radioLabel}>
179
+ <input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'agent'} onChange={() => { set('authKind', 'agent') }} />
180
+ {tt('form.auth.agent')}
181
+ </label>
166
182
  </div>
167
183
  {editing != null && <span className={css.hint}>{tt('form.authKeepHint')}</span>}
168
184
  </div>
@@ -178,6 +194,12 @@ export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialo
178
194
  <input className={css.input} type="password" value={form.passphrase} onChange={event => { set('passphrase', event.target.value) }} />
179
195
  </label>
180
196
  </div>
197
+ ) : form.authKind === 'agent' ? (
198
+ <label className={css.field}>
199
+ <span className={css.fieldLabel}>{tt('form.agentPath')}</span>
200
+ <input className={css.input} value={form.agentPath} onChange={event => { set('agentPath', event.target.value) }} />
201
+ <span className={css.hint}>{tt('form.agentPathHint')}</span>
202
+ </label>
181
203
  ) : (
182
204
  <label className={css.field}>
183
205
  <span className={css.fieldLabel}>{tt('form.password')}</span>
@@ -157,7 +157,7 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
157
157
  <td className={css.mono}>{host.alias}</td>
158
158
  <td className={css.mono}>{host.host}:{host.port}</td>
159
159
  <td>{host.user}</td>
160
- <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
160
+ <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : host.auth === 'password' ? tt('form.auth.password') : tt('form.auth.agent')}</span></td>
161
161
  <td className={css.cellMuted}>{host.environment ?? ''}</td>
162
162
  <td className={css.cellMuted}>{host.tags.join(', ')}</td>
163
163
  <td className={css.cellMuted}>{host.description ?? ''}</td>
@@ -414,6 +414,11 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
414
414
  border-color: var(--dsw-alias-state-warn-primary);
415
415
  }
416
416
 
417
+ .badge[data-kind='agent'] {
418
+ color: var(--dsw-alias-state-success-primary);
419
+ border-color: var(--dsw-alias-state-success-primary);
420
+ }
421
+
417
422
  .badge[data-status='ok'] {
418
423
  color: var(--dsw-alias-state-success-primary);
419
424
  border-color: var(--dsw-alias-state-success-primary);
@@ -6,7 +6,7 @@
6
6
  import { existsSync, readFileSync } from 'node:fs'
7
7
  import { Client, type ConnectConfig } from 'ssh2'
8
8
  import type { ExecResult, SshHostEntry } from '../protocol.ts'
9
- import { expandHome, type HostStore } from '../store.ts'
9
+ import { expandHome, normalizeAgentPath, type HostStore } from '../store.ts'
10
10
 
11
11
  /** Default engine knobs. */
12
12
  export interface EngineOptions {
@@ -74,6 +74,12 @@ export function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['soc
74
74
  if (sock !== undefined) config.sock = sock
75
75
  if (entry.auth.kind === 'password') {
76
76
  config.password = entry.auth.password
77
+ } else if (entry.auth.kind === 'agent') {
78
+ const agentPath = resolveAgentPath(entry.auth.agentPath)
79
+ if (agentPath === undefined) {
80
+ throw new Error('ssh-agent is not available: set SSH_AUTH_SOCK or configure an agent path (use \'pageant\' for PuTTY Pageant on Windows)')
81
+ }
82
+ config.agent = agentPath
77
83
  } else {
78
84
  const keyPath = entry.auth.keyPath === undefined ? undefined : expandHome(entry.auth.keyPath)
79
85
  if (keyPath === undefined || !existsSync(keyPath)) {
@@ -87,6 +93,16 @@ export function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['soc
87
93
  return config
88
94
  }
89
95
 
96
+ /** Resolve the ssh2 agent path for 'agent' auth. */
97
+ export function resolveAgentPath(agentPath?: string): string | undefined {
98
+ const explicit = normalizeAgentPath(agentPath)
99
+ if (explicit !== undefined) return explicit
100
+ const sock = process.env.SSH_AUTH_SOCK
101
+ if (sock !== undefined && sock !== '') return sock
102
+ if (process.platform === 'win32') return 'pageant'
103
+ return undefined
104
+ }
105
+
90
106
  /** Connect one ssh2 client (resolve on ready, reject on error/close). */
91
107
  export function connectClient(config: ConnectConfig): Promise<Client> {
92
108
  return new Promise((resolve, reject) => {
package/src/index.ts CHANGED
@@ -64,7 +64,7 @@ const DEFAULT_ANNOUNCE = true
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/dsh-ssh.json(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
67
+ export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 ~/.dsh/dsh-ssh.json(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
68
68
 
69
69
  /**
70
70
  * Mount the SSH engine, routes, tools, and announcement.
package/src/protocol.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /** Authentication flavors a host entry may carry. */
8
- export type SshAuthKind = 'key' | 'password'
8
+ export type SshAuthKind = 'key' | 'password' | 'agent'
9
9
 
10
10
  /** One stored host entry (the ~/.dsh/dsh-ssh.json store shape). */
11
11
  export interface SshHostEntry {
@@ -26,6 +26,12 @@ export interface SshHostEntry {
26
26
  passphrase?: string
27
27
  /** Password for 'password' auth. */
28
28
  password?: string
29
+ /**
30
+ * ssh-agent endpoint for 'agent' auth. A socket path, the special value
31
+ * 'pageant' (PuTTY Pageant on Windows), or undefined for auto-detect
32
+ * (SSH_AUTH_SOCK, then Pageant on Windows).
33
+ */
34
+ agentPath?: string
29
35
  }
30
36
  /** Jump chain: local aliases connected through in order (ProxyJump). */
31
37
  proxyJump: string[]
package/src/store.ts CHANGED
@@ -38,13 +38,16 @@ export function validateHostPayload(payload: unknown): string | undefined {
38
38
  const auth = p.auth as Record<string, unknown> | undefined
39
39
  if (auth !== undefined) {
40
40
  if (typeof auth !== 'object' || auth === null) return 'auth must be an object'
41
- if (auth.kind !== 'key' && auth.kind !== 'password') return 'auth.kind must be key or password'
41
+ if (auth.kind !== 'key' && auth.kind !== 'password' && auth.kind !== 'agent') return 'auth.kind must be key, password or agent'
42
42
  if (auth.kind === 'key' && (typeof auth.keyPath !== 'string' || auth.keyPath.trim() === '')) {
43
43
  return 'auth.keyPath is required for key auth'
44
44
  }
45
45
  if (auth.kind === 'password' && auth.password !== undefined && typeof auth.password !== 'string') {
46
46
  return 'auth.password must be a string when provided'
47
47
  }
48
+ if (auth.kind === 'agent' && auth.agentPath !== undefined && typeof auth.agentPath !== 'string') {
49
+ return 'auth.agentPath must be a string when provided'
50
+ }
48
51
  }
49
52
  if (p.port !== undefined && (typeof p.port !== 'number' || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) {
50
53
  return 'port must be an integer in 1..65535'
@@ -101,6 +104,8 @@ export class HostStore {
101
104
  let keyReady = true
102
105
  if (entry.auth.kind === 'key' && entry.auth.keyPath) {
103
106
  keyReady = existsSync(expandHome(entry.auth.keyPath))
107
+ } else if (entry.auth.kind === 'agent') {
108
+ keyReady = false
104
109
  }
105
110
  return {
106
111
  alias: entry.alias,
@@ -143,6 +148,7 @@ export class HostStore {
143
148
  keyPath: payload.auth.kind === 'key' ? expandHome(payload.auth.keyPath?.trim() ?? '') : undefined,
144
149
  passphrase: payload.auth.kind === 'key' ? payload.auth.passphrase ?? undefined : undefined,
145
150
  password: payload.auth.kind === 'password' ? payload.auth.password : undefined,
151
+ agentPath: payload.auth.kind === 'agent' ? normalizeAgentPath(payload.auth.agentPath) : undefined,
146
152
  },
147
153
  proxyJump: [...(payload.proxyJump ?? [])],
148
154
  description: payload.description?.trim() || undefined,
@@ -184,13 +190,16 @@ export class HostStore {
184
190
  if (patch.user !== undefined) entry.user = patch.user.trim()
185
191
  if (patch.auth !== undefined) {
186
192
  const auth = patch.auth
187
- if (auth.kind !== 'key' && auth.kind !== 'password') throw new Error('auth.kind must be key or password')
193
+ if (auth.kind !== 'key' && auth.kind !== 'password' && auth.kind !== 'agent') throw new Error('auth.kind must be key, password or agent')
188
194
  if (auth.kind === 'key' && (typeof auth.keyPath !== 'string' || auth.keyPath.trim() === '')) {
189
195
  throw new Error('auth.keyPath is required for key auth')
190
196
  }
191
197
  if (auth.kind === 'password' && auth.password !== undefined && typeof auth.password !== 'string') {
192
198
  throw new Error('auth.password must be a string when provided')
193
199
  }
200
+ if (auth.kind === 'agent' && auth.agentPath !== undefined && typeof auth.agentPath !== 'string') {
201
+ throw new Error('auth.agentPath must be a string when provided')
202
+ }
194
203
  // A changed key path with no passphrase means the new key has none;
195
204
  // only keep the old passphrase when the key path is unchanged.
196
205
  const keyChanged = auth.kind === 'key'
@@ -203,6 +212,9 @@ export class HostStore {
203
212
  ? (auth.passphrase !== undefined ? auth.passphrase : (keyChanged ? undefined : entry.auth.passphrase))
204
213
  : undefined,
205
214
  password: auth.kind === 'password' ? auth.password : undefined,
215
+ agentPath: auth.kind === 'agent'
216
+ ? (auth.agentPath !== undefined ? normalizeAgentPath(auth.agentPath) : entry.auth.agentPath)
217
+ : undefined,
206
218
  }
207
219
  }
208
220
  if (patch.proxyJump !== undefined) entry.proxyJump = [...patch.proxyJump]
@@ -281,9 +293,16 @@ export class HostStore {
281
293
  port: block.props.port !== undefined ? Number.parseInt(block.props.port, 10) : 22,
282
294
  user: block.props.user ?? process.env.USER ?? 'root',
283
295
  auth: {
284
- kind: block.props.identityfile !== undefined ? 'key' : 'password',
296
+ kind: block.props.identityfile !== undefined
297
+ ? 'key'
298
+ : block.props.identityagent !== undefined && block.props.identityagent.toLowerCase() !== 'none'
299
+ ? 'agent'
300
+ : 'password',
285
301
  keyPath: block.props.identityfile,
286
302
  password: block.props.password,
303
+ agentPath: block.props.identityagent !== undefined && block.props.identityagent.toLowerCase() !== 'none'
304
+ ? normalizeAgentPath(block.props.identityagent)
305
+ : undefined,
287
306
  },
288
307
  proxyJump: block.props.proxyjump !== undefined
289
308
  ? block.props.proxyjump.split(',').map(hop => hop.trim()).filter(hop => hop !== '')
@@ -355,6 +374,17 @@ export class HostStore {
355
374
  }
356
375
  }
357
376
 
377
+ /** Normalize an agent endpoint for storage: trim, expand `~`, and resolve the SSH_AUTH_SOCK token. */
378
+ export function normalizeAgentPath(agentPath: string | undefined): string | undefined {
379
+ const trimmed = agentPath?.trim()
380
+ if (trimmed === undefined || trimmed === '') return undefined
381
+ if (trimmed === 'SSH_AUTH_SOCK' || trimmed === '$SSH_AUTH_SOCK') {
382
+ const sock = process.env.SSH_AUTH_SOCK
383
+ return sock !== undefined && sock !== '' ? sock : undefined
384
+ }
385
+ return expandHome(trimmed)
386
+ }
387
+
358
388
  /** Expand a leading `~` in a filesystem path. */
359
389
  export function expandHome(path: string): string {
360
390
  if (path === '~') return homedir()
package/src/tools.ts CHANGED
@@ -83,7 +83,7 @@ export function sshListTool(engine: SshEngine) {
83
83
  host: { type: 'string', required: true },
84
84
  port: { type: 'integer', required: true },
85
85
  user: { type: 'string', required: true },
86
- auth: { type: 'string', enum: ['key', 'password'], required: true },
86
+ auth: { type: 'string', enum: ['key', 'password', 'agent'], required: true },
87
87
  keyReady: { type: 'boolean', required: true },
88
88
  proxyJump: { type: 'array', items: { type: 'string' }, required: true },
89
89
  description: { type: 'string' },