@mrrisega/dsh-remote 0.7.0-beta.1 → 0.7.0-beta.3
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/dsh-bridge.mjs +128 -12
- package/clients/dsh-remote/dsh-events.mjs +66 -8
- package/clients/dsh-remote/mobile-adapter.mjs +19 -7
- package/clients/dsh-remote/test/device-registration-report.test.mjs +133 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +88 -0
- package/clients/dsh-remote/test/harness-cookie-authority.test.mjs +121 -0
- package/clients/dsh-remote/test/harness-cookie-selfheal.test.mjs +15 -7
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +41 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +87 -1
- package/clients/dsh-remote/wechat-runtime.mjs +88 -13
- package/dsh-setup.mjs +8 -2
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +67 -8
- package/packages/dsh-remote-web/lib/index.js +160 -19
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +128 -12
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +66 -8
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +19 -7
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +88 -13
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +8 -2
- package/packages/dsh-remote-web/test/desktop-compat.test.mjs +39 -6
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +178 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +38 -0
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +13 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// dsh-remote-web 侧栏快捷入口「落点」回归(2026-10-09 桌面端事故)。
|
|
2
|
+
//
|
|
3
|
+
// 【事故】桌面端(DSH 0.2)设置面板本身就是 `role="dialog" aria-modal="true"` 的弹层,
|
|
4
|
+
// 它左侧栏目列表的第一项叫 **「通用设置」**。旧代码用「文本包含『设置』」(`indexOf !== -1`)
|
|
5
|
+
// 去找侧栏那颗齿轮按钮 → **命中的是弹层里的「通用设置」**,于是我们的「📱 远程访问」
|
|
6
|
+
// 快捷按钮被插进了设置面板栏目列表的最上面。业主截图反馈:「植入错了地方」。
|
|
7
|
+
//
|
|
8
|
+
// 本用例把 client.js 里的导航注入辅助函数**原样切出来**,喂一个最小 DOM,逐条锁住:
|
|
9
|
+
// ① 官方「设置」触发器只按**语义锚点**(aria-haspopup="dialog")识别,且必须在弹层之外;
|
|
10
|
+
// ② 弹层内的「通用设置」**永远不能**被当成落点;
|
|
11
|
+
// ③ 精确相等优先于子串匹配;
|
|
12
|
+
// ④ 栏目项(在弹层内)仍能被找到 —— 修好落点不能把「点开栏目」这条路径弄坏。
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
|
|
17
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
18
|
+
|
|
19
|
+
/** 把 `var NAV_ENTRY_ID` 到 `openRemoteSettings` 之前的那段辅助函数切出来(含依赖的常量)。 */
|
|
20
|
+
function navHelpersSource() {
|
|
21
|
+
const start = SOURCE.indexOf('var NAV_ENTRY_ID = "dru-nav-remote";');
|
|
22
|
+
const end = SOURCE.indexOf("/** 打开「远程访问」设置栏目");
|
|
23
|
+
assert.ok(start > 0 && end > start, "切片锚点失效:侧栏入口辅助函数结构变了");
|
|
24
|
+
return SOURCE.slice(start, end);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ───────────────────────── 最小 DOM ─────────────────────────
|
|
28
|
+
// 只实现被测代码真正用到的那几个能力:属性/类名/文本、closest、querySelectorAll。
|
|
29
|
+
class El {
|
|
30
|
+
constructor(tag, opts = {}) {
|
|
31
|
+
this.tagName = String(tag).toUpperCase();
|
|
32
|
+
this.id = opts.id || "";
|
|
33
|
+
this.className = opts.cls || "";
|
|
34
|
+
this.attrs = opts.attrs || {};
|
|
35
|
+
this.ownText = opts.text || "";
|
|
36
|
+
this.parentNode = null;
|
|
37
|
+
this.children = [];
|
|
38
|
+
}
|
|
39
|
+
get textContent() {
|
|
40
|
+
return this.ownText + this.children.map((c) => c.textContent).join("");
|
|
41
|
+
}
|
|
42
|
+
getAttribute(name) {
|
|
43
|
+
if (name === "class") return this.className;
|
|
44
|
+
if (name === "id") return this.id;
|
|
45
|
+
return Object.prototype.hasOwnProperty.call(this.attrs, name) ? String(this.attrs[name]) : null;
|
|
46
|
+
}
|
|
47
|
+
append(...nodes) {
|
|
48
|
+
for (const n of nodes) { n.parentNode = this; this.children.push(n); }
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
/** 冒泡查最近的祖先(含自身)是否匹配选择器。 */
|
|
52
|
+
closest(selector) {
|
|
53
|
+
let cur = this;
|
|
54
|
+
while (cur) {
|
|
55
|
+
if (matches(cur, selector)) return cur;
|
|
56
|
+
cur = cur.parentNode;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function descendants(root, out = []) {
|
|
62
|
+
for (const c of root.children) { out.push(c); descendants(c, out); }
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** 单条复合选择器:tag + [attr] / [attr="v"] / [class*="x" i]。够用即可,不求完整 CSS。 */
|
|
66
|
+
function matchesSimple(el, sel) {
|
|
67
|
+
const m = /^([a-zA-Z]*)((?:\[[^\]]*\])*)$/.exec(sel.trim());
|
|
68
|
+
if (!m) return false;
|
|
69
|
+
const [, tag, attrPart] = m;
|
|
70
|
+
if (tag && el.tagName !== tag.toUpperCase()) return false;
|
|
71
|
+
const attrs = attrPart.match(/\[[^\]]*\]/g) || [];
|
|
72
|
+
for (const a of attrs) {
|
|
73
|
+
const inner = a.slice(1, -1);
|
|
74
|
+
const eq = /^([^=*^$|~]+)(?:([*^$|~]?=)"?([^"]*)"?)?(?:\s+i)?$/.exec(inner);
|
|
75
|
+
if (!eq) return false;
|
|
76
|
+
const [, name, op, val] = eq;
|
|
77
|
+
const caseInsensitive = /\si$/.test(inner);
|
|
78
|
+
const actual = el.getAttribute(name.trim());
|
|
79
|
+
if (op === undefined) { if (actual === null) return false; continue; }
|
|
80
|
+
const hay = String(actual ?? "");
|
|
81
|
+
const needle = String(val ?? "");
|
|
82
|
+
const [H, N] = caseInsensitive ? [hay.toLowerCase(), needle.toLowerCase()] : [hay, needle];
|
|
83
|
+
if (op === "=" && H !== N) return false;
|
|
84
|
+
if (op === "*=" && H.indexOf(N) === -1) return false;
|
|
85
|
+
if (op === "^=" && H.indexOf(N) !== 0) return false;
|
|
86
|
+
if (op === "$=" && !H.endsWith(N)) return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
function matches(el, selector) {
|
|
91
|
+
return String(selector).split(",").some((one) => matchesSimple(el, one));
|
|
92
|
+
}
|
|
93
|
+
function makeDocument(roots) {
|
|
94
|
+
const all = roots.flatMap((r) => [r, ...descendants(r)]);
|
|
95
|
+
return {
|
|
96
|
+
_all: all,
|
|
97
|
+
getElementById: (id) => all.find((e) => e.id === id) || null,
|
|
98
|
+
querySelectorAll: (selector) => all.filter((e) => matches(e, selector)),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function loadNavHelpers(doc) {
|
|
102
|
+
const src = navHelpersSource() + "\nreturn { pickSettingsTrigger, pickNavByToken, navInDialog };";
|
|
103
|
+
// eslint-disable-next-line no-new-func
|
|
104
|
+
return new Function("document", "window", src)(doc, {});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 复刻桌面端 0.2 的真实结构:侧栏一颗齿轮 + 设置弹层(首项「通用设置」)。 */
|
|
108
|
+
function desktopFixture() {
|
|
109
|
+
const sidebar = new El("div", { cls: "sidebar" });
|
|
110
|
+
const trigger = new El("button", {
|
|
111
|
+
cls: "Dws9Sa_trigger",
|
|
112
|
+
attrs: { "aria-label": "设置", "aria-haspopup": "dialog", "aria-expanded": "false" },
|
|
113
|
+
text: "设置"
|
|
114
|
+
});
|
|
115
|
+
sidebar.append(trigger);
|
|
116
|
+
|
|
117
|
+
const dialog = new El("div", { attrs: { role: "dialog", "aria-modal": "true" } });
|
|
118
|
+
const nav = new El("nav", { cls: "Dws9Sa_nav" });
|
|
119
|
+
const general = new El("button", { cls: "Dws9Sa_navCell", text: "通用设置" });
|
|
120
|
+
const remote = new El("button", { cls: "Dws9Sa_navCell", text: "📱 远程访问" });
|
|
121
|
+
const models = new El("button", { cls: "Dws9Sa_navCell", text: "模型" });
|
|
122
|
+
nav.append(general, models, remote);
|
|
123
|
+
dialog.append(nav);
|
|
124
|
+
|
|
125
|
+
const doc = makeDocument([sidebar, dialog]);
|
|
126
|
+
return { doc, trigger, dialog, general, remote };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ───────────────────────── 用例 ─────────────────────────
|
|
130
|
+
|
|
131
|
+
test("★ 事故锁:官方「设置」触发器只认语义锚点,且必须落在**设置弹层之外**", () => {
|
|
132
|
+
const { doc, trigger } = desktopFixture();
|
|
133
|
+
const { pickSettingsTrigger } = loadNavHelpers(doc);
|
|
134
|
+
assert.equal(pickSettingsTrigger(), trigger, "必须命中侧栏那颗 aria-haspopup=dialog 的齿轮按钮");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("★ 事故锁:弹层里的「通用设置」永远不能成为落点", () => {
|
|
138
|
+
const { doc, general } = desktopFixture();
|
|
139
|
+
const { pickSettingsTrigger, pickNavByToken } = loadNavHelpers(doc);
|
|
140
|
+
assert.notEqual(pickSettingsTrigger(), general, "「通用设置」在弹层内,绝不能被当成侧栏设置按钮");
|
|
141
|
+
// 严格等值 + 只要弹层外:结果只能是侧栏触发器,不能是「通用设置」
|
|
142
|
+
assert.notEqual(pickNavByToken("设置", { exactOnly: true, insideDialog: false }), general);
|
|
143
|
+
// 老写法(子串匹配)现在只能作为**兜底**,精确命中优先 —— 先保证不误伤
|
|
144
|
+
assert.notEqual(pickNavByToken("设置"), general, "子串匹配不得再压过精确匹配");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("弹层内没有 aria-haspopup 的栏目项,不能被当成「设置」触发器", () => {
|
|
148
|
+
const dialogOnly = new El("div", { attrs: { role: "dialog", "aria-modal": "true" } });
|
|
149
|
+
const nav = new El("nav");
|
|
150
|
+
nav.append(new El("button", { cls: "navCell", text: "通用设置" }));
|
|
151
|
+
dialogOnly.append(nav);
|
|
152
|
+
const { pickSettingsTrigger } = loadNavHelpers(makeDocument([dialogOnly]));
|
|
153
|
+
assert.equal(pickSettingsTrigger(), null, "找不到可信锚点时必须**不注入**,绝不将就一个错落点");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("栏目项仍在弹层内被找到(修落点不能把「打开远程访问栏目」这条路弄坏)", () => {
|
|
157
|
+
const { doc, remote } = desktopFixture();
|
|
158
|
+
const { pickNavByToken } = loadNavHelpers(doc);
|
|
159
|
+
assert.equal(pickNavByToken("远程访问", { insideDialog: true }), remote);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("精确相等优先于子串:给了 exactOnly 时子串一律不算", () => {
|
|
163
|
+
const root = new El("div", { cls: "sidebar" });
|
|
164
|
+
const general = new El("button", { cls: "navCell", text: "通用设置" });
|
|
165
|
+
const settings = new El("button", { cls: "navCell", text: "设置" });
|
|
166
|
+
root.append(general, settings);
|
|
167
|
+
const { pickNavByToken } = loadNavHelpers(makeDocument([root]));
|
|
168
|
+
assert.equal(pickNavByToken("设置", { exactOnly: true, insideDialog: false }), settings);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("源码约束:注入路径不得再出现「按文案模糊找设置」的写法", () => {
|
|
172
|
+
assert.ok(!/pickNavByToken\(\s*"设置"\s*\)/.test(SOURCE),
|
|
173
|
+
"侧栏落点不得再用 pickNavByToken(\"设置\")(子串会命中「通用设置」→ 按钮进设置面板)");
|
|
174
|
+
assert.match(SOURCE, /aria-haspopup="dialog"/, "必须按 aria-haspopup=dialog 认官方设置触发器");
|
|
175
|
+
assert.match(SOURCE, /\[role="dialog"\], \[aria-modal="true"\]/, "必须有「在设置弹层内」的判据");
|
|
176
|
+
assert.match(SOURCE, /var host = pickSettingsTrigger\(\)/, "常驻注入必须走语义锚点");
|
|
177
|
+
assert.match(SOURCE, /if \(navInDialog\(host\)\) return;/, "mountNavEntry 必须有最后一道弹层防线");
|
|
178
|
+
});
|
|
@@ -414,6 +414,44 @@ const m = await import(${JSON.stringify(INDEX_URL)});
|
|
|
414
414
|
console.log(JSON.stringify(m.__telemetryInternals.eventOf("plugin_loaded")));
|
|
415
415
|
`;
|
|
416
416
|
|
|
417
|
+
test("★ 宿主版本(桌面端 0.2 真机形态):argv[1] 是开关时,仍能从 argv 里的宿主根取到版本", async () => {
|
|
418
|
+
// 真机实测(DeepSeek Harness 0.2,Electron)宿主进程 argv:
|
|
419
|
+
// [0] …/MacOS/DeepSeek Harness
|
|
420
|
+
// [1] --expose-internals ← 开关,不是路径
|
|
421
|
+
// [2] …/app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/index.js
|
|
422
|
+
// [3] …/app.asar/dsh ← 宿主根(含 node_modules/@deepseek-ai/dsh)
|
|
423
|
+
// 旧实现只看 argv[1] → 桌面端**必然**取不到版本(这正是生产反馈里答不出宿主版本的原因)。
|
|
424
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-harness-desktop-"));
|
|
425
|
+
try {
|
|
426
|
+
const hostRoot = path.join(root, "app.asar", "dsh");
|
|
427
|
+
await fakeHarness(path.join(hostRoot, "node_modules", "@deepseek-ai", "dsh"), { version: "0.2.0-rc.2" });
|
|
428
|
+
assert.equal(TELEMETRY.harnessVersionFromRoot(hostRoot), "0.2.0-rc.2", "宿主根里应能读到 @deepseek-ai/dsh 的版本");
|
|
429
|
+
|
|
430
|
+
const argv = [
|
|
431
|
+
"/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness",
|
|
432
|
+
"--expose-internals",
|
|
433
|
+
path.join(hostRoot, "node_modules", "@deepseek-ai", "dsh-desktop-host", "lib", "index.js"),
|
|
434
|
+
hostRoot,
|
|
435
|
+
path.join(root, "profiles", "desktop"),
|
|
436
|
+
];
|
|
437
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv(argv), "0.2.0-rc.2", "★桌面端 argv 必须能解析出宿主版本");
|
|
438
|
+
|
|
439
|
+
// 开关/相对路径绝不能被当成路径去 realpath(否则抛错或被当成"未知"掩盖真实问题)
|
|
440
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv(["--expose-internals", "-x"]), "", "全是开关 → 空串");
|
|
441
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv(["relative/path", ""]), "", "非绝对路径 → 空串");
|
|
442
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv([]), "", "空 argv → 空串");
|
|
443
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv(undefined), "", "undefined → 空串(绝不抛)");
|
|
444
|
+
// 不是 DSH 的宿主根(有 package.json 但名字不对)→ 空串,绝不猜
|
|
445
|
+
const otherRoot = path.join(root, "other-root");
|
|
446
|
+
await fakeHarness(path.join(otherRoot, "node_modules", "@deepseek-ai", "dsh"), { name: "not-dsh", version: "1.0.0" });
|
|
447
|
+
assert.equal(TELEMETRY.harnessVersionFromRoot(otherRoot), "", "名字不是 @deepseek-ai/dsh → 空串");
|
|
448
|
+
// Web 版形态仍然照旧可用(argv[1] 是 DSH 主模块)
|
|
449
|
+
const webEntry = await fakeHarness(path.join(root, "web", "node_modules", "@deepseek-ai", "dsh"), { version: "0.1.5-rc.2" });
|
|
450
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv([webEntry]), "0.1.5-rc.2", "Web 版(argv[1]=主模块)路径不得被改坏");
|
|
451
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
|
|
417
455
|
test("宿主版本:运行时从主模块解析(argv[1] → 上一级 package.json);未知一律空串且不抛", async () => {
|
|
418
456
|
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-harness-"));
|
|
419
457
|
try {
|
|
@@ -407,7 +407,7 @@ test("清理助手:护栏命中时绝不删 profile(relayDir 误配成 profi
|
|
|
407
407
|
test("安全:三处机密写入都要显式收紧 ACL(Windows 上 mode 0o600 无效)", () => {
|
|
408
408
|
for (const [label, src, callSites] of [
|
|
409
409
|
["安装器", SETUP_SRC, [/hardenFile\(CONFIG_PATH\)/]],
|
|
410
|
-
["插件半", INDEX_SRC, [/hardenFile\(configPathOf\(relayDir\)\)
|
|
410
|
+
["插件半", INDEX_SRC, [/hardenFile\(configPathOf\(relayDir\)\)/]],
|
|
411
411
|
["bridge", fs.readFileSync(path.join(HERE, "..", "..", "..", "clients", "dsh-remote", "dsh-bridge.mjs"), "utf8"), [/hardenFile\(CONFIG_PATH\)/]],
|
|
412
412
|
]) {
|
|
413
413
|
assert.ok(/function hardenFile\(file\)/.test(src), `${label} 应有 hardenFile`);
|
|
@@ -417,6 +417,18 @@ test("安全:三处机密写入都要显式收紧 ACL(Windows 上 mode 0o600
|
|
|
417
417
|
}
|
|
418
418
|
});
|
|
419
419
|
|
|
420
|
+
test("安全:Cookie 的每个写入点都必须紧跟着 hardenFile(按 authority 分文件后不得漏收)", () => {
|
|
421
|
+
// 0.7.0-beta.2 把 Cookie 从「一份共享文件」改成「本宿主专属 + 旧版镜像」两份(见 P0-2 事故根治)。
|
|
422
|
+
// 写点从 1 个变 2 个 —— 逐个按变量名断言,漏收的那一份在 Windows 上就是 ACL 敞开状态。
|
|
423
|
+
const mint = INDEX_SRC.slice(INDEX_SRC.indexOf("async function mintHarnessCookie"), INDEX_SRC.indexOf("function readHarnessCookie"));
|
|
424
|
+
assert.ok(mint.length > 0, "应能定位到 mintHarnessCookie(找不到说明函数被改名,请同步本用例)");
|
|
425
|
+
const written = [...mint.matchAll(/writeFileSync\((\w+),/g)].map((m) => m[1]);
|
|
426
|
+
assert.ok(written.length >= 2, `mintHarnessCookie 应写两份 Cookie(本宿主专属 + 旧版镜像),实际 ${written.length}`);
|
|
427
|
+
for (const v of written) {
|
|
428
|
+
assert.ok(new RegExp(`hardenFile\\(${v}\\)`).test(mint), `Cookie 写入点 ${v} 之后必须调用 hardenFile 收紧 ACL`);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
420
432
|
test("安全:hardenFile 在非 Windows 上只 chmod(不引入任何子进程,零副作用)", () => {
|
|
421
433
|
const src = SETUP_SRC.slice(SETUP_SRC.indexOf("function hardenFile(file)"), SETUP_SRC.indexOf("// ---------- 配置读写 ----------"));
|
|
422
434
|
const calls = [];
|