dsh-remote-vs 0.0.0-stage → 0.8.3

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 (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +385 -0
  3. package/README.md +347 -2
  4. package/cordis.patch.yml +259 -0
  5. package/icon.svg +7 -0
  6. package/lib/client.js +62602 -0
  7. package/lib/index.js +4287 -0
  8. package/lib/p2-bridge-refs.js +10 -0
  9. package/lib/p2-bridge.js +16 -0
  10. package/lib/p2-router-fs.js +252 -0
  11. package/lib/p2-router-refs.js +466 -0
  12. package/lib/p2-router-spill.js +62 -0
  13. package/lib/p2-router-subprocess.js +64 -0
  14. package/lib/p2-shell-bash.js +24 -0
  15. package/lib/ssh-proc-arI5lm4W.js +632 -0
  16. package/lib/types/changes/recorder.d.ts +240 -0
  17. package/lib/types/changes/status.d.ts +201 -0
  18. package/lib/types/changes/text-diff.d.ts +65 -0
  19. package/lib/types/client/CodeEditor.d.ts +125 -0
  20. package/lib/types/client/Dialog.d.ts +14 -0
  21. package/lib/types/client/DirectoryFlow.d.ts +30 -0
  22. package/lib/types/client/EditorPane.d.ts +30 -0
  23. package/lib/types/client/HostsView.d.ts +10 -0
  24. package/lib/types/client/NativeExplorer.d.ts +39 -0
  25. package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
  26. package/lib/types/client/RemoteEditorView.d.ts +20 -0
  27. package/lib/types/client/RemoteExplorer.d.ts +18 -0
  28. package/lib/types/client/RemoteRoot.d.ts +16 -0
  29. package/lib/types/client/SearchView.d.ts +14 -0
  30. package/lib/types/client/SettingsHosts.d.ts +15 -0
  31. package/lib/types/client/TerminalView.d.ts +16 -0
  32. package/lib/types/client/WorkspaceWizard.d.ts +16 -0
  33. package/lib/types/client/WorkspacesView.d.ts +14 -0
  34. package/lib/types/client/XTerm.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +162 -0
  36. package/lib/types/client/changes/ChangesDock.d.ts +35 -0
  37. package/lib/types/client/changes/model.d.ts +36 -0
  38. package/lib/types/client/changes/undo-affordance.d.ts +48 -0
  39. package/lib/types/client/changes/undo-context.d.ts +25 -0
  40. package/lib/types/client/composer.d.ts +165 -0
  41. package/lib/types/client/i18n.d.ts +17 -0
  42. package/lib/types/client/index.d.ts +14 -0
  43. package/lib/types/client/remote-file-open-target.d.ts +247 -0
  44. package/lib/types/client/remote-file-open.d.ts +35 -0
  45. package/lib/types/client/reveal.d.ts +15 -0
  46. package/lib/types/client/scm/DiffView.d.ts +24 -0
  47. package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
  48. package/lib/types/client/scm/RepoPicker.d.ts +24 -0
  49. package/lib/types/client/scm/ScmPanel.d.ts +15 -0
  50. package/lib/types/client/scm/actions.d.ts +170 -0
  51. package/lib/types/client/scm/diff-model.d.ts +29 -0
  52. package/lib/types/client/scm/stage-queue.d.ts +99 -0
  53. package/lib/types/client/scm/status-map.d.ts +157 -0
  54. package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
  55. package/lib/types/client/selection-popup.d.ts +61 -0
  56. package/lib/types/client/store.d.ts +437 -0
  57. package/lib/types/client/theme.d.ts +15 -0
  58. package/lib/types/client/ui.d.ts +46 -0
  59. package/lib/types/client/upload-plan.d.ts +78 -0
  60. package/lib/types/client/upload-source.d.ts +46 -0
  61. package/lib/types/client/use-remote-chat-target.d.ts +17 -0
  62. package/lib/types/client/vsc/FindWidget.d.ts +81 -0
  63. package/lib/types/client/vsc/change-marks.d.ts +150 -0
  64. package/lib/types/client/vsc/editor-langs.d.ts +5 -0
  65. package/lib/types/client/vsc/editor-palette.d.ts +37 -0
  66. package/lib/types/client/vsc/editor-theme.d.ts +32 -0
  67. package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
  68. package/lib/types/client/vsc/kit.d.ts +195 -0
  69. package/lib/types/client/vsc/markdown.d.ts +11 -0
  70. package/lib/types/client/vsc/reveal-line.d.ts +38 -0
  71. package/lib/types/client/vsc/tokenize.d.ts +104 -0
  72. package/lib/types/deploy.d.ts +94 -0
  73. package/lib/types/fs-ops.d.ts +8 -0
  74. package/lib/types/git-format.d.ts +34 -0
  75. package/lib/types/git-ops.d.ts +183 -0
  76. package/lib/types/http.d.ts +14 -0
  77. package/lib/types/importers.d.ts +20 -0
  78. package/lib/types/index.d.ts +87 -0
  79. package/lib/types/keygen.d.ts +19 -0
  80. package/lib/types/loopback.d.ts +7 -0
  81. package/lib/types/p2/bridge-refs.d.ts +21 -0
  82. package/lib/types/p2/bridge.d.ts +24 -0
  83. package/lib/types/p2/file-refs.d.ts +73 -0
  84. package/lib/types/p2/helper-rpc.d.ts +63 -0
  85. package/lib/types/p2/in-flight-gate.d.ts +25 -0
  86. package/lib/types/p2/router-fs.d.ts +92 -0
  87. package/lib/types/p2/router-refs.d.ts +45 -0
  88. package/lib/types/p2/router-spill.d.ts +11 -0
  89. package/lib/types/p2/router-subprocess.d.ts +23 -0
  90. package/lib/types/p2/shell-bash.d.ts +16 -0
  91. package/lib/types/p2/ssh-proc.d.ts +22 -0
  92. package/lib/types/p2/worlds.d.ts +179 -0
  93. package/lib/types/paths.d.ts +12 -0
  94. package/lib/types/pool.d.ts +105 -0
  95. package/lib/types/protocol.d.ts +452 -0
  96. package/lib/types/routes.d.ts +27 -0
  97. package/lib/types/search-ops.d.ts +68 -0
  98. package/lib/types/sftp.d.ts +90 -0
  99. package/lib/types/store.d.ts +66 -0
  100. package/lib/types/terminal.d.ts +51 -0
  101. package/lib/types/timeouts.d.ts +83 -0
  102. package/lib/types/tools.d.ts +41 -0
  103. package/lib/worlds-PUumRIh7.js +18765 -0
  104. package/locale/en.json +6 -0
  105. package/locale/zh.json +6 -0
  106. package/package.json +189 -4
  107. package/src/changes/recorder.ts +902 -0
  108. package/src/changes/status.ts +448 -0
  109. package/src/changes/text-diff.ts +377 -0
  110. package/src/client/CodeEditor.tsx +923 -0
  111. package/src/client/Dialog.tsx +76 -0
  112. package/src/client/DirectoryFlow.tsx +431 -0
  113. package/src/client/EditorPane.tsx +512 -0
  114. package/src/client/HostsView.tsx +312 -0
  115. package/src/client/NativeExplorer.tsx +121 -0
  116. package/src/client/RemoteDirectoryPicker.tsx +157 -0
  117. package/src/client/RemoteEditorView.tsx +54 -0
  118. package/src/client/RemoteExplorer.tsx +1398 -0
  119. package/src/client/RemoteRoot.tsx +389 -0
  120. package/src/client/SearchView.tsx +407 -0
  121. package/src/client/SettingsHosts.tsx +136 -0
  122. package/src/client/TerminalView.tsx +308 -0
  123. package/src/client/WorkspaceWizard.tsx +199 -0
  124. package/src/client/WorkspacesView.tsx +93 -0
  125. package/src/client/XTerm.tsx +308 -0
  126. package/src/client/api.ts +331 -0
  127. package/src/client/changes/ChangesDock.tsx +348 -0
  128. package/src/client/changes/changes.css +230 -0
  129. package/src/client/changes/model.ts +347 -0
  130. package/src/client/changes/undo-affordance.ts +389 -0
  131. package/src/client/changes/undo-context.ts +34 -0
  132. package/src/client/composer.ts +436 -0
  133. package/src/client/css.d.ts +7 -0
  134. package/src/client/i18n.ts +702 -0
  135. package/src/client/index.tsx +286 -0
  136. package/src/client/remote-file-open-target.ts +553 -0
  137. package/src/client/remote-file-open.ts +261 -0
  138. package/src/client/reveal.ts +33 -0
  139. package/src/client/scm/DiffView.tsx +570 -0
  140. package/src/client/scm/HistoryPanel.tsx +353 -0
  141. package/src/client/scm/RepoPicker.tsx +238 -0
  142. package/src/client/scm/ScmPanel.tsx +857 -0
  143. package/src/client/scm/actions.ts +460 -0
  144. package/src/client/scm/diff-model.ts +206 -0
  145. package/src/client/scm/stage-queue.ts +264 -0
  146. package/src/client/scm/status-map.ts +381 -0
  147. package/src/client/scm/untracked-dirs.ts +139 -0
  148. package/src/client/selection-popup.ts +157 -0
  149. package/src/client/store.tsx +1670 -0
  150. package/src/client/theme.ts +54 -0
  151. package/src/client/ui.tsx +136 -0
  152. package/src/client/upload-plan.ts +153 -0
  153. package/src/client/upload-source.ts +145 -0
  154. package/src/client/use-remote-chat-target.ts +153 -0
  155. package/src/client/vsc/FindWidget.tsx +446 -0
  156. package/src/client/vsc/change-marks.ts +586 -0
  157. package/src/client/vsc/editor-langs.ts +137 -0
  158. package/src/client/vsc/editor-palette.ts +107 -0
  159. package/src/client/vsc/editor-theme.ts +93 -0
  160. package/src/client/vsc/fallback-highlight.ts +145 -0
  161. package/src/client/vsc/kit.tsx +777 -0
  162. package/src/client/vsc/markdown.tsx +172 -0
  163. package/src/client/vsc/reveal-line.ts +142 -0
  164. package/src/client/vsc/tokenize.ts +569 -0
  165. package/src/client/vsc/vsc.css +2669 -0
  166. package/src/deploy.ts +481 -0
  167. package/src/fs-ops.ts +57 -0
  168. package/src/git-format.ts +205 -0
  169. package/src/git-ops.ts +693 -0
  170. package/src/http.ts +55 -0
  171. package/src/importers.ts +141 -0
  172. package/src/index.ts +442 -0
  173. package/src/keygen.ts +77 -0
  174. package/src/loopback.ts +47 -0
  175. package/src/p2/bridge-refs.ts +27 -0
  176. package/src/p2/bridge.ts +32 -0
  177. package/src/p2/file-refs.ts +392 -0
  178. package/src/p2/helper-rpc.ts +394 -0
  179. package/src/p2/in-flight-gate.ts +63 -0
  180. package/src/p2/router-fs.ts +327 -0
  181. package/src/p2/router-refs.ts +166 -0
  182. package/src/p2/router-spill.ts +75 -0
  183. package/src/p2/router-subprocess.ts +92 -0
  184. package/src/p2/shell-bash.ts +30 -0
  185. package/src/p2/ssh-proc.ts +802 -0
  186. package/src/p2/worlds.ts +735 -0
  187. package/src/paths.ts +50 -0
  188. package/src/pool.ts +811 -0
  189. package/src/protocol.ts +460 -0
  190. package/src/routes.ts +802 -0
  191. package/src/search-ops.ts +220 -0
  192. package/src/sftp.ts +677 -0
  193. package/src/store.ts +364 -0
  194. package/src/terminal.ts +291 -0
  195. package/src/timeouts.ts +122 -0
  196. package/src/tools.ts +320 -0
  197. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
  198. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
  199. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
  200. package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
package/src/store.ts ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Durable store for dsh-remote-vs: hosts + workspace bindings in
3
+ * $DSH_HOME/dsh-remote.json (0600, atomic tmp+rename writes).
4
+ */
5
+ import { existsSync, statSync, openSync, readFileSync, renameSync, writeSync, closeSync } from 'node:fs'
6
+ import { randomUUID } from 'node:crypto'
7
+ import { join } from 'node:path'
8
+ import { aliasRoot, keysDir, storePath } from './paths.ts'
9
+ import type {
10
+ RemoteDeployState,
11
+ RemoteHostEntry,
12
+ RemoteHostSummary,
13
+ RemoteWorkspaceBinding,
14
+ } from './protocol.ts'
15
+
16
+ /** On-disk document shape. */
17
+ interface StoreDoc {
18
+ version: 1
19
+ hosts: RemoteHostEntry[]
20
+ workspaces: RemoteWorkspaceBinding[]
21
+ /** Removed alias dirs (fail-closed tombstones; never auto-deleted). */
22
+ tombstones: string[]
23
+ }
24
+
25
+ /** Empty document. */
26
+ function emptyDoc(): StoreDoc {
27
+ return { version: 1, hosts: [], workspaces: [], tombstones: [] }
28
+ }
29
+
30
+ /** Identity of the store file at one point in time; `null` = the file is absent. */
31
+ type FileStamp = { mtimeMs: number; size: number } | null
32
+
33
+ /** Same file identity (both absent counts as same). */
34
+ function sameStamp(left: FileStamp, right: FileStamp): boolean {
35
+ if (left === null || right === null) return left === right
36
+ return left.mtimeMs === right.mtimeMs && left.size === right.size
37
+ }
38
+
39
+ /** Alias grammar: 1-64 chars of letters/digits/._- */
40
+ const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
41
+
42
+ function assertAlias(alias: unknown): asserts alias is string {
43
+ if (typeof alias !== 'string' || !ALIAS_RE.test(alias)) {
44
+ throw new Error('alias must match [A-Za-z0-9._-]{1,64} and start with an alphanumeric')
45
+ }
46
+ }
47
+
48
+ /** Payload accepted by create/update (secrets included, host-side only). */
49
+ export interface HostPayload {
50
+ alias: string
51
+ host: string
52
+ port?: number
53
+ user: string
54
+ auth: { kind: 'key' | 'password'; keyPath?: string; passphrase?: string; password?: string }
55
+ description?: string
56
+ tags?: string[]
57
+ }
58
+
59
+ export interface WorkspacePayload {
60
+ name: string
61
+ alias: string
62
+ remotePath: string
63
+ }
64
+
65
+ export class RemoteStore {
66
+ private readonly file: string
67
+ private doc: StoreDoc
68
+ /** File identity when `doc` was read from disk (undefined = `doc` is not known fresh). */
69
+ private stamp: FileStamp | undefined
70
+
71
+ constructor(file?: string) {
72
+ this.file = file ?? storePath()
73
+ // `doc` starts empty and `reload()` fills it: `stamp` is undefined, so the
74
+ // first read always takes the real path (missing file -> empty doc).
75
+ this.doc = emptyDoc()
76
+ this.reload()
77
+ }
78
+
79
+ /**
80
+ * Reload from disk (picks up external edits).
81
+ *
82
+ * Re-reading on every access is deliberate: the root fs router asks this store
83
+ * 2-4 times per routed file operation, and an edit made by another process
84
+ * must be visible without a restart. A stat is orders of magnitude cheaper
85
+ * than read + parse, so the file's identity (mtime + size) decides instead —
86
+ * unchanged means the cached doc is already the file's content. The stamp is
87
+ * taken BEFORE the read: a write landing in between leaves the older stamp
88
+ * behind, so the next access re-reads rather than trusting a stale doc. The
89
+ * one edit this cannot see is a same-length rewrite inside the filesystem's
90
+ * mtime granularity (sub-ms on NTFS/ext4).
91
+ */
92
+ reload(): void {
93
+ const stamp = this.stampOf()
94
+ if (stamp !== undefined && this.stamp !== undefined && sameStamp(stamp, this.stamp)) return
95
+ this.doc = this.loadDoc()
96
+ this.stamp = stamp
97
+ }
98
+
99
+ /**
100
+ * File identity right now: `null` when the file is absent, `undefined` when it
101
+ * cannot be statted at all. Both cases fall through to a full load, so the
102
+ * missing-file and unreadable-file paths behave exactly as they did before.
103
+ */
104
+ private stampOf(): FileStamp | undefined {
105
+ try {
106
+ const stat = statSync(this.file, { throwIfNoEntry: false })
107
+ return stat === undefined ? null : { mtimeMs: stat.mtimeMs, size: stat.size }
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }
112
+
113
+ private loadDoc(): StoreDoc {
114
+ if (!existsSync(this.file)) return emptyDoc()
115
+ let parsed: unknown
116
+ try {
117
+ parsed = JSON.parse(readFileSync(this.file, 'utf8'))
118
+ } catch (error) {
119
+ throw new Error(`cannot parse ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
120
+ }
121
+ if (typeof parsed !== 'object' || parsed === null) throw new Error(`${this.file}: root must be an object`)
122
+ const doc = parsed as Partial<StoreDoc>
123
+ if (doc.version !== 1) throw new Error(`${this.file}: unsupported version ${(doc as { version?: unknown }).version}`)
124
+ if (!Array.isArray(doc.hosts) || !Array.isArray(doc.workspaces)) {
125
+ throw new Error(`${this.file}: hosts/workspaces must be arrays`)
126
+ }
127
+ return {
128
+ version: 1,
129
+ hosts: doc.hosts,
130
+ workspaces: doc.workspaces,
131
+ tombstones: Array.isArray(doc.tombstones) ? doc.tombstones.filter(t => typeof t === 'string') : [],
132
+ }
133
+ }
134
+
135
+ private save(): void {
136
+ // The caller already mutated `doc`, so in-memory state is ahead of the file:
137
+ // invalidate the stamp. A failed write must not pin the dirty doc in place
138
+ // (the next read goes back to the file, as it always did), and a successful
139
+ // one is picked up by that same re-read, which re-arms the fast path.
140
+ this.stamp = undefined
141
+ const payload = JSON.stringify(this.doc, null, 2)
142
+ const tmp = `${this.file}.${process.pid}.tmp`
143
+ // 0600 from creation (Windows: best effort, mode is advisory there).
144
+ const fd = openSync(tmp, 'w', 0o600)
145
+ try {
146
+ writeSync(fd, payload)
147
+ } finally {
148
+ closeSync(fd)
149
+ }
150
+ renameSync(tmp, this.file)
151
+ }
152
+
153
+ // ------------------------------------------------------------- hosts
154
+ // NOTE: reads revalidate the file first (one stat; the file is tiny and a
155
+ // read + parse only happens when its identity changed), so routers + routes +
156
+ // GUI still see fresh state without a watcher.
157
+
158
+ list(): RemoteHostEntry[] {
159
+ this.reload()
160
+ return [...this.doc.hosts]
161
+ }
162
+
163
+ get(alias: string): RemoteHostEntry | undefined {
164
+ this.reload()
165
+ return this.doc.hosts.find(h => h.alias === alias)
166
+ }
167
+
168
+ require(alias: string): RemoteHostEntry {
169
+ const entry = this.get(alias)
170
+ if (entry === undefined) throw new Error(`unknown host alias: ${alias}`)
171
+ return entry
172
+ }
173
+
174
+ create(payload: HostPayload): RemoteHostEntry {
175
+ assertAlias(payload.alias)
176
+ if (this.get(payload.alias) !== undefined) throw new Error(`alias already exists: ${payload.alias}`)
177
+ const entry = this.normalize(payload, undefined)
178
+ this.doc.hosts.push(entry)
179
+ this.save()
180
+ return entry
181
+ }
182
+
183
+ update(alias: string, patch: Partial<Omit<HostPayload, 'alias'>>): RemoteHostEntry {
184
+ const current = this.require(alias)
185
+ // Empty auth materials mean "keep the stored one" (the browser never
186
+ // echoes secrets back, so an untouched field arrives empty/omitted).
187
+ let auth = patch.auth ?? current.auth
188
+ if (patch.auth !== undefined) {
189
+ if (patch.auth.kind === 'key') {
190
+ const keyPath = (patch.auth.keyPath ?? '').trim()
191
+ auth = {
192
+ kind: 'key',
193
+ keyPath: keyPath !== ''
194
+ ? keyPath
195
+ : current.auth.kind === 'key'
196
+ ? (current.auth.keyPath as string)
197
+ : '',
198
+ ...(patch.auth.passphrase !== undefined
199
+ ? { passphrase: patch.auth.passphrase }
200
+ : current.auth.kind === 'key' && current.auth.passphrase !== undefined
201
+ ? { passphrase: current.auth.passphrase }
202
+ : {}),
203
+ }
204
+ } else {
205
+ const password = patch.auth.password ?? ''
206
+ auth = {
207
+ kind: 'password',
208
+ password: password !== ''
209
+ ? password
210
+ : current.auth.kind === 'password'
211
+ ? (current.auth.password as string)
212
+ : '',
213
+ }
214
+ }
215
+ }
216
+ const merged: HostPayload = {
217
+ alias,
218
+ host: patch.host ?? current.host,
219
+ port: patch.port ?? current.port,
220
+ user: patch.user ?? current.user,
221
+ auth,
222
+ description: patch.description ?? current.description,
223
+ tags: patch.tags ?? current.tags,
224
+ }
225
+ const next = this.normalize(merged, current)
226
+ const index = this.doc.hosts.findIndex(h => h.alias === alias)
227
+ this.doc.hosts[index] = next
228
+ this.save()
229
+ return next
230
+ }
231
+
232
+ remove(alias: string): boolean {
233
+ const before = this.doc.hosts.length
234
+ this.doc.hosts = this.doc.hosts.filter(h => h.alias !== alias)
235
+ this.doc.workspaces = this.doc.workspaces.filter(w => w.alias !== alias)
236
+ if (this.doc.hosts.length === before) return false
237
+ this.save()
238
+ return true
239
+ }
240
+
241
+ private normalize(payload: HostPayload, current: RemoteHostEntry | undefined): RemoteHostEntry {
242
+ if (typeof payload.host !== 'string' || payload.host.trim() === '') throw new Error('host must be a non-empty string')
243
+ const port = payload.port ?? 22
244
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('port must be an integer 1-65535')
245
+ if (typeof payload.user !== 'string' || payload.user.trim() === '') throw new Error('user must be a non-empty string')
246
+ const auth = payload.auth
247
+ if (auth === undefined || (auth.kind !== 'key' && auth.kind !== 'password')) {
248
+ throw new Error('auth.kind must be key or password')
249
+ }
250
+ if (auth.kind === 'key') {
251
+ if (typeof auth.keyPath !== 'string' || auth.keyPath.trim() === '') throw new Error('auth.keyPath is required for key auth')
252
+ if (auth.passphrase !== undefined && typeof auth.passphrase !== 'string') throw new Error('auth.passphrase must be a string when provided')
253
+ } else {
254
+ if (typeof auth.password !== 'string' || auth.password === '') throw new Error('auth.password must be a non-empty string for password auth')
255
+ }
256
+ if (payload.description !== undefined && typeof payload.description !== 'string') throw new Error('description must be a string when provided')
257
+ if (payload.tags !== undefined && (!Array.isArray(payload.tags) || payload.tags.some(t => typeof t !== 'string'))) {
258
+ throw new Error('tags must be string[] when provided')
259
+ }
260
+ const now = Date.now()
261
+ return {
262
+ alias: payload.alias,
263
+ host: payload.host.trim(),
264
+ port,
265
+ user: payload.user.trim(),
266
+ auth: {
267
+ kind: auth.kind,
268
+ ...(auth.keyPath !== undefined ? { keyPath: auth.keyPath } : {}),
269
+ ...(auth.passphrase !== undefined ? { passphrase: auth.passphrase } : {}),
270
+ ...(auth.password !== undefined ? { password: auth.password } : {}),
271
+ },
272
+ ...(payload.description !== undefined ? { description: payload.description } : {}),
273
+ tags: payload.tags ?? [],
274
+ ...(current?.deploy !== undefined ? { deploy: current.deploy } : {}),
275
+ createdAt: current?.createdAt ?? now,
276
+ updatedAt: now,
277
+ }
278
+ }
279
+
280
+ /** Secret-free projection for browser/agent surfaces. */
281
+ summarize(entry: RemoteHostEntry): RemoteHostSummary {
282
+ let keyReady = false
283
+ if (entry.auth.kind === 'key' && entry.auth.keyPath !== undefined) {
284
+ try {
285
+ keyReady = existsSync(entry.auth.keyPath)
286
+ } catch {
287
+ keyReady = false
288
+ }
289
+ }
290
+ return {
291
+ alias: entry.alias,
292
+ host: entry.host,
293
+ port: entry.port,
294
+ user: entry.user,
295
+ auth: entry.auth.kind,
296
+ keyReady,
297
+ managedKey: entry.auth.kind === 'key'
298
+ && entry.auth.keyPath !== undefined
299
+ && entry.auth.keyPath.startsWith(keysDir() + (process.platform === 'win32' ? '\\' : '/')),
300
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
301
+ tags: [...entry.tags],
302
+ ...(entry.deploy !== undefined ? { deploy: entry.deploy } : {}),
303
+ createdAt: entry.createdAt,
304
+ updatedAt: entry.updatedAt,
305
+ }
306
+ }
307
+
308
+ setDeploy(alias: string, state: RemoteDeployState): void {
309
+ const entry = this.require(alias)
310
+ entry.deploy = state
311
+ entry.updatedAt = Date.now()
312
+ this.save()
313
+ }
314
+
315
+ // -------------------------------------------------------- workspaces
316
+
317
+ listWorkspaces(): RemoteWorkspaceBinding[] {
318
+ this.reload()
319
+ return [...this.doc.workspaces]
320
+ }
321
+
322
+ createWorkspace(payload: WorkspacePayload): RemoteWorkspaceBinding {
323
+ if (typeof payload.name !== 'string' || payload.name.trim() === '') throw new Error('name must be a non-empty string')
324
+ this.require(payload.alias)
325
+ if (typeof payload.remotePath !== 'string' || !payload.remotePath.startsWith('/')) {
326
+ throw new Error('remotePath must be an absolute POSIX path')
327
+ }
328
+ const binding: RemoteWorkspaceBinding = {
329
+ id: randomUUID().slice(0, 8),
330
+ name: payload.name.trim(),
331
+ alias: payload.alias,
332
+ remotePath: payload.remotePath,
333
+ createdAt: Date.now(),
334
+ }
335
+ this.doc.workspaces.push(binding)
336
+ this.save()
337
+ return binding
338
+ }
339
+
340
+ removeWorkspace(id: string): boolean {
341
+ this.reload()
342
+ const victim = this.doc.workspaces.find(w => w.id === id)
343
+ if (victim === undefined) return false
344
+ // Tombstone the alias BEFORE dropping the binding (history stays readable,
345
+ // new tool calls fail closed instead of silently running locally).
346
+ this.addTombstone(join(aliasRoot(), id))
347
+ this.doc.workspaces = this.doc.workspaces.filter(w => w.id !== id)
348
+ this.save()
349
+ return true
350
+ }
351
+
352
+ /** Record a removed alias path (idempotent). */
353
+ addTombstone(aliasPath: string): void {
354
+ if (!this.doc.tombstones.includes(aliasPath)) {
355
+ this.doc.tombstones.push(aliasPath)
356
+ this.save()
357
+ }
358
+ }
359
+
360
+ listTombstones(): string[] {
361
+ this.reload()
362
+ return [...this.doc.tombstones]
363
+ }
364
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Remote PTY terminal sessions: one ssh2 shell per session, surviving socket
3
+ * detaches (reattach by id). Frames are JSON text (see protocol.ts).
4
+ */
5
+ import { randomUUID } from 'node:crypto'
6
+ import type { WebSocket } from 'ws'
7
+ import type { ClientChannel } from 'ssh2'
8
+ import type { RemotePool } from './pool.ts'
9
+ import type { TerminalClientFrame, TerminalServerFrame } from './protocol.ts'
10
+
11
+ interface Session {
12
+ id: string
13
+ alias: string
14
+ channel: ClientChannel
15
+ alive: boolean
16
+ exitCode: number | null
17
+ sockets: Set<WebSocket>
18
+ /** Bytes handed to sockets that have not flushed yet (see broadcast). */
19
+ pending: number
20
+ /** True while the channel is paused because a socket fell behind. */
21
+ paused: boolean
22
+ /** Fires once the last socket has been gone for EMPTY_SOCKET_TTL_MS. */
23
+ reap: ReturnType<typeof setTimeout> | undefined
24
+ }
25
+
26
+ /**
27
+ * A session with no attached socket is kept this long so that a page reload
28
+ * can reattach by id. Past it the session closes and the remote shell dies.
29
+ *
30
+ * Without this sweep nothing ever released the session: its `pool.hold` never
31
+ * returned to zero, so the connection could not idle out, and the remote sshd
32
+ * session slot stayed taken for the life of the host process. Ten minutes is
33
+ * far longer than a reload needs and far shorter than "until you restart DSH".
34
+ */
35
+ const EMPTY_SOCKET_TTL_MS = 10 * 60_000
36
+
37
+ /**
38
+ * Terminal output is a live stream with no natural end, so it needs a bound.
39
+ * When a socket falls this far behind, pause the channel — that pushes the
40
+ * brake all the way back to the remote command, instead of growing the host
41
+ * process. Resume once the socket has drained.
42
+ *
43
+ * The pause is real backpressure, not a local buffer: ssh2 only sends a window
44
+ * adjustment while the stream is flowing, so a paused channel lets the peer's
45
+ * send window drain to zero and the remote command blocks on its own write.
46
+ * The retained ceiling is therefore this mark plus ssh2's window (2 MiB) plus
47
+ * whatever is in flight — bounded, but not exactly 4 MiB.
48
+ *
49
+ * The counter is per session, not per socket: one lagging socket pauses the
50
+ * channel for every socket attached to that shell. That is deliberate — the
51
+ * alternative is letting the slowest consumer grow the host process.
52
+ */
53
+ const OUT_HIGH_WATER = 4 * 1024 * 1024
54
+ const OUT_LOW_WATER = 1 * 1024 * 1024
55
+
56
+ /**
57
+ * The largest client frame accepted, in bytes.
58
+ *
59
+ * The only frame with a payload is `in` — keystrokes, and pastes, which the
60
+ * client chunks to stay under this. The upgrade server (`routes.ts`) passes
61
+ * this value as ws's `maxPayload`, so an oversized frame is refused before it
62
+ * is buffered at all; the check in `hookSocket` is the second layer, and it is
63
+ * what keeps such a frame from becoming a string plus an object graph in the
64
+ * process that also runs the agent.
65
+ */
66
+ export const TERMINAL_MAX_FRAME_BYTES = 1024 * 1024
67
+
68
+ export class TerminalSessionRegistry {
69
+ private readonly sessions = new Map<string, Session>()
70
+
71
+ constructor(private readonly pool: RemotePool) {}
72
+
73
+ /** Open a shell and attach the first socket. */
74
+ async open(ws: WebSocket, alias: string, cols: number, rows: number): Promise<string> {
75
+ const id = randomUUID().slice(0, 8)
76
+ let channel: ClientChannel
77
+ try {
78
+ channel = await this.pool.shell(alias, { cols, rows })
79
+ } catch (error) {
80
+ this.send(ws, { t: 'error', message: error instanceof Error ? error.message : String(error) })
81
+ try {
82
+ ws.close(1011)
83
+ } catch {
84
+ // Ignore.
85
+ }
86
+ throw error
87
+ }
88
+ const session: Session = {
89
+ id,
90
+ alias,
91
+ channel,
92
+ alive: true,
93
+ exitCode: null,
94
+ sockets: new Set(),
95
+ pending: 0,
96
+ paused: false,
97
+ reap: undefined,
98
+ }
99
+ this.sessions.set(id, session)
100
+ channel.on('data', (chunk: Buffer) => {
101
+ this.broadcast(session, { t: 'out', d: chunk.toString('utf8') })
102
+ })
103
+ channel.stderr.on('data', (chunk: Buffer) => {
104
+ this.broadcast(session, { t: 'out', d: chunk.toString('utf8') })
105
+ })
106
+ channel.on('close', (code: number | null) => {
107
+ session.alive = false
108
+ session.exitCode = code
109
+ this.broadcast(session, { t: 'exit', code })
110
+ for (const socket of session.sockets) {
111
+ try {
112
+ socket.close(1000)
113
+ } catch {
114
+ // Ignore.
115
+ }
116
+ }
117
+ })
118
+ this.hookSocket(session, ws)
119
+ this.send(ws, { t: 'ready', id })
120
+ return id
121
+ }
122
+
123
+ /**
124
+ * Attach another socket to a live session; false when unknown/dead.
125
+ *
126
+ * The size in the URL is deliberately NOT applied here. It is the same
127
+ * fixed 80x24 the view uses to open a shell, and re-applying it on every
128
+ * reattach — which is what a page reload does — SHRANK a running shell:
129
+ * SIGWINCH, and a full-screen program re-laying itself out at 80 columns,
130
+ * until that tab became visible and refit. The session already has a size.
131
+ * The socket sends a real `resize` frame as soon as it can measure itself
132
+ * (`XTerm.pushSize`), which is the only place that knows how wide it is.
133
+ *
134
+ * The parameters stay because the upgrade handler owns the URL and passes
135
+ * them on both paths; they matter to `open` (the PTY's initial size) only.
136
+ */
137
+ attach(ws: WebSocket, id: string, _cols: number, _rows: number): boolean {
138
+ const session = this.sessions.get(id)
139
+ if (session === undefined || !session.alive) return false
140
+ this.hookSocket(session, ws)
141
+ this.send(ws, { t: 'ready', id })
142
+ return true
143
+ }
144
+
145
+ /** Close one session (kills the remote shell). */
146
+ close(id: string): boolean {
147
+ const session = this.sessions.get(id)
148
+ if (session === undefined) return false
149
+ this.sessions.delete(id)
150
+ this.cancelReap(session)
151
+ try {
152
+ session.channel.close()
153
+ } catch {
154
+ // Ignore.
155
+ }
156
+ for (const socket of session.sockets) {
157
+ try {
158
+ socket.close(1000)
159
+ } catch {
160
+ // Ignore.
161
+ }
162
+ }
163
+ session.sockets.clear()
164
+ return true
165
+ }
166
+
167
+ /** Close every session (plugin unload). */
168
+ dispose(): void {
169
+ for (const id of [...this.sessions.keys()]) this.close(id)
170
+ }
171
+
172
+ list(): { id: string; alias: string; alive: boolean }[] {
173
+ return [...this.sessions.values()].map(s => ({ id: s.id, alias: s.alias, alive: s.alive }))
174
+ }
175
+
176
+ private hookSocket(session: Session, ws: WebSocket): void {
177
+ // A socket is back: the session is in use, so cancel any pending reap.
178
+ this.cancelReap(session)
179
+ session.sockets.add(ws)
180
+ ws.on('message', (raw) => {
181
+ // Refuse an oversized frame before parsing it — see
182
+ // TERMINAL_MAX_FRAME_BYTES. 1009 is the protocol's "message too big".
183
+ const bytes = Array.isArray(raw) ? raw.reduce((total, part) => total + part.byteLength, 0) : raw.byteLength
184
+ if (bytes > TERMINAL_MAX_FRAME_BYTES) {
185
+ try {
186
+ ws.close(1009)
187
+ } catch {
188
+ // Already gone.
189
+ }
190
+ return
191
+ }
192
+ let frame: TerminalClientFrame
193
+ try {
194
+ frame = JSON.parse(String(raw)) as TerminalClientFrame
195
+ } catch {
196
+ return
197
+ }
198
+ if (!session.alive) return
199
+ if (frame.t === 'in' && typeof frame.d === 'string') {
200
+ try {
201
+ session.channel.write(frame.d)
202
+ } catch {
203
+ // Ignore.
204
+ }
205
+ } else if (frame.t === 'resize' && Number.isFinite(frame.cols) && Number.isFinite(frame.rows)) {
206
+ try {
207
+ session.channel.setWindow(frame.rows, frame.cols, 0, 0)
208
+ } catch {
209
+ // Ignore.
210
+ }
211
+ }
212
+ })
213
+ const detach = (): void => {
214
+ session.sockets.delete(ws)
215
+ if (session.sockets.size > 0) return
216
+ // Nobody is watching any more. Release the brake first — a paused channel
217
+ // would otherwise stay paused with no socket left to resume it — then
218
+ // start the clock that ends the session (and with it the remote shell).
219
+ session.pending = 0
220
+ this.setPaused(session, false)
221
+ this.armReap(session)
222
+ }
223
+ ws.on('close', detach)
224
+ ws.on('error', detach)
225
+ }
226
+
227
+ /** Keep the shell for a while (reload can reattach), then close it for good. */
228
+ private armReap(session: Session): void {
229
+ this.cancelReap(session)
230
+ const timer = setTimeout(() => {
231
+ session.reap = undefined
232
+ // A late detach can arm a timer for a session that close() already
233
+ // removed; never act on an entry that is no longer the registered one.
234
+ if (this.sessions.get(session.id) !== session) return
235
+ if (session.sockets.size === 0) this.close(session.id)
236
+ }, EMPTY_SOCKET_TTL_MS)
237
+ timer.unref?.()
238
+ session.reap = timer
239
+ }
240
+
241
+ private cancelReap(session: Session): void {
242
+ if (session.reap === undefined) return
243
+ clearTimeout(session.reap)
244
+ session.reap = undefined
245
+ }
246
+
247
+ private setPaused(session: Session, paused: boolean): void {
248
+ if (session.paused === paused) return
249
+ session.paused = paused
250
+ try {
251
+ if (paused) session.channel.pause()
252
+ else session.channel.resume()
253
+ } catch {
254
+ // The channel may already be gone; the flag still records the intent.
255
+ }
256
+ }
257
+
258
+ private send(ws: WebSocket, frame: TerminalServerFrame): void {
259
+ try {
260
+ ws.send(JSON.stringify(frame))
261
+ } catch {
262
+ // Socket raced away.
263
+ }
264
+ }
265
+
266
+ private broadcast(session: Session, frame: TerminalServerFrame): void {
267
+ const text = JSON.stringify(frame)
268
+ // Bytes, not UTF-16 code units: CJK and emoji output would otherwise be
269
+ // undercounted by up to 3x and the brake would come on too late.
270
+ const size = Buffer.byteLength(text, 'utf8')
271
+ for (const socket of session.sockets) {
272
+ let settled = false
273
+ // The send callback fires once the frame is on the wire. It is the only
274
+ // public signal for "this socket has caught up", and it avoids relying on
275
+ // ws internals (bufferedAmount does not report the socket write queue).
276
+ const done = (): void => {
277
+ if (settled) return
278
+ settled = true
279
+ session.pending = Math.max(0, session.pending - size)
280
+ if (session.pending <= OUT_LOW_WATER) this.setPaused(session, false)
281
+ }
282
+ session.pending += size
283
+ try {
284
+ socket.send(text, done)
285
+ } catch {
286
+ done()
287
+ }
288
+ }
289
+ if (session.pending > OUT_HIGH_WATER) this.setPaused(session, true)
290
+ }
291
+ }