@linxin666/dsh-ssh 0.4.4 → 0.4.5

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.
@@ -93,7 +93,7 @@ export const zh = {
93
93
  'terminal.ready': '终端已连接({alias})',
94
94
  'terminal.exited': '终端已退出({alias})',
95
95
  'terminal.error': '终端错误:{error}',
96
- 'terminal.noWebSocket': '当前页面不支持 WebSocket(应用外壳只转发 HTTP),请改用浏览器打开的 Web 界面使用终端。',
96
+ 'terminal.noWebSocket': '当前页面不支持 WebSocket(应用外壳没有可连接的本地主机地址),请改用浏览器打开的 Web 界面使用终端。',
97
97
  'terminal.auth.title': '二次身份验证 (2FA / 交互式认证)',
98
98
  'terminal.auth.submit': '提交验证码',
99
99
  'terminal.auth.cancel': '取消',
@@ -254,7 +254,7 @@ export const en: Record<keyof typeof zh, string> = {
254
254
  'terminal.ready': 'Terminal connected ({alias})',
255
255
  'terminal.exited': 'Terminal exited ({alias})',
256
256
  'terminal.error': 'Terminal error: {error}',
257
- 'terminal.noWebSocket': 'This page cannot carry a WebSocket (the application shell forwards HTTP only). Open the Web UI in a browser to use the terminal.',
257
+ 'terminal.noWebSocket': 'This page cannot carry a WebSocket (the application shell published no local host address to reach). Open the Web UI in a browser to use the terminal.',
258
258
  'terminal.auth.title': 'Two-Factor Authentication (2FA)',
259
259
  'terminal.auth.submit': 'Submit Code',
260
260
  'terminal.auth.cancel': 'Cancel',
@@ -13,6 +13,8 @@ interface ClusterOptions {
13
13
  tags?: string[]
14
14
  timeoutMs?: number
15
15
  maxWorkers?: number
16
+ /** Caller cancellation, forwarded to every host's command. */
17
+ signal?: AbortSignal
16
18
  }
17
19
 
18
20
  /** Run one command against many hosts concurrently. */
@@ -46,7 +48,7 @@ export async function cluster(engine: PoolEngine, options: ClusterOptions): Prom
46
48
  while (queue.length > 0) {
47
49
  const entry = queue.shift()!
48
50
  try {
49
- const result = await execCommand(engine, entry.alias, options.command, options.timeoutMs)
51
+ const result = await execCommand(engine, entry.alias, options.command, options.timeoutMs, options.signal)
50
52
  results.push({ alias: entry.alias, ok: result.success, exitCode: result.exitCode, timedOut: result.timedOut, stdout: result.stdout, stderr: result.stderr, durationMs: result.durationMs })
51
53
  } catch (error) {
52
54
  results.push({ alias: entry.alias, ok: false, error: error instanceof Error ? error.message : String(error) })
@@ -398,7 +398,7 @@ export function sweepPool(engine: PoolEngine): void {
398
398
  * Run `fn` with a live client for `alias`, reconnecting (up to the
399
399
  * attempt budget) when the connection broke mid-flight.
400
400
  */
401
- export async function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts = 3): Promise<T> {
401
+ export async function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts = 3, signal?: AbortSignal): Promise<T> {
402
402
  let lastError: unknown
403
403
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
404
404
  let record = engine.pool.get(alias)
@@ -414,6 +414,9 @@ export async function withClient<T>(engine: PoolEngine, alias: string, fn: (clie
414
414
  return result
415
415
  } catch (error) {
416
416
  lastError = error
417
+ // A cancelled call is never replayed: the caller asked for it to stop,
418
+ // and a retry would re-run a command that may not be idempotent.
419
+ if (signal?.aborted === true) throw error
417
420
  // Retry only when the connection actually broke mid-flight: drop the
418
421
  // corpse and let the next attempt reconnect (a reconnect may replay a
419
422
  // non-idempotent command — the documented trade-off). A failure on a
@@ -428,24 +431,53 @@ export async function withClient<T>(engine: PoolEngine, alias: string, fn: (clie
428
431
  }
429
432
 
430
433
  /** Run one command on `alias` (reusing the pooled connection). */
431
- export async function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
434
+ export async function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number, signal?: AbortSignal): Promise<ExecResult> {
432
435
  const started = Date.now()
433
436
  const budget = timeoutMs !== undefined && timeoutMs > 0 ? timeoutMs : engine.opts.defaultExecTimeoutMs
434
437
  return withClient(engine, alias, async (client) => {
435
438
  return await new Promise<ExecResult>((resolve, reject) => {
439
+ // Caller cancellation is cooperative and quiescent: aborting closes the
440
+ // remote channel and settles this call like a timeout does, so the
441
+ // command stops without the pool replaying it. The channel is captured
442
+ // in a mutable slot because abort may fire before client.exec calls back.
443
+ let aborted = signal !== undefined && signal.aborted
444
+ let kill = (): void => { /* channel not open yet */ }
445
+ const onAbort = (): void => {
446
+ aborted = true
447
+ kill()
448
+ }
449
+ if (signal !== undefined && !signal.aborted) signal.addEventListener('abort', onAbort, { once: true })
436
450
  client.exec(command, (error, stream) => {
437
451
  if (error !== undefined) {
452
+ signal?.removeEventListener('abort', onAbort)
438
453
  reject(error)
439
454
  return
440
455
  }
456
+ kill = (): void => {
457
+ try { stream.signal('KILL') } catch { /* channel gone */ }
458
+ try { stream.close() } catch { /* channel gone */ }
459
+ }
460
+ // The abort may already have arrived while this channel opened.
461
+ if (aborted) kill()
441
462
  const stdout = { text: '', truncated: false }
442
463
  const stderr = { text: '', truncated: false }
443
464
  let timedOut = false
444
465
  let settled = false
466
+ /** One cancelled outcome: the remote channel was killed on caller request. */
467
+ const cancelledResult = (): ExecResult => ({
468
+ success: false,
469
+ exitCode: null,
470
+ timedOut: false,
471
+ stdout: stdout.text,
472
+ stderr: stderr.text,
473
+ durationMs: Date.now() - started,
474
+ error: 'command cancelled by the caller',
475
+ })
445
476
  const finish = (): void => {
446
477
  if (settled) return
447
478
  settled = true
448
479
  clearTimeout(timer)
480
+ signal?.removeEventListener('abort', onAbort)
449
481
  resolve({
450
482
  success: false,
451
483
  exitCode: null,
@@ -470,6 +502,14 @@ export async function execCommand(engine: PoolEngine, alias: string, command: st
470
502
  if (settled) return
471
503
  settled = true
472
504
  clearTimeout(timer)
505
+ signal?.removeEventListener('abort', onAbort)
506
+ // A caller cancellation settles as cancelled, never as a broken
507
+ // connection: the latter would send withClient into a retry that
508
+ // re-runs a command the caller asked to stop.
509
+ if (aborted) {
510
+ resolve(cancelledResult())
511
+ return
512
+ }
473
513
  if (typeof code !== 'number' && !timedOut) {
474
514
  // The channel closed without an exit status: the connection
475
515
  // dropped mid-flight. Reject so withClient can reconnect and
@@ -490,6 +530,11 @@ export async function execCommand(engine: PoolEngine, alias: string, command: st
490
530
  if (settled) return
491
531
  settled = true
492
532
  clearTimeout(timer)
533
+ signal?.removeEventListener('abort', onAbort)
534
+ if (aborted) {
535
+ resolve(cancelledResult())
536
+ return
537
+ }
493
538
  reject(streamError)
494
539
  })
495
540
  })
package/src/engine.ts CHANGED
@@ -74,8 +74,8 @@ export class SshEngine {
74
74
  // ------------------------------------------------------------ exec
75
75
 
76
76
  /** Run one command on `alias` (reusing the pooled connection). */
77
- async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
78
- return execCommand(this, alias, command, timeoutMs)
77
+ async exec(alias: string, command: string, timeoutMs?: number, signal?: AbortSignal): Promise<ExecResult> {
78
+ return execCommand(this, alias, command, timeoutMs, signal)
79
79
  }
80
80
 
81
81
  /** Run one command against many hosts concurrently. */
@@ -86,6 +86,7 @@ export class SshEngine {
86
86
  tags?: string[]
87
87
  timeoutMs?: number
88
88
  maxWorkers?: number
89
+ signal?: AbortSignal
89
90
  }): Promise<ClusterResult[]> {
90
91
  return cluster(this, options)
91
92
  }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import { makeRoutes } from './routes.ts'
18
18
  import { HostStore } from './store.ts'
19
19
  import { sshClusterTool, sshDownloadTool, sshExecTool, sshListTool, sshTunnelTool, sshUploadTool } from './tools.ts'
20
20
  import { mountOnce } from './mount-once.ts'
21
+ import { PLUGIN_TOOL_SECTION_ORDERS, visibleToolText } from './tool-surface.ts'
21
22
 
22
23
  /** Stable cordis plugin name. */
23
24
  export const name = 'ssh'
@@ -117,8 +118,11 @@ function profileConfig(ctx: Context): Record<string, unknown> | undefined {
117
118
  return config as Record<string, unknown>
118
119
  }
119
120
 
120
- /** Order of the announcement section within the tool-guidance band. */
121
- const SECTION_ORDER = 150
121
+ /** Order of the announcement section within the shared tool-guidance band. */
122
+ const SECTION_ORDER = PLUGIN_TOOL_SECTION_ORDERS.ssh
123
+
124
+ /** The six agent-tool names this plugin's announcement describes. */
125
+ const SSH_TOOL_NAMES = ['ssh_list', 'ssh_exec', 'ssh_upload', 'ssh_download', 'ssh_tunnel', 'ssh_cluster'] as const
122
126
 
123
127
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
124
128
  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 跳板机(别名或 [user@]host[:port] 地址)与 OpenSSH 语义的 ProxyCommand(跳过堡垒机客户端场景,只能由用户在 GUI 配置,agent 不可写);Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
@@ -190,7 +194,11 @@ function applyImpl(ctx: Context, config?: ResolvedConfig): void {
190
194
  disposeSection = ctx.systemPrompt.section({
191
195
  name: 'plugin:dsh-ssh',
192
196
  order: SECTION_ORDER,
193
- text: SSH_GUIDANCE,
197
+ // The announcement names the ssh_* tools, so it renders only while at
198
+ // least one of them is reachable: a restriction that removes the tool
199
+ // surface must take the guidance with it, instead of telling the model
200
+ // to call tools this session cannot see.
201
+ text: visibleToolText(ctx.tools, SSH_TOOL_NAMES, SSH_GUIDANCE),
194
202
  })
195
203
  }
196
204
  disposeRoutes = ctx.effect(
@@ -0,0 +1,95 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/tool-surface.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Family-shared tool-surface conventions for the packages that register
4
+ * model-facing tools.
5
+ *
6
+ * One home for the prompt-section placement and the visibility gate this
7
+ * family previously restated per package.
8
+ *
9
+ * Modelled on the official tool packages:
10
+ * - `@deepseek-ai/dsh-tool-subagent` renders its section through
11
+ * `tools.get(name, context.scope) === undefined ? '' : text`, so guidance
12
+ * disappears together with a restricted tool.
13
+ * - 21 of the 22 official tool packages register their tools GLOBALLY and gate
14
+ * them with a setting, not per agent. Only the Agent Teams tool package —
15
+ * whose tools belong to one membership — installs into each `agent.ctx`.
16
+ * This family follows the majority: registration stays global so the
17
+ * registry's per-scope `restrict()` mask (which filters inherited global
18
+ * tools and REJECTS scope-local names) can still withhold these tools.
19
+ *
20
+ * Structural types only: this copy lands in packages that do not all depend on
21
+ * the same SDK service packages.
22
+ */
23
+
24
+ /**
25
+ * Prompt-section order for each package's tool guidance.
26
+ *
27
+ * These sit in the family band between the deployment persona and the SDK's
28
+ * `PLAN_POLICY` (500). Values are owned here rather than written per package so
29
+ * the host plugins whose guidance shares one prompt cannot drift into an
30
+ * accidental tie.
31
+ */
32
+ export const PLUGIN_TOOL_SECTION_ORDERS = {
33
+ ssh: 150,
34
+ 'task-board': 200,
35
+ } as const
36
+
37
+ /** One plugin id keyed by {@link PLUGIN_TOOL_SECTION_ORDERS}. */
38
+ export type PluginToolSectionId = keyof typeof PLUGIN_TOOL_SECTION_ORDERS
39
+
40
+ /**
41
+ * Order of an external provider extension's own guidance section, right after
42
+ * the band of the plugin it extends.
43
+ *
44
+ * An extension names itself, not the plugin it plugs into, so its order stays
45
+ * out of {@link PLUGIN_TOOL_SECTION_ORDERS}: every package's copy of this module
46
+ * then carries no provider id. The slot still has one owner and cannot collide
47
+ * with a host plugin's band.
48
+ */
49
+ export const EXTENSION_TOOL_SECTION_ORDER = 210
50
+
51
+ /**
52
+ * The scoped tool lookup a section provider needs: the registry read for one
53
+ * assembly's scope. Matches `ctx.tools.get(name, scope)`.
54
+ */
55
+ export interface ScopedToolLookup {
56
+ get(name: string, scope?: unknown): unknown
57
+ }
58
+
59
+ /** One assembly context carrying the scope the section is rendered for. */
60
+ export interface SectionScopeContext {
61
+ scope?: unknown
62
+ }
63
+
64
+ /**
65
+ * Build a section provider that renders \`text\` only while at least one of
66
+ * \`toolNames\` is visible to the rendering scope.
67
+ *
68
+ * The native convention: a tool that a restriction, a preset, or a missing
69
+ * registry removed must not leave guidance behind telling the model to call it.
70
+ * An unreadable registry renders the text unchanged — guidance that outlives a
71
+ * momentary read failure is recoverable; guidance for a tool that is gone is
72
+ * not.
73
+ * @param tools - the scoped tool registry, when the deployment serves one.
74
+ * @param toolNames - registered names the guidance describes.
75
+ * @param text - the guidance rendered while they are reachable.
76
+ * @returns the section text provider.
77
+ */
78
+ export function visibleToolText(
79
+ tools: ScopedToolLookup | undefined,
80
+ toolNames: readonly string[],
81
+ text: string,
82
+ ): (context: SectionScopeContext) => string {
83
+ if (tools === undefined) return () => text
84
+ return (context: SectionScopeContext) => {
85
+ for (const name of toolNames) {
86
+ try {
87
+ if (tools.get(name, context.scope) !== undefined) return text
88
+ } catch {
89
+ // A refused lookup is not proof the tool is gone; keep the guidance.
90
+ return text
91
+ }
92
+ }
93
+ return ''
94
+ }
95
+ }
package/src/tools.ts CHANGED
@@ -91,8 +91,7 @@ function renderTunnel(tunnel: TunnelInfo): string {
91
91
  export function sshListTool(engine: SshEngine) {
92
92
  return defineTool({
93
93
  name: 'ssh_list',
94
- description: 'List configured SSH hosts (alias, host, user, auth, environment, tags, description). Use ssh_exec etc. with the alias. ' +
95
- 'Triggers: SSH, remote server, server IP/hostname, connect/login, check server/status, deploy, upload/download, jump host, tunnel, port forward.',
94
+ description: 'List configured SSH hosts (alias, host, user, auth, environment, tags, description). Use ssh_exec etc. with the alias.',
96
95
  parameters: {
97
96
  query: { type: 'string', description: 'Optional fuzzy match against alias, description, host, and tags.' },
98
97
  },
@@ -129,6 +128,8 @@ export function sshListTool(engine: SshEngine) {
129
128
  },
130
129
  render: (_args, value: { hosts?: AgentHostRow[] }) => text(renderHosts(value.hosts ?? [])),
131
130
  },
131
+ // Reads the local host registry only: safe to overlap with sibling calls.
132
+ isConcurrencySafe: () => true,
132
133
  async execute(args) {
133
134
  return { hosts: engine.list(args.query).map(toAgentHostRow) }
134
135
  },
@@ -139,8 +140,7 @@ export function sshListTool(engine: SshEngine) {
139
140
  export function sshExecTool(engine: SshEngine) {
140
141
  return defineTool({
141
142
  name: 'ssh_exec',
142
- 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. ' +
143
- 'Triggers: run command on server, deploy, check server/status, service control, view logs, any remote operation.',
143
+ 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.',
144
144
  parameters: {
145
145
  alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
146
146
  command: { type: 'string', required: true, description: 'The shell command to run remotely.' },
@@ -162,9 +162,11 @@ export function sshExecTool(engine: SshEngine) {
162
162
  },
163
163
  render: (_args, value: ExecResult) => text(renderExec(value)),
164
164
  },
165
- async execute(args) {
165
+ async execute(args, exec) {
166
166
  try {
167
- return await engine.exec(args.alias, args.command, args.timeoutMs)
167
+ // Forward the caller's cancellation: aborting closes the remote channel
168
+ // instead of leaving the command running on the host.
169
+ return await engine.exec(args.alias, args.command, args.timeoutMs, exec.signal)
168
170
  } catch (error) {
169
171
  return {
170
172
  success: false,
@@ -184,8 +186,7 @@ export function sshExecTool(engine: SshEngine) {
184
186
  export function sshUploadTool(engine: SshEngine) {
185
187
  return defineTool({
186
188
  name: 'ssh_upload',
187
- 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. ' +
188
- 'Triggers: upload file to server, deploy artifact, copy config to server.',
189
+ 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.',
189
190
  parameters: {
190
191
  alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
191
192
  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.' },
@@ -221,8 +222,7 @@ export function sshUploadTool(engine: SshEngine) {
221
222
  export function sshDownloadTool(engine: SshEngine) {
222
223
  return defineTool({
223
224
  name: 'ssh_download',
224
- 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. ' +
225
- 'Triggers: download file from server, fetch remote log/artifact.',
225
+ 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.',
226
226
  parameters: {
227
227
  alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
228
228
  remotePath: { type: 'string', required: true, description: 'Absolute path of the source file on the remote SSH host.' },
@@ -257,8 +257,7 @@ export function sshDownloadTool(engine: SshEngine) {
257
257
  export function sshTunnelTool(engine: SshEngine) {
258
258
  return defineTool({
259
259
  name: 'ssh_tunnel',
260
- description: 'Manage local port-forward tunnels to a configured SSH host. Start a tunnel to reach a remote internal service (database, web UI, API) through 127.0.0.1 on this machine. ' +
261
- 'Triggers: tunnel, port forward, connect database, access internal service.',
260
+ description: 'Manage local port-forward tunnels to a configured SSH host. Start a tunnel to reach a remote internal service (database, web UI, API) through 127.0.0.1 on this machine.',
262
261
  parameters: {
263
262
  action: { type: 'string', required: true, enum: ['start', 'list', 'stop', 'stop-all'], description: 'start / list / stop / stop-all.' },
264
263
  alias: { type: 'string', description: 'Host alias (required for start, optional for stop-all).' },
@@ -357,8 +356,7 @@ export function sshTunnelTool(engine: SshEngine) {
357
356
  export function sshClusterTool(engine: SshEngine) {
358
357
  return defineTool({
359
358
  name: 'ssh_cluster',
360
- description: 'Run one command concurrently across selected SSH hosts; at least one aliases, environment, or tags filter is required. ' +
361
- 'Triggers: run on selected servers, batch operation, production servers, cluster command.',
359
+ description: 'Run one command concurrently across selected SSH hosts; at least one aliases, environment, or tags filter is required.',
362
360
  parameters: {
363
361
  command: { type: 'string', required: true, description: 'The shell command to run on every matched host.' },
364
362
  aliases: { type: 'array', items: { type: 'string' }, description: 'Optional alias filter; at least one of aliases, environment, or tags is required.' },
@@ -394,12 +392,13 @@ export function sshClusterTool(engine: SshEngine) {
394
392
  },
395
393
  render: (_args, value: { results?: ClusterResult[] }) => text(renderCluster(value.results ?? [])),
396
394
  },
397
- async execute(args) {
395
+ async execute(args, exec) {
398
396
  const hasSelector = (Array.isArray(args.aliases) && args.aliases.some((alias) => typeof alias === 'string' && alias.trim() !== '')) ||
399
397
  (typeof args.environment === 'string' && args.environment.trim() !== '') ||
400
398
  (Array.isArray(args.tags) && args.tags.some((tag) => typeof tag === 'string' && tag.trim() !== ''))
401
399
  if (!hasSelector) throw new Error('ssh_cluster requires aliases, environment, or tags to limit the target set')
402
- return { results: await engine.cluster(args) }
400
+ // Cancellation reaches every host's in-flight command.
401
+ return { results: await engine.cluster({ ...args, signal: exec.signal }) }
403
402
  },
404
403
  })
405
404
  }