dsh-ssh-workbench 0.0.0-stage → 1.7.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/CONTRIBUTING.md +63 -0
  3. package/LICENSE +21 -0
  4. package/README.md +178 -2
  5. package/SECURITY.md +27 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +146 -0
  8. package/docs/execution-model.md +83 -0
  9. package/docs/images/chat-at-resource.png +0 -0
  10. package/docs/images/plugin.png +0 -0
  11. package/docs/images/remote-workbench.png +0 -0
  12. package/docs/images/workbench.png +0 -0
  13. package/docs/quickstart.md +106 -0
  14. package/lib/client.js +4991 -0
  15. package/lib/core/activity.js +88 -0
  16. package/lib/core/background.js +69 -0
  17. package/lib/core/broker.js +802 -0
  18. package/lib/core/dialect.js +28 -0
  19. package/lib/core/errors.js +138 -0
  20. package/lib/core/execution-stats.js +88 -0
  21. package/lib/core/gate-pwsh.js +88 -0
  22. package/lib/core/gate.js +572 -0
  23. package/lib/core/guard.js +123 -0
  24. package/lib/core/known-hosts.js +74 -0
  25. package/lib/core/live-executions.js +101 -0
  26. package/lib/core/local-file-api.js +174 -0
  27. package/lib/core/localfs.js +381 -0
  28. package/lib/core/managed-context.js +276 -0
  29. package/lib/core/mentions.js +216 -0
  30. package/lib/core/paths.js +104 -0
  31. package/lib/core/posix-quote.js +4 -0
  32. package/lib/core/posix-shell.js +89 -0
  33. package/lib/core/prefs.js +38 -0
  34. package/lib/core/remote-exec-tool.js +394 -0
  35. package/lib/core/remote-file-tools.js +559 -0
  36. package/lib/core/remote-shell-tool.js +310 -0
  37. package/lib/core/secrets.js +95 -0
  38. package/lib/core/server-ref.js +61 -0
  39. package/lib/core/shell-adapters.js +48 -0
  40. package/lib/core/ssh.js +761 -0
  41. package/lib/core/store.js +83 -0
  42. package/lib/core/terminals.js +299 -0
  43. package/lib/core/win32-adapter.js +141 -0
  44. package/lib/index.js +409 -0
  45. package/lib/routes.js +759 -0
  46. package/locale/en.json +6 -0
  47. package/locale/zh.json +6 -0
  48. package/package.json +89 -4
  49. package/vendor/NOTICE.md +50 -0
  50. package/vendor/XTERM-LICENSE +21 -0
  51. package/vendor/node_modules/asn1/LICENSE +19 -0
  52. package/vendor/node_modules/asn1/README.md +50 -0
  53. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  54. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  55. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  56. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  57. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  58. package/vendor/node_modules/asn1/lib/index.js +20 -0
  59. package/vendor/node_modules/asn1/package.json +31 -0
  60. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  61. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  62. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  63. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  64. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  65. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  66. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  67. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  68. package/vendor/node_modules/safer-buffer/package.json +34 -0
  69. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  70. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  71. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  72. package/vendor/node_modules/tweetnacl/README.md +459 -0
  73. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  74. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  75. package/vendor/node_modules/tweetnacl/package.json +58 -0
  76. package/vendor/ssh2/LICENSE +19 -0
  77. package/vendor/ssh2/lib/Channel.js +295 -0
  78. package/vendor/ssh2/lib/agent.js +1123 -0
  79. package/vendor/ssh2/lib/client.js +2176 -0
  80. package/vendor/ssh2/lib/http-agents.js +84 -0
  81. package/vendor/ssh2/lib/index.js +44 -0
  82. package/vendor/ssh2/lib/keygen.js +582 -0
  83. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  84. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  85. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  86. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  87. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  88. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  89. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  90. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  91. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  92. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  93. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  94. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  95. package/vendor/ssh2/lib/server.js +1380 -0
  96. package/vendor/ssh2/lib/utils.js +336 -0
  97. package/vendor/ssh2/package.json +34 -0
  98. package/vendor/xterm.js +53 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Plugin-local known_hosts with strict-by-default trust.
3
+ *
4
+ * ssh2 verifies the host key with `hostHash: 'sha256'`; the verifier receives
5
+ * the hex digest, so this store keeps `<address> sha256:<hex>` lines. Unknown
6
+ * hosts are rejected unless the user explicitly trusts the fingerprint.
7
+ */
8
+ import { knownHostsPath, readTextFile, writePrivateFile } from './paths.js'
9
+
10
+ function addressOf(server) {
11
+ return `${server.host}:${server.port}`
12
+ }
13
+
14
+ function normalizeFingerprint(fingerprint) {
15
+ const value = String(fingerprint ?? '').trim().toLowerCase()
16
+ if (!value) return ''
17
+ return value.startsWith('sha256:') ? value : `sha256:${value}`
18
+ }
19
+
20
+ function parse(text) {
21
+ const map = new Map()
22
+ for (const rawLine of text.split(/\r?\n/)) {
23
+ const line = rawLine.trim()
24
+ if (!line || line.startsWith('#')) continue
25
+ const match = /^(\S+)\s+(sha256:[0-9a-f]+)$/i.exec(line)
26
+ if (match) map.set(match[1], match[2].toLowerCase())
27
+ }
28
+ return map
29
+ }
30
+
31
+ export function createKnownHosts({ file = knownHostsPath() } = {}) {
32
+ const load = () => parse(readTextFile(file))
33
+ const save = (map) => {
34
+ const lines = [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([address, fp]) => `${address} ${fp}`)
35
+ writePrivateFile(file, `${lines.join('\n')}${lines.length ? '\n' : ''}`)
36
+ }
37
+ return {
38
+ addressOf,
39
+ /** `mode: 'strict'` rejects unknown hosts; `'accept-new'` records them (TOFU). */
40
+ verify(server, fingerprint, mode = 'strict') {
41
+ const address = addressOf(server)
42
+ const normalized = normalizeFingerprint(fingerprint)
43
+ if (!normalized) return false
44
+ const map = load()
45
+ const known = map.get(address)
46
+ if (known) return known === normalized
47
+ if (mode === 'accept-new') {
48
+ map.set(address, normalized)
49
+ save(map)
50
+ return true
51
+ }
52
+ return false
53
+ },
54
+ trust(server, fingerprint) {
55
+ const normalized = normalizeFingerprint(fingerprint)
56
+ if (!normalized) throw new Error('a fingerprint is required')
57
+ const map = load()
58
+ map.set(addressOf(server), normalized)
59
+ save(map)
60
+ },
61
+ revoke(server) {
62
+ const map = load()
63
+ const deleted = map.delete(addressOf(server))
64
+ save(map)
65
+ return deleted
66
+ },
67
+ list() {
68
+ return [...load().entries()].map(([address, fingerprint]) => ({ address, fingerprint }))
69
+ },
70
+ has(server) {
71
+ return load().has(addressOf(server))
72
+ },
73
+ }
74
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * In-flight remote executions, for the composer's "current target" indicator.
3
+ *
4
+ * This is a display-only projection. It never selects a target, never reaches
5
+ * a tool, and disappears as soon as the dispatch settles. Every remote tool is
6
+ * explicit-server, so this map is the only place where the UI can learn what a
7
+ * running tool is touching.
8
+ */
9
+ import { sessionKeyOf } from './remote-shell-tool.js'
10
+ import {
11
+ REMOTE_EDIT_TOOL_NAME,
12
+ REMOTE_GLOB_TOOL_NAME,
13
+ REMOTE_GREP_TOOL_NAME,
14
+ REMOTE_READ_TOOL_NAME,
15
+ REMOTE_WRITE_TOOL_NAME,
16
+ } from './remote-file-tools.js'
17
+ import { REMOTE_EXEC_TOOL_NAME } from './remote-exec-tool.js'
18
+
19
+ const SINGLE_SERVER_TOOLS = new Set([
20
+ 'remote_bash',
21
+ 'remote_pwsh',
22
+ REMOTE_READ_TOOL_NAME,
23
+ REMOTE_WRITE_TOOL_NAME,
24
+ REMOTE_EDIT_TOOL_NAME,
25
+ REMOTE_GLOB_TOOL_NAME,
26
+ REMOTE_GREP_TOOL_NAME,
27
+ ])
28
+
29
+ /** Extract the explicit server names a remote tool call will touch. */
30
+ export function serversOf(exec) {
31
+ const name = exec?.name
32
+ const args = exec?.arguments
33
+ if (!name || !args || typeof args !== 'object') return []
34
+ if (name === REMOTE_EXEC_TOOL_NAME) {
35
+ const tasks = Array.isArray(args.tasks) ? args.tasks : []
36
+ const names = []
37
+ for (const task of tasks) {
38
+ if (task && typeof task.server === 'string' && task.server.length > 0 && !names.includes(task.server)) {
39
+ names.push(task.server)
40
+ }
41
+ }
42
+ return names
43
+ }
44
+ if (!SINGLE_SERVER_TOOLS.has(name)) return []
45
+ return typeof args.server === 'string' && args.server.length > 0 ? [args.server] : []
46
+ }
47
+
48
+ /** @param {{ now?: () => number }} [options] */
49
+ export function createLiveExecutions(options = {}) {
50
+ const now = options.now ?? Date.now
51
+ let nextToken = 0
52
+ /** @type {Map<number, { sessionId: string, tool: string, servers: string[], startedAt: number }>} */
53
+ const active = new Map()
54
+
55
+ /** Register one dispatch; returns undefined when this is not a remote call. */
56
+ function begin(exec) {
57
+ const servers = serversOf(exec)
58
+ if (servers.length === 0) return undefined
59
+ const sessionId = sessionKeyOf(exec)
60
+ if (!sessionId) return undefined
61
+ const token = ++nextToken
62
+ active.set(token, {
63
+ sessionId,
64
+ tool: typeof exec.name === 'string' ? exec.name : '',
65
+ servers,
66
+ startedAt: now(),
67
+ })
68
+ return token
69
+ }
70
+
71
+ function end(token) {
72
+ if (token !== undefined) active.delete(token)
73
+ }
74
+
75
+ /** @returns {{ count: number, servers: { name: string, count: number, startedAt: number }[] }} */
76
+ function snapshot(sessionId) {
77
+ const byName = new Map()
78
+ let calls = 0
79
+ for (const entry of active.values()) {
80
+ if (entry.sessionId !== sessionId) continue
81
+ calls += 1
82
+ for (const name of entry.servers) {
83
+ const previous = byName.get(name)
84
+ byName.set(name, {
85
+ name,
86
+ count: (previous?.count ?? 0) + 1,
87
+ startedAt: previous ? Math.min(previous.startedAt, entry.startedAt) : entry.startedAt,
88
+ })
89
+ }
90
+ }
91
+ return { count: calls, servers: [...byName.values()] }
92
+ }
93
+
94
+ function clear(sessionId) {
95
+ for (const [token, entry] of active) {
96
+ if (sessionId === undefined || entry.sessionId === sessionId) active.delete(token)
97
+ }
98
+ }
99
+
100
+ return { begin, end, snapshot, clear, size: () => active.size }
101
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Local-filesystem implementation of the files-panel API.
3
+ *
4
+ * The workbench reuses the very same files component for remote SFTP and the
5
+ * local workspace. This adapter gives the local side the same method surface
6
+ * as the SSH runtime (`sftpHome`, `sftpList`, `sftpRead`, chunked open/read/
7
+ * write sessions, mkdir/remove/rename), so the panel and the file viewer stay
8
+ * provider-blind. Every path is resolved through the scoped local filesystem
9
+ * browser.
10
+ */
11
+ import fsp from 'node:fs/promises'
12
+ import { randomUUID } from 'node:crypto'
13
+
14
+ export const LOCAL_FILE_REF = '__local__'
15
+
16
+ const SYMLINK_WRITE_ERROR = 'refusing to write through a symlink'
17
+
18
+ export function createLocalFileApi(localfs) {
19
+ const server = { id: LOCAL_FILE_REF, name: LOCAL_FILE_REF, platform: 'local' }
20
+ const sessions = new Map()
21
+
22
+ const sessionFor = (id, kind) => {
23
+ const session = sessions.get(id)
24
+ if (!session || session.kind !== kind) throw new Error('local transfer session not found')
25
+ return session
26
+ }
27
+
28
+ async function lstatOrNull(target) {
29
+ try {
30
+ return await fsp.lstat(target)
31
+ } catch (error) {
32
+ if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return null
33
+ throw error
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Open a local path for a transfer session.
39
+ *
40
+ * Write sessions resolve the path as a directory entry (parents followed and
41
+ * bounded by the root, final component not followed) and refuse to write
42
+ * through a final symlink, so an uploaded file cannot escape via a link.
43
+ * Read sessions keep follow semantics (a symlink inside the root is readable;
44
+ * one leaving the root is rejected by `localfs.resolve`).
45
+ */
46
+ async function openLocal(target, flags, { noFollowFinal = false } = {}) {
47
+ const resolved = noFollowFinal
48
+ ? await localfs.resolveEntry(target)
49
+ : await localfs.resolve(target)
50
+ if (noFollowFinal) {
51
+ const info = await lstatOrNull(resolved)
52
+ if (info && info.isSymbolicLink()) throw new Error(SYMLINK_WRITE_ERROR)
53
+ }
54
+ const handle = await fsp.open(resolved, flags)
55
+ return { resolved, handle }
56
+ }
57
+
58
+ /** Canonical local workspace root, mirroring the remote login home. */
59
+ async function sftpHome(_server) {
60
+ return await localfs.rootReal()
61
+ }
62
+
63
+ async function sftpList(_server, remotePath) {
64
+ const target = typeof remotePath === 'string' && remotePath.trim() ? remotePath : await localfs.rootReal()
65
+ const result = await localfs.list(target)
66
+ return result.entries
67
+ }
68
+
69
+ async function sftpRead(_server, remotePath, options = {}) {
70
+ const result = await localfs.read(remotePath, options.maxBytes)
71
+ const buffer = Buffer.from(result.base64, 'base64')
72
+ const binary = buffer.includes(0)
73
+ const formatted = {
74
+ path: result.path,
75
+ size: result.size,
76
+ bytesRead: buffer.length,
77
+ truncated: result.truncated,
78
+ binary,
79
+ content: binary ? undefined : buffer.toString('utf8'),
80
+ }
81
+ if (options.base64 === true) formatted.base64 = result.base64
82
+ return formatted
83
+ }
84
+
85
+ async function sftpOpenWrite(_server, remotePath, truncate = true) {
86
+ let opened
87
+ try {
88
+ opened = await openLocal(remotePath, truncate === false ? 'r+' : 'w', { noFollowFinal: true })
89
+ } catch (error) {
90
+ if (truncate === false && error && error.code === 'ENOENT') {
91
+ opened = await openLocal(remotePath, 'w', { noFollowFinal: true })
92
+ } else throw error
93
+ }
94
+ const id = `locw_${randomUUID()}`
95
+ sessions.set(id, { kind: 'write', path: opened.resolved, handle: opened.handle })
96
+ return { session: id, path: opened.resolved }
97
+ }
98
+
99
+ async function sftpWriteChunk(sessionId, offset, buffer) {
100
+ const session = sessionFor(sessionId, 'write')
101
+ await session.handle.write(buffer, 0, buffer.length, offset)
102
+ return { session: sessionId, bytes: buffer.length, offset }
103
+ }
104
+
105
+ async function sftpOpenRead(_server, remotePath) {
106
+ const opened = await openLocal(remotePath, 'r')
107
+ const info = await opened.handle.stat()
108
+ if (!info.isFile()) {
109
+ await opened.handle.close()
110
+ throw new Error('not a file')
111
+ }
112
+ const id = `locr_${randomUUID()}`
113
+ sessions.set(id, { kind: 'read', path: opened.resolved, handle: opened.handle, size: info.size })
114
+ return { session: id, path: opened.resolved, size: info.size }
115
+ }
116
+
117
+ async function sftpReadChunk(sessionId, offset, length) {
118
+ const session = sessionFor(sessionId, 'read')
119
+ const buffer = Buffer.alloc(length)
120
+ const { bytesRead } = await session.handle.read(buffer, 0, length, offset)
121
+ return { session: sessionId, bytesRead, base64: buffer.subarray(0, bytesRead).toString('base64') }
122
+ }
123
+
124
+ async function sftpCloseTransfer(sessionId) {
125
+ const session = sessions.get(sessionId)
126
+ // The server ref lets routes invalidate the local directory cache and log
127
+ // local activity under the right resource instead of "unknown" (B5/B8).
128
+ if (!session) return { session: sessionId, path: null, server: LOCAL_FILE_REF }
129
+ sessions.delete(sessionId)
130
+ let bytes
131
+ try {
132
+ if (session.kind === 'write') bytes = (await session.handle.stat()).size
133
+ } catch {
134
+ // A failed stat still closes the descriptor below.
135
+ }
136
+ await session.handle.close()
137
+ return { session: sessionId, path: session.path, bytes, server: LOCAL_FILE_REF }
138
+ }
139
+
140
+ async function sftpMkdir(_server, remotePath) {
141
+ return await localfs.mkdir(remotePath)
142
+ }
143
+
144
+ async function sftpRemove(_server, remotePath) {
145
+ return await localfs.remove(remotePath)
146
+ }
147
+
148
+ async function sftpRename(_server, fromPath, toPath) {
149
+ return await localfs.rename(fromPath, toPath)
150
+ }
151
+
152
+ async function dispose() {
153
+ for (const session of [...sessions.values()]) {
154
+ try { await session.handle.close() } catch { /* already closed */ }
155
+ }
156
+ sessions.clear()
157
+ }
158
+
159
+ return {
160
+ server,
161
+ sftpHome,
162
+ sftpList,
163
+ sftpRead,
164
+ sftpOpenWrite,
165
+ sftpWriteChunk,
166
+ sftpOpenRead,
167
+ sftpReadChunk,
168
+ sftpCloseTransfer,
169
+ sftpMkdir,
170
+ sftpRemove,
171
+ sftpRename,
172
+ dispose,
173
+ }
174
+ }