dsh-ssh-tunnel 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1611 @@
1
+ /**
2
+ * dsh-ssh-tunnel — host half
3
+ * LiveAgent-style multi-host SSH + project-scoped grants + SSHManager tool.
4
+ */
5
+ import { createRequire } from 'node:module'
6
+ import { randomUUID } from 'node:crypto'
7
+ import {
8
+ chmodSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ lstatSync,
14
+ statSync,
15
+ writeFileSync,
16
+ renameSync,
17
+ unlinkSync,
18
+ rmdirSync,
19
+ } from 'node:fs'
20
+ import { homedir } from 'node:os'
21
+ import { dirname, join } from 'node:path'
22
+ import { Client } from 'ssh2'
23
+ import {
24
+ normalizeProjectKey,
25
+ constrainToWorkspace,
26
+ workspaceRoot,
27
+ WORKSPACE_ROOT,
28
+ } from './shared/path.js'
29
+ import {
30
+ hostCredentialConfigured,
31
+ publicHost,
32
+ modelHostSummary,
33
+ } from './shared/host-summary.js'
34
+ import { chunkFromBuffer } from './shared/shell-buffer.js'
35
+ import {
36
+ fingerprintFromHostKey,
37
+ formatHostFingerprint,
38
+ isUsableHostFingerprint,
39
+ } from './shared/host-key.js'
40
+ import {
41
+ SSH_KEEPALIVE_COUNT_MAX,
42
+ SSH_KEEPALIVE_INTERVAL_MS,
43
+ SSH_READY_TIMEOUT_MS,
44
+ SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
45
+ SSH_STATUS_DISCONNECTED,
46
+ SSH_STATUS_RECONNECTING,
47
+ disconnectedSessionError,
48
+ isLiveStatus,
49
+ kbiReconnectError,
50
+ kbiUnsupportedError,
51
+ pickOldestTombstone,
52
+ defaultTimeoutMsForAction,
53
+ SSH_TOOL_TIMEOUT_MS,
54
+ withTimeout,
55
+ } from './shared/session-policy.js'
56
+ import { createSessionRegistry } from './session.js'
57
+ import {
58
+ assertSessionStillAuthorized,
59
+ discardRevokedLocalFile,
60
+ } from './shared/session-auth.js'
61
+ import { isTrustedRequest, MAX_JSON_BODY_BYTES } from './shared/http-trust.js'
62
+ import {
63
+ findExistingHostByEndpoint,
64
+ migrateMarkerPath,
65
+ readJsonFile,
66
+ writeJsonAtomic,
67
+ } from './shared/persist.js'
68
+ import { resolveVendorAsset } from './shared/vendor.js'
69
+
70
+ // Out-of-tree plugins cannot reliably resolve @deepseek-ai/dsh-tools (see modlens).
71
+ // Register a raw JSON-Schema tool definition via ctx.tools.register instead of defineTool.
72
+ const requireSelf = createRequire(import.meta.url)
73
+
74
+ let PLUGIN_VERSION = '0.0.0'
75
+ try {
76
+ PLUGIN_VERSION = String(requireSelf('../package.json').version || PLUGIN_VERSION)
77
+ } catch {}
78
+
79
+ export const name = 'dsh-ssh-tunnel'
80
+ export const inject = ['webServer', 'sessions', 'tools', 'webRuntime']
81
+
82
+ /** Set true after SSHManager successfully enters the tools registry. */
83
+ let sshManagerRegistered = false
84
+
85
+ const DATA_DIR = process.env.DSH_HOME
86
+ ? join(process.env.DSH_HOME, 'ssh-tunnel')
87
+ : join(homedir(), '.dsh/ssh-tunnel')
88
+ const HOSTS_PATH = join(DATA_DIR, 'hosts.json')
89
+ const SECRETS_PATH = join(DATA_DIR, 'secrets.json')
90
+ const GRANTS_PATH = join(DATA_DIR, 'grants.json')
91
+ const KNOWN_PATH = join(DATA_DIR, 'known_hosts.json')
92
+
93
+ function rmLocalRecursive(abs) {
94
+ const st = lstatSync(abs)
95
+ if (st.isSymbolicLink() || st.isFile()) {
96
+ unlinkSync(abs)
97
+ return
98
+ }
99
+ if (st.isDirectory()) {
100
+ for (const name of readdirSync(abs)) {
101
+ rmLocalRecursive(join(abs, name))
102
+ }
103
+ rmdirSync(abs)
104
+ return
105
+ }
106
+ unlinkSync(abs)
107
+ }
108
+
109
+ function ensureDataDir() {
110
+ mkdirSync(DATA_DIR, { recursive: true })
111
+ try { chmodSync(DATA_DIR, 0o700) } catch {}
112
+ }
113
+
114
+ function readJson(path, fallback) {
115
+ return readJsonFile(path, fallback)
116
+ }
117
+
118
+ function writeJsonFile(path, data, mode = 0o600) {
119
+ ensureDataDir()
120
+ writeJsonAtomic(path, data, mode)
121
+ }
122
+
123
+
124
+ function sessionCwdOf(ctx, sessionId, clientCwd) {
125
+ try {
126
+ const headerCwd = ctx.sessions?.get?.(sessionId)?.header?.cwd
127
+ if (headerCwd) return normalizeProjectKey(headerCwd)
128
+ } catch {}
129
+ if (clientCwd) return normalizeProjectKey(clientCwd)
130
+ return ''
131
+ }
132
+
133
+ /* ---------------- Host store ---------------- */
134
+
135
+ function emptyHosts() {
136
+ return { version: 1, hosts: [] }
137
+ }
138
+
139
+ function loadHosts() {
140
+ const data = readJson(HOSTS_PATH, emptyHosts())
141
+ if (!Array.isArray(data.hosts)) data.hosts = []
142
+ data.version = 1
143
+ return data
144
+ }
145
+
146
+ function saveHosts(data) {
147
+ writeJsonFile(HOSTS_PATH, { version: 1, hosts: data.hosts || [] }, 0o600)
148
+ }
149
+
150
+ function loadSecrets() {
151
+ return readJson(SECRETS_PATH, { version: 1, byHostId: {} })
152
+ }
153
+
154
+ function saveSecrets(data) {
155
+ writeJsonFile(SECRETS_PATH, { version: 1, byHostId: data.byHostId || {} }, 0o600)
156
+ }
157
+
158
+ function loadGrants() {
159
+ return readJson(GRANTS_PATH, { version: 1, projects: {} })
160
+ }
161
+
162
+ function saveGrants(data) {
163
+ writeJsonFile(GRANTS_PATH, { version: 1, projects: data.projects || {} }, 0o600)
164
+ }
165
+
166
+ function loadKnown() {
167
+ return readJson(KNOWN_PATH, { version: 1, keys: {} })
168
+ }
169
+
170
+ function saveKnown(data) {
171
+ writeJsonFile(KNOWN_PATH, { version: 1, keys: data.keys || {} }, 0o600)
172
+ }
173
+
174
+
175
+
176
+
177
+ function upsertHostRecord(input) {
178
+ const hostsDoc = loadHosts()
179
+ const secrets = loadSecrets()
180
+ const id = (input.id && String(input.id).trim()) || randomUUID()
181
+ const existing = hostsDoc.hosts.find((h) => h.id === id)
182
+ const authType = input.authType || existing?.authType || 'privateKey'
183
+ if (authType === 'keyboardInteractive') {
184
+ throw new Error(kbiUnsupportedError())
185
+ }
186
+ const record = {
187
+ id,
188
+ name: String(input.name || existing?.name || input.host || 'host').trim() || 'host',
189
+ host: String(input.host || existing?.host || '').trim(),
190
+ port: Number(input.port != null ? input.port : existing?.port) || 22,
191
+ username: String(input.username || existing?.username || '').trim(),
192
+ authType,
193
+ privateKeyPath:
194
+ input.privateKeyPath !== undefined
195
+ ? String(input.privateKeyPath || '')
196
+ : existing?.privateKeyPath || '',
197
+ source: input.source || existing?.source || 'manual',
198
+ updatedAt: Date.now(),
199
+ }
200
+ if (!record.host || !record.username) {
201
+ throw new Error('host and username are required')
202
+ }
203
+ const sec = Object.assign({}, secrets.byHostId[id] || {})
204
+ if (input.password !== undefined) {
205
+ if (input.password === '' || input.password == null) delete sec.password
206
+ else sec.password = String(input.password)
207
+ }
208
+ if (input.privateKeyPem !== undefined) {
209
+ if (input.privateKeyPem === '' || input.privateKeyPem == null) delete sec.privateKeyPem
210
+ else sec.privateKeyPem = String(input.privateKeyPem)
211
+ }
212
+ if (input.passphrase !== undefined) {
213
+ if (input.passphrase === '' || input.passphrase == null) delete sec.passphrase
214
+ else sec.passphrase = String(input.passphrase)
215
+ }
216
+ if (Object.keys(sec).length) secrets.byHostId[id] = sec
217
+ else delete secrets.byHostId[id]
218
+
219
+ if (existing) {
220
+ hostsDoc.hosts = hostsDoc.hosts.map((h) => (h.id === id ? record : h))
221
+ } else {
222
+ hostsDoc.hosts.push(record)
223
+ }
224
+ saveHosts(hostsDoc)
225
+ saveSecrets(secrets)
226
+ return publicHost(record, secrets)
227
+ }
228
+
229
+ function deleteHostRecord(id) {
230
+ const hostsDoc = loadHosts()
231
+ hostsDoc.hosts = hostsDoc.hosts.filter((h) => h.id !== id)
232
+ saveHosts(hostsDoc)
233
+ const secrets = loadSecrets()
234
+ delete secrets.byHostId[id]
235
+ saveSecrets(secrets)
236
+ const grants = loadGrants()
237
+ for (const key of Object.keys(grants.projects || {})) {
238
+ const g = grants.projects[key]
239
+ if (g && Array.isArray(g.hostIds)) {
240
+ const had = new Set(g.hostIds).has(id)
241
+ g.hostIds = g.hostIds.filter((x) => x !== id)
242
+ g.updatedAt = Date.now()
243
+ if (had) registry.closeSessionsForHost(key, id)
244
+ }
245
+ }
246
+ saveGrants(grants)
247
+ return { ok: true }
248
+ }
249
+
250
+ function scanOpenSsh() {
251
+ const dirs = [join(homedir(), '.ssh'), '/workspace/.ssh']
252
+ const found = []
253
+ const configPaths = dirs.map((d) => join(d, 'config'))
254
+ for (const cfgPath of configPaths) {
255
+ if (!existsSync(cfgPath)) continue
256
+ let text = ''
257
+ try { text = readFileSync(cfgPath, 'utf8') } catch { continue }
258
+ let current = null
259
+ const flush = () => {
260
+ if (current && current.patterns.length) found.push(current)
261
+ current = null
262
+ }
263
+ for (const line of text.split(/\r?\n/)) {
264
+ const t = line.trim()
265
+ if (!t || t.startsWith('#')) continue
266
+ const m = /^(Host|HostName|User|Port|IdentityFile)\s+(.+)$/i.exec(t)
267
+ if (!m) continue
268
+ const key = m[1].toLowerCase()
269
+ const val = m[2].trim().replace(/^"|"$/g, '')
270
+ if (key === 'host') {
271
+ flush()
272
+ const patterns = val.split(/\s+/).filter((p) => p && p !== '*')
273
+ current = { patterns, hostName: '', user: '', port: 22, identityFile: '' }
274
+ } else if (current) {
275
+ if (key === 'hostname') current.hostName = val
276
+ if (key === 'user') current.user = val
277
+ if (key === 'port') current.port = Number(val) || 22
278
+ if (key === 'identityfile') current.identityFile = val.replace(/^~(?=\/)/, homedir())
279
+ }
280
+ }
281
+ flush()
282
+ }
283
+ const keyFiles = []
284
+ for (const dir of dirs) {
285
+ if (!existsSync(dir)) continue
286
+ try {
287
+ for (const name of readdirSync(dir)) {
288
+ if (name.endsWith('.pub') || name === 'config' || name === 'known_hosts' || name === 'authorized_keys') continue
289
+ const p = join(dir, name)
290
+ try {
291
+ if (statSync(p).isFile()) keyFiles.push(p)
292
+ } catch {}
293
+ }
294
+ } catch {}
295
+ }
296
+ return {
297
+ configs: found.map((c) => ({
298
+ name: c.patterns[0],
299
+ patterns: c.patterns,
300
+ host: c.hostName || c.patterns[0],
301
+ username: c.user || '',
302
+ port: c.port || 22,
303
+ privateKeyPath: c.identityFile || '',
304
+ authType: 'privateKey',
305
+ })),
306
+ privateKeys: keyFiles,
307
+ }
308
+ }
309
+
310
+ function importScanEntry(entry) {
311
+ return upsertHostRecord({
312
+ name: entry.name || entry.host,
313
+ host: entry.host,
314
+ port: entry.port || 22,
315
+ username: entry.username || 'root',
316
+ authType: 'privateKey',
317
+ privateKeyPath: entry.privateKeyPath || '',
318
+ source: 'openssh-scan',
319
+ })
320
+ }
321
+
322
+ /* ---------------- Grants ---------------- */
323
+
324
+ function getProjectGrants(projectPathKey) {
325
+ const key = normalizeProjectKey(projectPathKey)
326
+ const grants = loadGrants()
327
+ const g = grants.projects[key] || { hostIds: [], updatedAt: 0 }
328
+ return { projectPathKey: key, hostIds: [...(g.hostIds || [])], updatedAt: g.updatedAt || 0 }
329
+ }
330
+
331
+ function setProjectGrants(projectPathKey, hostIds) {
332
+ const key = normalizeProjectKey(projectPathKey)
333
+ if (!key) throw new Error('projectPathKey required')
334
+ const grants = loadGrants()
335
+ const ids = [...new Set((hostIds || []).map((x) => String(x).trim()).filter(Boolean))]
336
+ const prev = new Set((grants.projects[key]?.hostIds) || [])
337
+ grants.projects[key] = { hostIds: ids, updatedAt: Date.now() }
338
+ saveGrants(grants)
339
+ // Security: removing a host from a project grant must terminate its live
340
+ // sessions in this project, not just block new connections.
341
+ for (const removed of prev) {
342
+ if (!ids.includes(String(removed))) registry.closeSessionsForHost(key, String(removed))
343
+ }
344
+ return getProjectGrants(key)
345
+ }
346
+
347
+ /**
348
+ * Authorization predicate for using an SSH session: the host must still exist
349
+ * and be granted to the session's own project at use time. Re-evaluated on
350
+ * every session use / reconnect so a grant revocation takes effect immediately.
351
+ */
352
+ function isHostAuthorized(projectPathKey, hostId) {
353
+ const key = normalizeProjectKey(projectPathKey)
354
+ if (!key || !hostId) return false
355
+ const { hostIds } = getProjectGrants(key)
356
+ return hostIds.includes(String(hostId)) && !!getHostById(String(hostId))
357
+ }
358
+
359
+ /* ---------------- Sessions ---------------- */
360
+
361
+ /** @type {Map<string, any>} */
362
+ const pendingPrompts = new Map()
363
+
364
+ function loadPrivateKeyMaterial(host, secrets) {
365
+ const sec = secrets.byHostId[host.id] || {}
366
+ if (sec.privateKeyPem) return Buffer.from(sec.privateKeyPem)
367
+ if (host.privateKeyPath) {
368
+ const p = String(host.privateKeyPath).replace(/^~(?=\/)/, homedir())
369
+ if (!existsSync(p)) throw new Error('Cannot read private key: ' + p)
370
+ return readFileSync(p)
371
+ }
372
+ throw new Error('No private key configured')
373
+ }
374
+
375
+ function buildConnectOpts(host, secrets) {
376
+ const opts = {
377
+ host: host.host,
378
+ port: host.port || 22,
379
+ username: host.username,
380
+ readyTimeout: SSH_READY_TIMEOUT_MS,
381
+ keepaliveInterval: SSH_KEEPALIVE_INTERVAL_MS,
382
+ keepaliveCountMax: SSH_KEEPALIVE_COUNT_MAX,
383
+ // Pass a hex digest into hostVerifier (not a raw key Buffer).
384
+ hostHash: 'sha256',
385
+ }
386
+ const sec = secrets.byHostId[host.id] || {}
387
+ if (host.authType === 'password') {
388
+ if (!sec.password) throw new Error('password not configured')
389
+ opts.password = sec.password
390
+ } else if (host.authType === 'keyboardInteractive') {
391
+ throw new Error(kbiUnsupportedError())
392
+ } else {
393
+ opts.privateKey = loadPrivateKeyMaterial(host, secrets)
394
+ if (sec.passphrase) opts.passphrase = sec.passphrase
395
+ }
396
+ return opts
397
+ }
398
+
399
+ function knownKeyId(host, port) {
400
+ return `${host}:${port || 22}`
401
+ }
402
+
403
+ function connectSsh(host, secrets, options = {}) {
404
+ return new Promise((resolve, reject) => {
405
+ const client = new Client()
406
+ const opts = buildConnectOpts(host, secrets)
407
+ const known = loadKnown()
408
+ const kid = knownKeyId(host.host, host.port)
409
+ let settled = false
410
+
411
+ const finishErr = (e) => {
412
+ if (settled) return
413
+ settled = true
414
+ try { client.end() } catch {}
415
+ reject(e instanceof Error ? e : new Error(String(e)))
416
+ }
417
+ const finishOk = (value) => {
418
+ if (settled) return
419
+ settled = true
420
+ resolve(value)
421
+ }
422
+
423
+ // ssh2 hostVerifier: with hostHash, key is sha256 hex; otherwise Buffer (we still normalize).
424
+ opts.hostVerifier = (hashedKey, callback) => {
425
+ const fp = fingerprintFromHostKey(hashedKey)
426
+ const stored = known.keys[kid]
427
+ const storedFp = stored && isUsableHostFingerprint(stored.fingerprint)
428
+ ? String(stored.fingerprint).trim().toLowerCase()
429
+ : ''
430
+ const decide = (ok) => {
431
+ if (typeof callback === 'function') {
432
+ try { callback(ok) } catch {}
433
+ return
434
+ }
435
+ return ok
436
+ }
437
+ const remember = () => {
438
+ known.keys[kid] = {
439
+ fingerprint: fp,
440
+ algo: 'sha256',
441
+ trustedAt: Date.now(),
442
+ }
443
+ saveKnown(known)
444
+ }
445
+ // Missing, legacy binary String(Buffer), or unusable entries → re-prompt (safe migration).
446
+ if (!stored || !storedFp) {
447
+ if (options.trustHostKey) {
448
+ remember()
449
+ return decide(true)
450
+ }
451
+ const promptId = randomUUID()
452
+ pendingPrompts.set(promptId, {
453
+ kind: 'host_key',
454
+ hostId: host.id,
455
+ host: host.host,
456
+ port: host.port || 22,
457
+ fingerprint: fp,
458
+ fingerprintAlgo: 'SHA256',
459
+ message: `Unknown host key for ${host.host}:${host.port || 22} (${formatHostFingerprint(fp)})`,
460
+ })
461
+ finishErr(Object.assign(new Error('SSH_PROMPT'), { promptId, kind: 'host_key' }))
462
+ return decide(false)
463
+ }
464
+ if (storedFp !== fp) {
465
+ const promptId = randomUUID()
466
+ pendingPrompts.set(promptId, {
467
+ kind: 'host_key_changed',
468
+ hostId: host.id,
469
+ host: host.host,
470
+ port: host.port || 22,
471
+ fingerprint: fp,
472
+ fingerprintAlgo: 'SHA256',
473
+ previous: storedFp,
474
+ message: `Host key changed for ${host.host}:${host.port || 22} (new ${formatHostFingerprint(fp)})`,
475
+ })
476
+ finishErr(Object.assign(new Error('SSH_PROMPT'), { promptId, kind: 'host_key_changed' }))
477
+ return decide(false)
478
+ }
479
+ return decide(true)
480
+ }
481
+
482
+ client.on('ready', () => finishOk(client))
483
+ client.on('error', finishErr)
484
+ try {
485
+ client.connect(opts)
486
+ } catch (e) {
487
+ finishErr(e)
488
+ }
489
+ })
490
+ }
491
+
492
+ const registry = createSessionRegistry({
493
+ connectClient: (host, secrets, options) => connectSsh(host, secrets, options),
494
+ loadSecrets,
495
+ getPrompt: (promptId) => pendingPrompts.get(promptId),
496
+ getHostById,
497
+ isHostAuthorized,
498
+ })
499
+
500
+ /* ---------------- SFTP helpers ---------------- */
501
+
502
+ function sftpTimed(action, work, opts = {}, onTimeout) {
503
+ return withTimeout(typeof work === 'function' ? work() : work, opts.timeoutMs ?? defaultTimeoutMsForAction(action), {
504
+ signal: opts.signal,
505
+ onTimeout,
506
+ })
507
+ }
508
+
509
+ function sftpStat(sftp, remotePath, opts = {}) {
510
+ return sftpTimed(
511
+ 'sftp_stat',
512
+ () => new Promise((resolve, reject) => {
513
+ sftp.stat(remotePath, (err, st) => (err ? reject(err) : resolve(st)))
514
+ }),
515
+ opts,
516
+ )
517
+ }
518
+
519
+ function sftpReaddir(sftp, remotePath, opts = {}) {
520
+ return sftpTimed(
521
+ 'sftp_list',
522
+ () => new Promise((resolve, reject) => {
523
+ sftp.readdir(remotePath, (err, list) => (err ? reject(err) : resolve(list || [])))
524
+ }),
525
+ opts,
526
+ )
527
+ }
528
+
529
+ function sftpReadFile(sftp, remotePath, maxBytes = 512 * 1024, opts = {}) {
530
+ let rs
531
+ return sftpTimed(
532
+ 'sftp_read_text',
533
+ () => new Promise((resolve, reject) => {
534
+ const chunks = []
535
+ let total = 0
536
+ rs = sftp.createReadStream(remotePath)
537
+ rs.on('data', (c) => {
538
+ total += c.length
539
+ if (total <= maxBytes) chunks.push(c)
540
+ })
541
+ rs.on('error', reject)
542
+ rs.on('end', () => {
543
+ const buf = Buffer.concat(chunks)
544
+ resolve({ content: buf.toString('utf8'), truncated: total > maxBytes, size: total })
545
+ })
546
+ }),
547
+ opts,
548
+ () => {
549
+ try { rs?.destroy?.() } catch {}
550
+ },
551
+ )
552
+ }
553
+
554
+ function sftpWriteFile(sftp, remotePath, content, opts = {}) {
555
+ let ws
556
+ return sftpTimed(
557
+ 'sftp_write_text',
558
+ () => new Promise((resolve, reject) => {
559
+ ws = sftp.createWriteStream(remotePath)
560
+ ws.on('error', reject)
561
+ ws.on('close', () => resolve({ ok: true }))
562
+ ws.end(content)
563
+ }),
564
+ opts,
565
+ () => {
566
+ try { ws?.destroy?.() } catch {}
567
+ },
568
+ )
569
+ }
570
+
571
+ function sftpMkdir(sftp, remotePath, opts = {}) {
572
+ return sftpTimed(
573
+ 'sftp_mkdir',
574
+ () => new Promise((resolve, reject) => {
575
+ sftp.mkdir(remotePath, (err) => (err ? reject(err) : resolve({ ok: true })))
576
+ }),
577
+ opts,
578
+ )
579
+ }
580
+
581
+ function sftpRename(sftp, from, to, opts = {}) {
582
+ return sftpTimed(
583
+ 'sftp_rename',
584
+ () => new Promise((resolve, reject) => {
585
+ sftp.rename(from, to, (err) => (err ? reject(err) : resolve({ ok: true })))
586
+ }),
587
+ opts,
588
+ )
589
+ }
590
+
591
+ function sftpUnlink(sftp, remotePath, opts = {}) {
592
+ return sftpTimed(
593
+ 'sftp_delete',
594
+ () => new Promise((resolve, reject) => {
595
+ sftp.unlink(remotePath, (err) => (err ? reject(err) : resolve({ ok: true })))
596
+ }),
597
+ opts,
598
+ )
599
+ }
600
+
601
+ function sftpRmdir(sftp, remotePath, opts = {}) {
602
+ return sftpTimed(
603
+ 'sftp_delete',
604
+ () => new Promise((resolve, reject) => {
605
+ sftp.rmdir(remotePath, (err) => (err ? reject(err) : resolve({ ok: true })))
606
+ }),
607
+ opts,
608
+ )
609
+ }
610
+
611
+ function sftpFastGet(sftp, remote, local, opts = {}) {
612
+ return sftpTimed(
613
+ 'sftp_download',
614
+ () => new Promise((resolve, reject) => {
615
+ sftp.fastGet(remote, local, (err) => (err ? reject(err) : resolve({ ok: true })))
616
+ }),
617
+ opts,
618
+ )
619
+ }
620
+
621
+ function sftpFastPut(sftp, local, remote, opts = {}) {
622
+ return sftpTimed(
623
+ 'sftp_upload',
624
+ () => new Promise((resolve, reject) => {
625
+ sftp.fastPut(local, remote, (err) => (err ? reject(err) : resolve({ ok: true })))
626
+ }),
627
+ opts,
628
+ )
629
+ }
630
+
631
+ /**
632
+ * Local root for sidebar file operations: the project workspace it displays.
633
+ * The client already sends this key, so the guard can use the real workspace
634
+ * instead of a hard-coded `/workspace`.
635
+ */
636
+ function localProjectRoot(body) {
637
+ const src = body || {}
638
+ return normalizeProjectKey(src.projectPathKey || src.cwd || '')
639
+ }
640
+
641
+ /**
642
+ * Guard a local path against the project workspace the user opened rather than
643
+ * a fixed `/workspace`: on hosts whose workspace is a Windows drive path the
644
+ * fixed root rejects every real local path, which breaks SFTP upload/download
645
+ * and local file browsing.
646
+ */
647
+ function assertWorkspaceLocal(localPath, projectPathKey = '') {
648
+ return constrainToWorkspace(localPath, { root: projectPathKey })
649
+ }
650
+
651
+ /* ---------------- Resolve session for tools ---------------- */
652
+
653
+ function getHostById(id) {
654
+ return loadHosts().hosts.find((h) => h.id === id)
655
+ }
656
+
657
+ function allowedHostMap(projectPathKey) {
658
+ const { hostIds } = getProjectGrants(projectPathKey)
659
+ const secrets = loadSecrets()
660
+ const map = new Map()
661
+ for (const id of hostIds) {
662
+ const h = getHostById(id)
663
+ if (h) map.set(id, h)
664
+ }
665
+ return { map, secrets, hostIds }
666
+ }
667
+
668
+ async function resolveSession(args, projectPathKey, needsSftp, signal) {
669
+ const strategy = args.session_strategy || 'reuse_or_create'
670
+ if (!['reuse_or_create', 'new', 'require_existing'].includes(strategy)) {
671
+ throw new Error('SSHManager.session_strategy is invalid.')
672
+ }
673
+ const sessionId = args.session_id ? String(args.session_id).trim() : ''
674
+ const { map: allowed, hostIds } = allowedHostMap(projectPathKey)
675
+ if (!hostIds.length) throw new Error('No SSH hosts are associated with the current project.')
676
+
677
+ if (sessionId) {
678
+ if (strategy === 'new') throw new Error('SSHManager.session_strategy=new cannot be combined with session_id.')
679
+ const s = registry.get(sessionId)
680
+ if (!s || s.projectPathKey !== normalizeProjectKey(projectPathKey)) {
681
+ throw new Error('SSH session not found in the current project.')
682
+ }
683
+ if (!allowed.has(s.hostId)) throw new Error('SSH session host is not authorized for the current project.')
684
+ if (needsSftp && !s.sftpEnabled) {
685
+ throw new Error('SSH session does not have SFTP enabled. Use host_id to reuse or create an SFTP-enabled session.')
686
+ }
687
+ if (s.status === SSH_STATUS_DISCONNECTED) {
688
+ throw new Error(disconnectedSessionError())
689
+ }
690
+ if (s.status === SSH_STATUS_RECONNECTING) {
691
+ const live = await registry.waitForLive(sessionId, SSH_RECONNECT_ATTEMPT_TIMEOUT_MS)
692
+ if (needsSftp && !live.sftpEnabled) {
693
+ throw new Error('SSH session does not have SFTP enabled. Use host_id to reuse or create an SFTP-enabled session.')
694
+ }
695
+ return { session: live, reused: true, created: false, strategy: 'session_id' }
696
+ }
697
+ if (!isLiveStatus(s.status, s.running) || !s.client) {
698
+ throw new Error('session not running')
699
+ }
700
+ return { session: s, reused: true, created: false, strategy: 'session_id' }
701
+ }
702
+
703
+ const hostId = String(args.host_id || '').trim()
704
+ if (!hostId) throw new Error('SSHManager.host_id is required.')
705
+ const host = allowed.get(hostId)
706
+ if (!host) throw new Error('SSH host is not associated with the current project.')
707
+
708
+ if (strategy !== 'new') {
709
+ const reusable = registry.findReusableSession(projectPathKey, hostId, !!needsSftp)
710
+ if (reusable) {
711
+ return { session: reusable, reused: true, created: false, strategy }
712
+ }
713
+ if (strategy === 'reuse_or_create') {
714
+ const tomb = pickOldestTombstone([...registry.sessions.values()], {
715
+ projectPathKey: normalizeProjectKey(projectPathKey),
716
+ hostId,
717
+ needsSftp: !!needsSftp,
718
+ })
719
+ if (tomb) {
720
+ if (host.authType === 'keyboardInteractive') throw new Error(kbiUnsupportedError())
721
+ await registry.reconnectSession(tomb.id, {
722
+ manual: true,
723
+ signal,
724
+ projectPathKey,
725
+ })
726
+ const revived = registry.get(tomb.id)
727
+ if (!revived || !isLiveStatus(revived.status, revived.running)) {
728
+ throw new Error('SSH reconnect failed; session left disconnected.')
729
+ }
730
+ return { session: revived, reused: true, created: false, revived: true, strategy }
731
+ }
732
+ }
733
+ if (strategy === 'require_existing') {
734
+ throw new Error('No reusable SSH session exists for this host in the current project.')
735
+ }
736
+ }
737
+
738
+ if (host.authType === 'keyboardInteractive') {
739
+ throw new Error(kbiUnsupportedError())
740
+ }
741
+
742
+ const created = await registry.createLiveSession({
743
+ host,
744
+ projectPathKey,
745
+ title: args.title || `SSHManager: ${host.name || host.host}`,
746
+ sftpEnabled: true,
747
+ trustHostKey: false,
748
+ })
749
+ if (!created.ok) {
750
+ throw new Error(created.message || '请先在 SSH 隧道 Tab 手动完成连接/信任/MFA 后重试。')
751
+ }
752
+ const s = registry.get(created.session.session_id)
753
+ return { session: s, reused: false, created: true, strategy }
754
+ }
755
+
756
+ /* ---------------- HTTP helpers ---------------- */
757
+
758
+ function writeJson(res, status, body) {
759
+ res.statusCode = status
760
+ res.setHeader('content-type', 'application/json; charset=utf-8')
761
+ res.setHeader('cache-control', 'no-store')
762
+ res.end(JSON.stringify(body))
763
+ }
764
+
765
+ /** Serve an allowlisted vendor asset (browser terminal UMD/CSS) same-origin. */
766
+ async function serveVendor(req, res) {
767
+ try {
768
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
769
+ res.writeHead(405)
770
+ res.end()
771
+ return
772
+ }
773
+ const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
774
+ const prefix = '/dsh-ssh-tunnel/vendor/'
775
+ if (!pathname.startsWith(prefix)) {
776
+ res.writeHead(404)
777
+ res.end()
778
+ return
779
+ }
780
+ const name = pathname.slice(prefix.length)
781
+ // allowlist only: the resolver throws for anything else (incl. traversal)
782
+ const { path: filePath, type } = resolveVendorAsset(name)
783
+ const body = readFileSync(filePath)
784
+ res.statusCode = 200
785
+ res.setHeader('content-type', type)
786
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable')
787
+ res.setHeader('x-content-type-options', 'nosniff')
788
+ res.end(req.method === 'HEAD' ? undefined : body)
789
+ } catch (error) {
790
+ res.writeHead(404)
791
+ res.end()
792
+ }
793
+ }
794
+
795
+ async function readJsonBody(req) {
796
+ const chunks = []
797
+ let total = 0
798
+ for await (const chunk of req) {
799
+ total += chunk.length
800
+ if (total > MAX_JSON_BODY_BYTES) {
801
+ throw new Error('request body too large')
802
+ }
803
+ chunks.push(chunk)
804
+ }
805
+ if (!chunks.length) return {}
806
+ const raw = Buffer.concat(chunks).toString('utf8')
807
+ if (!raw.trim()) return {}
808
+ return JSON.parse(raw)
809
+ }
810
+
811
+ /* ---------------- API + Tool ---------------- */
812
+
813
+ async function handleApi(method, body, ctx) {
814
+ if (method === 'health') {
815
+ return {
816
+ ok: true,
817
+ version: PLUGIN_VERSION,
818
+ name: 'dsh-ssh-tunnel',
819
+ sshManagerRegistered,
820
+ }
821
+ }
822
+
823
+ if (method === 'getProjectContext') {
824
+ const sessionId = body.sessionId || ''
825
+ const projectPathKey = sessionCwdOf(ctx, sessionId, body.cwd)
826
+ return { sessionId, projectPathKey, hasProject: !!projectPathKey }
827
+ }
828
+
829
+ if (method === 'listHosts') {
830
+ const secrets = loadSecrets()
831
+ return { hosts: loadHosts().hosts.map((h) => publicHost(h, secrets)) }
832
+ }
833
+
834
+ if (method === 'saveHost') {
835
+ return { ok: true, host: upsertHostRecord(body.host || body) }
836
+ }
837
+
838
+ if (method === 'deleteHost') {
839
+ return deleteHostRecord(String(body.id || ''))
840
+ }
841
+
842
+ if (method === 'scanOpenSsh') {
843
+ return scanOpenSsh()
844
+ }
845
+
846
+ if (method === 'importScan') {
847
+ const host = importScanEntry(body.entry || body)
848
+ return { ok: true, host }
849
+ }
850
+
851
+ if (method === 'getGrants') {
852
+ const projectPathKey = normalizeProjectKey(body.projectPathKey || body.cwd || '')
853
+ return getProjectGrants(projectPathKey)
854
+ }
855
+
856
+ if (method === 'setGrants') {
857
+ return setProjectGrants(body.projectPathKey || body.cwd, body.hostIds || [])
858
+ }
859
+
860
+ if (method === 'listSessions') {
861
+ const projectPathKey = normalizeProjectKey(body.projectPathKey || body.cwd || '')
862
+ return { sessions: registry.listSessionsForProject(projectPathKey) }
863
+ }
864
+
865
+ if (method === 'connect') {
866
+ const host = getHostById(body.hostId || body.host_id)
867
+ if (!host) throw new Error('host not found')
868
+ const projectPathKey = normalizeProjectKey(body.projectPathKey || body.cwd || '')
869
+ if (!projectPathKey) throw new Error('projectPathKey required')
870
+ // Project access is explicit (Grants tab / setGrants). Connect must not silently expand it.
871
+ const grants = getProjectGrants(projectPathKey)
872
+ if (!grants.hostIds.includes(host.id)) {
873
+ throw new Error(
874
+ 'host is not authorized for this project; grant access under Project access before connecting',
875
+ )
876
+ }
877
+ const result = await registry.createLiveSession({
878
+ host,
879
+ projectPathKey,
880
+ title: body.title,
881
+ sftpEnabled: body.sftpEnabled !== false,
882
+ trustHostKey: !!body.trustHostKey,
883
+ })
884
+ return result
885
+ }
886
+
887
+ if (method === 'disconnect') {
888
+ return registry.closeSession(String(body.sessionId || body.session_id || ''), body.projectPathKey)
889
+ }
890
+
891
+ if (method === 'reconnect') {
892
+ return registry.reconnectSession(String(body.sessionId || body.session_id || ''), {
893
+ manual: true,
894
+ projectPathKey: body.projectPathKey,
895
+ })
896
+ }
897
+
898
+ if (method === 'answerPrompt') {
899
+ const promptId = String(body.promptId || '')
900
+ const p = pendingPrompts.get(promptId)
901
+ if (!p) throw new Error('prompt not found')
902
+ if (body.trustHostKey && (p.kind === 'host_key' || p.kind === 'host_key_changed')) {
903
+ const fp = fingerprintFromHostKey(p.fingerprint)
904
+ if (!isUsableHostFingerprint(fp)) {
905
+ throw new Error('invalid host key fingerprint in prompt')
906
+ }
907
+ const known = loadKnown()
908
+ known.keys[knownKeyId(p.host, p.port)] = {
909
+ fingerprint: fp,
910
+ algo: 'sha256',
911
+ trustedAt: Date.now(),
912
+ }
913
+ saveKnown(known)
914
+ pendingPrompts.delete(promptId)
915
+ if (body.connectAfter) {
916
+ const host = getHostById(p.hostId)
917
+ if (!host) return { ok: true, trusted: true }
918
+ const projectPathKey = normalizeProjectKey(body.projectPathKey || body.cwd || '')
919
+ if (!projectPathKey) return { ok: true, trusted: true, connected: false, error: 'projectPathKey required' }
920
+ const grants = getProjectGrants(projectPathKey)
921
+ if (!grants.hostIds.includes(host.id)) {
922
+ return {
923
+ ok: true,
924
+ trusted: true,
925
+ connected: false,
926
+ error:
927
+ 'host is not authorized for this project; grant access under Project access before connecting',
928
+ }
929
+ }
930
+ return registry.createLiveSession({
931
+ host,
932
+ projectPathKey,
933
+ title: body.title,
934
+ sftpEnabled: body.sftpEnabled !== false,
935
+ trustHostKey: true,
936
+ })
937
+ }
938
+ return { ok: true, trusted: true }
939
+ }
940
+ pendingPrompts.delete(promptId)
941
+ return { ok: true }
942
+ }
943
+
944
+ if (method === 'listPrompts') {
945
+ return {
946
+ prompts: [...pendingPrompts.entries()].map(([id, p]) => Object.assign({ promptId: id }, p)),
947
+ }
948
+ }
949
+
950
+ if (method === 'shellOpen') {
951
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
952
+ await registry.ensureShell(s)
953
+ assertSessionStillAuthorized(s, isHostAuthorized)
954
+ return {
955
+ ok: true,
956
+ session_id: s.id,
957
+ output: s.getOutputText ? s.getOutputText() : s.outputBuf.join(''),
958
+ seq: s.outputSeq || 0,
959
+ }
960
+ }
961
+
962
+ if (method === 'shellRead') {
963
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
964
+ if (!s.shellStream) await registry.ensureShell(s)
965
+ const since = body.since != null ? Number(body.since) : 0
966
+ const sliced = s.chunkFrom
967
+ ? s.chunkFrom(since)
968
+ : (() => {
969
+ const full = s.outputBuf.join('')
970
+ const c = chunkFromBuffer(full, since)
971
+ return { full, chunk: c.chunk, length: c.length, since: c.since }
972
+ })()
973
+ const out = {
974
+ ok: true,
975
+ session_id: s.id,
976
+ chunk: sliced.chunk,
977
+ length: sliced.length,
978
+ since: sliced.since,
979
+ seq: s.outputSeq || 0,
980
+ running: !!s.running,
981
+ }
982
+ // Only include full buffer when explicitly requested (debug / first paint helpers)
983
+ if (body.full === true) out.output = sliced.full
984
+ assertSessionStillAuthorized(s, isHostAuthorized)
985
+ return out
986
+ }
987
+
988
+ if (method === 'shellWrite') {
989
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
990
+ const stream = await registry.ensureShell(s)
991
+ const data = body.data != null ? String(body.data) : ''
992
+ if (!data) throw new Error('data required')
993
+ stream.write(data)
994
+ s.updatedAt = Date.now()
995
+ assertSessionStillAuthorized(s, isHostAuthorized)
996
+ return { ok: true }
997
+ }
998
+
999
+ if (method === 'shellResize') {
1000
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
1001
+ const stream = await registry.ensureShell(s)
1002
+ const cols = Number(body.cols) || 80
1003
+ const rows = Number(body.rows) || 24
1004
+ if (typeof stream.setWindow === 'function') stream.setWindow(rows, cols, 0, 0)
1005
+ assertSessionStillAuthorized(s, isHostAuthorized)
1006
+ return { ok: true, cols, rows }
1007
+ }
1008
+
1009
+ if (method === 'sftpList') {
1010
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
1011
+ if (!s.sftp) throw new Error('SFTP not enabled on this session')
1012
+ const remotePath = String(body.path || '.')
1013
+ const list = await sftpReaddir(s.sftp, remotePath)
1014
+ const entries = []
1015
+ for (const e of list) {
1016
+ const name = e.filename
1017
+ if (!name || name === '.') continue
1018
+ const attrs = e.attrs || {}
1019
+ let isDir = false
1020
+ try {
1021
+ if (typeof attrs.isDirectory === 'function') isDir = attrs.isDirectory()
1022
+ else if (attrs.mode != null) isDir = (attrs.mode & 0o170000) === 0o040000
1023
+ } catch {}
1024
+ entries.push({
1025
+ name,
1026
+ path: remotePath === '/' ? '/' + name : (remotePath.replace(/\/$/, '') + '/' + name),
1027
+ isDirectory: isDir,
1028
+ size: attrs.size || 0,
1029
+ mtime: attrs.mtime || 0,
1030
+ longname: e.longname || name,
1031
+ })
1032
+ }
1033
+ entries.sort((a, b) => {
1034
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
1035
+ return a.name.localeCompare(b.name)
1036
+ })
1037
+ assertSessionStillAuthorized(s, isHostAuthorized)
1038
+ return { ok: true, path: remotePath, entries }
1039
+ }
1040
+
1041
+ if (method === 'sftpReadText') {
1042
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
1043
+ if (!s.sftp) throw new Error('SFTP not enabled on this session')
1044
+ const remotePath = String(body.path || '')
1045
+ const r = await sftpReadFile(s.sftp, remotePath, body.maxBytes || 512 * 1024)
1046
+ assertSessionStillAuthorized(s, isHostAuthorized)
1047
+ return { ok: true, path: remotePath, ...r }
1048
+ }
1049
+
1050
+ if (method === 'sftpWriteText') {
1051
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
1052
+ if (!s.sftp) throw new Error('SFTP not enabled on this session')
1053
+ const remotePath = String(body.path || '')
1054
+ await sftpWriteFile(s.sftp, remotePath, String(body.content ?? ''))
1055
+ assertSessionStillAuthorized(s, isHostAuthorized)
1056
+ return { ok: true, path: remotePath }
1057
+ }
1058
+
1059
+ if (method === 'sftpMkdir') {
1060
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
1061
+ if (!s.sftp) throw new Error('SFTP not enabled on this session')
1062
+ const remotePath = String(body.path || '')
1063
+ await sftpMkdir(s.sftp, remotePath)
1064
+ assertSessionStillAuthorized(s, isHostAuthorized)
1065
+ return { ok: true, path: remotePath }
1066
+ }
1067
+
1068
+ if (method === 'sftpDelete') {
1069
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
1070
+ if (!s.sftp) throw new Error('SFTP not enabled on this session')
1071
+ const remotePath = String(body.path || '')
1072
+ try {
1073
+ await sftpUnlink(s.sftp, remotePath)
1074
+ } catch {
1075
+ await sftpRmdir(s.sftp, remotePath)
1076
+ }
1077
+ assertSessionStillAuthorized(s, isHostAuthorized)
1078
+ return { ok: true, path: remotePath }
1079
+ }
1080
+
1081
+ if (method === 'sftpRename') {
1082
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
1083
+ if (!s.sftp) throw new Error('SFTP not enabled on this session')
1084
+ await sftpRename(s.sftp, String(body.from_path || body.from), String(body.to_path || body.to))
1085
+ assertSessionStillAuthorized(s, isHostAuthorized)
1086
+ return { ok: true }
1087
+ }
1088
+
1089
+ if (method === 'sftpDownload') {
1090
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
1091
+ if (!s.sftp) throw new Error('SFTP not enabled on this session')
1092
+ const remote = String(body.remote_path || body.path)
1093
+ const local = assertWorkspaceLocal(String(body.local_path || body.to_path), localProjectRoot(body))
1094
+ mkdirSync(dirname(local), { recursive: true })
1095
+ try {
1096
+ await sftpFastGet(s.sftp, remote, local)
1097
+ } catch (e) {
1098
+ if (discardRevokedLocalFile(s, local, isHostAuthorized)) {
1099
+ throw new Error('SSH session host is not authorized for the current project.')
1100
+ }
1101
+ throw e
1102
+ }
1103
+ if (discardRevokedLocalFile(s, local, isHostAuthorized)) {
1104
+ throw new Error('SSH session host is not authorized for the current project.')
1105
+ }
1106
+ return { ok: true, remote, local }
1107
+ }
1108
+
1109
+ if (method === 'sftpUpload') {
1110
+ const s = registry.requireLiveSession(body.sessionId || body.session_id, body.projectPathKey)
1111
+ if (!s.sftp) throw new Error('SFTP not enabled on this session')
1112
+ const local = assertWorkspaceLocal(String(body.local_path || body.path), localProjectRoot(body))
1113
+ const remote = String(body.remote_path || body.to_path)
1114
+ await sftpFastPut(s.sftp, local, remote)
1115
+ assertSessionStillAuthorized(s, isHostAuthorized)
1116
+ return { ok: true, local, remote }
1117
+ }
1118
+
1119
+
1120
+ if (method === 'localList') {
1121
+ const localRoot = localProjectRoot(body)
1122
+ const abs = constrainToWorkspace(body.path || localRoot || WORKSPACE_ROOT, { root: localRoot })
1123
+ let names
1124
+ try {
1125
+ names = readdirSync(abs)
1126
+ } catch (e) {
1127
+ throw new Error('cannot list: ' + (e && e.message ? e.message : e))
1128
+ }
1129
+ const entries = []
1130
+ for (const name of names) {
1131
+ if (name === '.' || name === '..') continue
1132
+ const full = join(abs, name)
1133
+ try {
1134
+ constrainToWorkspace(full, { root: localRoot })
1135
+ } catch {
1136
+ continue
1137
+ }
1138
+ let st
1139
+ try { st = lstatSync(full) } catch { continue }
1140
+ const isDir = st.isDirectory()
1141
+ entries.push({
1142
+ name,
1143
+ path: full,
1144
+ isDirectory: isDir,
1145
+ size: st.size || 0,
1146
+ mtime: Math.floor((st.mtimeMs || 0) / 1000),
1147
+ })
1148
+ }
1149
+ entries.sort((a, b) => {
1150
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
1151
+ return a.name.localeCompare(b.name)
1152
+ })
1153
+ return { ok: true, path: abs, root: localRoot || workspaceRoot(), entries }
1154
+ }
1155
+
1156
+ if (method === 'localMkdir') {
1157
+ const localRoot = localProjectRoot(body)
1158
+ const abs = constrainToWorkspace(body.path || localRoot, { root: localRoot })
1159
+ mkdirSync(abs, { recursive: true })
1160
+ return { ok: true, path: abs }
1161
+ }
1162
+
1163
+ if (method === 'localDelete') {
1164
+ const abs = constrainToWorkspace(String(body.path || ''), { forUnlink: true, root: localProjectRoot(body) })
1165
+ rmLocalRecursive(abs)
1166
+ return { ok: true, path: abs }
1167
+ }
1168
+
1169
+ if (method === 'localRename') {
1170
+ const localRoot = localProjectRoot(body)
1171
+ const from = constrainToWorkspace(String(body.from_path || body.from), { forUnlink: true, root: localRoot })
1172
+ const to = constrainToWorkspace(String(body.to_path || body.to), { root: localRoot })
1173
+ renameSync(from, to)
1174
+ return { ok: true, from, to }
1175
+ }
1176
+
1177
+ if (method === 'migrateDraft') {
1178
+ return migrateOldDraft()
1179
+ }
1180
+
1181
+ throw new Error('unknown method: ' + method)
1182
+ }
1183
+
1184
+ function migrateOldDraft() {
1185
+ const marker = migrateMarkerPath(DATA_DIR)
1186
+ if (existsSync(marker)) return { ok: true, migrated: 0, skipped: true }
1187
+ const draftPath = join(
1188
+ process.env.DSH_HOME || join(homedir(), '.dsh'),
1189
+ 'profiles/web/dsh-ssh-ui.draft.json',
1190
+ )
1191
+ if (!existsSync(draftPath)) {
1192
+ writeFileSync(marker, String(Date.now()) + '\n', { mode: 0o600 })
1193
+ return { ok: true, migrated: 0 }
1194
+ }
1195
+ let raw
1196
+ try {
1197
+ raw = JSON.parse(readFileSync(draftPath, 'utf8'))
1198
+ } catch {
1199
+ return { ok: false, error: 'bad draft' }
1200
+ }
1201
+ const profiles =
1202
+ raw.version === 2 && Array.isArray(raw.profiles)
1203
+ ? raw.profiles
1204
+ : [raw]
1205
+ let n = 0
1206
+ for (const p of profiles) {
1207
+ if (!p || !p.host) continue
1208
+ const existing = findExistingHostByEndpoint(
1209
+ loadHosts().hosts,
1210
+ p.host,
1211
+ p.port || 22,
1212
+ p.username || 'root',
1213
+ )
1214
+ if (existing) continue
1215
+ const migratedAuth = p.authMode || p.authType || 'privateKey'
1216
+ if (migratedAuth === 'keyboardInteractive') continue
1217
+ upsertHostRecord({
1218
+ name: p.alias || `${p.username || 'user'}@${p.host}`,
1219
+ host: p.host,
1220
+ port: p.port || 22,
1221
+ username: p.username || 'root',
1222
+ authType: migratedAuth,
1223
+ privateKeyPath: p.privateKey && !String(p.privateKey).includes('BEGIN') ? p.privateKey : '',
1224
+ privateKeyPem: p.privateKey && String(p.privateKey).includes('BEGIN') ? p.privateKey : '',
1225
+ password: p.password || '',
1226
+ passphrase: p.passphrase || '',
1227
+ source: 'migrated-draft',
1228
+ })
1229
+ n++
1230
+ }
1231
+ writeFileSync(marker, String(Date.now()) + '\n', { mode: 0o600 })
1232
+ return { ok: true, migrated: n }
1233
+ }
1234
+
1235
+ function registerSshManagerTool(ctx) {
1236
+ if (!ctx.tools || typeof ctx.tools.register !== 'function') {
1237
+ console.error('[dsh-ssh-tunnel] tools service unavailable; SSHManager tool not registered')
1238
+ return () => {}
1239
+ }
1240
+ // Raw tool definition (JSON Schema parameters) — same pattern as @liustack/modlens.
1241
+ // Do not import defineTool from @deepseek-ai/dsh-tools; out-of-tree resolution is unreliable.
1242
+ const tool = {
1243
+ name: 'SSHManager',
1244
+ timeoutMs: SSH_TOOL_TIMEOUT_MS,
1245
+ description:
1246
+ 'Manage SSH sessions and remote SFTP for hosts associated with the current project. Use host_id from list_hosts. credential=saved means secrets are stored—do not ask user to paste them. credential=unsupported means the host uses retired keyboard-interactive auth: edit it to password or private key; never dial. Default session_strategy is reuse_or_create (revives a disconnected session for that host instead of opening another). Host-key prompts must be completed in the SSH Tunnel tab. exec defaults to 30s; SFTP metadata 30s; SFTP transfer 120s. Pass timeout_ms (1s–300s) to override. Stop cancels in-flight SSH/SFTP.',
1247
+ parameters: {
1248
+ type: 'object',
1249
+ properties: {
1250
+ action: {
1251
+ type: 'string',
1252
+ description:
1253
+ 'list_hosts|list_sessions|create_session|close_session|exec|sftp_list|sftp_stat|sftp_read_text|sftp_write_text|sftp_mkdir|sftp_rename|sftp_delete|sftp_upload|sftp_download|read_session|send_input|resize_session',
1254
+ },
1255
+ host_id: { type: 'string', description: 'Authorized host id from list_hosts' },
1256
+ session_id: { type: 'string', description: 'Existing session id' },
1257
+ session_strategy: {
1258
+ type: 'string',
1259
+ description: 'reuse_or_create|new|require_existing',
1260
+ },
1261
+ title: { type: 'string' },
1262
+ command: { type: 'string', description: 'Remote command for exec' },
1263
+ cwd: { type: 'string', description: 'Remote cwd for exec' },
1264
+ path: { type: 'string' },
1265
+ from_path: { type: 'string' },
1266
+ to_path: { type: 'string' },
1267
+ content: { type: 'string' },
1268
+ local_path: { type: 'string' },
1269
+ remote_path: { type: 'string' },
1270
+ data: { type: 'string', description: 'PTY input' },
1271
+ cols: { type: 'number' },
1272
+ rows: { type: 'number' },
1273
+ timeout_ms: {
1274
+ type: 'number',
1275
+ description:
1276
+ 'Operation timeout in milliseconds for exec, SFTP, and shell actions. Default 30000 for exec/SFTP metadata, 120000 for SFTP transfer. Clamped to 1000–300000. The whole SSHManager call also has a 300s host ceiling.',
1277
+ },
1278
+ max_bytes: { type: 'number' },
1279
+ },
1280
+ required: ['action'],
1281
+ additionalProperties: false,
1282
+ },
1283
+ output: {
1284
+ schema: {
1285
+ type: 'object',
1286
+ additionalProperties: false,
1287
+ properties: {
1288
+ text: { type: 'string' },
1289
+ isError: { type: 'boolean' },
1290
+ },
1291
+ required: ['text'],
1292
+ },
1293
+ render: (_args, value) => [{ type: 'text', text: value.text }],
1294
+ },
1295
+ async execute(args, exec) {
1296
+ const action = String(args.action || '').trim()
1297
+ const agent = exec.agent
1298
+ if (!agent) throw new Error('SSHManager requires an initiating agent')
1299
+ const sessionId = agent.session.id
1300
+ const projectPathKey = sessionCwdOf(ctx, sessionId)
1301
+ if (!projectPathKey) throw new Error('Select/open a project workspace first.')
1302
+ exec.signal?.throwIfAborted?.()
1303
+ const opOpts = { timeoutMs: args.timeout_ms, signal: exec.signal }
1304
+
1305
+ const ok = (t) => ({ text: t, isError: false })
1306
+ const fail = (t) => ({ text: `SSHManager failed: ${t}`, isError: true })
1307
+ const textify = (obj) => (typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2))
1308
+
1309
+ try {
1310
+ if (action === 'list_hosts') {
1311
+ const { map, secrets } = allowedHostMap(projectPathKey)
1312
+ const lines = [...map.values()].map((h) => {
1313
+ const s = modelHostSummary(h, secrets)
1314
+ return `- ${s.host_id} · ${s.name || s.endpoint} · ${s.endpoint} · auth=${s.authType} · credential=${s.credentialStatus}`
1315
+ })
1316
+ return ok(
1317
+ lines.length > 0
1318
+ ? `project: ${projectPathKey}\n` + lines.join('\n')
1319
+ : `project: ${projectPathKey}\nNo authorized SSH hosts.`,
1320
+ )
1321
+ }
1322
+
1323
+ if (action === 'list_sessions') {
1324
+ const { hostIds } = getProjectGrants(projectPathKey)
1325
+ const list = registry.listSessionsForProject(projectPathKey).filter((s) => hostIds.includes(s.host_id))
1326
+ const lines = list.map(
1327
+ (s) =>
1328
+ `- ${s.session_id} · host=${s.host_id} · ${s.status} · sftp=${s.sftpEnabled} · running=${s.running} · ${s.title}`,
1329
+ )
1330
+ return ok(lines.length ? lines.join('\n') : 'No SSH sessions in this project.')
1331
+ }
1332
+
1333
+ if (action === 'create_session') {
1334
+ const resolved = await resolveSession(
1335
+ { host_id: args.host_id, session_strategy: 'new', title: args.title },
1336
+ projectPathKey,
1337
+ true,
1338
+ exec.signal,
1339
+ )
1340
+ return ok(
1341
+ textify({
1342
+ session_id: resolved.session.id,
1343
+ host_id: resolved.session.hostId,
1344
+ created: resolved.created,
1345
+ reused: resolved.reused,
1346
+ sftpEnabled: resolved.session.sftpEnabled,
1347
+ }),
1348
+ )
1349
+ }
1350
+
1351
+ if (action === 'close_session') {
1352
+ const id = String(args.session_id || '')
1353
+ const s = registry.get(id)
1354
+ if (!s || s.projectPathKey !== normalizeProjectKey(projectPathKey)) {
1355
+ throw new Error('SSH session not found in the current project.')
1356
+ }
1357
+ registry.closeSession(id, projectPathKey)
1358
+ return ok(`closed ${id}`)
1359
+ }
1360
+
1361
+ if (action === 'exec') {
1362
+ const command = String(args.command || '')
1363
+ if (!command) throw new Error('command required')
1364
+ const resolved = await resolveSession(args, projectPathKey, false, exec.signal)
1365
+ const result = await registry.execOnSession(resolved.session, command, {
1366
+ cwd: args.cwd,
1367
+ timeoutMs: args.timeout_ms,
1368
+ maxBytes: args.max_bytes,
1369
+ signal: exec.signal,
1370
+ })
1371
+ resolved.session.updatedAt = Date.now()
1372
+ // Result gate: the grant may have been revoked while the command
1373
+ // ran; never deliver remote output past the boundary.
1374
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1375
+ return ok(
1376
+ [
1377
+ `session_id: ${resolved.session.id}`,
1378
+ `host_id: ${resolved.session.hostId}`,
1379
+ `reused: ${resolved.reused}`,
1380
+ `exit: ${result.code}`,
1381
+ '--- stdout ---',
1382
+ result.stdout || '(empty)',
1383
+ '--- stderr ---',
1384
+ result.stderr || '(empty)',
1385
+ ].join('\n'),
1386
+ )
1387
+ }
1388
+
1389
+ if (action === 'read_session') {
1390
+ const resolved = await resolveSession(args, projectPathKey, false, exec.signal)
1391
+ await registry.ensureShell(resolved.session, opOpts)
1392
+ const out = resolved.session.outputBuf.join('')
1393
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1394
+ return ok(`session_id: ${resolved.session.id}\n\n${out || '(empty)'}`)
1395
+ }
1396
+
1397
+ if (action === 'send_input') {
1398
+ const data = String(args.data || '')
1399
+ if (!data) throw new Error('data required')
1400
+ const resolved = await resolveSession(args, projectPathKey, false, exec.signal)
1401
+ const stream = await registry.ensureShell(resolved.session, opOpts)
1402
+ stream.write(data)
1403
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1404
+ return ok(`sent ${data.length} chars to ${resolved.session.id}`)
1405
+ }
1406
+
1407
+ if (action === 'resize_session') {
1408
+ const resolved = await resolveSession(args, projectPathKey, false, exec.signal)
1409
+ const stream = await registry.ensureShell(resolved.session, opOpts)
1410
+ const cols = Number(args.cols) || 80
1411
+ const rows = Number(args.rows) || 24
1412
+ if (typeof stream.setWindow === 'function') stream.setWindow(rows, cols, 0, 0)
1413
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1414
+ return ok(`resized ${resolved.session.id} to ${cols}x${rows}`)
1415
+ }
1416
+
1417
+ const sftpActions = new Set([
1418
+ 'sftp_list',
1419
+ 'sftp_stat',
1420
+ 'sftp_read_text',
1421
+ 'sftp_write_text',
1422
+ 'sftp_mkdir',
1423
+ 'sftp_rename',
1424
+ 'sftp_delete',
1425
+ 'sftp_upload',
1426
+ 'sftp_download',
1427
+ ])
1428
+ if (sftpActions.has(action)) {
1429
+ const resolved = await resolveSession(args, projectPathKey, true, exec.signal)
1430
+ const sftp = resolved.session.sftp
1431
+ if (!sftp) throw new Error('SFTP not available on session')
1432
+ const remotePath = String(args.path || args.remote_path || '')
1433
+
1434
+ if (action === 'sftp_list') {
1435
+ const list = await sftpReaddir(sftp, remotePath || '.', opOpts)
1436
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1437
+ const lines = list.map((e) => e.longname || e.filename)
1438
+ return ok(`session_id: ${resolved.session.id}\n` + lines.join('\n'))
1439
+ }
1440
+ if (action === 'sftp_stat') {
1441
+ const st = await sftpStat(sftp, remotePath, opOpts)
1442
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1443
+ return ok(
1444
+ textify({
1445
+ path: remotePath,
1446
+ size: st.size,
1447
+ mode: st.mode,
1448
+ isDirectory: st.isDirectory?.() ?? false,
1449
+ isFile: st.isFile?.() ?? false,
1450
+ }),
1451
+ )
1452
+ }
1453
+ if (action === 'sftp_read_text') {
1454
+ const r = await sftpReadFile(sftp, remotePath, args.max_bytes || 512 * 1024, opOpts)
1455
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1456
+ return ok(
1457
+ `session_id: ${resolved.session.id}\ntruncated: ${r.truncated}\nsize: ${r.size}\n\n${r.content}`,
1458
+ )
1459
+ }
1460
+ if (action === 'sftp_write_text') {
1461
+ await sftpWriteFile(sftp, remotePath, String(args.content ?? ''), opOpts)
1462
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1463
+ return ok(`wrote ${remotePath}`)
1464
+ }
1465
+ if (action === 'sftp_mkdir') {
1466
+ await sftpMkdir(sftp, remotePath, opOpts)
1467
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1468
+ return ok(`mkdir ${remotePath}`)
1469
+ }
1470
+ if (action === 'sftp_rename') {
1471
+ await sftpRename(sftp, String(args.from_path), String(args.to_path), opOpts)
1472
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1473
+ return ok(`renamed ${args.from_path} -> ${args.to_path}`)
1474
+ }
1475
+ if (action === 'sftp_delete') {
1476
+ try {
1477
+ await sftpUnlink(sftp, remotePath, opOpts)
1478
+ } catch {
1479
+ await sftpRmdir(sftp, remotePath, opOpts)
1480
+ }
1481
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1482
+ return ok(`deleted ${remotePath}`)
1483
+ }
1484
+ if (action === 'sftp_upload') {
1485
+ const local = assertWorkspaceLocal(String(args.local_path || args.path), projectPathKey)
1486
+ const remote = String(args.remote_path || args.to_path)
1487
+ await sftpFastPut(sftp, local, remote, opOpts)
1488
+ assertSessionStillAuthorized(resolved.session, isHostAuthorized)
1489
+ return ok(`uploaded ${local} -> ${remote}`)
1490
+ }
1491
+ if (action === 'sftp_download') {
1492
+ const remote = String(args.remote_path || args.path)
1493
+ const local = assertWorkspaceLocal(String(args.local_path || args.to_path), projectPathKey)
1494
+ mkdirSync(dirname(local), { recursive: true })
1495
+ try {
1496
+ await sftpFastGet(sftp, remote, local, opOpts)
1497
+ } catch (e) {
1498
+ // Mid-transfer revocation (or any failure): never leave a pull
1499
+ // from a revoked host on disk.
1500
+ if (discardRevokedLocalFile(resolved.session, local, isHostAuthorized)) {
1501
+ throw new Error('SSH session host is not authorized for the current project.')
1502
+ }
1503
+ throw e
1504
+ }
1505
+ // Result gate: the transfer may have finished in the same tick as
1506
+ // the revocation — remove the file and refuse delivery.
1507
+ if (discardRevokedLocalFile(resolved.session, local, isHostAuthorized)) {
1508
+ throw new Error('SSH session host is not authorized for the current project.')
1509
+ }
1510
+ return ok(`downloaded ${remote} -> ${local}`)
1511
+ }
1512
+ }
1513
+
1514
+ throw new Error('SSHManager.action is invalid: ' + action)
1515
+ } catch (e) {
1516
+ if (e && (e.aborted || e.name === 'AbortError')) return fail('Cancelled')
1517
+ const msg = e && e.message ? e.message : String(e)
1518
+ if (e && e.timedOut) return fail(`timed_out: true\n${msg}`)
1519
+ return fail(msg)
1520
+ }
1521
+ },
1522
+ }
1523
+
1524
+ try {
1525
+ const dispose = ctx.tools.register(tool)
1526
+ sshManagerRegistered = true
1527
+ return () => {
1528
+ sshManagerRegistered = false
1529
+ try {
1530
+ dispose()
1531
+ } catch {}
1532
+ }
1533
+ } catch (error) {
1534
+ sshManagerRegistered = false
1535
+ console.error(
1536
+ '[dsh-ssh-tunnel] SSHManager registration failed:',
1537
+ error && error.message ? error.message : error,
1538
+ )
1539
+ return () => {}
1540
+ }
1541
+ }
1542
+
1543
+ export function apply(ctx) {
1544
+ ensureDataDir()
1545
+ // best-effort migrate once
1546
+ try {
1547
+ migrateOldDraft()
1548
+ } catch (e) {
1549
+ console.warn('[dsh-ssh-tunnel] migrate draft skipped', e && e.message ? e.message : e)
1550
+ }
1551
+
1552
+ const trustedHosts = () => {
1553
+ try {
1554
+ return ctx.webRuntime?.trustedHosts || []
1555
+ } catch {
1556
+ return []
1557
+ }
1558
+ }
1559
+
1560
+ ctx.effect(
1561
+ () =>
1562
+ ctx.webServer.register({
1563
+ kind: 'prefix',
1564
+ path: '/dsh-ssh-tunnel/api',
1565
+ handler: async (req, res) => {
1566
+ if (!isTrustedRequest(req, trustedHosts())) {
1567
+ writeJson(res, 403, { ok: false, error: 'forbidden' })
1568
+ return
1569
+ }
1570
+ if (req.method !== 'POST') {
1571
+ writeJson(res, 405, { ok: false, error: 'method not allowed' })
1572
+ return
1573
+ }
1574
+ const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
1575
+ const prefix = '/dsh-ssh-tunnel/api/'
1576
+ if (!pathname.startsWith(prefix)) {
1577
+ writeJson(res, 404, { ok: false, error: 'not found' })
1578
+ return
1579
+ }
1580
+ const method = pathname.slice(prefix.length)
1581
+ if (!method || method.includes('/')) {
1582
+ writeJson(res, 404, { ok: false, error: 'not found' })
1583
+ return
1584
+ }
1585
+ try {
1586
+ const body = await readJsonBody(req)
1587
+ const result = await handleApi(method, body, ctx)
1588
+ writeJson(res, 200, result)
1589
+ } catch (error) {
1590
+ writeJson(res, 500, {
1591
+ ok: false,
1592
+ error: String(error && error.message ? error.message : error),
1593
+ })
1594
+ }
1595
+ },
1596
+ }),
1597
+ 'dsh-ssh-tunnel: api',
1598
+ )
1599
+
1600
+ ctx.effect(
1601
+ () =>
1602
+ ctx.webServer.register({
1603
+ kind: 'prefix',
1604
+ path: '/dsh-ssh-tunnel/vendor',
1605
+ handler: serveVendor,
1606
+ }),
1607
+ 'dsh-ssh-tunnel: vendor assets',
1608
+ )
1609
+
1610
+ ctx.effect(() => registerSshManagerTool(ctx), 'dsh-ssh-tunnel: SSHManager tool')
1611
+ }