@linxin666/dsh-ssh 0.4.4 → 0.4.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/README.i18n.yaml +2 -2
- package/README.md +16 -2
- package/README.zh.md +16 -2
- package/lib/client.js +62 -14
- package/lib/client.js.map +1 -1
- package/lib/index.js +193 -41
- package/lib/types/client/api.d.ts +21 -10
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/engine/cluster.d.ts +2 -0
- package/lib/types/engine/connection-pool.d.ts +2 -2
- package/lib/types/engine/tunnel.d.ts +2 -0
- package/lib/types/engine.d.ts +2 -1
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +18 -22
- package/src/client/api.ts +72 -15
- package/src/client/locales.ts +2 -2
- package/src/engine/cluster.ts +3 -1
- package/src/engine/connection-pool.ts +47 -2
- package/src/engine/tunnel.ts +74 -19
- package/src/engine.ts +3 -2
- package/src/index.ts +11 -3
- package/src/tool-surface.ts +95 -0
- package/src/tools.ts +15 -16
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- package/src/invariant.ts +0 -4
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.
|
|
4
|
+
"version": "0.4.6",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -14,10 +14,6 @@
|
|
|
14
14
|
"types": "./lib/types/index.d.ts",
|
|
15
15
|
"default": "./lib/index.js"
|
|
16
16
|
},
|
|
17
|
-
"./invariant": {
|
|
18
|
-
"types": "./lib/types/invariant.d.ts",
|
|
19
|
-
"default": "./lib/invariant.js"
|
|
20
|
-
},
|
|
21
17
|
"./client": {
|
|
22
18
|
"types": "./lib/types/client/index.d.ts",
|
|
23
19
|
"default": "./lib/client.js"
|
|
@@ -27,7 +23,7 @@
|
|
|
27
23
|
},
|
|
28
24
|
"dsh": {
|
|
29
25
|
"engines": {
|
|
30
|
-
"dsh": ">=0.2.0-rc.
|
|
26
|
+
"dsh": ">=0.2.0-rc.2"
|
|
31
27
|
},
|
|
32
28
|
"bundle": {
|
|
33
29
|
"patch": "./cordis.patch.yml"
|
|
@@ -50,26 +46,26 @@
|
|
|
50
46
|
"ws": "^8.18.0"
|
|
51
47
|
},
|
|
52
48
|
"peerDependencies": {
|
|
53
|
-
"@deepseek-ai/dsh": ">=0.2.0-rc.
|
|
49
|
+
"@deepseek-ai/dsh": ">=0.2.0-rc.2",
|
|
54
50
|
"react": "^18.2.0",
|
|
55
51
|
"react-dom": "^18.2.0"
|
|
56
52
|
},
|
|
57
53
|
"devDependencies": {
|
|
58
54
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
59
|
-
"@deepseek-ai/dsh-client-connection": "^0.2.0-rc.
|
|
60
|
-
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.
|
|
61
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.
|
|
62
|
-
"@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.
|
|
63
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.
|
|
65
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.
|
|
66
|
-
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.
|
|
67
|
-
"@deepseek-ai/dsh-llm": "^0.2.0-rc.
|
|
68
|
-
"@deepseek-ai/dsh-scope": "^0.2.0-rc.
|
|
69
|
-
"@deepseek-ai/dsh-settings": "^0.2.0-rc.
|
|
70
|
-
"@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.
|
|
71
|
-
"@deepseek-ai/dsh-timeout": "^0.2.0-rc.
|
|
72
|
-
"@deepseek-ai/dsh-tools": "^0.2.0-rc.
|
|
55
|
+
"@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.2",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
|
|
62
|
+
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
|
|
63
|
+
"@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
|
|
64
|
+
"@deepseek-ai/dsh-scope": "^0.2.0-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-timeout": "^0.2.0-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
|
|
73
69
|
"@types/node": "^22.20.0",
|
|
74
70
|
"@types/react": "~18.3.1",
|
|
75
71
|
"@types/react-dom": "^18.3.5",
|
|
@@ -99,7 +95,7 @@
|
|
|
99
95
|
"url": "https://github.com/zhu1090093659/dsh-web.git"
|
|
100
96
|
},
|
|
101
97
|
"scripts": {
|
|
102
|
-
"build": "tsc -
|
|
98
|
+
"build": "tsc -b tsconfig.build.json && tsdown",
|
|
103
99
|
"bundle": "tsdown",
|
|
104
100
|
"watch": "tsdown --watch",
|
|
105
101
|
"typecheck": "tsc --noEmit",
|
package/src/client/api.ts
CHANGED
|
@@ -88,27 +88,84 @@ interface PageLocation {
|
|
|
88
88
|
* The WebSocket URL the terminal route is reached at, or undefined when this
|
|
89
89
|
* page cannot carry one.
|
|
90
90
|
*
|
|
91
|
-
* A page
|
|
92
|
-
* Desktop shell serves its Web GUI from `dsh-app://app/`) has no WebSocket
|
|
93
|
-
* transport: its scheme handler forwards HTTP requests to the local host but
|
|
94
|
-
* cannot upgrade a socket, so `ws://app/...` never connects and the terminal
|
|
95
|
-
* reports "connection error" (issue #1744). Every other page is a web page and
|
|
96
|
-
* resolves the socket against its own origin, exactly as before.
|
|
91
|
+
* A web page resolves the socket against its own origin, exactly as before.
|
|
97
92
|
*
|
|
98
|
-
* A
|
|
99
|
-
*
|
|
100
|
-
*
|
|
93
|
+
* A page delivered by an application on this machine (the official DSH Desktop
|
|
94
|
+
* shell serves its Web GUI from `dsh-app://app/`) cannot carry a socket on its
|
|
95
|
+
* own scheme: the protocol handler forwards HTTP to the local Host but has no
|
|
96
|
+
* upgrade to forward. That is exactly the case the official transport hook
|
|
97
|
+
* covers - the shell publishes `__DSH_TRANSPORT__.streamBaseUrl`, the loopback
|
|
98
|
+
* authority of the Host it owns and already forwards every other request to
|
|
99
|
+
* (issue #1744). The socket is therefore dialed there instead, with the `ws`/
|
|
100
|
+
* `wss` scheme derived from that base, which is what the shell's
|
|
101
|
+
* `onBeforeSendHeaders` hook expects to see before it attaches its
|
|
102
|
+
* authority-bound credential.
|
|
103
|
+
*
|
|
104
|
+
* The base is used only on an application-delivered page. A web page that
|
|
105
|
+
* somehow carries the hook (the remote channel grants `ownsHost` to a paired
|
|
106
|
+
* LAN page, and that page is fenced behind a pairing channel) keeps resolving
|
|
107
|
+
* against its own origin, so this cannot reroute gated traffic onto a host the
|
|
108
|
+
* page is not entitled to reach. A blank authority is treated the same as an
|
|
109
|
+
* application scheme with no base: there is nothing to dial, and the caller
|
|
110
|
+
* gets the actionable reason instead of a socket that can only fail.
|
|
101
111
|
*
|
|
102
112
|
* @param location - the page location to read.
|
|
103
113
|
* @param search - the query string carrying `alias` or `session`.
|
|
114
|
+
* @param streamBaseUrl - `__DSH_TRANSPORT__.streamBaseUrl`, when the shell published one.
|
|
104
115
|
* @returns the absolute `ws:`/`wss:` URL, or undefined when the page cannot carry one.
|
|
105
116
|
*/
|
|
106
|
-
export function terminalSocketUrl(
|
|
117
|
+
export function terminalSocketUrl(
|
|
118
|
+
location: PageLocation,
|
|
119
|
+
search: string,
|
|
120
|
+
streamBaseUrl?: string | undefined,
|
|
121
|
+
): string | undefined {
|
|
107
122
|
if (WEB_PAGE_PROTOCOLS.includes(location.protocol)) {
|
|
108
123
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
|
|
109
124
|
return scheme + '://' + location.host + SSH_API.terminal + search
|
|
110
125
|
}
|
|
111
|
-
|
|
126
|
+
// Application-delivered page: the shell-owned Host authority is the only
|
|
127
|
+
// origin the socket can reach. An unparsable or non-network base is ignored
|
|
128
|
+
// rather than guessed at, so the caller reports the actionable reason.
|
|
129
|
+
const base = hostAuthorityOf(streamBaseUrl)
|
|
130
|
+
if (base === undefined) return undefined
|
|
131
|
+
const scheme = base.protocol === 'https:' ? 'wss' : 'ws'
|
|
132
|
+
return scheme + '://' + base.authority + SSH_API.terminal + search
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The authority and scheme of a shell-owned Host base, or undefined when the
|
|
137
|
+
* value is absent, unparsable, or not a network origin this Host can serve
|
|
138
|
+
* (an application scheme could only resolve back to the page that has no
|
|
139
|
+
* socket transport in the first place).
|
|
140
|
+
* @param base - the raw `streamBaseUrl` as published by the transport hook.
|
|
141
|
+
*/
|
|
142
|
+
function hostAuthorityOf(base: string | undefined): { protocol: string; authority: string } | undefined {
|
|
143
|
+
if (base === undefined || base === '') return undefined
|
|
144
|
+
let url: URL
|
|
145
|
+
try {
|
|
146
|
+
url = new URL(base)
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
|
|
151
|
+
// A base carrying userinfo or credentials would put them on the wire.
|
|
152
|
+
if (url.username !== '' || url.password !== '') return undefined
|
|
153
|
+
if (url.host === '') return undefined
|
|
154
|
+
return { protocol: url.protocol, authority: url.host }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The shell-owned Host authority the page is told to reach, read from the
|
|
159
|
+
* official transport hook. The global is written by the shell before any boot
|
|
160
|
+
* entry runs, so it is absent on every page it does not own.
|
|
161
|
+
* @param globals - the global object to read (defaults to the real one).
|
|
162
|
+
* @returns the published base URL, or undefined when the page carries none.
|
|
163
|
+
*/
|
|
164
|
+
function transportStreamBaseUrl(
|
|
165
|
+
globals: { __DSH_TRANSPORT__?: { streamBaseUrl?: unknown } } = globalThis as never,
|
|
166
|
+
): string | undefined {
|
|
167
|
+
const published = (globals as { __DSH_TRANSPORT__?: { streamBaseUrl?: unknown } }).__DSH_TRANSPORT__?.streamBaseUrl
|
|
168
|
+
return typeof published === 'string' ? published : undefined
|
|
112
169
|
}
|
|
113
170
|
|
|
114
171
|
/**
|
|
@@ -448,11 +505,11 @@ export class SshApi {
|
|
|
448
505
|
|
|
449
506
|
/** One terminal socket over either an alias (open) or a session id (attach). */
|
|
450
507
|
private terminalSocket(search: string): TerminalConnection {
|
|
451
|
-
const target = terminalSocketUrl(window.location, search)
|
|
508
|
+
const target = terminalSocketUrl(window.location, search, transportStreamBaseUrl())
|
|
452
509
|
if (target === undefined) {
|
|
453
|
-
// This page has no WebSocket transport at all
|
|
454
|
-
//
|
|
455
|
-
// a socket that can only report "connection error".
|
|
510
|
+
// This page has no WebSocket transport at all: an application-delivered
|
|
511
|
+
// page whose shell published no Host authority to dial. Say so plainly
|
|
512
|
+
// instead of opening a socket that can only report "connection error".
|
|
456
513
|
return failedTerminal(tt('terminal.noWebSocket'))
|
|
457
514
|
}
|
|
458
515
|
let socket: WebSocket
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
|
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
|
|
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',
|
package/src/engine/cluster.ts
CHANGED
|
@@ -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/tunnel.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface TunnelRecord {
|
|
|
16
16
|
/** The pooled connection this tunnel pins; siblings on one alias may share it. */
|
|
17
17
|
record: PoolRecord
|
|
18
18
|
sockets: Set<Socket>
|
|
19
|
+
/** Detach the link-death hook startTunnel installs on the pooled client. */
|
|
20
|
+
detachLink?: () => void
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** The engine slice the tunnel module needs (adds the tunnel registry). */
|
|
@@ -24,6 +26,22 @@ export interface TunnelEngine extends PoolEngine {
|
|
|
24
26
|
nextTunnelId: number
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/** Destroy one socket or channel stream, tolerating an already-dead peer. */
|
|
30
|
+
function destroyQuietly(target: { destroy(): void }): void {
|
|
31
|
+
try { target.destroy() } catch { /* already closed */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Tear down one tunnel because the SSH link behind it is gone. Idempotent: the
|
|
36
|
+
* tunnel registry is the guard, so a socket callback or a link-death hook that
|
|
37
|
+
* arrives after a manual stop does nothing instead of tearing down twice, and
|
|
38
|
+
* no teardown error can escape into the net connection handler — an exception
|
|
39
|
+
* there terminates the whole host process.
|
|
40
|
+
*/
|
|
41
|
+
function abortTunnel(engine: TunnelEngine, id: string): void {
|
|
42
|
+
try { stopTunnel(engine, id) } catch { /* teardown already in progress */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
/** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
|
|
28
46
|
export async function startTunnel(
|
|
29
47
|
engine: TunnelEngine,
|
|
@@ -58,26 +76,47 @@ export async function startTunnel(
|
|
|
58
76
|
const record = engine.pool.get(alias) ?? (await acquire(engine, alias))
|
|
59
77
|
const client = record.client
|
|
60
78
|
const sockets = new Set<Socket>()
|
|
79
|
+
// A tunnel whose SSH link died can only refuse what it is handed, so every
|
|
80
|
+
// path that learns the link is gone tears the tunnel down as well instead
|
|
81
|
+
// of leaving a listener alive on a connection that forwards nothing.
|
|
82
|
+
const onLinkClosed = (): void => { abortTunnel(engine, id) }
|
|
83
|
+
const detachLink = (): void => { client.off('close', onLinkClosed) }
|
|
61
84
|
const server = createServer((socket) => {
|
|
62
85
|
sockets.add(socket)
|
|
63
86
|
socket.on('close', () => { sockets.delete(socket) })
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
// The link can die between taking the record above and this connection.
|
|
88
|
+
if (record.broken) {
|
|
89
|
+
destroyQuietly(socket)
|
|
90
|
+
abortTunnel(engine, id)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
|
|
95
|
+
if (error !== undefined) {
|
|
96
|
+
// A refused remote channel is the remote service's answer, not a
|
|
97
|
+
// dead SSH link: the tunnel itself stays up for the next attempt.
|
|
98
|
+
destroyQuietly(socket)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
// Both ends of the pipe can die independently; destroy the pair so an
|
|
102
|
+
// unhandled 'error' event can never crash the host process.
|
|
103
|
+
const destroy = (): void => {
|
|
104
|
+
destroyQuietly(socket)
|
|
105
|
+
try { stream.close() } catch { /* gone */ }
|
|
106
|
+
}
|
|
107
|
+
stream.on('error', destroy)
|
|
108
|
+
socket.on('error', destroy)
|
|
109
|
+
stream.on('close', destroy)
|
|
110
|
+
socket.on('close', destroy)
|
|
111
|
+
stream.pipe(socket).pipe(stream)
|
|
112
|
+
})
|
|
113
|
+
} catch {
|
|
114
|
+
// ssh2 reports 'Not connected' by throwing out of forwardOut rather than
|
|
115
|
+
// through the callback; an exception escaping this connection handler
|
|
116
|
+
// is an uncaught exception that takes the host process down with it.
|
|
117
|
+
destroyQuietly(socket)
|
|
118
|
+
abortTunnel(engine, id)
|
|
119
|
+
}
|
|
81
120
|
})
|
|
82
121
|
try {
|
|
83
122
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -96,7 +135,20 @@ export async function startTunnel(
|
|
|
96
135
|
const address = server.address()
|
|
97
136
|
info.localPort = typeof address === 'object' && address !== null ? address.port : 0
|
|
98
137
|
info.state = 'forwarding'
|
|
99
|
-
engine.tunnels.set(id, { info, server, alias, record, sockets })
|
|
138
|
+
engine.tunnels.set(id, { info, server, alias, record, sockets, detachLink })
|
|
139
|
+
// A dead link must not keep a listener alive: without this hook the tunnel
|
|
140
|
+
// outlives its connection and every later local connection hits the dead
|
|
141
|
+
// forwardOut path above.
|
|
142
|
+
client.once('close', onLinkClosed)
|
|
143
|
+
if (record.broken) {
|
|
144
|
+
// A link that died while the listener was binding already emitted 'close',
|
|
145
|
+
// so the pool's broken flag is the only signal left for that window. The
|
|
146
|
+
// tunnel retires right away, and the start reports that instead of
|
|
147
|
+
// handing the caller a forwarding tunnel that no longer exists.
|
|
148
|
+
info.state = 'failed'
|
|
149
|
+
info.error = 'ssh link closed while the tunnel was starting'
|
|
150
|
+
abortTunnel(engine, id)
|
|
151
|
+
}
|
|
100
152
|
return info
|
|
101
153
|
}
|
|
102
154
|
|
|
@@ -110,9 +162,12 @@ export function stopTunnel(engine: TunnelEngine, id: string): boolean {
|
|
|
110
162
|
const tunnel = engine.tunnels.get(id)
|
|
111
163
|
if (tunnel === undefined) return false
|
|
112
164
|
engine.tunnels.delete(id)
|
|
165
|
+
// Drop the link-death hook first: a stop is never the link's death, and a
|
|
166
|
+
// long-lived pooled client outlives many tunnels.
|
|
167
|
+
try { tunnel.detachLink?.() } catch { /* already detached */ }
|
|
113
168
|
try { tunnel.server.close() } catch { /* already closed */ }
|
|
114
169
|
for (const socket of tunnel.sockets) {
|
|
115
|
-
|
|
170
|
+
destroyQuietly(socket)
|
|
116
171
|
}
|
|
117
172
|
tunnel.sockets.clear()
|
|
118
173
|
// Sibling tunnels on the same alias share the pinned connection: release it
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|