dsh-remote-workspaces 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -6,6 +6,8 @@ import { RoutingFileSystem } from './routing-fs.js'
6
6
  import { SshShellExecutor } from './shell-exec.js'
7
7
  import { registerAnchor, unregisterAnchor, findByCwd, updateAnchorOs } from './registry.js'
8
8
  import { applySearchTools } from './search.js'
9
+ import { createShellSessions } from './shell-sessions.js'
10
+ import { expandLocalTarget, localHome, localDriveRoots, localLevelListing, localListError, LOCAL_DRIVES } from './local-browse.js'
9
11
 
10
12
  export { parseSshConfig, expandTilde } from './ssh-config.js'
11
13
  export { SshClient, hostsFromConfig, shellQuote, defaultSshConfigPath, clientForHost } from './transport.js'
@@ -68,7 +70,16 @@ const INVOCATIONS = [
68
70
  invocation('sshAliasDetail', [jsonParameter('alias')]),
69
71
  invocation('testConnection', [jsonParameter('machine')]),
70
72
  invocation('listRemoteDir', [jsonParameter('machine'), jsonParameter('path')]),
73
+ invocation('listLocalDir', [jsonParameter('path')]),
71
74
  invocation('openRemoteWorkspace', [jsonParameter('machine'), jsonParameter('path')]),
75
+ invocation('openShellLocal', [jsonParameter('opts')]),
76
+ invocation('openShellRemote', [jsonParameter('machine'), jsonParameter('opts')]),
77
+ invocation('openShellAt', [jsonParameter('cwd'), jsonParameter('opts')]),
78
+ invocation('shellWrite', [jsonParameter('id'), jsonParameter('data')]),
79
+ invocation('shellRead', [jsonParameter('id')]),
80
+ invocation('shellResize', [jsonParameter('id'), jsonParameter('rows'), jsonParameter('cols')]),
81
+ invocation('shellClose', [jsonParameter('id')]),
82
+ invocation('shellList'),
72
83
  ]
73
84
 
74
85
  /** Build an `SshClient` from a machine record (alias/host/port/user/identityFile). */
@@ -183,7 +194,11 @@ async function resolveRemotePath(client, raw, sharedSftp) {
183
194
  * Host owner of the `remoteWorkspaces` Remote namespace. Every method returns
184
195
  * only lossless-JSON data and never echoes stored secrets back to the browser.
185
196
  */
186
- function remoteWorkspacesService() {
197
+ function remoteWorkspacesService(remote) {
198
+ const shells = createShellSessions({
199
+ getSubprocess: () => remote.getSubprocess(),
200
+ openRemote: (machine, opts) => sshClientFor(machine).openShell(opts),
201
+ })
187
202
  return {
188
203
  listMachines() {
189
204
  return { ok: true, machines: loadMachines().map(sanitizeMachine) }
@@ -261,6 +276,28 @@ function remoteWorkspacesService() {
261
276
  }
262
277
  },
263
278
 
279
+ async listLocalDir(path) {
280
+ // Virtual Windows drive-selection level: '上一级' at a drive root.
281
+ if (typeof path === 'string' && path === LOCAL_DRIVES) {
282
+ try {
283
+ const roots = await localDriveRoots()
284
+ const entries = roots.map((r) => ({ name: r.slice(0, 2), dir: true }))
285
+ return { ok: true, path: '', entries, truncated: false }
286
+ } catch (error) {
287
+ return { ok: false, error: '无法读取盘符:' + messageOf(error) }
288
+ }
289
+ }
290
+ let target = ''
291
+ try {
292
+ const home = localHome()
293
+ target = expandLocalTarget(path, home)
294
+ const level = await localLevelListing(target, {})
295
+ return { ok: true, path: level.path, entries: level.entries, truncated: level.truncated }
296
+ } catch (error) {
297
+ return { ok: false, error: localListError(error, target || (typeof path === 'string' ? path : '')) }
298
+ }
299
+ },
300
+
264
301
  /**
265
302
  * Open a remote directory as a workspace: create an EMPTY local anchor
266
303
  * directory (the harness's workspace identity — `fs.realpath` must resolve
@@ -306,6 +343,93 @@ function remoteWorkspacesService() {
306
343
  sftp.end()
307
344
  }
308
345
  },
346
+
347
+ // Shell tool: interactive local shell (ctx.subprocess.spawnTerminal) and
348
+ // remote shell (ssh2 conn.shell + pty). No approval (D6/D7): opening a
349
+ // shell is a user GUI action, equivalent to xshell — a separate line from
350
+ // the agent-side sandboxPolicy/approval gate. Credentials never ride the
351
+ // wire: the client sends only the sanitized machine (no secrets) and the
352
+ // host recovers password/passphrase from the store by id.
353
+ async openShellLocal(opts) {
354
+ try {
355
+ const session = await shells.openLocal(opts ?? {})
356
+ return { ok: true, ...session }
357
+ } catch (error) {
358
+ return { ok: false, error: messageOf(error) }
359
+ }
360
+ },
361
+
362
+ async openShellRemote(machine, opts) {
363
+ try {
364
+ const session = await shells.openRemote(machine ?? {}, opts ?? {})
365
+ return { ok: true, ...session }
366
+ } catch (error) {
367
+ return { ok: false, error: messageOf(error) }
368
+ }
369
+ },
370
+
371
+ /**
372
+ * Open a shell AT the current workspace cwd, resolving the target without
373
+ * the user choosing: a cwd under a registered remote anchor opens a remote
374
+ * shell on that machine, chdir'd to the anchor's remote path (+ subpath);
375
+ * anything else opens a local shell at that cwd. This is the default
376
+ * "click Shell" entry — no local/remote chooser.
377
+ */
378
+ async openShellAt(cwd, opts) {
379
+ try {
380
+ const anchor = typeof cwd === 'string' && cwd !== '' ? findByCwd(cwd) : undefined
381
+ if (anchor !== undefined) {
382
+ const machine = (anchor.machineId !== undefined && anchor.machineId !== null ? machineById(anchor.machineId) : undefined)
383
+ ?? { host: anchor.host, port: anchor.port, user: anchor.user }
384
+ const remoteCwd = anchor.remoteSubpath === ''
385
+ ? anchor.remotePath
386
+ : `${anchor.remotePath.replace(/\/+$/, '')}/${anchor.remoteSubpath}`
387
+ const session = await shells.openRemote(machine, { ...(opts ?? {}), cwd: remoteCwd })
388
+ return { ok: true, ...session, cwd: remoteCwd, label: machine.alias || machine.host || '远程' }
389
+ }
390
+ const session = await shells.openLocal({ ...(opts ?? {}), cwd })
391
+ return { ok: true, ...session, cwd: typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd(), label: '本机' }
392
+ } catch (error) {
393
+ return { ok: false, error: messageOf(error) }
394
+ }
395
+ },
396
+
397
+ async shellWrite(id, data) {
398
+ try {
399
+ await shells.write(id, data)
400
+ return { ok: true }
401
+ } catch (error) {
402
+ return { ok: false, error: messageOf(error) }
403
+ }
404
+ },
405
+
406
+ shellRead(id) {
407
+ try {
408
+ return { ok: true, ...shells.read(id) }
409
+ } catch (error) {
410
+ return { ok: false, error: messageOf(error) }
411
+ }
412
+ },
413
+
414
+ async shellResize(id, rows, cols) {
415
+ try {
416
+ return { ok: true, ...(await shells.resize(id, rows, cols)) }
417
+ } catch (error) {
418
+ return { ok: false, error: messageOf(error) }
419
+ }
420
+ },
421
+
422
+ async shellClose(id) {
423
+ try {
424
+ return { ok: true, ...(await shells.close(id)) }
425
+ } catch (error) {
426
+ return { ok: false, error: messageOf(error) }
427
+ }
428
+ },
429
+
430
+ shellList() {
431
+ return { ok: true, sessions: shells.list() }
432
+ },
309
433
  }
310
434
  }
311
435
 
@@ -334,7 +458,7 @@ export function apply(ctx) {
334
458
  ctx.provide('fs', new RoutingFileSystem(remote))
335
459
  ctx.provide('shell', new SshShellExecutor(remote))
336
460
 
337
- const service = remoteWorkspacesService()
461
+ const service = remoteWorkspacesService(remote)
338
462
  service.typertRemote = Object.freeze({ service, serviceKey: NAMESPACE, namespace: NAMESPACE })
339
463
  ctx.provide(NAMESPACE, service)
340
464
 
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Local-directory listing for the workspace-add flow's "本地文件夹" tab when
3
+ * the page is NOT served from the host's own loopback (remote access): the OS
4
+ * chooser would open on an unattended desktop, so the plugin browses the host
5
+ * filesystem in-app instead. Pure Node stdlib (the plugin cannot import
6
+ * harness packages); mirrors the `listRemoteDir` contract so the client reuses
7
+ * the same entry rendering.
8
+ *
9
+ * Security posture: read-only one-level directory listing, driven by the user's
10
+ * own GUI action. Never writes, never executes, and never enters the agent
11
+ * toolchain's sandboxPolicy/approval line (same trust surface as the harness
12
+ * `directory-picker-browse` backend).
13
+ */
14
+
15
+ import { access, readdir, stat } from 'node:fs/promises'
16
+ import { homedir } from 'node:os'
17
+ import { join, posix, win32 } from 'node:path'
18
+
19
+ /**
20
+ * Wire value for the virtual Windows drive-selection level: listing it returns
21
+ * the available drive roots. It is not a filesystem path, so it is fenced out
22
+ * of every real-path branch (never fully qualified). The client sends it only
23
+ * when a drive root's "上一级" is pressed on a win32 host (the client keeps an
24
+ * identical copy of this constant).
25
+ */
26
+ export const LOCAL_DRIVES = '::drives::'
27
+
28
+ /** The host account's home directory (listing default + `~` expansion). */
29
+ export function localHome() {
30
+ return homedir()
31
+ }
32
+
33
+ /**
34
+ * Available Windows drive roots (`C:\`, `D:\`, …) for the virtual drive level.
35
+ * Empty on non-win32 platforms. Probes `A:`–`Z:` with fs access.
36
+ * @param platform - replaces `process.platform` for deterministic tests.
37
+ */
38
+ export async function localDriveRoots(platform = process.platform) {
39
+ if (platform !== 'win32') return []
40
+ const found = []
41
+ for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
42
+ const root = `${letter}:\\`
43
+ try {
44
+ await access(root)
45
+ found.push(root)
46
+ } catch { /* absent drive */ }
47
+ }
48
+ return found
49
+ }
50
+
51
+ /**
52
+ * True when the path names one fixed filesystem location regardless of process
53
+ * state: POSIX-absolute on POSIX; on Windows only drive-qualified (`C:\…`) or
54
+ * complete UNC (`\\server\share…`) forms. Rooted drive-less forms and
55
+ * incomplete UNC prefixes pass `isAbsolute` yet still resolve against the
56
+ * process's current drive — mirroring the harness browse backend's fence so a
57
+ * wire value never rebases under the host cwd.
58
+ * @param path - candidate path.
59
+ * @param platform - replaces `process.platform` for deterministic tests.
60
+ */
61
+ export function localFullyQualified(path, platform = process.platform) {
62
+ if (typeof path !== 'string') return false
63
+ if (platform === 'win32') {
64
+ return win32.isAbsolute(path)
65
+ && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
66
+ }
67
+ return posix.isAbsolute(path)
68
+ }
69
+
70
+ /** Not-absolute / non-`~` input: never rebase under the host cwd. */
71
+ function notAbsolute(raw) {
72
+ const error = new Error(`不是完整路径(需要绝对路径或以 ~ 开头):${String(raw)}`)
73
+ error.code = 'DSH_NOT_ABSOLUTE'
74
+ return error
75
+ }
76
+
77
+ /** Resolve + normalize one fully qualified target on the platform. */
78
+ function normalizeTarget(input, platform) {
79
+ return platform === 'win32' ? win32.resolve(input) : posix.resolve(input)
80
+ }
81
+
82
+ /** Platform-consistent segment join (node's default join is host-flavored). */
83
+ function platformJoin(a, b, platform) {
84
+ return platform === 'win32' ? win32.join(a, b) : posix.join(a, b)
85
+ }
86
+
87
+ /**
88
+ * Turn the RPC's raw `path` argument into a concrete absolute listing target.
89
+ * `''`/undefined/`~`/`~/…` expand to the home directory; anything else must be
90
+ * fully qualified (client-browsed paths are, and `..` segments are resolved by
91
+ * the platform resolver, so the client's "上一级" can send `<path>/..`).
92
+ * @param raw - the wire value (may be undefined).
93
+ * @param home - home directory to expand against.
94
+ * @param platform - replaces `process.platform` for deterministic tests.
95
+ * @returns the absolute listing target.
96
+ * @throws {Error} with `code === 'DSH_NOT_ABSOLUTE'` for non-`~` relative input.
97
+ */
98
+ export function expandLocalTarget(raw, home, platform = process.platform) {
99
+ const input = typeof raw === 'string' ? raw.trim() : ''
100
+ if (input === '' || input === '~') return normalizeTarget(home, platform)
101
+ if (input.startsWith('~/') || input.startsWith('~\\')) {
102
+ return normalizeTarget(platformJoin(home, input.slice(2), platform), platform)
103
+ }
104
+ if (!localFullyQualified(input, platform)) throw notAbsolute(raw)
105
+ return normalizeTarget(input, platform)
106
+ }
107
+
108
+ /** Classify one dirent: directories (symlinked dirs probed) are enterable rows. */
109
+ async function entryRow(target, dirent) {
110
+ const isDirectory = dirent.isDirectory()
111
+ if (!isDirectory && dirent.isSymbolicLink()) {
112
+ try {
113
+ return { name: dirent.name, dir: (await stat(join(target, dirent.name))).isDirectory() }
114
+ } catch {
115
+ // Broken/cyclic link: keep a plain (non-enterable) row.
116
+ return { name: dirent.name, dir: false }
117
+ }
118
+ }
119
+ return { name: dirent.name, dir: isDirectory }
120
+ }
121
+
122
+ /**
123
+ * List one directory level. Directories and files both return (files render
124
+ * muted and non-clickable, mirroring the remote tab); `.`/`..` are dropped;
125
+ * rows are name-sorted. The complete level is bounded at `maxEntries` rows with
126
+ * `truncated` flagging a cut tail.
127
+ * @param target - absolute listing target (already expanded).
128
+ * @param opts - `maxEntries` bound (default 1000, like the harness browse
129
+ * backend); `platform` for tests.
130
+ * @returns `{ path, entries: [{name, dir}], truncated }`.
131
+ * @throws filesystem errors (`ENOENT`/`ENOTDIR`/`EACCES`…) unchanged.
132
+ */
133
+ export async function localLevelListing(target, opts = {}) {
134
+ const maxEntries = opts.maxEntries === undefined ? 1000 : opts.maxEntries
135
+ const entries = (await readdir(target, { withFileTypes: true }))
136
+ .filter((d) => d.name !== '.' && d.name !== '..')
137
+ const rows = []
138
+ for (const dirent of entries) {
139
+ if (rows.length === maxEntries) break
140
+ // eslint-disable-next-line no-await-in-loop -- per-entry symlink probes are sequential like the remote backend.
141
+ rows.push(await entryRow(target, dirent))
142
+ }
143
+ rows.sort((a, b) => a.name.localeCompare(b.name))
144
+ return {
145
+ path: target,
146
+ entries: rows,
147
+ truncated: entries.length > maxEntries,
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Operator-facing text for a failed local listing.
153
+ * @param error - thrown value (filesystem error or DSH_NOT_ABSOLUTE).
154
+ * @param target - resolved target (for messages naming the directory).
155
+ */
156
+ export function localListError(error, target) {
157
+ const code = error && error.code
158
+ if (code === 'DSH_NOT_ABSOLUTE') {
159
+ return error.message || '路径无效'
160
+ }
161
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
162
+ return `目录不存在:${target}`
163
+ }
164
+ if (code === 'EACCES' || code === 'EPERM') {
165
+ return `无权限读取:${target}`
166
+ }
167
+ return `无法读取目录:${target}(${error instanceof Error ? error.message : String(error)})`
168
+ }
package/src/registry.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
- import { join, sep } from 'node:path'
12
+ import { join } from 'node:path'
13
13
  import { remoteWorkspacesRoot } from './anchor.js'
14
14
 
15
15
  function anchorsPath() {
@@ -64,27 +64,36 @@ export function unregisterAnchor(anchorPath) {
64
64
  }
65
65
 
66
66
  /**
67
- * Resolve a session cwd (the anchor path, or any descendant) to its remote
68
- * origin. Returns `{ anchorPath, remotePath, remoteSubpath, host, port, user, machineId }`
69
- * or `undefined` when the cwd is not under any registered anchor.
67
+ * Resolve a session cwd the anchor path, any descendant, or the Files tree's
68
+ * `/`-joined child spelling of either to its remote origin. Returns
69
+ * `{ anchorPath, remotePath, remoteSubpath, host, port, user, machineId }` or
70
+ * `undefined` when the cwd is not under any registered anchor.
71
+ *
72
+ * Matching is done on separator-normalized forms: anchor keys are stored with
73
+ * the native `sep` (`\` on Windows), while harness session cwds and the right
74
+ * Sidebar Files tree spell paths with `/` (and children are `root + '/' + name`
75
+ * even when the root is `\`-spelled). `remoteSubpath` is always `/`-joined.
70
76
  */
71
77
  export function findByCwd(cwd) {
72
78
  if (typeof cwd !== 'string' || cwd === '') return undefined
73
79
  const anchors = loadAnchors()
80
+ const flat = (p) => p.replace(/\\/g, '/')
81
+ const query = flat(cwd)
74
82
  let best
75
83
  let bestLen = -1
76
84
  for (const [anchorPath, rec] of Object.entries(anchors)) {
77
- const base = anchorPath.endsWith(sep) ? anchorPath : anchorPath + sep
78
- if (cwd === anchorPath || cwd.startsWith(base)) {
79
- if (anchorPath.length > bestLen) {
80
- bestLen = anchorPath.length
81
- best = { anchorPath, ...rec }
85
+ const base = flat(anchorPath)
86
+ const prefix = base.endsWith('/') ? base : `${base}/`
87
+ if (query === base || query.startsWith(prefix)) {
88
+ if (base.length > bestLen) {
89
+ bestLen = base.length
90
+ best = { anchorPath, base, rec }
82
91
  }
83
92
  }
84
93
  }
85
94
  if (best === undefined) return undefined
86
- const rel = cwd === best.anchorPath ? '' : cwd.slice(best.anchorPath.length + sep.length)
87
- return { ...best, remoteSubpath: rel === '' ? '' : rel.split(sep).join('/') }
95
+ const rel = query === best.base ? '' : query.slice(best.base.length + 1)
96
+ return { ...best.rec, anchorPath: best.anchorPath, remoteSubpath: rel }
88
97
  }
89
98
 
90
99
  export default { loadAnchors, registerAnchor, unregisterAnchor, updateAnchorOs, findByCwd, remoteWorkspacesRoot }
package/src/routing-fs.js CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Routing filesystem: the plugin's `ctx.fs` provider.
3
3
  *
4
- * Routes by the session cwd. Two remote triggers:
5
- * - an `ssh://[user@]host[:port]/path` cwd (URI form), and
4
+ * Routes by the session cwd OR by the path itself. Remote triggers:
5
+ * - an `ssh://[user@]host[:port]/path` cwd (URI form),
6
6
  * - a LOCAL anchor directory registered in the remote-workspace registry
7
- * (`anchors.json`), whose real content lives on the remote host.
7
+ * (`anchors.json`), whose real content lives on the remote host, and
8
+ * - any absolute path that IS a registered anchor or sits under one (the
9
+ * anchor path is the remote world's local alias — the Files tree lists,
10
+ * expands and opens entirely in this spelling, so routing must answer it
11
+ * with or without an anchor cwd).
8
12
  * Everything else goes to the local backend (with the workspace-write fence).
9
13
  *
10
14
  * The world identity is ENCODED into the target key (`ssh://host/path` for
@@ -75,6 +79,41 @@ export class RoutingFileSystem {
75
79
  return `ssh://${user ? `${user}@` : ''}${host}${port ? `:${port}` : ''}${subKey}`
76
80
  }
77
81
 
82
+ /**
83
+ * Anchor alias of an absolute LOCAL path: a registered anchor dir — or any
84
+ * path under one — is the remote world's local spelling, so the registry
85
+ * lookup that maps a session cwd maps the path itself. Returns the remote
86
+ * origin (`{ host, user, port, remotePath }`) when `path` is an anchor dir or
87
+ * a descendant of one, else null. This is what lets the right Sidebar Files
88
+ * tree (which roots at the anchor path and joins children with `/`) reach the
89
+ * remote: those strings ARE remote aliases.
90
+ */
91
+ aliasOfPath(path) {
92
+ if (typeof path !== 'string' || path === '') return null
93
+ const hit = findByCwd(path)
94
+ if (hit === undefined) return null
95
+ return {
96
+ host: hit.host,
97
+ user: hit.user,
98
+ port: hit.port,
99
+ remotePath: hit.remoteSubpath === '' ? hit.remotePath : posix.join(hit.remotePath, hit.remoteSubpath),
100
+ }
101
+ }
102
+
103
+ /** Remote routing params for a call, from its cwd first and its path second. */
104
+ routeRemote(path, cwd) {
105
+ const byCwd = this.remoteCwd(cwd)
106
+ if (byCwd !== null) return byCwd
107
+ const byPath = this.aliasOfPath(path)
108
+ return byPath === null ? null : { ...byPath, remoteCwd: byPath.remotePath }
109
+ }
110
+
111
+ /** The path argument translated to the remote world when it is itself an alias. */
112
+ remotePathArg(path) {
113
+ const alias = this.aliasOfPath(path)
114
+ return alias === null ? path : alias.remotePath
115
+ }
116
+
78
117
  /** Decode a target key into { backend, target } using the encoded world prefix. */
79
118
  splitTarget(target) {
80
119
  const key = String(target.targetKey)
@@ -92,10 +131,17 @@ export class RoutingFileSystem {
92
131
 
93
132
  async resolve(path, opts) {
94
133
  const cwd = opts && opts.cwd
95
- const remote = this.remoteCwd(cwd)
134
+ // Remote by cwd (session anchor), or by the path itself when no remote cwd
135
+ // is given — the Files endpoint resolves the workspace root WITHOUT a cwd,
136
+ // and the root is the anchor path, so the path must trigger routing alone.
137
+ const remote = this.routeRemote(path, cwd)
96
138
  if (remote !== null) {
97
139
  const backend = this.remoteBackend(remote.host, remote.user, remote.port)
98
- const sub = await backend.resolve(path, { cwd: remote.remoteCwd })
140
+ // An anchor-absolute path (the Files vocabulary) is translated to its
141
+ // remote spelling here; a relative path under an anchor cwd passes
142
+ // through unchanged so the backend's cwd resolution applies as before.
143
+ const arg = this.remotePathArg(path)
144
+ const sub = await backend.resolve(arg, { cwd: remote.remoteCwd })
99
145
  return {
100
146
  targetKey: this.encodeTarget(remote.host, remote.user, remote.port, sub.targetKey),
101
147
  displayPath: sub.displayPath,
@@ -135,10 +181,11 @@ export class RoutingFileSystem {
135
181
 
136
182
  lstat(path, opts, signal) {
137
183
  const cwd = opts && opts.cwd
138
- const remote = this.remoteCwd(cwd)
184
+ const remote = this.routeRemote(path, cwd)
139
185
  if (remote !== null) {
140
186
  const backend = this.remoteBackend(remote.host, remote.user, remote.port)
141
- return backend.lstat(path, { cwd: remote.remoteCwd })
187
+ const arg = this.remotePathArg(path)
188
+ return backend.lstat(arg, { cwd: remote.remoteCwd })
142
189
  }
143
190
  return this.local.lstat(path, opts)
144
191
  }
@@ -0,0 +1,180 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ /**
4
+ * In-process registry of interactive UI shell sessions (local + remote).
5
+ *
6
+ * Every `openLocal` allocates an INDEPENDENT PTY process through the harness's
7
+ * `ctx.subprocess.spawnTerminal` seam; every `openRemote` opens an INDEPENDENT
8
+ * ssh2 shell channel through `SshClient.openShell`. Sessions never share a
9
+ * process/connection, cwd, stdin/stdout, or buffer (the §3.1 isolation model).
10
+ * Lifecycle is process-local (matches ctx.terminals semantics): no persistence,
11
+ * no cross-process recovery.
12
+ *
13
+ * A session keeps TWO buffers over the same stream:
14
+ * - `unread` — drained by `read` (incremental polling);
15
+ * - `history` — the full (capped) output, returned on ATTACH so a re-mounted
16
+ * tab replays its scrollback instead of coming back blank.
17
+ *
18
+ * Attach: an `open*` carrying a stable `key` REUSES a still-live session under
19
+ * that key instead of spawning a new one — this is what keeps a Shell tab's
20
+ * terminal alive across the client unmounting it on a DSH-session switch.
21
+ */
22
+
23
+ const MAX_UNREAD = 256 * 1024
24
+ const MAX_HISTORY = 512 * 1024
25
+
26
+ export function createShellSessions({ getSubprocess, openRemote: openRemoteChannel, sweepMs = 10 * 60 * 1000 }) {
27
+ const sessions = new Map()
28
+
29
+ // Reap sessions no client has read from or written to within `sweepMs`: a
30
+ // shell whose tab was lost to a refresh (or an archived session) has nobody
31
+ // left to close it. An on-screen tab polls continuously, so only abandoned
32
+ // shells — or ones hidden longer than the threshold — are collected.
33
+ function sweep(now = Date.now()) {
34
+ for (const [id, session] of [...sessions]) {
35
+ if (!session.ended && now - session.lastActivityAt > sweepMs) {
36
+ sessions.delete(id)
37
+ void session.handle.terminate()
38
+ }
39
+ }
40
+ }
41
+ const sweepTimer = setInterval(sweep, 60 * 1000)
42
+ if (typeof sweepTimer.unref === 'function') sweepTimer.unref()
43
+
44
+ function liveSession(key) {
45
+ if (typeof key !== 'string' || key === '') return undefined
46
+ const existing = sessions.get(key)
47
+ return existing !== undefined && !existing.ended ? existing : undefined
48
+ }
49
+
50
+ function pushCapped(bag, cap, buf) {
51
+ bag.chunks.push(buf)
52
+ bag.bytes += buf.length
53
+ while (bag.bytes > cap && bag.chunks.length > 1) {
54
+ bag.bytes -= bag.chunks.shift().length
55
+ }
56
+ }
57
+
58
+ function register(handle, meta, key) {
59
+ const id = typeof key === 'string' && key !== '' ? key : randomUUID()
60
+ const session = {
61
+ id,
62
+ handle,
63
+ meta,
64
+ unread: { chunks: [], bytes: 0 },
65
+ history: { chunks: [], bytes: 0 },
66
+ ended: false,
67
+ lastActivityAt: Date.now(),
68
+ }
69
+ handle.output.on('data', (chunk) => {
70
+ const buf = Buffer.from(chunk)
71
+ pushCapped(session.unread, MAX_UNREAD, buf)
72
+ pushCapped(session.history, MAX_HISTORY, buf)
73
+ })
74
+ handle.output.on('end', () => { session.ended = true })
75
+ handle.output.on('close', () => { session.ended = true })
76
+ sessions.set(id, session)
77
+ return session
78
+ }
79
+
80
+ function historyText(session) {
81
+ return Buffer.concat(session.history.chunks).toString('utf8')
82
+ }
83
+
84
+ async function openLocal(opts = {}) {
85
+ const key = typeof opts.key === 'string' && opts.key !== '' ? opts.key : undefined
86
+ const existing = liveSession(key)
87
+ if (existing !== undefined) {
88
+ const history = historyText(existing)
89
+ // The attach hands the client the full scrollback, so the unread tail is
90
+ // now covered — start incremental reads fresh to avoid double replay.
91
+ existing.unread.chunks.length = 0
92
+ existing.unread.bytes = 0
93
+ return { id: existing.id, pid: existing.handle.pid, kind: 'local', attached: true, history }
94
+ }
95
+ const subprocess = getSubprocess()
96
+ if (subprocess === undefined || typeof subprocess.spawnTerminal !== 'function') {
97
+ throw new Error('subprocess service unavailable (no spawnTerminal)')
98
+ }
99
+ const win = process.platform === 'win32'
100
+ const argv = win ? ['powershell.exe', '-NoLogo'] : ['bash', '-i']
101
+ const cwd = typeof opts.cwd === 'string' && opts.cwd !== '' ? opts.cwd : process.cwd()
102
+ const rows = Number.isInteger(opts.rows) && opts.rows > 0 ? opts.rows : 24
103
+ const cols = Number.isInteger(opts.cols) && opts.cols > 0 ? opts.cols : 80
104
+ const handle = await subprocess.spawnTerminal({ argv, cwd, rows, cols, graceMs: 3000 })
105
+ const session = register(handle, { kind: 'local', label: win ? 'PowerShell' : 'bash' }, key)
106
+ return { id: session.id, pid: handle.pid, kind: 'local', attached: false }
107
+ }
108
+
109
+ async function openRemote(machine, opts = {}) {
110
+ const key = typeof opts.key === 'string' && opts.key !== '' ? opts.key : undefined
111
+ const existing = liveSession(key)
112
+ if (existing !== undefined) {
113
+ const history = historyText(existing)
114
+ existing.unread.chunks.length = 0
115
+ existing.unread.bytes = 0
116
+ return { id: existing.id, pid: null, kind: 'remote', attached: true, history }
117
+ }
118
+ if (typeof openRemoteChannel !== 'function') throw new Error('remote shell unavailable (no openShell)')
119
+ const rows = Number.isInteger(opts.rows) && opts.rows > 0 ? opts.rows : 24
120
+ const cols = Number.isInteger(opts.cols) && opts.cols > 0 ? opts.cols : 80
121
+ const cwd = typeof opts.cwd === 'string' && opts.cwd !== '' ? opts.cwd : undefined
122
+ const handle = await openRemoteChannel(machine ?? {}, { rows, cols, ...(cwd ? { cwd } : {}) })
123
+ const session = register(handle, {
124
+ kind: 'remote',
125
+ label: (machine && (machine.alias || machine.host)) || 'remote',
126
+ }, key)
127
+ return { id: session.id, pid: null, kind: 'remote', attached: false }
128
+ }
129
+
130
+ function requireSession(id) {
131
+ const session = sessions.get(id)
132
+ if (session === undefined) throw new Error(`shell session not found: ${id}`)
133
+ return session
134
+ }
135
+
136
+ async function write(id, data) {
137
+ const session = requireSession(id)
138
+ session.lastActivityAt = Date.now()
139
+ if (!session.ended && typeof data === 'string' && data !== '') await session.handle.write(data)
140
+ }
141
+
142
+ function read(id) {
143
+ const session = requireSession(id)
144
+ session.lastActivityAt = Date.now()
145
+ const text = Buffer.concat(session.unread.chunks).toString('utf8')
146
+ session.unread.chunks.length = 0
147
+ session.unread.bytes = 0
148
+ return { text, eof: session.ended }
149
+ }
150
+
151
+ async function resize(id, rows, cols) {
152
+ const session = requireSession(id)
153
+ if (session.ended) return { resized: false }
154
+ if (typeof session.handle.resize === 'function') {
155
+ session.handle.resize(rows, cols)
156
+ return { resized: true }
157
+ }
158
+ return { resized: false }
159
+ }
160
+
161
+ async function close(id) {
162
+ const session = sessions.get(id)
163
+ if (session === undefined) return { closed: false }
164
+ sessions.delete(id)
165
+ await session.handle.terminate()
166
+ return { closed: true }
167
+ }
168
+
169
+ function list() {
170
+ return [...sessions.values()].map((session) => ({
171
+ id: session.id,
172
+ pid: session.handle.pid ?? null,
173
+ kind: session.meta.kind,
174
+ label: session.meta.label,
175
+ ended: session.ended,
176
+ }))
177
+ }
178
+
179
+ return { openLocal, openRemote, write, read, resize, close, list, sweep }
180
+ }