dsh-ssh-tunnel 1.0.0 → 1.0.2

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