dsh-remote-workspaces 0.2.0 → 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.
@@ -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
+ }
package/src/transport.js CHANGED
@@ -29,6 +29,22 @@ export function psQuote(value) {
29
29
  return `'${String(value).replace(/'/g, "''")}'`
30
30
  }
31
31
 
32
+ /**
33
+ * First-input `cd` command that lands an interactive shell channel in `cwd`,
34
+ * dialect-aware: POSIX single-quotes, PowerShell (cd = Set-Location) single-
35
+ * quotes a forward-slash win path, and cmd.exe — where single quotes are
36
+ * literal — uses double quotes + backslashes with `/d` to switch drive too.
37
+ */
38
+ export function shellCdCommand(profile, cwd) {
39
+ const p = profile ?? { family: 'unknown' }
40
+ if (p.family === 'windows') {
41
+ const winPath = toWinPath(cwd)
42
+ if (p.shell === 'cmd') return `cd /d "${winPath.replace(/\//g, '\\')}"\r`
43
+ return `cd ${psQuote(winPath)}\r`
44
+ }
45
+ return `cd ${shellQuote(cwd)}\r`
46
+ }
47
+
32
48
  /**
33
49
  * Strip PowerShell progress records from a stderr capture. When a nested
34
50
  * powershell is launched by a Windows OpenSSH exec channel (e.g. the cmd
@@ -113,9 +129,15 @@ export function profileCacheKey({ host, user, port }) {
113
129
 
114
130
  /**
115
131
  * Detect a target's OS family and default exec shell without parsing
116
- * localized output: `uname -s` proves a POSIX shell; `cmd /c ver` proves
117
- * Windows (it runs from cmd, PowerShell and Git Bash defaults alike); a
132
+ * localized output: `uname -s` proves a POSIX shell; the bare `ver` builtin
133
+ * proves a cmd-default Windows host; a quoted nested `cmd /c "ver"` proves
134
+ * Windows under a PowerShell default (PowerShell itself has no `ver`); a
118
135
  * `$PSVersionTable` expression then tells cmd from PowerShell as the default.
136
+ *
137
+ * Quoting matters on real cmd-default Windows hosts: OpenSSH wraps the exec
138
+ * payload for cmd.exe in a way that mangles inner unquoted spaces — `cmd /c
139
+ * ver` arrives as `ver"` and fails, while a bare single token (`ver`) and a
140
+ * quoted inner command (`cmd /c "ver"`) both pass (verified on a real host).
119
141
  */
120
142
  export async function probeRemoteProfile(client) {
121
143
  const uname = await client.run('uname -s')
@@ -123,16 +145,30 @@ export async function probeRemoteProfile(client) {
123
145
  const os = (uname.stdout ?? '').trim().toLowerCase()
124
146
  return { family: 'posix', os: os.startsWith('darwin') ? 'darwin' : 'linux', shell: 'posix' }
125
147
  }
126
- const ver = await client.run('cmd /c ver')
127
- if (ver.ok && /windows/i.test(ver.stdout ?? '')) {
148
+ const ver = await client.run('ver')
149
+ if (ver.ok) return { family: 'windows', os: 'windows', shell: 'cmd' }
150
+ const nested = await client.run('cmd /c "ver"')
151
+ if (nested.ok && /windows/i.test(nested.stdout ?? '')) {
128
152
  const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
129
153
  return ps.ok
130
154
  ? { family: 'windows', os: 'windows', shell: 'powershell' }
131
155
  : { family: 'windows', os: 'windows', shell: 'cmd' }
132
156
  }
157
+ const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
158
+ if (ps.ok) return { family: 'windows', os: 'windows', shell: 'powershell' }
133
159
  return { family: 'unknown', os: 'unknown', shell: 'unknown' }
134
160
  }
135
161
 
162
+ /**
163
+ * Error text used whenever a command cannot run because the remote profile
164
+ * probe failed (family 'unknown'). Explicit instead of guessing a dialect:
165
+ * sending a POSIX `cd 'x' || exit 1` script to a Windows/cmd host produces the
166
+ * misleading "文件名、目录名或卷标语法不正确" (Win32 123) failure, and the
167
+ * other way around is equally wrong — so callers refuse loudly.
168
+ */
169
+ export const PROBE_UNKNOWN_MSG =
170
+ 'cannot determine the remote shell type (exec probe failed); command not executed. Check the host side: the account home/profile directory must exist, the OpenSSH DefaultShell must be valid, and the exec channel itself must be usable.'
171
+
136
172
  /** Encode a PowerShell script for quote-safe transport through cmd.exe.
137
173
  *
138
174
  * `powershell -EncodedCommand` would be ideal (no quoting at all), but a
@@ -162,7 +198,15 @@ export function psCommandEnvelope(script) {
162
198
  * text (no CLIXML records).
163
199
  */
164
200
  export function buildExecScript(profile, command, cwd) {
165
- if (profile === undefined || profile.family !== 'windows') {
201
+ if (profile === undefined) {
202
+ return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
203
+ }
204
+ if (profile.family === 'unknown') {
205
+ // Never guess: a POSIX script against a Windows/cmd host dies on the first
206
+ // line with Win32 123-style errors and never reaches the command.
207
+ throw new Error(PROBE_UNKNOWN_MSG)
208
+ }
209
+ if (profile.family !== 'windows') {
166
210
  return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
167
211
  }
168
212
  const script = [
@@ -362,6 +406,9 @@ export class SshClient {
362
406
  */
363
407
  async execShell(command, { cwd, timeoutMs = 60000, stdoutMaxBytes = 64000, stderrMaxBytes = 64000, stdin, signal } = {}) {
364
408
  const profile = await this.profile()
409
+ if (profile.family === 'unknown') {
410
+ return { ok: false, error: PROBE_UNKNOWN_MSG }
411
+ }
365
412
  const script = buildExecScript(profile, command, cwd)
366
413
  return await new Promise((resolve) => {
367
414
  const conn = new Client()
@@ -466,6 +513,63 @@ export class SshClient {
466
513
  })
467
514
  }
468
515
 
516
+ /**
517
+ * Open an INTERACTIVE shell channel with a PTY — the remote half of the UI
518
+ * Shell tool. ssh2 `conn.shell` + a pty is the same mechanism on POSIX and
519
+ * Windows OpenSSH (ConPTY) remotes; the target's DefaultShell decides whether
520
+ * the session is a login shell, cmd, or PowerShell, and its OS decides the
521
+ * line endings. The channel merges stderr into stdout (one data stream) and
522
+ * the bytes are passed through RAW — a PTY already emits terminal-ready CRLF
523
+ * that xterm renders directly, so the exec-channel CRLF folding must NOT be
524
+ * applied here.
525
+ *
526
+ * When `cwd` is given, the shell is chdir'd there as its first input (a
527
+ * dialect-aware `cd`, probed once per target). The shell still starts at the
528
+ * remote account's home; the `cd` is emitted into the PTY so the prompt and
529
+ * every later command land in the requested directory.
530
+ *
531
+ * Resolves a handle compatible with the local PTY one:
532
+ * { output, write, terminate, resize, pid } where `output` is the ssh2
533
+ * stream (an EventEmitter emitting 'data' and 'close'), `resize` maps to
534
+ * `setWindow` (REACHABLE here, unlike the local seam — the S0 finding), and
535
+ * `terminate` closes the channel then ends the connection.
536
+ */
537
+ openShell({ rows = 24, cols = 80, term = 'xterm-256color', env, cwd } = {}) {
538
+ const chdirPromise = cwd !== undefined && cwd !== null && cwd !== ''
539
+ ? this.profile().then((profile) => shellCdCommand(profile, cwd)).catch(() => '')
540
+ : Promise.resolve('')
541
+ return chdirPromise.then((chdir) => new Promise((resolve, reject) => {
542
+ const conn = new Client()
543
+ let settled = false
544
+ const fail = (error) => {
545
+ if (settled) return
546
+ settled = true
547
+ try { conn.end() } catch {}
548
+ reject(error instanceof Error ? error : new Error(String(error)))
549
+ }
550
+ const timer = setTimeout(() => fail(new Error('SSH shell 连接超时')), this.readyTimeoutMs)
551
+ conn.on('ready', () => {
552
+ conn.shell({ term, rows, cols, ...(env ? { env } : {}) }, (err, stream) => {
553
+ if (err) { clearTimeout(timer); fail(err); return }
554
+ clearTimeout(timer)
555
+ settled = true
556
+ let closed = false
557
+ stream.on('close', () => { closed = true })
558
+ if (chdir !== '') stream.write(chdir)
559
+ resolve({
560
+ output: stream,
561
+ pid: null,
562
+ write(data) { if (!closed) { try { stream.write(data) } catch {} } },
563
+ terminate() { try { stream.close() } catch {} try { conn.end() } catch {} },
564
+ resize(r, c) { if (!closed) { try { stream.setWindow(r, c) } catch {} } },
565
+ })
566
+ })
567
+ })
568
+ conn.on('error', (connError) => { clearTimeout(timer); fail(connError) })
569
+ try { conn.connect(this.connectConfig()) } catch (connectError) { clearTimeout(timer); fail(connectError) }
570
+ }))
571
+ }
572
+
469
573
  /**
470
574
  * Open a LONG-LIVED exec channel for streaming (used by background/start
471
575
  * jobs on Windows remotes, where no nohup-style detach exists and closing
@@ -480,6 +584,15 @@ export class SshClient {
480
584
  */
481
585
  async execStream(command, { cwd, signal, stdoutMaxBytes = 16 * 1024 * 1024, stderrMaxBytes = 16 * 1024 * 1024 } = {}) {
482
586
  const profile = await this.profile()
587
+ if (profile.family === 'unknown') {
588
+ // Same explicit refusal as execShell: no dialect to build the script in.
589
+ return {
590
+ readOut: () => ({ delta: '', lossy: false }),
591
+ readErr: () => ({ delta: '', lossy: false }),
592
+ exit: Promise.resolve({ error: PROBE_UNKNOWN_MSG }),
593
+ terminate() {},
594
+ }
595
+ }
483
596
  const windows = profile.family === 'windows'
484
597
  const ssh = this
485
598
  // The marker runs FIRST inside the same remote shell that owns the exec
@@ -604,14 +717,18 @@ export class SshClient {
604
717
 
605
718
  /**
606
719
  * Cached remote execution profile ({ family, os, shell }); probed once per
607
- * target and reused for the process lifetime.
720
+ * target and reused for the process lifetime. FAILED probes (family
721
+ * 'unknown') are never cached, so the next call re-probes: a transient
722
+ * failure or a host-side fix is picked up without restarting the process.
608
723
  */
609
724
  async profile() {
610
725
  const key = profileCacheKey(this)
611
726
  let profile = REMOTE_PROFILE_CACHE.get(key)
612
727
  if (profile === undefined) {
613
728
  profile = await probeRemoteProfile(this)
614
- REMOTE_PROFILE_CACHE.set(key, profile)
729
+ if (profile.family === 'posix' || profile.family === 'windows') {
730
+ REMOTE_PROFILE_CACHE.set(key, profile)
731
+ }
615
732
  }
616
733
  return profile
617
734
  }