rdsh-gateway 0.8.5 → 0.9.0
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/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/join.js +36 -11
- package/dist/proxy.js +23 -1
- package/dist/serve.js +5 -1
- package/dist/service.d.ts +6 -0
- package/dist/service.js +29 -0
- package/dist/spawn-dsh.d.ts +37 -0
- package/dist/spawn-dsh.js +87 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type { SessionPayload } from "./session.ts";
|
|
|
10
10
|
export { PairManager } from "./pair.ts";
|
|
11
11
|
export { startGateway } from "./server.ts";
|
|
12
12
|
export type { GatewayOptions, RunningGateway } from "./server.ts";
|
|
13
|
-
export { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.ts";
|
|
13
|
+
export { findDsh, spawnDsh, dshSpawnEnv, RDSH_REMOTE_TTY, checkRemotePickerGraph, remotePickerWarning, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.ts";
|
|
14
14
|
export type { SpawnedDsh } from "./spawn-dsh.ts";
|
|
15
15
|
export { forwardHttp, createUpgradeProxy, rewriteHeadersForDsh } from "./proxy.ts";
|
|
16
16
|
export type { ProxyTarget } from "./proxy.ts";
|
|
@@ -21,7 +21,7 @@ export { ipInCidrs, parseCidr, ipToInt } from "./cidr.ts";
|
|
|
21
21
|
export { loadTls } from "./tls.ts";
|
|
22
22
|
export type { TlsMaterial } from "./tls.ts";
|
|
23
23
|
export { loginPageHtml } from "./login-page.ts";
|
|
24
|
-
export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.ts";
|
|
24
|
+
export { installService, uninstallService, serviceStatus, startService, stopService, restartService, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.ts";
|
|
25
25
|
export type { ServiceSpec } from "./service.ts";
|
|
26
26
|
export declare const NAME = "rdsh-gateway";
|
|
27
27
|
export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.ts";
|
package/dist/index.js
CHANGED
|
@@ -7,14 +7,14 @@ export { serve } from "./serve.js";
|
|
|
7
7
|
export { SessionManager, SESSION_COOKIE, sessionTokenFromCookie } from "./session.js";
|
|
8
8
|
export { PairManager } from "./pair.js";
|
|
9
9
|
export { startGateway } from "./server.js";
|
|
10
|
-
export { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.js";
|
|
10
|
+
export { findDsh, spawnDsh, dshSpawnEnv, RDSH_REMOTE_TTY, checkRemotePickerGraph, remotePickerWarning, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.js";
|
|
11
11
|
export { forwardHttp, createUpgradeProxy, rewriteHeadersForDsh } from "./proxy.js";
|
|
12
12
|
export { loadConfig, normalizeConfig, resolveConfigPath, saveConfig, DEFAULT_HOST_CONFIG_PATH } from "./config.js";
|
|
13
13
|
export { hashPassword, verifyPassword, UserManager } from "./auth.js";
|
|
14
14
|
export { ipInCidrs, parseCidr, ipToInt } from "./cidr.js";
|
|
15
15
|
export { loadTls } from "./tls.js";
|
|
16
16
|
export { loginPageHtml } from "./login-page.js";
|
|
17
|
-
export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.js";
|
|
17
|
+
export { installService, uninstallService, serviceStatus, startService, stopService, restartService, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.js";
|
|
18
18
|
export const NAME = "rdsh-gateway";
|
|
19
19
|
export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.js";
|
|
20
20
|
export { readPersistedToken, clearPersistedToken } from "./token-store.js";
|
package/dist/join.js
CHANGED
|
@@ -14,7 +14,7 @@ import { request as httpsRequest } from "node:https";
|
|
|
14
14
|
import { hostname as osHostname } from "node:os";
|
|
15
15
|
import { WebSocket } from "ws";
|
|
16
16
|
import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload, FLAG_E2E, MAX_PAYLOAD_LENGTH } from "rdsh-tunnel";
|
|
17
|
-
import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning } from "./spawn-dsh.js";
|
|
17
|
+
import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning, checkRemotePickerGraph, remotePickerWarning } from "./spawn-dsh.js";
|
|
18
18
|
import { rewriteHeadersForDsh } from "./proxy.js";
|
|
19
19
|
import { clearPersistedToken, persistToken, readPersistedToken } from "./token-store.js";
|
|
20
20
|
import { decodeBody, encodeBody, firstEncoding } from "./http-encoding.js";
|
|
@@ -301,6 +301,14 @@ export function startJoin(opts) {
|
|
|
301
301
|
const gatedHttp = new Map();
|
|
302
302
|
/** 已报告过"loopback 补丁未命中"的路径(每个路径只报一次,避免日志刷屏)。 */
|
|
303
303
|
const patchMissReported = new Set();
|
|
304
|
+
/** 已报告过"loopback 补丁命中"的路径(与 miss 对称:只看得到 miss 会让人误判"补丁从未生效")。 */
|
|
305
|
+
const patchHitReported = new Set();
|
|
306
|
+
/**
|
|
307
|
+
* 已确认**不含**补丁目标串的 URL(键为完整 path,含 query)——命中过一次 miss 后直接走流式,
|
|
308
|
+
* 不再每次页面加载都缓冲 + 解压 + 全量扫描(DSH 前端壳资源 2 个就 ~1.3 MB)。
|
|
309
|
+
* 键含 query 且不截断:combo 路由用 query 选内容,资源 URL 由内容决定(rev/hash),内容一变 URL 就变。
|
|
310
|
+
*/
|
|
311
|
+
const patchSkipped = new Set();
|
|
304
312
|
/** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
|
|
305
313
|
function gateCookie(headers) {
|
|
306
314
|
const ck = headers["cookie"];
|
|
@@ -474,7 +482,8 @@ export function startJoin(opts) {
|
|
|
474
482
|
responded = true;
|
|
475
483
|
const status = upRes.statusCode ?? 502;
|
|
476
484
|
const baseHeaders = normalizeRespHeaders(upRes.headers);
|
|
477
|
-
|
|
485
|
+
// 已确认该 URL 不含目标串(DSH 前端壳资源就属此类)→ 直接流式,省掉白解压与全量扫描
|
|
486
|
+
const wantsPatch = dio?.jsPatch?.() === true && isJsContentType(upRes.headers) && !patchSkipped.has(path);
|
|
478
487
|
// 非 JS / 未开补丁:保持原流式路径(OPEN 立即发,body 边到边发)
|
|
479
488
|
if (!wantsPatch) {
|
|
480
489
|
send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: baseHeaders })));
|
|
@@ -503,8 +512,11 @@ export function startJoin(opts) {
|
|
|
503
512
|
let body = raw;
|
|
504
513
|
let outHeaders = baseHeaders;
|
|
505
514
|
let hit = false;
|
|
515
|
+
/** 解码成功、且明文里确实**没有**目标串 → 该 URL 可以永久短路(换编码回来也一样没有)。 */
|
|
516
|
+
let targetAbsent = false;
|
|
506
517
|
if (decoded !== null) {
|
|
507
518
|
const patched = patchLoopbackJs(decoded);
|
|
519
|
+
targetAbsent = patched === null;
|
|
508
520
|
const recoded = patched === null ? null : encodeBody(patched, encoding);
|
|
509
521
|
if (recoded !== null) {
|
|
510
522
|
body = recoded;
|
|
@@ -516,17 +528,26 @@ export function startJoin(opts) {
|
|
|
516
528
|
outHeaders["cache-control"] = "public, max-age=300";
|
|
517
529
|
}
|
|
518
530
|
}
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
531
|
+
// 命中与未命中都必须留痕(每个路径一次):fail-open 是刻意设计,但"补丁是否生效"不能靠猜。
|
|
532
|
+
// 两次误判都源于只看得到一半日志:2026-09-14 gzip 事故(只见 miss)、2026-09-17
|
|
533
|
+
// 「补丁在 0.1.5-rc.2 上永不命中」(把前端壳资源的 miss 当成了判据所在的 bundle)。
|
|
534
|
+
const logKey = (path.split("?")[0] ?? path).slice(0, 120);
|
|
535
|
+
if (hit) {
|
|
536
|
+
if (!patchHitReported.has(logKey) || process.env.RDSH_DEBUG_PATCH === "1") {
|
|
537
|
+
patchHitReported.add(logKey);
|
|
538
|
+
console.log(`[patch] hit: ${logKey} (${raw.length}B → ${body.length}B, ${encoding === "" ? "identity" : encoding})`);
|
|
526
539
|
}
|
|
527
540
|
}
|
|
528
|
-
else
|
|
529
|
-
|
|
541
|
+
else {
|
|
542
|
+
const firstMiss = !patchMissReported.has(logKey);
|
|
543
|
+
patchMissReported.add(logKey);
|
|
544
|
+
// 只有"解码后确实没有目标串"才永久短路;编码不支持(decoded === null)时并没看过明文,
|
|
545
|
+
// 保持每次尝试(同一 URL 换个编码回来仍能被补丁);重压失败同理不算。
|
|
546
|
+
if (targetAbsent)
|
|
547
|
+
patchSkipped.add(path);
|
|
548
|
+
if (firstMiss) {
|
|
549
|
+
console.log(`[patch] miss: ${logKey} (${raw.length}B, content-encoding=${encoding === "" ? "identity" : encoding}) — fail-open${targetAbsent ? ";此 URL 后续不再尝试补丁" : ""}`);
|
|
550
|
+
}
|
|
530
551
|
}
|
|
531
552
|
send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: outHeaders })));
|
|
532
553
|
sendChunkedBody(send, streamId, body);
|
|
@@ -905,6 +926,10 @@ export async function join(opts) {
|
|
|
905
926
|
console.warn("rdsh join: dsh 0.1.2+ 会话 cookie 换发失败——远程访问将返回 401。");
|
|
906
927
|
}
|
|
907
928
|
}
|
|
929
|
+
// 自检:目录选择器必须是浏览器内形态(否则远端浏览器只会看到弹在宿主屏幕上的原生对话框)
|
|
930
|
+
if ((await checkRemotePickerGraph(dsh.port, dshAuthCookieHeader)) === false) {
|
|
931
|
+
console.warn(`\n⚠ ${remotePickerWarning()}\n`);
|
|
932
|
+
}
|
|
908
933
|
// 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
|
|
909
934
|
const { token, insecure, name } = await registerJoin(opts);
|
|
910
935
|
console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
|
package/dist/proxy.js
CHANGED
|
@@ -7,6 +7,20 @@
|
|
|
7
7
|
import { request } from "node:http";
|
|
8
8
|
import { WebSocket, WebSocketServer } from "ws";
|
|
9
9
|
import { decodeBody, encodeBody, firstEncoding } from "./http-encoding.js";
|
|
10
|
+
/**
|
|
11
|
+
* 已知**不含**补丁目标串的 URL(按 patcher 函数身份隔离——目标串由 patcher 决定,只看 URL 会误判)。
|
|
12
|
+
* 目的与隧道路径(`join.ts` 的 `patchSkipped`)一致:DSH 前端壳资源每次页面加载都被缓冲 + 解压 + 全量扫描,
|
|
13
|
+
* 而这些字节永远不会命中;记住一次即可一直走流式。
|
|
14
|
+
*/
|
|
15
|
+
const jsPatchMissedUrls = new WeakMap();
|
|
16
|
+
function missedUrlsFor(patcher) {
|
|
17
|
+
const existing = jsPatchMissedUrls.get(patcher);
|
|
18
|
+
if (existing !== undefined)
|
|
19
|
+
return existing;
|
|
20
|
+
const created = new Set();
|
|
21
|
+
jsPatchMissedUrls.set(patcher, created);
|
|
22
|
+
return created;
|
|
23
|
+
}
|
|
10
24
|
/**
|
|
11
25
|
* 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
|
|
12
26
|
* - Host → 127.0.0.1:<port>(M1 事实:DSH 只信任 loopback/trusted Host)
|
|
@@ -81,7 +95,12 @@ export function forwardHttp(req, res, target, opts) {
|
|
|
81
95
|
});
|
|
82
96
|
return;
|
|
83
97
|
}
|
|
84
|
-
const
|
|
98
|
+
const patchUrl = req.url ?? "";
|
|
99
|
+
const patchMissed = opts?.jsPatch === undefined ? undefined : missedUrlsFor(opts.jsPatch);
|
|
100
|
+
const canPatchJs = opts?.jsPatch !== undefined &&
|
|
101
|
+
typeof contentType === "string" &&
|
|
102
|
+
/javascript/i.test(contentType) &&
|
|
103
|
+
patchMissed?.has(patchUrl) !== true;
|
|
85
104
|
if (canPatchJs) {
|
|
86
105
|
// JS bundle 需缓冲后才能替换目标串;先按 content-encoding 解码(dsh 会 gzip JS),
|
|
87
106
|
// 补丁后按原编码重压(fail-open:未命中/不支持编码 → 原字节原头透传)
|
|
@@ -95,6 +114,9 @@ export function forwardHttp(req, res, target, opts) {
|
|
|
95
114
|
const patched = decoded === null ? null : opts.jsPatch(decoded);
|
|
96
115
|
const recoded = patched === null ? null : encodeBody(patched, encoding);
|
|
97
116
|
const outBody = recoded ?? raw;
|
|
117
|
+
// 只有"解码后确实没有目标串"才记住该 URL(编码不支持时没看过明文,保持每次尝试)
|
|
118
|
+
if (patched === null && decoded !== null)
|
|
119
|
+
patchMissed?.add(patchUrl);
|
|
98
120
|
const outHeaders = { ...upstreamRes.headers };
|
|
99
121
|
if (recoded !== null) {
|
|
100
122
|
outHeaders["content-length"] = String(outBody.length);
|
package/dist/serve.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { networkInterfaces } from "node:os";
|
|
5
5
|
import { startGateway } from "./server.js";
|
|
6
|
-
import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning } from "./spawn-dsh.js";
|
|
6
|
+
import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning, checkRemotePickerGraph, remotePickerWarning } from "./spawn-dsh.js";
|
|
7
7
|
import { loadConfig, resolveConfigPath } from "./config.js";
|
|
8
8
|
import { UserManager } from "./auth.js";
|
|
9
9
|
import { loadTls } from "./tls.js";
|
|
@@ -39,6 +39,10 @@ export async function serve(opts) {
|
|
|
39
39
|
console.warn("rdsh: dsh 0.1.2+ 会话 cookie 换发失败——远程访问将返回 401(请确认 dsh web 已就绪或暂用 dsh@0.1.1-rc.2)。");
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
// 自检:目录选择器必须是浏览器内形态(否则远端浏览器只会看到弹在宿主屏幕上的原生对话框)
|
|
43
|
+
if ((await checkRemotePickerGraph(dsh.port, dshAuthCookieHeader)) === false) {
|
|
44
|
+
console.warn(`\n⚠ ${remotePickerWarning()}\n`);
|
|
45
|
+
}
|
|
42
46
|
// TLS 决策:有 tls.cert/key → https;无 → http(behindProxy 或 pair/none)。
|
|
43
47
|
// password + http + 非反代 → server.ts 安全约束拒绝启动(需自行提供证书)。
|
|
44
48
|
const tlsMaterial = await loadTls(config.tls, config.behindProxy);
|
package/dist/service.d.ts
CHANGED
|
@@ -30,3 +30,9 @@ export declare function installService(spec: ServiceSpec): Promise<string>;
|
|
|
30
30
|
export declare function serviceStatus(name?: string): Promise<string>;
|
|
31
31
|
/** 停止并移除服务。 */
|
|
32
32
|
export declare function uninstallService(name?: string): Promise<string>;
|
|
33
|
+
/** 启动服务(运行时控制,不改变开机自启配置)。 */
|
|
34
|
+
export declare function startService(name?: string): Promise<string>;
|
|
35
|
+
/** 停止服务(运行时控制,不改变开机自启配置)。 */
|
|
36
|
+
export declare function stopService(name?: string): Promise<string>;
|
|
37
|
+
/** 重启服务(运行时控制,不改变开机自启配置)。 */
|
|
38
|
+
export declare function restartService(name?: string): Promise<string>;
|
package/dist/service.js
CHANGED
|
@@ -158,4 +158,33 @@ export async function uninstallService(name = SERVICE_NAME) {
|
|
|
158
158
|
await rm(launchdPlistPath(name), { force: true });
|
|
159
159
|
return `removed ${launchdPlistPath(name)}`;
|
|
160
160
|
}
|
|
161
|
+
/** 启动服务(运行时控制,不改变开机自启配置)。 */
|
|
162
|
+
export async function startService(name = SERVICE_NAME) {
|
|
163
|
+
if (isLinux()) {
|
|
164
|
+
await run("systemctl", ["--user", "start", name]);
|
|
165
|
+
return `started ${name}`;
|
|
166
|
+
}
|
|
167
|
+
// macOS:kickstart -k 确保在跑(已运行则先停再启)。
|
|
168
|
+
await run("launchctl", ["kickstart", "-k", `com.${name}`]);
|
|
169
|
+
return `started com.${name}`;
|
|
170
|
+
}
|
|
171
|
+
/** 停止服务(运行时控制,不改变开机自启配置)。 */
|
|
172
|
+
export async function stopService(name = SERVICE_NAME) {
|
|
173
|
+
if (isLinux()) {
|
|
174
|
+
await run("systemctl", ["--user", "stop", name]);
|
|
175
|
+
return `stopped ${name}`;
|
|
176
|
+
}
|
|
177
|
+
// macOS:stop 仅停当前实例;KeepAlive(仅失败退出重启)可能立即拉起 —— 与 systemd stop 语义不同。
|
|
178
|
+
await run("launchctl", ["stop", `com.${name}`]).catch(() => undefined);
|
|
179
|
+
return `stopped com.${name}`;
|
|
180
|
+
}
|
|
181
|
+
/** 重启服务(运行时控制,不改变开机自启配置)。 */
|
|
182
|
+
export async function restartService(name = SERVICE_NAME) {
|
|
183
|
+
if (isLinux()) {
|
|
184
|
+
await run("systemctl", ["--user", "restart", name]);
|
|
185
|
+
return `restarted ${name}`;
|
|
186
|
+
}
|
|
187
|
+
await run("launchctl", ["kickstart", "-k", `com.${name}`]);
|
|
188
|
+
return `restarted com.${name}`;
|
|
189
|
+
}
|
|
161
190
|
//# sourceMappingURL=service.js.map
|
package/dist/spawn-dsh.d.ts
CHANGED
|
@@ -16,6 +16,27 @@ export interface SpawnedDsh {
|
|
|
16
16
|
* 找不到返回 null。
|
|
17
17
|
*/
|
|
18
18
|
export declare function findDsh(override?: string): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* 注入给 dsh 的「远端操作者」信号值。用可读字面量而非 `/dev/...`:
|
|
21
|
+
* 让 `env` 里一眼看出这是 rdsh 注入的,而不是真实终端路径。
|
|
22
|
+
*/
|
|
23
|
+
export declare const RDSH_REMOTE_TTY = "rdsh-remote";
|
|
24
|
+
/**
|
|
25
|
+
* 构造 spawn dsh 时的环境:保证 dsh 把目录选择器解析为 **browse**(浏览器内目录浏览器)。
|
|
26
|
+
*
|
|
27
|
+
* 事实依据(doc/fix/20260917-remote-workspace-picker/discussion.md F2/F4/F8):
|
|
28
|
+
* `@deepseek-ai/dsh-host-directory-picker-auto` 在 boot 时读 `SSH_CONNECTION`/`SSH_TTY`
|
|
29
|
+
* (仅继承进程层,非空即可)——「操作者看不到宿主显示」正是 rdsh 的场景(等价于
|
|
30
|
+
* SSH 端口转发下的无人值守宿主)。不注入它,宿主是 macOS/Windows/带 DISPLAY 的 Linux
|
|
31
|
+
* 时会解析成 `native`,原生对话框弹在宿主屏幕上、远端浏览器无法操作。
|
|
32
|
+
*
|
|
33
|
+
* 只设 `SSH_TTY`:`SSH_CONNECTION` 有固定 `"ip port ip port"` 格式,写假值会误导解析它的工具。
|
|
34
|
+
* 用户真的在 SSH 会话里启动时(已有非空信号)保持原值不动。
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ 这是上游启发式的输入:上游若改变判定信号,本注入会静默失效(spawn 后自检见
|
|
37
|
+
* `checkRemotePickerGraph` 与 doc/fix/20260917-remote-workspace-picker/solution.md §6)。
|
|
38
|
+
*/
|
|
39
|
+
export declare function dshSpawnEnv(base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
19
40
|
/**
|
|
20
41
|
* spawn `dsh web --port 0 --no-open` 并等待其报告监听端口。
|
|
21
42
|
* 就绪后把 dsh 的 stdout/stderr 透传到本进程。
|
|
@@ -29,6 +50,22 @@ export declare function spawnDsh(dshPath: string): Promise<SpawnedDsh>;
|
|
|
29
50
|
* 返回 cookie 的 `name=value` 段(不含属性);换发失败/超时/无 cookie → null(调用方降级)。
|
|
30
51
|
*/
|
|
31
52
|
export declare function exchangeDshSessionCookie(port: number, token: string, timeoutMs?: number): Promise<string | null>;
|
|
53
|
+
/**
|
|
54
|
+
* 自检:spawn 出的 dsh 是否真的把目录选择器解析成了 **browse**。
|
|
55
|
+
*
|
|
56
|
+
* 方法(与 doc/fix/20260917-remote-workspace-picker 的验证手段一致):取一次首页,
|
|
57
|
+
* 在注入的 boot graph 里找浏览器内选择器的客户端模块 —— 在 → 远端浏览器可用;
|
|
58
|
+
* 不在 → 选择器是 `native`(原生对话框弹在宿主屏幕上)。
|
|
59
|
+
*
|
|
60
|
+
* @returns `true`(已挂载 browse)/ `false`(确认未挂载)/ `null`(无法判定:网络、鉴权、超限)
|
|
61
|
+
* 只有 `false` 才值得告警;`null` 静默,避免噪声。
|
|
62
|
+
*/
|
|
63
|
+
export declare function checkRemotePickerGraph(port: number, cookieHeader?: string | null, timeoutMs?: number): Promise<boolean | null>;
|
|
64
|
+
/**
|
|
65
|
+
* 自检失败(确认未挂载浏览器内选择器)时的告警文案。
|
|
66
|
+
* 零文档导向:说清后果 + 直接给动作。
|
|
67
|
+
*/
|
|
68
|
+
export declare function remotePickerWarning(): string;
|
|
32
69
|
/** 探测 dsh 版本号(`dsh --version` 输出如 `0.1.2-rc.1`);失败/不可解析 → null。 */
|
|
33
70
|
export declare function detectDshVersion(dshPath: string, timeoutMs?: number): Promise<string | null>;
|
|
34
71
|
/**
|
package/dist/spawn-dsh.js
CHANGED
|
@@ -42,6 +42,32 @@ export function findDsh(override) {
|
|
|
42
42
|
}
|
|
43
43
|
return null;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* 注入给 dsh 的「远端操作者」信号值。用可读字面量而非 `/dev/...`:
|
|
47
|
+
* 让 `env` 里一眼看出这是 rdsh 注入的,而不是真实终端路径。
|
|
48
|
+
*/
|
|
49
|
+
export const RDSH_REMOTE_TTY = "rdsh-remote";
|
|
50
|
+
/**
|
|
51
|
+
* 构造 spawn dsh 时的环境:保证 dsh 把目录选择器解析为 **browse**(浏览器内目录浏览器)。
|
|
52
|
+
*
|
|
53
|
+
* 事实依据(doc/fix/20260917-remote-workspace-picker/discussion.md F2/F4/F8):
|
|
54
|
+
* `@deepseek-ai/dsh-host-directory-picker-auto` 在 boot 时读 `SSH_CONNECTION`/`SSH_TTY`
|
|
55
|
+
* (仅继承进程层,非空即可)——「操作者看不到宿主显示」正是 rdsh 的场景(等价于
|
|
56
|
+
* SSH 端口转发下的无人值守宿主)。不注入它,宿主是 macOS/Windows/带 DISPLAY 的 Linux
|
|
57
|
+
* 时会解析成 `native`,原生对话框弹在宿主屏幕上、远端浏览器无法操作。
|
|
58
|
+
*
|
|
59
|
+
* 只设 `SSH_TTY`:`SSH_CONNECTION` 有固定 `"ip port ip port"` 格式,写假值会误导解析它的工具。
|
|
60
|
+
* 用户真的在 SSH 会话里启动时(已有非空信号)保持原值不动。
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ 这是上游启发式的输入:上游若改变判定信号,本注入会静默失效(spawn 后自检见
|
|
63
|
+
* `checkRemotePickerGraph` 与 doc/fix/20260917-remote-workspace-picker/solution.md §6)。
|
|
64
|
+
*/
|
|
65
|
+
export function dshSpawnEnv(base = process.env) {
|
|
66
|
+
const hasSignal = (base.SSH_TTY ?? "") !== "" || (base.SSH_CONNECTION ?? "") !== "";
|
|
67
|
+
if (hasSignal)
|
|
68
|
+
return { ...base };
|
|
69
|
+
return { ...base, SSH_TTY: RDSH_REMOTE_TTY };
|
|
70
|
+
}
|
|
45
71
|
/**
|
|
46
72
|
* spawn `dsh web --port 0 --no-open` 并等待其报告监听端口。
|
|
47
73
|
* 就绪后把 dsh 的 stdout/stderr 透传到本进程。
|
|
@@ -51,6 +77,7 @@ export function spawnDsh(dshPath) {
|
|
|
51
77
|
return new Promise((resolve, reject) => {
|
|
52
78
|
const child = spawn(dshPath, ["web", "--port", "0", "--no-open"], {
|
|
53
79
|
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
env: dshSpawnEnv(),
|
|
54
81
|
});
|
|
55
82
|
let stderrBuf = "";
|
|
56
83
|
const timeout = setTimeout(() => {
|
|
@@ -124,6 +151,66 @@ export function exchangeDshSessionCookie(port, token, timeoutMs = EXCHANGE_TIMEO
|
|
|
124
151
|
});
|
|
125
152
|
});
|
|
126
153
|
}
|
|
154
|
+
const GRAPH_CHECK_TIMEOUT_MS = 5_000;
|
|
155
|
+
const MAX_GRAPH_BYTES = 512 * 1024;
|
|
156
|
+
/** boot graph 里浏览器内目录选择器的客户端模块标识(命中即说明 picker = browse)。 */
|
|
157
|
+
const BROWSE_PICKER_MODULE = "@deepseek-ai/dsh-client-ui-directory-picker-browse";
|
|
158
|
+
/**
|
|
159
|
+
* 自检:spawn 出的 dsh 是否真的把目录选择器解析成了 **browse**。
|
|
160
|
+
*
|
|
161
|
+
* 方法(与 doc/fix/20260917-remote-workspace-picker 的验证手段一致):取一次首页,
|
|
162
|
+
* 在注入的 boot graph 里找浏览器内选择器的客户端模块 —— 在 → 远端浏览器可用;
|
|
163
|
+
* 不在 → 选择器是 `native`(原生对话框弹在宿主屏幕上)。
|
|
164
|
+
*
|
|
165
|
+
* @returns `true`(已挂载 browse)/ `false`(确认未挂载)/ `null`(无法判定:网络、鉴权、超限)
|
|
166
|
+
* 只有 `false` 才值得告警;`null` 静默,避免噪声。
|
|
167
|
+
*/
|
|
168
|
+
export function checkRemotePickerGraph(port, cookieHeader = null, timeoutMs = GRAPH_CHECK_TIMEOUT_MS) {
|
|
169
|
+
return new Promise((resolve) => {
|
|
170
|
+
const headers = { Host: `127.0.0.1:${port}` };
|
|
171
|
+
if (cookieHeader !== null)
|
|
172
|
+
headers.Cookie = cookieHeader;
|
|
173
|
+
let settled = false;
|
|
174
|
+
const finish = (value) => {
|
|
175
|
+
if (settled)
|
|
176
|
+
return;
|
|
177
|
+
settled = true;
|
|
178
|
+
resolve(value);
|
|
179
|
+
};
|
|
180
|
+
const req = get({ host: "127.0.0.1", port, path: "/", headers }, (res) => {
|
|
181
|
+
if (res.statusCode !== 200) {
|
|
182
|
+
res.resume();
|
|
183
|
+
finish(null);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
let body = "";
|
|
187
|
+
res.setEncoding("utf8");
|
|
188
|
+
res.on("data", (chunk) => {
|
|
189
|
+
body += chunk;
|
|
190
|
+
if (body.length > MAX_GRAPH_BYTES) {
|
|
191
|
+
req.destroy();
|
|
192
|
+
finish(null);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
res.on("end", () => finish(body.includes(BROWSE_PICKER_MODULE)));
|
|
196
|
+
res.on("error", () => finish(null));
|
|
197
|
+
});
|
|
198
|
+
req.on("error", () => finish(null));
|
|
199
|
+
req.setTimeout(timeoutMs, () => {
|
|
200
|
+
req.destroy();
|
|
201
|
+
finish(null);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 自检失败(确认未挂载浏览器内选择器)时的告警文案。
|
|
207
|
+
* 零文档导向:说清后果 + 直接给动作。
|
|
208
|
+
*/
|
|
209
|
+
export function remotePickerWarning() {
|
|
210
|
+
return ("rdsh: 远端浏览器的目录选择器不是浏览器内目录浏览器(很可能是宿主原生对话框,远端无法操作)。" +
|
|
211
|
+
"若装了 dsh-web-remote 插件,请检查 profile 的 cordis.patch.yml 是否被其它 patch 层覆盖(目录选择器只允许一个 pin 通道)。" +
|
|
212
|
+
"否则可能是 dsh 版本变更导致远端信号失效——升级 remote-dsh:npm i -g remote-dsh@latest。");
|
|
213
|
+
}
|
|
127
214
|
/** 探测 dsh 版本号(`dsh --version` 输出如 `0.1.2-rc.1`);失败/不可解析 → null。 */
|
|
128
215
|
export function detectDshVersion(dshPath, timeoutMs = VERSION_TIMEOUT_MS) {
|
|
129
216
|
return new Promise((resolve) => {
|