dsh-remote-web 0.7.6 → 0.7.9-beta.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.
package/lib/index.js CHANGED
@@ -1687,21 +1687,59 @@ function launchdStatusIn(target) {
1687
1687
  return { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
1688
1688
  }
1689
1689
 
1690
- // ---------- Windows:bridge 进程发现(0.6.7) ----------
1690
+ // ---------- Windows:bridge 进程发现(0.6.7 立;2026-10-29 反馈后重写) ----------
1691
1691
  //
1692
1692
  // Windows 没有 pgrep/ps/launchctl,而 bridge 在 Windows 上也不由服务管理器托管
1693
- // (dsh-setup.mjs 明确不生成 Windows 自启动)。所以进程发现按「pid 文件优先、PowerShell 兜底」两级:
1693
+ // (dsh-setup.mjs 明确不生成 Windows 自启动)。所以进程发现按「pid 文件 → bridge 自写证据 → PowerShell 扫描」三级:
1694
1694
  // ① pid 文件:插件自己 spawn 的 watcher、以及 `dsh-remote run` 跑起来的 watcher/bridge
1695
1695
  // 都会把 pid 落到配置目录(见 dsh-setup.mjs 的 writePidFile)。读文件 + process.kill(pid,0)
1696
1696
  // 判活,零 shell 开销——面板 2~3s 轮询一次也扛得住。
1697
- // ② PowerShell 扫 node.exe 的命令行:兼容「用户跑的是没写 pid 文件的旧版运行时」。
1697
+ // ② bridge **自己**写的单实例锁 `.dsh-bridge-instance.lock`({pid,at,script},见 dsh-bridge.mjs 的
1698
+ // acquireInstanceLock)与状态文件 `.dsh-bridge-state.json` 里的 pid:进程自证身份。父进程
1699
+ // watcher 会把 `.dsh-bridge.pid` 删掉(子进程一退出就删),而孤儿 bridge 仍活着的场景里,
1700
+ // 这条「自写证据」是唯一还成立的判据(2026-10-29 事故正是这个形态)。
1701
+ // ③ PowerShell 全量枚举 + 命令行匹配:兼容「用户跑的是没写 pid 文件的旧版运行时」。
1698
1702
  // 这条路径要起一个 powershell.exe(数百毫秒),故结果带 TTL 缓存,绝不被轮询打爆。
1703
+ //
1704
+ // 【2026-10-29 修复的三处结构性缺陷】(取证:dsh-relay-internal/2026-10-29-反馈bridge_wedged-取证/)
1705
+ // F1 旧条件 `if (!watcher.length && !bridge.length)`:**watcher pid 文件有效时永不扫**。于是
1706
+ // 「watcher 在跑 + bridge pid 文件刚被删(新 bridge 撞本地实例锁秒退时 watcher 会删它)」
1707
+ // ⇒ bridge 恒为空 ⇒ 面板「bridge 未运行 / 中继未注册」⇒ 90s 后判「守护卡死」⇒ 误重启 3 次
1708
+ // ⇒ bridge_wedged,而中继数据面全程在线。现在改成「**只要 bridge 为空就扫**」,扫描结果只补充不覆盖。
1709
+ // F2 旧扫描写死 `Name='node.exe'`:桌面端(Electron)宿主用 process.execPath 拉起 watcher,
1710
+ // 镜像名是桌面端 exe;bridge 的解释器是 NODE_BIN(可能也是同一个 exe)⇒ 两类进程都可能全漏。
1711
+ // 现在无 Name 过滤、按命令行匹配,并把 Name 一起带回(故障时一眼看出解释器是谁)。
1712
+ // F3 扫描失败与「扫到 0 个」不再混为一谈:失败时置 degraded,随 /bridge-status 下发(scanDegraded)。
1699
1713
 
1700
1714
  const WATCHER_PID_FILE = ".dsh-watcher.pid";
1701
1715
  const BRIDGE_PID_FILE = ".dsh-bridge.pid";
1716
+ /** bridge 自己写的单实例锁(必须与 dsh-bridge.mjs 的 INSTANCE_LOCK_FILE 同名)。 */
1717
+ const BRIDGE_LOCK_FILE = ".dsh-bridge-instance.lock";
1702
1718
  /** Windows 自启动任务名(与 dsh-setup.mjs 的 WIN_TASK_NAME 必须一致)。 */
1703
1719
  const WIN_TASK_NAME = "dsh-remote-bridge";
1704
1720
  const WIN_SCAN_TTL_MS = 5000;
1721
+ /**
1722
+ * pid 文件的「信任窗口」:**取不到进程命令行**时(Windows 上 PowerShell 不可用/进程不在扫描结果里;
1723
+ * POSIX 上 ps 失败),只有 pid 文件足够新才敢按「大概是我们的进程」处理。超过窗口一律当无法确认 ——
1724
+ * 宁可漏杀/漏报,也绝不对一个可能已被系统回收给无关进程的 pid 发 taskkill /T /F。
1725
+ */
1726
+ const PID_FILE_TRUST_MS = 10 * 60 * 1000;
1727
+ /**
1728
+ * 状态文件心跳的信任窗口:新版 bridge 在心跳拍里节流刷新 `.dsh-bridge-state.json` 的
1729
+ * `{pid, heartbeat_at}`(≥10s 一次,隧道心跳默认 20s;见 dsh-bridge.mjs 的 stampBridgeHeartbeat)。
1730
+ * 120s ≈ 容忍 6 拍丢失(机器休眠/事件循环卡顿)。超过窗口就认为「这份 pid 可能已被系统回收
1731
+ * 给无关进程」,此时必须靠命令行确认,绝不能只看 pid 活着就报「bridge 在跑」。
1732
+ */
1733
+ const STATE_HEARTBEAT_TRUST_MS = 120_000;
1734
+
1735
+ /**
1736
+ * 扫描结果缓存 TTL。生产恒为 WIN_SCAN_TTL_MS;`DSH_RELAY_WIN_SCAN_TTL_MS` 只给用例用
1737
+ * (0 = 不缓存),避免同一进程内多条用例被 5s 缓存互相串味 —— 与 DSH_RELAY_WEDGE_MS 同一约定。
1738
+ */
1739
+ function winScanTtlMs() {
1740
+ const v = Number(process.env.DSH_RELAY_WIN_SCAN_TTL_MS);
1741
+ return Number.isFinite(v) && v >= 0 ? v : WIN_SCAN_TTL_MS;
1742
+ }
1705
1743
 
1706
1744
  /** 读 pid 文件(内容为纯数字 pid;缺失/损坏返回 null)。 */
1707
1745
  function readPidFile(relayDir, name) {
@@ -1713,41 +1751,65 @@ function readPidFile(relayDir, name) {
1713
1751
  }
1714
1752
  }
1715
1753
 
1754
+ /** pid 文件有多旧(毫秒);读不到 = Infinity(= 完全不可信)。 */
1755
+ function pidFileAgeMs(relayDir, name) {
1756
+ try { return Date.now() - (statSync(join(relayDir, name)).mtimeMs || 0); } catch { return Infinity; }
1757
+ }
1758
+
1759
+ /** 安静读一个 JSON 证据文件(bridge 自写的锁/状态);缺失/损坏 → null。 */
1760
+ function readJsonFileQuiet(p) {
1761
+ try {
1762
+ const j = JSON.parse(readFileSync(p, "utf8"));
1763
+ return j && typeof j === "object" ? j : null;
1764
+ } catch { return null; }
1765
+ }
1766
+
1716
1767
  /**
1717
- * 用 PowerShell 列出所有 node.exe 的 pid/父 pid/命令行。
1768
+ * 用 PowerShell 列出所有**命令行里带 dsh-setup.mjs / dsh-bridge.mjs** 的进程(不再按镜像名过滤)。
1718
1769
  * 走 -EncodedCommand(UTF-16LE base64):命令行里带引号/中文/空格时不会被 PowerShell 自己的
1719
1770
  * 引号解析吃掉,也不需要经过 shell。Get-CimInstance 不可用时回退 Get-WmiObject(Win7/精简系统)。
1720
- */
1721
- let winProcScanCache = { at: 0, procs: [] };
1722
- function windowsNodeProcesses() {
1723
- if (Date.now() - winProcScanCache.at < WIN_SCAN_TTL_MS) return winProcScanCache.procs;
1771
+ *
1772
+ * @returns {{procs: Array<{pid:number, ppid:number|null, name:string, commandLine:string}>, degraded:boolean}}
1773
+ * degraded=true 表示「没扫成」(无 PowerShell / 调用超时 / JSON 解析失败)——
1774
+ * 必须与「扫到 0 个」区分开,否则诊断里会谎报「本机一个进程都没有」。
1775
+ */
1776
+ let winProcScanCache = { at: 0, procs: [], degraded: false };
1777
+ function windowsProcessScan() {
1778
+ const ttl = winScanTtlMs();
1779
+ if (ttl > 0 && Date.now() - winProcScanCache.at < ttl) return winProcScanCache;
1724
1780
  const script = [
1725
- "try { $p = Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" }",
1726
- "catch { $p = Get-WmiObject Win32_Process -Filter \"Name='node.exe'\" }",
1727
- "$p | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress",
1781
+ "try { $p = Get-CimInstance Win32_Process } catch { $p = Get-WmiObject Win32_Process }",
1782
+ "$p | Where-Object { $_.CommandLine -and ($_.CommandLine -match 'dsh-setup\\.mjs' -or $_.CommandLine -match 'dsh-bridge\\.mjs') } | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress",
1728
1783
  ].join("; ");
1729
1784
  const encoded = Buffer.from(script, "utf16le").toString("base64");
1730
1785
  let procs = [];
1786
+ let degraded = false;
1731
1787
  try {
1732
1788
  const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
1733
1789
  encoding: "utf8", timeout: 8000, windowsHide: true, maxBuffer: 8 * 1024 * 1024,
1734
1790
  });
1735
1791
  const raw = String(r.stdout || "").trim();
1736
- if (raw) {
1737
- const parsed = JSON.parse(raw);
1738
- for (const p of (Array.isArray(parsed) ? parsed : [parsed])) {
1739
- const pid = Number(p && p.ProcessId);
1740
- if (!Number.isInteger(pid) || pid <= 0) continue;
1741
- procs.push({
1742
- pid,
1743
- ppid: Number(p.ParentProcessId) || null,
1744
- commandLine: String((p && p.CommandLine) || ""),
1745
- });
1746
- }
1792
+ // 非 0 退出且没有输出 = 没扫成(无 PowerShell / 被策略拒 / WMI 不可用)
1793
+ if (!raw) {
1794
+ if (r.error || (Number.isFinite(r.status) && r.status !== 0)) degraded = true;
1795
+ } else {
1796
+ try {
1797
+ const parsed = JSON.parse(raw);
1798
+ for (const p of (Array.isArray(parsed) ? parsed : [parsed])) {
1799
+ const pid = Number(p && p.ProcessId);
1800
+ if (!Number.isInteger(pid) || pid <= 0) continue;
1801
+ procs.push({
1802
+ pid,
1803
+ ppid: Number(p.ParentProcessId) || null,
1804
+ name: String((p && p.Name) || ""),
1805
+ commandLine: String((p && p.CommandLine) || ""),
1806
+ });
1807
+ }
1808
+ } catch { procs = []; degraded = true; } // 有输出但不是 JSON → 当作没扫成
1747
1809
  }
1748
- } catch { procs = []; } // 无 PowerShell / 解析失败 → 静默降级为空(不阻断状态查询)
1749
- winProcScanCache = { at: Date.now(), procs };
1750
- return procs;
1810
+ } catch { procs = []; degraded = true; } // spawnSync 抛错(ENOENT 等)→ 没扫成
1811
+ if (ttl > 0) winProcScanCache = { at: Date.now(), procs, degraded };
1812
+ return { procs, degraded };
1751
1813
  }
1752
1814
 
1753
1815
  /**
@@ -1766,35 +1828,146 @@ function windowsTaskRegistered() {
1766
1828
  return ok;
1767
1829
  }
1768
1830
 
1769
- /** Windows 版 manualStatus:pid 文件优先,PowerShell 兜底。语义与 POSIX 版一致。 */
1831
+ /* ── 进程身份校验(防 PID 复用:既防误判「在跑」,也防误杀)─────────────────────────
1832
+ *
1833
+ * pid 文件只证明「某个时刻有人写了这个 pid」,**不证明**「现在这个 pid 还是我们的进程」。
1834
+ * Windows 的 PID 会被快速回收,于是「watcher 死了 → pid 文件还在 → 该 pid 已被无关进程占用」
1835
+ * 是常态而不是异常。旧实现只做 pidAlive() 判活,后果有两个方向:
1836
+ * · 误判「守护在运行」⇒ 自愈永久 no-op(「多次修复都没解决」那一类);
1837
+ * · 更严重:stopBridgeDetached 会 taskkill /T /F 杀掉它 ⇒ **杀掉一棵与产品无关的进程树**。
1838
+ * 纪律(与 dsh-setup.mjs 的 looksLikeWatcher/looksLikeBridge 同一方向:宁可漏杀):
1839
+ * · 命令行**确认**是本 relayDir 的 dsh-setup.mjs / dsh-bridge.mjs → ident 非空(是我们的);
1840
+ * · 命令行**取到了但不是**(不指向这两个脚本 / 不指向本 relayDir)→ cmdKnown=true、ident="";
1841
+ * · 取不到命令行(Windows:不在扫描结果里或扫描 degraded;POSIX:ps 失败)→ cmdKnown=false,
1842
+ * 这一档只能用 pid 文件新鲜度兜底(PID_FILE_TRUST_MS)。
1843
+ */
1844
+ function processCommandLine(pid, procs) {
1845
+ if (!Number.isInteger(pid) || pid <= 0) return "";
1846
+ if (procs) {
1847
+ const hit = procs.find((p) => p && p.pid === pid);
1848
+ return hit ? String(hit.commandLine || "") : "";
1849
+ }
1850
+ return commandLineOfPid(pid);
1851
+ }
1852
+
1853
+ /** @returns {{ident:"watcher"|"bridge"|"", cmdKnown:boolean}} */
1854
+ function pidIdentityOf(pid, relayDir, procs) {
1855
+ const cmd = processCommandLine(pid, procs);
1856
+ if (!cmd) return { ident: "", cmdKnown: false };
1857
+ const norm = (v) => String(v).replace(/\\/g, "/").toLowerCase();
1858
+ const isBridge = /dsh-bridge\.mjs/.test(cmd);
1859
+ const isWatcher = /dsh-setup\.mjs/.test(cmd);
1860
+ if (!isBridge && !isWatcher) return { ident: "", cmdKnown: true }; // 取到了命令行:明确不是我们的脚本
1861
+ if (!norm(cmd).includes(norm(relayDir || ""))) return { ident: "", cmdKnown: true }; // 同类脚本,但不是本配置目录
1862
+ return { ident: isBridge ? "bridge" : "watcher", cmdKnown: true };
1863
+ }
1864
+
1865
+ /**
1866
+ * Windows 版 manualStatus:pid 文件 → bridge 自写证据 → PowerShell 扫描(扫描结果只补充、不覆盖)。
1867
+ *
1868
+ * 返回值里的 scanDegraded / bridgeEvidence 是 2026-10-29 事故的产物:面板/诊断必须能区分
1869
+ * 「bridge 真没有(pid 文件、锁、状态文件、扫描四路都说没有)」与「我们根本没能扫」(degraded),
1870
+ * 否则又会像那次一样把「工具看不见」当成「进程不存在」。
1871
+ */
1770
1872
  function windowsManualStatus(relayDir) {
1873
+ const selfPid = process.pid;
1771
1874
  const watcher = [];
1772
1875
  const bridge = [];
1773
1876
  const allBridge = [];
1877
+ const evidence = [];
1774
1878
  const push = (arr, pid) => { if (pid && !arr.includes(pid)) arr.push(pid); };
1879
+ const addBridge = (pid, why) => {
1880
+ if (!pid || pid === selfPid || allBridge.includes(pid)) return;
1881
+ push(bridge, pid); push(allBridge, pid); evidence.push(why);
1882
+ };
1775
1883
  const wPid = readPidFile(relayDir, WATCHER_PID_FILE);
1776
1884
  const bPid = readPidFile(relayDir, BRIDGE_PID_FILE);
1777
- if (wPid && pidAlive(wPid) !== false && wPid !== process.pid) push(watcher, wPid);
1778
- if (bPid && pidAlive(bPid) !== false && bPid !== process.pid) { push(bridge, bPid); push(allBridge, bPid); }
1779
- // 兜底扫描只在 pid 文件一无所获时进行:避免每次轮询都起一个 PowerShell。
1780
- if (!watcher.length && !bridge.length) {
1781
- for (const p of windowsNodeProcesses()) {
1782
- if (p.pid === process.pid || p.pid === wPid || p.pid === bPid) continue;
1783
- if (/dsh-setup\.mjs/.test(p.commandLine)) push(watcher, p.pid);
1784
- else if (/dsh-bridge\.mjs/.test(p.commandLine)) { push(bridge, p.pid); push(allBridge, p.pid); }
1885
+ const wAlive = Boolean(wPid && pidAlive(wPid) !== false);
1886
+ const bAlive = Boolean(bPid && pidAlive(bPid) !== false);
1887
+
1888
+ // ① bridge pid 文件(父进程 watcher 写的那份;子进程一退出它就会被删 —— 这正是事故现场的形态)
1889
+ if (bAlive && bPid !== selfPid) addBridge(bPid, "pid-file");
1890
+
1891
+ // ② bridge 自写证据(F3):进程自己写的「我在跑」比任何外部扫描都可靠,而且不需要起 PowerShell。
1892
+ // · `.dsh-bridge-instance.lock`:{pid,at,script},bridge 启动时自己写(script 即身份自证);
1893
+ // · `.dsh-bridge-state.json`:新版 bridge 写 {pid, heartbeat_at}(心跳拍 ≥10s 刷新一次)。
1894
+ // 这两条正是「父进程把 .dsh-bridge.pid 删了、孤儿 bridge 还活着」时唯一还成立的判据。
1895
+ const state = bridgeStateFile(relayDir);
1896
+ const stPid = Number(state && state.pid);
1897
+ const hbAt = Number(state && state.heartbeat_at) || 0;
1898
+ const hbFresh = hbAt > 0 && Date.now() - hbAt < STATE_HEARTBEAT_TRUST_MS;
1899
+ const stPidAlive = Number.isInteger(stPid) && stPid > 0 && stPid !== selfPid && pidAlive(stPid) !== false;
1900
+ const lock = readJsonFileQuiet(join(relayDir, BRIDGE_LOCK_FILE));
1901
+ const lockPid = Number(lock && lock.pid);
1902
+ const lockAlive = Number.isInteger(lockPid) && lockPid > 0 && lockPid !== selfPid && pidAlive(lockPid) !== false;
1903
+ const lockScriptOk = String((lock && lock.script) || "") === "dsh-bridge.mjs";
1904
+ // 心跳「已经停了」= 状态文件里有过心跳但已超信任窗口。此时锁文件里的 pid 很可能已被系统回收给
1905
+ // 无关进程(bridge 死了、锁没删),必须先拿到命令行确认 —— 否则会永久谎报「bridge 在跑」。
1906
+ const heartbeatStopped = hbAt > 0 && !hbFresh;
1907
+ let lockNeedsProof = false; // 需要命令行确认的锁候选
1908
+
1909
+ let procs = null; // null = 本次没扫描(省掉一次 PowerShell)
1910
+ let scanDegraded = false;
1911
+ if (!bridge.length) {
1912
+ if (lockAlive && lockScriptOk) {
1913
+ if (heartbeatStopped) lockNeedsProof = true; // 心跳停了 → 等扫描用命令行确认
1914
+ else addBridge(lockPid, "instance-lock"); // script 自证 + pid 活着(心跳新鲜或旧版无心跳)
1915
+ }
1916
+ if (!bridge.length && stPidAlive && hbFresh) addBridge(stPid, "state-file");
1917
+ }
1918
+
1919
+ if (!bridge.length) {
1920
+ // ③ F1:**只要 bridge 为空就扫**(不再要求 watcher 也为空)。
1921
+ // 旧条件 `if (!watcher.length && !bridge.length)` 让「watcher 在跑、bridge pid 文件刚被删」
1922
+ // 时一次都不扫 ⇒ bridge 恒为空 ⇒ 误判卡死 → 误重启 → bridge_wedged(2026-10-29 事故)。
1923
+ // F2:扫描不按镜像名过滤(Electron 宿主的 watcher/bridge 不叫 node.exe),按命令行匹配。
1924
+ const sc = windowsProcessScan();
1925
+ procs = sc.procs;
1926
+ scanDegraded = Boolean(sc.degraded);
1927
+ for (const p of procs) {
1928
+ if (!p || p.pid === selfPid) continue;
1929
+ const cmd = String(p.commandLine || "");
1930
+ if (/dsh-setup\.mjs/.test(cmd)) { push(watcher, p.pid); continue; }
1931
+ if (/dsh-bridge\.mjs/.test(cmd)) addBridge(p.pid, "scan");
1932
+ }
1933
+ // 扫描也没找到时,才允许「自写证据 + 命令行确认」这一档(宁可漏报,也不假阳性)
1934
+ if (!bridge.length && lockNeedsProof && pidIdentityOf(lockPid, relayDir, procs).ident === "bridge") {
1935
+ addBridge(lockPid, "instance-lock");
1936
+ }
1937
+ if (!bridge.length && stPidAlive && pidIdentityOf(stPid, relayDir, procs).ident === "bridge") {
1938
+ addBridge(stPid, "state-file");
1785
1939
  }
1786
1940
  }
1787
- return { watcher, bridge, allBridge };
1941
+
1942
+ // ⑤ watcher(守护)pid 文件:必须通过身份/新鲜度校验,否则**不得**报「守护在运行」——
1943
+ // 否则 PID 复用会让 watcherWedgeOf 判「守护活了 N 秒仍无 bridge」→ 误重启(甚至误杀)。
1944
+ if (wAlive && wPid !== selfPid) {
1945
+ let trusted;
1946
+ if (procs) {
1947
+ const id = pidIdentityOf(wPid, relayDir, procs);
1948
+ if (id.ident === "watcher") trusted = true;
1949
+ else if (id.ident === "bridge") { addBridge(wPid, "scan"); trusted = false; } // pid 文件被覆盖成 bridge:算 bridge 更准
1950
+ else trusted = !id.cmdKnown && pidFileAgeMs(relayDir, WATCHER_PID_FILE) < PID_FILE_TRUST_MS;
1951
+ } else {
1952
+ // bridge 已从 pid 文件/自写证据确认 ⇒ 本次没扫描。watcher 列表此时不参与任何风险决策
1953
+ // (wedge 只在「bridge 为空」时才计算),保持旧行为即可,不必为此白起一次 PowerShell。
1954
+ trusted = true;
1955
+ }
1956
+ if (trusted) push(watcher, wPid);
1957
+ }
1958
+
1959
+ return { watcher, bridge, allBridge, scanDegraded, bridgeEvidence: evidence };
1788
1960
  }
1789
1961
 
1790
1962
  /** 检查手动运行的 watcher(dsh-setup.mjs run)与 bridge 子进程(排除 launchd 托管链)。 */
1791
1963
  function manualStatus(relayDir) {
1792
1964
  if (isWindows()) return windowsManualStatus(relayDir || DEFAULT_RELAY_DIR);
1793
1965
  const launchdPid = launchdStatus().pid;
1794
- const out = (() => {
1966
+ const pg = (() => {
1795
1967
  const r = sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs'");
1796
- return r.ok ? r.stdout : "";
1968
+ return { out: r.ok ? r.stdout : "", stderr: String(r.stderr || "") };
1797
1969
  })();
1970
+ const out = pg.out;
1798
1971
  const watcher = [];
1799
1972
  const bridge = [];
1800
1973
  // 全部 bridge 子进程(含 launchd 托管链里的):连接阶段判定「bridge 进程真的在跑」用它。
@@ -1823,7 +1996,15 @@ function manualStatus(relayDir) {
1823
1996
  if (!launchdManaged) bridge.push(pid); // launchd 托管的 bridge 子进程(原语义:不计入 manual.bridge)
1824
1997
  }
1825
1998
  }
1826
- return { watcher, bridge, allBridge };
1999
+ // scanDegraded/bridgeEvidence 与 Windows 版对齐(见 windowsManualStatus 的注释):
2000
+ // POSIX 上 pgrep 匹配的就是命令行本身,不存在「镜像名不对」的盲区。
2001
+ // 注意 pgrep「没匹配到」= 退出码 1 + 无输出 + 无错误输出 ⇒ 这是**有效结论(0 个)**,不是 degraded;
2002
+ // 只有 pgrep 本身不存在/执行失败(stderr 非空)才算扫描不可用。
2003
+ return {
2004
+ watcher, bridge, allBridge,
2005
+ scanDegraded: Boolean(pg.stderr && pg.stderr.trim()),
2006
+ bridgeEvidence: allBridge.length ? ["pgrep"] : [],
2007
+ };
1827
2008
  }
1828
2009
 
1829
2010
  // ---------- 插件市场一键全功能:缺桌面运行环境时自动后台安装 dsh-remote ----------
@@ -2309,10 +2490,27 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
2309
2490
  detail: "桌面运行环境(bridge)尚未安装,已在后台自动安装,完成后会自动启动 bridge——请稍候刷新面板(也可点下方「一键更新」查看进度)",
2310
2491
  };
2311
2492
  }
2312
- // 幂等:watcher 已在跑就别再拉第二个(否则会有两个 bridge 抢同一个设备登记)
2313
- const existing = readPidFile(relayDir, WATCHER_PID_FILE);
2314
- if (existing && pidAlive(existing) !== false) {
2315
- return { ok: true, status: "running", pid: existing };
2493
+ // 幂等:watcher 已在跑就别再拉第二个(否则会有两个 bridge 抢同一个设备登记)。
2494
+ // ⚠️ 2026-10-29(PID 复用):pid **活着**不等于它是我们的 watcher —— pid 文件可能是上一任守护
2495
+ // 留下的,而那个 pid 早已被系统回收给无关进程。不校验就会**永久**返回「已在运行」⇒
2496
+ // 面板「启动 / 立即修复」与每轮自愈全部空转(这与「多次修复都没解决」是同一类故障)。
2497
+ const forced = takeForcedDetachedSpawn();
2498
+ if (forced) {
2499
+ // 旧守护杀不掉/被跳过结束,但本轮确实要「换掉它」:跳过幂等判断,直接拉起新实例。
2500
+ // 新实例会用自己的 pid 覆盖 pid 文件;旧实例若真活着,dsh-setup.mjs 自己的 pid 去重会拦住它自己。
2501
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2502
+ `[dsh-remote-web] ${forced} → 强制拉起新守护(跳过「已在运行」幂等判断)`);
2503
+ }
2504
+ const existing = forced ? null : readPidFile(relayDir, WATCHER_PID_FILE);
2505
+ if (existing && existing !== process.pid && pidAlive(existing) !== false) {
2506
+ const id = pidIdentityOf(existing, relayDir);
2507
+ const fresh = pidFileAgeMs(relayDir, WATCHER_PID_FILE) < PID_FILE_TRUST_MS;
2508
+ if (id.ident || (!id.cmdKnown && fresh)) return { ok: true, status: "running", pid: existing };
2509
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2510
+ `[dsh-remote-web] watcher pid 文件里的 pid=${existing} `
2511
+ + `${id.cmdKnown ? "不是本运行环境的进程(PID 可能已被系统回收)" : "无法确认归属且 pid 文件已超 10 分钟未更新"}`
2512
+ + " → 按过期处理,重新拉起守护");
2513
+ removePidFileQuiet(relayDir, WATCHER_PID_FILE);
2316
2514
  }
2317
2515
  const setup = join(relayDir, "dsh-setup.mjs");
2318
2516
  if (!existsSync(setup)) return { ok: false, status: "not-installed", detail: `运行环境入口脚本不存在(${setup})` };
@@ -2360,20 +2558,85 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
2360
2558
  };
2361
2559
  }
2362
2560
 
2561
+ /* ── F4:动进程之前先验身份(2026-10-29 修复 PID 复用误杀)────────────────────────
2562
+ *
2563
+ * 旧实现 `stopBridgeDetached` 对 pid 文件里的 pid 只做 pidAlive() 判活,然后直接
2564
+ * `killPid()`(Windows = `taskkill /PID <pid> /T /F`)。pid 一旦被系统回收给无关进程,
2565
+ * 这条路径会**杀掉一棵与产品无关的进程树**。现在按「身份 / 新鲜度」两级决定:
2566
+ * · ident 非空(命令行确认是本 relayDir 的 dsh-setup.mjs / dsh-bridge.mjs)→ 结束它;
2567
+ * · cmdKnown=true 但 ident=""(取到了命令行、明确不是我们的)→ **不结束**,只清残留 pid 文件;
2568
+ * · 取不到命令行 → 只有 pid 文件在 PID_FILE_TRUST_MS 内才敢结束,否则跳过并写一行安装日志。
2569
+ * pid 文件的删除同样收紧:**只有「进程已确认结束」或「已确认不是我们的进程」才删**,
2570
+ * 不再无条件先删(那会让「进程还在、现场没了」成为常态,正是这次事故取证困难的原因之一)。
2571
+ */
2572
+ function pidFileVerdict(relayDir, name) {
2573
+ const pid = readPidFile(relayDir, name);
2574
+ if (!pid) return { name, pid: null, alive: false, ident: "", cmdKnown: false, ageMs: Infinity, fresh: false };
2575
+ const alive = pidAlive(pid) !== false;
2576
+ const ageMs = pidFileAgeMs(relayDir, name);
2577
+ const id = alive ? pidIdentityOf(pid, relayDir) : { ident: "", cmdKnown: false };
2578
+ return { name, pid, alive, ident: id.ident, cmdKnown: id.cmdKnown, ageMs, fresh: ageMs < PID_FILE_TRUST_MS };
2579
+ }
2580
+
2581
+ function removePidFileQuiet(relayDir, name) {
2582
+ try { rmSync(join(relayDir, name), { force: true }); } catch { /* 非关键 */ }
2583
+ }
2584
+
2585
+ /* 「重启卡死守护」专用的一次性强制拉起标记。
2586
+ *
2587
+ * 场景:pid 文件里的守护**杀不掉**(pid 已被系统回收给无关进程 / kill 工具不可用 / 按安全纪律
2588
+ * 主动跳过)。这时 `startBridge` 的幂等判断会因「pid 文件还在且新鲜」再次返回「已在运行」→
2589
+ * 重启自愈变成永久 no-op(本次要根治的死循环换个形态回来)。所以由调用方显式下一个一次性标记,
2590
+ * 让紧接着的那次 `startBridgeDetached` 跳过幂等判断、直接拉起新实例。
2591
+ * 用模块级标记而不是改 `startBridge(relayDir)` 签名:那两处签名被源码契约用例按字面锁定。
2592
+ */
2593
+ let forcedDetachedSpawn = "";
2594
+ function forceNextDetachedSpawn(reason) { forcedDetachedSpawn = String(reason || "restart"); }
2595
+ function takeForcedDetachedSpawn() { const r = forcedDetachedSpawn; forcedDetachedSpawn = ""; return r; }
2596
+
2363
2597
  /** 停止「脱离进程」方式运行的 bridge(结束 watcher 与其 bridge 子进程,并清掉 pid 文件)。 */
2364
2598
  function stopBridgeDetached(relayDir, opts = {}) {
2365
2599
  // 账号切换时用 force:即使处于测试隔离模式也要把进程停掉(否则"换个账号"在测试里走不通),
2366
2600
  // 但隔离模式下仍只用信号结束**本插件记录的** pid,不碰任何系统服务。
2367
2601
  if (skipsSystemOps() && !opts.force) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
2368
- const targets = [];
2369
- for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
2370
- const pid = readPidFile(relayDir, name);
2371
- if (pid && pidAlive(pid) !== false && !targets.includes(pid)) targets.push(pid);
2602
+ const decisions = [WATCHER_PID_FILE, BRIDGE_PID_FILE].map((name) => pidFileVerdict(relayDir, name));
2603
+ const killed = [];
2604
+ const skipped = [];
2605
+ for (const d of decisions) {
2606
+ if (!d.pid) { removePidFileQuiet(relayDir, d.name); continue; } // 文件里没有有效 pid:无主残留
2607
+ if (!d.alive) { removePidFileQuiet(relayDir, d.name); continue; } // 已确认结束 → 删(安全)
2608
+ if (d.ident) {
2609
+ killPid(d.pid, "SIGKILL"); // Windows 上是 taskkill /T /F:连带子进程
2610
+ killed.push(d.pid);
2611
+ if (pidAlive(d.pid) === false) removePidFileQuiet(relayDir, d.name); // 只有确认结束才删
2612
+ continue;
2613
+ }
2614
+ if (d.cmdKnown) {
2615
+ // 取到了命令行、明确不是我们的进程(别的配置目录/别的脚本):不结束,只清残留文件
2616
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2617
+ `[dsh-remote-web] ${d.name} 里的 pid=${d.pid} 不是本运行环境的进程(PID 可能已被系统回收),`
2618
+ + "不结束它,按残留清理 pid 文件");
2619
+ removePidFileQuiet(relayDir, d.name);
2620
+ continue;
2621
+ }
2622
+ // 取不到命令行:只有 pid 文件足够新才敢认为它是我们的进程;否则宁可漏杀
2623
+ if (d.fresh) {
2624
+ killPid(d.pid, "SIGKILL");
2625
+ killed.push(d.pid);
2626
+ if (pidAlive(d.pid) === false) removePidFileQuiet(relayDir, d.name); // 只有确认结束才删
2627
+ continue;
2628
+ }
2629
+ skipped.push(d);
2372
2630
  }
2373
- for (const pid of targets) killPid(pid, "SIGKILL");
2374
- for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
2375
- try { rmSync(join(relayDir, name), { force: true }); } catch { /* 非关键 */ }
2631
+ for (const d of skipped) {
2632
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2633
+ `[dsh-remote-web] 无法确认 pid 归属,跳过结束(避免误杀):${d.name} pid=${d.pid}`
2634
+ + `(pid 文件已 ${Math.max(1, Math.round((d.ageMs || 0) / 60000))} 分钟未更新,且取不到该进程的命令行)`);
2376
2635
  }
2636
+ // 本轮「没能结束掉」的 pid(尝试过但杀不掉,或按安全纪律主动跳过):
2637
+ // 调用方(尤其是「重启卡死守护」那条自愈)用它决定要不要强制拉起新实例 ——
2638
+ // 否则「pid 文件里那个 pid 杀不掉且仍然新鲜」会让重启变成永久 no-op。
2639
+ const unresolvedPids = [...killed, ...skipped.map((d) => d.pid)].filter((pid) => pidAlive(pid) !== false);
2377
2640
  // 复核:pid 文件之外可能还有用户手动跑起来、或用旧版运行时起的进程
2378
2641
  const manual = manualStatus(relayDir);
2379
2642
  const still = [...new Set([...manual.watcher, ...manual.bridge])];
@@ -2381,7 +2644,12 @@ function stopBridgeDetached(relayDir, opts = {}) {
2381
2644
  ok: still.length === 0,
2382
2645
  status: still.length ? "failed" : "stopped",
2383
2646
  pid: null,
2384
- detail: still.length ? `仍有 ${still.length} 个 bridge 相关进程未结束(pid ${still.join(", ")})` : void 0,
2647
+ killed,
2648
+ skippedPids: skipped.map((d) => d.pid),
2649
+ unresolvedPids,
2650
+ detail: still.length
2651
+ ? `仍有 ${still.length} 个 bridge 相关进程未结束(pid ${still.join(", ")})`
2652
+ : (skipped.length ? "已结束可确认归属的进程;无法确认归属的 pid 已跳过(避免误杀)" : void 0),
2385
2653
  };
2386
2654
  }
2387
2655
 
@@ -2533,6 +2801,24 @@ function scheduleRuntime(relayDir) {
2533
2801
  logSelfhealReady(relayDir, cfg, "进程探测(pgrep/pid 文件)");
2534
2802
  return;
2535
2803
  }
2804
+ // ── ②.5 设备绑定硬上限(bridge 退出码 5):永久性失败时**不要**每 12 秒重启一次 ──
2805
+ // 本自愈循环与 ensureConnection 是两条独立的拉起路径。watcher(dsh-setup.mjs)已在按
2806
+ // 10s→1min→5min→15min(封顶 30min)退避等待时,这里再 startBridge(systemctl restart /
2807
+ // launchctl kickstart)会把 watcher 整个换掉、把它的内存退避清零 —— 又回到 10s 空撞一次。
2808
+ // 所以退避窗口内直接放弃本拍(人工操作走面板按钮 = force 路径,不受这条约束)。
2809
+ const bindErrNow = readBindError(relayDir);
2810
+ const bindGateNow = bindFailGate(relayDir, bindErrNow);
2811
+ if (bindGateNow.permanent && bindGateNow.blocked) {
2812
+ const book = connectBook(relayDir);
2813
+ if (book.bindFailLogged !== bindGateNow.until) {
2814
+ book.bindFailLogged = bindGateNow.until;
2815
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2816
+ `[dsh-remote-web] 自愈暂停拉起 bridge:设备绑定已达上限(退出码 ${DEVICE_BIND_CAP_EXIT_CODE}),`
2817
+ + `约 ${Math.ceil(bindGateNow.remainingMs / 1000)} 秒后再试(指数退避,最长 30 分钟);`
2818
+ + "请在手机端「管理电脑」解绑不再使用的设备,解绑后自动恢复");
2819
+ }
2820
+ return;
2821
+ }
2536
2822
  // ── ③ 确实没在跑 → 过护栏后才动手(绝不允许"判据失败"直接变成"每 12 秒杀一次隧道")──
2537
2823
  const gate = selfhealRestartGate(relayDir);
2538
2824
  if (!gate.allowed) { noteSelfhealBlocked(relayDir, gate); return; }
@@ -4398,7 +4684,8 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
4398
4684
  function commandLineOfPid(pid) {
4399
4685
  if (!Number.isInteger(pid) || pid <= 0) return "";
4400
4686
  if (isWindows()) {
4401
- const hit = windowsNodeProcesses().find((p) => p.pid === pid);
4687
+ // 2026-10-29:改走「无镜像名过滤」的全量扫描(旧的 Name='node.exe' 过滤看不到 Electron 宿主的进程)。
4688
+ const hit = windowsProcessScan().procs.find((p) => p.pid === pid);
4402
4689
  return hit ? String(hit.commandLine || "") : "";
4403
4690
  }
4404
4691
  const r = sh(`ps -p ${pid} -o command=`);
@@ -4435,8 +4722,11 @@ function stopBridgeForRuntimeReplace(relayDir) {
4435
4722
  if (skipsSystemOps()) return { skipped: true, pids: [] };
4436
4723
  const pids = collectRuntimePids(relayDir).filter((pid) => pidAlive(pid) !== false && pidRunsFrom(pid, relayDir));
4437
4724
  for (const pid of pids) killPid(pid, "SIGKILL"); // Windows 上是 taskkill /T /F:连带子进程
4725
+ // 2026-10-29:pid 文件只在「已确认结束」或「已确认不是我们的进程」时才删;
4726
+ // 取不到命令行且进程仍活着的条目保持现场(宁可留残留文件,也不制造「进程在、文件没了」的假象)。
4438
4727
  for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
4439
- try { rmSync(join(relayDir, name), { force: true }); } catch { /* 非关键 */ }
4728
+ const v = pidFileVerdict(relayDir, name);
4729
+ if (!v.pid || !v.alive || v.cmdKnown) removePidFileQuiet(relayDir, name);
4440
4730
  }
4441
4731
  return { skipped: false, pids };
4442
4732
  }
@@ -4983,6 +5273,10 @@ function resetBridgeForAccountChange(relayDir, why) {
4983
5273
  // 它必须无条件生效 —— 所以顺手清掉自愈护栏额度,绝不允许"自愈循环之前用完了 3 次额度"
4984
5274
  // 导致切换账号后新 bridge 起不来(那会让设备继续留在旧账号里,是 2026-09-19 那个老故障的翻版)。
4985
5275
  resetSelfhealGuard(relayDir);
5276
+ // 换账号 = 设备绑定前提已变:插件半的设备绑定退避也一并复位(新账号的额度/硬上限可能完全不同)
5277
+ const bk = connectBook(relayDir);
5278
+ bk.bindFail = { sig: bindFailSignatureOf(readBindError(relayDir)), step: 0, until: 0 };
5279
+ bk.bindFailLogged = 0;
4986
5280
  const stopped = stopBridge(relayDir);
4987
5281
  // 脱离进程形态(launchd 托管不住时的兜底)也要收干净,否则它会用旧凭据继续跑
4988
5282
  try { stopBridgeDetached(relayDir, { force: true }); } catch { /* 非关键 */ }
@@ -5078,6 +5372,18 @@ const INSTALL_STALE_MS = 10 * 60 * 1000;
5078
5372
  const BIND_ERROR_FRESH_MS = 60 * 60 * 1000;
5079
5373
  /** 自动重试退避(毫秒):可重试错误按此节奏自动重试,用尽后停在 60s(仍会自动重试,不出现死胡同)。 */
5080
5374
  const CONNECT_BACKOFF_MS = [2000, 4000, 8000, 16_000, 30_000, 60_000];
5375
+ /**
5376
+ * 设备登记「永久性失败」的退避(契约 docs/device-overquota-lock-contract.md §6.3/§6.4)。
5377
+ *
5378
+ * bridge 遇到服务端 `device_bind_cap`(防滥用硬上限)会写 `.bind-error.json` 并以**专用退出码 5**
5379
+ * 退出。watcher(dsh-setup.mjs)自己会做 10s→1min→5min→15min(封顶 30min)的指数退避,但它的状态
5380
+ * 在**内存**里 —— 插件半的 startBridge(`launchctl kickstart` / `systemctl restart`)会把 watcher
5381
+ * 一并换掉,等于每次自愈都把它辛苦攒的退避清零、又 10s 空撞一次。所以插件半必须自己记一份:
5382
+ * 退避窗口内不再发起拉起,`.bind-error.json` 的内容签名变化(解绑/换账号/被清掉)时复位。
5383
+ */
5384
+ const DEVICE_BIND_CAP_EXIT_CODE = 5;
5385
+ const BIND_CAP_BACKOFF_MS = [10_000, 60_000, 300_000, 900_000];
5386
+ const BIND_CAP_BACKOFF_MAX_MS = 30 * 60 * 1000;
5081
5387
 
5082
5388
  /** 连接阶段文案(面向非技术用户;node 半算好,面板直接显示,避免两端各写一套)。 */
5083
5389
  const CONNECT_TEXT = {
@@ -5096,7 +5402,7 @@ const connectBooks = new Map();
5096
5402
  function connectBook(relayDir) {
5097
5403
  let b = connectBooks.get(relayDir);
5098
5404
  if (!b) {
5099
- b = { attempts: 0, lastAttemptAt: 0, reportAt: 0, watcherSeenAt: 0 };
5405
+ b = { attempts: 0, lastAttemptAt: 0, reportAt: 0, watcherSeenAt: 0, bindFail: { sig: "", step: 0, until: 0 } };
5100
5406
  connectBooks.set(relayDir, b);
5101
5407
  }
5102
5408
  return b;
@@ -5106,6 +5412,63 @@ function connectBackoffMs(attempts) {
5106
5412
  const i = Math.min(Math.max(attempts, 0), CONNECT_BACKOFF_MS.length - 1);
5107
5413
  return CONNECT_BACKOFF_MS[i];
5108
5414
  }
5415
+
5416
+ /**
5417
+ * `.bind-error.json` 的稳定签名:只取 code/device_id/message,**不含 at** ——
5418
+ * bridge 每次重试都会刷新 at,拿它当"变了"的依据等于永远变化、退避形同虚设。
5419
+ */
5420
+ function bindFailSignatureOf(bindError) {
5421
+ if (!bindError || typeof bindError !== "object") return "";
5422
+ return [bindError.code || "", bindError.device_id || "", bindError.message || ""].join("|");
5423
+ }
5424
+
5425
+ /** 是否是「永久性」登记失败(硬上限;用户不解绑就不会自愈,必须指数退避而不是每 10s 重试)。 */
5426
+ function isPermanentBindFailure(bindError) {
5427
+ return Boolean(bindError && typeof bindError === "object" && bindError.code === "device_bind_cap");
5428
+ }
5429
+
5430
+ /** 第 step 次(从 1 开始)拉起后的退避时长:10s→1min→5min→15min,之后封顶 30min。 */
5431
+ function bindCapBackoffMs(step) {
5432
+ const n = Math.max(1, Number(step) || 1);
5433
+ if (n <= BIND_CAP_BACKOFF_MS.length) return Math.min(BIND_CAP_BACKOFF_MAX_MS, BIND_CAP_BACKOFF_MS[n - 1]);
5434
+ return BIND_CAP_BACKOFF_MAX_MS;
5435
+ }
5436
+
5437
+ /**
5438
+ * 插件半的「设备绑定硬上限」退避闸门:**只读**,由 ensureConnection 在动手前调用。
5439
+ * 签名变化(换账号 / 用户解绑后重试得到不同结论 / 成功登记把提示清掉)→ 复位。
5440
+ * @returns {{permanent:boolean, blocked:boolean, until:number, step:number, remainingMs:number}}
5441
+ */
5442
+ function bindFailGate(relayDir, bindError) {
5443
+ const book = connectBook(relayDir);
5444
+ if (!book.bindFail) book.bindFail = { sig: "", step: 0, until: 0 };
5445
+ const st = book.bindFail;
5446
+ const sig = bindFailSignatureOf(bindError);
5447
+ if (sig !== st.sig) {
5448
+ st.sig = sig;
5449
+ st.step = 0;
5450
+ st.until = 0;
5451
+ }
5452
+ const now = Date.now();
5453
+ return {
5454
+ permanent: isPermanentBindFailure(bindError),
5455
+ blocked: now < st.until,
5456
+ until: st.until,
5457
+ step: st.step,
5458
+ remainingMs: Math.max(0, st.until - now),
5459
+ };
5460
+ }
5461
+
5462
+ /** 记一次「即将(重新)拉起 bridge」:永久性失败时步进退避,返回退避后的状态。 */
5463
+ function noteBindFailAttempt(relayDir, bindError) {
5464
+ const book = connectBook(relayDir);
5465
+ if (!book.bindFail) book.bindFail = { sig: bindFailSignatureOf(bindError), step: 0, until: 0 };
5466
+ const st = book.bindFail;
5467
+ st.sig = bindFailSignatureOf(bindError);
5468
+ st.step = Math.min(st.step + 1, BIND_CAP_BACKOFF_MS.length + 1);
5469
+ st.until = Date.now() + bindCapBackoffMs(st.step);
5470
+ return st;
5471
+ }
5109
5472
  /** 本机是否已有可用账号凭据(SaaS 账号或自建访问密钥)——没有就谈不上自动连接,先引导登录。 */
5110
5473
  function hasAccountCreds(cfg) {
5111
5474
  return Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
@@ -5357,6 +5720,11 @@ async function composeConnect(relayDir, opts = {}) {
5357
5720
  const bridgeRunning = Boolean(launchd.running || bridgePids.length > 0 || (manual.bridge || []).length > 0);
5358
5721
  const watcherPids = Array.isArray(manual.watcher) ? manual.watcher : [];
5359
5722
  const watcherRunning = Boolean(!bridgeRunning && watcherPids.length > 0);
5723
+ // F5(2026-10-29):进程发现的「结论」与「工具怎么得出这个结论的」必须一起下发 ——
5724
+ // 那次事故里面板只有一句「bridge 未运行」,看不出是「扫描不可用(工具看不见)」还是
5725
+ // 「四路证据都说没有(真没有)」,于是排查方向被带偏。scanDegraded/bridgeEvidence 就是答案。
5726
+ const scanDegraded = Boolean(manual.scanDegraded);
5727
+ const bridgeEvidence = Array.isArray(manual.bridgeEvidence) ? manual.bridgeEvidence : [];
5360
5728
  const wedge = watcherWedgeOf(relayDir, { watcherRunning, bridgeRunning, now });
5361
5729
  const rateLimitedUntil = loginRateLimitedUntil(relayDir);
5362
5730
  const bindError = readBindError(relayDir);
@@ -5365,6 +5733,31 @@ async function composeConnect(relayDir, opts = {}) {
5365
5733
  const state = bridgeStateFile(relayDir);
5366
5734
  const deviceId = String(cfg.device_id || "");
5367
5735
 
5736
+ // ── 「设备被超额锁定」证据(契约 §8.1)──
5737
+ // 两个来源都认:① 新 bridge 写的状态文件 phase=locked + locked{plan,limit,upgrade_to};
5738
+ // ② `.bind-error.json` code=device_locked / 旧版 device_limit_exceeded(老 bridge/半升级场景)。
5739
+ // 锁定 ≠ 故障:设备行还在、手机端能看到它,只是中继按套餐额度拒绝接入;bridge 不退出,
5740
+ // 每 30s 轮询 /api/devices,用户解绑或升级后自动解锁重连。面板的任务是把这两条出路讲清楚。
5741
+ const stateLocked = state && state.phase === "locked" && state.locked && typeof state.locked === "object" ? state.locked : null;
5742
+ const bindLocked = bindError && (bindError.code === "device_locked" || bindError.code === "device_limit_exceeded")
5743
+ ? bindError
5744
+ : null;
5745
+ const lockRaw = stateLocked || bindLocked || null;
5746
+ const locked = lockRaw
5747
+ ? {
5748
+ message: String(lockRaw.message || ""),
5749
+ plan: String(lockRaw.plan || ""),
5750
+ limit: Number.isFinite(Number(lockRaw.limit)) && Number(lockRaw.limit) >= 0 ? Number(lockRaw.limit) : null,
5751
+ upgradeTo: String(lockRaw.upgrade_to || lockRaw.upgradeTo || "pro_max")
5752
+ }
5753
+ : null;
5754
+ /** 契约 §8.1 的固定文案(盘点口径:可同时用 N 台 + 两条出路 + 会自动解锁,不让用户以为要重启)。 */
5755
+ const lockedDetail = locked
5756
+ ? `本机已被锁定(当前套餐可同时使用 ${locked.limit === null ? "N" : locked.limit} 台)。`
5757
+ + "升级 Pro Max 可多台设备同时使用;或到手机端「管理电脑」解绑其他设备后本机会自动解锁。"
5758
+ + "(后台服务保持运行并每 30 秒检查一次,解锁后自动恢复,无需重启。)"
5759
+ : "";
5760
+
5368
5761
  // ── 中继注册证据(只有 bridge 进程在跑时才算「现在能用」) ──
5369
5762
  // ⚠️ 先判「这个 bridge 是不是当前账号的」:账号切换后进程不会自己换凭据,
5370
5763
  // 旧账号留下的状态文件(phase=online + 旧 device_id)会让面板谎报「已连接」,
@@ -5432,6 +5825,15 @@ async function composeConnect(relayDir, opts = {}) {
5432
5825
  + "正在后台自动补装(约 1~2 分钟),装完会自动启动 bridge。";
5433
5826
  }
5434
5827
  }
5828
+ } else if (locked) {
5829
+ // 契约 §8.1:锁定态给「error」阶段(用户确实用不了),但**不是**普通故障:
5830
+ // 文案要说清「两条出路」+「会自动解锁」,且不能出现"无限重试"的暗示。
5831
+ // 必须排在「bridge 没在跑」之前:锁定是服务端额度结论,bridge 为等待解锁会一直跑着;
5832
+ // 即使进程被手动停掉,用户看到的也该是「已被锁定」而不是「正在启动」。
5833
+ phase = "error";
5834
+ retryable = true;
5835
+ error = { code: "device_locked", message: locked.message || "本机已被锁定(超出当前套餐设备额度)" };
5836
+ detail = lockedDetail;
5435
5837
  } else if (!bridgeRunning) {
5436
5838
  if (launchd.crashing) {
5437
5839
  phase = "error";
@@ -5443,7 +5845,11 @@ async function composeConnect(relayDir, opts = {}) {
5443
5845
  } else if (bindFresh && bindError) {
5444
5846
  phase = "error";
5445
5847
  error = { code: bindError.code || "bind_failed", message: bindError.message || "设备登记失败" };
5446
- detail = "bridge 因设备登记失败退出,已进入自动重试;请按提示处理后重试。";
5848
+ // device_bind_cap = 防滥用硬上限(bridge 退出码 5):守护按指数退避重试(10s→1min→5min→15min,
5849
+ // 封顶 30min),不是"每 10 秒撞一次"。文案要给出用户能做的事,不能暗示无限重试。
5850
+ detail = bindError.code === "device_bind_cap"
5851
+ ? "已绑定设备数达到上限:请在手机端「管理电脑」解绑不再使用的设备;解绑后本机会自动恢复(守护已按指数退避重试,无需手动重启)。"
5852
+ : "bridge 因设备登记失败退出,已进入自动重试;请按提示处理后重试。";
5447
5853
  } else if (wedge.wedged && wedge.exhausted) {
5448
5854
  // 守护活着、却反复拉不起 bridge,且已自动重启到上限 → 这是**真的**卡死了,给明确结论 + 出路。
5449
5855
  // 旧实现在这种状态下永远显示「正在启动 Bridge…」,用户只能反复点修复(实测反馈)。
@@ -5479,8 +5885,10 @@ async function composeConnect(relayDir, opts = {}) {
5479
5885
  } else if (bindFresh && bindError) {
5480
5886
  phase = "error";
5481
5887
  error = { code: bindError.code || "bind_failed", message: bindError.message || "设备登记失败" };
5482
- detail = bindError.code === "device_limit_exceeded"
5483
- ? "已达本套餐设备数上限:同机重装会自动顶替旧设备;仍失败请到手机端「设备管理」解绑旧设备后点「重试」。"
5888
+ // 旧语义(「解绑旧设备才能登录」「无限重试」)已废弃:超额现在一律走 locked 分支(上面),
5889
+ // 只有硬上限/网络类注册失败会落到这里。
5890
+ detail = bindError.code === "device_bind_cap"
5891
+ ? "已绑定设备数达到上限:请在手机端「管理电脑」解绑不再使用的设备;解绑后本机会自动恢复(守护已按指数退避重试,无需手动重启)。"
5484
5892
  : "请确认网络与账号状态后点「重试」;仍不成功可点「复制诊断信息」发给客服。";
5485
5893
  } else if (!accountCurrent) {
5486
5894
  // 进程在跑,但它是**上一个账号**的 bridge:必须重启才能用新账号登记
@@ -5517,6 +5925,11 @@ async function composeConnect(relayDir, opts = {}) {
5517
5925
  bridgeRunning,
5518
5926
  accountCurrent, // 运行中的 bridge 是否属于当前账号(false = 上一个账号的进程还在跑)
5519
5927
  bridgePids,
5928
+ // 进程发现的「证据来源」与「扫描是否可用」(F5,2026-10-29):
5929
+ // bridgeEvidence ∈ {pid-file, instance-lock, state-file, scan, pgrep},
5930
+ // scanDegraded=true 表示这台机器上插件根本没能扫进程(这时「bridge 未运行」没有说服力)。
5931
+ bridgeEvidence,
5932
+ scanDegraded,
5520
5933
  // 守护(watcher)与上游:这两项以前完全不下发,于是「守护活着但拉不起 bridge」
5521
5934
  // 与「上游端口不对」在面板上都长得跟「正在启动」一模一样(用户反馈就是这么来的)。
5522
5935
  watcherPids,
@@ -5545,7 +5958,19 @@ async function composeConnect(relayDir, opts = {}) {
5545
5958
  attempts: book.attempts,
5546
5959
  nextRetryInMs,
5547
5960
  error,
5548
- bindError: bindFresh && bindError ? { code: bindError.code || "", message: bindError.message || "" } : null,
5961
+ // 设备被超额锁定(契约 §8):null = 未锁定;非 null 时面板显示锁定文案 + 「🚀 升级 Pro Max」按钮。
5962
+ // limit 为 null 表示服务端没给额度数值(旧版),文案退回占位。
5963
+ locked,
5964
+ bindError: bindFresh && bindError
5965
+ ? {
5966
+ code: bindError.code || "",
5967
+ message: bindError.message || "",
5968
+ deviceId: bindError.device_id || "",
5969
+ plan: bindError.plan || "",
5970
+ limit: Number.isFinite(Number(bindError.limit)) && Number(bindError.limit) >= 0 ? Number(bindError.limit) : null,
5971
+ upgradeTo: bindError.upgrade_to || ""
5972
+ }
5973
+ : null,
5549
5974
  // 安装/配对自检(2026-10-17):kind/text/backup.action 见 setupStateOf 的长注释。
5550
5975
  // 面板据此显示「运行环境缺失:需要重新安装…」「未配对:需要在面板里登录一次…」
5551
5976
  // 以及「检测到历史配对备份(时间),可一键恢复」。
@@ -5628,6 +6053,11 @@ function buildConnectDiagnostics(relayDir, conn, log) {
5628
6053
  ? ""
5629
6054
  : `运行环境缺文件: ${conn.runtimeMissing.slice(0, 8).join(", ")}${conn.runtimeMissing.length > 8 ? ` 等 ${conn.runtimeMissing.length} 个` : ""}`,
5630
6055
  `bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
6056
+ // 进程发现的证据来源(F5,2026-10-29):没有这一行时,「bridge 未运行」到底是「真没有」还是
6057
+ // 「工具没看见」在诊断里无法区分 —— 上一次事故就是这么被带偏的。
6058
+ `进程发现: ${conn.scanDegraded
6059
+ ? "⚠️ 进程扫描不可用(PowerShell 调用失败),本次结论只来自 pid 文件/自写证据 —— 「未运行」不一定是真的没跑"
6060
+ : "扫描可用"}|bridge 证据: ${conn.bridgeEvidence && conn.bridgeEvidence.length ? conn.bridgeEvidence.join("/") : "无(pid 文件、实例锁、状态文件、扫描都没有活着的 bridge)"}`,
5631
6061
  `后台守护: ${conn.watcherRunning ? `在运行(pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `,已 ${Math.round(conn.watcherAgeMs / 1000)} 秒` : ""}${conn.watcherWedged ? ",⚠️ 迟迟未拉起 bridge" : ""}${conn.watcherRestarts ? `,已自动重启 ${conn.watcherRestarts} 次` : ""})` : "未运行"}`,
5632
6062
  // 只在真的重启过 bridge 时才出现这一行(正常用户零新增文案,不制造噪音):
5633
6063
  // 它回答的是"插件自己有没有在反复重启 bridge"—— 2026-10-13 那个故障里,这个问题
@@ -5786,6 +6216,27 @@ function ensureConnection(relayDir, opts = {}) {
5786
6216
  if (opts.force) resetSelfhealGuard(relayDir);
5787
6217
  if (opts.force) { book.attempts = 0; book.lastAttemptAt = 0; }
5788
6218
  const now = Date.now();
6219
+ // ── 设备绑定「永久性失败」→ 插件半指数退避(契约 §6.3/§6.4)────────────────────────────
6220
+ // bridge 撞到防滥用硬上限(device_bind_cap)会写 .bind-error.json 并以**退出码 5** 退出。
6221
+ // watcher 自己会退避,但它的退避状态只存在内存里 —— 插件半的 startBridge(launchctl kickstart /
6222
+ // systemctl restart)会把 watcher 整个换掉,等于每轮自愈都把它的退避清零、又回 10s 空撞一次。
6223
+ // 所以插件半必须自己记一份:退避窗口内**不**发起拉起(也不白耗一次 device-login),
6224
+ // 窗口过了才允许一次新尝试,并按 10s→1min→5min→15min(封顶 30min)继续加倍。
6225
+ // ⚠️ 必须放在「通用 connect 退避」之前判断:否则上一轮动作留下的 2s connect 退避会先 return null,
6226
+ // 面板看到的是"什么也没发生",而不是"设备绑定已达上限、正在退避等待"。
6227
+ // 人工操作(opts.force = 面板「重试 / 一键修复」)永远立即放行:安全阀不能把用户锁在门外。
6228
+ const bindErrorNow = readBindError(relayDir);
6229
+ if (opts.force) book.bindFail = { sig: bindFailSignatureOf(bindErrorNow), step: 0, until: 0 };
6230
+ const bindGateEarly = bindFailGate(relayDir, bindErrorNow);
6231
+ if (bindGateEarly.permanent && !opts.force && bindGateEarly.blocked && runtimeReady(relayDir)) {
6232
+ // 同一条结论只写一行日志(面板每 2~3s 轮询一次,逐轮写会把安装日志刷爆)
6233
+ if (book.bindFailLogged !== bindGateEarly.until) {
6234
+ book.bindFailLogged = bindGateEarly.until;
6235
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
6236
+ `[dsh-remote-web] 设备绑定已达上限(bridge 退出码 ${DEVICE_BIND_CAP_EXIT_CODE})→ 自愈暂停拉起约 ${Math.ceil(bindGateEarly.remainingMs / 1000)} 秒(指数退避,最长 30 分钟);请在手机端「管理电脑」解绑不再使用的设备,解绑后自动恢复`);
6237
+ }
6238
+ return { action: "bind-cap-backoff", until: bindGateEarly.until, step: bindGateEarly.step };
6239
+ }
5789
6240
  if (!opts.force && book.lastAttemptAt && now - book.lastAttemptAt < connectBackoffMs(book.attempts)) return null;
5790
6241
  const cfg = loadConfig(relayDir);
5791
6242
  if (!hasAccountCreds(cfg)) {
@@ -5810,6 +6261,9 @@ function ensureConnection(relayDir, opts = {}) {
5810
6261
  ensureRuntime(relayDir);
5811
6262
  return { action: "provision" };
5812
6263
  }
6264
+ // 运行时已就绪 → 退避窗口外允许一次新尝试:先记账(下一次要等更久),再照常走下面的判断。
6265
+ if (bindGateEarly.permanent && !opts.force) noteBindFailAttempt(relayDir, bindErrorNow);
6266
+ else book.bindFailLogged = 0;
5813
6267
  const launchd = launchdStatus();
5814
6268
  const manual = manualStatus(relayDir);
5815
6269
  const bridgeProcs = [...(manual.allBridge || []), ...(manual.bridge || [])];
@@ -5844,7 +6298,14 @@ function ensureConnection(relayDir, opts = {}) {
5844
6298
  // 这两种都必须**显式结束卡死的守护**,否则 startBridge 幂等会直接返回、重启变成 no-op。
5845
6299
  // macOS 走 launchctl,startBridge 内部已先 bootout 再 bootstrap,等价于重启。
5846
6300
  // ★ 0.6.15:以前这里把 Linux 排除在外(那时 Linux 的 startBridge 是死路,重启没意义)。
5847
- if (isWindows() || (isLinux() && !systemdUserAvailableCached())) stopBridgeDetached(relayDir);
6301
+ // ★ 2026-10-29:如果那个守护**杀不掉**(pid 已被系统回收给无关进程 / kill 工具不可用 /
6302
+ // 按安全纪律主动跳过),`startBridge` 的幂等判断会因「pid 文件还在且新鲜」再次返回
6303
+ // 「已在运行」→ 重启变成永久 no-op(就是本次要根治的死循环)。此时必须**强制拉起**:
6304
+ // 新 watcher 会用自己的 pid 覆盖 pid 文件;旧实例若真活着,dsh-setup.mjs 自己的 pid 去重会拦。
6305
+ let stopInfo = null;
6306
+ if (isWindows() || (isLinux() && !systemdUserAvailableCached())) stopInfo = stopBridgeDetached(relayDir);
6307
+ const unresolved = (stopInfo && Array.isArray(stopInfo.unresolvedPids)) ? stopInfo.unresolvedPids : [];
6308
+ if (unresolved.length) forceNextDetachedSpawn(`watcher pid 未能结束(pid ${unresolved.join(", ")})`);
5848
6309
  const r = startBridge(relayDir);
5849
6310
  appendLogLine(relayDir, AUTO_INSTALL_LOG,
5850
6311
  `[dsh-remote-web] 后台守护已存活 ${Math.round(wedge.ageMs / 1000)} 秒仍无 bridge 进程`
@@ -6661,8 +7122,8 @@ export const __telemetryInternals = {
6661
7122
  * 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
6662
7123
  * 之类的约定统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
6663
7124
  * 契约:POST <api_url>/api/install-report,头 `authorization: Bearer <device-login JWT>`,
6664
- * body { device_id?, install_source, install_version, harness_version, host_os, host_arch },成功 200 {ok:true};
6665
- * 404/400 一律忽略(老服务端没有该接口时不能报错)。
7125
+ * body { device_id?, install_source, install_version, harness_version, ref_code?, host_os, host_arch },
7126
+ * 成功 200 {ok:true};404/400 一律忽略(老服务端没有该接口时不能报错)。
6666
7127
  */
6667
7128
  const INSTALL_REPORT_FILE = ".dsh-install-report.json";
6668
7129
  const INSTALL_REPORT_TTL_MS = 24 * 3600 * 1000;
@@ -6675,16 +7136,36 @@ function installSourceOf(relayDir) {
6675
7136
  if (runtimeReady(relayDir)) return "npx";
6676
7137
  return "plugin_market";
6677
7138
  }
7139
+ /**
7140
+ * 渠道归因码:安装器 `--ref <code>` 写进 .dsh-config.json 的 `ref_code`(3~12 位字母或数字)。
7141
+ * 规则与 clients/dsh-remote/src/channel-ref.mjs 同一份;插件包**不包含** clients/(files 只有 lib+runtime),
7142
+ * 所以这里保留这几行等价实现 —— 改契约时两边一起改。
7143
+ * 归一化 = NFKC(全角→半角)+ trim + 小写,与后端 channel.js normalizeCode 一致。
7144
+ * 缺失/非法 → 空串,调用方据此**整个省略**字段(绝不上报空串/脏值)。
7145
+ */
7146
+ function refCodeOf(cfg) {
7147
+ const raw = cfg && cfg.ref_code;
7148
+ let v = "";
7149
+ if (typeof raw === "number") v = Number.isInteger(raw) && raw >= 0 ? String(raw) : "";
7150
+ else if (typeof raw === "string") v = raw;
7151
+ else return "";
7152
+ const normalized = v.normalize("NFKC").trim().toLowerCase();
7153
+ return /^[a-z0-9]{3,12}$/.test(normalized) ? normalized : "";
7154
+ }
6678
7155
  async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
6679
7156
  const cfg = cfgOverride || loadConfig(relayDir);
6680
7157
  if (!hasAccountCreds(cfg)) return { ok: false, skipped: "no_credentials" };
6681
7158
  const source = installSourceOf(relayDir);
6682
7159
  const version = PLUGIN_VERSION;
7160
+ // 渠道归因码(可选):服务端字段可选,缺失/非法就整个省略。
7161
+ const refCode = refCodeOf(cfg);
6683
7162
  const marker = join(relayDir, INSTALL_REPORT_FILE);
6684
7163
  if (!opts.force) {
6685
7164
  try {
6686
7165
  const prev = JSON.parse(readFileSync(marker, "utf8"));
6687
- if (prev && prev.version === version && prev.source === source && Date.now() - Number(prev.at || 0) < INSTALL_REPORT_TTL_MS) {
7166
+ // ref 也是去重键的一部分:同一版本下后补 `--ref`(重跑安装器)时必须能补报一次,
7167
+ // 否则归因会在 24h 窗口内被"已上报过"吞掉。
7168
+ if (prev && prev.version === version && prev.source === source && (prev.ref || "") === refCode && Date.now() - Number(prev.at || 0) < INSTALL_REPORT_TTL_MS) {
6688
7169
  return { ok: true, skipped: "already_reported" };
6689
7170
  }
6690
7171
  } catch { /* 首次上报 */ }
@@ -6699,6 +7180,7 @@ async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
6699
7180
  // 宿主(DeepSeek Harness)版本:与 install_version(插件版本)是两个数,
6700
7181
  // 只报插件版本时「是不是 DSH 升级导致的故障」答不出来(2026-10 生产反馈的普遍缺口)。
6701
7182
  harness_version: harnessVersion(),
7183
+ ...(refCode ? { ref_code: refCode } : {}),
6702
7184
  host_os: process.platform,
6703
7185
  host_arch: process.arch,
6704
7186
  };
@@ -6716,8 +7198,8 @@ async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
6716
7198
  // 老服务端没有该接口(404)或字段不认(400)→ 忽略,绝不影响 UI
6717
7199
  if (r.status === 400 || r.status === 404) return { ok: false, skipped: `http_${r.status}` };
6718
7200
  if (!r.ok) return { ok: false, status: r.status };
6719
- try { writeFileSync(marker, JSON.stringify({ at: Date.now(), version, source })); } catch { /* 非关键 */ }
6720
- return { ok: true, status: r.status, install_source: source, install_version: version };
7201
+ try { writeFileSync(marker, JSON.stringify({ at: Date.now(), version, source, ref: refCode })); } catch { /* 非关键 */ }
7202
+ return { ok: true, status: r.status, install_source: source, install_version: version, ...(refCode ? { ref_code: refCode } : {}) };
6721
7203
  } catch (e) {
6722
7204
  return { ok: false, skipped: `unreachable: ${e.message}` };
6723
7205
  }
@@ -7735,6 +8217,11 @@ function registerRoutes(ctx, relayDir) {
7735
8217
  const captchaAnswer = body.captcha_answer ?? body.captcha;
7736
8218
  if (captchaId !== void 0) payload.captcha_id = String(captchaId);
7737
8219
  if (captchaAnswer !== void 0) payload.captcha_answer = String(captchaAnswer);
8220
+ // 渠道归因(安装器 `--ref` → 配置 ref_code):面板注册时带上 `channel_code`,
8221
+ // 与 `invite_code`(C 端裂变)是两个字段、互不影响。请求体里自带 channel_code 时
8222
+ // 以请求体为准(手机端 `?ref=` 路径会自己带),这里绝不覆盖。
8223
+ const refCode = refCodeOf(loadConfig(relayDir));
8224
+ if (refCode && !payload.channel_code) payload.channel_code = refCode;
7738
8225
  const r = await relayFetch(relayDir, "/api/register", {
7739
8226
  method: "POST",
7740
8227
  headers: { "content-type": "application/json" },
@@ -8213,6 +8700,11 @@ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerP
8213
8700
  // (用例必须把 relayDir 指向 mkdtemp 临时目录、HOME 指向假目录,绝不能在真实
8214
8701
  // ~/.dsh-remote 或真实 ~/.dsh 上做写入测试)。
8215
8702
  setupStateOf as __setupStateOf, ensureConnection as __ensureConnection,
8703
+ // 设备超额锁定(契约 §8):面板文案 / 插件半设备绑定退避都要能被单测钉住
8704
+ composeConnect as __composeConnect, readBindError as __readBindError, bindFailGate as __bindFailGate,
8705
+ bindFailSignatureOf as __bindFailSignatureOf, isPermanentBindFailure as __isPermanentBindFailure,
8706
+ bindCapBackoffMs as __bindCapBackoffMs, connectBook as __connectBook, noteBindFailAttempt as __noteBindFailAttempt,
8707
+ DEVICE_BIND_CAP_EXIT_CODE as __DEVICE_BIND_CAP_EXIT_CODE, BIND_CAP_BACKOFF_MAX_MS as __BIND_CAP_BACKOFF_MAX_MS,
8216
8708
  backupConfigFile as __backupConfigFile, listConfigBackups as __listConfigBackups,
8217
8709
  restoreConfigBackup as __restoreConfigBackup, runtimeDirPresent as __runtimeDirPresent,
8218
8710
  CONFIG_BACKUP_KEEP as __CONFIG_BACKUP_KEEP, CONFIG_BACKUP_DIR as __CONFIG_BACKUP_DIR,