@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.
package/lib/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
1
  import { closeSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
2
  import { Client } from "ssh2";
4
3
  import { homedir, tmpdir } from "node:os";
@@ -1408,7 +1407,8 @@ function buildConnectConfig(entry, sock, opts) {
1408
1407
  username: entry.user,
1409
1408
  readyTimeout: opts.connectTimeoutMs,
1410
1409
  keepaliveInterval: opts.keepaliveIntervalMs,
1411
- keepaliveCountMax: 3
1410
+ keepaliveCountMax: 3,
1411
+ tryKeyboard: true
1412
1412
  };
1413
1413
  if (sock !== void 0) config.sock = sock;
1414
1414
  if (entry.auth.kind === "password") config.password = entry.auth.password;
@@ -1433,7 +1433,7 @@ function resolveAgentPath(agentPath) {
1433
1433
  if (process.platform === "win32") return "pageant";
1434
1434
  }
1435
1435
  /** Connect one ssh2 client (resolve on ready, reject on error/close). */
1436
- function connectClient(config) {
1436
+ function connectClient(config, onKeyboardInteractive) {
1437
1437
  return new Promise((resolve, reject) => {
1438
1438
  const client = new Client();
1439
1439
  let settled = false;
@@ -1450,6 +1450,20 @@ function connectClient(config) {
1450
1450
  settled = true;
1451
1451
  resolve(client);
1452
1452
  });
1453
+ client.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
1454
+ if (onKeyboardInteractive !== void 0) {
1455
+ onKeyboardInteractive(name, instructions, instructionsLang, prompts.map((p) => ({
1456
+ prompt: p.prompt,
1457
+ echo: Boolean(p.echo)
1458
+ })), finish);
1459
+ return;
1460
+ }
1461
+ if (config.password !== void 0 && prompts.length > 0 && prompts.every((p) => /password/i.test(p.prompt))) {
1462
+ finish(prompts.map(() => config.password));
1463
+ return;
1464
+ }
1465
+ fail(/* @__PURE__ */ new Error("Authentication failed (keyboard-interactive): " + (prompts.map((p) => p.prompt.trim()).join(", ") || "unsupported interactive challenge")));
1466
+ });
1453
1467
  client.on("error", fail);
1454
1468
  try {
1455
1469
  client.connect(config);
@@ -1475,7 +1489,7 @@ function appendOutput(target, chunk, maxBytes) {
1475
1489
  * order, each forwarding a stream to the next destination, ending with the
1476
1490
  * target client. Shared by the pool and standalone shell sessions.
1477
1491
  */
1478
- async function connectChain(engine, entry) {
1492
+ async function connectChain(engine, entry, onKeyboardInteractive) {
1479
1493
  const hops = [];
1480
1494
  let sock;
1481
1495
  const chain = entry.proxyJump;
@@ -1484,7 +1498,7 @@ async function connectChain(engine, entry) {
1484
1498
  const hopAlias = chain[index];
1485
1499
  const hop = engine.store.find(hopAlias);
1486
1500
  if (hop === void 0) throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
1487
- const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts));
1501
+ const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts), onKeyboardInteractive);
1488
1502
  hops.push(hopClient);
1489
1503
  const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
1490
1504
  const nextHost = next !== void 0 ? next.host : entry.host;
@@ -1502,7 +1516,7 @@ async function connectChain(engine, entry) {
1502
1516
  }
1503
1517
  let target;
1504
1518
  try {
1505
- target = await connectClient(buildConnectConfig(entry, sock, engine.opts));
1519
+ target = await connectClient(buildConnectConfig(entry, sock, engine.opts), onKeyboardInteractive);
1506
1520
  return {
1507
1521
  client: target,
1508
1522
  hops
@@ -1688,10 +1702,10 @@ async function execCommand(engine, alias, command, timeoutMs) {
1688
1702
  * The shell is a long-lived exclusive stream: it uses its own connection so
1689
1703
  * closing it can never tear down a pooled exec/tunnel sharing the alias.
1690
1704
  */
1691
- async function openShell(engine, alias, size) {
1705
+ async function openShell(engine, alias, size, onKeyboardInteractive) {
1692
1706
  const entry = engine.store.find(alias);
1693
1707
  if (entry === void 0) throw new Error("alias '" + alias + "' not found — add it first");
1694
- const { client, hops } = await connectChain(engine, entry);
1708
+ const { client, hops } = await connectChain(engine, entry, onKeyboardInteractive);
1695
1709
  return await new Promise((resolve, reject) => {
1696
1710
  client.shell({
1697
1711
  term: "xterm-256color",
@@ -2161,8 +2175,8 @@ var SshEngine = class {
2161
2175
  return cluster(this, options);
2162
2176
  }
2163
2177
  /** Open a PTY shell session for the web terminal (standalone connection). */
2164
- async openShell(alias, size) {
2165
- return openShell(this, alias, size);
2178
+ async openShell(alias, size, onKeyboardInteractive) {
2179
+ return openShell(this, alias, size, onKeyboardInteractive);
2166
2180
  }
2167
2181
  /** Upload one local file (or directory tree) to a remote path. */
2168
2182
  async upload(alias, localPath, remotePath, recursive, onProgress) {
@@ -2796,6 +2810,7 @@ function makeRoutes(deps) {
2796
2810
  let session;
2797
2811
  let closed = false;
2798
2812
  let paused = false;
2813
+ let pendingAuthFinish;
2799
2814
  const resume = () => {
2800
2815
  if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
2801
2816
  paused = false;
@@ -2811,14 +2826,39 @@ function makeRoutes(deps) {
2811
2826
  }
2812
2827
  };
2813
2828
  const closeSession = () => {
2829
+ if (pendingAuthFinish !== void 0) {
2830
+ const fn = pendingAuthFinish;
2831
+ pendingAuthFinish = void 0;
2832
+ try {
2833
+ fn([]);
2834
+ } catch {}
2835
+ }
2814
2836
  const opened = session;
2815
2837
  session = void 0;
2816
2838
  if (opened !== void 0) opened.close();
2817
2839
  };
2840
+ const onKeyboardInteractive = (name, instructions, _lang, prompts, finish) => {
2841
+ const entry = store.find(alias);
2842
+ if (entry?.auth.kind === "password" && entry.auth.password !== void 0 && prompts.length > 0 && prompts.every((p) => /password/i.test(p.prompt))) {
2843
+ const password = entry.auth.password;
2844
+ finish(prompts.map(() => password));
2845
+ return;
2846
+ }
2847
+ pendingAuthFinish = finish;
2848
+ sendFrame({
2849
+ type: "auth_prompt",
2850
+ name,
2851
+ instructions,
2852
+ prompts: prompts.map((p) => ({
2853
+ prompt: p.prompt,
2854
+ echo: p.echo
2855
+ }))
2856
+ });
2857
+ };
2818
2858
  engine.openShell(alias, {
2819
2859
  cols: Number.isFinite(cols) ? cols : 80,
2820
2860
  rows: Number.isFinite(rows) ? rows : 24
2821
- }).then((opened) => {
2861
+ }, onKeyboardInteractive).then((opened) => {
2822
2862
  if (ws.readyState !== WebSocket.OPEN) {
2823
2863
  opened.close();
2824
2864
  return;
@@ -2863,6 +2903,13 @@ function makeRoutes(deps) {
2863
2903
  }
2864
2904
  if (frame.type === "input") session?.send(frame.data);
2865
2905
  else if (frame.type === "resize") session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows));
2906
+ else if (frame.type === "auth_response") {
2907
+ if (pendingAuthFinish !== void 0) {
2908
+ const fn = pendingAuthFinish;
2909
+ pendingAuthFinish = void 0;
2910
+ fn(frame.responses);
2911
+ }
2912
+ }
2866
2913
  });
2867
2914
  ws.on("close", () => {
2868
2915
  closed = true;
@@ -3512,7 +3559,7 @@ const inject = [
3512
3559
  * surface edits. Spelled here rather than imported: the browser half spells
3513
3560
  * the same value and must not depend on a Host package.
3514
3561
  */
3515
- const SSH_SETTINGS_NAMESPACE = settingsNamespace("dsh-ssh");
3562
+ const SSH_SETTINGS_NAMESPACE = "dsh-ssh";
3516
3563
  const Config = import_lib.default.object({
3517
3564
  announceToAgent: import_lib.default.boolean().default(false),
3518
3565
  enabled: import_lib.default.boolean().default(true),
@@ -3532,18 +3579,21 @@ const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维)
3532
3579
  const apply = mountOnce("@linxin666/dsh-ssh", applyImpl);
3533
3580
  function applyImpl(ctx, config) {
3534
3581
  let current = () => config ?? {};
3582
+ let isSettingsBound = false;
3583
+ const store = new HostStore();
3584
+ const engine = new SshEngine(store);
3585
+ ctx.effect(() => () => {
3586
+ engine.dispose();
3587
+ }, "dsh-ssh: engine");
3535
3588
  const resolve = () => {
3536
3589
  const value = current();
3590
+ let enabled = value.enabled ?? true;
3591
+ if (enabled === false && !isSettingsBound && store.list().length > 0) enabled = true;
3537
3592
  return {
3538
3593
  announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
3539
- enabled: value.enabled ?? true
3594
+ enabled
3540
3595
  };
3541
3596
  };
3542
- const store = new HostStore();
3543
- const engine = new SshEngine(store);
3544
- ctx.effect(() => () => {
3545
- engine.dispose();
3546
- }, "dsh-ssh: engine");
3547
3597
  const { routes, upgrade } = makeRoutes({
3548
3598
  store,
3549
3599
  engine
@@ -3594,12 +3644,15 @@ function applyImpl(ctx, config) {
3594
3644
  };
3595
3645
  }, "dsh-ssh: tools");
3596
3646
  };
3597
- installSettingsSection(ctx, SSH_SETTINGS_NAMESPACE, Config, config ?? {}, {
3598
- setSource: (source) => {
3599
- current = source;
3600
- sync();
3601
- },
3602
- onChange: sync
3647
+ ctx.inject(["settings"], (settingsCtx) => {
3648
+ settingsCtx.settings.installSection(ctx, SSH_SETTINGS_NAMESPACE, Config, config ?? {}, {
3649
+ setSource: (source) => {
3650
+ isSettingsBound = true;
3651
+ current = source;
3652
+ sync();
3653
+ },
3654
+ onChange: sync
3655
+ });
3603
3656
  });
3604
3657
  sync();
3605
3658
  }
@@ -15,10 +15,17 @@ export interface TerminalConnection {
15
15
  onOutput: ((data: string) => void) | undefined;
16
16
  /** Fired on the exit frame (or transport error). */
17
17
  onExit: ((code: number | null, error?: string) => void) | undefined;
18
+ /** Fired when the server requests keyboard-interactive 2FA. */
19
+ onAuthPrompt: ((name: string, instructions: string, prompts: Array<{
20
+ prompt: string;
21
+ echo: boolean;
22
+ }>) => void) | undefined;
18
23
  /** Send raw input to the remote shell. */
19
24
  send(data: string): void;
20
25
  /** Resize the remote PTY. */
21
26
  resize(cols: number, rows: number): void;
27
+ /** Send interactive 2FA response codes to the remote server. */
28
+ sendAuthResponse(responses: string[]): void;
22
29
  /** Close the socket and the remote session. */
23
30
  close(): void;
24
31
  }
@@ -10,7 +10,8 @@
10
10
  * Export discipline (packages/client rule): the /client surface carries what
11
11
  * cordis loading needs plus types only — all value exports stay internal.
12
12
  */
13
- import type { ClientContext, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client';
13
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
14
+ import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client';
14
15
  import { type SshKey } from './locales.ts';
15
16
  declare module '@deepseek-ai/cordis' {
16
17
  interface Context {
@@ -81,6 +81,11 @@ export declare const zh: {
81
81
  readonly 'terminal.ready': "终端已连接({alias})";
82
82
  readonly 'terminal.exited': "终端已退出({alias})";
83
83
  readonly 'terminal.error': "终端错误:{error}";
84
+ readonly 'terminal.auth.title': "二次身份验证 (2FA / 交互式认证)";
85
+ readonly 'terminal.auth.submit': "提交验证码";
86
+ readonly 'terminal.auth.cancel': "取消";
87
+ readonly 'terminal.auth.placeholder': "请输入验证码或口令";
88
+ readonly 'terminal.auth.hint': "服务器请求进行交互式二次验证(输入仅本次生效,不落盘):";
84
89
  readonly 'transfer.selectHost': "选择主机";
85
90
  readonly 'transfer.remotePath': "远程路径";
86
91
  readonly 'transfer.remotePathHint': "如 /tmp/ 或 /home/user/app.tar.gz";
@@ -141,6 +146,7 @@ export declare const zh: {
141
146
  readonly 'common.error': "出错:{error}";
142
147
  readonly 'common.refresh': "刷新";
143
148
  readonly 'common.confirm': "确定";
149
+ readonly 'error.disabled': "SSH 插件在宿主端未启用。请前往「设置 → Web 插件 → SSH」打开 enabled 开关。";
144
150
  };
145
151
  export declare const en: Record<keyof typeof zh, string>;
146
152
  /** Locale key union. */
@@ -50,8 +50,13 @@ export interface PoolEngine {
50
50
  export declare function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig;
51
51
  /** Resolve the ssh2 agent path for 'agent' auth. */
52
52
  export declare function resolveAgentPath(agentPath?: string): string | undefined;
53
+ /** Keyboard-interactive handler callback for 2FA / dynamic prompt flow. */
54
+ export type KeyboardInteractiveHandler = (name: string, instructions: string, instructionsLang: string, prompts: Array<{
55
+ prompt: string;
56
+ echo: boolean;
57
+ }>, finish: (responses: string[]) => void) => void;
53
58
  /** Connect one ssh2 client (resolve on ready, reject on error/close). */
54
- export declare function connectClient(config: ConnectConfig): Promise<Client>;
59
+ export declare function connectClient(config: ConnectConfig, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<Client>;
55
60
  /** Cap captured output at the configured byte budget (marks truncation). */
56
61
  export declare function appendOutput(target: {
57
62
  text: string;
@@ -62,7 +67,7 @@ export declare function appendOutput(target: {
62
67
  * order, each forwarding a stream to the next destination, ending with the
63
68
  * target client. Shared by the pool and standalone shell sessions.
64
69
  */
65
- export declare function connectChain(engine: PoolEngine, entry: SshHostEntry): Promise<{
70
+ export declare function connectChain(engine: PoolEngine, entry: SshHostEntry, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<{
66
71
  client: Client;
67
72
  hops: Client[];
68
73
  }>;
@@ -3,7 +3,7 @@
3
3
  * ssh2 connection with a long-lived shell channel, resize, and pausable
4
4
  * output delivery.
5
5
  */
6
- import { type PoolEngine } from './connection-pool.ts';
6
+ import { type KeyboardInteractiveHandler, type PoolEngine } from './connection-pool.ts';
7
7
  /** A live PTY shell session. */
8
8
  export interface ShellSession {
9
9
  /** Assign to receive remote output. */
@@ -29,4 +29,4 @@ export interface ShellSession {
29
29
  export declare function openShell(engine: PoolEngine, alias: string, size: {
30
30
  cols: number;
31
31
  rows: number;
32
- }): Promise<ShellSession>;
32
+ }, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<ShellSession>;
@@ -7,10 +7,10 @@
7
7
  */
8
8
  import type { ClusterResult, ExecResult, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts';
9
9
  import type { HostStore } from './store.ts';
10
- import { type EngineOptions, type PoolRecord } from './engine/connection-pool.ts';
10
+ import { type EngineOptions, type KeyboardInteractiveHandler, type PoolRecord } from './engine/connection-pool.ts';
11
11
  import { type ShellSession } from './engine/pty.ts';
12
12
  import { type TunnelRecord } from './engine/tunnel.ts';
13
- export type { EngineOptions } from './engine/connection-pool.ts';
13
+ export type { EngineOptions, KeyboardInteractiveHandler } from './engine/connection-pool.ts';
14
14
  export type { ShellSession } from './engine/pty.ts';
15
15
  export type { PoolRecord } from './engine/connection-pool.ts';
16
16
  export type { TunnelRecord } from './engine/tunnel.ts';
@@ -50,7 +50,7 @@ export declare class SshEngine {
50
50
  openShell(alias: string, size: {
51
51
  cols: number;
52
52
  rows: number;
53
- }): Promise<ShellSession>;
53
+ }, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<ShellSession>;
54
54
  /** Upload one local file (or directory tree) to a remote path. */
55
55
  upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void): Promise<{
56
56
  bytes: number;
@@ -8,6 +8,7 @@
8
8
  * no dsh source changes.
9
9
  */
10
10
  import type { Context } from '@deepseek-ai/cordis';
11
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
11
12
  import z from 'schemastery';
12
13
  /** Stable cordis plugin name. */
13
14
  export declare const name = "ssh";
@@ -18,7 +19,7 @@ export declare const inject: string[];
18
19
  * surface edits. Spelled here rather than imported: the browser half spells
19
20
  * the same value and must not depend on a Host package.
20
21
  */
21
- export declare const SSH_SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").SettingsNamespace;
22
+ export declare const SSH_SETTINGS_NAMESPACE: SettingsNamespace;
22
23
  /** Plugin config, validated by the same-named schemastery schema. */
23
24
  export interface Config {
24
25
  /**
@@ -160,6 +160,14 @@ export type TerminalServerFrame = {
160
160
  type: 'exit';
161
161
  code: number | null;
162
162
  error?: string;
163
+ } | {
164
+ type: 'auth_prompt';
165
+ name: string;
166
+ instructions: string;
167
+ prompts: Array<{
168
+ prompt: string;
169
+ echo: boolean;
170
+ }>;
163
171
  };
164
172
  export type TerminalClientFrame = {
165
173
  type: 'input';
@@ -168,6 +176,9 @@ export type TerminalClientFrame = {
168
176
  type: 'resize';
169
177
  cols: number;
170
178
  rows: number;
179
+ } | {
180
+ type: 'auth_response';
181
+ responses: string[];
171
182
  };
172
183
  /** Route paths the client calls (shared literals). */
173
184
  export declare const SSH_API_BASE: "/api/dsh-ssh";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.3.5",
4
+ "version": "0.3.9",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -26,15 +26,15 @@
26
26
  },
27
27
  "dsh": {
28
28
  "engines": {
29
- "dsh": ">=0.1.1-rc.1"
29
+ "dsh": ">=0.1.2-alpha.1"
30
30
  },
31
31
  "bundle": {
32
32
  "patch": "./cordis.patch.yml"
33
33
  },
34
34
  "client": {
35
35
  "inject": [
36
- "@deepseek-ai/dsh-client-runtime",
37
- "@deepseek-ai/dsh-client-connection",
36
+ "@deepseek-ai/dsh-client-locale",
37
+ "@deepseek-ai/dsh-client-ui-renderer",
38
38
  "@deepseek-ai/dsh-client-ui-settings"
39
39
  ],
40
40
  "platform": "web"
@@ -51,19 +51,19 @@
51
51
  "react-dom": "^18.2.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@deepseek-ai/cordis": "^4.0.1",
55
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
56
- "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
57
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
58
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
59
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
60
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
61
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
62
- "@deepseek-ai/dsh-scope": "^0.1.1-rc.2",
63
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
64
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
65
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
66
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
54
+ "@deepseek-ai/cordis": "^4.0.2",
55
+ "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.2",
56
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
57
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.2",
58
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
59
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
60
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
61
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
62
+ "@deepseek-ai/dsh-scope": "^0.1.2-alpha.2",
63
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
64
+ "@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2",
65
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.2",
66
+ "@deepseek-ai/dsh-tools": "^0.1.2-alpha.2",
67
67
  "@types/node": "^22.20.0",
68
68
  "@types/react": "~18.3.1",
69
69
  "@types/react-dom": "^18.3.5",
package/src/client/api.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  type TransferStreamLine,
19
19
  type TunnelInfo,
20
20
  } from '../protocol.ts'
21
+ import { tt } from './panel/helpers.ts'
21
22
 
22
23
  /** Minimal File System Access API surface (not in all lib.dom versions). */
23
24
  interface WindowWithFileSystemAccess {
@@ -51,12 +52,15 @@ async function readJson<T>(response: Response): Promise<T> {
51
52
  try {
52
53
  body = await response.json()
53
54
  } catch {
55
+ if (response.status === 404) {
56
+ throw new SshApiError(tt('error.disabled'))
57
+ }
54
58
  throw new SshApiError(`HTTP ${response.status}: invalid JSON response`)
55
59
  }
56
60
  if (!response.ok) {
57
61
  const message = typeof body === 'object' && body !== null && typeof (body as { error?: unknown }).error === 'string'
58
62
  ? (body as { error: string }).error
59
- : `HTTP ${response.status}`
63
+ : (response.status === 404 ? tt('error.disabled') : `HTTP ${response.status}`)
60
64
  throw new SshApiError(message)
61
65
  }
62
66
  return body as T
@@ -80,10 +84,14 @@ export interface TerminalConnection {
80
84
  onOutput: ((data: string) => void) | undefined
81
85
  /** Fired on the exit frame (or transport error). */
82
86
  onExit: ((code: number | null, error?: string) => void) | undefined
87
+ /** Fired when the server requests keyboard-interactive 2FA. */
88
+ onAuthPrompt: ((name: string, instructions: string, prompts: Array<{ prompt: string; echo: boolean }>) => void) | undefined
83
89
  /** Send raw input to the remote shell. */
84
90
  send(data: string): void
85
91
  /** Resize the remote PTY. */
86
92
  resize(cols: number, rows: number): void
93
+ /** Send interactive 2FA response codes to the remote server. */
94
+ sendAuthResponse(responses: string[]): void
87
95
  /** Close the socket and the remote session. */
88
96
  close(): void
89
97
  }
@@ -345,6 +353,7 @@ export class SshApi {
345
353
  onReady: undefined,
346
354
  onOutput: undefined,
347
355
  onExit: undefined,
356
+ onAuthPrompt: undefined,
348
357
  send: (data) => {
349
358
  if (socket.readyState === WebSocket.OPEN) {
350
359
  socket.send(JSON.stringify({ type: 'input', data } satisfies TerminalClientFrame))
@@ -355,6 +364,11 @@ export class SshApi {
355
364
  socket.send(JSON.stringify({ type: 'resize', cols, rows } satisfies TerminalClientFrame))
356
365
  }
357
366
  },
367
+ sendAuthResponse: (responses) => {
368
+ if (socket.readyState === WebSocket.OPEN) {
369
+ socket.send(JSON.stringify({ type: 'auth_response', responses } satisfies TerminalClientFrame))
370
+ }
371
+ },
358
372
  close: () => {
359
373
  try { socket.close() } catch { /* already closed */ }
360
374
  },
@@ -369,6 +383,7 @@ export class SshApi {
369
383
  if (frame.type === 'ready') connection.onReady?.()
370
384
  else if (frame.type === 'output') connection.onOutput?.(frame.data)
371
385
  else if (frame.type === 'exit') connection.onExit?.(frame.code, frame.error)
386
+ else if (frame.type === 'auth_prompt') connection.onAuthPrompt?.(frame.name, frame.instructions, frame.prompts)
372
387
  }
373
388
  socket.onclose = () => { connection.onExit?.(null, 'connection closed') }
374
389
  socket.onerror = () => { connection.onExit?.(null, 'connection error') }
@@ -10,11 +10,14 @@
10
10
  * Export discipline (packages/client rule): the /client surface carries what
11
11
  * cordis loading needs plus types only — all value exports stay internal.
12
12
  */
13
- import type { ClientContext, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
13
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
14
+ import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
14
15
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
15
16
  import type {} from '@deepseek-ai/dsh-client-locale/client'
16
17
  // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
17
18
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
19
+ // Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
20
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
18
21
  // Type-only: pulls the LocaleNamespaceMap merge table.
19
22
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
20
23
  import { SshApi } from './api.ts'
@@ -85,6 +85,11 @@ export const zh = {
85
85
  'terminal.ready': '终端已连接({alias})',
86
86
  'terminal.exited': '终端已退出({alias})',
87
87
  'terminal.error': '终端错误:{error}',
88
+ 'terminal.auth.title': '二次身份验证 (2FA / 交互式认证)',
89
+ 'terminal.auth.submit': '提交验证码',
90
+ 'terminal.auth.cancel': '取消',
91
+ 'terminal.auth.placeholder': '请输入验证码或口令',
92
+ 'terminal.auth.hint': '服务器请求进行交互式二次验证(输入仅本次生效,不落盘):',
88
93
  // transfer
89
94
  'transfer.selectHost': '选择主机',
90
95
  'transfer.remotePath': '远程路径',
@@ -149,6 +154,7 @@ export const zh = {
149
154
  'common.error': '出错:{error}',
150
155
  'common.refresh': '刷新',
151
156
  'common.confirm': '确定',
157
+ 'error.disabled': 'SSH 插件在宿主端未启用。请前往「设置 → Web 插件 → SSH」打开 enabled 开关。',
152
158
  } as const
153
159
 
154
160
  export const en: Record<keyof typeof zh, string> = {
@@ -231,6 +237,11 @@ export const en: Record<keyof typeof zh, string> = {
231
237
  'terminal.ready': 'Terminal connected ({alias})',
232
238
  'terminal.exited': 'Terminal exited ({alias})',
233
239
  'terminal.error': 'Terminal error: {error}',
240
+ 'terminal.auth.title': 'Two-Factor Authentication (2FA)',
241
+ 'terminal.auth.submit': 'Submit Code',
242
+ 'terminal.auth.cancel': 'Cancel',
243
+ 'terminal.auth.placeholder': 'Enter code or passphrase',
244
+ 'terminal.auth.hint': 'The server requested interactive 2FA (session only, never saved):',
234
245
  'transfer.selectHost': 'Select host',
235
246
  'transfer.remotePath': 'Remote path',
236
247
  'transfer.remotePathHint': 'e.g. /tmp/ or /home/user/app.tar.gz',
@@ -291,6 +302,7 @@ export const en: Record<keyof typeof zh, string> = {
291
302
  'common.error': 'Error: {error}',
292
303
  'common.refresh': 'Refresh',
293
304
  'common.confirm': 'Confirm',
305
+ 'error.disabled': 'The SSH plugin is disabled on the host. Please enable it in Settings → Web Plugins → SSH.',
294
306
  }
295
307
 
296
308
  /** Locale key union. */