@linxin666/dsh-ssh 0.3.5 → 0.3.9

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.
@@ -36,6 +36,13 @@ type TerminalStatus =
36
36
  | { kind: 'exited'; alias: string; detail?: string }
37
37
  | { kind: 'error'; detail: string }
38
38
 
39
+ /** In-flight 2FA challenge prompts. */
40
+ interface AuthPromptState {
41
+ name: string
42
+ instructions: string
43
+ prompts: Array<{ prompt: string; echo: boolean }>
44
+ }
45
+
39
46
  /** Injected-once guard for the xterm stylesheet (one tag per page load). */
40
47
  let xtermCssInjected = false
41
48
 
@@ -60,6 +67,8 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
60
67
  const [hosts, setHosts] = useState<SshHostSummary[]>([])
61
68
  const [alias, setAlias] = useState(presetAlias ?? '')
62
69
  const [status, setStatus] = useState<TerminalStatus>({ kind: 'idle' })
70
+ const [authPrompt, setAuthPrompt] = useState<AuthPromptState | undefined>(undefined)
71
+ const [authInputs, setAuthInputs] = useState<string[]>([])
63
72
  const containerRef = useRef<HTMLDivElement | null>(null)
64
73
  const termRef = useRef<Terminal | null>(null)
65
74
  const fitRef = useRef<FitAddon | null>(null)
@@ -103,12 +112,15 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
103
112
  }, [presetAlias, requestId])
104
113
 
105
114
  const teardown = (): void => {
115
+ setAuthPrompt(undefined)
116
+ setAuthInputs([])
106
117
  const connection = connRef.current
107
118
  connRef.current = null
108
119
  if (connection !== null) {
109
120
  connection.onReady = undefined
110
121
  connection.onOutput = undefined
111
122
  connection.onExit = undefined
123
+ connection.onAuthPrompt = undefined
112
124
  connection.close()
113
125
  }
114
126
  // Release the xterm input subscription explicitly and dispose the
@@ -182,11 +194,21 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
182
194
  connRef.current = connection
183
195
  let settled = false
184
196
  dataSubRef.current = term.onData(data => { connection.send(data) })
185
- connection.onReady = () => { setStatus({ kind: 'connected', alias: target }) }
197
+ connection.onAuthPrompt = (name, instructions, prompts) => {
198
+ setAuthPrompt({ name, instructions, prompts })
199
+ setAuthInputs(prompts.map(() => ''))
200
+ }
201
+ connection.onReady = () => {
202
+ setAuthPrompt(undefined)
203
+ setAuthInputs([])
204
+ setStatus({ kind: 'connected', alias: target })
205
+ }
186
206
  connection.onOutput = data => { term.write(data) }
187
207
  connection.onExit = (_code, error) => {
188
208
  if (settled) return
189
209
  settled = true
210
+ setAuthPrompt(undefined)
211
+ setAuthInputs([])
190
212
  dataSubRef.current?.dispose()
191
213
  dataSubRef.current = null
192
214
  term.options.disableStdin = true
@@ -201,6 +223,13 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
201
223
  setStatus({ kind: 'idle' })
202
224
  }
203
225
 
226
+ const submitAuth = (e?: React.FormEvent): void => {
227
+ e?.preventDefault()
228
+ if (connRef.current === null || authPrompt === undefined) return
229
+ connRef.current.sendAuthResponse(authInputs)
230
+ setAuthPrompt(undefined)
231
+ }
232
+
204
233
  const active = status.kind === 'connecting' || status.kind === 'connected'
205
234
 
206
235
  return (
@@ -224,6 +253,37 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
224
253
  {status.kind === 'idle' && (
225
254
  <div className={css.termPlaceholder}>{hosts.length === 0 ? tt('hosts.empty') : tt('terminal.placeholder')}</div>
226
255
  )}
256
+ {authPrompt !== undefined && (
257
+ <div className={css.modalBackdrop} style={{ position: 'absolute', zIndex: 10 }}>
258
+ <form className={css.modalCard} style={{ maxWidth: 420 }} onSubmit={submitAuth}>
259
+ <div className={css.modalHeader}>
260
+ <h3 className={css.modalTitle}>{authPrompt.name || tt('terminal.auth.title')}</h3>
261
+ </div>
262
+ <p className={css.hint}>{authPrompt.instructions || tt('terminal.auth.hint')}</p>
263
+ {authPrompt.prompts.map((p, idx) => (
264
+ <label key={idx} className={css.field}>
265
+ <span className={css.fieldLabel}>{p.prompt.trim() || tt('terminal.auth.title')}</span>
266
+ <input
267
+ autoFocus={idx === 0}
268
+ className={css.input}
269
+ type={p.echo ? 'text' : 'password'}
270
+ placeholder={tt('terminal.auth.placeholder')}
271
+ value={authInputs[idx] ?? ''}
272
+ onChange={event => {
273
+ const next = [...authInputs]
274
+ next[idx] = event.target.value
275
+ setAuthInputs(next)
276
+ }}
277
+ />
278
+ </label>
279
+ ))}
280
+ <div className={css.modalFooter}>
281
+ <button type="button" className={css.ghostButton} onClick={disconnect}>{tt('terminal.auth.cancel')}</button>
282
+ <button type="submit" className={css.primaryButton}>{tt('terminal.auth.submit')}</button>
283
+ </div>
284
+ </form>
285
+ </div>
286
+ )}
227
287
  </div>
228
288
  </div>
229
289
  )
package/src/dsh-home.ts CHANGED
@@ -39,4 +39,4 @@ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: strin
39
39
  /** Resolve the DSH home directory from the live environment. */
40
40
  export function dshHome(): string {
41
41
  return resolveDshHome()
42
- }
42
+ }
@@ -70,6 +70,7 @@ export function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['soc
70
70
  readyTimeout: opts.connectTimeoutMs,
71
71
  keepaliveInterval: opts.keepaliveIntervalMs,
72
72
  keepaliveCountMax: 3,
73
+ tryKeyboard: true,
73
74
  }
74
75
  if (sock !== undefined) config.sock = sock
75
76
  if (entry.auth.kind === 'password') {
@@ -103,8 +104,17 @@ export function resolveAgentPath(agentPath?: string): string | undefined {
103
104
  return undefined
104
105
  }
105
106
 
107
+ /** Keyboard-interactive handler callback for 2FA / dynamic prompt flow. */
108
+ export type KeyboardInteractiveHandler = (
109
+ name: string,
110
+ instructions: string,
111
+ instructionsLang: string,
112
+ prompts: Array<{ prompt: string; echo: boolean }>,
113
+ finish: (responses: string[]) => void,
114
+ ) => void
115
+
106
116
  /** Connect one ssh2 client (resolve on ready, reject on error/close). */
107
- export function connectClient(config: ConnectConfig): Promise<Client> {
117
+ export function connectClient(config: ConnectConfig, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<Client> {
108
118
  return new Promise((resolve, reject) => {
109
119
  const client = new Client()
110
120
  let settled = false
@@ -119,6 +129,18 @@ export function connectClient(config: ConnectConfig): Promise<Client> {
119
129
  settled = true
120
130
  resolve(client)
121
131
  })
132
+ client.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => {
133
+ if (onKeyboardInteractive !== undefined) {
134
+ onKeyboardInteractive(name, instructions, instructionsLang, prompts.map(p => ({ prompt: p.prompt, echo: Boolean(p.echo) })), finish)
135
+ return
136
+ }
137
+ // Step 1: Automatic PAM password fallback: if a password is configured and all prompts ask for password
138
+ if (config.password !== undefined && prompts.length > 0 && prompts.every(p => /password/i.test(p.prompt))) {
139
+ finish(prompts.map(() => config.password as string))
140
+ return
141
+ }
142
+ fail(new Error('Authentication failed (keyboard-interactive): ' + (prompts.map(p => p.prompt.trim()).join(', ') || 'unsupported interactive challenge')))
143
+ })
122
144
  // Keep an error listener attached after the handshake: when TCP connects
123
145
  // but the handshake drops, ssh2 can emit a second 'error' after a single
124
146
  // once-listener is gone, which would surface as an unhandled 'error' event.
@@ -152,7 +174,11 @@ export function appendOutput(target: { text: string; truncated: boolean }, chunk
152
174
  * order, each forwarding a stream to the next destination, ending with the
153
175
  * target client. Shared by the pool and standalone shell sessions.
154
176
  */
155
- export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Promise<{ client: Client; hops: Client[] }> {
177
+ export async function connectChain(
178
+ engine: PoolEngine,
179
+ entry: SshHostEntry,
180
+ onKeyboardInteractive?: KeyboardInteractiveHandler,
181
+ ): Promise<{ client: Client; hops: Client[] }> {
156
182
  const hops: Client[] = []
157
183
  let sock: ConnectConfig['sock']
158
184
  const chain = entry.proxyJump
@@ -163,7 +189,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
163
189
  if (hop === undefined) {
164
190
  throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
165
191
  }
166
- const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
192
+ const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts), onKeyboardInteractive)
167
193
  hops.push(hopClient)
168
194
  const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
169
195
  const nextHost = next !== undefined ? next.host : entry.host
@@ -187,7 +213,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
187
213
  }
188
214
  let target: Client | undefined
189
215
  try {
190
- target = await connectClient(buildConnectConfig(entry, sock, engine.opts))
216
+ target = await connectClient(buildConnectConfig(entry, sock, engine.opts), onKeyboardInteractive)
191
217
  return { client: target, hops }
192
218
  } catch (error) {
193
219
  for (const client of hops) client.end()
package/src/engine/pty.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * output delivery.
5
5
  */
6
6
 
7
- import { connectChain, type PoolEngine } from './connection-pool.ts'
7
+ import { connectChain, type KeyboardInteractiveHandler, type PoolEngine } from './connection-pool.ts'
8
8
 
9
9
  /** A live PTY shell session. */
10
10
  export interface ShellSession {
@@ -29,10 +29,15 @@ export interface ShellSession {
29
29
  * The shell is a long-lived exclusive stream: it uses its own connection so
30
30
  * closing it can never tear down a pooled exec/tunnel sharing the alias.
31
31
  */
32
- export async function openShell(engine: PoolEngine, alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
32
+ export async function openShell(
33
+ engine: PoolEngine,
34
+ alias: string,
35
+ size: { cols: number; rows: number },
36
+ onKeyboardInteractive?: KeyboardInteractiveHandler,
37
+ ): Promise<ShellSession> {
33
38
  const entry = engine.store.find(alias)
34
39
  if (entry === undefined) throw new Error('alias \'' + alias + '\' not found — add it first')
35
- const { client, hops } = await connectChain(engine, entry)
40
+ const { client, hops } = await connectChain(engine, entry, onKeyboardInteractive)
36
41
  return await new Promise<ShellSession>((resolve, reject) => {
37
42
  client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (error, stream) => {
38
43
  if (error !== undefined) {
package/src/engine.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  disposeRecord,
15
15
  sweepPool,
16
16
  type EngineOptions,
17
+ type KeyboardInteractiveHandler,
17
18
  type PoolRecord,
18
19
  } from './engine/connection-pool.ts'
19
20
  import { openShell, type ShellSession } from './engine/pty.ts'
@@ -21,7 +22,7 @@ import { download, ls, upload } from './engine/sftp.ts'
21
22
  import { listTunnels, startTunnel, stopAllTunnels, stopTunnel, type TunnelRecord } from './engine/tunnel.ts'
22
23
  import { cluster } from './engine/cluster.ts'
23
24
 
24
- export type { EngineOptions } from './engine/connection-pool.ts'
25
+ export type { EngineOptions, KeyboardInteractiveHandler } from './engine/connection-pool.ts'
25
26
  export type { ShellSession } from './engine/pty.ts'
26
27
  export type { PoolRecord } from './engine/connection-pool.ts'
27
28
  export type { TunnelRecord } from './engine/tunnel.ts'
@@ -92,8 +93,12 @@ export class SshEngine {
92
93
  // -------------------------------------------------------------- shell
93
94
 
94
95
  /** Open a PTY shell session for the web terminal (standalone connection). */
95
- async openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
96
- return openShell(this, alias, size)
96
+ async openShell(
97
+ alias: string,
98
+ size: { cols: number; rows: number },
99
+ onKeyboardInteractive?: KeyboardInteractiveHandler,
100
+ ): Promise<ShellSession> {
101
+ return openShell(this, alias, size, onKeyboardInteractive)
97
102
  }
98
103
 
99
104
  // -------------------------------------------------------------- sftp
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { Context } from '@deepseek-ai/cordis'
12
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
12
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
13
13
  import z from 'schemastery'
14
14
  import type {} from '@deepseek-ai/dsh-host-webserver'
15
15
  import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -31,7 +31,7 @@ export const inject = ['webServer', 'tools', 'systemPrompt']
31
31
  * surface edits. Spelled here rather than imported: the browser half spells
32
32
  * the same value and must not depend on a Host package.
33
33
  */
34
- export const SSH_SETTINGS_NAMESPACE = settingsNamespace('dsh-ssh')
34
+ export const SSH_SETTINGS_NAMESPACE = 'dsh-ssh' as SettingsNamespace
35
35
 
36
36
  /** Plugin config, validated by the same-named schemastery schema. */
37
37
  export interface Config {
@@ -77,18 +77,28 @@ function applyImpl(ctx: Context, config?: Config): void {
77
77
  // The live source the surfaces read: the settings section once the web
78
78
  // settings surface is served, the composition entry otherwise.
79
79
  let current: () => Config = () => config ?? {}
80
+ let isSettingsBound = false
81
+
82
+ const store = new HostStore()
83
+ const engine = new SshEngine(store)
84
+ ctx.effect(() => () => { engine.dispose() }, 'dsh-ssh: engine')
85
+
80
86
  const resolve = (): Config => {
81
87
  const value = current()
88
+ let enabled = value.enabled ?? true
89
+ // When dsh-ssh was seeded with enabled: false (e.g. from an aggregate profile line),
90
+ // but the user has never explicitly configured settings (not bound yet)
91
+ // AND already has active host records in dsh-ssh.json, keep the plugin enabled so
92
+ // existing users are not broken upon upgrade (#1250).
93
+ if (enabled === false && !isSettingsBound && store.list().length > 0) {
94
+ enabled = true
95
+ }
82
96
  return {
83
97
  announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
84
- enabled: value.enabled ?? true,
98
+ enabled,
85
99
  }
86
100
  }
87
101
 
88
- const store = new HostStore()
89
- const engine = new SshEngine(store)
90
- ctx.effect(() => () => { engine.dispose() }, 'dsh-ssh: engine')
91
-
92
102
  // The /api/dsh-ssh route family + terminal upgrade.
93
103
  const { routes, upgrade } = makeRoutes({ store, engine })
94
104
  let disposeRoutes: (() => void) | undefined
@@ -152,15 +162,18 @@ function applyImpl(ctx: Context, config?: Config): void {
152
162
  )
153
163
  }
154
164
 
155
- installSettingsSection(ctx, SSH_SETTINGS_NAMESPACE, Config, config ?? {}, {
156
- setSource: (source) => {
157
- current = source
158
- sync()
159
- },
160
- onChange: sync,
165
+ ctx.inject(['settings'], (settingsCtx) => {
166
+ settingsCtx.settings.installSection(ctx, SSH_SETTINGS_NAMESPACE, Config, config ?? {}, {
167
+ setSource: (source) => {
168
+ isSettingsBound = true
169
+ current = source
170
+ sync()
171
+ },
172
+ onChange: sync,
173
+ })
161
174
  })
162
175
 
163
176
  // Initial registration from the composition entry (covers deployments with
164
- // no settings service, whose installSettingsSection never fires its hooks).
177
+ // no settings service, whose installSection never fires its hooks).
165
178
  sync()
166
179
  }
package/src/protocol.ts CHANGED
@@ -167,10 +167,17 @@ export type TerminalServerFrame =
167
167
  | { type: 'ready'; alias: string }
168
168
  | { type: 'output'; data: string }
169
169
  | { type: 'exit'; code: number | null; error?: string }
170
+ | {
171
+ type: 'auth_prompt'
172
+ name: string
173
+ instructions: string
174
+ prompts: Array<{ prompt: string; echo: boolean }>
175
+ }
170
176
 
171
177
  export type TerminalClientFrame =
172
178
  | { type: 'input'; data: string }
173
179
  | { type: 'resize'; cols: number; rows: number }
180
+ | { type: 'auth_response'; responses: string[] }
174
181
 
175
182
  /** Route paths the client calls (shared literals). */
176
183
  export const SSH_API_BASE = '/api/dsh-ssh' as const
package/src/routes.ts CHANGED
@@ -15,7 +15,7 @@ import { tmpdir } from 'node:os'
15
15
  import { randomBytes } from 'node:crypto'
16
16
  import { WebSocket, WebSocketServer } from 'ws'
17
17
  import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
18
- import type { SshEngine, ShellSession } from './engine.ts'
18
+ import type { SshEngine, ShellSession, KeyboardInteractiveHandler } from './engine.ts'
19
19
  import { readJsonBody, writeJson } from './http.ts'
20
20
  import { isLoopbackRequest } from './loopback.ts'
21
21
  import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
@@ -466,6 +466,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
466
466
  let session: ShellSession | undefined
467
467
  let closed = false
468
468
  let paused = false
469
+ let pendingAuthFinish: ((responses: string[]) => void) | undefined
469
470
  // Resume the shell once the socket's send buffer drains below the
470
471
  // low-water mark (transport backpressure).
471
472
  const resume = (): void => {
@@ -483,14 +484,38 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
483
484
  }
484
485
  }
485
486
  const closeSession = (): void => {
487
+ if (pendingAuthFinish !== undefined) {
488
+ const fn = pendingAuthFinish
489
+ pendingAuthFinish = undefined
490
+ try { fn([]) } catch { /* ignore */ }
491
+ }
486
492
  const opened = session
487
493
  session = undefined
488
494
  if (opened !== undefined) opened.close()
489
495
  }
496
+
497
+ const onKeyboardInteractive: KeyboardInteractiveHandler = (name, instructions, _lang, prompts, finish) => {
498
+ const entry = store.find(alias)
499
+ // Auto-answer password if configured and prompt is a password request
500
+ if (entry?.auth.kind === 'password' && entry.auth.password !== undefined && prompts.length > 0 && prompts.every(p => /password/i.test(p.prompt))) {
501
+ const password = entry.auth.password
502
+ finish(prompts.map(() => password))
503
+ return
504
+ }
505
+ // Interactive 2FA prompt sent to the client terminal
506
+ pendingAuthFinish = finish
507
+ sendFrame({
508
+ type: 'auth_prompt',
509
+ name,
510
+ instructions,
511
+ prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo })),
512
+ })
513
+ }
514
+
490
515
  engine.openShell(alias, {
491
516
  cols: Number.isFinite(cols) ? cols : 80,
492
517
  rows: Number.isFinite(rows) ? rows : 24,
493
- }).then((opened) => {
518
+ }, onKeyboardInteractive).then((opened) => {
494
519
  if (ws.readyState !== WebSocket.OPEN) {
495
520
  opened.close()
496
521
  return
@@ -519,6 +544,12 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
519
544
  session?.send(frame.data)
520
545
  } else if (frame.type === 'resize') {
521
546
  session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows))
547
+ } else if (frame.type === 'auth_response') {
548
+ if (pendingAuthFinish !== undefined) {
549
+ const fn = pendingAuthFinish
550
+ pendingAuthFinish = undefined
551
+ fn(frame.responses)
552
+ }
522
553
  }
523
554
  })
524
555
  ws.on('close', () => {