dsh-remote 0.8.35 → 0.8.36

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/README.en.md CHANGED
@@ -47,7 +47,7 @@ The native **"Add workspace"** flow — a centered modal with two tabs, opening
47
47
 
48
48
  ## Features
49
49
 
50
- ![Capabilities at a glance — multi-machine SSH, live alias resolution, the two-tab picker, three-way sync, remote @ completion, audit, port forwarding, sidebar editing, self-update, and the 20 rw_* tools](docs/shots/features.png)
50
+ ![Capabilities at a glance — multi-machine SSH, live alias resolution, the two-tab picker, three-way sync, remote @ completion, audit, port forwarding, sidebar editing, self-update, and the 21 rw_* tools](docs/shots/features.png)
51
51
 
52
52
  The image above is the overview. What follows is only what the image does not make obvious.
53
53
 
@@ -115,6 +115,58 @@ Since **v0.8.18** it installs and mounts only itself; the Web sidebar ([dsh-bett
115
115
 
116
116
  > **Remote context is session-scoped:** the "Remote workspace" system-prompt section appears only when the current session's workspace is a remote mirror; local sessions are unaffected and the model will not call `rw_*` on its own.
117
117
 
118
+ ## Open a remote machine's DSH Web UI locally
119
+
120
+ The `dsh web` running on a remote machine can also be opened **as a page on this machine** (issue #46). The direction stays "this machine dials out":
121
+
122
+ > **The remote opens no port.** DSH deliberately refuses `--host 0.0.0.0` (it would put remote
123
+ > code execution on the network), so the correct shape is: SSH out, start a `dsh web` on the remote
124
+ > that listens on **127.0.0.1 only**, and carry its port back over the SSH connection to a loopback
125
+ > port here.
126
+
127
+ Usage: Settings → Remote workspaces → **"Remote DSH Web UI"** → pick a machine → **Connect & open**.
128
+ A new tab opens showing that remote machine's full DSH interface (chat, tool tree, settings).
129
+
130
+ - The URL looks like `http://127.0.0.1:3088/?token=…`. That token is a one-time credential the remote
131
+ process prints at startup and is used once, on this machine, by that redirect: DSH exchanges it for a
132
+ 30-day signed cookie and lands on a clean `/`. **Do not share that tokenized URL.**
133
+ - "Disconnect" only closes the tunnel and deliberately does **not** kill the remote process (it may be
134
+ an instance you use elsewhere). "Disconnect & stop remote" also ends the process — but only one this
135
+ plugin started.
136
+ - If the remote already runs an interface (for example one you started in a terminal), pick the machine
137
+ and paste its `http://127.0.0.1:<port>/?token=…` into the field, then "Attach to running instance" —
138
+ no second instance will be started.
139
+ - When the remote `dsh` is not on the SSH login PATH, point `webAttachCommand` at its absolute path; to
140
+ keep an attached instance out of the remote user's own sessions and settings, point `webAttachDshHome`
141
+ at a scratch directory.
142
+
143
+ ### Cannot connect? Check the environment, then deploy in one click
144
+
145
+ **A failed connect is usually not the plugin's fault: the remote dsh version or environment is wrong.**
146
+ The classic case: dsh 0.1.0-rc.6 depends on `node-pty@1.1.0`, whose published package ships
147
+ **no `linux-x64` prebuild**, so `dsh web` cannot start on Linux at all. Previously all you saw was
148
+ "the remote DSH did not report a startup token", with no way to tell why.
149
+
150
+ Settings → Remote workspaces → **"Remote dsh health check & deploy"**:
151
+
152
+ 1. **Check (read-only)** — probes the remote's platform / node / npm / dsh version / **whether the
153
+ native module can load** / whether it knows `--no-open` / proxy and npm registry, and reports a
154
+ verdict with a suggested fix. It **writes nothing**, so it is safe to run freely.
155
+ 2. **Deploy & verify** — appears only when step 1 says it is both needed and possible. It installs into
156
+ a **private directory** on the remote (default `~/.dsh-remote/dsh`): **no system directory, no PATH
157
+ change, no replacing the version you use**; deleting that directory is a complete rollback. It then
158
+ verifies step by step (binary / native module / `web` subcommand) and, on success, **records the
159
+ resulting dsh path per machine** so later connects use it.
160
+ 3. **Let the AI investigate** — appears only after a failure. It creates a session and hands the problem
161
+ to the built-in `dsh-remote-deploy` skill, which covers the long tail the deterministic path cannot
162
+ express (no npm, sudo-only systems, proxies, internal mirrors, Windows remotes). **Nothing is created
163
+ unless you click it**, because it spends model budget.
164
+
165
+ Related config: `webInstallPrefix` (where; default `$HOME/.dsh-remote/dsh`), `webInstallVersion`
166
+ (which version; default `0.1.5-rc.2`, the first that can start the web surface on Linux), and
167
+ `webInstallRegistry` (npm registry; **empty keeps the remote's own config**, so an internal mirror is
168
+ never overridden).
169
+
118
170
  ## CLI defaults (optional)
119
171
 
120
172
  Provide a default machine in `cordis.patch.yml`:
@@ -210,6 +262,13 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
210
262
  | `updateMode` | string | `auto` | self-update behaviour: `auto` checks npm on load and every 6h and applies a newer release; `manual` only checks when asked; `off` disables checks. **Default changed to `auto` in 0.8.27** — safe because 0.8.24 added the host-half hot swap |
211
263
  | `updateCheckIntervalMs` | int | 21600000 (6h) | how often `auto` mode checks npm (floor 60000) |
212
264
  | `updateAutoReload` | bool | `true` | hot-swap the host half after an update lands; `false` defers it to the next process start and the panel reports `pendingReload` |
265
+ | `webAttachPortStart` | int | `3088` | first local port for the "Remote DSH Web UI" tunnel (later ports are tried when taken; loopback only) |
266
+ | `webAttachCommand` | string | `dsh` | command used to start `dsh web` on the remote; change it when the remote `dsh` is not on the SSH login PATH |
267
+ | `webAttachDshHome` | string | `''` | `DSH_HOME` exported on the remote when attaching; empty reuses the remote user's own harness home |
268
+ | `webAttachWaitSeconds` | int | `45` | how long to wait for a freshly started remote `dsh web` to print its launch token |
269
+ | `webInstallPrefix` | string | `''` | where an automatic deployment installs dsh; empty means `$HOME/.dsh-remote/dsh` (no system directory, no PATH change) |
270
+ | `webInstallVersion` | string | `0.1.5-rc.2` | dsh version an automatic deployment installs; the default is the first that can start the web surface on Linux |
271
+ | `webInstallRegistry` | string | `''` | npm registry for the install; **empty keeps the remote's own config**, so an internal mirror is never overridden |
213
272
 
214
273
  > The authoritative list is the `Config` schema in `lib/index.js`; this table mirrors it.
215
274
 
@@ -235,6 +294,16 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
235
294
 
236
295
  **The plugin vanished after a DSH upgrade** — DSH's compatibility check dropped the bundle; upgrade to **0.8.29+** (see "DSH version compatibility" above).
237
296
 
297
+ **"Remote DSH Web UI" will not connect / spins forever** — first check that `dsh web` starts on that
298
+ machine *at all*. Two known traps (the plugin names them in its error):
299
+ 1. **dsh 0.1.0-rc.6 cannot start the web surface on Linux** — the `node-pty@1.1.0` it depends on ships
300
+ macOS/Windows prebuilds but **no `linux-x64`**, so it fails with `Failed to load native module:
301
+ pty.node`. Upgrade to 0.1.5-rc.2+ (its `node-pty@1.2.0-beta.15` ships the Linux build).
302
+ 2. **dsh 0.1.0-rc.6 does not know `--no-open`** (the flag came later); the plugin drops it and retries
303
+ once automatically, so there is nothing to do.
304
+ If the remote `dsh` is not on the SSH login PATH (a private prefix, say), point `webAttachCommand` at
305
+ its absolute path.
306
+
238
307
  ## Safety
239
308
 
240
309
  Giving the plugin a machine's credentials lets the agent run **shell commands as your user** on that host — only add machines you trust. Passwords live in a local file (or the OS keychain); treat them as sensitive. With `auditLog` on, every command is recorded.
package/README.md CHANGED
@@ -47,7 +47,7 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
47
47
 
48
48
  ## 功能
49
49
 
50
- ![核心能力总览 — 多机 SSH / 别名实时解析 / 双 tab 选择器 / 三路同步 / 远程 @ 补全 / 安全审计 / 端口转发 / 侧栏编辑 / 自动更新,以及 20 个 rw_* 工具](docs/shots/features.png)
50
+ ![核心能力总览 — 多机 SSH / 别名实时解析 / 双 tab 选择器 / 三路同步 / 远程 @ 补全 / 安全审计 / 端口转发 / 侧栏编辑 / 自动更新,以及 21 个 rw_* 工具](docs/shots/features.png)
51
51
 
52
52
  上图是能力总览;下面只列**上图没说清、但用起来需要知道**的部分。
53
53
 
@@ -105,6 +105,49 @@ dsh plugin add dsh-remote
105
105
 
106
106
  > **Remote context 是 session 级的**:只有当前 session 的工作区是某个远程镜像时,system prompt 才注入「Remote workspace」段落;普通本地 session 不受影响,模型也不会主动调 `rw_*`。
107
107
 
108
+ ## 在本地打开远程机器上的 DSH 界面
109
+
110
+ 远程机器上那个 `dsh web` 也可以**当成本机的一个页面打开**(issue #46)。方向仍然是「本地主动连出去」:
111
+
112
+ > **远端不需要开放任何端口**。DSH 刻意拒绝 `--host 0.0.0.0`(它会把远程代码执行能力暴露到网络上),
113
+ > 所以正确的做法是由本机 SSH 连过去,在远端**只监听 127.0.0.1** 地起一个 `dsh web`,
114
+ > 再把它的端口经 SSH 隧道搬回本机的一个 loopback 端口。
115
+
116
+ 用法:设置 → 远程工作区 → **「远程 DSH 界面」** → 选一台机器 → **连接并打开**。
117
+ 浏览器会新开一个标签页,显示的就是**那台远端机器**的完整 DSH 界面(聊天、工具树、设置都在)。
118
+
119
+ - 界面地址形如 `http://127.0.0.1:3088/?token=…`。那个 token 是远端进程启动时打印的一次性凭据,
120
+ 只在**本机**的这次跳转里用一次:DSH 会把它换成 30 天有效的签名 cookie 并跳转到干净的 `/`。
121
+ **请不要把这个带 token 的地址外发**。
122
+ - 「断开」只关隧道,**不会**杀掉远端进程(它可能是你在别处也在用的实例);
123
+ 要连远端进程一起停,用「断开并停止远端」——它只会结束**本次由插件启动**的那个进程。
124
+ - 若远端已经在跑一个界面(比如你自己在终端里起的),可以选机器后把它的
125
+ `http://127.0.0.1:<端口>/?token=…` 粘进输入框,点「连接已有实例」,插件不会再去启第二个。
126
+ - 远端 `dsh` 不在 SSH 登录 PATH 时,用 `webAttachCommand` 指向它的绝对路径;
127
+ 想让它别污染远端用户自己的会话/设置,用 `webAttachDshHome` 指一个临时目录。
128
+
129
+ ### 连不上?先体检,再一键部署
130
+
131
+ **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对。** 最典型的一种:dsh 0.1.0-rc.6
132
+ 依赖 `node-pty@1.1.0`,而**那个版本发布的包里没有 `linux-x64` 预编译产物** ⇒ `dsh web`
133
+ 在 Linux 上根本起不来。这种情况以前只能看到「没拿到启动令牌」,无从判断。
134
+
135
+ 设置 → 远程工作区 → **「远端 dsh 体检与部署」**:
136
+
137
+ 1. **体检(只读)** —— 探测远端平台/node/npm/dsh 版本/**原生模块能否启动**/是否认识
138
+ `--no-open`/代理与 npm 源,给出结论与修复建议。**不写任何东西**,可以随便点。
139
+ 2. **部署并验证** —— 只在上一步判定「需要且可以自动装」时才出现。它会装到远端
140
+ **私有目录**(默认 `~/.dsh-remote/dsh`):**不写系统目录、不改 PATH、不覆盖你在用的版本**,
141
+ 删掉那个目录即完全回滚。装完逐步校验(二进制 / 原生模块 / `web` 子命令),
142
+ 成功后**按机器记住**这条 dsh 路径,之后连接就用它。
143
+ 3. **让 AI 排查** —— 只在失败后出现。它会创建一个会话,让内置的 `dsh-remote-deploy`
144
+ 技能去处理确定性流程覆盖不到的长尾(没有 npm、要 sudo、代理、内网镜像源、Windows 远端等)。
145
+ **只有你点它才会创建**,因为这会消耗模型额度。
146
+
147
+ 相关配置:`webInstallPrefix`(装哪,默认 `$HOME/.dsh-remote/dsh`)、
148
+ `webInstallVersion`(装哪个版本,默认 `0.1.5-rc.2`——第一个在 Linux 上能起 web 的版本)、
149
+ `webInstallRegistry`(npm 源;**留空则沿用远端自己的配置**,不会覆盖你的内网镜像)。
150
+
108
151
  ## 可选:CLI 默认机
109
152
 
110
153
  可在 `cordis.patch.yml` 提供默认机:
@@ -201,6 +244,13 @@ scripts/dev-run.sh --status # 是否在运行
201
244
  | `updateMode` | string | `auto` | 自更新模式:`auto`=加载时及每 6 小时检查并自动应用、`manual`=仅在手动检查时查、`off`=完全不查。**0.8.27 起默认 `auto`**——之所以现在才安全,是因为 0.8.24 补上了宿主半热切换 |
202
245
  | `updateCheckIntervalMs` | int | 21600000(6h) | `auto` 模式检查 npm 的间隔(下限 60000) |
203
246
  | `updateAutoReload` | bool | `true` | 更新落地后自动热切换宿主半;`false` 则留到下次启动,设置页会显示 `pendingReload` |
247
+ | `webAttachPortStart` | int | `3088` | 「远程 DSH 界面」在本机监听的起始端口(占用则顺延,仅监听 127.0.0.1) |
248
+ | `webAttachCommand` | string | `dsh` | 在远端启动 `dsh web` 用的命令;远端 `dsh` 不在 SSH 登录 PATH 时改这里 |
249
+ | `webAttachDshHome` | string | `''` | 远端启动时导出的 `DSH_HOME`;留空则复用远端用户自己的 harness home |
250
+ | `webAttachWaitSeconds` | int | `45` | 等待远端 `dsh web` 打印启动令牌的秒数 |
251
+ | `webInstallPrefix` | string | `''` | 自动部署装到远端的哪个目录;留空用 `$HOME/.dsh-remote/dsh`(不写系统目录、不改 PATH) |
252
+ | `webInstallVersion` | string | `0.1.5-rc.2` | 自动部署安装的 dsh 版本;默认值是第一个在 Linux 上能启动 web 的版本 |
253
+ | `webInstallRegistry` | string | `''` | 安装用的 npm 源;**留空沿用远端自己的配置**,不会覆盖内网镜像 |
204
254
 
205
255
  > 权威清单是 `lib/index.js` 里的 `Config` schema,本表与之一致。
206
256
 
@@ -226,6 +276,14 @@ scripts/dev-run.sh --status # 是否在运行
226
276
 
227
277
  **升级后插件整个不见了** —— DSH 兼容性判定丢弃了 bundle,升到 **0.8.29+** 即可(见上文「DSH 版本兼容性」)。
228
278
 
279
+ **「远程 DSH 界面」连不上/一直转圈** —— 先确认那台机器上的 `dsh web` **本身**能起来。
280
+ 两个已知坑(插件会在报错里直接点出来):
281
+ ① **dsh 0.1.0-rc.6 在 Linux 上无法启动 web**——它依赖的 `node-pty@1.1.0` 只带了
282
+ macOS/Windows 的预编译产物,**没有 `linux-x64`**,会报 `Failed to load native module: pty.node`。
283
+ 升到 0.1.5-rc.2+ 即可(其 `node-pty@1.2.0-beta.15` 带 Linux 预编译)。
284
+ ② **dsh 0.1.0-rc.6 不认识 `--no-open`**(该 flag 是后加的);插件会自动去掉它重试一次,无需你处理。
285
+ 如果远端 `dsh` 不在 SSH 登录 PATH 里(例如装在私有前缀),用 `webAttachCommand` 指向它的绝对路径。
286
+
229
287
  ## 安全提醒
230
288
 
231
289
  把凭据交给插件等于允许 Agent 以你的用户身份在该主机执行 **shell 命令**——只添加可信机器。密码存在本机文件(或钥匙串),请当作敏感数据。开启 `auditLog` 时每条命令都会记入审计日志。
package/lib/client.js CHANGED
@@ -105,6 +105,36 @@ window.__ModuleLoader__.load({
105
105
  'settings.audit': '最近执行的远程命令(审计日志)',
106
106
  'settings.auditEmpty': '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)',
107
107
  'settings.auditRefresh': '刷新日志',
108
+ 'settings.webAttach': '远程 DSH 界面',
109
+ 'settings.webAttachHint': '在本机浏览器里打开远程机器上的 DSH 网页界面。不需要在远端开放任何端口:插件会主动 SSH 连出去,在远端启一个只监听 127.0.0.1 的 dsh web,再把它的端口通过 SSH 隧道搬回本机。',
110
+ 'settings.webAttachMachine': '机器',
111
+ 'settings.webAttachOpen': '连接并打开',
112
+ 'settings.webAttachOpening': '正在连接…',
113
+ 'settings.webAttachReopen': '打开',
114
+ 'settings.webAttachClose': '断开',
115
+ 'settings.webAttachCloseStop': '断开并停止远端',
116
+ 'settings.webAttachReconnect': '重连',
117
+ 'settings.webAttachDropped': '连接已断开',
118
+ 'settings.webAttachNone': '尚未连接任何远程界面',
119
+ 'settings.webAttachExisting': '或粘贴远端已在运行的界面地址(http://127.0.0.1:<端口>/?token=…)',
120
+ 'settings.webAttachExistingGo': '连接已有实例',
121
+ 'settings.webAttachTokenWarn': '地址含登录令牌,仅供本机使用,请勿外发。',
122
+ 'settings.webAttachFailed': '连接失败:',
123
+ 'settings.deploy': '远端 dsh 体检与部署',
124
+ 'settings.deployHint': '连接失败大多不是插件的问题,而是远端 dsh 版本或环境不对(例如旧版在 Linux 上无法启动 web)。这里可以只读体检,也可以一键装到远端私有目录(不影响用户在用的版本)。',
125
+ 'settings.deployProbe': '体检(只读)',
126
+ 'settings.deployProbing': '正在体检…',
127
+ 'settings.deployInstall': '部署并验证',
128
+ 'settings.deployInstalling': '正在部署…',
129
+ 'settings.deployConfirm': '将在远端私有目录安装 dsh(不写系统目录、不改 PATH、不覆盖用户在用的版本)。继续?',
130
+ 'settings.deployInvestigate': '让 AI 排查',
131
+ 'settings.deployInvestigating': '正在创建排查会话…',
132
+ 'settings.deployOk': '远端环境可用',
133
+ 'settings.deployBlocked': '远端环境有问题',
134
+ 'settings.deployUseCommand': '将使用的 dsh',
135
+ 'settings.deploySteps': '安装步骤',
136
+ 'settings.deployDone': '部署完成,已记录该机器使用的 dsh',
137
+ 'settings.deployInvestigateStarted': '已创建排查会话,请在会话里跟进。',
108
138
  'settings.badgePwd': '密码',
109
139
  'settings.badgeKeychain': '钥匙串',
110
140
  'settings.badgeProxy': '跳板',
@@ -333,6 +363,36 @@ window.__ModuleLoader__.load({
333
363
  'settings.audit': 'Recent remote commands (audit log)',
334
364
  'settings.auditEmpty': 'No records yet (commands run remotely are logged to $DSH_HOME/remote-workspaces/audit.log)',
335
365
  'settings.auditRefresh': 'Refresh log',
366
+ 'settings.webAttach': 'Remote DSH Web UI',
367
+ 'settings.webAttachHint': 'Open the DSH web interface of a remote machine in this browser. The remote never opens an inbound port: this plugin SSHes out, starts a loopback-only dsh web there, and carries its port back over the SSH connection.',
368
+ 'settings.webAttachMachine': 'Machine',
369
+ 'settings.webAttachOpen': 'Connect & open',
370
+ 'settings.webAttachOpening': 'Connecting…',
371
+ 'settings.webAttachReopen': 'Open',
372
+ 'settings.webAttachClose': 'Disconnect',
373
+ 'settings.webAttachCloseStop': 'Disconnect & stop remote',
374
+ 'settings.webAttachReconnect': 'Reconnect',
375
+ 'settings.webAttachDropped': 'Disconnected',
376
+ 'settings.webAttachNone': 'No remote UI attached',
377
+ 'settings.webAttachExisting': 'Or paste the URL of an interface already running there (http://127.0.0.1:<port>/?token=…)',
378
+ 'settings.webAttachExistingGo': 'Attach to running instance',
379
+ 'settings.webAttachTokenWarn': 'The URL carries a login token. It is for this machine only — do not share it.',
380
+ 'settings.webAttachFailed': 'Could not attach: ',
381
+ 'settings.deploy': 'Remote dsh health check & deploy',
382
+ 'settings.deployHint': 'A failed connect is usually not the plugin\'s fault: the remote dsh version or environment is wrong (an old one cannot start the web surface on Linux at all). Check read-only, or install into a private directory on the remote that leaves the user\'s own version untouched.',
383
+ 'settings.deployProbe': 'Check (read-only)',
384
+ 'settings.deployProbing': 'Checking…',
385
+ 'settings.deployInstall': 'Deploy & verify',
386
+ 'settings.deployInstalling': 'Deploying…',
387
+ 'settings.deployConfirm': 'Install dsh into a private directory on the remote (no system directory, no PATH change, no replacing the version in use). Continue?',
388
+ 'settings.deployInvestigate': 'Let the AI investigate',
389
+ 'settings.deployInvestigating': 'Creating a session…',
390
+ 'settings.deployOk': 'Remote environment is usable',
391
+ 'settings.deployBlocked': 'Remote environment has a problem',
392
+ 'settings.deployUseCommand': 'dsh that will be used',
393
+ 'settings.deploySteps': 'Install steps',
394
+ 'settings.deployDone': 'Deployed; this machine now records the dsh it uses',
395
+ 'settings.deployInvestigateStarted': 'A session was created — follow up there.',
336
396
  'settings.badgePwd': 'password',
337
397
  'settings.badgeKeychain': 'keychain',
338
398
  'settings.badgeProxy': 'jump',
@@ -785,6 +845,21 @@ window.__ModuleLoader__.load({
785
845
  const [advOpen, setAdvOpen] = React.useState(false) // 高级配置折叠
786
846
  const [fwdOpen, setFwdOpen] = React.useState(true)
787
847
  const [auditOpen, setAuditOpen] = React.useState(false)
848
+ // Issue #46: attach a REMOTE machine's DSH Web UI as a local page. One
849
+ // session per machine; `webAttach` holds the machineId currently opening.
850
+ const [webAttaches, setWebAttaches] = React.useState([])
851
+ const [webAttachMachine, setWebAttachMachine] = React.useState('')
852
+ const [webAttachOpen, setWebAttachOpen] = React.useState(false)
853
+ const [webAttachBusy, setWebAttachBusy] = React.useState('')
854
+ const [webAttachErr, setWebAttachErr] = React.useState('')
855
+ const [webAttachExisting, setWebAttachExisting] = React.useState('')
856
+ // Issue #46 P1–P4: deterministic remote deployment + the AI escape hatch.
857
+ // `deploy` holds the last probe/install outcome for the selected machine;
858
+ // `deployBusy` is the running step id, so the UI can show progress.
859
+ const [deploy, setDeploy] = React.useState(null)
860
+ const [deployBusy, setDeployBusy] = React.useState('')
861
+ const [deployErr, setDeployErr] = React.useState('')
862
+ const [deployOpen, setDeployOpen] = React.useState(false)
788
863
  React.useEffect(() => { ensureChromeCss() }, [])
789
864
 
790
865
  const testConn = () => {
@@ -874,6 +949,43 @@ window.__ModuleLoader__.load({
874
949
  const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
875
950
  React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
876
951
 
952
+ // ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
953
+ // The remote never opens an inbound port; we SSH out and carry the remote
954
+ // loopback web UI back to a local port. One tunnel per machine.
955
+ const refreshWebAttaches = () => api('GET', '/dsh-remote/web-attach')
956
+ .then((r) => setWebAttaches(r.attaches || []))
957
+ .catch(() => {})
958
+ React.useEffect(() => { refreshWebAttaches(); const t = setInterval(refreshWebAttaches, 15000); return () => clearInterval(t) }, [])
959
+ const webAttachAction = (action, machineId, extra) => {
960
+ setWebAttachBusy(machineId + ':' + action); setWebAttachErr('')
961
+ return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
962
+ .then((r) => { refreshWebAttaches(); return r })
963
+ .catch((e) => { setWebAttachErr(String((e && e.message) || e)); return null })
964
+ .then((r) => { setWebAttachBusy(''); return r })
965
+ }
966
+
967
+ // ── 远程部署:体检 / 部署并验证 / 让 AI 排查 ───────────────────────────
968
+ // The probe is read-only, so it is safe to run before the user agrees to
969
+ // anything; the install requires an explicit second click (confirm: true).
970
+ const deployAction = (action, machineId, extra) => {
971
+ setDeployBusy(action); setDeployErr('')
972
+ return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
973
+ .then((r) => {
974
+ if (action === 'probe') setDeploy({ kind: 'probe', ...r, at: Date.now() })
975
+ if (action === 'install') setDeploy({ kind: 'install', ...r, at: Date.now() })
976
+ if (action === 'investigate') setDeploy({ kind: 'investigate', ...r, at: Date.now() })
977
+ return r
978
+ })
979
+ .catch((e) => {
980
+ const msg = String((e && e.message) || e)
981
+ setDeployErr(msg)
982
+ // Keep the failing step list so the operator sees WHERE it stopped.
983
+ setDeploy({ kind: action, ok: false, error: msg, at: Date.now() })
984
+ return null
985
+ })
986
+ .then((r) => { setDeployBusy(''); refreshWebAttaches(); return r })
987
+ }
988
+
877
989
  const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
878
990
  const setFb = (k) => (ev) => setForm({ ...form, [k]: ev.target.checked })
879
991
  const startEdit = (m) => {
@@ -1092,6 +1204,132 @@ window.__ModuleLoader__.load({
1092
1204
  workspaces.map((w, i) => React.createElement('div', { key: w + i, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
1093
1205
  React.createElement('code', { style: { flex: 1, fontSize: 12, fontFamily: 'monospace' } }, w),
1094
1206
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, tr('settings.switch'))))) : null,
1207
+ // 远程 DSH 界面(issue #46)
1208
+ React.createElement('div', { style: boxS },
1209
+ sectionToggle(webAttachOpen, setWebAttachOpen, tr('settings.webAttach')),
1210
+ webAttachOpen ? React.createElement(React.Fragment, null,
1211
+ React.createElement('div', { style: { opacity: 0.65, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.webAttachHint')),
1212
+ // The URL below carries a one-time login token. It stays on loopback,
1213
+ // but a user could paste it into a chat — say so where the URL is.
1214
+ React.createElement('div', { style: { color: T.warn, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.webAttachTokenWarn')),
1215
+ // Live tunnels, one row per machine.
1216
+ webAttaches.length ? webAttaches.map((a) => React.createElement('div', { key: a.machineId || a.localPort, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', fontSize: 12, flexWrap: 'wrap' } },
1217
+ React.createElement('span', { style: { color: a.active ? T.ok : T.muted } }, a.active ? '●' : '○'),
1218
+ React.createElement('code', { style: { fontFamily: 'monospace' } }, a.cleanUrl || ''),
1219
+ React.createElement('span', { style: { opacity: 0.6, fontFamily: 'monospace' } }, '→ :' + a.remotePort),
1220
+ a.active ? React.createElement('a', {
1221
+ className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, textDecoration: 'none' },
1222
+ // The token rides on this loopback URL only; DSH exchanges it
1223
+ // for a signed cookie and redirects to a clean '/'.
1224
+ href: a.url, target: '_blank', rel: 'noopener noreferrer',
1225
+ }, tr('settings.webAttachReopen')) : null,
1226
+ // A tunnel whose listener died (remote restart, sshd drop) keeps
1227
+ // its row so the user can see what happened and reconnect. The
1228
+ // remote process is NOT auto-restarted: that would silently
1229
+ // resurrect a server on a machine the user may be done with.
1230
+ !a.active ? React.createElement('button', {
1231
+ type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' },
1232
+ disabled: !!webAttachBusy, onClick: () => webAttachAction('open', a.machineId),
1233
+ }, tr('settings.webAttachReconnect')) : null,
1234
+ !a.active ? React.createElement('span', { style: { color: T.muted, fontSize: 11 } }, a.error || tr('settings.webAttachDropped')) : null,
1235
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' },
1236
+ disabled: !!webAttachBusy, onClick: () => webAttachAction('close', a.machineId) }, tr('settings.webAttachClose')),
1237
+ // Only offered for a process we started: an attach onto the user's
1238
+ // own running DSH must never signal it.
1239
+ a.managed
1240
+ ? React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' },
1241
+ disabled: !!webAttachBusy, onClick: () => webAttachAction('close', a.machineId, { stopRemote: true }) }, tr('settings.webAttachCloseStop'))
1242
+ : null)) : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, tr('settings.webAttachNone')),
1243
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' } },
1244
+ React.createElement('select', { className: 'dsh-rw-input', value: webAttachMachine, onChange: (e) => setWebAttachMachine(e.target.value), style: { ...inputS, maxWidth: 200 } },
1245
+ React.createElement('option', { value: '' }, tr('settings.webAttachMachine') + '…'),
1246
+ machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, `${m.name || m.host} (${m.username}@${m.host})`))),
1247
+ React.createElement('button', {
1248
+ type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: primaryBtnS,
1249
+ disabled: !!webAttachBusy || !webAttachMachine,
1250
+ // The machine is named explicitly in the request, so attaching
1251
+ // never has to move the user's "current machine" selection.
1252
+ onClick: () => webAttachAction('open', webAttachMachine),
1253
+ }, webAttachBusy.endsWith(':open') ? tr('settings.webAttachOpening') : tr('settings.webAttachOpen'))),
1254
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' } },
1255
+ React.createElement('input', { className: 'dsh-rw-input', value: webAttachExisting, onChange: (e) => setWebAttachExisting(e.target.value), placeholder: tr('settings.webAttachExisting'), style: { ...inputS, flex: 1, minWidth: 260 } }),
1256
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' },
1257
+ disabled: !!webAttachBusy || !webAttachMachine || !webAttachExisting.trim(),
1258
+ onClick: () => webAttachAction('open', webAttachMachine, { existingUrl: webAttachExisting.trim() }).then(() => setWebAttachExisting('')) }, tr('settings.webAttachExistingGo'))),
1259
+ webAttachErr ? React.createElement('div', { style: { color: T.danger, fontSize: 12, marginTop: 6, wordBreak: 'break-all' } }, tr('settings.webAttachFailed') + webAttachErr) : null,
1260
+ ) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, String(webAttaches.length)),
1261
+ ),
1262
+ // 远端 dsh 体检与部署(issue #46 P1–P4):确定性的体检/安装,失败再交给 AI。
1263
+ React.createElement('div', { style: boxS },
1264
+ sectionToggle(deployOpen, setDeployOpen, tr('settings.deploy')),
1265
+ deployOpen ? React.createElement(React.Fragment, null,
1266
+ React.createElement('div', { style: { opacity: 0.65, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.deployHint')),
1267
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6, flexWrap: 'wrap' } },
1268
+ React.createElement('select', { className: 'dsh-rw-input', value: webAttachMachine, onChange: (e) => setWebAttachMachine(e.target.value), style: { ...inputS, maxWidth: 200 } },
1269
+ React.createElement('option', { value: '' }, tr('settings.webAttachMachine') + '…'),
1270
+ machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, `${m.name || m.host} (${m.username}@${m.host})`))),
1271
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' },
1272
+ disabled: !!deployBusy || !webAttachMachine,
1273
+ onClick: () => deployAction('probe', webAttachMachine) },
1274
+ deployBusy === 'probe' ? tr('settings.deployProbing') : tr('settings.deployProbe'))),
1275
+ // Verdict from the last probe/install, rendered as findings with the
1276
+ // severity the backend assigned — the UI never re-classifies them.
1277
+ deploy ? React.createElement('div', { style: { fontSize: 12, lineHeight: 1.6 } },
1278
+ deploy.kind === 'probe' && deploy.verdict
1279
+ ? React.createElement('div', { style: { fontWeight: 600, color: deploy.verdict.ok ? T.ok : T.danger, marginBottom: 4 } },
1280
+ deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked'))
1281
+ : null,
1282
+ (deploy.verdict && deploy.verdict.findings || []).map((f, i) => React.createElement('div', { key: f.code + i, style: { display: 'flex', gap: 6, alignItems: 'flex-start', padding: '2px 0' } },
1283
+ React.createElement('span', { style: { color: f.severity === 'blocker' ? T.danger : f.severity === 'warn' ? T.warn : f.severity === 'ok' ? T.ok : T.muted } },
1284
+ f.severity === 'blocker' ? '●' : f.severity === 'ok' ? '●' : '○'),
1285
+ React.createElement('span', null,
1286
+ React.createElement('span', null, f.summary),
1287
+ f.detail ? React.createElement('span', { style: { opacity: 0.7 } }, ' —— ' + f.detail) : null,
1288
+ f.fix ? React.createElement('div', { style: { opacity: 0.8, fontSize: 11 } }, '→ ' + f.fix) : null))),
1289
+ deploy.command || (deploy.verdict && deploy.verdict.useCommand)
1290
+ ? React.createElement('div', { style: { marginTop: 4, fontFamily: 'monospace', fontSize: 11, opacity: 0.8, wordBreak: 'break-all' } },
1291
+ tr('settings.deployUseCommand') + ':' + (deploy.command || deploy.verdict.useCommand))
1292
+ : null,
1293
+ // Per-step progress: a failure names the step it stopped at, which
1294
+ // is the whole reason the plan is split into separate commands.
1295
+ Array.isArray(deploy.steps) && deploy.steps.length
1296
+ ? React.createElement('div', { style: { marginTop: 6 } },
1297
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 2 } }, tr('settings.deploySteps')),
1298
+ deploy.steps.map((s) => React.createElement('div', { key: s.id, style: { fontSize: 11, fontFamily: 'monospace', display: 'flex', gap: 6 } },
1299
+ React.createElement('span', { style: { color: s.ok ? T.ok : T.danger } }, s.ok ? '✓' : '✗'),
1300
+ React.createElement('span', null, s.title),
1301
+ !s.ok && s.output ? React.createElement('span', { style: { opacity: 0.75, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, ' — ' + String(s.output).slice(0, 300)) : null)))
1302
+ : null,
1303
+ deploy.ok === true && deploy.steps
1304
+ ? React.createElement('div', { style: { color: T.ok, marginTop: 4 } }, tr('settings.deployDone'))
1305
+ : null,
1306
+ deploy.kind === 'investigate' && deploy.sessionId
1307
+ ? React.createElement('div', { style: { color: T.ok, marginTop: 4 } }, tr('settings.deployInvestigateStarted') + ' (' + deploy.sessionId + ')')
1308
+ : null,
1309
+ ) : null,
1310
+ // The install button only appears when a probe says it is both needed
1311
+ // and possible; clicking it is the explicit consent (confirm: true).
1312
+ deploy && deploy.verdict && deploy.verdict.canAutoInstall && !deploy.verdict.ok
1313
+ ? React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
1314
+ disabled: !!deployBusy,
1315
+ onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
1316
+ deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall'))
1317
+ : null,
1318
+ // The AI escape hatch: offered after a failure, and ONLY on click —
1319
+ // it costs a model call, so it is never automatic.
1320
+ (deployErr || (deploy && deploy.ok === false))
1321
+ ? React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', marginTop: 6 },
1322
+ disabled: !!deployBusy,
1323
+ onClick: () => deployAction('investigate', webAttachMachine, {
1324
+ findings: (deploy && deploy.verdict && deploy.verdict.findings) || [],
1325
+ facts: deploy && deploy.facts,
1326
+ error: deployErr || (deploy && deploy.error) || '',
1327
+ }) },
1328
+ deployBusy === 'investigate' ? tr('settings.deployInvestigating') : tr('settings.deployInvestigate'))
1329
+ : null,
1330
+ deployErr ? React.createElement('div', { style: { color: T.danger, fontSize: 12, marginTop: 6, wordBreak: 'break-all' } }, deployErr) : null,
1331
+ ) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, deploy && deploy.verdict ? (deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked')) : ''),
1332
+ ),
1095
1333
  // 端口转发面板
1096
1334
  React.createElement('div', { style: boxS },
1097
1335
  sectionToggle(fwdOpen, setFwdOpen, tr('settings.forwards')),
@@ -67,11 +67,103 @@ export function connectionRoute(route) {
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * Reject a browser-shaped request that is not same-origin ours.
72
+ *
73
+ * This is the FALLBACK fence, used only when the Connection service (which owns
74
+ * DSH's authoritative `requestRejection`) is absent — an old composition that
75
+ * has a `webServer` but no browser-authentication owner. It is deliberately
76
+ * fail-closed on the three browser vectors our own routes would otherwise
77
+ * accept:
78
+ * • `sec-fetch-site: cross-site` — a page on any origin driving our API;
79
+ * • an `Origin` that is not our own authority — the same, with CORS markers;
80
+ * • a non-loopback `Host` — DNS rebinding, where a name the attacker controls
81
+ * resolves to 127.0.0.1 so even `Origin === Host` looks "same-origin".
82
+ * Non-browser callers (curl, scripts) carry no `Sec-Fetch-*`/`Origin` and a
83
+ * loopback `Host`, so they keep working; that is the same shape DSH's own fence
84
+ * implements for `/api`.
85
+ *
86
+ * A request with no HTTP headers at all is not a browser request — it comes
87
+ * from an in-process caller (tests, and the Desktop carrier's IPC path, which
88
+ * authenticates before dispatch) — so there is no browser layer to defend.
89
+ *
90
+ * @param {import('node:http').IncomingMessage} req - the request under test.
91
+ * @returns {number|undefined} 403 to reject, undefined to proceed.
92
+ */
93
+ function fallbackRejection(req) {
94
+ const headers = req && req.headers
95
+ if (!headers || typeof headers !== 'object') return undefined
96
+ const header = (name) => {
97
+ const value = headers[name]
98
+ return typeof value === 'string' ? value : Array.isArray(value) ? value[0] : undefined
99
+ }
100
+ if (header('sec-fetch-site') === 'cross-site') return 403
101
+ const host = header('host')
102
+ const origin = header('origin')
103
+ if (origin !== undefined && host !== undefined) {
104
+ try {
105
+ if (new URL(origin).host !== host) return 403
106
+ } catch {
107
+ return 403
108
+ }
109
+ }
110
+ if (host !== undefined) {
111
+ const hostname = host.replace(/:\d+$/, '').toLowerCase()
112
+ const loopback = hostname === 'localhost' || hostname === '[::1]'
113
+ || /^127(?:\.\d{1,3}){3}$/.test(hostname)
114
+ if (!loopback) return 403
115
+ }
116
+ return undefined
117
+ }
118
+
119
+ /**
120
+ * Wrap one route so every request passes DSH's browser trust fence first.
121
+ *
122
+ * The legacy `/dsh-remote/*` routes are registered straight on `webServer`, which
123
+ * applies no Host/Origin check and no browser authentication — `requestRejection`
124
+ * lives on the Connection service and is only wired to `/api` and the
125
+ * `/api/remote.mux` upgrade. Without this wrapper our mutating routes were
126
+ * reachable cross-site as "simple requests" (no preflight: a `text/plain` POST
127
+ * needs no CORS approval), so any page the user visited could define a tunnel,
128
+ * rewrite the machine registry, or drive `update-apply`. The model tools were
129
+ * never exposed that way; only these JSON routes were.
130
+ *
131
+ * The lookup is deferred to request time because `webServer` and `connection`
132
+ * arrive independently: the fence must engage as soon as authentication exists,
133
+ * and must not be baked in from whichever service happened to be present when
134
+ * the route was registered.
135
+ *
136
+ * @param {object} route - a webServer route definition.
137
+ * @param {() => object|undefined} connectionOf - resolves the live Connection service.
138
+ * @returns {object} a route whose handler enforces the fence before dispatching.
139
+ */
140
+ export function guardRoute(route, connectionOf) {
141
+ const inner = route.handler
142
+ return {
143
+ ...route,
144
+ handler: async (req, res) => {
145
+ const connection = connectionOf()
146
+ const rejection = typeof connection?.requestRejection === 'function'
147
+ ? connection.requestRejection(req)
148
+ : fallbackRejection(req)
149
+ if (rejection !== undefined) {
150
+ res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
151
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
152
+ return
153
+ }
154
+ return inner(req, res)
155
+ },
156
+ }
157
+ }
158
+
70
159
  export function registerHttpTransports(ctx, routes) {
71
160
  // Reactive injection matters: either service can arrive after the plugin.
72
161
  // Neither optional transport is a hard prerequisite for the SSH tools.
73
162
  ctx.inject(['webServer'], (inner) => {
74
- const disposers = routes.map((route) => inner.get('webServer').register(route))
163
+ // Apply DSH's own browser fence to every legacy route; the Connection
164
+ // service is read per request so a late-arriving authenticator still counts.
165
+ const guarded = routes.map((route) => guardRoute(route, () => ctx.get('connection')))
166
+ const disposers = guarded.map((route) => inner.get('webServer').register(route))
75
167
  inner.effect(() => () => disposers.forEach((dispose) => dispose()), 'dsh-remote.web-routes')
76
168
  })
77
169
  ctx.inject(['connection'], (inner) => {