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 +70 -1
- package/README.md +59 -1
- package/lib/client.js +238 -0
- package/lib/http-transport.js +93 -1
- package/lib/index.js +380 -0
- package/lib/pool.js +9 -1
- package/lib/web-attach.js +467 -0
- package/lib/web-deploy-skill.js +205 -0
- package/lib/web-deploy.js +652 -0
- package/package.json +1 -1
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
|
-

|
|
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
|
-

|
|
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')),
|
package/lib/http-transport.js
CHANGED
|
@@ -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
|
-
|
|
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) => {
|