@linxin666/dsh-ssh 0.3.19 → 0.3.21

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,45 @@
1
+ /**
2
+ * One `document.body` MutationObserver per page, shared by every family
3
+ * plugin bundle.
4
+ *
5
+ * Family plugins that inject at the DOM level must notice when the shell
6
+ * re-renders around their injected node: the sidebar entry rows
7
+ * (sidebar-entry-core), the center-column panel containers
8
+ * (panel-mount-core), and the aggregate shell's column shims. Each consumer
9
+ * used to install its OWN `MutationObserver` on `document.body` with
10
+ * `{ childList: true, subtree: true }`, so a page carrying N family plugins
11
+ * paid N native observers and N callback invocations for EVERY mutation batch
12
+ * the app produced — chat token streaming alone produces many per second, and
13
+ * the count grows with the number of installed plugins.
14
+ *
15
+ * This module keeps exactly ONE body observer per page. The registry lives on
16
+ * `globalThis` under a `Symbol.for` key, so the per-package generated copies
17
+ * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
18
+ * reach the same hub at runtime instead of one hub per module instance.
19
+ * Record subscribers receive the mutations accumulated since the last flush.
20
+ * Consumers that only re-check their DOM use subscribeBodyInvalidations:
21
+ * when all subscribers use that path, the hub retains no mutation records
22
+ * (or detached subtrees) while a background page's animation frames pause.
23
+ * Both paths run at most once per frame. The last subscriber disconnects the
24
+ * observer, cancels the pending frame and releases its records.
25
+ *
26
+ * Failure policy: without a DOM or `MutationObserver` the subscription is a
27
+ * no-op disposer (the same silence the per-consumer observers had), and when
28
+ * `requestAnimationFrame` is unavailable the flush runs synchronously so a
29
+ * subscriber is never silently dropped. A throwing subscriber cannot stop the
30
+ * others.
31
+ */
32
+ /**
33
+ * Subscribe to a coalesced DOM re-check without retaining mutation records.
34
+ * The marked wrapper also works with an older hub, which delivers records
35
+ * that it simply ignores until a page reload picks up the updated hub.
36
+ */
37
+ export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
38
+ /**
39
+ * Subscribe to body-level childList mutations.
40
+ * @param subscriber - called at most once per animation frame with the records
41
+ * collected since the previous flush; must be safe to run repeatedly.
42
+ * @returns the disposer removing this subscriber (and the observer when it was
43
+ * the last one).
44
+ */
45
+ export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
@@ -36,6 +36,12 @@ export declare const zh: {
36
36
  readonly 'hosts.delete': "删除";
37
37
  readonly 'hosts.deleteConfirm': "确定删除主机「{alias}」?将同时停止其隧道。";
38
38
  readonly 'hosts.imported': "解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。";
39
+ readonly 'hosts.proxyBadge': "代理";
40
+ readonly 'import.reason.wildcard': "通配符 pattern,需手动配置";
41
+ readonly 'import.reason.existing': "别名已存在";
42
+ readonly 'import.reason.match': "Match 条件块,不支持";
43
+ readonly 'import.reason.invalid': "字段不合法,无法导入";
44
+ readonly 'import.more': "其余 {count} 个未显示";
39
45
  readonly 'hosts.testing': "连接测试中…";
40
46
  readonly 'hosts.testOk': "连接成功({latency} ms)";
41
47
  readonly 'hosts.testFail': "连接失败:{error}";
@@ -62,7 +68,9 @@ export declare const zh: {
62
68
  readonly 'form.passwordHint': "明文保存在 $DSH_HOME/dsh-ssh.json(本机私有文件)";
63
69
  readonly 'form.passwordRequired': "密码认证需要填写密码。";
64
70
  readonly 'form.proxyJump': "跳板机别名";
65
- readonly 'form.proxyJumpHint': "逗号分隔的本地主机别名,按顺序连接";
71
+ readonly 'form.proxyJumpHint': "逗号分隔的本地主机别名,或 [user@]host[:port] 地址,按顺序连接";
72
+ readonly 'form.proxyCommand': "ProxyCommand";
73
+ readonly 'form.proxyCommandHint': "OpenSSH ProxyCommand:%h 主机、%p 端口、%r 用户、%n 别名;留空或填 none 表示不使用。与跳板机别名互斥。";
66
74
  readonly 'form.description': "备注";
67
75
  readonly 'form.environment': "环境";
68
76
  readonly 'form.environmentHint': "如 development / production";
@@ -1,6 +1,6 @@
1
1
  import type { SshApi } from '../api.ts';
2
2
  import type { TunnelInfo } from '../../protocol.ts';
3
- /** Live-tunnel polling interval while the tab is mounted (ms). */
3
+ /** Live-tunnel polling interval while the tab and page are visible (ms). */
4
4
  export declare const TUNNEL_POLL_MS = 5000;
5
5
  /**
6
6
  * Return `next` only when the tunnel list changed in a user-visible way
@@ -12,6 +12,8 @@ export declare function diffTunnels(prev: TunnelInfo[] | null, next: TunnelInfo[
12
12
  /** Tunnels tab props. */
13
13
  export interface TunnelsTabProps {
14
14
  api: SshApi;
15
+ /** Pause automatic reads while the owning panel is closed, preserving form state. */
16
+ active?: boolean;
15
17
  }
16
18
  /** The tunnels tab. */
17
- export declare function TunnelsTab({ api }: TunnelsTabProps): import("react").JSX.Element;
19
+ export declare function TunnelsTab({ api, active }: TunnelsTabProps): import("react").JSX.Element;
@@ -21,7 +21,7 @@
21
21
  import { type Root } from 'react-dom/client';
22
22
  /** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
23
23
  export interface CenterPanelMountOptions {
24
- /** Render the panel React tree into a root (initial mount, remount, locale refresh). */
24
+ /** Render the panel tree (first open, remount while open, locale refresh). */
25
25
  render: (root: Root) => void;
26
26
  /** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
27
27
  viewDatasetKey: string;
@@ -1,18 +1,3 @@
1
- /**
2
- * Shared sidebar entry injection core.
3
- *
4
- * dsh's sidebar shell exposes no slot an external plugin can register into,
5
- * so the entry row is injected between the shell's New Session button and the
6
- * workspace browser. The injection self-heals: a MutationObserver watches the
7
- * sidebar root and re-inserts the row whenever a React re-render displaces it
8
- * (re-insertion happens in the same frame, before paint, so no flicker).
9
- *
10
- * The row is plain DOM (no React tree) so it can never disturb the shell's
11
- * reconciliation; the view it toggles is a separate root owned by the caller.
12
- *
13
- * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
14
- * edit the shared source and re-run the sync instead of editing a copy.
15
- */
16
1
  /** Per-package configuration for one sidebar entry row. */
17
2
  export interface SidebarEntryOptions {
18
3
  /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
@@ -62,10 +62,30 @@ export declare function appendOutput(target: {
62
62
  text: string;
63
63
  truncated: boolean;
64
64
  }, chunk: Buffer, maxBytes: number): void;
65
+ /**
66
+ * Parse an OpenSSH jump hop written as an address: `[user@]host[:port]`
67
+ * (an IPv6 literal must be bracketed). Returns undefined when the value is not
68
+ * an address at all.
69
+ */
70
+ export declare function parseJumpSpec(spec: string): {
71
+ host: string;
72
+ port?: number;
73
+ user?: string;
74
+ } | undefined;
75
+ /**
76
+ * Resolve one ProxyJump spec: an alias configured in this plugin wins, and any
77
+ * other value is read as an OpenSSH address whose credentials come from the
78
+ * target entry (an ad-hoc hop has no stored auth of its own).
79
+ */
80
+ export declare function resolveHop(engine: PoolEngine, entry: SshHostEntry, spec: string): SshHostEntry;
65
81
  /**
66
82
  * Build one full jump chain for an entry: hop clients connected through in
67
83
  * order, each forwarding a stream to the next destination, ending with the
68
84
  * target client. Shared by the pool and standalone shell sessions.
85
+ *
86
+ * A ProxyCommand is the transport that reaches its own host, so the entry's
87
+ * own command seeds the chain when there is no jump chain, and the first hop's
88
+ * command is used when the hop carries one.
69
89
  */
70
90
  export declare function connectChain(engine: PoolEngine, entry: SshHostEntry, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<{
71
91
  client: Client;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * OpenSSH-compatible ProxyCommand transport: run the command with the user's
3
+ * shell and expose its stdio as a Duplex that ssh2 accepts as `sock`.
4
+ *
5
+ * The command comes verbatim from the user's own 0600 store file and runs with
6
+ * the DSH host process's privileges — the same trust the `ssh(1)` client gives
7
+ * the identical line in `~/.ssh/config`.
8
+ */
9
+ import { type ChildProcess } from 'node:child_process';
10
+ import { Duplex } from 'node:stream';
11
+ /** Token source for one host: the values %h / %p / %r / %n expand to. */
12
+ export interface ProxyTarget {
13
+ host: string;
14
+ port: number;
15
+ user: string;
16
+ /** The name the user addresses this host by (OpenSSH's %n). */
17
+ alias: string;
18
+ }
19
+ /** Expand the OpenSSH ProxyCommand tokens; unknown %X sequences stay verbatim. */
20
+ export declare function expandProxyTokens(command: string, target: ProxyTarget): string;
21
+ /** One running proxy command: its transport plus the process behind it. */
22
+ export interface ProxyCommandProcess {
23
+ /** The duplex transport handed to ssh2 as `sock`. */
24
+ stream: Duplex;
25
+ /** The spawned shell; killing the stream kills it (and its process group). */
26
+ child: ChildProcess;
27
+ }
28
+ /**
29
+ * Start one ProxyCommand and bridge its stdio into a Duplex.
30
+ *
31
+ * Failure modes are reported through the stream: a spawn error, or an exit
32
+ * before the SSH handshake finished, destroys it with the exit status and the
33
+ * tail of the command's stderr instead of letting ssh2 wait for its own
34
+ * `readyTimeout`.
35
+ */
36
+ export declare function startProxyCommand(rawCommand: string, target: ProxyTarget): ProxyCommandProcess;
@@ -39,7 +39,7 @@ export interface Config {
39
39
  }
40
40
  export declare const Config: z<Config>;
41
41
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
42
- export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B SSH \u8FD0\u7EF4\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CSSH\u300D\u5165\u53E3\uFF1B\u5728 dsh-web \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-ssh\uFF09\u7EDF\u4E00\u7EF4\u62A4\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 $DSH_HOME/dsh-ssh.json\uFF08\u9ED8\u8BA4 ~/.dsh\uFF09\uFF08\u53EF\u4ECE ~/.ssh/config \u5BFC\u5165\uFF09\uFF1B\u6301\u4E45\u8FDE\u63A5\u6C60\u590D\u7528\u957F\u8FDE\u63A5\uFF08\u7A7A\u95F2 30 \u5206\u949F\u81EA\u52A8\u65AD\u5F00\uFF09\uFF1Bssh_list \u5217\u51FA\u4E3B\u673A\u3001ssh_exec \u6267\u884C\u8FDC\u7A0B\u547D\u4EE4\u3001ssh_upload/ssh_download \u4F20\u8F93\u6587\u4EF6\u3001ssh_tunnel \u672C\u5730\u7AEF\u53E3\u8F6C\u53D1\uFF08\u8BBF\u95EE\u8FDC\u7A0B\u6570\u636E\u5E93/\u5185\u7F51\u670D\u52A1\uFF09\u3001ssh_cluster \u6309 aliases/environment/tags \u81F3\u5C11\u4E00\u79CD\u975E\u7A7A selector \u7B5B\u9009\u540E\u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1B\u652F\u6301\u5BC6\u94A5/\u5BC6\u7801/ssh-agent \u8BA4\u8BC1\u3001passphrase \u5BC6\u94A5\u4E0E ProxyJump \u8DF3\u677F\u673A\uFF1BWeb \u7EC8\u7AEF\u8D70 WebSocket\u3002\u9650\u5236\uFF1A\u4E3B\u673A\u64CD\u4F5C\u7531\u7528\u6237\u5728 GUI \u4E2D\u914D\u7F6E\u540E agent \u65B9\u53EF\u4F7F\u7528\uFF1B\u5BC6\u7801\u4EE5\u660E\u6587\u5B58\u5728\u7528\u6237\u4E3B\u76EE\u5F55\u79C1\u6709\u6587\u4EF6\uFF08\u6743\u9650 0600\uFF09\uFF1B\u547D\u4EE4\u8F93\u51FA\u539F\u6837\u8FD4\u56DE\u3001\u53EF\u80FD\u542B\u654F\u611F\u4FE1\u606F\uFF1B\u65AD\u7EBF\u91CD\u8FDE\u53EF\u80FD\u91CD\u653E\u975E\u5E42\u7B49\u547D\u4EE4\uFF1B\u4F20\u8F93/\u6267\u884C\u6D88\u8017\u771F\u5B9E\u8FDC\u7A0B\u8D44\u6E90\uFF0C\u5148\u786E\u8BA4\u518D\u64CD\u4F5C\u3002\u8DEF\u5F84\u533A\u5206\uFF1A\u672C\u673A\uFF08dsh host\uFF09\u4E0A\u7684\u6587\u4EF6\u4E0E\u547D\u4EE4\u4E00\u5F8B\u7528\u672C\u5730\u5DE5\u5177\uFF08read / write / edit / bash\uFF09\uFF0Cssh_* \u5DE5\u5177\u53EA\u9488\u5BF9\u8FDC\u7A0B\u4E3B\u673A\u4E0A\u7684\u8DEF\u5F84\u3002\u7528\u6237\u63D0\u5230\u300CSSH / \u8FDC\u7A0B\u670D\u52A1\u5668 / \u670D\u52A1\u5668\u64CD\u4F5C / \u8DF3\u677F\u673A / \u96A7\u9053 / \u90E8\u7F72 / \u4E0A\u4F20\u4E0B\u8F7D\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
42
+ export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B SSH \u8FD0\u7EF4\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CSSH\u300D\u5165\u53E3\uFF1B\u5728 dsh-web \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-ssh\uFF09\u7EDF\u4E00\u7EF4\u62A4\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 $DSH_HOME/dsh-ssh.json\uFF08\u9ED8\u8BA4 ~/.dsh\uFF09\uFF08\u53EF\u4ECE ~/.ssh/config \u5BFC\u5165\uFF09\uFF1B\u6301\u4E45\u8FDE\u63A5\u6C60\u590D\u7528\u957F\u8FDE\u63A5\uFF08\u7A7A\u95F2 30 \u5206\u949F\u81EA\u52A8\u65AD\u5F00\uFF09\uFF1Bssh_list \u5217\u51FA\u4E3B\u673A\u3001ssh_exec \u6267\u884C\u8FDC\u7A0B\u547D\u4EE4\u3001ssh_upload/ssh_download \u4F20\u8F93\u6587\u4EF6\u3001ssh_tunnel \u672C\u5730\u7AEF\u53E3\u8F6C\u53D1\uFF08\u8BBF\u95EE\u8FDC\u7A0B\u6570\u636E\u5E93/\u5185\u7F51\u670D\u52A1\uFF09\u3001ssh_cluster \u6309 aliases/environment/tags \u81F3\u5C11\u4E00\u79CD\u975E\u7A7A selector \u7B5B\u9009\u540E\u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1B\u652F\u6301\u5BC6\u94A5/\u5BC6\u7801/ssh-agent \u8BA4\u8BC1\u3001passphrase \u5BC6\u94A5\u3001ProxyJump \u8DF3\u677F\u673A\uFF08\u522B\u540D\u6216 [user@]host[:port] \u5730\u5740\uFF09\u4E0E OpenSSH \u8BED\u4E49\u7684 ProxyCommand\uFF08\u8DF3\u8FC7\u5821\u5792\u673A\u5BA2\u6237\u7AEF\u573A\u666F\uFF0C\u53EA\u80FD\u7531\u7528\u6237\u5728 GUI \u914D\u7F6E\uFF0Cagent \u4E0D\u53EF\u5199\uFF09\uFF1BWeb \u7EC8\u7AEF\u8D70 WebSocket\u3002\u9650\u5236\uFF1A\u4E3B\u673A\u64CD\u4F5C\u7531\u7528\u6237\u5728 GUI \u4E2D\u914D\u7F6E\u540E agent \u65B9\u53EF\u4F7F\u7528\uFF1B\u5BC6\u7801\u4EE5\u660E\u6587\u5B58\u5728\u7528\u6237\u4E3B\u76EE\u5F55\u79C1\u6709\u6587\u4EF6\uFF08\u6743\u9650 0600\uFF09\uFF1B\u547D\u4EE4\u8F93\u51FA\u539F\u6837\u8FD4\u56DE\u3001\u53EF\u80FD\u542B\u654F\u611F\u4FE1\u606F\uFF1B\u65AD\u7EBF\u91CD\u8FDE\u53EF\u80FD\u91CD\u653E\u975E\u5E42\u7B49\u547D\u4EE4\uFF1B\u4F20\u8F93/\u6267\u884C\u6D88\u8017\u771F\u5B9E\u8FDC\u7A0B\u8D44\u6E90\uFF0C\u5148\u786E\u8BA4\u518D\u64CD\u4F5C\u3002\u8DEF\u5F84\u533A\u5206\uFF1A\u672C\u673A\uFF08dsh host\uFF09\u4E0A\u7684\u6587\u4EF6\u4E0E\u547D\u4EE4\u4E00\u5F8B\u7528\u672C\u5730\u5DE5\u5177\uFF08read / write / edit / bash\uFF09\uFF0Cssh_* \u5DE5\u5177\u53EA\u9488\u5BF9\u8FDC\u7A0B\u4E3B\u673A\u4E0A\u7684\u8DEF\u5F84\u3002\u7528\u6237\u63D0\u5230\u300CSSH / \u8FDC\u7A0B\u670D\u52A1\u5668 / \u670D\u52A1\u5668\u64CD\u4F5C / \u8DF3\u677F\u673A / \u96A7\u9053 / \u90E8\u7F72 / \u4E0A\u4F20\u4E0B\u8F7D\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
43
43
  /**
44
44
  * Mount the SSH engine, routes, tools, and announcement.
45
45
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
@@ -33,6 +33,13 @@ export interface SshHostEntry {
33
33
  };
34
34
  /** Jump chain: local aliases connected through in order (ProxyJump). */
35
35
  proxyJump: string[];
36
+ /**
37
+ * OpenSSH-compatible ProxyCommand: a shell command whose stdio becomes the
38
+ * connection transport. Tokens %h (host) / %p (port) / %r (user) / %n (alias)
39
+ * expand before exec and %% is a literal percent; an empty value or 'none'
40
+ * means "no proxy command". Mutually exclusive with a non-empty proxyJump.
41
+ */
42
+ proxyCommand?: string;
36
43
  /** Free-form note. */
37
44
  description?: string;
38
45
  /** Deployment environment label (development / production / ...). */
@@ -54,6 +61,8 @@ export interface SshHostSummary {
54
61
  /** Whether the key path exists on the host machine (key auth only). */
55
62
  keyReady: boolean;
56
63
  proxyJump: string[];
64
+ /** Configured ProxyCommand, echoed to the browser so the form can edit it. */
65
+ proxyCommand?: string;
57
66
  description?: string;
58
67
  environment?: string;
59
68
  tags: string[];
@@ -132,18 +141,31 @@ export interface HostPayload {
132
141
  */
133
142
  auth?: SshHostEntry['auth'];
134
143
  proxyJump?: string[];
144
+ /**
145
+ * ProxyCommand to store. An empty string or 'none' clears a stored value;
146
+ * omitting the key leaves it untouched (PATCH semantics).
147
+ */
148
+ proxyCommand?: string;
135
149
  description?: string;
136
150
  environment?: string;
137
151
  tags?: string[];
138
152
  location?: string;
139
153
  }
154
+ /** Why one ssh_config Host block was not imported. */
155
+ export type ImportSkipReason = 'wildcard' | 'existing' | 'match' | 'invalid';
156
+ /** One skipped ssh_config block and the reason it could not be imported. */
157
+ export interface ImportSkipBlock {
158
+ /** Host pattern (or '`Match …`' label) that was skipped. */
159
+ name: string;
160
+ reason: ImportSkipReason;
161
+ }
140
162
  /** Import outcome from ~/.ssh/config. */
141
163
  export interface ImportResult {
142
164
  parsed: number;
143
165
  added: number;
144
166
  skipped: number;
145
- /** Aliases that failed to map (wildcard patterns, missing HostName, ...). */
146
- skippedNames: string[];
167
+ /** Every skipped block with its reason, in scan order and deduplicated by name. */
168
+ skippedBlocks: ImportSkipBlock[];
147
169
  }
148
170
  /** JSON error body used by every route. */
149
171
  export interface ApiErrorBody {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Minimal `~/.ssh/config` reader for the one-shot host import: Host blocks with
3
+ * `Include` expansion and explicit skip reasons. Values stay raw strings; the
4
+ * store maps them onto a HostPayload.
5
+ *
6
+ * Deliberate simplifications (documented in the package README):
7
+ * - `Match` blocks are skipped — their options are conditional and must never
8
+ * merge into the Host block above them.
9
+ * - A multi-pattern `Host a b` line only exposes its first pattern.
10
+ * - Include pathnames accept `~`, globs, and several whitespace-separated
11
+ * pathnames, but not environment variables, `%` tokens, or quoting.
12
+ * - Relative Include pathnames resolve against the directory of the config
13
+ * file the import started from (OpenSSH: `~/.ssh` for a user config).
14
+ * - A file that already contributed lines is never read twice, which doubles
15
+ * as the Include cycle guard.
16
+ */
17
+ import type { ImportSkipBlock } from './protocol.ts';
18
+ /** One parsed Host block. */
19
+ export interface SshBlock {
20
+ /** Raw Host line value (may hold several whitespace-separated patterns). */
21
+ pattern: string;
22
+ /** Lowercased option key/value pairs; the last occurrence of a key wins. */
23
+ props: Record<string, string>;
24
+ }
25
+ /** One config file read, with every Include expanded in place. */
26
+ export interface SshConfigRead {
27
+ blocks: SshBlock[];
28
+ skipped: ImportSkipBlock[];
29
+ }
30
+ /**
31
+ * Parse one ssh_config file (Include expanded) into Host blocks plus the
32
+ * `Match` blocks that were deliberately skipped.
33
+ */
34
+ export declare function readSshConfigBlocks(configPath: string): SshConfigRead;
@@ -42,12 +42,15 @@ export declare class HostStore {
42
42
  delete(alias: string): void;
43
43
  /**
44
44
  * Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
45
- * pattern and a HostName become entries (key auth via IdentityFile, jump
46
- * hosts via ProxyJump). Existing aliases are skipped.
45
+ * pattern become entries (a missing HostName falls back to the pattern
46
+ * itself; key auth via IdentityFile; jump hosts via ProxyJump; ProxyCommand
47
+ * kept verbatim). `Include` files are expanded, and `Match` blocks,
48
+ * wildcard patterns, and aliases that already exist are skipped with a
49
+ * reason instead of silently disappearing.
47
50
  * @returns import statistics.
48
51
  */
49
52
  importFromSshConfig(): ImportResult;
50
- private skippedNames;
53
+ private skippedBlocks;
51
54
  /**
52
55
  * Last parsed store keyed by file identity. list/find ride every acquire
53
56
  * and GUI refresh; re-reading and re-parsing the whole file each call is
@@ -59,5 +62,10 @@ export declare class HostStore {
59
62
  }
60
63
  /** Normalize an agent endpoint for storage: trim, expand `~`, and resolve the SSH_AUTH_SOCK token. */
61
64
  export declare function normalizeAgentPath(agentPath: string | undefined): string | undefined;
65
+ /**
66
+ * Normalize a ProxyCommand for storage: trim, and treat an empty value or
67
+ * OpenSSH's `none` keyword as "no proxy command" (an explicit clear).
68
+ */
69
+ export declare function normalizeProxyCommand(raw: string | undefined): string | undefined;
62
70
  /** Expand a leading `~` in a filesystem path. */
63
71
  export declare function expandHome(path: string): string;
@@ -4,6 +4,30 @@
4
4
  * is immediately operable by any agent, and vice versa.
5
5
  */
6
6
  import type { SshEngine } from './engine.ts';
7
+ import type { SshAuthKind, SshHostSummary } from './protocol.ts';
8
+ /**
9
+ * One host row for the agent surface. The ProxyCommand STRING is deliberately
10
+ * projected to a boolean: the command may embed credentials for a bastion
11
+ * client, and the model only needs to know that the host goes through one.
12
+ */
13
+ export interface AgentHostRow {
14
+ alias: string;
15
+ host: string;
16
+ port: number;
17
+ user: string;
18
+ auth: SshAuthKind;
19
+ keyReady: boolean;
20
+ proxyJump: string[];
21
+ proxyCommandConfigured: boolean;
22
+ description?: string;
23
+ environment?: string;
24
+ tags: string[];
25
+ location?: string;
26
+ createdAt: number;
27
+ updatedAt: number;
28
+ }
29
+ /** Project one summary onto the agent-facing row shape. */
30
+ export declare function toAgentHostRow(host: SshHostSummary): AgentHostRow;
7
31
  /** The host-list tool. */
8
32
  export declare function sshListTool(engine: SshEngine): import("@deepseek-ai/dsh-tools").ToolDefinition;
9
33
  /** The command-execution tool. */
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.19",
4
+ "version": "0.3.21",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dsh": {
28
28
  "engines": {
29
- "dsh": ">=0.1.2-rc.1"
29
+ "dsh": ">=0.1.5-rc.1"
30
30
  },
31
31
  "bundle": {
32
32
  "patch": "./cordis.patch.yml"
@@ -52,18 +52,18 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@deepseek-ai/cordis": "^4.0.2",
55
- "@deepseek-ai/dsh-client-connection": "^0.1.2-rc.1",
56
- "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
57
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
58
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
59
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
60
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
61
- "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
62
- "@deepseek-ai/dsh-scope": "^0.1.2-rc.1",
63
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
64
- "@deepseek-ai/dsh-system-prompt": "^0.1.2-rc.1",
65
- "@deepseek-ai/dsh-timeout": "^0.1.2-rc.1",
66
- "@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
55
+ "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
56
+ "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
57
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
58
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
59
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
60
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
61
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
62
+ "@deepseek-ai/dsh-scope": "^0.1.5-rc.1",
63
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
64
+ "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.1",
65
+ "@deepseek-ai/dsh-timeout": "^0.1.5-rc.1",
66
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.1",
67
67
  "@types/node": "^22.20.0",
68
68
  "@types/react": "~18.3.1",
69
69
  "@types/react-dom": "^18.3.5",
@@ -0,0 +1,133 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/body-mutations.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * One `document.body` MutationObserver per page, shared by every family
4
+ * plugin bundle.
5
+ *
6
+ * Family plugins that inject at the DOM level must notice when the shell
7
+ * re-renders around their injected node: the sidebar entry rows
8
+ * (sidebar-entry-core), the center-column panel containers
9
+ * (panel-mount-core), and the aggregate shell's column shims. Each consumer
10
+ * used to install its OWN `MutationObserver` on `document.body` with
11
+ * `{ childList: true, subtree: true }`, so a page carrying N family plugins
12
+ * paid N native observers and N callback invocations for EVERY mutation batch
13
+ * the app produced — chat token streaming alone produces many per second, and
14
+ * the count grows with the number of installed plugins.
15
+ *
16
+ * This module keeps exactly ONE body observer per page. The registry lives on
17
+ * `globalThis` under a `Symbol.for` key, so the per-package generated copies
18
+ * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
19
+ * reach the same hub at runtime instead of one hub per module instance.
20
+ * Record subscribers receive the mutations accumulated since the last flush.
21
+ * Consumers that only re-check their DOM use subscribeBodyInvalidations:
22
+ * when all subscribers use that path, the hub retains no mutation records
23
+ * (or detached subtrees) while a background page's animation frames pause.
24
+ * Both paths run at most once per frame. The last subscriber disconnects the
25
+ * observer, cancels the pending frame and releases its records.
26
+ *
27
+ * Failure policy: without a DOM or `MutationObserver` the subscription is a
28
+ * no-op disposer (the same silence the per-consumer observers had), and when
29
+ * `requestAnimationFrame` is unavailable the flush runs synchronously so a
30
+ * subscriber is never silently dropped. A throwing subscriber cannot stop the
31
+ * others.
32
+ */
33
+
34
+ /** The page-wide hub: one observer plus its subscribers and pending records. */
35
+ interface BodyMutationHub {
36
+ observer: MutationObserver
37
+ subscribers: Set<(records: MutationRecord[]) => void>
38
+ pending: MutationRecord[]
39
+ scheduled: boolean
40
+ frame?: number
41
+ }
42
+
43
+ /** Cross-bundle registry key; `Symbol.for` so every module copy agrees. */
44
+ const HUB_KEY = Symbol.for('dsh-web.body-mutation-hub')
45
+ const INVALIDATION_ONLY = Symbol.for('dsh-web.body-mutation-invalidation')
46
+ type Subscriber = ((records: MutationRecord[]) => void) & { [INVALIDATION_ONLY]?: true }
47
+
48
+ function needsRecords(subscribers: Set<(records: MutationRecord[]) => void>): boolean {
49
+ for (const listener of subscribers) {
50
+ if (!(listener as Subscriber)[INVALIDATION_ONLY]) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * Subscribe to a coalesced DOM re-check without retaining mutation records.
57
+ * The marked wrapper also works with an older hub, which delivers records
58
+ * that it simply ignores until a page reload picks up the updated hub.
59
+ */
60
+ export function subscribeBodyInvalidations(subscriber: () => void): () => void {
61
+ const listener: Subscriber = () => { subscriber() }
62
+ listener[INVALIDATION_ONLY] = true
63
+ return subscribeBodyMutations(listener)
64
+ }
65
+
66
+ /**
67
+ * Subscribe to body-level childList mutations.
68
+ * @param subscriber - called at most once per animation frame with the records
69
+ * collected since the previous flush; must be safe to run repeatedly.
70
+ * @returns the disposer removing this subscriber (and the observer when it was
71
+ * the last one).
72
+ */
73
+ export function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void {
74
+ if (typeof globalThis === 'undefined' || typeof document === 'undefined') return () => {}
75
+ if (typeof MutationObserver !== 'function') return () => {}
76
+ const registry = globalThis as unknown as Record<symbol, unknown>
77
+ let hub = registry[HUB_KEY] as BodyMutationHub | undefined
78
+ if (hub === undefined) {
79
+ const subscribers = new Set<(records: MutationRecord[]) => void>()
80
+ const created: BodyMutationHub = {
81
+ observer: undefined as unknown as MutationObserver,
82
+ subscribers,
83
+ pending: [],
84
+ scheduled: false,
85
+ }
86
+ const flush = (): void => {
87
+ created.frame = undefined
88
+ created.scheduled = false
89
+ const batch = created.pending
90
+ created.pending = []
91
+ for (const listener of [...subscribers]) {
92
+ if (!subscribers.has(listener)) continue
93
+ try {
94
+ listener(batch)
95
+ } catch {
96
+ // One subscriber must never break the others; the next flush retries it.
97
+ }
98
+ }
99
+ }
100
+ const schedule = (): void => {
101
+ if (created.scheduled) return
102
+ created.scheduled = true
103
+ if (typeof requestAnimationFrame === 'function') created.frame = requestAnimationFrame(flush)
104
+ else flush()
105
+ }
106
+ created.observer = new MutationObserver((records) => {
107
+ if (needsRecords(subscribers)) {
108
+ for (const record of records) created.pending.push(record)
109
+ }
110
+ schedule()
111
+ })
112
+ created.observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true })
113
+ registry[HUB_KEY] = created
114
+ hub = created
115
+ }
116
+ const active = hub
117
+ active.subscribers.add(subscriber)
118
+ let subscribed = true
119
+ return () => {
120
+ if (!subscribed) return
121
+ subscribed = false
122
+ active.subscribers.delete(subscriber)
123
+ if (!needsRecords(active.subscribers)) active.pending = []
124
+ if (active.subscribers.size === 0 && registry[HUB_KEY] === active) {
125
+ active.observer.disconnect()
126
+ if (active.frame !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(active.frame)
127
+ active.frame = undefined
128
+ active.pending = []
129
+ active.scheduled = false
130
+ delete registry[HUB_KEY]
131
+ }
132
+ }
133
+ }
@@ -38,6 +38,12 @@ export const zh = {
38
38
  'hosts.delete': '删除',
39
39
  'hosts.deleteConfirm': '确定删除主机「{alias}」?将同时停止其隧道。',
40
40
  'hosts.imported': '解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。',
41
+ 'hosts.proxyBadge': '代理',
42
+ 'import.reason.wildcard': '通配符 pattern,需手动配置',
43
+ 'import.reason.existing': '别名已存在',
44
+ 'import.reason.match': 'Match 条件块,不支持',
45
+ 'import.reason.invalid': '字段不合法,无法导入',
46
+ 'import.more': '其余 {count} 个未显示',
41
47
  'hosts.testing': '连接测试中…',
42
48
  'hosts.testOk': '连接成功({latency} ms)',
43
49
  'hosts.testFail': '连接失败:{error}',
@@ -65,7 +71,9 @@ export const zh = {
65
71
  'form.passwordHint': '明文保存在 $DSH_HOME/dsh-ssh.json(本机私有文件)',
66
72
  'form.passwordRequired': '密码认证需要填写密码。',
67
73
  'form.proxyJump': '跳板机别名',
68
- 'form.proxyJumpHint': '逗号分隔的本地主机别名,按顺序连接',
74
+ 'form.proxyJumpHint': '逗号分隔的本地主机别名,或 [user@]host[:port] 地址,按顺序连接',
75
+ 'form.proxyCommand': 'ProxyCommand',
76
+ 'form.proxyCommandHint': 'OpenSSH ProxyCommand:%h 主机、%p 端口、%r 用户、%n 别名;留空或填 none 表示不使用。与跳板机别名互斥。',
69
77
  'form.description': '备注',
70
78
  'form.environment': '环境',
71
79
  'form.environmentHint': '如 development / production',
@@ -192,6 +200,12 @@ export const en: Record<keyof typeof zh, string> = {
192
200
  'hosts.delete': 'Delete',
193
201
  'hosts.deleteConfirm': 'Delete host "{alias}"? Its tunnels will be stopped.',
194
202
  'hosts.imported': 'Parsed {parsed} blocks, added {added} hosts, skipped {skipped}.',
203
+ 'hosts.proxyBadge': 'proxy',
204
+ 'import.reason.wildcard': 'wildcard pattern — configure it by hand',
205
+ 'import.reason.existing': 'alias already configured',
206
+ 'import.reason.match': 'Match block — not supported',
207
+ 'import.reason.invalid': 'invalid fields — not imported',
208
+ 'import.more': '{count} more not shown',
195
209
  'hosts.testing': 'Testing connection…',
196
210
  'hosts.testOk': 'Connected ({latency} ms)',
197
211
  'hosts.testFail': 'Connection failed: {error}',
@@ -218,7 +232,9 @@ export const en: Record<keyof typeof zh, string> = {
218
232
  'form.passwordHint': 'Stored in plaintext in $DSH_HOME/dsh-ssh.json (private local file)',
219
233
  'form.passwordRequired': 'Password auth requires a password.',
220
234
  'form.proxyJump': 'Jump host aliases',
221
- 'form.proxyJumpHint': 'Comma-separated local aliases, chained in order',
235
+ 'form.proxyJumpHint': 'Comma-separated local aliases or [user@]host[:port] addresses, chained in order',
236
+ 'form.proxyCommand': 'ProxyCommand',
237
+ 'form.proxyCommandHint': 'OpenSSH ProxyCommand: %h host, %p port, %r user, %n alias; empty or none disables it. Mutually exclusive with the jump chain.',
222
238
  'form.description': 'Description',
223
239
  'form.environment': 'Environment',
224
240
  'form.environmentHint': 'e.g. development / production',
@@ -31,6 +31,7 @@ interface FormState {
31
31
  password: string
32
32
  agentPath: string
33
33
  proxyJump: string
34
+ proxyCommand: string
34
35
  description: string
35
36
  environment: string
36
37
  tags: string
@@ -55,6 +56,7 @@ function blankOf(editing: SshHostSummary | null | undefined): FormState {
55
56
  password: '',
56
57
  agentPath: '',
57
58
  proxyJump: (editing?.proxyJump ?? []).join(', '),
59
+ proxyCommand: editing?.proxyCommand ?? '',
58
60
  description: editing?.description ?? '',
59
61
  environment: editing?.environment ?? '',
60
62
  tags: (editing?.tags ?? []).join(', '),
@@ -121,6 +123,9 @@ export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialo
121
123
  user,
122
124
  auth,
123
125
  proxyJump: splitList(form.proxyJump),
126
+ // Always sent (never omitted): an empty value is the explicit clear,
127
+ // because the API cannot express "remove this field" any other way.
128
+ proxyCommand: form.proxyCommand.trim(),
124
129
  description: form.description.trim() === '' ? undefined : form.description.trim(),
125
130
  environment: form.environment.trim() === '' ? undefined : form.environment.trim(),
126
131
  tags: splitList(form.tags),
@@ -212,6 +217,16 @@ export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialo
212
217
  <input className={css.input} value={form.proxyJump} onChange={event => { set('proxyJump', event.target.value) }} />
213
218
  <span className={css.hint}>{tt('form.proxyJumpHint')}</span>
214
219
  </label>
220
+ <label className={css.field}>
221
+ <span className={css.fieldLabel}>{tt('form.proxyCommand')}</span>
222
+ <input
223
+ className={css.input}
224
+ value={form.proxyCommand}
225
+ placeholder="corp-vpn proxy %h %p %r"
226
+ onChange={event => { set('proxyCommand', event.target.value) }}
227
+ />
228
+ <span className={css.hint}>{tt('form.proxyCommandHint')}</span>
229
+ </label>
215
230
  <div className={css.formRow}>
216
231
  <label className={css.field}>
217
232
  <span className={css.fieldLabel}>{tt('form.environment')}</span>