@tiphareth/dsh-hardssh 0.1.2-alpha

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 (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17794 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +142 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +99 -0
  126. package/src/client/workspace-gate.ts +232 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Incremental UTF-8 output collector with a strict BYTE budget.
3
+ *
4
+ * The previous implementation compared `text.length` (UTF-16 code units)
5
+ * against `chunk.length` (bytes) and decoded each chunk in isolation, so
6
+ * multi-byte characters split across chunks turned into U+FFFD and the
7
+ * budget was never truly enforced. This collector counts raw input bytes,
8
+ * decodes across chunk boundaries with StringDecoder, and truncates on a
9
+ * complete UTF-8 sequence boundary.
10
+ * @module dsh-ssh/exec/output
11
+ */
12
+
13
+ import { StringDecoder } from 'node:string_decoder'
14
+
15
+ /** Marker appended when the captured output hits the byte budget. */
16
+ export const TRUNCATION_MARKER = '\n[output truncated]'
17
+
18
+ /** One captured stream (stdout or stderr). */
19
+ export class BoundedUtf8Output {
20
+ private readonly decoder = new StringDecoder('utf8')
21
+ private readonly parts: string[] = []
22
+ private _bytesAccepted = 0
23
+ private _truncated = false
24
+ private _ended = false
25
+
26
+ constructor(private readonly maxBytes: number) {}
27
+
28
+ /** Raw bytes accepted so far (before truncation). */
29
+ get bytesAccepted(): number {
30
+ return this._bytesAccepted
31
+ }
32
+
33
+ /** True once the byte budget was hit and output was cut. */
34
+ get truncated(): boolean {
35
+ return this._truncated
36
+ }
37
+
38
+ append(chunk: Buffer): void {
39
+ if (this._truncated || chunk.length === 0) return
40
+ const remaining = this.maxBytes - this._bytesAccepted
41
+ if (chunk.length <= remaining) {
42
+ this._bytesAccepted += chunk.length
43
+ this.parts.push(this.decoder.write(chunk))
44
+ return
45
+ }
46
+ // Budget exhausted mid-chunk: keep the longest prefix that ends on a
47
+ // complete UTF-8 sequence, then stop accepting input. Back off from a
48
+ // continuation byte (0b10xxxxxx) to the sequence lead, and drop the
49
+ // lead itself when its continuations were cut away.
50
+ let cut = remaining
51
+ while (cut > 0 && (chunk[cut] & 0xc0) === 0x80) cut -= 1
52
+ if (cut > 0 && (chunk[cut - 1] & 0xc0) === 0xc0) cut -= 1
53
+ const kept = chunk.subarray(0, cut)
54
+ this._bytesAccepted += kept.length
55
+ if (kept.length > 0) this.parts.push(this.decoder.write(kept))
56
+ this.parts.push(this.decoder.end())
57
+ this._ended = true
58
+ this._truncated = true
59
+ }
60
+
61
+ /** The captured text (truncation marker appended when cut). Idempotent. */
62
+ finish(): string {
63
+ if (!this._ended) {
64
+ this._ended = true
65
+ this.parts.push(this.decoder.end())
66
+ }
67
+ const text = this.parts.join('')
68
+ return this._truncated ? text + TRUNCATION_MARKER : text
69
+ }
70
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * SSH host-key trust store (TOFU): first-connection trust-on-first-use.
3
+ *
4
+ * When enabled, the engine refuses to connect to a host whose public-key
5
+ * fingerprint has not been explicitly trusted: the first encounter records a
6
+ * `pending` entry and the connection is REJECTED (credentials are never sent
7
+ * to an unverified host), the operator confirms the fingerprint through the
8
+ * GUI, and every later connection re-checks the stored fingerprint with a
9
+ * constant-time comparison.
10
+ *
11
+ * The store is deliberately independent of the credentials vault: a
12
+ * fingerprint is not a secret, so verifying the host needs no unlock, and
13
+ * Host-Key protectio n can ship (and be tested) before the vault lands.
14
+ *
15
+ * @module dsh-hardssh/known-hosts
16
+ */
17
+
18
+ import { createHash, timingSafeEqual } from 'node:crypto'
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
20
+ import { homedir } from 'node:os'
21
+ import { dirname, join } from 'node:path'
22
+
23
+ /** File format version. */
24
+ const FORMAT_VERSION = 1
25
+
26
+ /** One known-host record. */
27
+ export interface KnownHostRecord {
28
+ alias: string
29
+ host: string
30
+ port: number
31
+ /** Server public-key algorithm (ssh-ed25519, ssh-rsa, …) for display. */
32
+ keyType: string
33
+ /** Canonical fingerprint: 'SHA256:' + base64 (no '=' padding). */
34
+ fingerprint: string
35
+ /** 'pending' = seen once, waiting for explicit trust; 'trusted' = confirmed. */
36
+ status: 'pending' | 'trusted'
37
+ firstSeenAt: number
38
+ confirmedAt: number | null
39
+ }
40
+
41
+ interface KnownHostsFile {
42
+ version: number
43
+ hosts: KnownHostRecord[]
44
+ }
45
+
46
+ /** Default file location: <home>/.dsh/ssh-known-hosts.json. */
47
+ export function knownHostsPath(): string {
48
+ return join(homedir(), '.dsh', 'ssh-known-hosts.json')
49
+ }
50
+
51
+ /** Normalize a fingerprint to the canonical 'SHA256:<b64-no-padding>' form. */
52
+ export function normalizeFingerprint(input: string): string {
53
+ const trimmed = input.trim().replace(/^SHA256:/iu, '')
54
+ return 'SHA256:' + trimmed.replace(/=+$/u, '')
55
+ }
56
+
57
+ /** Constant-time comparison of two fingerprints (canonical form, length-safe). */
58
+ export function fingerprintsEqual(a: string, b: string): boolean {
59
+ const bufA = Buffer.from(normalizeFingerprint(a).slice('SHA256:'.length))
60
+ const bufB = Buffer.from(normalizeFingerprint(b).slice('SHA256:'.length))
61
+ return bufA.length === bufB.length && timingSafeEqual(bufA, bufB)
62
+ }
63
+
64
+ /** Compute the canonical OpenSSH-style fingerprint of a server key blob. */
65
+ export function fingerprintOf(rawKey: Buffer): string {
66
+ const digest = createHash('sha256').update(rawKey).digest('base64').replace(/=+$/u, '')
67
+ return 'SHA256:' + digest
68
+ }
69
+
70
+ /** Host-key check outcome. */
71
+ export type HostKeyCheck =
72
+ | { kind: 'trusted' }
73
+ | { kind: 'unknown'; fingerprintSha256: string }
74
+ | { kind: 'mismatch'; expected: string; actual: string }
75
+
76
+ /** Persisted host-key trust store (atomic write, 0600). */
77
+ export class KnownHostsStore {
78
+ private readonly path: string
79
+ private records = new Map<string, KnownHostRecord>()
80
+
81
+ constructor(filePath?: string) {
82
+ this.path = filePath ?? knownHostsPath()
83
+ this.load()
84
+ }
85
+
86
+ private load(): void {
87
+ if (!existsSync(this.path)) return
88
+ try {
89
+ const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as KnownHostsFile
90
+ if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.hosts)) return
91
+ for (const record of parsed.hosts) {
92
+ if (isRecord(record)) this.records.set(record.alias, record)
93
+ }
94
+ } catch {
95
+ // Corrupt / unreadable trust file: fail open (treat as empty) rather
96
+ // than brick every SSH connection; the first connect re-records pending.
97
+ }
98
+ }
99
+
100
+ private save(): void {
101
+ const dir = dirname(this.path)
102
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
103
+ const file: KnownHostsFile = { version: FORMAT_VERSION, hosts: [...this.records.values()] }
104
+ const tmp = this.path + '.tmp'
105
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
106
+ renameSync(tmp, this.path)
107
+ }
108
+
109
+ lookup(alias: string): KnownHostRecord | undefined {
110
+ return this.records.get(alias)
111
+ }
112
+
113
+ /** First encounter: record as pending (non-destructive re-observe keeps status). */
114
+ observe(alias: string, meta: { host: string; port: number; keyType: string; fingerprintSha256: string }): void {
115
+ const existing = this.records.get(alias)
116
+ if (existing !== undefined) return // keep pending/trusted as-is
117
+ this.records.set(alias, {
118
+ alias,
119
+ host: meta.host,
120
+ port: meta.port,
121
+ keyType: meta.keyType,
122
+ fingerprint: meta.fingerprintSha256,
123
+ status: 'pending',
124
+ firstSeenAt: Date.now(),
125
+ confirmedAt: null,
126
+ })
127
+ this.save()
128
+ }
129
+
130
+ /** pending → trusted (must match the recorded fingerprint). */
131
+ trust(alias: string): void {
132
+ const record = this.records.get(alias)
133
+ if (record === undefined) throw new Error(`no host-key record for '${alias}'`)
134
+ record.status = 'trusted'
135
+ record.confirmedAt = Date.now()
136
+ this.save()
137
+ }
138
+
139
+ /** Forget a host (key rotation /误信 reset → next connect re-TOFU). */
140
+ forget(alias: string): void {
141
+ this.records.delete(alias)
142
+ this.save()
143
+ }
144
+
145
+ list(): KnownHostRecord[] {
146
+ return [...this.records.values()]
147
+ }
148
+ }
149
+
150
+ /** Per-alias host-key policy: check a server key against the store. */
151
+ export class HostKeyPolicy {
152
+ constructor(private readonly knownHosts: KnownHostsStore) {}
153
+
154
+ /** Evaluate one server key: trusted / unknown (records pending) / mismatch. */
155
+ check(alias: string, rawKey: Buffer): HostKeyCheck {
156
+ const recorded = this.knownHosts.lookup(alias)
157
+ const actual = fingerprintOf(rawKey)
158
+ if (recorded === undefined) {
159
+ // First encounter: remember as pending, but NOT trusted — the caller
160
+ // must refuse the connection until the operator confirms.
161
+ this.knownHosts.observe(alias, {
162
+ host: '', // host/port are filled by the engine's richer record on confirm
163
+ port: 0,
164
+ keyType: keyTypeOf(rawKey),
165
+ fingerprintSha256: actual,
166
+ })
167
+ return { kind: 'unknown', fingerprintSha256: actual }
168
+ }
169
+ if (recorded.status !== 'trusted') return { kind: 'unknown', fingerprintSha256: recorded.fingerprint }
170
+ if (fingerprintsEqual(recorded.fingerprint, actual)) return { kind: 'trusted' }
171
+ return { kind: 'mismatch', expected: recorded.fingerprint, actual }
172
+ }
173
+ }
174
+
175
+ /** Best-effort key type label from the raw blob's base64 header. */
176
+ function keyTypeOf(rawKey: Buffer): string {
177
+ try {
178
+ const base64 = rawKey.toString('base64')
179
+ const decoded = Buffer.from(base64, 'base64').toString('utf8')
180
+ const end = decoded.indexOf(' ')
181
+ if (end > 0) return decoded.slice(0, end)
182
+ } catch { /* fall through */ }
183
+ return 'ssh-unknown'
184
+ }
185
+
186
+ function isRecord(value: unknown): value is KnownHostRecord {
187
+ if (typeof value !== 'object' || value === null) return false
188
+ const record = value as Record<string, unknown>
189
+ return typeof record.alias === 'string'
190
+ && typeof record.fingerprint === 'string'
191
+ && (record.status === 'pending' || record.status === 'trusted')
192
+ }
193
+
194
+ /** Error: the host key is not trusted yet (first encounter, awaiting confirm). */
195
+ export class HostKeyUnknownError extends Error {
196
+ readonly fingerprintSha256: string
197
+ constructor(alias: string, fingerprintSha256: string) {
198
+ super(`host key for '${alias}' 尚未信任 — 请先确认 SHA256 指纹 ${fingerprintSha256}`)
199
+ this.name = 'HostKeyUnknownError'
200
+ this.fingerprintSha256 = fingerprintSha256
201
+ }
202
+ }
203
+
204
+ /** Error: the host key changed since it was trusted (possible MITM / key rotation). */
205
+ export class HostKeyMismatchError extends Error {
206
+ readonly expected: string
207
+ readonly actual: string
208
+ constructor(alias: string, expected: string, actual: string) {
209
+ super(`host key for '${alias}' 已变化(期望 ${expected},实际 ${actual})—— 请先人工核验服务器后 forget 重置信任`)
210
+ this.name = 'HostKeyMismatchError'
211
+ this.expected = expected
212
+ this.actual = actual
213
+ }
214
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The SSH operations capability, now hosted inside dsh-hardssh (migrated
3
+ * from the legacy dsh-ssh package): host config store
4
+ * (~/.dsh/dsh-ssh.json), the shared ssh2 engine, /api/dsh-ssh routes, the
5
+ * six ssh_* agent tools, the `dsh-ssh` settings namespace, and the SSH
6
+ * system-prompt section.
7
+ *
8
+ * This module deliberately does NOT create or dispose the engine/store:
9
+ * ownership belongs to src/index.ts (one instance, one dispose point). It
10
+ * only mounts/unmounts surfaces against the shared instances.
11
+ */
12
+
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import z from '@deepseek-ai/schemastery'
15
+ import type {} from '@deepseek-ai/dsh-host-webserver'
16
+ import type {} from '@deepseek-ai/dsh-system-prompt'
17
+ import type {} from '@deepseek-ai/dsh-tools'
18
+ import type { SshEngine } from './engine.ts'
19
+ import type { HostStore } from './store.ts'
20
+ import { makeRoutes } from './routes.ts'
21
+ import { invalidateRemoteEnvironment } from '../remote/environment.ts'
22
+ import { sshClusterTool, sshDownloadTool, sshExecTool, sshListTool, sshTunnelTool, sshUploadTool } from './tools.ts'
23
+
24
+ /** Settings namespace of the SSH capability. Kept as 'dsh-ssh' so existing
25
+ * user settings survive the package merge unchanged. */
26
+ export const SSH_SETTINGS_NAMESPACE = 'dsh-ssh'
27
+
28
+ /** SSH-capability config, validated by the same-named schemastery schema. */
29
+ export interface SshConfig {
30
+ /** When true (default), a system-prompt section announces the SSH capability. */
31
+ announceToAgent?: boolean
32
+ /** Master switch for the SSH surfaces (routes, tools, prompt section). */
33
+ enabled?: boolean
34
+ /**
35
+ * Secret storage mode:
36
+ * - 'none' (default): passwords/passphrases are NEVER persisted; each
37
+ * session's first connection prompts for them (VSCode Remote-SSH style)
38
+ * and they live in memory (session password table) for the connection
39
+ * pool lifetime (idle 30 min auto-disconnect; new terminals reuse it).
40
+ * - 'vault': store secrets encrypted at rest (AES-256-GCM) under a master
41
+ * password / DSH_CREDENTIAL_PASSWORD; for headless agents that must run
42
+ * password hosts unattended.
43
+ */
44
+ secretStorage?: 'none' | 'vault'
45
+ }
46
+
47
+ export const SshConfig: z<SshConfig> = z.object({
48
+ announceToAgent: z.boolean().default(true),
49
+ enabled: z.boolean().default(true),
50
+ secretStorage: z.union([z.const('none'), z.const('vault')]).default('none'),
51
+ })
52
+
53
+ /** Schema default, re-read for hand-built test contexts. */
54
+ const DEFAULT_ANNOUNCE = true
55
+
56
+ /** Order of the announcement section within the tool-guidance band. */
57
+ const SECTION_ORDER = 150
58
+
59
+ /** Model-facing announcement: SSH operations capability, capabilities, limits. */
60
+ export const SSH_GUIDANCE = '本机已安装 dsh-hardssh 插件(内建 DSH 远程 SSH 运维):侧边栏「SSH」入口;SSH 运维与 SSH 工作区在 dsh-hardssh 中统一维护。能力:主机配置存 ~/.dsh/dsh-ssh.json(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;连接建立失败会自动重连,但命令或文件操作一旦提交就不会自动重试——结果不明确时应先核实远端状态,再决定是否人工重试;传输/执行消耗真实远程资源,先确认再操作。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
61
+
62
+ /** Shared instances the SSH surfaces run on. */
63
+ export interface SshCapabilityDeps {
64
+ store: import('./store.ts').SecureHostStore
65
+ engine: SshEngine
66
+ /** Host-key TOFU trust store; absent → host-key verification disabled. */
67
+ knownHosts?: import('./known-hosts.ts').KnownHostsStore
68
+ /** Credential vault; absent → vault endpoints disabled. */
69
+ vault?: import('./vault.ts').Vault
70
+ /** SSH-bound workspace ledger (host-delete reference guard). */
71
+ ledger?: import('../ledger.ts').SshWorkspaceLedger
72
+ }
73
+
74
+ /**
75
+ * Mount the SSH operations surfaces (routes, tools, settings, prompt) against
76
+ * the shared engine/store. Called from src/index.ts BEFORE the workspace
77
+ * `enabled` early-return, so the SSH capability stays independently togglable
78
+ * via its own `dsh-ssh` settings namespace.
79
+ */
80
+ export function mountSshCapability(ctx: Context, deps: SshCapabilityDeps): void {
81
+ const { store, engine } = deps
82
+ let current: () => SshConfig = () => ({})
83
+ const resolve = (): SshConfig => {
84
+ const value = current()
85
+ return {
86
+ announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
87
+ enabled: value.enabled ?? true,
88
+ }
89
+ }
90
+
91
+ // The /api/dsh-ssh route family + terminal upgrade. Registered through a
92
+ // dynamic webServer inject (headless profiles lack the service — mirroring
93
+ // the workspace routes in src/index.ts). The enabled switch is honored at
94
+ // registration time; toggling it later takes effect on reload.
95
+ const { routes, upgrade } = makeRoutes({
96
+ store,
97
+ engine,
98
+ knownHosts: deps.knownHosts,
99
+ vault: deps.vault,
100
+ ledger: deps.ledger,
101
+ // Host PATCH/DELETE also invalidates the per-alias environment cache so
102
+ // a re-pointed alias cannot keep serving the old host's env.
103
+ onHostInvalidated: (alias) => invalidateRemoteEnvironment(engine, alias),
104
+ })
105
+ ctx.inject(['webServer'], (scoped) => {
106
+ if (!resolve().enabled) return () => { /* disabled: nothing registered */ }
107
+ const disposers = routes.map(route => scoped.webServer.register(route))
108
+ const upgradeDisposer = scoped.webServer.registerUpgrade(upgrade)
109
+ return () => {
110
+ for (const dispose of disposers) dispose()
111
+ upgradeDisposer()
112
+ }
113
+ })
114
+
115
+ // Agent tools + their prompt sections.
116
+ const tools = [
117
+ sshListTool(engine),
118
+ sshExecTool(engine),
119
+ sshUploadTool(engine),
120
+ sshDownloadTool(engine),
121
+ sshTunnelTool(engine),
122
+ sshClusterTool(engine),
123
+ ]
124
+ let disposeTools: (() => void) | undefined
125
+
126
+ // System-prompt announcement.
127
+ let disposeSection: (() => void) | undefined
128
+
129
+ // Register (or drop) tools/prompt to match the current settings source.
130
+ // Routes are managed by the dynamic inject above.
131
+ const sync = (): void => {
132
+ const value = resolve()
133
+ if (disposeSection !== undefined) {
134
+ disposeSection()
135
+ disposeSection = undefined
136
+ }
137
+ if (disposeTools !== undefined) {
138
+ disposeTools()
139
+ disposeTools = undefined
140
+ }
141
+ if (!value.enabled) return
142
+ if (value.announceToAgent) {
143
+ disposeSection = ctx.systemPrompt.section({
144
+ name: 'plugin:dsh-ssh',
145
+ order: SECTION_ORDER,
146
+ text: SSH_GUIDANCE,
147
+ })
148
+ }
149
+ disposeTools = ctx.effect(
150
+ () => {
151
+ const disposers = tools.map(tool => ctx.tools.register(tool))
152
+ return () => { for (const dispose of disposers) dispose() }
153
+ },
154
+ 'dsh-ssh: tools',
155
+ )
156
+ }
157
+
158
+ // Settings-backed configuration (dsh-settings service; optional in
159
+ // headless profiles). The service was rewritten in dsh 0.1.2: the old
160
+ // `installSettingsSection`/`settingsNamespace` exports are gone, replaced
161
+ // by ctx.settings.installSection(owner, ns, schema, entry, hooks). The
162
+ // default entry is the resolved config defaults; setSource/onChange keep
163
+ // the sync() wiring equivalent for both generations.
164
+ type SettingsService = {
165
+ installSection?(owner: Context, ns: string, schema: unknown, entry: unknown, hooks: {
166
+ setSource(current: () => SshConfig): void
167
+ onChange(): void
168
+ }): void
169
+ }
170
+ const settings = ctx.get('settings') as SettingsService | undefined
171
+ if (settings?.installSection !== undefined) {
172
+ settings.installSection(ctx, 'dsh-ssh', SshConfig, {}, {
173
+ setSource: (source) => {
174
+ current = source
175
+ sync()
176
+ },
177
+ onChange: sync,
178
+ })
179
+ }
180
+
181
+ // Initial registration (covers deployments with no settings service, whose
182
+ // installSection never fires its hooks).
183
+ sync()
184
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Wire contract between the host half (routes.ts) and the browser half
3
+ * (client/api.ts). Pure types only — imported by both halves, bundled into
4
+ * each, no runtime identity to share.
5
+ */
6
+
7
+ /** Authentication flavors a host entry may carry. */
8
+ export type SshAuthKind = 'key' | 'password'
9
+
10
+ /** One stored host entry (the ~/.dsh/dsh-ssh.json store shape). */
11
+ export interface SshHostEntry {
12
+ /** Stable, user-chosen identifier used by every operation. */
13
+ alias: string
14
+ /** Hostname or IP of the target. */
15
+ host: string
16
+ /** SSH port (default 22). */
17
+ port: number
18
+ /** Login user. */
19
+ user: string
20
+ /** Authentication. */
21
+ auth: {
22
+ kind: SshAuthKind
23
+ /** Absolute path to the private key for 'key' auth. */
24
+ keyPath?: string
25
+ /** Passphrase for an encrypted key. */
26
+ passphrase?: string
27
+ /** Password for 'password' auth. */
28
+ password?: string
29
+ /** Vault ref to the encrypted password/passphrase (v2 secure store). */
30
+ secretRef?: string
31
+ }
32
+ /** Jump chain: local aliases connected through in order (ProxyJump). */
33
+ proxyJump: string[]
34
+ /** Free-form note. */
35
+ description?: string
36
+ /** Deployment environment label (development / production / ...). */
37
+ environment?: string
38
+ /** Free-form tags. */
39
+ tags: string[]
40
+ /** Physical location note. */
41
+ location?: string
42
+ createdAt: number
43
+ updatedAt: number
44
+ }
45
+
46
+ /** Public (secret-free) projection of an entry, safe for the browser/agent. */
47
+ export interface SshHostSummary {
48
+ alias: string
49
+ host: string
50
+ port: number
51
+ user: string
52
+ auth: SshAuthKind
53
+ /** Whether the key path exists on the host machine (key auth only). */
54
+ keyReady: boolean
55
+ proxyJump: string[]
56
+ description?: string
57
+ environment?: string
58
+ tags: string[]
59
+ location?: string
60
+ createdAt: number
61
+ updatedAt: number
62
+ }
63
+
64
+ /** Result of one non-interactive command execution. */
65
+ export interface ExecResult {
66
+ success: boolean
67
+ /** Remote exit code, or null when the channel died without one. */
68
+ exitCode: number | null
69
+ timedOut: boolean
70
+ stdout: string
71
+ stderr: string
72
+ /** Wall-clock duration of the round trip in ms. */
73
+ durationMs: number
74
+ /** Connection error message when the command never ran. */
75
+ error?: string
76
+ }
77
+
78
+ /** One server entry in a cluster run. */
79
+ export interface ClusterResult {
80
+ alias: string
81
+ ok: boolean
82
+ exitCode?: number | null
83
+ timedOut?: boolean
84
+ stdout?: string
85
+ stderr?: string
86
+ durationMs?: number
87
+ error?: string
88
+ }
89
+
90
+ /** SFTP transfer progress frame (upload stream). */
91
+ export interface TransferProgress {
92
+ phase: 'connecting' | 'transferring' | 'done' | 'error'
93
+ file: string
94
+ transferred: number
95
+ total: number
96
+ percent: number
97
+ speedBps?: number
98
+ error?: string
99
+ }
100
+
101
+ /** One active local port-forward tunnel. */
102
+ export interface TunnelInfo {
103
+ id: string
104
+ alias: string
105
+ localPort: number
106
+ remoteHost: string
107
+ remotePort: number
108
+ state: 'forwarding' | 'connecting' | 'failed'
109
+ error?: string
110
+ startedAt: number
111
+ }
112
+
113
+ /** One directory listing entry (remote file browser). */
114
+ export interface RemoteDirEntry {
115
+ name: string
116
+ type: 'dir' | 'file' | 'other'
117
+ size: number
118
+ mtimeMs: number
119
+ mode?: number
120
+ }
121
+
122
+ /** Test-connection outcome. */
123
+ export interface TestResult {
124
+ ok: boolean
125
+ latencyMs?: number
126
+ error?: string
127
+ /** Stable machine code for host-key routing (HOST_KEY_UNKNOWN / HOST_KEY_MISMATCH / NEEDS_PASSWORD). */
128
+ code?: string
129
+ /** Which secret a connection needs when code === 'NEEDS_PASSWORD'. */
130
+ secret?: 'password' | 'passphrase'
131
+ /** The pending/expected fingerprint when a host key was refused. */
132
+ hostKeyFingerprint?: string
133
+ /** Mismatch detail (expected vs actual) when the host key changed. */
134
+ hostKeyMismatch?: { expected: string; actual: string }
135
+ }
136
+
137
+ /** Host edit payload (create/update); 'alias' comes from the URL for updates. */
138
+ export interface HostPayload {
139
+ alias?: string
140
+ host: string
141
+ port?: number
142
+ user: string
143
+ /**
144
+ * Authentication. Required on create; on update an omitted auth keeps the
145
+ * stored secrets (the browser never receives them back).
146
+ */
147
+ auth?: SshHostEntry['auth']
148
+ proxyJump?: string[]
149
+ description?: string
150
+ environment?: string
151
+ tags?: string[]
152
+ location?: string
153
+ }
154
+
155
+ /** Import outcome from ~/.ssh/config. */
156
+ export interface ImportResult {
157
+ parsed: number
158
+ added: number
159
+ skipped: number
160
+ /** Aliases that failed to map (wildcard patterns, missing HostName, ...). */
161
+ skippedNames: string[]
162
+ }
163
+
164
+ /** JSON error body used by every route. */
165
+ export interface ApiErrorBody {
166
+ error: string
167
+ /** Stable machine code (HOST_KEY_UNKNOWN / HOST_KEY_MISMATCH / HOST_IN_USE / ...). */
168
+ code?: string
169
+ /** Which secret a connection needs when code === 'NEEDS_PASSWORD'. */
170
+ secret?: 'password' | 'passphrase'
171
+ /** The fingerprint when a host key was refused. */
172
+ hostKeyFingerprint?: string
173
+ /** Mismatch detail (expected vs actual) when the host key changed. */
174
+ hostKeyMismatch?: { expected: string; actual: string }
175
+ /** Workspace titles referencing a host that cannot be deleted (HOST_IN_USE). */
176
+ workspaces?: Array<{ id: string; title: string }>
177
+ }
178
+
179
+ /** WebSocket terminal protocol frames (host -> client and client -> host). */
180
+ export type TerminalServerFrame =
181
+ | { type: 'ready'; alias: string }
182
+ | { type: 'output'; data: string }
183
+ | { type: 'exit'; code: number | null; error?: string }
184
+
185
+ export type TerminalClientFrame =
186
+ | { type: 'input'; data: string }
187
+ | { type: 'resize'; cols: number; rows: number }
188
+
189
+ /** Route paths the client calls (shared literals). */
190
+ export const SSH_API_BASE = '/api/dsh-ssh' as const
191
+
192
+ export const SSH_API = {
193
+ hosts: SSH_API_BASE + '/hosts',
194
+ importSshConfig: SSH_API_BASE + '/hosts/import-ssh-config',
195
+ test: SSH_API_BASE + '/test',
196
+ exec: SSH_API_BASE + '/exec',
197
+ cluster: SSH_API_BASE + '/cluster',
198
+ upload: SSH_API_BASE + '/upload',
199
+ download: SSH_API_BASE + '/download',
200
+ ls: SSH_API_BASE + '/ls',
201
+ tunnel: SSH_API_BASE + '/tunnel',
202
+ terminal: SSH_API_BASE + '/terminal',
203
+ knownHosts: SSH_API_BASE + '/known-hosts',
204
+ vault: SSH_API_BASE + '/vault',
205
+ sessionSecret: SSH_API_BASE + '/session-secret',
206
+ connections: SSH_API_BASE + '/connections',
207
+ } as const
208
+
209
+ /** One known-host record as seen by the browser (no secrets). */
210
+ export interface KnownHostView {
211
+ alias: string
212
+ host: string
213
+ port: number
214
+ keyType: string
215
+ fingerprint: string
216
+ status: 'pending' | 'trusted'
217
+ firstSeenAt: number
218
+ confirmedAt: number | null
219
+ }
220
+
221
+ /** Host-key trust actions the browser can perform. */
222
+ export type KnownHostAction = 'trust' | 'forget'
223
+
224
+ /** NDJSON transfer stream line shapes (upload). */
225
+ export type TransferStreamLine =
226
+ | { type: 'progress'; progress: TransferProgress }
227
+ | { type: 'result'; ok: boolean; transferredBytes?: number; error?: string }