@mrrisega/dsh-remote 0.6.18-beta.1 → 0.6.18-beta.2
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/clients/dsh-remote/e2ee-shim-script.js +38 -6
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +43 -1
- package/package.json +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +38 -6
- package/packages/dsh-remote-web/test/setup-output.test.mjs +22 -0
|
@@ -710,7 +710,7 @@
|
|
|
710
710
|
/** 生成 window.WebSocket 的替换构造函数:命中策略才套 SeE2eeWs,否则原样放行。 */
|
|
711
711
|
function seMakeWsCtor(NativeWS, ctx) {
|
|
712
712
|
function SeWs(url, protocols) {
|
|
713
|
-
var full = "", should = false, scoped = String(url);
|
|
713
|
+
var full = "", should = false, scoped = String(url), passUrl = url;
|
|
714
714
|
try {
|
|
715
715
|
var base = ctx.origin || (typeof window !== "undefined" ? window.location.href : undefined);
|
|
716
716
|
var uo = new URL(url, base);
|
|
@@ -727,12 +727,19 @@
|
|
|
727
727
|
scoped = seScopeToDevice(uo.pathname + uo.search, ctx.deviceId);
|
|
728
728
|
var so = scoped === (uo.pathname + uo.search) ? uo : new URL(scoped, base);
|
|
729
729
|
full = so.pathname + so.search;
|
|
730
|
-
|
|
730
|
+
/* ★ 关键:改写结果要**留住**。原来不改写分支 return 的是原始 url,等于把
|
|
731
|
+
`/remote/<dev>/api/remote.mux` 又丢回根路径 —— 那正是 404 的来源。 */
|
|
732
|
+
passUrl = so.href;
|
|
733
|
+
/* ⚠️ 必须同时要求"有会话":URL 改写在无会话时也要做(这是 2026-09-26 事故的正题),
|
|
734
|
+
但**加密**没有会话就做不了 —— 少了这个判断,未解锁的页面上任何
|
|
735
|
+
`new WebSocket("/api/…")` 都会在 ctx.sess.sessId 上抛 TypeError,
|
|
736
|
+
把整个页面打挂(比原来"不装补丁"还糟)。 */
|
|
737
|
+
should = !!ctx.sess && seShouldEncryptWs(full);
|
|
731
738
|
}
|
|
732
739
|
} catch (e) { should = false; }
|
|
733
|
-
if (!should) return new NativeWS(
|
|
740
|
+
if (!should) return new NativeWS(passUrl, protocols);
|
|
734
741
|
var target = seBuildWsTarget(scoped, ctx.sess.sessId, seRandomHex(8), base);
|
|
735
|
-
if (!target) return new NativeWS(
|
|
742
|
+
if (!target) return new NativeWS(passUrl, protocols);
|
|
736
743
|
return new SeE2eeWs(NativeWS, target.href, protocols, { sess: ctx.sess, wsLabel: target.wsLabel });
|
|
737
744
|
}
|
|
738
745
|
SeWs.OPEN = 1; SeWs.CONNECTING = 0; SeWs.CLOSING = 2; SeWs.CLOSED = 3;
|
|
@@ -875,9 +882,28 @@
|
|
|
875
882
|
+ "一直这样请返回设备列表重新解锁(会自动重建加密通道)。");
|
|
876
883
|
}
|
|
877
884
|
|
|
878
|
-
/* ----
|
|
885
|
+
/* ---- 补丁安装:**URL 改写永远装,加密按会话开关** ----
|
|
886
|
+
*
|
|
887
|
+
* 🔴 2026-09-27 修(P0-1 收尾):原来 fetch/WS 补丁只在"有会话"时安装(`_install(sess)`),
|
|
888
|
+
* 可**这正是它最需要生效的场景**:镜像页没有会话 → 不装补丁 → 官方客户端把 mux WS 拼成根路径
|
|
889
|
+
* `/api/remote.mux` → 中继 `resolveRoute` 只认 `dsh_device` cookie,cookie 过期/缺失就 404
|
|
890
|
+
* (`rejectUpgrade` 写状态行后立刻 destroy → nginx 只看到不透明的 009)→
|
|
891
|
+
* `e2ee-hello` 永远送不到宿主机 → **会话永远建不起来** → 之后每个受保护请求都是
|
|
892
|
+
* `unknown_session` 502。**死循环**:没会话→不装补丁→建不了会话。
|
|
893
|
+
* 生产已有实锤:上线 reject 分支日志后中继立刻打出
|
|
894
|
+
* `WS 升级被拒: reason=route_unresolved status=404 path=/api/remote.mux`。
|
|
895
|
+
*
|
|
896
|
+
* 现在:`_install()` 在 boot 阶段**无条件**安装(`ctx.sess` 先为 null),会话到了再填进去。
|
|
897
|
+
* 补丁里"不改写就原样放行"的分支也会带上改写后的 URL(见 seMakeWsCtor 的 passUrl)——
|
|
898
|
+
* 否则改写算出来又被丢掉,等于没做。
|
|
899
|
+
*/
|
|
879
900
|
var _installed = false;
|
|
901
|
+
var _ctx = null;
|
|
880
902
|
function _install(sess) {
|
|
903
|
+
if (sess) {
|
|
904
|
+
// 会话后到:只更新 ctx(补丁已装好,闭包读的是同一个对象)
|
|
905
|
+
if (_ctx) { _ctx.sess = sess; return; }
|
|
906
|
+
}
|
|
881
907
|
if (_installed) return;
|
|
882
908
|
_installed = true;
|
|
883
909
|
seFailureHook = _reportFail;
|
|
@@ -886,7 +912,8 @@
|
|
|
886
912
|
/* deviceId 取自**页面路径**(`/remote/<deviceId>/…`,2026-09-19 起的标准入口):
|
|
887
913
|
WS 侧用它把根路径的 mux 改写成设备作用域形态,从而不再依赖会过期的 dsh_device cookie
|
|
888
914
|
(见 seScopeToDevice 的长注释与 2026-09-26 的 nginx 日志证据)。读不到就留空 = 不改写。 */
|
|
889
|
-
|
|
915
|
+
_ctx = { sess: sess || null, origin: window.location.origin, deviceId: seDeviceIdOfPath(window.location.pathname) };
|
|
916
|
+
var ctx = _ctx;
|
|
890
917
|
try {
|
|
891
918
|
if (window.fetch) {
|
|
892
919
|
var origFetch = window.fetch.bind(window);
|
|
@@ -906,6 +933,7 @@
|
|
|
906
933
|
function _boot() {
|
|
907
934
|
try {
|
|
908
935
|
if (!(window.crypto && window.crypto.subtle && window.crypto.getRandomValues)) {
|
|
936
|
+
_install(null);
|
|
909
937
|
_badge("warn", "未加密:环境不支持", "当前浏览器环境不支持 WebCrypto(需 HTTPS 安全上下文),镜像页数据流保持明文。");
|
|
910
938
|
return;
|
|
911
939
|
}
|
|
@@ -936,12 +964,16 @@
|
|
|
936
964
|
return;
|
|
937
965
|
}
|
|
938
966
|
}
|
|
967
|
+
/* ⚠️ 即使没有会话也必须先装补丁:WS 的设备作用域改写与加密无关(见 _install 长注释)。
|
|
968
|
+
否则这里 return 掉 → mux 打根路径 → 404 → 会话永远建不起来(死循环)。 */
|
|
969
|
+
_install(null);
|
|
939
970
|
_badge("warn", "未加密:未解锁(明文)", "本次为明文连接。密钥只存内存;请返回设备列表重新点选设备解锁后,镜像页的数据请求与 WebSocket 将自动加密。");
|
|
940
971
|
return;
|
|
941
972
|
}
|
|
942
973
|
var sess = null;
|
|
943
974
|
try { sess = seSessionOfHandover(raw); } catch (e) { sess = null; }
|
|
944
975
|
if (!sess) {
|
|
976
|
+
_install(null);
|
|
945
977
|
_badge("warn", "未加密:交接单无效", "一次性交接单无法解析(可能已损坏或过期)。请返回设备列表重新解锁。");
|
|
946
978
|
return;
|
|
947
979
|
}
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
maybeInjectE2eeShim,
|
|
40
40
|
e2eeShimEnabled
|
|
41
41
|
} from "../e2ee-shim.mjs";
|
|
42
|
-
import { loadShimCore, extractShimCore } from "./lib-e2ee-shim.mjs";
|
|
42
|
+
import { loadShimCore, extractShimCore, readShimScript } from "./lib-e2ee-shim.mjs";
|
|
43
43
|
import { loadNativeHandoverCore, extractNativeHandoverCore, loadNativeWcCore } from "./lib-native-e2ee.mjs";
|
|
44
44
|
|
|
45
45
|
const OFFICIAL_HTML = `<!doctype html>
|
|
@@ -562,3 +562,45 @@ test("fetch 包装:真实字节往返(shim 密封 → 桥端打开 → 封回
|
|
|
562
562
|
assert.equal(res.headers.get("x-round"), String(i));
|
|
563
563
|
}
|
|
564
564
|
});
|
|
565
|
+
|
|
566
|
+
// ─────────────── 2026-09-27:P0-1 收尾(设备作用域必须在"没有会话"时也生效) ───────────────
|
|
567
|
+
|
|
568
|
+
test("★ WS 设备作用域:不改写就走放行分支时,也必须用**改写后**的 URL", () => {
|
|
569
|
+
// 事故现场:镜像页**没有会话**(未解锁)→ 不加密 → 走放行分支。
|
|
570
|
+
// 官方客户端把 mux 拼成根路径 `/api/remote.mux`,中继只认会过期的 dsh_device cookie → 404
|
|
571
|
+
// (rejectUpgrade 写状态行后立刻 destroy → nginx 只看到 009)→ e2ee-hello 送不到 →
|
|
572
|
+
// 会话永远建不起来 → 每个受保护请求 unknown_session 502。死循环。
|
|
573
|
+
const ORIGIN = "http://relay.local";
|
|
574
|
+
const patched = shim.seMakeWsCtor(FakeNative, { sess: null, origin: ORIGIN, deviceId: "dev-8e5fb209cdc8" });
|
|
575
|
+
const ws = patched(ORIGIN + "/api/remote.mux?e2ee=abc&w=def");
|
|
576
|
+
assert.ok(ws instanceof FakeNative, "无会话 → 不套加密代理(放行)");
|
|
577
|
+
assert.equal(ws.url, ORIGIN + "/remote/dev-8e5fb209cdc8/api/remote.mux?e2ee=abc&w=def",
|
|
578
|
+
"🔴 放行分支必须带**改写后**的设备作用域 URL —— 否则改写算了又被丢掉,等于没做");
|
|
579
|
+
|
|
580
|
+
// 反向:本来就在设备作用域里 → 一个字节都不改
|
|
581
|
+
const ws2 = patched(ORIGIN + "/remote/dev-x/api/remote.mux?w=1");
|
|
582
|
+
assert.equal(ws2.url, ORIGIN + "/remote/dev-x/api/remote.mux?w=1", "已在设备作用域 → 原样放行");
|
|
583
|
+
|
|
584
|
+
// 反向:非受保护路径(如静态资源)不做设备改写,保持原样
|
|
585
|
+
const ws3 = patched(ORIGIN + "/assets/index.js");
|
|
586
|
+
assert.equal(ws3.url, ORIGIN + "/assets/index.js", "非受保护前缀不改写");
|
|
587
|
+
|
|
588
|
+
// 反向:拿不到 deviceId(例如旧入口 `/`)→ 不改写,且不得崩
|
|
589
|
+
const patchedNoDev = shim.seMakeWsCtor(FakeNative, { sess: null, origin: ORIGIN, deviceId: "" });
|
|
590
|
+
const ws4 = patchedNoDev(ORIGIN + "/api/remote.mux");
|
|
591
|
+
assert.equal(ws4.url, ORIGIN + "/api/remote.mux", "没有 deviceId 时保持原样(宁可 404 也不要乱拼)");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("★ 补丁必须在 boot 阶段**无条件**安装(没会话也要装,否则建不了会话)", () => {
|
|
595
|
+
const src = readShimScript();
|
|
596
|
+
// 三条"放弃会话"的早退分支,都必须在 return 之前先装补丁
|
|
597
|
+
for (const marker of ["未加密:未解锁(明文)", "未加密:交接单无效", "未加密:环境不支持"]) {
|
|
598
|
+
const i = src.indexOf(marker);
|
|
599
|
+
assert.ok(i > 0, `找不到分支 ${marker}`);
|
|
600
|
+
const before = src.slice(Math.max(0, i - 320), i);
|
|
601
|
+
assert.match(before, /_install\(null\)/, `🔴 「${marker}」分支必须在 return 之前先 _install(null),否则 WS 改写根本不生效`);
|
|
602
|
+
}
|
|
603
|
+
// 会话后到时只填 ctx,不重复包装(否则 fetch/WS 会被套多层)
|
|
604
|
+
assert.match(src, /if \(sess\) \{[\s\S]{0,200}?_ctx\.sess = sess; return;/,
|
|
605
|
+
"同一页面重复 _install(sess) 只能更新会话,不得重复包装 window.fetch/WebSocket");
|
|
606
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.18-beta.
|
|
3
|
+
"version": "0.6.18-beta.2",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.18-beta.
|
|
3
|
+
"version": "0.6.18-beta.2",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -710,7 +710,7 @@
|
|
|
710
710
|
/** 生成 window.WebSocket 的替换构造函数:命中策略才套 SeE2eeWs,否则原样放行。 */
|
|
711
711
|
function seMakeWsCtor(NativeWS, ctx) {
|
|
712
712
|
function SeWs(url, protocols) {
|
|
713
|
-
var full = "", should = false, scoped = String(url);
|
|
713
|
+
var full = "", should = false, scoped = String(url), passUrl = url;
|
|
714
714
|
try {
|
|
715
715
|
var base = ctx.origin || (typeof window !== "undefined" ? window.location.href : undefined);
|
|
716
716
|
var uo = new URL(url, base);
|
|
@@ -727,12 +727,19 @@
|
|
|
727
727
|
scoped = seScopeToDevice(uo.pathname + uo.search, ctx.deviceId);
|
|
728
728
|
var so = scoped === (uo.pathname + uo.search) ? uo : new URL(scoped, base);
|
|
729
729
|
full = so.pathname + so.search;
|
|
730
|
-
|
|
730
|
+
/* ★ 关键:改写结果要**留住**。原来不改写分支 return 的是原始 url,等于把
|
|
731
|
+
`/remote/<dev>/api/remote.mux` 又丢回根路径 —— 那正是 404 的来源。 */
|
|
732
|
+
passUrl = so.href;
|
|
733
|
+
/* ⚠️ 必须同时要求"有会话":URL 改写在无会话时也要做(这是 2026-09-26 事故的正题),
|
|
734
|
+
但**加密**没有会话就做不了 —— 少了这个判断,未解锁的页面上任何
|
|
735
|
+
`new WebSocket("/api/…")` 都会在 ctx.sess.sessId 上抛 TypeError,
|
|
736
|
+
把整个页面打挂(比原来"不装补丁"还糟)。 */
|
|
737
|
+
should = !!ctx.sess && seShouldEncryptWs(full);
|
|
731
738
|
}
|
|
732
739
|
} catch (e) { should = false; }
|
|
733
|
-
if (!should) return new NativeWS(
|
|
740
|
+
if (!should) return new NativeWS(passUrl, protocols);
|
|
734
741
|
var target = seBuildWsTarget(scoped, ctx.sess.sessId, seRandomHex(8), base);
|
|
735
|
-
if (!target) return new NativeWS(
|
|
742
|
+
if (!target) return new NativeWS(passUrl, protocols);
|
|
736
743
|
return new SeE2eeWs(NativeWS, target.href, protocols, { sess: ctx.sess, wsLabel: target.wsLabel });
|
|
737
744
|
}
|
|
738
745
|
SeWs.OPEN = 1; SeWs.CONNECTING = 0; SeWs.CLOSING = 2; SeWs.CLOSED = 3;
|
|
@@ -875,9 +882,28 @@
|
|
|
875
882
|
+ "一直这样请返回设备列表重新解锁(会自动重建加密通道)。");
|
|
876
883
|
}
|
|
877
884
|
|
|
878
|
-
/* ----
|
|
885
|
+
/* ---- 补丁安装:**URL 改写永远装,加密按会话开关** ----
|
|
886
|
+
*
|
|
887
|
+
* 🔴 2026-09-27 修(P0-1 收尾):原来 fetch/WS 补丁只在"有会话"时安装(`_install(sess)`),
|
|
888
|
+
* 可**这正是它最需要生效的场景**:镜像页没有会话 → 不装补丁 → 官方客户端把 mux WS 拼成根路径
|
|
889
|
+
* `/api/remote.mux` → 中继 `resolveRoute` 只认 `dsh_device` cookie,cookie 过期/缺失就 404
|
|
890
|
+
* (`rejectUpgrade` 写状态行后立刻 destroy → nginx 只看到不透明的 009)→
|
|
891
|
+
* `e2ee-hello` 永远送不到宿主机 → **会话永远建不起来** → 之后每个受保护请求都是
|
|
892
|
+
* `unknown_session` 502。**死循环**:没会话→不装补丁→建不了会话。
|
|
893
|
+
* 生产已有实锤:上线 reject 分支日志后中继立刻打出
|
|
894
|
+
* `WS 升级被拒: reason=route_unresolved status=404 path=/api/remote.mux`。
|
|
895
|
+
*
|
|
896
|
+
* 现在:`_install()` 在 boot 阶段**无条件**安装(`ctx.sess` 先为 null),会话到了再填进去。
|
|
897
|
+
* 补丁里"不改写就原样放行"的分支也会带上改写后的 URL(见 seMakeWsCtor 的 passUrl)——
|
|
898
|
+
* 否则改写算出来又被丢掉,等于没做。
|
|
899
|
+
*/
|
|
879
900
|
var _installed = false;
|
|
901
|
+
var _ctx = null;
|
|
880
902
|
function _install(sess) {
|
|
903
|
+
if (sess) {
|
|
904
|
+
// 会话后到:只更新 ctx(补丁已装好,闭包读的是同一个对象)
|
|
905
|
+
if (_ctx) { _ctx.sess = sess; return; }
|
|
906
|
+
}
|
|
881
907
|
if (_installed) return;
|
|
882
908
|
_installed = true;
|
|
883
909
|
seFailureHook = _reportFail;
|
|
@@ -886,7 +912,8 @@
|
|
|
886
912
|
/* deviceId 取自**页面路径**(`/remote/<deviceId>/…`,2026-09-19 起的标准入口):
|
|
887
913
|
WS 侧用它把根路径的 mux 改写成设备作用域形态,从而不再依赖会过期的 dsh_device cookie
|
|
888
914
|
(见 seScopeToDevice 的长注释与 2026-09-26 的 nginx 日志证据)。读不到就留空 = 不改写。 */
|
|
889
|
-
|
|
915
|
+
_ctx = { sess: sess || null, origin: window.location.origin, deviceId: seDeviceIdOfPath(window.location.pathname) };
|
|
916
|
+
var ctx = _ctx;
|
|
890
917
|
try {
|
|
891
918
|
if (window.fetch) {
|
|
892
919
|
var origFetch = window.fetch.bind(window);
|
|
@@ -906,6 +933,7 @@
|
|
|
906
933
|
function _boot() {
|
|
907
934
|
try {
|
|
908
935
|
if (!(window.crypto && window.crypto.subtle && window.crypto.getRandomValues)) {
|
|
936
|
+
_install(null);
|
|
909
937
|
_badge("warn", "未加密:环境不支持", "当前浏览器环境不支持 WebCrypto(需 HTTPS 安全上下文),镜像页数据流保持明文。");
|
|
910
938
|
return;
|
|
911
939
|
}
|
|
@@ -936,12 +964,16 @@
|
|
|
936
964
|
return;
|
|
937
965
|
}
|
|
938
966
|
}
|
|
967
|
+
/* ⚠️ 即使没有会话也必须先装补丁:WS 的设备作用域改写与加密无关(见 _install 长注释)。
|
|
968
|
+
否则这里 return 掉 → mux 打根路径 → 404 → 会话永远建不起来(死循环)。 */
|
|
969
|
+
_install(null);
|
|
939
970
|
_badge("warn", "未加密:未解锁(明文)", "本次为明文连接。密钥只存内存;请返回设备列表重新点选设备解锁后,镜像页的数据请求与 WebSocket 将自动加密。");
|
|
940
971
|
return;
|
|
941
972
|
}
|
|
942
973
|
var sess = null;
|
|
943
974
|
try { sess = seSessionOfHandover(raw); } catch (e) { sess = null; }
|
|
944
975
|
if (!sess) {
|
|
976
|
+
_install(null);
|
|
945
977
|
_badge("warn", "未加密:交接单无效", "一次性交接单无法解析(可能已损坏或过期)。请返回设备列表重新解锁。");
|
|
946
978
|
return;
|
|
947
979
|
}
|
|
@@ -134,3 +134,25 @@ test("版本比较:预设版 < 同号正式版;beta.10 < beta.11(更新提
|
|
|
134
134
|
assert.match(src, /const cmp = compareVersions\(latest, current\)/, "update-check 应用 semver 比较");
|
|
135
135
|
assert.match(src, /cmp === null \? Boolean\(latest && latest !== current\) : cmp > 0/);
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
test("★ 插件覆盖必须原子:staging → 必需文件自检 → rename 切换(2026-09-26「dsh 起不来」事故)", () => {
|
|
139
|
+
// 事故:旧实现 `rmSync(dest)` → `cpSync(pluginDir, dest)`,于是拷贝期间 dest 是"半个插件":
|
|
140
|
+
// dsh 恰在此刻启动 → package.json 已声明 bundle,但 cordis.patch.yml 还没拷到 →
|
|
141
|
+
// `failed to read overlay … cordis.patch.yml: ENOENT` → **整个 GUI 起不来**。
|
|
142
|
+
// 时间证据:dsh 报错 23:46:03,cordis.patch.yml 写完 23:46:05(差 2 秒)。
|
|
143
|
+
const reqFn = src.slice(src.indexOf("const PLUGIN_REQUIRED_FILES"), src.indexOf("function copyPluginIntoProfile"));
|
|
144
|
+
for (const f of ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"]) {
|
|
145
|
+
assert.match(reqFn, new RegExp(f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `必需文件清单缺 ${f}(缺了就又会出现"半个插件")`);
|
|
146
|
+
}
|
|
147
|
+
const fn = src.slice(src.indexOf("function copyPluginIntoProfile"), src.indexOf("function ensurePluginLinked"));
|
|
148
|
+
assert.match(fn, /\.staging-/, "必须先拷到 staging(全程不碰 dest)");
|
|
149
|
+
assert.match(fn, /missingPluginFiles\(stage\)/, "🔴 切换前必须自检必需文件");
|
|
150
|
+
assert.ok(fn.indexOf("missingPluginFiles(stage)") < fn.indexOf("renameSync(stage, dest)"),
|
|
151
|
+
"🔴 自检必须发生在 rename 切换**之前**");
|
|
152
|
+
assert.match(fn, /renameSync\(dest, backup\)/, "旧版本要先挪成 backup");
|
|
153
|
+
assert.match(fn, /renameSync\(backup, dest\)/, "切换失败必须把 backup 改回来(旧版仍可用)");
|
|
154
|
+
// 反向锁:绝不允许回到"先 rm dest、再 cpSync 逐文件"的旧写法
|
|
155
|
+
assert.ok(!/fs\.rmSync\(dest,\s*\{\s*recursive:\s*true,\s*force:\s*true\s*\}\);\s*\n\s*fs\.cpSync\(/.test(fn),
|
|
156
|
+
"🔴 不得再出现「先删 dest 再原地逐文件拷贝」——那正是这次事故的成因");
|
|
157
|
+
assert.match(fn, /\.staging-\$\{process\.pid\}|\.staging-/, "被强杀留下的 staging 要能清理");
|
|
158
|
+
});
|