@mrrisega/dsh-remote 0.6.14 → 0.6.15
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 +64 -13
- package/clients/dsh-remote/dsh-events.mjs +22 -2
- package/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/clients/dsh-remote/mobile-adapter.mjs +172 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
- package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
- package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
- package/clients/dsh-remote/upstream-discovery.mjs +186 -30
- package/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/dsh-setup.mjs +161 -7
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +31 -1
- package/packages/dsh-remote-web/lib/index.js +392 -49
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +172 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
- package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
- package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
- package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
|
@@ -31,7 +31,8 @@ import {
|
|
|
31
31
|
toFormatterNode,
|
|
32
32
|
CONTROL_HEADER
|
|
33
33
|
} from "../wechat-runtime.mjs";
|
|
34
|
-
import
|
|
34
|
+
import * as channelMod from "../wechat-channel.mjs";
|
|
35
|
+
import { saveAccount, loadState, formatNotification, SessionCooldown } from "../wechat-channel.mjs";
|
|
35
36
|
import { NODE_KINDS } from "../dsh-events.mjs";
|
|
36
37
|
|
|
37
38
|
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "wc-rt-"));
|
|
@@ -61,6 +62,9 @@ function fakeIlink(handler) {
|
|
|
61
62
|
}
|
|
62
63
|
if (url.pathname.endsWith("/sendmessage")) {
|
|
63
64
|
try { state.sends.push(JSON.parse(body)); } catch { state.sends.push({ raw: body }); }
|
|
65
|
+
// 可选的发送结果覆盖:用例用它模拟真机上的 `ret=-2 errmsg=prepare failed`
|
|
66
|
+
// (平台侧发送**间歇性**失败,正是"任务跑完没收到推送"那次的现场)。
|
|
67
|
+
if (handler && handler.onSendMessage) return handler.onSendMessage(state, reply);
|
|
64
68
|
return reply({ ret: 0, message_id: "m" + state.sends.length });
|
|
65
69
|
}
|
|
66
70
|
if (url.pathname.endsWith("/notifystart")) { state.notifyStart += 1; return reply({ ret: 0 }); }
|
|
@@ -642,6 +646,53 @@ test("★ v2:完成推送带会话名称 + 结论,而不只是「任务已停止
|
|
|
642
646
|
}
|
|
643
647
|
});
|
|
644
648
|
|
|
649
|
+
test("★ 0.6.15:完成推送必须把「当前会话」切到刚跑完的那个(否则回话会发进上一次的会话)", async () => {
|
|
650
|
+
// 业主实测:B 任务跑完推了结论,他在微信里直接回一句"再改一下",那条消息却发给了
|
|
651
|
+
// **上一次的当前会话 A** —— A 平白多了一条指令,B 永远收不到。
|
|
652
|
+
const ilink = await fakeIlink();
|
|
653
|
+
try {
|
|
654
|
+
const sub = makeFakeSubscriber();
|
|
655
|
+
sub.sessionsFixture = [
|
|
656
|
+
{ sessionId: "session-b", title: "刚跑完的活", running: false, cwd: "/p" },
|
|
657
|
+
{ sessionId: "session-a", title: "早先的会话", running: false, cwd: "/p" }
|
|
658
|
+
];
|
|
659
|
+
sub.summaryFixture = "已改完。";
|
|
660
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
661
|
+
rt.currentSessionId = "session-a";
|
|
662
|
+
rt.currentSessionTitle = "早先的会话";
|
|
663
|
+
|
|
664
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-b", reason: "completed", at: 1 });
|
|
665
|
+
assert.equal(rt.currentSessionId, "session-b", "推送完必须切到刚完成的会话");
|
|
666
|
+
assert.equal(loadState(relayDir).current_session_id, "session-b", "切换必须落盘(重启后仍然对)");
|
|
667
|
+
|
|
668
|
+
// 现在回一句纯文本 → 必须发给 B,而不是 A
|
|
669
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "再改一下" } }] });
|
|
670
|
+
const prompt = sub.calls.filter((c) => c.m === "promptSession").pop();
|
|
671
|
+
assert.ok(prompt, "纯文本必须派给会话");
|
|
672
|
+
assert.equal(prompt.sessionId, "session-b", "回话必须发给**刚完成**的那个会话");
|
|
673
|
+
assert.equal(prompt.text, "再改一下");
|
|
674
|
+
} finally {
|
|
675
|
+
await ilink.close();
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("★ 0.6.15:已经是当前会话的完成推送不重复写状态(幂等)", async () => {
|
|
680
|
+
const ilink = await fakeIlink();
|
|
681
|
+
try {
|
|
682
|
+
const sub = makeFakeSubscriber();
|
|
683
|
+
sub.sessionsFixture = [{ sessionId: "session-s1", title: "同一个任务", running: false, cwd: "/p" }];
|
|
684
|
+
sub.summaryFixture = "完成。";
|
|
685
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
686
|
+
rt.currentSessionId = "session-s1";
|
|
687
|
+
rt.currentSessionTitle = "同一个任务";
|
|
688
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-s1", reason: "completed", at: 1 });
|
|
689
|
+
assert.equal(rt.currentSessionId, "session-s1");
|
|
690
|
+
assert.match(lastText(ilink.state), /同一个任务/);
|
|
691
|
+
} finally {
|
|
692
|
+
await ilink.close();
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
645
696
|
test("★ 分层:免费档的完成推送不能承诺「回复就能接着做」,要改成会员说明 + App 链接", async () => {
|
|
646
697
|
// 默认那句「回复这条消息就能接着这个会话往下做」对免费用户是**空头承诺**:
|
|
647
698
|
// 他回复纯文本只会拿到付费引导。文案必须跟着档位走,否则用户会以为产品坏了。
|
|
@@ -1745,3 +1796,219 @@ test("★ 日报占位一天一个,换天时会清掉旧占位(不攒文件)", a
|
|
|
1745
1796
|
await ilink.close();
|
|
1746
1797
|
}
|
|
1747
1798
|
});
|
|
1799
|
+
|
|
1800
|
+
// ---------------------------------------------------------------------------
|
|
1801
|
+
// 出站必须回带 context_token(2026-09-23 业主实测「没收到微信通道」)
|
|
1802
|
+
//
|
|
1803
|
+
// 平台要求出站消息带回「这条会话的上下文令牌」,而它只出现在**入站消息**里
|
|
1804
|
+
// (官方实现:messaging/send.js 的 buildTextMessageReq → `msg.context_token`;
|
|
1805
|
+
// 腾讯自家插件同样是「入站取出 → 按 (账号,用户) 缓存 → 发送时回带」)。
|
|
1806
|
+
// 我们以前从不发它 → 真机日志 `sendMessage: ret=-2 errmsg=prepare failed`:消息发不出去。
|
|
1807
|
+
// ---------------------------------------------------------------------------
|
|
1808
|
+
|
|
1809
|
+
test("★★ 出站必须回带 context_token(入站带来什么就回什么)", async () => {
|
|
1810
|
+
const ilink = await fakeIlink();
|
|
1811
|
+
try {
|
|
1812
|
+
const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
|
|
1813
|
+
// 真实形状:入站消息带顶层 context_token;发送目标是账号的 userId
|
|
1814
|
+
await rt.handleInbound({
|
|
1815
|
+
from_user_id: "user-1", context_token: "CT-abc-123",
|
|
1816
|
+
item_list: [{ type: 1, text_item: { text: "/status" } }]
|
|
1817
|
+
});
|
|
1818
|
+
const last = ilink.state.sends.at(-1);
|
|
1819
|
+
assert.equal(last.msg.context_token, "CT-abc-123",
|
|
1820
|
+
"★出站 msg 必须回带 context_token —— 不带就是 ret=-2 prepare failed(消息根本发不出去)");
|
|
1821
|
+
assert.equal(last.msg.to_user_id, "user-1");
|
|
1822
|
+
} finally {
|
|
1823
|
+
await ilink.close();
|
|
1824
|
+
}
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
test("★ 没有 context_token 时**完全不带这个键**(而不是空串)", async () => {
|
|
1828
|
+
const ilink = await fakeIlink();
|
|
1829
|
+
try {
|
|
1830
|
+
const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
|
|
1831
|
+
await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "/status" } }] });
|
|
1832
|
+
const last = ilink.state.sends.at(-1);
|
|
1833
|
+
assert.ok(!("context_token" in last.msg),
|
|
1834
|
+
"★拿不到令牌时不得塞空串(与官方 `contextToken ?? undefined` 同口径)");
|
|
1835
|
+
} finally {
|
|
1836
|
+
await ilink.close();
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
test("★ context_token 落盘持久化:bridge 重启后不必等用户先说话也能发", async () => {
|
|
1841
|
+
const ilink = await fakeIlink();
|
|
1842
|
+
try {
|
|
1843
|
+
const first = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
|
|
1844
|
+
await first.rt.handleInbound({
|
|
1845
|
+
from_user_id: "user-1", context_token: "CT-persist-9",
|
|
1846
|
+
item_list: [{ type: 1, text_item: { text: "/status" } }]
|
|
1847
|
+
});
|
|
1848
|
+
// 同一个 relayDir 上新建一个 runtime(= bridge 重启)
|
|
1849
|
+
const rt2 = createWeChatRuntime({
|
|
1850
|
+
relayDir: first.relayDir, upstream: "http://127.0.0.1:1", secret: SECRET, tier: "pro", logger: quietLogger()
|
|
1851
|
+
});
|
|
1852
|
+
rt2.subscriber = makeFakeSubscriber();
|
|
1853
|
+
await rt2.reply("user-1", "重启后的第一条推送");
|
|
1854
|
+
assert.equal(ilink.state.sends.at(-1).msg.context_token, "CT-persist-9",
|
|
1855
|
+
"★重启后必须从状态文件恢复令牌 —— 否则重启后的第一条通知(最该送达的那条)发不出去");
|
|
1856
|
+
} finally {
|
|
1857
|
+
await ilink.close();
|
|
1858
|
+
}
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
test("★ 解绑必须清掉 context_token(换绑后不能拿上一个人的上下文去发)", async () => {
|
|
1862
|
+
const ilink = await fakeIlink();
|
|
1863
|
+
try {
|
|
1864
|
+
const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
|
|
1865
|
+
await rt.handleInbound({
|
|
1866
|
+
from_user_id: "user-1", context_token: "CT-old-user",
|
|
1867
|
+
item_list: [{ type: 1, text_item: { text: "/status" } }]
|
|
1868
|
+
});
|
|
1869
|
+
await rt.unbind();
|
|
1870
|
+
assert.equal(rt.channel.client.contextToken, "", "内存里必须清掉");
|
|
1871
|
+
const st = JSON.parse(fs.readFileSync(path.join(rt.relayDir, ".wechat-state.json"), "utf8"));
|
|
1872
|
+
assert.ok(!st.context_token, `状态文件里也必须清掉,实际:${JSON.stringify(st.context_token)}`);
|
|
1873
|
+
} finally {
|
|
1874
|
+
await ilink.close();
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
test("★ extractContextToken 防御式:形状不认识一律当没有,绝不猜", () => {
|
|
1879
|
+
const { extractContextToken } = channelMod;
|
|
1880
|
+
assert.equal(extractContextToken(null), "");
|
|
1881
|
+
assert.equal(extractContextToken("CT-x"), "", "字符串消息没有这个字段");
|
|
1882
|
+
assert.equal(extractContextToken({}), "");
|
|
1883
|
+
assert.equal(extractContextToken({ context_token: "" }), "", "空串=没有");
|
|
1884
|
+
assert.equal(extractContextToken({ context_token: " " }), "", "纯空白=没有");
|
|
1885
|
+
assert.equal(extractContextToken({ context_token: 123 }), "", "非字符串=没有");
|
|
1886
|
+
assert.equal(extractContextToken({ context_token: " CT-1 " }), "CT-1", "合法值去首尾空白");
|
|
1887
|
+
assert.equal(extractContextToken({ contextToken: "CT-2" }), "CT-2", "兼容驼峰写法");
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
// ═════════════ 2026-09-25 事故:完成推送"消失" + 回话跑进另一个会话 ═════════════
|
|
1891
|
+
//
|
|
1892
|
+
// 现场(业主):任务跑完一条微信都没收到;他在微信里回了一句,结果**发进了上一个会话**。
|
|
1893
|
+
// 日志证据:48 条 `sendmessage 失败:sendMessage: ret=-2 errmsg=prepare failed`
|
|
1894
|
+
// —— 平台侧发送是**间歇性**失败的,而旧实现失败即丢弃:那条"任务跑完了"永远消失了。
|
|
1895
|
+
// 状态证据:`current_session_id` 停在几天前的旧会话上(因为切换只在推送成功后发生)。
|
|
1896
|
+
|
|
1897
|
+
test("★ 事故锁:主动推送失败**不能消失** —— 入队,并在下一次入站时补发(带「补发」标记)", async () => {
|
|
1898
|
+
let failSends = true;
|
|
1899
|
+
const ilink = await fakeIlink({
|
|
1900
|
+
onSendMessage: (_state, reply) => (failSends
|
|
1901
|
+
? reply({ ret: -2, errmsg: "prepare failed" })
|
|
1902
|
+
: reply({ ret: 0, message_id: "ok" })),
|
|
1903
|
+
});
|
|
1904
|
+
try {
|
|
1905
|
+
const sub = makeFakeSubscriber();
|
|
1906
|
+
sub.sessionsFixture = [{ sessionId: "session-done", title: "跑完的活", running: false, cwd: "/p" }];
|
|
1907
|
+
sub.summaryFixture = "做完了。";
|
|
1908
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
1909
|
+
const stateOf = () => JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
|
|
1910
|
+
|
|
1911
|
+
// ① 平台侧发不出去 → 完成推送必须**入队**,而不是丢掉
|
|
1912
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-done", reason: "completed", at: 1 });
|
|
1913
|
+
const queued = stateOf().pending_outbox || [];
|
|
1914
|
+
assert.equal(queued.length, 1, "★ 推送失败必须入队(旧实现直接丢,用户永远不知道这条存在过)");
|
|
1915
|
+
assert.match(String(queued[0].text), /跑完的活|做完了/, "入队的应该是那条完整正文");
|
|
1916
|
+
|
|
1917
|
+
// ② 平台恢复 + 用户随便发一条 → 补发必须跟上
|
|
1918
|
+
failSends = false;
|
|
1919
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/status" } }] });
|
|
1920
|
+
await waitFor(() => (stateOf().pending_outbox || []).length === 0);
|
|
1921
|
+
const resent = allTexts(ilink.state).filter((t) => t.startsWith("(补发)"));
|
|
1922
|
+
assert.equal(resent.length, 1, "★ 恢复后必须把积压补上(且只补一次)");
|
|
1923
|
+
assert.match(resent[0], /跑完的活/, "补发的就是那条完成通知");
|
|
1924
|
+
} finally {
|
|
1925
|
+
await ilink.close();
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
test("★ 事故锁:静音(/quiet)也要记下「最近完成的任务」,回话不能跑进上一个会话", async () => {
|
|
1930
|
+
const ilink = await fakeIlink();
|
|
1931
|
+
try {
|
|
1932
|
+
const sub = makeFakeSubscriber();
|
|
1933
|
+
sub.sessionsFixture = [
|
|
1934
|
+
{ sessionId: "session-new", title: "刚跑完的活", running: false, cwd: "/p" },
|
|
1935
|
+
{ sessionId: "session-old", title: "早先的会话", running: false, cwd: "/p" },
|
|
1936
|
+
];
|
|
1937
|
+
sub.summaryFixture = "完成。";
|
|
1938
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
1939
|
+
rt.currentSessionId = "session-old";
|
|
1940
|
+
rt.currentSessionTitle = "早先的会话";
|
|
1941
|
+
// 静音:完成推送**不会**发出来(用户主动要求的"别打扰")
|
|
1942
|
+
rt.channel.writeState({ quiet: true });
|
|
1943
|
+
|
|
1944
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-new", reason: "completed", at: 2 });
|
|
1945
|
+
assert.equal(lastText(ilink.state).includes("刚跑完的活"), false, "静音时不推送(前置条件)");
|
|
1946
|
+
|
|
1947
|
+
// 用户下一秒回话 → 必须发给他**刚跑完**的那个会话,而不是旧会话
|
|
1948
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "再改一下" } }] });
|
|
1949
|
+
const prompt = sub.calls.filter((c) => c.m === "promptSession").pop();
|
|
1950
|
+
assert.ok(prompt, "纯文本必须派给会话");
|
|
1951
|
+
assert.equal(prompt.sessionId, "session-new",
|
|
1952
|
+
"★ 静音只表示不打扰,不表示「当它没发生过」—— 回话目标必须是最近完成的任务");
|
|
1953
|
+
assert.match(lastText(ilink.state), /刚跑完的活/, "确认文案里要说清发给了哪个会话");
|
|
1954
|
+
assert.match(lastText(ilink.state), /已自动切为回复对象/, "自动切换要如实告知(不能悄悄改目标)");
|
|
1955
|
+
void relayDir;
|
|
1956
|
+
} finally {
|
|
1957
|
+
await ilink.close();
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
test("★ 事故锁:用户主动 /use 切走的会话不能被「最近完成」抢回去", async () => {
|
|
1962
|
+
const ilink = await fakeIlink();
|
|
1963
|
+
try {
|
|
1964
|
+
const sub = makeFakeSubscriber();
|
|
1965
|
+
sub.sessionsFixture = [
|
|
1966
|
+
{ sessionId: "session-done", title: "刚跑完的活", running: false, cwd: "/p" },
|
|
1967
|
+
{ sessionId: "session-mine", title: "我要聊的", running: false, cwd: "/p" },
|
|
1968
|
+
];
|
|
1969
|
+
sub.summaryFixture = "完成。";
|
|
1970
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
1971
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
|
|
1972
|
+
assert.equal(rt.currentSessionId, "session-mine", "前置条件:/use 切到了 2 号");
|
|
1973
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-done", reason: "completed", at: 3 });
|
|
1974
|
+
|
|
1975
|
+
// 完成事件在处理时会把它自己设为回复目标(这是产品口径);但用户**之后**再切回去要算数:
|
|
1976
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
|
|
1977
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续聊我的" } }] });
|
|
1978
|
+
const prompt = sub.calls.filter((c) => c.m === "promptSession").pop();
|
|
1979
|
+
assert.equal(prompt.sessionId, "session-mine",
|
|
1980
|
+
"★ 对表只在「指针落后于最近完成」时切换;用户明确选过的会话不许被抢回去");
|
|
1981
|
+
} finally {
|
|
1982
|
+
await ilink.close();
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 emit、没人接 → 全静默)", async () => {
|
|
1987
|
+
// 那次「一条推送都没有、日志里也没有任何报错」的根因就在这类静默里:
|
|
1988
|
+
// `#emitFault` 只 emit('fault'),而运行时没接这个事件 —— follow 失败/发现失败/流上限
|
|
1989
|
+
// 全都不留任何痕迹。现在必须:① 一条 warn 进 bridge 日志;② 非预期的记进状态文件。
|
|
1990
|
+
const ilink = await fakeIlink();
|
|
1991
|
+
try {
|
|
1992
|
+
const warns = [];
|
|
1993
|
+
const sub = makeFakeSubscriber();
|
|
1994
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
1995
|
+
rt.logger = { info() {}, warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {} };
|
|
1996
|
+
await rt.startChannel(); // 真实接线:#startSubscriber() → 给注入的订阅器挂 handler
|
|
1997
|
+
try {
|
|
1998
|
+
sub.emit("fault", { code: "follow-limit", message: "follow 流已达上限 8", sessionId: "session-x", expected: false });
|
|
1999
|
+
const st = JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
|
|
2000
|
+
assert.ok(warns.some((m) => m.includes("订阅异常 follow-limit")),
|
|
2001
|
+
`订阅异常必须进日志(否则事后无从查起),实际:${JSON.stringify(warns)}`);
|
|
2002
|
+
assert.equal(st.last_fault && st.last_fault.code, "follow-limit", "非预期的订阅异常要记进状态供面板显示");
|
|
2003
|
+
|
|
2004
|
+
// 预期内的(子代理会话必然被拒 / 会话刚被删)只记日志,不该污染故障状态
|
|
2005
|
+
sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
|
|
2006
|
+
const st2 = JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
|
|
2007
|
+
assert.equal(st2.last_fault.code, "follow-limit", "expected 的异常不该覆盖真正的故障");
|
|
2008
|
+
} finally {
|
|
2009
|
+
await rt.stopChannel();
|
|
2010
|
+
}
|
|
2011
|
+
} finally {
|
|
2012
|
+
await ilink.close();
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
@@ -158,13 +158,140 @@ export function dshProcessPids(platform = process.platform) {
|
|
|
158
158
|
.filter((n) => Number.isInteger(n) && n > 1 && n !== process.pid);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// ---------- 「本机所有回环监听端口」:NAS 上的救命稻草(0.6.15) ----------
|
|
162
|
+
//
|
|
163
|
+
// 【为什么必须加这一层】飞牛 fnOS 用户诊断(2026-09-25,插件 0.6.14):
|
|
164
|
+
// 上游 dsh web: http://127.0.0.1:3080(默认值 3080(**未取到 dsh web 实际监听端口**),⚠️ 不可达)
|
|
165
|
+
// 他的 dsh web 实际在 **2298**(fnOS 应用里配的内部监听端口),而上面五条发现路径**全都没命中**:
|
|
166
|
+
// · 插件半的 ctx.webServer.port 没取到(该环境下拿不到);
|
|
167
|
+
// · `lsof` 在 NAS 上**根本没装**;`pgrep -f dsh` 也可能没有;
|
|
168
|
+
// · 静态候选只有 3080 与 Desktop 的 43120 区间 —— 2298 永远不在里面。
|
|
169
|
+
// 于是用户去改端口号(改成 20xx、又改回 3080)当然都没用:**没有任何一条路径会去试 2298**。
|
|
170
|
+
//
|
|
171
|
+
// 做法:直接把"这台机器上正在 LISTEN 的回环端口"全列出来当候选,再逐个做**身份校验**。
|
|
172
|
+
// 为什么敢全列:身份校验(`looksLikeDshWeb` / `looksLikeDshRemoteSelf`)会拒掉所有陌生服务,
|
|
173
|
+
// 多探几个端口只是几十毫秒;而漏掉一个端口就是"用户永远连不上"。
|
|
174
|
+
|
|
175
|
+
/** 解析 /proc/net/tcp 一行里的本地地址与状态(Linux 无依赖:不需要 ss/netstat/lsof)。 */
|
|
176
|
+
function parseProcNetLine(line) {
|
|
177
|
+
const cols = String(line || "").trim().split(/\s+/);
|
|
178
|
+
if (cols.length < 4) return null;
|
|
179
|
+
const local = cols[1];
|
|
180
|
+
const state = cols[3];
|
|
181
|
+
const m = /^([0-9A-Fa-f]{1,32}):([0-9A-Fa-f]{1,4})$/.exec(local || "");
|
|
182
|
+
if (!m) return null;
|
|
183
|
+
return { hex: m[1], port: parseInt(m[2], 16), state: String(state).toUpperCase() };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 32 位小端 hex(/proc/net/tcp)→ IPv4 点分串。 */
|
|
187
|
+
function ipv4OfHex(hex) {
|
|
188
|
+
const h = String(hex).padStart(8, "0").slice(-8);
|
|
189
|
+
const bytes = [h.slice(6, 8), h.slice(4, 6), h.slice(2, 4), h.slice(0, 2)].map((x) => parseInt(x, 16));
|
|
190
|
+
return bytes.join(".");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 128 位 hex(/proc/net/tcp6)→ 是否回环(::1)。 */
|
|
194
|
+
function isIpv6LoopbackHex(hex) {
|
|
195
|
+
const h = String(hex).toLowerCase().padStart(32, "0");
|
|
196
|
+
// ::1 在 /proc 里按 4 组小端 32 位存放 → 末组为 01000000,其余全 0
|
|
197
|
+
return /^0{24}01000000$/.test(h);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Linux:直接读 /proc/net/tcp{,6}(内核真值,NAS 上不需要任何外部命令)。 */
|
|
201
|
+
export function procNetLoopbackPorts() {
|
|
202
|
+
const ports = new Set();
|
|
203
|
+
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
204
|
+
let text = "";
|
|
205
|
+
try { text = fs.readFileSync(file, "utf8"); } catch { continue; }
|
|
206
|
+
for (const line of text.split("\n").slice(1)) {
|
|
207
|
+
const row = parseProcNetLine(line);
|
|
208
|
+
if (!row || row.state !== "0A") continue; // 0A = LISTEN
|
|
209
|
+
const loop = file.endsWith("tcp6")
|
|
210
|
+
? isIpv6LoopbackHex(row.hex)
|
|
211
|
+
: ipv4OfHex(row.hex).startsWith("127.");
|
|
212
|
+
// 通配绑定(0.0.0.0 / ::)也收:dsh web 绑 0.0.0.0 时回环照样能连
|
|
213
|
+
const wildcard = file.endsWith("tcp6") ? /^0+$/.test(String(row.hex)) : ipv4OfHex(row.hex) === "0.0.0.0";
|
|
214
|
+
if (loop || wildcard) ports.add(row.port);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return [...ports];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `ss -ltnH` 输出里的本地端口(Linux;部分精简系统没有 /proc,或反之)。 */
|
|
221
|
+
function ssLoopbackPorts() {
|
|
222
|
+
const ports = new Set();
|
|
223
|
+
const out = tryExec("ss", ["-ltnH"], 5000) || tryExec("ss", ["-ltn"], 5000);
|
|
224
|
+
for (const line of out.split("\n")) {
|
|
225
|
+
const cols = line.trim().split(/\s+/);
|
|
226
|
+
if (cols.length < 4) continue;
|
|
227
|
+
// ss 的本地地址是第 4 列(State Recv-Q Send-Q Local Address:Port Peer Address:Port)
|
|
228
|
+
const m = /[:.](\d+)$/.exec(cols[3] || "");
|
|
229
|
+
if (m) ports.add(Number(m[1]));
|
|
230
|
+
}
|
|
231
|
+
return [...ports];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `netstat -ltn` 输出里的本地端口(BSD/GNU 两种格式都认)。 */
|
|
235
|
+
function netstatLoopbackPorts() {
|
|
236
|
+
const ports = new Set();
|
|
237
|
+
const out = (tryExec("netstat", ["-ltn"], 5000) || tryExec("netstat", ["-an"], 6000));
|
|
238
|
+
for (const line of out.split("\n")) {
|
|
239
|
+
if (!/listen/i.test(line) && !/^tcp/i.test(line)) continue;
|
|
240
|
+
const m = /(?:^|\s)(?:\d{1,3}\.){3}\d{1,3}[:.](\d+)|\[?::1?\]?[:.](\d+)/.exec(line);
|
|
241
|
+
const p = m ? Number(m[1] || m[2]) : 0;
|
|
242
|
+
if (p > 0 && p < 65536) ports.add(p);
|
|
243
|
+
}
|
|
244
|
+
return [...ports];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `lsof -nP -iTCP -sTCP:LISTEN` 输出里的本地端口(macOS/装了 lsof 的 Linux)。 */
|
|
248
|
+
function lsofLoopbackPorts() {
|
|
249
|
+
const ports = new Set();
|
|
250
|
+
const out = tryExec("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], 6000);
|
|
251
|
+
for (const line of out.split("\n")) {
|
|
252
|
+
const m = /(?:TCP\s+)?(?:\d{1,3}\.){3}\d{1,3}:(\d+)\s*\(LISTEN\)/.exec(line) || /TCP\s+\*:(\d+)/.exec(line);
|
|
253
|
+
if (m) ports.add(Number(m[1]));
|
|
254
|
+
}
|
|
255
|
+
return [...ports];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 本机所有回环(含通配绑定)LISTEN 端口,去重。
|
|
260
|
+
* 多来源合并而不是"取第一个成功的":NAS 上 lsof/ss/netstat/pgrep 各有缺失,
|
|
261
|
+
* 只有合起来才够全;重复端口由 Set 消化,代价只是多读几个文件。
|
|
262
|
+
*/
|
|
263
|
+
export function allLoopbackListeningPorts(platform = process.platform) {
|
|
264
|
+
const ports = new Set();
|
|
265
|
+
const push = (list) => { for (const p of list) { const n = Number(p); if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n); } };
|
|
266
|
+
if (platform === "win32") {
|
|
267
|
+
for (const line of tryExec("netstat", ["-ano"], 6000).split(/\r?\n/)) {
|
|
268
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
269
|
+
const cols = line.trim().split(/\s+/);
|
|
270
|
+
const m = /:(\d+)$/.exec(cols[1] || "");
|
|
271
|
+
if (m) push([Number(m[1])]);
|
|
272
|
+
}
|
|
273
|
+
return [...ports];
|
|
274
|
+
}
|
|
275
|
+
if (platform === "linux") {
|
|
276
|
+
push(procNetLoopbackPorts()); // 无依赖,优先
|
|
277
|
+
push(ssLoopbackPorts());
|
|
278
|
+
}
|
|
279
|
+
push(lsofLoopbackPorts());
|
|
280
|
+
push(netstatLoopbackPorts());
|
|
281
|
+
return [...ports];
|
|
282
|
+
}
|
|
283
|
+
|
|
161
284
|
/**
|
|
162
285
|
* 候选列表(已去重,按"可能性"排序),每项带**来源标注**(用于日志与测试断言)。
|
|
163
286
|
* 顺序 = 提示里的端口 → 显式额外端口 → DSH_WEB_URL 的端口 → 祖先/dsh 进程实测监听端口
|
|
164
|
-
* → 3080 → Desktop 默认端口区间。
|
|
287
|
+
* → **本机所有回环监听端口** → 3080 → Desktop 默认端口区间。
|
|
288
|
+
*
|
|
289
|
+
* 「所有回环监听端口」排在静态候选之前:NAS 上 dsh web 的端口(实测 2298)**只可能**从这一层找到,
|
|
290
|
+
* 排在 3080 之后就等于白加(3080 探测失败之前不会去试它,而静态候选有 12 个 Desktop 端口)。
|
|
291
|
+
* 最后仍留静态候选兜底(宿主把端口藏得很深、/proc 也没有的极端情况)。
|
|
165
292
|
* @returns {Array<{port:number, source:string}>}
|
|
166
293
|
*/
|
|
167
|
-
export function candidatePorts({ relayDir = "", env = process.env, extraPorts = [], platform = process.platform } = {}) {
|
|
294
|
+
export function candidatePorts({ relayDir = "", env = process.env, extraPorts = [], platform = process.platform, listenPorts = null } = {}) {
|
|
168
295
|
const out = [];
|
|
169
296
|
const seen = new Set();
|
|
170
297
|
const push = (p, source) => {
|
|
@@ -179,6 +306,10 @@ export function candidatePorts({ relayDir = "", env = process.env, extraPorts =
|
|
|
179
306
|
push(portOf(upstreamFromDshWebUrl(env)), "dsh_web_url");
|
|
180
307
|
const pids = [...new Set([process.pid, ...ancestorPids(process.pid, 6, platform), ...dshProcessPids(platform)])];
|
|
181
308
|
for (const pid of pids) for (const p of listeningPortsOfPid(pid, platform)) push(p, "process");
|
|
309
|
+
// ★ 本机所有回环 LISTEN 端口(0.6.15):NAS 上 dsh web 在 2298 这类非默认端口时唯一的发现路径。
|
|
310
|
+
// 调用方可以注入 listenPorts(测试用),避免用例依赖开发机真实监听表。
|
|
311
|
+
const listen = Array.isArray(listenPorts) ? listenPorts : allLoopbackListeningPorts(platform);
|
|
312
|
+
for (const p of listen) push(p, "listen");
|
|
182
313
|
push(3080, "static");
|
|
183
314
|
for (let i = 0; i < DESKTOP_PORT_SCAN; i++) push(DESKTOP_DEFAULT_PORT + i, "static");
|
|
184
315
|
return out;
|
|
@@ -208,8 +339,28 @@ export function looksLikeDshWeb(status, text) {
|
|
|
208
339
|
return DSH_WEB_MARKERS.some((m) => body.includes(m));
|
|
209
340
|
}
|
|
210
341
|
|
|
342
|
+
/** 本插件自己的路由(`GET /dsh-remote/self`)—— 只有"宿主这台 dsh web"才会应答。 */
|
|
343
|
+
export const DSH_REMOTE_SELF_PATH = "/dsh-remote/self";
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 身份校验的**第二判据**:本插件自己的 `/dsh-remote/self`。
|
|
347
|
+
*
|
|
348
|
+
* 为什么需要它:`looksLikeDshWeb` 依赖 dsh web 首页的 HTML 特征(`__ModuleLoader__` 等),
|
|
349
|
+
* 而首页在有些部署下是**跳转/登录页/自定义门户**,特征一个字都不出现 —— 于是明明端口就是对的,
|
|
350
|
+
* 却被判成"陌生服务"。`/dsh-remote/self` 由**本插件**注册在同一个 web server 上,
|
|
351
|
+
* 返回 `{"ok":true,"version":…,"channel":…,"relayDir":…}`,是本机最硬的同一性证据
|
|
352
|
+
* (它跑在同一条隧道上,能应答就说明这个端口就是宿主的 dsh web)。
|
|
353
|
+
*/
|
|
354
|
+
export function looksLikeDshRemoteSelf(status, text) {
|
|
355
|
+
if (Number(status) !== 200) return false;
|
|
356
|
+
const body = String(text || "");
|
|
357
|
+
if (!body.includes("relayDir")) return false;
|
|
358
|
+
return body.includes('"channel"') || body.includes('"runtimeReady"') || body.includes("dsh-remote");
|
|
359
|
+
}
|
|
360
|
+
|
|
211
361
|
/**
|
|
212
|
-
*
|
|
362
|
+
* 探测一个候选:先 GET `/`(dsh web 首页特征),不中再 GET `/dsh-remote/self`(本插件特征)。
|
|
363
|
+
* 两条判据任一命中即通过。
|
|
213
364
|
* @returns {Promise<boolean>}
|
|
214
365
|
*/
|
|
215
366
|
export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl } = {}) {
|
|
@@ -217,34 +368,38 @@ export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl
|
|
|
217
368
|
if (!target) return false;
|
|
218
369
|
const doFetch = fetchImpl || globalThis.fetch;
|
|
219
370
|
if (typeof doFetch !== "function") return false;
|
|
220
|
-
|
|
221
|
-
const ac = new AbortController();
|
|
222
|
-
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
223
|
-
let res;
|
|
224
|
-
try {
|
|
225
|
-
// redirect: manual —— 不跟随跳转(跳走的多半不是我们要的宿主)
|
|
226
|
-
res = await doFetch(`${target}/`, { signal: ac.signal, redirect: "manual" });
|
|
227
|
-
} finally {
|
|
228
|
-
clearTimeout(timer);
|
|
229
|
-
}
|
|
230
|
-
const status = Number(res && res.status) || 0;
|
|
231
|
-
if (status >= 500) return false;
|
|
232
|
-
// 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
|
|
233
|
-
let text = "";
|
|
371
|
+
const once = async (path, check) => {
|
|
234
372
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
373
|
+
const ac = new AbortController();
|
|
374
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
375
|
+
let res;
|
|
376
|
+
try {
|
|
377
|
+
// redirect: manual —— 不跟随跳转(跳走的多半不是我们要的宿主)
|
|
378
|
+
res = await doFetch(`${target}${path}`, { signal: ac.signal, redirect: "manual" });
|
|
379
|
+
} finally {
|
|
380
|
+
clearTimeout(timer);
|
|
242
381
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
382
|
+
const status = Number(res && res.status) || 0;
|
|
383
|
+
if (status >= 500) return false;
|
|
384
|
+
// 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
|
|
385
|
+
let text = "";
|
|
386
|
+
try {
|
|
387
|
+
if (res.body && typeof res.body.getReader === "function") {
|
|
388
|
+
const reader = res.body.getReader();
|
|
389
|
+
const { value } = await reader.read();
|
|
390
|
+
text = value ? Buffer.from(value).toString("utf8") : "";
|
|
391
|
+
try { await reader.cancel(); } catch { /* ignore */ }
|
|
392
|
+
} else if (typeof res.text === "function") {
|
|
393
|
+
text = await res.text();
|
|
394
|
+
}
|
|
395
|
+
} catch { /* 读失败 → text 为空 → 判否 */ }
|
|
396
|
+
return check(status, text.slice(0, 8192));
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
if (await once("/", looksLikeDshWeb)) return true;
|
|
402
|
+
return once(DSH_REMOTE_SELF_PATH, looksLikeDshRemoteSelf);
|
|
248
403
|
}
|
|
249
404
|
|
|
250
405
|
/**
|
|
@@ -261,6 +416,7 @@ export async function discoverUpstream({
|
|
|
261
416
|
fetchImpl,
|
|
262
417
|
platform = process.platform,
|
|
263
418
|
extraPorts = [],
|
|
419
|
+
listenPorts = null,
|
|
264
420
|
log
|
|
265
421
|
} = {}) {
|
|
266
422
|
const hint = resolveUpstreamHint({ relayDir, env });
|
|
@@ -273,7 +429,7 @@ export async function discoverUpstream({
|
|
|
273
429
|
|
|
274
430
|
// ③ 候选并行探测(本地端口,拒绝是瞬时的;并行是为了不让 watcher 卡住)
|
|
275
431
|
const hintPort = portOf(hint.url);
|
|
276
|
-
const entries = candidatePorts({ relayDir, env, platform, extraPorts }).filter((e) => e.port !== hintPort);
|
|
432
|
+
const entries = candidatePorts({ relayDir, env, platform, extraPorts, listenPorts }).filter((e) => e.port !== hintPort);
|
|
277
433
|
const results = await Promise.all(entries.map(async (e) => ({ ...e, ok: await probeDshWeb(`http://127.0.0.1:${e.port}`, { timeoutMs, fetchImpl }) })));
|
|
278
434
|
const hit = results.find((r) => r.ok);
|
|
279
435
|
if (hit) {
|