@linxin666/dsh-ssh 0.3.17 → 0.3.18

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
@@ -2094,6 +2094,10 @@ function stopAllTunnels(engine, alias) {
2094
2094
  //#region src/engine/cluster.ts
2095
2095
  /** Run one command against many hosts concurrently. */
2096
2096
  async function cluster(engine, options) {
2097
+ const hasAliases = Array.isArray(options.aliases) && options.aliases.some((alias) => typeof alias === "string" && alias.trim() !== "");
2098
+ const hasEnvironment = typeof options.environment === "string" && options.environment.trim() !== "";
2099
+ const hasTags = Array.isArray(options.tags) && options.tags.some((tag) => typeof tag === "string" && tag.trim() !== "");
2100
+ if (!hasAliases && !hasEnvironment && !hasTags) throw new Error("ssh_cluster requires aliases, environment, or tags to limit the target set");
2097
2101
  let targets = engine.store.list();
2098
2102
  if (options.aliases !== void 0 && options.aliases.length > 0) targets = targets.filter((entry) => options.aliases.includes(entry.alias));
2099
2103
  if (options.environment !== void 0 && options.environment !== "") targets = targets.filter((entry) => entry.environment === options.environment);
@@ -2552,6 +2556,13 @@ function makeRoutes(deps) {
2552
2556
  const environment = typeof body?.environment === "string" ? body.environment : void 0;
2553
2557
  const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
2554
2558
  const maxWorkers = typeof body?.maxWorkers === "number" ? body.maxWorkers : void 0;
2559
+ const hasAliases = aliases?.some((alias) => alias.trim() !== "") === true;
2560
+ const hasEnvironment = typeof environment === "string" && environment.trim() !== "";
2561
+ const hasTags = tags?.some((tag) => tag.trim() !== "") === true;
2562
+ if (!hasAliases && !hasEnvironment && !hasTags) {
2563
+ writeJson(res, 400, { error: "ssh_cluster requires aliases, environment, or tags to limit the target set" });
2564
+ return;
2565
+ }
2555
2566
  try {
2556
2567
  writeJson(res, 200, { results: await engine.cluster({
2557
2568
  command,
@@ -3437,7 +3448,7 @@ function sshTunnelTool(engine) {
3437
3448
  function sshClusterTool(engine) {
3438
3449
  return defineTool({
3439
3450
  name: "ssh_cluster",
3440
- description: "Run one command concurrently across many SSH hosts (all hosts, or filtered by aliases / environment / tags). Triggers: run on all servers, batch operation, production servers, cluster command.",
3451
+ description: "Run one command concurrently across selected SSH hosts; at least one aliases, environment, or tags filter is required. Triggers: run on selected servers, batch operation, production servers, cluster command.",
3441
3452
  parameters: {
3442
3453
  command: {
3443
3454
  type: "string",
@@ -3447,16 +3458,16 @@ function sshClusterTool(engine) {
3447
3458
  aliases: {
3448
3459
  type: "array",
3449
3460
  items: { type: "string" },
3450
- description: "Explicit alias list; when absent every configured host matches."
3461
+ description: "Optional alias filter; at least one of aliases, environment, or tags is required."
3451
3462
  },
3452
3463
  environment: {
3453
3464
  type: "string",
3454
- description: "Only hosts with this environment label."
3465
+ description: "Optional environment filter; at least one selector is required."
3455
3466
  },
3456
3467
  tags: {
3457
3468
  type: "array",
3458
3469
  items: { type: "string" },
3459
- description: "Only hosts carrying ALL these tags."
3470
+ description: "Optional ALL-tags filter; at least one selector is required."
3460
3471
  },
3461
3472
  timeoutMs: {
3462
3473
  type: "integer",
@@ -3499,6 +3510,7 @@ function sshClusterTool(engine) {
3499
3510
  render: (_args, value) => text(renderCluster(value.results ?? []))
3500
3511
  },
3501
3512
  async execute(args) {
3513
+ if (!(Array.isArray(args.aliases) && args.aliases.some((alias) => typeof alias === "string" && alias.trim() !== "") || typeof args.environment === "string" && args.environment.trim() !== "" || Array.isArray(args.tags) && args.tags.some((tag) => typeof tag === "string" && tag.trim() !== ""))) throw new Error("ssh_cluster requires aliases, environment, or tags to limit the target set");
3502
3514
  return { results: await engine.cluster(args) };
3503
3515
  }
3504
3516
  });
@@ -3570,7 +3582,7 @@ const DEFAULT_ANNOUNCE = false;
3570
3582
  /** Order of the announcement section within the tool-guidance band. */
3571
3583
  const SECTION_ORDER = 150;
3572
3584
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
3573
- const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(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 / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
3585
+ const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 按 aliases/environment/tags 至少一种非空 selector 筛选后集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
3574
3586
  /**
3575
3587
  * Mount the SSH engine, routes, tools, and announcement.
3576
3588
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
@@ -126,7 +126,7 @@ export declare const zh: {
126
126
  readonly 'cluster.command': "命令";
127
127
  readonly 'cluster.run': "执行";
128
128
  readonly 'cluster.confirm': "确定在匹配的主机上执行该命令吗?该操作会真实运行在远程服务器上。";
129
- readonly 'cluster.aliases': "主机别名(逗号分隔,留空为全部)";
129
+ readonly 'cluster.aliases': "主机别名(逗号分隔,与环境、标签至少填一项)";
130
130
  readonly 'cluster.environment': "环境过滤";
131
131
  readonly 'cluster.tags': "标签过滤(逗号分隔)";
132
132
  readonly 'cluster.results': "执行结果";
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Cluster execution: run one command concurrently across many hosts (all, or
3
- * filtered by explicit aliases / environment / tags with ALL semantics).
2
+ * Cluster execution: run one command concurrently across selected hosts; the
3
+ * caller must provide at least one non-empty aliases / environment / tags filter.
4
4
  */
5
5
  import type { ClusterResult } from '../protocol.ts';
6
6
  import { type PoolEngine } from './connection-pool.ts';
@@ -39,7 +39,7 @@ export interface Config {
39
39
  }
40
40
  export declare const Config: z<Config>;
41
41
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
42
- 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 \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
+ 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 \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 \u6309 aliases/environment/tags \u81F3\u5C11\u4E00\u79CD\u975E\u7A7A selector \u7B5B\u9009\u540E\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";
43
43
  /**
44
44
  * Mount the SSH engine, routes, tools, and announcement.
45
45
  * @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.3.17",
4
+ "version": "0.3.18",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -133,7 +133,7 @@ export const zh = {
133
133
  'cluster.command': '命令',
134
134
  'cluster.run': '执行',
135
135
  'cluster.confirm': '确定在匹配的主机上执行该命令吗?该操作会真实运行在远程服务器上。',
136
- 'cluster.aliases': '主机别名(逗号分隔,留空为全部)',
136
+ 'cluster.aliases': '主机别名(逗号分隔,与环境、标签至少填一项)',
137
137
  'cluster.environment': '环境过滤',
138
138
  'cluster.tags': '标签过滤(逗号分隔)',
139
139
  'cluster.results': '执行结果',
@@ -282,7 +282,7 @@ export const en: Record<keyof typeof zh, string> = {
282
282
  'cluster.command': 'Command',
283
283
  'cluster.run': 'Run',
284
284
  'cluster.confirm': 'Run this command on the matching hosts? It executes for real on the remote servers.',
285
- 'cluster.aliases': 'Host aliases (comma-separated; empty = all)',
285
+ 'cluster.aliases': 'Host aliases (comma-separated; require at least one filter)',
286
286
  'cluster.environment': 'Environment filter',
287
287
  'cluster.tags': 'Tag filter (comma-separated)',
288
288
  'cluster.results': 'Results',
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Cluster execution: run one command concurrently across many hosts (all, or
3
- * filtered by explicit aliases / environment / tags with ALL semantics).
2
+ * Cluster execution: run one command concurrently across selected hosts; the
3
+ * caller must provide at least one non-empty aliases / environment / tags filter.
4
4
  */
5
5
 
6
6
  import type { ClusterResult } from '../protocol.ts'
@@ -17,6 +17,13 @@ interface ClusterOptions {
17
17
 
18
18
  /** Run one command against many hosts concurrently. */
19
19
  export async function cluster(engine: PoolEngine, options: ClusterOptions): Promise<ClusterResult[]> {
20
+ const hasAliases = Array.isArray(options.aliases) && options.aliases.some(alias => typeof alias === 'string' && alias.trim() !== '')
21
+ const hasEnvironment = typeof options.environment === 'string' && options.environment.trim() !== ''
22
+ const hasTags = Array.isArray(options.tags) && options.tags.some(tag => typeof tag === 'string' && tag.trim() !== '')
23
+ if (!hasAliases && !hasEnvironment && !hasTags) {
24
+ throw new Error('ssh_cluster requires aliases, environment, or tags to limit the target set')
25
+ }
26
+
20
27
  let targets = engine.store.list()
21
28
  if (options.aliases !== undefined && options.aliases.length > 0) {
22
29
  targets = targets.filter(entry => options.aliases!.includes(entry.alias))
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 插件全家桶仓库(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 / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
67
+ export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 按 aliases/environment/tags 至少一种非空 selector 筛选后集群并发执行;支持密钥/密码/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
@@ -225,6 +225,13 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
225
225
  const environment = typeof body?.environment === 'string' ? body.environment : undefined
226
226
  const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
227
227
  const maxWorkers = typeof body?.maxWorkers === 'number' ? body.maxWorkers : undefined
228
+ const hasAliases = aliases?.some(alias => alias.trim() !== '') === true
229
+ const hasEnvironment = typeof environment === 'string' && environment.trim() !== ''
230
+ const hasTags = tags?.some(tag => tag.trim() !== '') === true
231
+ if (!hasAliases && !hasEnvironment && !hasTags) {
232
+ writeJson(res, 400, { error: 'ssh_cluster requires aliases, environment, or tags to limit the target set' })
233
+ return
234
+ }
228
235
  try {
229
236
  writeJson(res, 200, { results: await engine.cluster({ command, aliases, environment, tags, timeoutMs, maxWorkers }) })
230
237
  } catch (error) {
package/src/tools.ts CHANGED
@@ -327,13 +327,13 @@ export function sshTunnelTool(engine: SshEngine) {
327
327
  export function sshClusterTool(engine: SshEngine) {
328
328
  return defineTool({
329
329
  name: 'ssh_cluster',
330
- description: 'Run one command concurrently across many SSH hosts (all hosts, or filtered by aliases / environment / tags). ' +
331
- 'Triggers: run on all servers, batch operation, production servers, cluster command.',
330
+ description: 'Run one command concurrently across selected SSH hosts; at least one aliases, environment, or tags filter is required. ' +
331
+ 'Triggers: run on selected servers, batch operation, production servers, cluster command.',
332
332
  parameters: {
333
333
  command: { type: 'string', required: true, description: 'The shell command to run on every matched host.' },
334
- aliases: { type: 'array', items: { type: 'string' }, description: 'Explicit alias list; when absent every configured host matches.' },
335
- environment: { type: 'string', description: 'Only hosts with this environment label.' },
336
- tags: { type: 'array', items: { type: 'string' }, description: 'Only hosts carrying ALL these tags.' },
334
+ aliases: { type: 'array', items: { type: 'string' }, description: 'Optional alias filter; at least one of aliases, environment, or tags is required.' },
335
+ environment: { type: 'string', description: 'Optional environment filter; at least one selector is required.' },
336
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional ALL-tags filter; at least one selector is required.' },
337
337
  timeoutMs: { type: 'integer', description: 'Per-host timeout in milliseconds.' },
338
338
  maxWorkers: { type: 'integer', description: 'Concurrency cap (default 8).' },
339
339
  },
@@ -365,6 +365,10 @@ export function sshClusterTool(engine: SshEngine) {
365
365
  render: (_args, value: { results?: ClusterResult[] }) => text(renderCluster(value.results ?? [])),
366
366
  },
367
367
  async execute(args) {
368
+ const hasSelector = (Array.isArray(args.aliases) && args.aliases.some((alias) => typeof alias === 'string' && alias.trim() !== '')) ||
369
+ (typeof args.environment === 'string' && args.environment.trim() !== '') ||
370
+ (Array.isArray(args.tags) && args.tags.some((tag) => typeof tag === 'string' && tag.trim() !== ''))
371
+ if (!hasSelector) throw new Error('ssh_cluster requires aliases, environment, or tags to limit the target set')
368
372
  return { results: await engine.cluster(args) }
369
373
  },
370
374
  })