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