dsh-ssh-tunnel 1.0.1 → 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.
@@ -1,511 +1,899 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Offline smoke tests (no SSH, no DSH process).
4
- * Run: node scripts/smoke-test.mjs
5
- */
6
- import assert from 'node:assert/strict'
7
- import { tmpdir } from 'node:os'
8
- import { join } from 'node:path'
9
- import { existsSync, mkdirSync, realpathSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
10
- import { normalizeProjectKey, isPathInsideRoots, joinUnderRoot, constrainToWorkspace, workspaceRoot, workspaceRoots } from '../lib/shared/path.js'
11
- import {
12
- hostCredentialConfigured,
13
- publicHost,
14
- modelHostSummary,
15
- assertNoSecretFields,
16
- } from '../lib/shared/host-summary.js'
17
- import { chunkFromBuffer } from '../lib/shared/shell-buffer.js'
18
- import {
19
- fingerprintFromHostKey,
20
- formatHostFingerprint,
21
- isUsableHostFingerprint,
22
- } from '../lib/shared/host-key.js'
23
- import {
24
- clampTimeoutMs,
25
- defaultTimeoutMsForAction,
26
- findReusableLive,
27
- pickOldestTombstone,
28
- withTimeout,
29
- SSH_TIMEOUT_MIN_MS,
30
- SSH_TIMEOUT_MAX_MS,
31
- SSH_EXEC_DEFAULT_TIMEOUT_MS,
32
- SSH_SFTP_TRANSFER_TIMEOUT_MS,
33
- SSH_STATUS_CONNECTED,
34
- SSH_STATUS_DISCONNECTED,
35
- disconnectedSessionError,
36
- kbiUnsupportedError,
37
- } from '../lib/shared/session-policy.js'
38
- import { createSessionRegistry } from '../lib/session.js'
39
- import { isLoopbackHostname, isTrustedRequest } from '../lib/shared/http-trust.js'
40
- import {
41
- findExistingHostByEndpoint,
42
- readJsonFile,
43
- writeJsonAtomic,
44
- } from '../lib/shared/persist.js'
45
- import {
46
- assertSessionStillAuthorized,
47
- discardRevokedLocalFile,
48
- } from '../lib/shared/session-auth.js'
49
- import {
50
- VENDOR_ASSETS,
51
- isVendorAssetAllowed,
52
- resolveVendorAsset,
53
- } from '../lib/shared/vendor.js'
54
-
55
- // Hermetic workspace root for the path guard: point it at a throwaway directory
56
- // so the suite runs on hosts whose real workspace is not `/workspace` (for
57
- // example Windows drive paths). workspaceRoot() reads the variable at call time.
58
- const WS_ROOT = join(tmpdir(), `dsh-ssh-ws-${process.pid}`)
59
- mkdirSync(WS_ROOT, { recursive: true })
60
- process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT = WS_ROOT
61
- const wsPath = (...segs) => normalizeProjectKey(join(WS_ROOT, ...segs))
62
- const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
63
-
64
- let failed = 0
65
- const pending = []
66
- function test(name, fn) {
67
- const run = () => {
68
- try {
69
- const result = fn()
70
- if (result && typeof result.then === 'function') {
71
- return result.then(
72
- () => console.log('ok ', name),
73
- (e) => {
74
- failed++
75
- console.error('FAIL', name, e && e.message ? e.message : e)
76
- },
77
- )
78
- }
79
- console.log('ok ', name)
80
- } catch (e) {
81
- failed++
82
- console.error('FAIL', name, e && e.message ? e.message : e)
83
- }
84
- }
85
- const out = run()
86
- if (out && typeof out.then === 'function') pending.push(out)
87
- }
88
-
89
- test('normalizeProjectKey strips trailing slash', () => {
90
- const key = normalizeProjectKey(wsPath('DSH-plugin') + '/')
91
- assert.equal(key, wsPath('DSH-plugin'))
92
- assert.ok(!key.endsWith('/') && !key.endsWith('\\'), 'no trailing separator')
93
- })
94
-
95
- test('normalizeProjectKey empty', () => {
96
- assert.equal(normalizeProjectKey(''), '')
97
- assert.equal(normalizeProjectKey(null), '')
98
- })
99
-
100
- test('isPathInsideRoots allows children only', () => {
101
- const root = wsPath('DSH-plugin')
102
- assert.equal(isPathInsideRoots(wsPath('DSH-plugin', 'a'), [root]), true)
103
- assert.equal(isPathInsideRoots(wsPath('other'), [root]), false)
104
- assert.equal(isPathInsideRoots(root, [root]), true)
105
- })
106
-
107
- test('isPathInsideRoots accepts children of filesystem roots', () => {
108
- const root = normalizeProjectKey('/')
109
- assert.equal(isPathInsideRoots(normalizeProjectKey('/etc/passwd'), [root]), true)
110
- assert.equal(isPathInsideRoots(root, [root]), true)
111
- })
112
-
113
- test('joinUnderRoot rejects escape', () => {
114
- const root = wsPath('DSH-plugin')
115
- assert.throws(() => joinUnderRoot(root, join('..', '..', 'etc', 'passwd')))
116
- const ok = joinUnderRoot(root, join('sub', 'file.txt'))
117
- assert.equal(ok, wsPath('DSH-plugin', 'sub', 'file.txt'))
118
- })
119
-
120
- test('constrainToWorkspace rejects lexical escape', () => {
121
- const re = new RegExp('must be under ' + escRe(workspaceRoot()))
122
- assert.throws(() => constrainToWorkspace('/home/node/.dsh/ssh-tunnel/secrets.json'), re)
123
- assert.throws(() => constrainToWorkspace('/tmp'), re)
124
- })
125
-
126
- // The guard used to know only `/workspace`, so on hosts whose project
127
- // workspace is a Windows drive path every real local path was rejected and
128
- // SFTP upload/download never started. The project workspace the host passes at
129
- // call time is now an allowed root (the env override stays allowed too).
130
- test('constrainToWorkspace accepts the project workspace root', () => {
131
- const project = join(realpathSync(WS_ROOT), 'DSH-project')
132
- mkdirSync(project, { recursive: true })
133
- const file = join(project, 'upload.txt')
134
- writeFileSync(file, 'probe', 'utf8')
135
- assert.equal(constrainToWorkspace(file, { root: project }), file)
136
- assert.equal(
137
- constrainToWorkspace(join('nested', 'child.txt'), { root: project }),
138
- join(project, 'nested', 'child.txt'),
139
- )
140
- assert.equal(constrainToWorkspace(wsPath('DSH-plugin', 'a'), { root: project }), wsPath('DSH-plugin', 'a'))
141
- assert.throws(
142
- () => constrainToWorkspace(join(tmpdir(), 'dsh-ssh-outside-probe.txt'), { root: project }),
143
- /must be under/,
144
- )
145
- })
146
-
147
- test('workspaceRoots lists the project workspace then the override', () => {
148
- const project = normalizeProjectKey(join(realpathSync(WS_ROOT), 'DSH-project'))
149
- assert.deepEqual(workspaceRoots(), [normalizeProjectKey(WS_ROOT)])
150
- assert.deepEqual(workspaceRoots(project), [project, normalizeProjectKey(WS_ROOT)])
151
- assert.equal(workspaceRoot(project), project)
152
- })
153
-
154
- test('persist: corrupt json throws; atomic write replaces', () => {
155
- const dir = join(tmpdir(), `dsh-ssh-persist-${process.pid}`)
156
- mkdirSync(dir, { recursive: true })
157
- const p = join(dir, 'secrets.json')
158
- writeFileSync(p, '{not json', 'utf8')
159
- assert.throws(() => readJsonFile(p, { version: 1, byHostId: {} }), /corrupt json/)
160
- writeJsonAtomic(p, { version: 1, byHostId: { h1: { password: 'x' } } }, 0o600)
161
- const loaded = readJsonFile(p, null)
162
- assert.equal(loaded.byHostId.h1.password, 'x')
163
- assert.equal(existsSync(p + '.tmp'), false)
164
- assert.equal(findExistingHostByEndpoint(
165
- [{ host: 'a', port: 22, username: 'root', id: '1' }],
166
- 'a',
167
- 22,
168
- 'root',
169
- )?.id, '1')
170
- assert.equal(findExistingHostByEndpoint(
171
- [{ host: 'a', port: 22, username: 'root', id: '1' }],
172
- 'a',
173
- 22,
174
- 'other',
175
- ), undefined)
176
- })
177
-
178
- test('http-trust: 0.0.0.0 is not loopback; evil Origin is rejected', () => {
179
- assert.equal(isLoopbackHostname('127.0.0.1'), true)
180
- assert.equal(isLoopbackHostname('::1'), true)
181
- assert.equal(isLoopbackHostname('0.0.0.0'), false)
182
- const loopReq = { headers: { host: '127.0.0.1:3080' } }
183
- assert.equal(isTrustedRequest(loopReq, []), true)
184
- const zeroReq = { headers: { host: '0.0.0.0:3080' } }
185
- assert.equal(isTrustedRequest(zeroReq, []), false)
186
- const csrf = { headers: { host: '127.0.0.1:3080', origin: 'http://evil.example' } }
187
- assert.equal(isTrustedRequest(csrf, []), false)
188
- const sameOrigin = { headers: { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080' } }
189
- assert.equal(isTrustedRequest(sameOrigin, []), true)
190
- })
191
-
192
- test('constrainToWorkspace rejects outbound symlink', () => {
193
- const base = wsPath('DSH-plugin')
194
- const link = join(base, '.audit-path-guard-link')
195
- mkdirSync(base, { recursive: true })
196
- try {
197
- try { unlinkSync(link) } catch {}
198
- try {
199
- // The link target must exist for the realpath walk to observe the escape.
200
- symlinkSync(tmpdir(), link, 'dir')
201
- } catch (e) {
202
- if (e && (e.code === 'EPERM' || e.code === 'EACCES' || e.code === 'ENOSYS')) {
203
- console.log('skip constrainToWorkspace rejects outbound symlink (symlink unavailable)')
204
- return
205
- }
206
- throw e
207
- }
208
- assert.throws(
209
- () => constrainToWorkspace(join(link, 'ssh-tunnel', '_nope')),
210
- new RegExp('escapes ' + escRe(workspaceRoot()) + ' via symlink'),
211
- )
212
- const unlinked = constrainToWorkspace(link, { forUnlink: true })
213
- assert.equal(unlinked, normalizeProjectKey(link))
214
- } finally {
215
- try { unlinkSync(link) } catch {}
216
- }
217
- })
218
-
219
- test('credential + publicHost never leak secrets', () => {
220
- const host = {
221
- id: 'h1',
222
- name: 'n',
223
- host: '1.2.3.4',
224
- port: 22,
225
- username: 'root',
226
- authType: 'password',
227
- }
228
- const secrets = { byHostId: { h1: { password: 's3cret', privateKeyPem: 'BEGIN' } } }
229
- assert.equal(hostCredentialConfigured(host, secrets), true)
230
- const pub = publicHost(host, secrets)
231
- assert.equal(pub.passwordConfigured, true)
232
- assert.equal(pub.credentialStatus, 'saved')
233
- assert.equal('password' in pub, false)
234
- assert.equal('privateKeyPem' in pub, false)
235
- assertNoSecretFields(pub)
236
- const model = modelHostSummary(host, secrets)
237
- assertNoSecretFields(model)
238
- assert.equal(model.credentialStatus, 'saved')
239
- })
240
-
241
- test('retired keyboard-interactive is unsupported, not a usable credential', () => {
242
- const host = { id: 'h2', authType: 'keyboardInteractive', host: 'x', username: 'u' }
243
- const pub = publicHost(host, { byHostId: {} })
244
- assert.equal(pub.credentialStatus, 'unsupported')
245
- assert.equal(pub.credentialConfigured, false)
246
- })
247
-
248
- test('chunkFromBuffer incremental', () => {
249
- const a = chunkFromBuffer('hello', 0)
250
- assert.equal(a.chunk, 'hello')
251
- assert.equal(a.length, 5)
252
- const b = chunkFromBuffer('hello world', 5)
253
- assert.equal(b.chunk, ' world')
254
- assert.equal(b.since, 5)
255
- })
256
-
257
- test('host key fingerprint is stable sha256 hex (no UTF-8 collision)', () => {
258
- const prefix = Buffer.concat([
259
- Buffer.from([0, 0, 0, 11]),
260
- Buffer.from('ssh-ed25519'),
261
- Buffer.from([0, 0, 0, 32]),
262
- ])
263
- const k1 = Buffer.concat([prefix, Buffer.alloc(32, 0x80)])
264
- const k2 = Buffer.concat([prefix, Buffer.alloc(32, 0x81)])
265
- // Legacy bug: String(buf) collapsed different keys via U+FFFD.
266
- assert.equal(String(k1) === String(k2), true)
267
- const f1 = fingerprintFromHostKey(k1)
268
- const f2 = fingerprintFromHostKey(k2)
269
- assert.equal(isUsableHostFingerprint(f1), true)
270
- assert.equal(isUsableHostFingerprint(f2), true)
271
- assert.notEqual(f1, f2)
272
- // ssh2 hostHash path: already-hex digest is kept as-is (lowercased).
273
- assert.equal(fingerprintFromHostKey(f1.toUpperCase()), f1)
274
- assert.equal(formatHostFingerprint(f1), 'SHA256:' + f1)
275
- // Legacy binary stored strings are rejected as unusable.
276
- assert.equal(isUsableHostFingerprint(String(k1)), false)
277
- assert.equal(isUsableHostFingerprint('\u0000ssh-ed25519'), false)
278
- })
279
-
280
- test('clampTimeoutMs bounds and fallback', () => {
281
- assert.equal(clampTimeoutMs(undefined, 30_000), 30_000)
282
- assert.equal(clampTimeoutMs(0, 30_000), 30_000)
283
- assert.equal(clampTimeoutMs(500, 30_000), SSH_TIMEOUT_MIN_MS)
284
- assert.equal(clampTimeoutMs(999_999, 30_000), SSH_TIMEOUT_MAX_MS)
285
- })
286
-
287
- test('defaultTimeoutMsForAction', () => {
288
- assert.equal(defaultTimeoutMsForAction('exec'), SSH_EXEC_DEFAULT_TIMEOUT_MS)
289
- assert.equal(defaultTimeoutMsForAction('sftp_upload'), SSH_SFTP_TRANSFER_TIMEOUT_MS)
290
- assert.equal(defaultTimeoutMsForAction('sftp_list'), 30_000)
291
- })
292
-
293
- test('reuse live vs tombstone pick', () => {
294
- const key = '/workspace'
295
- const sessions = [
296
- { id: 'dead', hostId: 'h1', projectPathKey: key, status: 'disconnected', running: false, sftpEnabled: true, createdAt: 1 },
297
- { id: 'live', hostId: 'h1', projectPathKey: key, status: 'connected', running: true, sftpEnabled: true, createdAt: 2 },
298
- ]
299
- assert.equal(findReusableLive(sessions, { projectPathKey: key, hostId: 'h1', needsSftp: true }).id, 'live')
300
- assert.equal(pickOldestTombstone(sessions, { projectPathKey: key, hostId: 'h1', needsSftp: true }).id, 'dead')
301
- assert.equal(
302
- findReusableLive(
303
- sessions.filter((s) => s.id === 'dead'),
304
- { projectPathKey: key, hostId: 'h1', needsSftp: true },
305
- ),
306
- undefined,
307
- )
308
- })
309
-
310
- test('withTimeout rejects and calls onTimeout', async () => {
311
- let hit = false
312
- await assert.rejects(
313
- () => withTimeout(new Promise(() => {}), 1000, { onTimeout: () => { hit = true } }),
314
- (e) => e.timedOut === true && hit === true,
315
- )
316
- })
317
-
318
- test('disconnectedSessionError is stable English for the model', () => {
319
- assert.match(disconnectedSessionError(), /disconnected/i)
320
- })
321
-
322
- test('kbiUnsupportedError tells the model to switch auth', () => {
323
- assert.match(kbiUnsupportedError(), /not supported/i)
324
- assert.match(kbiUnsupportedError(), /password or private key/i)
325
- })
326
-
327
- /* ------------------------------------------------------------------ *
328
- * Security regression: revoking a host's project grant must make its *
329
- * established tunnel sessions unusable AND unreconnectable, and revoke *
330
- * must terminate those sessions (DSH plugin upgrade audit 2026-09-08). *
331
- * ------------------------------------------------------------------ */
332
-
333
- function makeRegistry({ isHostAuthorized = () => true } = {}) {
334
- return createSessionRegistry({
335
- connectClient: async () => {
336
- throw new Error('connectClient must not be used in offline tests')
337
- },
338
- loadSecrets: () => ({ byHostId: {} }),
339
- getPrompt: () => undefined,
340
- getHostById: (id) => ({
341
- id,
342
- name: 'x',
343
- host: '1.2.3.4',
344
- port: 22,
345
- username: 'root',
346
- authType: 'password',
347
- }),
348
- isHostAuthorized,
349
- })
350
- }
351
-
352
- function liveRec(id, hostId, projectPathKey) {
353
- return {
354
- id,
355
- hostId,
356
- projectPathKey,
357
- status: SSH_STATUS_CONNECTED,
358
- running: true,
359
- client: {},
360
- sftpEnabled: true,
361
- closing: false,
362
- createdAt: Date.now(),
363
- updatedAt: Date.now(),
364
- reconnectRunner: false,
365
- reconnectAttempt: 0,
366
- connectionGeneration: 1,
367
- title: `t-${id}`,
368
- endpoint: '',
369
- }
370
- }
371
-
372
- test('revoked host: requireLiveSession refuses use of established session', () => {
373
- const registry = makeRegistry({ isHostAuthorized: () => false })
374
- const key = normalizeProjectKey('/workspace/DSH-plugin')
375
- registry.sessions.set('s1', liveRec('s1', 'h-sg', key))
376
- // Shell / SFTP operations all go through requireLiveSession; a revoked host
377
- // must not be usable even though the session object is still live.
378
- assert.throws(() => registry.requireLiveSession('s1', key), /not authorized/)
379
- })
380
-
381
- test('granted host: requireLiveSession still allows use', () => {
382
- const registry = makeRegistry({ isHostAuthorized: () => true })
383
- const key = normalizeProjectKey('/workspace/DSH-plugin')
384
- registry.sessions.set('s1', liveRec('s1', 'h-sg', key))
385
- assert.equal(registry.requireLiveSession('s1', key).id, 's1')
386
- })
387
-
388
- test('revoked host: reconnect refuses to resurrect the session', async () => {
389
- const registry = makeRegistry({ isHostAuthorized: () => false })
390
- const key = normalizeProjectKey('/workspace/DSH-plugin')
391
- const rec = liveRec('s1', 'h-sg', key)
392
- rec.status = SSH_STATUS_DISCONNECTED
393
- rec.running = false
394
- registry.sessions.set('s1', rec)
395
- await assert.rejects(
396
- () => registry.reconnectSession('s1', { manual: true, projectPathKey: key }),
397
- /not authorized/,
398
- )
399
- })
400
-
401
- test('requireLiveSession and closeSession require projectPathKey', () => {
402
- const registry = makeRegistry()
403
- const key = normalizeProjectKey('/workspace/DSH-plugin')
404
- registry.sessions.set('s1', liveRec('s1', 'h-sg', key))
405
- assert.throws(() => registry.requireLiveSession('s1'), /projectPathKey required/)
406
- assert.throws(() => registry.requireLiveSession('s1', ''), /projectPathKey required/)
407
- assert.throws(() => registry.requireLiveSession('s1', normalizeProjectKey('/workspace/hk')), /not in this project/)
408
- assert.throws(() => registry.closeSession('s1'), /projectPathKey required/)
409
- assert.equal(registry.closeSession('s1', key).ok, true)
410
- assert.equal(registry.sessions.has('s1'), false)
411
- })
412
-
413
- test('closeSessionsForHost terminates only the revoked host sessions in the project', () => {
414
- const registry = makeRegistry()
415
- const keyA = normalizeProjectKey('/workspace/DSH-plugin')
416
- const keyB = normalizeProjectKey('/workspace/other')
417
- registry.sessions.set('a1', liveRec('a1', 'h-sg', keyA)) // revoked host, this project
418
- registry.sessions.set('b1', liveRec('b1', 'h2', keyA)) // other host, same project
419
- registry.sessions.set('c1', liveRec('c1', 'h-sg', keyB)) // same host, other project
420
- const out = registry.closeSessionsForHost(keyA, 'h-sg')
421
- assert.equal(out.closed, 1)
422
- assert.equal(registry.sessions.has('a1'), false)
423
- assert.equal(registry.sessions.has('b1'), true)
424
- assert.equal(registry.sessions.has('c1'), true)
425
- })
426
-
427
- /* ------------------------------------------------------------------ *
428
- * Security regression (v0.4.3): a tool result must not be delivered *
429
- * after the host's grant is revoked mid-operation, and a downloaded *
430
- * file must not survive a mid-transfer revocation. *
431
- * ------------------------------------------------------------------ */
432
-
433
- test('result gate: revoked host denies tool result delivery', () => {
434
- assert.throws(
435
- () =>
436
- assertSessionStillAuthorized(
437
- { projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
438
- () => false,
439
- ),
440
- /not authorized/,
441
- )
442
- })
443
-
444
- test('result gate: granted host still delivers tool results', () => {
445
- assert.doesNotThrow(() =>
446
- assertSessionStillAuthorized({ projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' }, () => true),
447
- )
448
- })
449
-
450
- test('result gate: absent predicate is fail-closed', () => {
451
- assert.throws(
452
- () =>
453
- assertSessionStillAuthorized({ projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' }, undefined),
454
- /predicate required/,
455
- )
456
- })
457
-
458
- test('download discard: revoked host removes the local file', () => {
459
- const p = join(tmpdir(), `dsh-ssh-tunnel-revoke-${process.pid}.txt`)
460
- writeFileSync(p, 'secret data', 'utf8')
461
- const discarded = discardRevokedLocalFile(
462
- { projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
463
- p,
464
- () => false,
465
- )
466
- assert.equal(discarded, true)
467
- assert.equal(existsSync(p), false)
468
- })
469
-
470
- test('download discard: granted host keeps the local file', () => {
471
- const p = join(tmpdir(), `dsh-ssh-tunnel-grant-${process.pid}.txt`)
472
- writeFileSync(p, 'data', 'utf8')
473
- try {
474
- const discarded = discardRevokedLocalFile(
475
- { projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
476
- p,
477
- () => true,
478
- )
479
- assert.equal(discarded, false)
480
- assert.equal(existsSync(p), true)
481
- } finally {
482
- try {
483
- unlinkSync(p)
484
- } catch {}
485
- }
486
- })
487
-
488
- test('vendor: allowlist resolves the three xterm assets to existing files', () => {
489
- assert.deepEqual(Object.keys(VENDOR_ASSETS).sort(), ['addon-fit.js', 'xterm.css', 'xterm.js'])
490
- for (const [name, entry] of Object.entries(VENDOR_ASSETS)) {
491
- assert.equal(isVendorAssetAllowed(name), true, name)
492
- const r = resolveVendorAsset(name)
493
- assert.equal(existsSync(r.path), true, `${name} -> ${r.path}`)
494
- assert.equal(typeof r.type, 'string')
495
- }
496
- })
497
-
498
- test('vendor: unknown or traversing names are rejected', () => {
499
- assert.equal(isVendorAssetAllowed('../../etc/passwd'), false)
500
- assert.equal(isVendorAssetAllowed('xterm.js?x=1'), false)
501
- assert.equal(isVendorAssetAllowed('xterm.js/../'), false)
502
- assert.throws(() => resolveVendorAsset('../../etc/passwd'), /not allowed/)
503
- assert.throws(() => resolveVendorAsset(''), /not allowed/)
504
- })
505
-
506
- await Promise.all(pending)
507
- if (failed) {
508
- console.error(`\n${failed} failed`)
509
- process.exit(1)
510
- }
511
- console.log('\nall passed')
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Offline smoke tests. No real SSH hosts and no DSH process: remote-behavior
4
+ * cases dial an in-process ssh2.Server on 127.0.0.1 with a throwaway key.
5
+ * Run: node scripts/smoke-test.mjs
6
+ */
7
+ import assert from 'node:assert/strict'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { existsSync, mkdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
11
+ import { generateKeyPairSync } from 'node:crypto'
12
+ import { normalizeProjectKey, isPathInsideRoots, constrainToWorkspace, workspaceRoot, workspaceRoots } from '../lib/shared/path.js'
13
+ import { requireString, stringOrDefault } from '../lib/shared/args.js'
14
+ import {
15
+ hostCredentialConfigured,
16
+ publicHost,
17
+ modelHostSummary,
18
+ assertNoSecretFields,
19
+ } from '../lib/shared/host-summary.js'
20
+ import { chunkFromBuffer } from '../lib/shared/shell-buffer.js'
21
+ import {
22
+ fingerprintFromHostKey,
23
+ formatHostFingerprint,
24
+ isUsableHostFingerprint,
25
+ } from '../lib/shared/host-key.js'
26
+ import {
27
+ clampTimeoutMs,
28
+ clampMaxBytes,
29
+ defaultTimeoutMsForAction,
30
+ findReusableLive,
31
+ pickOldestTombstone,
32
+ withTimeout,
33
+ sshError,
34
+ hostKeyPromptMessage,
35
+ API_ERROR_STATUS,
36
+ MAX_BYTES_MIN,
37
+ MAX_BYTES_MAX,
38
+ SSH_TIMEOUT_MIN_MS,
39
+ SSH_TIMEOUT_MAX_MS,
40
+ SSH_EXEC_DEFAULT_TIMEOUT_MS,
41
+ SSH_SFTP_TRANSFER_TIMEOUT_MS,
42
+ SSH_STATUS_CONNECTED,
43
+ SSH_STATUS_DISCONNECTED,
44
+ disconnectedSessionError,
45
+ kbiUnsupportedError,
46
+ } from '../lib/shared/session-policy.js'
47
+ import { createSessionRegistry } from '../lib/session.js'
48
+ import { isLoopbackHostname, isTrustedRequest } from '../lib/shared/http-trust.js'
49
+ import {
50
+ findExistingHostByEndpoint,
51
+ readJsonFile,
52
+ writeJsonAtomic,
53
+ } from '../lib/shared/persist.js'
54
+ import {
55
+ assertSessionStillAuthorized,
56
+ discardRevokedLocalFile,
57
+ } from '../lib/shared/session-auth.js'
58
+ import {
59
+ VENDOR_ASSETS,
60
+ isVendorAssetAllowed,
61
+ resolveVendorAsset,
62
+ } from '../lib/shared/vendor.js'
63
+ import ssh2 from 'ssh2'
64
+ const { Server: SshServer, Client } = ssh2
65
+
66
+ // Hermetic workspace root for the path guard: point it at a throwaway directory
67
+ // so the suite runs on hosts whose real workspace is not `/workspace` (for
68
+ // example Windows drive paths). workspaceRoot() reads the variable at call time.
69
+ const tempDirs = []
70
+ function makeTempDir(prefix) {
71
+ const d = join(tmpdir(), `${prefix}-${process.pid}`)
72
+ mkdirSync(d, { recursive: true })
73
+ tempDirs.push(d)
74
+ return d
75
+ }
76
+ const WS_ROOT = makeTempDir('dsh-ssh-ws')
77
+ process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT = WS_ROOT
78
+ const wsPath = (...segs) => normalizeProjectKey(join(WS_ROOT, ...segs))
79
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
80
+
81
+ let passed = 0
82
+ let failed = 0
83
+ let skipped = 0
84
+ const pending = []
85
+ class Skip extends Error {}
86
+ function skip(reason) {
87
+ throw new Skip(reason)
88
+ }
89
+ function test(name, fn) {
90
+ const run = () => {
91
+ try {
92
+ const result = fn()
93
+ if (result && typeof result.then === 'function') {
94
+ return result.then(
95
+ () => {
96
+ passed++
97
+ console.log('ok ', name)
98
+ },
99
+ (e) => {
100
+ if (e instanceof Skip) {
101
+ skipped++
102
+ console.log('skip ', name)
103
+ return
104
+ }
105
+ failed++
106
+ console.error('FAIL', name, e && e.message ? e.message : e)
107
+ },
108
+ )
109
+ }
110
+ passed++
111
+ console.log('ok ', name)
112
+ } catch (e) {
113
+ if (e instanceof Skip) {
114
+ skipped++
115
+ console.log('skip ', name)
116
+ return
117
+ }
118
+ failed++
119
+ console.error('FAIL', name, e && e.message ? e.message : e)
120
+ }
121
+ }
122
+ const out = run()
123
+ if (out && typeof out.then === 'function') pending.push(out)
124
+ }
125
+
126
+ test('normalizeProjectKey strips trailing slash', () => {
127
+ const key = normalizeProjectKey(wsPath('DSH-plugin') + '/')
128
+ assert.equal(key, wsPath('DSH-plugin'))
129
+ assert.ok(!key.endsWith('/') && !key.endsWith('\\'), 'no trailing separator')
130
+ })
131
+
132
+ test('normalizeProjectKey empty', () => {
133
+ assert.equal(normalizeProjectKey(''), '')
134
+ assert.equal(normalizeProjectKey(null), '')
135
+ })
136
+
137
+ test('isPathInsideRoots allows children only', () => {
138
+ const root = wsPath('DSH-plugin')
139
+ assert.equal(isPathInsideRoots(wsPath('DSH-plugin', 'a'), [root]), true)
140
+ assert.equal(isPathInsideRoots(wsPath('other'), [root]), false)
141
+ assert.equal(isPathInsideRoots(root, [root]), true)
142
+ })
143
+
144
+ test('isPathInsideRoots accepts children of filesystem roots', () => {
145
+ const root = normalizeProjectKey('/')
146
+ assert.equal(isPathInsideRoots(normalizeProjectKey('/etc/passwd'), [root]), true)
147
+ assert.equal(isPathInsideRoots(root, [root]), true)
148
+ })
149
+
150
+ test('constrainToWorkspace rejects lexical escape', () => {
151
+ const re = new RegExp('must be under ' + escRe(workspaceRoot()))
152
+ assert.throws(() => constrainToWorkspace('/home/node/.dsh/ssh-tunnel/secrets.json'), re)
153
+ assert.throws(() => constrainToWorkspace('/tmp'), re)
154
+ })
155
+
156
+ // The guard used to know only `/workspace`, so on hosts whose project
157
+ // workspace is a Windows drive path every real local path was rejected and
158
+ // SFTP upload/download never started. The project workspace the host passes at
159
+ // call time is now an allowed root (the env override stays allowed too).
160
+ test('constrainToWorkspace accepts the project workspace root', () => {
161
+ const project = join(realpathSync(WS_ROOT), 'DSH-project')
162
+ mkdirSync(project, { recursive: true })
163
+ const file = join(project, 'upload.txt')
164
+ writeFileSync(file, 'probe', 'utf8')
165
+ assert.equal(constrainToWorkspace(file, { root: project }), file)
166
+ assert.equal(
167
+ constrainToWorkspace(join('nested', 'child.txt'), { root: project }),
168
+ join(project, 'nested', 'child.txt'),
169
+ )
170
+ assert.equal(constrainToWorkspace(wsPath('DSH-plugin', 'a'), { root: project }), wsPath('DSH-plugin', 'a'))
171
+ assert.throws(
172
+ () => constrainToWorkspace(join(tmpdir(), 'dsh-ssh-outside-probe.txt'), { root: project }),
173
+ /must be under/,
174
+ )
175
+ })
176
+
177
+ // No implicit fallback root: guessing one silently widens the trust boundary.
178
+ test('constrainToWorkspace fails closed with no root configured', () => {
179
+ const saved = process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT
180
+ delete process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT
181
+ try {
182
+ assert.throws(() => constrainToWorkspace('/tmp/whatever'), /no local workspace root configured/)
183
+ assert.throws(() => constrainToWorkspace('/tmp/whatever', { root: '' }), /no local workspace root configured/)
184
+ } finally {
185
+ if (saved !== undefined) process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT = saved
186
+ }
187
+ })
188
+
189
+ test('constrainToWorkspace tolerates a missing leaf inside the root', () => {
190
+ const p = wsPath('DSH-plugin', 'not-yet-created', 'child.txt')
191
+ assert.equal(constrainToWorkspace(p), p)
192
+ })
193
+
194
+ test('workspaceRoots lists the project workspace then the override', () => {
195
+ const project = normalizeProjectKey(join(realpathSync(WS_ROOT), 'DSH-project'))
196
+ assert.deepEqual(workspaceRoots(), [normalizeProjectKey(WS_ROOT)])
197
+ assert.deepEqual(workspaceRoots(project), [project, normalizeProjectKey(WS_ROOT)])
198
+ assert.equal(workspaceRoot(project), project)
199
+ })
200
+
201
+ test('requireString rejects missing and empty values', () => {
202
+ assert.throws(() => requireString(undefined, 'path'), /path required/)
203
+ assert.throws(() => requireString('', 'path'), /path required/)
204
+ assert.throws(() => requireString(' ', 'path'), /path required/)
205
+ assert.equal(requireString('x', 'path'), 'x')
206
+ assert.equal(stringOrDefault(undefined, 'fb', 'path'), 'fb')
207
+ assert.equal(stringOrDefault('', 'fb', 'path'), 'fb')
208
+ assert.equal(stringOrDefault('a', 'fb', 'path'), 'a')
209
+ assert.throws(() => stringOrDefault(undefined, '', 'path'), /path required/)
210
+ })
211
+
212
+ test('persist: corrupt json throws; atomic write replaces', () => {
213
+ const dir = makeTempDir('dsh-ssh-persist')
214
+ const p = join(dir, 'secrets.json')
215
+ writeFileSync(p, '{not json', 'utf8')
216
+ assert.throws(() => readJsonFile(p, { version: 1, byHostId: {} }), /corrupt json/)
217
+ writeJsonAtomic(p, { version: 1, byHostId: { h1: { password: 'x' } } }, 0o600)
218
+ const loaded = readJsonFile(p, null)
219
+ assert.equal(loaded.byHostId.h1.password, 'x')
220
+ assert.equal(existsSync(p + '.tmp'), false)
221
+ assert.equal(findExistingHostByEndpoint(
222
+ [{ host: 'a', port: 22, username: 'root', id: '1' }],
223
+ 'a',
224
+ 22,
225
+ 'root',
226
+ )?.id, '1')
227
+ assert.equal(findExistingHostByEndpoint(
228
+ [{ host: 'a', port: 22, username: 'root', id: '1' }],
229
+ 'a',
230
+ 22,
231
+ 'other',
232
+ ), undefined)
233
+ })
234
+
235
+ test('http-trust: 0.0.0.0 is not loopback; evil Origin is rejected', () => {
236
+ assert.equal(isLoopbackHostname('127.0.0.1'), true)
237
+ assert.equal(isLoopbackHostname('::1'), true)
238
+ assert.equal(isLoopbackHostname('0.0.0.0'), false)
239
+ const loopReq = { headers: { host: '127.0.0.1:3080' } }
240
+ assert.equal(isTrustedRequest(loopReq, []), true)
241
+ const zeroReq = { headers: { host: '0.0.0.0:3080' } }
242
+ assert.equal(isTrustedRequest(zeroReq, []), false)
243
+ const csrf = { headers: { host: '127.0.0.1:3080', origin: 'http://evil.example' } }
244
+ assert.equal(isTrustedRequest(csrf, []), false)
245
+ const sameOrigin = { headers: { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080' } }
246
+ assert.equal(isTrustedRequest(sameOrigin, []), true)
247
+ })
248
+
249
+ test('constrainToWorkspace rejects outbound symlink', () => {
250
+ const base = wsPath('DSH-plugin')
251
+ const link = join(base, '.audit-path-guard-link')
252
+ mkdirSync(base, { recursive: true })
253
+ try {
254
+ try { unlinkSync(link) } catch {}
255
+ try {
256
+ // The link target must exist for the realpath walk to observe the escape.
257
+ symlinkSync(tmpdir(), link, 'dir')
258
+ } catch (e) {
259
+ if (e && (e.code === 'EPERM' || e.code === 'EACCES' || e.code === 'ENOSYS')) {
260
+ skip('symlink unavailable on this host/filesystem')
261
+ return
262
+ }
263
+ throw e
264
+ }
265
+ assert.throws(
266
+ () => constrainToWorkspace(join(link, 'ssh-tunnel', '_nope')),
267
+ new RegExp('escapes ' + escRe(workspaceRoot()) + ' via symlink'),
268
+ )
269
+ const unlinked = constrainToWorkspace(link, { forUnlink: true })
270
+ assert.equal(unlinked, normalizeProjectKey(link))
271
+ } finally {
272
+ try { unlinkSync(link) } catch {}
273
+ }
274
+ })
275
+
276
+ test('credential + publicHost never leak secrets', () => {
277
+ const host = {
278
+ id: 'h1',
279
+ name: 'n',
280
+ host: '1.2.3.4',
281
+ port: 22,
282
+ username: 'root',
283
+ authType: 'password',
284
+ }
285
+ const secrets = { byHostId: { h1: { password: 's3cret', privateKeyPem: 'BEGIN' } } }
286
+ assert.equal(hostCredentialConfigured(host, secrets), true)
287
+ const pub = publicHost(host, secrets)
288
+ assert.equal(pub.passwordConfigured, true)
289
+ assert.equal(pub.credentialStatus, 'saved')
290
+ assert.equal('password' in pub, false)
291
+ assert.equal('privateKeyPem' in pub, false)
292
+ assertNoSecretFields(pub)
293
+ const model = modelHostSummary(host, secrets)
294
+ assertNoSecretFields(model)
295
+ assert.equal(model.credentialStatus, 'saved')
296
+ })
297
+
298
+ test('retired keyboard-interactive is unsupported, not a usable credential', () => {
299
+ const host = { id: 'h2', authType: 'keyboardInteractive', host: 'x', username: 'u' }
300
+ const pub = publicHost(host, { byHostId: {} })
301
+ assert.equal(pub.credentialStatus, 'unsupported')
302
+ assert.equal(pub.credentialConfigured, false)
303
+ })
304
+
305
+ test('chunkFromBuffer incremental', () => {
306
+ const a = chunkFromBuffer('hello', 0)
307
+ assert.equal(a.chunk, 'hello')
308
+ assert.equal(a.length, 5)
309
+ const b = chunkFromBuffer('hello world', 5)
310
+ assert.equal(b.chunk, ' world')
311
+ assert.equal(b.since, 5)
312
+ })
313
+
314
+ test('host key fingerprint is stable sha256 hex (no UTF-8 collision)', () => {
315
+ const prefix = Buffer.concat([
316
+ Buffer.from([0, 0, 0, 11]),
317
+ Buffer.from('ssh-ed25519'),
318
+ Buffer.from([0, 0, 0, 32]),
319
+ ])
320
+ const k1 = Buffer.concat([prefix, Buffer.alloc(32, 0x80)])
321
+ const k2 = Buffer.concat([prefix, Buffer.alloc(32, 0x81)])
322
+ // Legacy bug: String(buf) collapsed different keys via U+FFFD.
323
+ assert.equal(String(k1) === String(k2), true)
324
+ const f1 = fingerprintFromHostKey(k1)
325
+ const f2 = fingerprintFromHostKey(k2)
326
+ assert.equal(isUsableHostFingerprint(f1), true)
327
+ assert.equal(isUsableHostFingerprint(f2), true)
328
+ assert.notEqual(f1, f2)
329
+ // ssh2 hostHash path: already-hex digest is kept as-is (lowercased).
330
+ assert.equal(fingerprintFromHostKey(f1.toUpperCase()), f1)
331
+ assert.equal(formatHostFingerprint(f1), 'SHA256:' + f1)
332
+ // Legacy binary stored strings are rejected as unusable.
333
+ assert.equal(isUsableHostFingerprint(String(k1)), false)
334
+ assert.equal(isUsableHostFingerprint('\u0000ssh-ed25519'), false)
335
+ })
336
+
337
+ test('clampTimeoutMs bounds and fallback', () => {
338
+ assert.equal(clampTimeoutMs(undefined, 30_000), 30_000)
339
+ assert.equal(clampTimeoutMs(0, 30_000), 30_000)
340
+ assert.equal(clampTimeoutMs(500, 30_000), SSH_TIMEOUT_MIN_MS)
341
+ assert.equal(clampTimeoutMs(999_999, 30_000), SSH_TIMEOUT_MAX_MS)
342
+ })
343
+
344
+ test('clampMaxBytes bounds and fallback', () => {
345
+ assert.equal(clampMaxBytes(undefined, 262_144), 262_144)
346
+ assert.equal(clampMaxBytes(0, 262_144), 262_144)
347
+ assert.equal(clampMaxBytes(10, 262_144), MAX_BYTES_MIN)
348
+ assert.equal(clampMaxBytes(999_999_999, 262_144), MAX_BYTES_MAX)
349
+ })
350
+
351
+ test('sshError carries an API code; the status map covers every code', () => {
352
+ const e = sshError('not_found', 'nope')
353
+ assert.equal(e.code, 'not_found')
354
+ assert.equal(e.message, 'nope')
355
+ for (const c of ['bad_request', 'forbidden', 'not_found', 'conflict', 'gone', 'payload_too_large']) {
356
+ assert.equal(typeof API_ERROR_STATUS[c], 'number', c)
357
+ }
358
+ assert.match(hostKeyPromptMessage(), /SSH Tunnel tab/)
359
+ })
360
+
361
+ test('defaultTimeoutMsForAction', () => {
362
+ assert.equal(defaultTimeoutMsForAction('exec'), SSH_EXEC_DEFAULT_TIMEOUT_MS)
363
+ assert.equal(defaultTimeoutMsForAction('sftp_upload'), SSH_SFTP_TRANSFER_TIMEOUT_MS)
364
+ assert.equal(defaultTimeoutMsForAction('sftp_list'), 30_000)
365
+ })
366
+
367
+ test('reuse live vs tombstone pick', () => {
368
+ const key = '/workspace'
369
+ const sessions = [
370
+ { id: 'dead', hostId: 'h1', projectPathKey: key, status: 'disconnected', running: false, sftpEnabled: true, createdAt: 1 },
371
+ { id: 'live', hostId: 'h1', projectPathKey: key, status: 'connected', running: true, sftpEnabled: true, createdAt: 2 },
372
+ ]
373
+ assert.equal(findReusableLive(sessions, { projectPathKey: key, hostId: 'h1', needsSftp: true }).id, 'live')
374
+ assert.equal(pickOldestTombstone(sessions, { projectPathKey: key, hostId: 'h1', needsSftp: true }).id, 'dead')
375
+ assert.equal(
376
+ findReusableLive(
377
+ sessions.filter((s) => s.id === 'dead'),
378
+ { projectPathKey: key, hostId: 'h1', needsSftp: true },
379
+ ),
380
+ undefined,
381
+ )
382
+ })
383
+
384
+ test('withTimeout rejects and calls onTimeout', async () => {
385
+ let hit = false
386
+ await assert.rejects(
387
+ () => withTimeout(new Promise(() => {}), 1000, { onTimeout: () => { hit = true } }),
388
+ (e) => e.timedOut === true && hit === true,
389
+ )
390
+ })
391
+
392
+ test('disconnectedSessionError is stable English for the model', () => {
393
+ assert.match(disconnectedSessionError(), /disconnected/i)
394
+ })
395
+
396
+ test('kbiUnsupportedError tells the model to switch auth', () => {
397
+ assert.match(kbiUnsupportedError(), /not supported/i)
398
+ assert.match(kbiUnsupportedError(), /password or private key/i)
399
+ })
400
+
401
+ /* ------------------------------------------------------------------ *
402
+ * Security regression: revoking a host's project grant must make its *
403
+ * established tunnel sessions unusable AND unreconnectable, and revoke *
404
+ * must terminate those sessions (DSH plugin upgrade audit 2026-09-08). *
405
+ * ------------------------------------------------------------------ */
406
+
407
+ function makeRegistry({ isHostAuthorized = () => true } = {}) {
408
+ return createSessionRegistry({
409
+ connectClient: async () => {
410
+ throw new Error('connectClient must not be used in offline tests')
411
+ },
412
+ loadSecrets: () => ({ byHostId: {} }),
413
+ getPrompt: () => undefined,
414
+ getHostById: (id) => ({
415
+ id,
416
+ name: 'x',
417
+ host: '1.2.3.4',
418
+ port: 22,
419
+ username: 'root',
420
+ authType: 'password',
421
+ }),
422
+ isHostAuthorized,
423
+ })
424
+ }
425
+
426
+ function liveRec(id, hostId, projectPathKey) {
427
+ return {
428
+ id,
429
+ hostId,
430
+ projectPathKey,
431
+ status: SSH_STATUS_CONNECTED,
432
+ running: true,
433
+ client: {},
434
+ sftpEnabled: true,
435
+ closing: false,
436
+ createdAt: Date.now(),
437
+ updatedAt: Date.now(),
438
+ reconnectRunner: false,
439
+ reconnectAttempt: 0,
440
+ connectionGeneration: 1,
441
+ title: `t-${id}`,
442
+ endpoint: '',
443
+ }
444
+ }
445
+
446
+ test('revoked host: requireLiveSession refuses use of established session', () => {
447
+ const registry = makeRegistry({ isHostAuthorized: () => false })
448
+ const key = normalizeProjectKey('/workspace/DSH-plugin')
449
+ registry.sessions.set('s1', liveRec('s1', 'h-sg', key))
450
+ // Shell / SFTP operations all go through requireLiveSession; a revoked host
451
+ // must not be usable even though the session object is still live.
452
+ assert.throws(() => registry.requireLiveSession('s1', key), /not authorized/)
453
+ })
454
+
455
+ test('granted host: requireLiveSession still allows use', () => {
456
+ const registry = makeRegistry({ isHostAuthorized: () => true })
457
+ const key = normalizeProjectKey('/workspace/DSH-plugin')
458
+ registry.sessions.set('s1', liveRec('s1', 'h-sg', key))
459
+ assert.equal(registry.requireLiveSession('s1', key).id, 's1')
460
+ })
461
+
462
+ test('revoked host: reconnect refuses to resurrect the session', async () => {
463
+ const registry = makeRegistry({ isHostAuthorized: () => false })
464
+ const key = normalizeProjectKey('/workspace/DSH-plugin')
465
+ const rec = liveRec('s1', 'h-sg', key)
466
+ rec.status = SSH_STATUS_DISCONNECTED
467
+ rec.running = false
468
+ registry.sessions.set('s1', rec)
469
+ await assert.rejects(
470
+ () => registry.reconnectSession('s1', { manual: true, projectPathKey: key }),
471
+ /not authorized/,
472
+ )
473
+ })
474
+
475
+ test('requireLiveSession and closeSession require projectPathKey', () => {
476
+ const registry = makeRegistry()
477
+ const key = normalizeProjectKey('/workspace/DSH-plugin')
478
+ registry.sessions.set('s1', liveRec('s1', 'h-sg', key))
479
+ assert.throws(() => registry.requireLiveSession('s1'), /projectPathKey required/)
480
+ assert.throws(() => registry.requireLiveSession('s1', ''), /projectPathKey required/)
481
+ assert.throws(() => registry.requireLiveSession('s1', normalizeProjectKey('/workspace/hk')), /not in this project/)
482
+ assert.throws(() => registry.closeSession('s1'), /projectPathKey required/)
483
+ assert.equal(registry.closeSession('s1', key).ok, true)
484
+ assert.equal(registry.sessions.has('s1'), false)
485
+ })
486
+
487
+ test('closeSession on a missing session reports not_found', () => {
488
+ const registry = makeRegistry()
489
+ assert.throws(() => registry.closeSession('missing', '/workspace'), (e) => e.code === 'not_found')
490
+ })
491
+
492
+ test('closeSessionsForHost terminates only the revoked host sessions in the project', () => {
493
+ const registry = makeRegistry()
494
+ const keyA = normalizeProjectKey('/workspace/DSH-plugin')
495
+ const keyB = normalizeProjectKey('/workspace/other')
496
+ registry.sessions.set('a1', liveRec('a1', 'h-sg', keyA)) // revoked host, this project
497
+ registry.sessions.set('b1', liveRec('b1', 'h2', keyA)) // other host, same project
498
+ registry.sessions.set('c1', liveRec('c1', 'h-sg', keyB)) // same host, other project
499
+ const out = registry.closeSessionsForHost(keyA, 'h-sg')
500
+ assert.equal(out.closed, 1)
501
+ assert.equal(registry.sessions.has('a1'), false)
502
+ assert.equal(registry.sessions.has('b1'), true)
503
+ assert.equal(registry.sessions.has('c1'), true)
504
+ })
505
+
506
+ /* ------------------------------------------------------------------ *
507
+ * Shell output buffer: seq cursors survive ring eviction. *
508
+ * ------------------------------------------------------------------ */
509
+
510
+ function makeBufferRegistry() {
511
+ return createSessionRegistry({
512
+ connectClient: async () => ({ on() {}, end() {}, exec() {} }),
513
+ loadSecrets: () => ({ byHostId: {} }),
514
+ getPrompt: () => undefined,
515
+ getHostById: (id) => ({ id, name: 'x', host: '1.2.3.4', port: 22, username: 'root', authType: 'password' }),
516
+ isHostAuthorized: () => true,
517
+ })
518
+ }
519
+
520
+ async function makeBufferSession() {
521
+ const registry = makeBufferRegistry()
522
+ const host = { id: 'h-buf', name: 'x', host: '1.2.3.4', port: 22, username: 'root', authType: 'password' }
523
+ const created = await registry.createLiveSession({
524
+ host,
525
+ projectPathKey: normalizeProjectKey('/workspace/DSH-plugin'),
526
+ sftpEnabled: false,
527
+ })
528
+ assert.equal(created.ok, true)
529
+ return { registry, rec: registry.get(created.session.session_id) }
530
+ }
531
+
532
+ test('shell buffer: seq cursor delivers only unseen output', async () => {
533
+ const { rec } = await makeBufferSession()
534
+ rec.pushOutput('hello')
535
+ rec.pushOutput(' world')
536
+ let r = rec.chunkFrom(0)
537
+ assert.equal(r.chunk, 'hello world')
538
+ assert.equal(r.since, 2)
539
+ assert.equal(r.seq, 2)
540
+ assert.equal(r.dropped, false)
541
+ assert.equal(r.chunkTruncated, false)
542
+ r = rec.chunkFrom(r.since)
543
+ assert.equal(r.chunk, '')
544
+ rec.pushOutput('!')
545
+ r = rec.chunkFrom(2)
546
+ assert.equal(r.chunk, '!')
547
+ assert.equal(r.seq, 3)
548
+ // A cursor is never pinned to the buffer tail: since advances with the seq.
549
+ assert.equal(rec.chunkFrom(99).dropped, false)
550
+ })
551
+
552
+ test('shell buffer: eviction advances baseSeq and flags dropped', async () => {
553
+ const { rec } = await makeBufferSession()
554
+ const big = 'x'.repeat(64 * 1024)
555
+ for (let i = 0; i < 12; i++) rec.pushOutput(big) // 768 KiB > 512 KiB ring
556
+ const fresh = rec.chunkFrom(0) // reader never read anything
557
+ assert.equal(fresh.dropped, true)
558
+ assert.ok(fresh.baseSeq > 1, 'baseSeq advanced past seq 1')
559
+ const current = rec.chunkFrom(fresh.seq)
560
+ assert.equal(current.dropped, false)
561
+ // Per-response cap: 8 retained 64 KiB chunks come back in bounded slices.
562
+ assert.ok(Buffer.byteLength(fresh.chunk) <= 256 * 1024 + 64 * 1024)
563
+ assert.equal(fresh.chunkTruncated, true)
564
+ })
565
+
566
+ test('shell buffer: single over-cap chunk is delivered whole and flagged', async () => {
567
+ const { rec } = await makeBufferSession()
568
+ rec.pushOutput('y'.repeat(300 * 1024))
569
+ const r = rec.chunkFrom(0)
570
+ assert.equal(r.chunk.length, 300 * 1024)
571
+ assert.equal(r.chunkTruncated, true)
572
+ assert.equal(r.dropped, false)
573
+ })
574
+
575
+ /* ------------------------------------------------------------------ *
576
+ * Security regression (v0.4.3): a tool result must not be delivered *
577
+ * after the host's grant is revoked mid-operation, and a downloaded *
578
+ * file must not survive a mid-transfer revocation. *
579
+ * ------------------------------------------------------------------ */
580
+
581
+ test('result gate: revoked host denies tool result delivery', () => {
582
+ assert.throws(
583
+ () =>
584
+ assertSessionStillAuthorized(
585
+ { projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
586
+ () => false,
587
+ ),
588
+ /not authorized/,
589
+ )
590
+ })
591
+
592
+ test('result gate: granted host still delivers tool results', () => {
593
+ assert.doesNotThrow(() =>
594
+ assertSessionStillAuthorized({ projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' }, () => true),
595
+ )
596
+ })
597
+
598
+ test('result gate: custom message replaces the default on denial', () => {
599
+ assert.throws(
600
+ () =>
601
+ assertSessionStillAuthorized(
602
+ { projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
603
+ () => false,
604
+ 'custom denial text',
605
+ ),
606
+ /custom denial text/,
607
+ )
608
+ })
609
+
610
+ test('result gate: absent predicate is fail-closed', () => {
611
+ assert.throws(
612
+ () =>
613
+ assertSessionStillAuthorized({ projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' }, undefined),
614
+ /predicate required/,
615
+ )
616
+ })
617
+
618
+ test('result gate: empty project binding is denied (fail-closed)', () => {
619
+ assert.throws(
620
+ () => assertSessionStillAuthorized({ projectPathKey: '', hostId: 'h-sg' }, () => false),
621
+ /not authorized/,
622
+ )
623
+ })
624
+
625
+ test('download discard: revoked host removes the local file', () => {
626
+ const p = join(tmpdir(), `dsh-ssh-tunnel-revoke-${process.pid}.txt`)
627
+ writeFileSync(p, 'secret data', 'utf8')
628
+ const discarded = discardRevokedLocalFile(
629
+ { projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
630
+ p,
631
+ () => false,
632
+ )
633
+ assert.equal(discarded, true)
634
+ assert.equal(existsSync(p), false)
635
+ })
636
+
637
+ test('download discard: granted host keeps the local file', () => {
638
+ const p = join(tmpdir(), `dsh-ssh-tunnel-grant-${process.pid}.txt`)
639
+ writeFileSync(p, 'data', 'utf8')
640
+ try {
641
+ const discarded = discardRevokedLocalFile(
642
+ { projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
643
+ p,
644
+ () => true,
645
+ )
646
+ assert.equal(discarded, false)
647
+ assert.equal(existsSync(p), true)
648
+ } finally {
649
+ try {
650
+ unlinkSync(p)
651
+ } catch {}
652
+ }
653
+ })
654
+
655
+ test('vendor: allowlist resolves the three xterm assets to existing files', () => {
656
+ assert.deepEqual(Object.keys(VENDOR_ASSETS).sort(), ['addon-fit.js', 'xterm.css', 'xterm.js'])
657
+ for (const [name, entry] of Object.entries(VENDOR_ASSETS)) {
658
+ assert.equal(isVendorAssetAllowed(name), true, name)
659
+ const r = resolveVendorAsset(name)
660
+ assert.equal(existsSync(r.path), true, `${name} -> ${r.path}`)
661
+ assert.equal(typeof r.type, 'string')
662
+ }
663
+ })
664
+
665
+ test('vendor: unknown or traversing names are rejected', () => {
666
+ assert.equal(isVendorAssetAllowed('../../etc/passwd'), false)
667
+ assert.equal(isVendorAssetAllowed('xterm.js?x=1'), false)
668
+ assert.equal(isVendorAssetAllowed('xterm.js/../'), false)
669
+ assert.throws(() => resolveVendorAsset('../../etc/passwd'), /not allowed/)
670
+ assert.throws(() => resolveVendorAsset(''), /not allowed/)
671
+ })
672
+
673
+ /* ------------------------------------------------------------------ *
674
+ * Remote exec settlement semantics, driven against a real (local, *
675
+ * in-process) ssh2.Server: exit-status, exit-signal, connection cut, *
676
+ * UTF-8 split across data events, max_bytes, and cwd composition. *
677
+ * ------------------------------------------------------------------ */
678
+
679
+ const HOST_KEY_PEM = (() => {
680
+ // ssh2's key parser wants classic PEM formats; PKCS#1 RSA parses reliably.
681
+ const { privateKey } = generateKeyPairSync('rsa', {
682
+ modulusLength: 2048,
683
+ privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
684
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
685
+ })
686
+ return privateKey
687
+ })()
688
+
689
+ async function startSshServer(onExec) {
690
+ const server = new SshServer({ hostKeys: [HOST_KEY_PEM] }, (client) => {
691
+ client.on('authentication', (ctx) => {
692
+ if (ctx.method === 'password' && ctx.username === 'user' && ctx.password === 'pass') ctx.accept()
693
+ else ctx.reject()
694
+ })
695
+ client.on('session', (accept) => {
696
+ const session = accept()
697
+ session.on('exec', (acceptExec, rejectExec, info) => onExec(client, acceptExec, info))
698
+ })
699
+ })
700
+ await new Promise((resolve, reject) => {
701
+ server.once('error', reject)
702
+ server.listen(0, '127.0.0.1', resolve)
703
+ })
704
+ return server
705
+ }
706
+
707
+ async function makeLiveSession(server) {
708
+ const port = server.address().port
709
+ const registry = createSessionRegistry({
710
+ connectClient: () =>
711
+ new Promise((resolve, reject) => {
712
+ const c = new Client()
713
+ c.on('ready', () => resolve(c))
714
+ c.on('error', reject)
715
+ c.connect({ host: '127.0.0.1', port, username: 'user', password: 'pass', readyTimeout: 5000 })
716
+ }),
717
+ loadSecrets: () => ({ byHostId: {} }),
718
+ getPrompt: () => undefined,
719
+ getHostById: (id) => ({ id, name: 't', host: '127.0.0.1', port, username: 'user', authType: 'password' }),
720
+ isHostAuthorized: () => true,
721
+ })
722
+ const host = { id: 'h-ssh', name: 't', host: '127.0.0.1', port, username: 'user', authType: 'password' }
723
+ const created = await registry.createLiveSession({
724
+ host,
725
+ projectPathKey: normalizeProjectKey('/workspace/DSH-plugin'),
726
+ sftpEnabled: false,
727
+ })
728
+ assert.equal(created.ok, true)
729
+ const rec = registry.get(created.session.session_id)
730
+ return { registry, rec }
731
+ }
732
+
733
+ async function stopServer(server, registry) {
734
+ for (const s of registry.sessions.values()) {
735
+ try { s.client?.end() } catch {}
736
+ }
737
+ await new Promise((resolve) => server.close(resolve))
738
+ }
739
+
740
+ // One server + one live session per test; `run` receives an exec() bound to
741
+ // the session record, mirroring registry.execOnSession.
742
+ async function execFixture(onServerExec, run) {
743
+ const server = await startSshServer(onServerExec)
744
+ const { registry, rec } = await makeLiveSession(server)
745
+ try {
746
+ await run((command, opts) => registry.execOnSession(rec, command, opts))
747
+ } finally {
748
+ await stopServer(server, registry)
749
+ }
750
+ }
751
+
752
+ test('exec: normal exit settles with exitKnown code and both streams', async () => {
753
+ await execFixture(
754
+ (client, accept) => {
755
+ const stream = accept()
756
+ stream.write('out-ok')
757
+ stream.stderr.write('err-line')
758
+ stream.exit(3)
759
+ stream.end()
760
+ },
761
+ async (exec) => {
762
+ const r = await exec('any')
763
+ assert.equal(r.exitKnown, true)
764
+ assert.equal(r.code, 3)
765
+ assert.equal(r.signal, null)
766
+ assert.equal(r.connectionDropped, false)
767
+ assert.equal(r.truncated, false)
768
+ assert.equal(r.stdout, 'out-ok')
769
+ assert.equal(r.stderr, 'err-line')
770
+ },
771
+ )
772
+ })
773
+
774
+ test('exec: signal death reports the signal, not exit 0', async () => {
775
+ await execFixture(
776
+ (client, accept) => {
777
+ const stream = accept()
778
+ stream.write('partial')
779
+ stream.exit('KILL') // exit-signal, no exit-status
780
+ stream.close()
781
+ },
782
+ async (exec) => {
783
+ const r = await exec('any')
784
+ assert.equal(r.exitKnown, false)
785
+ assert.equal(r.code, null)
786
+ assert.equal(r.signal, 'SIGKILL')
787
+ assert.equal(r.connectionDropped, false)
788
+ },
789
+ )
790
+ })
791
+
792
+ test('exec: connection cut reports connectionDropped, not exit 0', async () => {
793
+ await execFixture(
794
+ (client, accept) => {
795
+ const stream = accept()
796
+ stream.write('half')
797
+ setImmediate(() => {
798
+ try { client.end() } catch {}
799
+ })
800
+ },
801
+ async (exec) => {
802
+ const r = await exec('any')
803
+ assert.equal(r.exitKnown, false)
804
+ assert.equal(r.code, null)
805
+ assert.equal(r.signal, null)
806
+ assert.equal(r.connectionDropped, true)
807
+ },
808
+ )
809
+ })
810
+
811
+ test('exec: command not found settles as exit 127', async () => {
812
+ await execFixture(
813
+ (client, accept) => {
814
+ const stream = accept()
815
+ stream.stderr.write('command not found')
816
+ stream.exit(127)
817
+ stream.end()
818
+ },
819
+ async (exec) => {
820
+ const r = await exec('nope')
821
+ assert.equal(r.exitKnown, true)
822
+ assert.equal(r.code, 127)
823
+ assert.equal(r.stderr, 'command not found')
824
+ },
825
+ )
826
+ })
827
+
828
+ test('exec: UTF-8 split across data events survives (no U+FFFD)', async () => {
829
+ await execFixture(
830
+ (client, accept) => {
831
+ const stream = accept()
832
+ const buf = Buffer.from('中文测试', 'utf8')
833
+ stream.write(buf.subarray(0, 5))
834
+ stream.write(buf.subarray(5))
835
+ stream.exit(0)
836
+ stream.end()
837
+ },
838
+ async (exec) => {
839
+ const r = await exec('any')
840
+ assert.equal(r.exitKnown, true)
841
+ assert.equal(r.code, 0)
842
+ assert.equal(r.stdout, '中文测试')
843
+ assert.ok(!r.stdout.includes('\uFFFD'), 'no replacement characters')
844
+ },
845
+ )
846
+ })
847
+
848
+ test('exec: max_bytes truncates by byte and flags truncated', async () => {
849
+ await execFixture(
850
+ (client, accept) => {
851
+ const stream = accept()
852
+ stream.write('a'.repeat(5000))
853
+ stream.exit(0)
854
+ stream.end()
855
+ },
856
+ async (exec) => {
857
+ const r = await exec('any', { maxBytes: 1024 })
858
+ assert.equal(r.truncated, true)
859
+ assert.equal(Buffer.byteLength(r.stdout), 1024)
860
+ // Sub-floor values clamp to 1 KiB instead of trusting 10.
861
+ const r2 = await exec('any', { maxBytes: 10 })
862
+ assert.equal(Buffer.byteLength(r2.stdout), 1024)
863
+ },
864
+ )
865
+ })
866
+
867
+ test('exec: cwd is single-quote escaped and echoed; relative cwd rejects', async () => {
868
+ const seen = []
869
+ await execFixture(
870
+ (client, accept, info) => {
871
+ seen.push(info.command)
872
+ const stream = accept()
873
+ stream.exit(0)
874
+ stream.end()
875
+ },
876
+ async (exec) => {
877
+ await exec('echo hi', { cwd: "/tmp/it's" })
878
+ assert.equal(seen[0], "cd -- '/tmp/it'\\''s' && echo hi")
879
+ const r = await exec('echo hi', { cwd: '/tmp/plain' })
880
+ assert.equal(seen[1], "cd -- '/tmp/plain' && echo hi")
881
+ assert.equal(r.cwd, '/tmp/plain')
882
+ await assert.rejects(
883
+ () => exec('echo hi', { cwd: 'relative/path' }),
884
+ /absolute POSIX path/,
885
+ )
886
+ assert.equal(seen.length, 2, 'relative cwd never dialed the remote')
887
+ },
888
+ )
889
+ })
890
+
891
+ await Promise.all(pending)
892
+ for (const d of tempDirs) {
893
+ try { rmSync(d, { recursive: true, force: true }) } catch {}
894
+ }
895
+ console.log(`\n${passed} passed, ${skipped} skipped, ${failed} failed`)
896
+ if (failed) {
897
+ process.exit(1)
898
+ }
899
+ console.log('all passed')