@mrrisega/dsh-remote 0.3.9 → 0.4.1

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.
@@ -95,6 +95,22 @@ function saveLocalConfig(cfg) {
95
95
  }
96
96
  }
97
97
 
98
+ /**
99
+ * Harness 浏览器会话 Cookie(由 dsh-remote-ui 插件在进程内换取后写入
100
+ * <relayDir>/.harness-cookie.json)。新版 dsh web(0.1.2+)对每个请求校验该 Cookie,
101
+ * 不带则 401 → 手机端白页;bridge 对所有上游 HTTP/WS 请求自动携带,让手机表现为已授权浏览器。
102
+ */
103
+ const HARNESS_COOKIE_FILE = ".harness-cookie.json";
104
+ function harnessCookieOf() {
105
+ try {
106
+ const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_FILE);
107
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
108
+ return j && typeof j.cookie === "string" && j.cookie ? j.cookie : "";
109
+ } catch {
110
+ return "";
111
+ }
112
+ }
113
+
98
114
  /**
99
115
  * 解析稳定 deviceId:优先级 env DSH_BRIDGE_DEVICE_ID > argv[2] > 配置 device_id > 生成。
100
116
  * 生成格式 dev-<12hex> 并持久化到 .dsh-config.json,保证每台 Mac 重启后 id 不变。
@@ -257,6 +273,8 @@ export function buildWsHeaders(headers) {
257
273
  const up = new URL(UPSTREAM);
258
274
  const out = sanitizeRequestHeaders(headers);
259
275
  out.Host = up.host; // e.g. "127.0.0.1:3080"
276
+ const ck = harnessCookieOf(); // 新版 dsh web 的浏览器会话 Cookie
277
+ if (ck) out.Cookie = ck;
260
278
  return out;
261
279
  }
262
280
 
@@ -335,7 +353,10 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
335
353
  const safe = safePath(path);
336
354
  if (safe === null) throw new Error("非法路径");
337
355
  const url = `${UPSTREAM}${safe}`;
338
- const init = { method, headers: sanitizeRequestHeaders(reqHeaders) };
356
+ const reqHdrs = sanitizeRequestHeaders(reqHeaders);
357
+ const ck = harnessCookieOf(); // 新版 dsh web 的浏览器会话 Cookie(否则 401 白页)
358
+ if (ck) reqHdrs.Cookie = ck;
359
+ const init = { method, headers: reqHdrs };
339
360
  if (body !== undefined && body !== null && body !== "") {
340
361
  // 新协议 http 帧的 body 一律 base64;旧协议 body 是原始文本
341
362
  init.body = isB64 ? Buffer.from(String(body), "base64") : String(body);
package/dsh-setup.mjs CHANGED
@@ -99,6 +99,19 @@ function deriveTunnelUrl(apiUrl) {
99
99
  return apiUrl.replace(/\/relay-api\/?$/, "").replace(/^https/, "wss");
100
100
  }
101
101
 
102
+ /**
103
+ * 模式归一化(SaaS 权威):清掉自建残留(local_key/假 tunnel_url),api_url 与
104
+ * tunnel_url 一律按云端权威地址重算。防止"切过自建后又登云端"时残留配置把
105
+ * bridge 带到错误服务器(此前手机永远看不到设备的根因之一)。
106
+ */
107
+ function applySaaSMode(cfg) {
108
+ delete cfg.local_key;
109
+ delete cfg.server; // 旧字段兜底
110
+ cfg.api_url = String(cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
111
+ cfg.tunnel_url = deriveTunnelUrl(cfg.api_url);
112
+ return cfg;
113
+ }
114
+
102
115
  /** 归一化自建服务器地址:缺省补 wss://,去掉末尾 / */
103
116
  function normalizeTunnelUrl(raw) {
104
117
  let u = String(raw || "").trim().replace(/\/+$/, "");
@@ -239,9 +252,7 @@ function serveSettings() {
239
252
  return send(400, JSON.stringify({ ok: false, message: "SaaS 模式需要手机号与密码" }), "application/json");
240
253
  }
241
254
  cfg.phone = body.phone; cfg.password = body.password;
242
- delete cfg.local_key;
243
- cfg.api_url = body.api_url || cfg.api_url || DEFAULT_API;
244
- if (!cfg.tunnel_url) cfg.tunnel_url = deriveTunnelUrl(cfg.api_url);
255
+ applySaaSMode(cfg); // 清自建残留,api/tunnel 权威重算
245
256
  }
246
257
  // 自动获取服务端下发的 bridge_secret(device-login 共享密钥,一键安装开箱即用)
247
258
  if (!cfg.bridge_secret && !cfg.local_key) {
@@ -328,7 +339,7 @@ async function runBridge() {
328
339
  // 每次循环重读配置:设置页登录/切换模式后无需重启守护即可生效
329
340
  cfg = loadConfig();
330
341
  const saas = Boolean((cfg.phone || cfg.email) && cfg.password);
331
- const local = Boolean(cfg.local_key);
342
+ let local = Boolean(cfg.local_key);
332
343
  if (!saas && !local) {
333
344
  if (!warnedNoLogin) {
334
345
  warnedNoLogin = true;
@@ -341,9 +352,13 @@ async function runBridge() {
341
352
  console.log("⚠️ 自建模式缺少服务器地址:请用 `dsh-remote setup --server wss://host:port --key <密钥>` 重新配置。");
342
353
  return;
343
354
  }
344
- if (saas && !cfg.tunnel_url) {
345
- cfg.tunnel_url = deriveTunnelUrl(cfg.api_url || DEFAULT_API);
355
+ if (saas) {
356
+ // SaaS 权威归一化:清掉任何自建残留,tunnel/api 始终指向云端地址
357
+ applySaaSMode(cfg);
358
+ local = false;
346
359
  saveConfig(cfg);
360
+ } else if (local && !cfg.tunnel_url) {
361
+ cfg.tunnel_url = deriveTunnelUrl(cfg.api_url || DEFAULT_API);
347
362
  }
348
363
  const apiUrl = saas ? (cfg.api_url || DEFAULT_API) : "";
349
364
 
@@ -458,7 +473,8 @@ async function setup(argv) {
458
473
  console.log(` 手机端: 打开 ${cfg.tunnel_url.replace(/^ws/, "https")}/app/ ,用访问密钥登录即可。`);
459
474
  } else {
460
475
  console.log(` 远程控制地址: ${pub.app_url || DEFAULT_APP_URL}`);
461
- console.log(` 下一步: 运行 \`dsh-remote settings\` 打开设置页,用手机号+密码登录(或注册)。`);
476
+ console.log(` 下一步(小白/无需任何命令): 打开 dsh web → 设置 → 「远程控制」→ 注册/登录手机号即可。`);
477
+ console.log(` 若你是从 DeepSeek App/插件市场 安装:请【完全退出并重开 App】(或刷新 profile),插件即生效。`);
462
478
  console.log(` 登录后 bridge 会自动启动,手机端即可看到本机。`);
463
479
  }
464
480
  if (svc.path) console.log(` 自启动服务: ${svc.path}`);
@@ -652,7 +668,8 @@ async function pluginCmd(argv) {
652
668
  : "✅ node_modules/dsh-remote-ui 已就绪");
653
669
 
654
670
  console.log(`✅ 插件安装完成。配置目录: ${CONFIG_DIR}`);
655
- console.log(" 重启 dsh web(或重开 profile)后,在「设置远程控制」查看面板。");
671
+ console.log(" 打开 dsh web 设置 「远程控制」,注册/登录手机号即可(无需任何命令)。");
672
+ console.log(" 若从 DeepSeek App/插件市场 安装:请完全退出并重开 App 让插件生效。");
656
673
  }
657
674
 
658
675
  // ---------- main ----------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.3.9",
3
+ "version": "0.4.1",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -106,6 +106,95 @@ function saveConfig(relayDir, cfg) {
106
106
  }
107
107
  }
108
108
 
109
+ /**
110
+ * SaaS 模式权威归一化(与 dsh-setup.mjs 同规则):
111
+ * 清除自建残留(local_key/假 tunnel_url),api_url 与 tunnel_url 一律按云端权威地址重算。
112
+ * 解决“切过自建(填了假地址)后,再登/重装云端账号仍连错服务器、手机看不到设备”的残留配置问题。
113
+ */
114
+ function applySaaSMode(cfg) {
115
+ delete cfg.local_key;
116
+ delete cfg.server;
117
+ cfg.api_url = String(cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
118
+ cfg.tunnel_url = cfg.api_url.replace(/\/relay-api\/?$/, "").replace(/^https/, "wss");
119
+ return cfg;
120
+ }
121
+
122
+ // ---------- Harness 浏览器会话代持(0.1.2-rc.1+ 的 ?token 鉴权) ----------
123
+
124
+ /**
125
+ * 新版 dsh web(0.1.2-rc.1+)启动会打印带 ?token= 的 URL,并只给"换到了会话 Cookie"的浏览器放行,
126
+ * 其余请求一律 401(手机经隧道因此白页)。本插件与 Harness 同进程:
127
+ * - 通过 ctx.connection 服务拿到本进程 launch token(authenticatedUrl 自带 token);
128
+ * - 在本地向 /?token=… 发起 token 交换,捕获下发的 dsh-auth-* 会话 Cookie;
129
+ * - 写入 <relayDir>/.harness-cookie.json,bridge 上游转发时自动携带,让手机表现为已授权浏览器。
130
+ * 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
131
+ */
132
+ const HARNESS_COOKIE_FILE = ".harness-cookie.json";
133
+ const HARNESS_AUTH_RETRY_MS = 3000;
134
+ const HARNESS_AUTH_RETRY_MAX = 20; // 最多约 60s 等 connection 服务就绪
135
+ const HARNESS_AUTH_REFRESH_MS = 6 * 3600 * 1000;
136
+
137
+ /** 读取响应里的 set-cookie(兼容 getSetCookie / get 两种实现)。 */
138
+ function setCookieOf(res) {
139
+ try {
140
+ if (typeof res.headers.getSetCookie === "function") return res.headers.getSetCookie().join("; ");
141
+ } catch { /* ignore */ }
142
+ return res.headers.get("set-cookie") || "";
143
+ }
144
+
145
+ async function mintHarnessCookie(ctx, relayDir) {
146
+ try {
147
+ const port = ctx.webServer?.port;
148
+ if (!port) return false;
149
+ // 不能把 "connection" 写进 inject(0.1.1 无该服务会拖死激活),只能运行时 try 获取
150
+ let holder;
151
+ try { holder = ctx.get("connection"); } catch { holder = void 0; }
152
+ if (!holder && ctx.connection !== void 0) { try { holder = ctx.connection; } catch { /* ignore */ } }
153
+ const svc = holder && typeof holder.authenticatedUrl === "function"
154
+ ? holder
155
+ : holder && holder.connection && typeof holder.connection.authenticatedUrl === "function"
156
+ ? holder.connection
157
+ : null;
158
+ if (!svc) return false; // 老版本无浏览器鉴权 → 无需 cookie
159
+ const tokenUrl = svc.authenticatedUrl(`http://127.0.0.1:${port}`);
160
+ const res = await fetch(tokenUrl, { redirect: "manual", signal: AbortSignal.timeout(6000) });
161
+ const cookie = setCookieOf(res).split(";")[0].trim();
162
+ if (!cookie || !cookie.startsWith("dsh-auth-")) return false;
163
+ const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
164
+ mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
165
+ writeFileSync(join(relayDir, HARNESS_COOKIE_FILE), JSON.stringify(out, null, 2), { mode: 0o600 });
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ /** 后台调度:启动重试直到换取成功,成功后每 6h 刷新(与插件生命周期同进退)。 */
173
+ function scheduleHarnessMint(ctx, relayDir) {
174
+ let succeeded = false;
175
+ let retries = 0;
176
+ const attempt = async () => {
177
+ if (succeeded) return;
178
+ if (await mintHarnessCookie(ctx, relayDir)) succeeded = true;
179
+ };
180
+ const bootIv = setInterval(() => {
181
+ if (succeeded || ++retries > HARNESS_AUTH_RETRY_MAX) {
182
+ clearInterval(bootIv);
183
+ return;
184
+ }
185
+ void attempt();
186
+ }, HARNESS_AUTH_RETRY_MS);
187
+ bootIv.unref?.();
188
+ const refreshIv = setInterval(() => {
189
+ void mintHarnessCookie(ctx, relayDir).catch(() => {});
190
+ }, HARNESS_AUTH_REFRESH_MS);
191
+ refreshIv.unref?.();
192
+ return () => {
193
+ clearInterval(bootIv);
194
+ clearInterval(refreshIv);
195
+ };
196
+ }
197
+
109
198
  // ---------- bridge 服务状态 / 启停(launchctl,macOS) ----------
110
199
 
111
200
  function launchAgentPath() {
@@ -524,14 +613,8 @@ function registerRoutes(ctx, relayDir) {
524
613
  delete cfg.device_private_key;
525
614
  delete cfg.device_public_key;
526
615
  }
527
- // 切回 SaaS:仅当此前是自建模式(local_key)才清理本地隧道/API 地址;
528
- // 正常 SaaS 用户的 tunnel_url 是核心配置(wss://公网域名),绝不能删
529
- const wasLocal = Boolean(cfg.local_key);
530
- delete cfg.local_key;
531
- if (wasLocal) {
532
- if (cfg.tunnel_url) delete cfg.tunnel_url;
533
- if (cfg.api_url) delete cfg.api_url;
534
- }
616
+ // SaaS 权威归一化:清除自建残留(local_key/假 tunnel_url),api/tunnel 一律按云端重算
617
+ applySaaSMode(cfg);
535
618
  }
536
619
  saveConfig(relayDir, cfg);
537
620
  const bridgeRestart = startBridge(relayDir);
@@ -729,5 +812,7 @@ function registerRoutes(ctx, relayDir) {
729
812
  export function apply(ctx, config = {}) {
730
813
  const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
731
814
  ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-ui: /dsh-remote routes");
815
+ // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
816
+ ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-ui: harness browser-session mint");
732
817
  ctx.logger?.info?.(`dsh-remote-ui: /dsh-remote routes ready (relayDir=${relayDir})`);
733
818
  }