dsh-workbuddy-xdpool 1.5.2 → 1.6.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/CHANGELOG.md +86 -0
- package/lib/bin.js +239 -122
- package/lib/index.js +243 -126
- package/package.json +15 -13
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,92 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.6.0 (2026-09-24)
|
|
8
|
+
|
|
9
|
+
这一版修的都是「已装、已登录,插件却说找不到 App / 存不进去」这一类误报。
|
|
10
|
+
|
|
11
|
+
### 一、WorkBuddy 装在非系统盘时找不到 App(社区反馈 + 本机复现)
|
|
12
|
+
|
|
13
|
+
报错:`holds encrypted credentials but the desktop app could not provide the key`,
|
|
14
|
+
但 App 明明装着并在运行。
|
|
15
|
+
|
|
16
|
+
根因:Windows 只探测四个固定在**系统盘**的路径,而实际安装位置是自定义盘符 ——
|
|
17
|
+
本机实测两个 App 都在非默认位置,且**国际版的文件名都不一样**:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
D:\workbuddy\WorkBuddy.exe 国内版 5.5.3
|
|
21
|
+
D:\workbuddyai\WorkBuddyAI.exe 国际版 5.6.2 ← 文件名都不同
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
修法(三层,按可靠性排序):
|
|
25
|
+
|
|
26
|
+
1. **读注册表**(最权威)——安装器自己记录的 `DisplayIcon` / `InstallLocation`,
|
|
27
|
+
不管装在哪个盘、目录叫什么、可执行文件叫什么都能找到;
|
|
28
|
+
2. 环境变量 `WORKBUDDY_APP_EXECUTABLE`(用户显式指定,优先级最高);
|
|
29
|
+
3. 兜底枚举:`ProgramFiles` / `ProgramW6432` / `ProgramFiles(x86)` / `LOCALAPPDATA`,
|
|
30
|
+
**外加所有固定盘符**的 `Program Files`,再向下扫一层匹配 `workbuddy*` 目录。
|
|
31
|
+
|
|
32
|
+
同时补上国际版可执行文件名 `WorkBuddyAI.exe` —— 它此前从未进过候选,
|
|
33
|
+
所以哪怕路径对了也找不到。
|
|
34
|
+
|
|
35
|
+
**实测验证**:注册表方案在本机正确发现两个 App,且用国内版 App 派生的密钥
|
|
36
|
+
能解开国际版凭据(两个 App 共用同一个构建期密钥,`keyId` 完全匹配)。
|
|
37
|
+
|
|
38
|
+
### 二、报错文案不再误导
|
|
39
|
+
|
|
40
|
+
原文案暗示「去装 App」,而用户 App 就装着。现在区分两种情况,并给出真正可执行的指引:
|
|
41
|
+
设置 `WORKBUDDY_APP_EXECUTABLE` 指向实际路径,并明说**重新登录无效**
|
|
42
|
+
(凭据本身是好的,缺的是密钥)。
|
|
43
|
+
|
|
44
|
+
### 三、读账号很慢 / 重新检测账号也报错
|
|
45
|
+
|
|
46
|
+
每次读凭据都会 spawn App 并等待 10 秒超时;一旦失败,逐个凭据文件再来一遍。
|
|
47
|
+
新增**失败负缓存**(60 秒):一次失败后短期内不再重试,
|
|
48
|
+
既让「扫描账号」瞬间返回,也保证装上/启动 App 后无需重启 DSH 就能恢复。
|
|
49
|
+
|
|
50
|
+
### 四、「保留积分」保存报 `settings service unavailable; creditReserves was not saved`
|
|
51
|
+
|
|
52
|
+
**这是我 1.5.0 引入的 bug**:校验写入了不存在的 API。
|
|
53
|
+
本版 `dsh-settings` 暴露的是**按命名空间**的写入方法,没有 `set(key, value)`:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
update(ns, patch, expectedRevision) // 把 patch 合并进该命名空间的用户层
|
|
57
|
+
replace(ns, section, expectedRevision)
|
|
58
|
+
mutate(ns, ops, expectedRevision)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
现在改用 `update(ns, { [key]: value })`。**「await + 回读校验」的理念保持不变**
|
|
62
|
+
(它确实抓到了问题,只是抓错了原因)——写入后仍然回读文档比对,不一致就报错。
|
|
63
|
+
|
|
64
|
+
### 测试
|
|
65
|
+
|
|
66
|
+
188 → 195(新增 `app-location.test.ts` 7 例:注册表优先、override 优先、
|
|
67
|
+
多盘符与文件名变体、macOS 仍走 bundle 而不碰注册表)。
|
|
68
|
+
|
|
69
|
+
## 1.5.3 (2026-09-24)
|
|
70
|
+
|
|
71
|
+
### 内核依赖对齐 DSH 实际版本,消除「需要 0.1.7」的歧义
|
|
72
|
+
|
|
73
|
+
`devDependencies` 里十个 `@deepseek-ai/dsh-*` 包原先钉在最老的版本
|
|
74
|
+
(`0.1.1-rc.2` / `0.1.2-rc.1`),而插件实际运行在 DSH Desktop 2.0.13 捆绑的
|
|
75
|
+
**`0.1.5-rc.2`** 上——开发环境与运行环境不一致,正是姊妹项目踩过的那类坑
|
|
76
|
+
(用旧类型构建、或反过来用新类型构建跑在旧内核上)。
|
|
77
|
+
|
|
78
|
+
现在钉到 **`0.1.5-rc.2`**,与实际运行内核一致。`dsh-client-runtime` 例外:该包
|
|
79
|
+
已停止发布(最后版本 `0.1.1-rc.2`),保持不动。
|
|
80
|
+
|
|
81
|
+
**peerDependencies 未改**,仍是最宽的 `>=0.1.1-rc.1 <0.2.0`。实测(semver)该范围
|
|
82
|
+
同时接受 `0.1.5-rc.2` 与 `0.1.7-rc.1`,比 `^0.1.5-rc.1`、`^0.1.5-0` 都广,所以
|
|
83
|
+
插件本身从来没有版本不兼容——市场页面上那句「需要 DSH >=0.1.7-rc.1」是市场对
|
|
84
|
+
预发布范围的判定方式所致,不是插件声明有误。
|
|
85
|
+
|
|
86
|
+
内核 `0.1.7-rc.1` 目前只在 npm 的 `next` 通道(`latest` 仍是 `0.0.1-rc.1`),
|
|
87
|
+
属于预发布;内核版本由 DSH Desktop 应用捆绑,需等官方发新版桌面端才能跟进。
|
|
88
|
+
|
|
89
|
+
### 测试
|
|
90
|
+
|
|
91
|
+
仍为 188 通过(本次只改依赖声明,无代码改动)。
|
|
92
|
+
|
|
7
93
|
## 1.5.2 (2026-09-24)
|
|
8
94
|
|
|
9
95
|
### 修掉测试对运行时区的依赖(发布流程在 CI 上红了)
|
package/lib/bin.js
CHANGED
|
@@ -2,8 +2,8 @@ import { createDecipheriv, createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
4
|
import { basename, join, resolve } from "node:path";
|
|
5
|
-
import { execFile } from "node:child_process";
|
|
6
|
-
import { existsSync, readFileSync, readdirSync
|
|
5
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
7
|
import z from "@deepseek-ai/schemastery";
|
|
8
8
|
import "@earendil-works/pi-ai";
|
|
9
9
|
import "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
@@ -1642,8 +1642,15 @@ const SYMMETRIC_SCHEME = "sym-v1";
|
|
|
1642
1642
|
const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
|
|
1643
1643
|
/** How long the app is given to answer with its key payload. */
|
|
1644
1644
|
const KEY_FETCH_TIMEOUT_MS = 1e4;
|
|
1645
|
-
/**
|
|
1646
|
-
|
|
1645
|
+
/**
|
|
1646
|
+
* Executable file names the desktop app ships under, in probe order.
|
|
1647
|
+
*
|
|
1648
|
+
* `WorkBuddyAI.exe` is the INTERNATIONAL build; both apps can be installed side
|
|
1649
|
+
* by side (observed on a real machine: `D:\\workbuddy\\WorkBuddy.exe` for the
|
|
1650
|
+
* domestic one and `D:\\workbuddyai\\WorkBuddyAI.exe` for the international one),
|
|
1651
|
+
* so the name cannot be assumed.
|
|
1652
|
+
*/
|
|
1653
|
+
const APP_EXECUTABLE_NAMES = ["WorkBuddy.exe", "WorkBuddyAI.exe"];
|
|
1647
1654
|
/**
|
|
1648
1655
|
* macOS bundles the desktop app may be installed as, in probe order.
|
|
1649
1656
|
*
|
|
@@ -1752,6 +1759,23 @@ function openEncryptedField(field, key) {
|
|
|
1752
1759
|
return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]).toString("utf8");
|
|
1753
1760
|
}
|
|
1754
1761
|
/**
|
|
1762
|
+
* The key id an encrypted field envelope demands, or undefined when the
|
|
1763
|
+
* envelope cannot be read.
|
|
1764
|
+
*
|
|
1765
|
+
* The account pool uses it to pick the right desktop build's key when more
|
|
1766
|
+
* than one build (domestic and international) is installed on the same machine:
|
|
1767
|
+
* each `.info` file names the key id its fields were sealed under, so the opener
|
|
1768
|
+
* must select the matching derived key rather than assume one build exists.
|
|
1769
|
+
*/
|
|
1770
|
+
function encryptedFieldKeyId(field) {
|
|
1771
|
+
try {
|
|
1772
|
+
const record = JSON.parse(Buffer.from(field.envelope, "base64").toString("utf8"));
|
|
1773
|
+
return typeof record["keyId"] === "string" ? record["keyId"] : void 0;
|
|
1774
|
+
} catch {
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1755
1779
|
* The executable inside a macOS app bundle, read from the bundle's own
|
|
1756
1780
|
* `Info.plist`.
|
|
1757
1781
|
*
|
|
@@ -1777,116 +1801,139 @@ function macosBundleExecutable(bundle) {
|
|
|
1777
1801
|
return join(bundle, "Contents", "MacOS", name);
|
|
1778
1802
|
}
|
|
1779
1803
|
/**
|
|
1780
|
-
*
|
|
1804
|
+
* Windows install locations recorded by the app's own uninstaller.
|
|
1781
1805
|
*
|
|
1782
|
-
* The
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
* and `
|
|
1786
|
-
*
|
|
1787
|
-
*
|
|
1788
|
-
*
|
|
1789
|
-
*
|
|
1790
|
-
* `readBundleExecutable` is injectable, in the same spirit as `platform`/`home`/
|
|
1791
|
-
* `env`: the macOS branch consults the real filesystem, so without a seam the
|
|
1792
|
-
* expected candidates would depend on whether the host machine happens to have
|
|
1793
|
-
* the app installed — and the test would pass on a developer's Mac while
|
|
1794
|
-
* failing in CI.
|
|
1795
|
-
*/
|
|
1796
|
-
function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env, readBundleExecutable = macosBundleExecutable) {
|
|
1797
|
-
const candidates = [env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim()];
|
|
1798
|
-
if (platform === "win32") {
|
|
1799
|
-
const local = env["LOCALAPPDATA"]?.trim();
|
|
1800
|
-
const programFiles = env["ProgramFiles"]?.trim();
|
|
1801
|
-
const programFilesX86 = env["ProgramFiles(x86)"]?.trim();
|
|
1802
|
-
candidates.push(local === void 0 || local === "" ? void 0 : join(local, "Programs", "WorkBuddy", APP_EXECUTABLE_NAME), local === void 0 || local === "" ? void 0 : join(local, "WorkBuddy", APP_EXECUTABLE_NAME), programFiles === void 0 || programFiles === "" ? void 0 : join(programFiles, "WorkBuddy", APP_EXECUTABLE_NAME), programFilesX86 === void 0 || programFilesX86 === "" ? void 0 : join(programFilesX86, "WorkBuddy", APP_EXECUTABLE_NAME));
|
|
1803
|
-
} else if (platform === "darwin") for (const name of MACOS_APP_BUNDLE_NAMES) candidates.push(readBundleExecutable(join("/Applications", name)), readBundleExecutable(join(home, "Applications", name)));
|
|
1804
|
-
return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
|
|
1805
|
-
}
|
|
1806
|
-
/**
|
|
1807
|
-
* Bundle identifier PREFIXES the desktop app is signed with — `com.tencent.
|
|
1808
|
-
* workbuddy` (domestic, observed as `…workbuddy.mac`) and `com.workbuddy`
|
|
1809
|
-
* (international, observed as `com.workbuddy.workbuddy-ai`).
|
|
1810
|
-
*
|
|
1811
|
-
* Used to CONFIRM that a discovered bundle really is WorkBuddy before it is
|
|
1812
|
-
* launched. This matters because the discovery below scans directories and then
|
|
1813
|
-
* execs what it finds: every Electron app is built around a binary called
|
|
1814
|
-
* `Electron`, so a name-only match could pick a different product's bundle and
|
|
1815
|
-
* run it. The identifier is the app's own claim about itself, so it is the
|
|
1816
|
-
* check that makes the scan safe.
|
|
1817
|
-
*/
|
|
1818
|
-
const APP_BUNDLE_IDENTIFIER_PREFIXES = ["com.tencent.workbuddy", "com.workbuddy"];
|
|
1819
|
-
/**
|
|
1820
|
-
* Whether a bundle identifies itself as the WorkBuddy desktop app.
|
|
1806
|
+
* The registry is the authoritative answer: it survives a non-default drive, a
|
|
1807
|
+
* renamed folder and a differently-named executable, none of which any fixed
|
|
1808
|
+
* path list can predict. Real machines put the app at `D:\workbuddy\WorkBuddy.exe`
|
|
1809
|
+
* and `D:\workbuddyai\WorkBuddyAI.exe` — exactly the layouts a
|
|
1810
|
+
* `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see, which is why the
|
|
1811
|
+
* plugin reported "the desktop app could not provide the key" for an app that was
|
|
1812
|
+
* installed and running.
|
|
1821
1813
|
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1814
|
+
* `DisplayIcon` is the field that actually carries the path (observed as
|
|
1815
|
+
* `D:\workbuddy\WorkBuddy.exe,0`); `InstallLocation` is usually empty for these
|
|
1816
|
+
* installers, so both are read and either may contribute.
|
|
1824
1817
|
*
|
|
1825
|
-
*
|
|
1826
|
-
*
|
|
1827
|
-
* wrong one would execute an unrelated application.
|
|
1818
|
+
* Returns [] on any failure — a missing registry key is the normal case on
|
|
1819
|
+
* non-Windows, not an error.
|
|
1828
1820
|
*/
|
|
1829
|
-
function
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1821
|
+
function windowsRegistryAppPaths() {
|
|
1822
|
+
if (process.platform !== "win32") return [];
|
|
1823
|
+
const roots = [
|
|
1824
|
+
["HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
|
|
1825
|
+
["HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
|
|
1826
|
+
["HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"]
|
|
1827
|
+
];
|
|
1828
|
+
const out = [];
|
|
1829
|
+
for (const [root] of roots) {
|
|
1830
|
+
let listing;
|
|
1831
|
+
try {
|
|
1832
|
+
listing = execFileSync("reg", [
|
|
1833
|
+
"query",
|
|
1834
|
+
root,
|
|
1835
|
+
"/s",
|
|
1836
|
+
"/v",
|
|
1837
|
+
"DisplayName"
|
|
1838
|
+
], {
|
|
1839
|
+
encoding: "utf8",
|
|
1840
|
+
timeout: 1e4,
|
|
1841
|
+
windowsHide: true,
|
|
1842
|
+
maxBuffer: 8388608
|
|
1843
|
+
});
|
|
1844
|
+
} catch {
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
const keys = listing.split(/\r?\n(?=HKEY_)/u).filter((block) => /WorkBuddy|CodeBuddy/iu.test(block));
|
|
1848
|
+
for (const key of keys) {
|
|
1849
|
+
const keyPath = /^(HKEY_[^\r\n]+)/u.exec(key)?.[1]?.trim();
|
|
1850
|
+
if (keyPath === void 0) continue;
|
|
1851
|
+
for (const name of ["DisplayIcon", "InstallLocation"]) try {
|
|
1852
|
+
const value = execFileSync("reg", [
|
|
1853
|
+
"query",
|
|
1854
|
+
keyPath,
|
|
1855
|
+
"/v",
|
|
1856
|
+
name
|
|
1857
|
+
], {
|
|
1858
|
+
encoding: "utf8",
|
|
1859
|
+
timeout: 5e3,
|
|
1860
|
+
windowsHide: true
|
|
1861
|
+
});
|
|
1862
|
+
const raw = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(value)?.[1]?.trim();
|
|
1863
|
+
if (raw === void 0 || raw === "") continue;
|
|
1864
|
+
const cleaned = raw.replace(/^"/u, "").replace(/",-?\d+$/u, "").replace(/,-?\d+$/u, "").trim();
|
|
1865
|
+
out.push(cleaned);
|
|
1866
|
+
} catch {}
|
|
1867
|
+
}
|
|
1835
1868
|
}
|
|
1836
|
-
|
|
1837
|
-
if (identifier === void 0 || identifier === "") return false;
|
|
1838
|
-
return APP_BUNDLE_IDENTIFIER_PREFIXES.some((prefix) => identifier === prefix || identifier.startsWith(`${prefix}.`));
|
|
1869
|
+
return out;
|
|
1839
1870
|
}
|
|
1840
1871
|
/**
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1872
|
+
* Windows fallbacks for an app the registry did not cover: the well-known
|
|
1873
|
+
* per-user and machine-wide locations, plus every fixed drive's `Program Files`.
|
|
1843
1874
|
*
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
1846
|
-
* is installed and signed in. The scan is deliberately ONE level deep and
|
|
1847
|
-
* matches the known bundle names only, so it stays predictable and cheap; each
|
|
1848
|
-
* candidate is then confirmed by {@link isWorkbuddyBundle} before use.
|
|
1849
|
-
*
|
|
1850
|
-
* Returns [] when the parent is absent or unreadable — a missing directory is
|
|
1851
|
-
* the normal case, not an error.
|
|
1875
|
+
* Drive enumeration matters because installing to a non-system drive is common
|
|
1876
|
+
* on Windows and no environment variable points there.
|
|
1852
1877
|
*/
|
|
1853
|
-
function
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
const
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1878
|
+
function windowsFallbackAppPaths(env) {
|
|
1879
|
+
const out = [];
|
|
1880
|
+
const roots = /* @__PURE__ */ new Set();
|
|
1881
|
+
for (const key of [
|
|
1882
|
+
"ProgramFiles",
|
|
1883
|
+
"ProgramW6432",
|
|
1884
|
+
"ProgramFiles(x86)",
|
|
1885
|
+
"LOCALAPPDATA"
|
|
1886
|
+
]) {
|
|
1887
|
+
const value = env[key]?.trim();
|
|
1888
|
+
if (value !== void 0 && value !== "") roots.add(value);
|
|
1889
|
+
}
|
|
1890
|
+
for (let code = 67; code <= 90; code += 1) {
|
|
1891
|
+
const drive = String.fromCharCode(code) + ":\\";
|
|
1892
|
+
try {
|
|
1893
|
+
if (!existsSync(drive)) continue;
|
|
1894
|
+
} catch {
|
|
1895
|
+
continue;
|
|
1871
1896
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1897
|
+
roots.add(join(drive, "Program Files"));
|
|
1898
|
+
roots.add(join(drive, "Program Files (x86)"));
|
|
1899
|
+
}
|
|
1900
|
+
for (const root of roots) for (const name of APP_EXECUTABLE_NAMES) {
|
|
1901
|
+
out.push(join(root, "WorkBuddy", name));
|
|
1902
|
+
out.push(join(root, "WorkBuddy AI", name));
|
|
1903
|
+
out.push(join(root, "Programs", "WorkBuddy", name));
|
|
1904
|
+
}
|
|
1905
|
+
for (const root of roots) try {
|
|
1906
|
+
for (const entry of readdirSync(root)) {
|
|
1907
|
+
if (!/^(workbuddy|codebuddy)/iu.test(entry)) continue;
|
|
1908
|
+
for (const name of APP_EXECUTABLE_NAMES) out.push(join(root, entry, name));
|
|
1909
|
+
}
|
|
1910
|
+
} catch {}
|
|
1911
|
+
return out;
|
|
1874
1912
|
}
|
|
1875
1913
|
/**
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
1914
|
+
* Candidate paths of the WorkBuddy desktop executable, in probe order.
|
|
1915
|
+
*
|
|
1916
|
+
* Order is deliberate:
|
|
1917
|
+
* 1. the explicit override, because a user who set it knows where the app is;
|
|
1918
|
+
* 2. the registry, which is what the installer itself recorded;
|
|
1919
|
+
* 3. derived fallbacks (per-user, machine-wide, every fixed drive).
|
|
1920
|
+
*
|
|
1921
|
+
* Only the Windows branch consults the registry (it is the only platform with
|
|
1922
|
+
* one). macOS asks each bundle for its own `CFBundleExecutable` instead, because
|
|
1923
|
+
* the WorkBuddy bundles ship a binary named `Electron`, not after the app.
|
|
1924
|
+
*
|
|
1925
|
+
* `readBundleExecutable` and `registryPaths` are injectable in the same spirit as
|
|
1926
|
+
* `platform`/`home`/`env`: both consult the real machine, so without a seam the
|
|
1927
|
+
* expected candidates would depend on what happens to be installed where the
|
|
1928
|
+
* suite runs — passing on a developer's box and failing in CI.
|
|
1878
1929
|
*/
|
|
1879
|
-
function
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
try {
|
|
1887
|
-
if (existsSync(executable)) return executable;
|
|
1888
|
-
} catch {}
|
|
1889
|
-
}
|
|
1930
|
+
function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env, readBundleExecutable = macosBundleExecutable, registryPaths = windowsRegistryAppPaths) {
|
|
1931
|
+
const candidates = [env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim()];
|
|
1932
|
+
if (platform === "win32") {
|
|
1933
|
+
candidates.push(...registryPaths());
|
|
1934
|
+
candidates.push(...windowsFallbackAppPaths(env));
|
|
1935
|
+
} else if (platform === "darwin") for (const name of MACOS_APP_BUNDLE_NAMES) candidates.push(readBundleExecutable(join("/Applications", name)), readBundleExecutable(join(home, "Applications", name)));
|
|
1936
|
+
return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
|
|
1890
1937
|
}
|
|
1891
1938
|
/**
|
|
1892
1939
|
* Ask the installed desktop app for its key payload by running its own binary
|
|
@@ -1921,28 +1968,95 @@ function fetchAtRestKeyPayload(executable) {
|
|
|
1921
1968
|
});
|
|
1922
1969
|
});
|
|
1923
1970
|
}
|
|
1924
|
-
/** Process-lifetime cache of the derived key; never persisted. */
|
|
1925
|
-
let cachedKey;
|
|
1926
|
-
let inflightKey;
|
|
1927
1971
|
/**
|
|
1928
|
-
* The desktop app's at-rest
|
|
1929
|
-
* (
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1972
|
+
* The desktop app's at-rest keys, indexed by the key id each derived key
|
|
1973
|
+
* reports (the first 16 hex of its SHA-256).
|
|
1974
|
+
*
|
|
1975
|
+
* More than one build can be installed on one machine — the domestic
|
|
1976
|
+
* `WorkBuddy.exe` and the international `WorkBuddyAI.exe` share a key id on the
|
|
1977
|
+
* builds seen here, but a future build may rotate it, and the discovery below
|
|
1978
|
+
* must keep working if they ever diverge. A field envelope names the key id it
|
|
1979
|
+
* was sealed under, so the opener selects the matching derived key instead of
|
|
1980
|
+
* assuming a single build exists. Cached per process and never persisted.
|
|
1933
1981
|
*/
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1982
|
+
const atRestKeyById = /* @__PURE__ */ new Map();
|
|
1983
|
+
let inflightKeys;
|
|
1984
|
+
/**
|
|
1985
|
+
* When the last full key sweep failed, and how long that failure is trusted.
|
|
1986
|
+
*
|
|
1987
|
+
* Without this, EVERY credential read spawned the app and waited out the
|
|
1988
|
+
* 10-second timeout before giving up — which is what made "rescan accounts" and
|
|
1989
|
+
* every status poll crawl on a machine where the app could not be found. A
|
|
1990
|
+
* failure is negative-cached briefly: long enough that a burst of reads costs
|
|
1991
|
+
* one sweep, short enough that installing or starting the app is picked up
|
|
1992
|
+
* without restarting DSH.
|
|
1993
|
+
*/
|
|
1994
|
+
let lastKeyFailureAtMs = 0;
|
|
1995
|
+
const KEY_FAILURE_BACKOFF_MS = 6e4;
|
|
1996
|
+
/**
|
|
1997
|
+
* Load every desktop build's key id into {@link atRestKeyById}.
|
|
1998
|
+
*
|
|
1999
|
+
* Mirrors the reference `provideTheKey` shape: probe EVERY candidate executable
|
|
2000
|
+
* (not just the first that exists) and keep the key each one yields. A build
|
|
2001
|
+
* that fails to answer — a timeout, a single-instance lock, an older build
|
|
2002
|
+
* without the native module — is skipped on its own and does NOT poison the
|
|
2003
|
+
* other builds, which is exactly the failure mode the single-candidate path
|
|
2004
|
+
* had: one bad spawn cached `undefined` for the whole process and every
|
|
2005
|
+
* encrypted field then reported "no app could be located".
|
|
2006
|
+
*/
|
|
2007
|
+
function ensureAtRestKeys() {
|
|
2008
|
+
if (atRestKeyById.size > 0) return Promise.resolve();
|
|
2009
|
+
if (Date.now() - lastKeyFailureAtMs < KEY_FAILURE_BACKOFF_MS) return Promise.resolve();
|
|
2010
|
+
inflightKeys ??= (async () => {
|
|
2011
|
+
const candidates = workbuddyAppExecutableCandidates().filter((candidate) => {
|
|
2012
|
+
try {
|
|
2013
|
+
return existsSync(candidate);
|
|
2014
|
+
} catch {
|
|
2015
|
+
return false;
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
if (candidates.length === 0) {
|
|
2019
|
+
lastKeyFailureAtMs = Date.now();
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
let anySuccess = false;
|
|
2023
|
+
await Promise.all(candidates.map(async (executable) => {
|
|
2024
|
+
try {
|
|
2025
|
+
const key = deriveAtRestKey(await fetchAtRestKeyPayload(executable));
|
|
2026
|
+
atRestKeyById.set(deriveAtRestKeyId(key), key);
|
|
2027
|
+
anySuccess = true;
|
|
2028
|
+
} catch {}
|
|
2029
|
+
}));
|
|
2030
|
+
if (anySuccess) lastKeyFailureAtMs = 0;
|
|
2031
|
+
else lastKeyFailureAtMs = Date.now();
|
|
1942
2032
|
})().finally(() => {
|
|
1943
|
-
|
|
2033
|
+
inflightKeys = void 0;
|
|
2034
|
+
});
|
|
2035
|
+
return inflightKeys;
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Synchronous key lookup for a key id already loaded by {@link ensureAtRestKeys}.
|
|
2039
|
+
*
|
|
2040
|
+
* The account pool warms the cache up front (via {@link readAtRestKey}) and then
|
|
2041
|
+
* opens each encrypted field through a synchronous closure, because the parser
|
|
2042
|
+
* runs `decrypt` inline. Lookups that race the warm-up, or ask for a key id no
|
|
2043
|
+
* installed build produced, return undefined and are reported as the
|
|
2044
|
+
* encrypted-but-unavailable error rather than a silently empty token.
|
|
2045
|
+
*/
|
|
2046
|
+
function atRestKeyFor(keyId) {
|
|
2047
|
+
return atRestKeyById.get(keyId);
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Backwards-compatible single-key view: the first key any build provided.
|
|
2051
|
+
*
|
|
2052
|
+
* Kept so callers that do not yet carry a key id (and the legacy tests) still
|
|
2053
|
+
* resolve to a usable key on single-build machines. Multi-build callers should
|
|
2054
|
+
* prefer {@link readAtRestKeyById} and select by the field's own key id.
|
|
2055
|
+
*/
|
|
2056
|
+
function readAtRestKey() {
|
|
2057
|
+
return ensureAtRestKeys().then(() => {
|
|
2058
|
+
for (const key of atRestKeyById.values()) return key;
|
|
1944
2059
|
});
|
|
1945
|
-
return inflightKey;
|
|
1946
2060
|
}
|
|
1947
2061
|
//#endregion
|
|
1948
2062
|
//#region src/accounts.ts
|
|
@@ -2017,7 +2131,7 @@ const ENCRYPTED_CREDENTIAL_CODE = "ENCRYPTED_CREDENTIAL";
|
|
|
2017
2131
|
var WorkBuddyEncryptedCredentialError = class extends Error {
|
|
2018
2132
|
code = ENCRYPTED_CREDENTIAL_CODE;
|
|
2019
2133
|
constructor(sourcePath) {
|
|
2020
|
-
super(`workbuddy: ${sourcePath} holds encrypted credentials but
|
|
2134
|
+
super(`workbuddy: ${sourcePath} holds encrypted credentials, but no WorkBuddy desktop app could be located to provide the key. If the app IS installed, it is simply outside the paths this plugin probes — set WORKBUDDY_APP_EXECUTABLE to its full .exe path (then restart DSH) and the credential will open. Signing in again will not help: the credential itself is intact. Run \`dsh-workbuddy-xdpool doctor\` to see which paths were probed.`);
|
|
2021
2135
|
this.name = "WorkBuddyEncryptedCredentialError";
|
|
2022
2136
|
}
|
|
2023
2137
|
};
|
|
@@ -2201,10 +2315,13 @@ async function readCredential(path) {
|
|
|
2201
2315
|
* error that would look like a corrupt file.
|
|
2202
2316
|
*/
|
|
2203
2317
|
async function encryptedFieldOpener() {
|
|
2204
|
-
|
|
2205
|
-
if (key === void 0) return void 0;
|
|
2318
|
+
await readAtRestKey().catch(() => void 0);
|
|
2206
2319
|
return (field) => {
|
|
2207
2320
|
if (!isEncryptedFieldWrapper(field)) throw new Error("workbuddy: not an encrypted field wrapper");
|
|
2321
|
+
const keyId = encryptedFieldKeyId(field);
|
|
2322
|
+
if (keyId === void 0) throw new Error("workbuddy: encrypted field has no key id");
|
|
2323
|
+
const key = atRestKeyFor(keyId);
|
|
2324
|
+
if (key === void 0) throw new Error("workbuddy: no at-rest key available for this encrypted field");
|
|
2208
2325
|
return openEncryptedField(field, key);
|
|
2209
2326
|
};
|
|
2210
2327
|
}
|
package/lib/index.js
CHANGED
|
@@ -3,8 +3,8 @@ import z from "@deepseek-ai/schemastery";
|
|
|
3
3
|
import { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { readFile, readdir } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
7
|
-
import { existsSync, readFileSync, readdirSync
|
|
6
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
8
8
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
9
9
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
10
10
|
import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
@@ -1749,8 +1749,15 @@ const SYMMETRIC_SCHEME = "sym-v1";
|
|
|
1749
1749
|
const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
|
|
1750
1750
|
/** How long the app is given to answer with its key payload. */
|
|
1751
1751
|
const KEY_FETCH_TIMEOUT_MS = 1e4;
|
|
1752
|
-
/**
|
|
1753
|
-
|
|
1752
|
+
/**
|
|
1753
|
+
* Executable file names the desktop app ships under, in probe order.
|
|
1754
|
+
*
|
|
1755
|
+
* `WorkBuddyAI.exe` is the INTERNATIONAL build; both apps can be installed side
|
|
1756
|
+
* by side (observed on a real machine: `D:\\workbuddy\\WorkBuddy.exe` for the
|
|
1757
|
+
* domestic one and `D:\\workbuddyai\\WorkBuddyAI.exe` for the international one),
|
|
1758
|
+
* so the name cannot be assumed.
|
|
1759
|
+
*/
|
|
1760
|
+
const APP_EXECUTABLE_NAMES = ["WorkBuddy.exe", "WorkBuddyAI.exe"];
|
|
1754
1761
|
/**
|
|
1755
1762
|
* macOS bundles the desktop app may be installed as, in probe order.
|
|
1756
1763
|
*
|
|
@@ -1859,6 +1866,23 @@ function openEncryptedField(field, key) {
|
|
|
1859
1866
|
return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]).toString("utf8");
|
|
1860
1867
|
}
|
|
1861
1868
|
/**
|
|
1869
|
+
* The key id an encrypted field envelope demands, or undefined when the
|
|
1870
|
+
* envelope cannot be read.
|
|
1871
|
+
*
|
|
1872
|
+
* The account pool uses it to pick the right desktop build's key when more
|
|
1873
|
+
* than one build (domestic and international) is installed on the same machine:
|
|
1874
|
+
* each `.info` file names the key id its fields were sealed under, so the opener
|
|
1875
|
+
* must select the matching derived key rather than assume one build exists.
|
|
1876
|
+
*/
|
|
1877
|
+
function encryptedFieldKeyId(field) {
|
|
1878
|
+
try {
|
|
1879
|
+
const record = JSON.parse(Buffer.from(field.envelope, "base64").toString("utf8"));
|
|
1880
|
+
return typeof record["keyId"] === "string" ? record["keyId"] : void 0;
|
|
1881
|
+
} catch {
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1862
1886
|
* The executable inside a macOS app bundle, read from the bundle's own
|
|
1863
1887
|
* `Info.plist`.
|
|
1864
1888
|
*
|
|
@@ -1884,116 +1908,139 @@ function macosBundleExecutable(bundle) {
|
|
|
1884
1908
|
return join(bundle, "Contents", "MacOS", name);
|
|
1885
1909
|
}
|
|
1886
1910
|
/**
|
|
1887
|
-
*
|
|
1888
|
-
*
|
|
1889
|
-
* The Windows build is the one that encrypts credentials, so Windows leads;
|
|
1890
|
-
* the macOS bundles are listed because the same native module ships there and
|
|
1891
|
-
* the encryption policy is enabled on macOS builds too (observed from 5.6.x),
|
|
1892
|
-
* and `undefined` entries (an unset env variable) are dropped.
|
|
1893
|
-
*
|
|
1894
|
-
* On macOS the executable name comes from each bundle (see
|
|
1895
|
-
* {@link macosBundleExecutable}) rather than being assembled from the app name.
|
|
1896
|
-
*
|
|
1897
|
-
* `readBundleExecutable` is injectable, in the same spirit as `platform`/`home`/
|
|
1898
|
-
* `env`: the macOS branch consults the real filesystem, so without a seam the
|
|
1899
|
-
* expected candidates would depend on whether the host machine happens to have
|
|
1900
|
-
* the app installed — and the test would pass on a developer's Mac while
|
|
1901
|
-
* failing in CI.
|
|
1902
|
-
*/
|
|
1903
|
-
function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env, readBundleExecutable = macosBundleExecutable) {
|
|
1904
|
-
const candidates = [env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim()];
|
|
1905
|
-
if (platform === "win32") {
|
|
1906
|
-
const local = env["LOCALAPPDATA"]?.trim();
|
|
1907
|
-
const programFiles = env["ProgramFiles"]?.trim();
|
|
1908
|
-
const programFilesX86 = env["ProgramFiles(x86)"]?.trim();
|
|
1909
|
-
candidates.push(local === void 0 || local === "" ? void 0 : join(local, "Programs", "WorkBuddy", APP_EXECUTABLE_NAME), local === void 0 || local === "" ? void 0 : join(local, "WorkBuddy", APP_EXECUTABLE_NAME), programFiles === void 0 || programFiles === "" ? void 0 : join(programFiles, "WorkBuddy", APP_EXECUTABLE_NAME), programFilesX86 === void 0 || programFilesX86 === "" ? void 0 : join(programFilesX86, "WorkBuddy", APP_EXECUTABLE_NAME));
|
|
1910
|
-
} else if (platform === "darwin") for (const name of MACOS_APP_BUNDLE_NAMES) candidates.push(readBundleExecutable(join("/Applications", name)), readBundleExecutable(join(home, "Applications", name)));
|
|
1911
|
-
return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
|
|
1912
|
-
}
|
|
1913
|
-
/**
|
|
1914
|
-
* Bundle identifier PREFIXES the desktop app is signed with — `com.tencent.
|
|
1915
|
-
* workbuddy` (domestic, observed as `…workbuddy.mac`) and `com.workbuddy`
|
|
1916
|
-
* (international, observed as `com.workbuddy.workbuddy-ai`).
|
|
1911
|
+
* Windows install locations recorded by the app's own uninstaller.
|
|
1917
1912
|
*
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
1921
|
-
* `
|
|
1922
|
-
*
|
|
1923
|
-
*
|
|
1924
|
-
|
|
1925
|
-
const APP_BUNDLE_IDENTIFIER_PREFIXES = ["com.tencent.workbuddy", "com.workbuddy"];
|
|
1926
|
-
/**
|
|
1927
|
-
* Whether a bundle identifies itself as the WorkBuddy desktop app.
|
|
1913
|
+
* The registry is the authoritative answer: it survives a non-default drive, a
|
|
1914
|
+
* renamed folder and a differently-named executable, none of which any fixed
|
|
1915
|
+
* path list can predict. Real machines put the app at `D:\workbuddy\WorkBuddy.exe`
|
|
1916
|
+
* and `D:\workbuddyai\WorkBuddyAI.exe` — exactly the layouts a
|
|
1917
|
+
* `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see, which is why the
|
|
1918
|
+
* plugin reported "the desktop app could not provide the key" for an app that was
|
|
1919
|
+
* installed and running.
|
|
1928
1920
|
*
|
|
1929
|
-
*
|
|
1930
|
-
*
|
|
1921
|
+
* `DisplayIcon` is the field that actually carries the path (observed as
|
|
1922
|
+
* `D:\workbuddy\WorkBuddy.exe,0`); `InstallLocation` is usually empty for these
|
|
1923
|
+
* installers, so both are read and either may contribute.
|
|
1931
1924
|
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
* wrong one would execute an unrelated application.
|
|
1925
|
+
* Returns [] on any failure — a missing registry key is the normal case on
|
|
1926
|
+
* non-Windows, not an error.
|
|
1935
1927
|
*/
|
|
1936
|
-
function
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1928
|
+
function windowsRegistryAppPaths() {
|
|
1929
|
+
if (process.platform !== "win32") return [];
|
|
1930
|
+
const roots = [
|
|
1931
|
+
["HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
|
|
1932
|
+
["HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
|
|
1933
|
+
["HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"]
|
|
1934
|
+
];
|
|
1935
|
+
const out = [];
|
|
1936
|
+
for (const [root] of roots) {
|
|
1937
|
+
let listing;
|
|
1938
|
+
try {
|
|
1939
|
+
listing = execFileSync("reg", [
|
|
1940
|
+
"query",
|
|
1941
|
+
root,
|
|
1942
|
+
"/s",
|
|
1943
|
+
"/v",
|
|
1944
|
+
"DisplayName"
|
|
1945
|
+
], {
|
|
1946
|
+
encoding: "utf8",
|
|
1947
|
+
timeout: 1e4,
|
|
1948
|
+
windowsHide: true,
|
|
1949
|
+
maxBuffer: 8388608
|
|
1950
|
+
});
|
|
1951
|
+
} catch {
|
|
1952
|
+
continue;
|
|
1953
|
+
}
|
|
1954
|
+
const keys = listing.split(/\r?\n(?=HKEY_)/u).filter((block) => /WorkBuddy|CodeBuddy/iu.test(block));
|
|
1955
|
+
for (const key of keys) {
|
|
1956
|
+
const keyPath = /^(HKEY_[^\r\n]+)/u.exec(key)?.[1]?.trim();
|
|
1957
|
+
if (keyPath === void 0) continue;
|
|
1958
|
+
for (const name of ["DisplayIcon", "InstallLocation"]) try {
|
|
1959
|
+
const value = execFileSync("reg", [
|
|
1960
|
+
"query",
|
|
1961
|
+
keyPath,
|
|
1962
|
+
"/v",
|
|
1963
|
+
name
|
|
1964
|
+
], {
|
|
1965
|
+
encoding: "utf8",
|
|
1966
|
+
timeout: 5e3,
|
|
1967
|
+
windowsHide: true
|
|
1968
|
+
});
|
|
1969
|
+
const raw = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(value)?.[1]?.trim();
|
|
1970
|
+
if (raw === void 0 || raw === "") continue;
|
|
1971
|
+
const cleaned = raw.replace(/^"/u, "").replace(/",-?\d+$/u, "").replace(/,-?\d+$/u, "").trim();
|
|
1972
|
+
out.push(cleaned);
|
|
1973
|
+
} catch {}
|
|
1974
|
+
}
|
|
1942
1975
|
}
|
|
1943
|
-
|
|
1944
|
-
if (identifier === void 0 || identifier === "") return false;
|
|
1945
|
-
return APP_BUNDLE_IDENTIFIER_PREFIXES.some((prefix) => identifier === prefix || identifier.startsWith(`${prefix}.`));
|
|
1976
|
+
return out;
|
|
1946
1977
|
}
|
|
1947
1978
|
/**
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
* Users do file apps into subfolders (`/Applications/IDE/WorkBuddy.app`), and
|
|
1952
|
-
* a hardcoded `/Applications/<name>` then reports the app as missing while it
|
|
1953
|
-
* is installed and signed in. The scan is deliberately ONE level deep and
|
|
1954
|
-
* matches the known bundle names only, so it stays predictable and cheap; each
|
|
1955
|
-
* candidate is then confirmed by {@link isWorkbuddyBundle} before use.
|
|
1979
|
+
* Windows fallbacks for an app the registry did not cover: the well-known
|
|
1980
|
+
* per-user and machine-wide locations, plus every fixed drive's `Program Files`.
|
|
1956
1981
|
*
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1982
|
+
* Drive enumeration matters because installing to a non-system drive is common
|
|
1983
|
+
* on Windows and no environment variable points there.
|
|
1959
1984
|
*/
|
|
1960
|
-
function
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
const
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1985
|
+
function windowsFallbackAppPaths(env) {
|
|
1986
|
+
const out = [];
|
|
1987
|
+
const roots = /* @__PURE__ */ new Set();
|
|
1988
|
+
for (const key of [
|
|
1989
|
+
"ProgramFiles",
|
|
1990
|
+
"ProgramW6432",
|
|
1991
|
+
"ProgramFiles(x86)",
|
|
1992
|
+
"LOCALAPPDATA"
|
|
1993
|
+
]) {
|
|
1994
|
+
const value = env[key]?.trim();
|
|
1995
|
+
if (value !== void 0 && value !== "") roots.add(value);
|
|
1996
|
+
}
|
|
1997
|
+
for (let code = 67; code <= 90; code += 1) {
|
|
1998
|
+
const drive = String.fromCharCode(code) + ":\\";
|
|
1999
|
+
try {
|
|
2000
|
+
if (!existsSync(drive)) continue;
|
|
2001
|
+
} catch {
|
|
2002
|
+
continue;
|
|
1978
2003
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
2004
|
+
roots.add(join(drive, "Program Files"));
|
|
2005
|
+
roots.add(join(drive, "Program Files (x86)"));
|
|
2006
|
+
}
|
|
2007
|
+
for (const root of roots) for (const name of APP_EXECUTABLE_NAMES) {
|
|
2008
|
+
out.push(join(root, "WorkBuddy", name));
|
|
2009
|
+
out.push(join(root, "WorkBuddy AI", name));
|
|
2010
|
+
out.push(join(root, "Programs", "WorkBuddy", name));
|
|
2011
|
+
}
|
|
2012
|
+
for (const root of roots) try {
|
|
2013
|
+
for (const entry of readdirSync(root)) {
|
|
2014
|
+
if (!/^(workbuddy|codebuddy)/iu.test(entry)) continue;
|
|
2015
|
+
for (const name of APP_EXECUTABLE_NAMES) out.push(join(root, entry, name));
|
|
2016
|
+
}
|
|
2017
|
+
} catch {}
|
|
2018
|
+
return out;
|
|
1981
2019
|
}
|
|
1982
2020
|
/**
|
|
1983
|
-
*
|
|
1984
|
-
*
|
|
2021
|
+
* Candidate paths of the WorkBuddy desktop executable, in probe order.
|
|
2022
|
+
*
|
|
2023
|
+
* Order is deliberate:
|
|
2024
|
+
* 1. the explicit override, because a user who set it knows where the app is;
|
|
2025
|
+
* 2. the registry, which is what the installer itself recorded;
|
|
2026
|
+
* 3. derived fallbacks (per-user, machine-wide, every fixed drive).
|
|
2027
|
+
*
|
|
2028
|
+
* Only the Windows branch consults the registry (it is the only platform with
|
|
2029
|
+
* one). macOS asks each bundle for its own `CFBundleExecutable` instead, because
|
|
2030
|
+
* the WorkBuddy bundles ship a binary named `Electron`, not after the app.
|
|
2031
|
+
*
|
|
2032
|
+
* `readBundleExecutable` and `registryPaths` are injectable in the same spirit as
|
|
2033
|
+
* `platform`/`home`/`env`: both consult the real machine, so without a seam the
|
|
2034
|
+
* expected candidates would depend on what happens to be installed where the
|
|
2035
|
+
* suite runs — passing on a developer's box and failing in CI.
|
|
1985
2036
|
*/
|
|
1986
|
-
function
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
try {
|
|
1994
|
-
if (existsSync(executable)) return executable;
|
|
1995
|
-
} catch {}
|
|
1996
|
-
}
|
|
2037
|
+
function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env, readBundleExecutable = macosBundleExecutable, registryPaths = windowsRegistryAppPaths) {
|
|
2038
|
+
const candidates = [env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim()];
|
|
2039
|
+
if (platform === "win32") {
|
|
2040
|
+
candidates.push(...registryPaths());
|
|
2041
|
+
candidates.push(...windowsFallbackAppPaths(env));
|
|
2042
|
+
} else if (platform === "darwin") for (const name of MACOS_APP_BUNDLE_NAMES) candidates.push(readBundleExecutable(join("/Applications", name)), readBundleExecutable(join(home, "Applications", name)));
|
|
2043
|
+
return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
|
|
1997
2044
|
}
|
|
1998
2045
|
/**
|
|
1999
2046
|
* Ask the installed desktop app for its key payload by running its own binary
|
|
@@ -2028,28 +2075,95 @@ function fetchAtRestKeyPayload(executable) {
|
|
|
2028
2075
|
});
|
|
2029
2076
|
});
|
|
2030
2077
|
}
|
|
2031
|
-
/** Process-lifetime cache of the derived key; never persisted. */
|
|
2032
|
-
let cachedKey;
|
|
2033
|
-
let inflightKey;
|
|
2034
2078
|
/**
|
|
2035
|
-
* The desktop app's at-rest
|
|
2036
|
-
* (
|
|
2037
|
-
*
|
|
2038
|
-
*
|
|
2039
|
-
*
|
|
2079
|
+
* The desktop app's at-rest keys, indexed by the key id each derived key
|
|
2080
|
+
* reports (the first 16 hex of its SHA-256).
|
|
2081
|
+
*
|
|
2082
|
+
* More than one build can be installed on one machine — the domestic
|
|
2083
|
+
* `WorkBuddy.exe` and the international `WorkBuddyAI.exe` share a key id on the
|
|
2084
|
+
* builds seen here, but a future build may rotate it, and the discovery below
|
|
2085
|
+
* must keep working if they ever diverge. A field envelope names the key id it
|
|
2086
|
+
* was sealed under, so the opener selects the matching derived key instead of
|
|
2087
|
+
* assuming a single build exists. Cached per process and never persisted.
|
|
2040
2088
|
*/
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2089
|
+
const atRestKeyById = /* @__PURE__ */ new Map();
|
|
2090
|
+
let inflightKeys;
|
|
2091
|
+
/**
|
|
2092
|
+
* When the last full key sweep failed, and how long that failure is trusted.
|
|
2093
|
+
*
|
|
2094
|
+
* Without this, EVERY credential read spawned the app and waited out the
|
|
2095
|
+
* 10-second timeout before giving up — which is what made "rescan accounts" and
|
|
2096
|
+
* every status poll crawl on a machine where the app could not be found. A
|
|
2097
|
+
* failure is negative-cached briefly: long enough that a burst of reads costs
|
|
2098
|
+
* one sweep, short enough that installing or starting the app is picked up
|
|
2099
|
+
* without restarting DSH.
|
|
2100
|
+
*/
|
|
2101
|
+
let lastKeyFailureAtMs = 0;
|
|
2102
|
+
const KEY_FAILURE_BACKOFF_MS = 6e4;
|
|
2103
|
+
/**
|
|
2104
|
+
* Load every desktop build's key id into {@link atRestKeyById}.
|
|
2105
|
+
*
|
|
2106
|
+
* Mirrors the reference `provideTheKey` shape: probe EVERY candidate executable
|
|
2107
|
+
* (not just the first that exists) and keep the key each one yields. A build
|
|
2108
|
+
* that fails to answer — a timeout, a single-instance lock, an older build
|
|
2109
|
+
* without the native module — is skipped on its own and does NOT poison the
|
|
2110
|
+
* other builds, which is exactly the failure mode the single-candidate path
|
|
2111
|
+
* had: one bad spawn cached `undefined` for the whole process and every
|
|
2112
|
+
* encrypted field then reported "no app could be located".
|
|
2113
|
+
*/
|
|
2114
|
+
function ensureAtRestKeys() {
|
|
2115
|
+
if (atRestKeyById.size > 0) return Promise.resolve();
|
|
2116
|
+
if (Date.now() - lastKeyFailureAtMs < KEY_FAILURE_BACKOFF_MS) return Promise.resolve();
|
|
2117
|
+
inflightKeys ??= (async () => {
|
|
2118
|
+
const candidates = workbuddyAppExecutableCandidates().filter((candidate) => {
|
|
2119
|
+
try {
|
|
2120
|
+
return existsSync(candidate);
|
|
2121
|
+
} catch {
|
|
2122
|
+
return false;
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
if (candidates.length === 0) {
|
|
2126
|
+
lastKeyFailureAtMs = Date.now();
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
let anySuccess = false;
|
|
2130
|
+
await Promise.all(candidates.map(async (executable) => {
|
|
2131
|
+
try {
|
|
2132
|
+
const key = deriveAtRestKey(await fetchAtRestKeyPayload(executable));
|
|
2133
|
+
atRestKeyById.set(deriveAtRestKeyId(key), key);
|
|
2134
|
+
anySuccess = true;
|
|
2135
|
+
} catch {}
|
|
2136
|
+
}));
|
|
2137
|
+
if (anySuccess) lastKeyFailureAtMs = 0;
|
|
2138
|
+
else lastKeyFailureAtMs = Date.now();
|
|
2049
2139
|
})().finally(() => {
|
|
2050
|
-
|
|
2140
|
+
inflightKeys = void 0;
|
|
2141
|
+
});
|
|
2142
|
+
return inflightKeys;
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Synchronous key lookup for a key id already loaded by {@link ensureAtRestKeys}.
|
|
2146
|
+
*
|
|
2147
|
+
* The account pool warms the cache up front (via {@link readAtRestKey}) and then
|
|
2148
|
+
* opens each encrypted field through a synchronous closure, because the parser
|
|
2149
|
+
* runs `decrypt` inline. Lookups that race the warm-up, or ask for a key id no
|
|
2150
|
+
* installed build produced, return undefined and are reported as the
|
|
2151
|
+
* encrypted-but-unavailable error rather than a silently empty token.
|
|
2152
|
+
*/
|
|
2153
|
+
function atRestKeyFor(keyId) {
|
|
2154
|
+
return atRestKeyById.get(keyId);
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Backwards-compatible single-key view: the first key any build provided.
|
|
2158
|
+
*
|
|
2159
|
+
* Kept so callers that do not yet carry a key id (and the legacy tests) still
|
|
2160
|
+
* resolve to a usable key on single-build machines. Multi-build callers should
|
|
2161
|
+
* prefer {@link readAtRestKeyById} and select by the field's own key id.
|
|
2162
|
+
*/
|
|
2163
|
+
function readAtRestKey() {
|
|
2164
|
+
return ensureAtRestKeys().then(() => {
|
|
2165
|
+
for (const key of atRestKeyById.values()) return key;
|
|
2051
2166
|
});
|
|
2052
|
-
return inflightKey;
|
|
2053
2167
|
}
|
|
2054
2168
|
//#endregion
|
|
2055
2169
|
//#region src/accounts.ts
|
|
@@ -2124,7 +2238,7 @@ const ENCRYPTED_CREDENTIAL_CODE = "ENCRYPTED_CREDENTIAL";
|
|
|
2124
2238
|
var WorkBuddyEncryptedCredentialError = class extends Error {
|
|
2125
2239
|
code = ENCRYPTED_CREDENTIAL_CODE;
|
|
2126
2240
|
constructor(sourcePath) {
|
|
2127
|
-
super(`workbuddy: ${sourcePath} holds encrypted credentials but
|
|
2241
|
+
super(`workbuddy: ${sourcePath} holds encrypted credentials, but no WorkBuddy desktop app could be located to provide the key. If the app IS installed, it is simply outside the paths this plugin probes — set WORKBUDDY_APP_EXECUTABLE to its full .exe path (then restart DSH) and the credential will open. Signing in again will not help: the credential itself is intact. Run \`dsh-workbuddy-xdpool doctor\` to see which paths were probed.`);
|
|
2128
2242
|
this.name = "WorkBuddyEncryptedCredentialError";
|
|
2129
2243
|
}
|
|
2130
2244
|
};
|
|
@@ -2308,10 +2422,13 @@ async function readCredential(path) {
|
|
|
2308
2422
|
* error that would look like a corrupt file.
|
|
2309
2423
|
*/
|
|
2310
2424
|
async function encryptedFieldOpener() {
|
|
2311
|
-
|
|
2312
|
-
if (key === void 0) return void 0;
|
|
2425
|
+
await readAtRestKey().catch(() => void 0);
|
|
2313
2426
|
return (field) => {
|
|
2314
2427
|
if (!isEncryptedFieldWrapper(field)) throw new Error("workbuddy: not an encrypted field wrapper");
|
|
2428
|
+
const keyId = encryptedFieldKeyId(field);
|
|
2429
|
+
if (keyId === void 0) throw new Error("workbuddy: encrypted field has no key id");
|
|
2430
|
+
const key = atRestKeyFor(keyId);
|
|
2431
|
+
if (key === void 0) throw new Error("workbuddy: no at-rest key available for this encrypted field");
|
|
2315
2432
|
return openEncryptedField(field, key);
|
|
2316
2433
|
};
|
|
2317
2434
|
}
|
|
@@ -3103,10 +3220,10 @@ function createWorkBuddyAdapter(options) {
|
|
|
3103
3220
|
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
3104
3221
|
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
|
|
3105
3222
|
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
3223
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
3106
3224
|
...REQUEST_IMAGE_BUDGETS,
|
|
3107
3225
|
piProvider: provider
|
|
3108
3226
|
};
|
|
3109
|
-
Object.assign(profile, { modelErrors: /* @__PURE__ */ new Map() });
|
|
3110
3227
|
let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
3111
3228
|
return {
|
|
3112
3229
|
providerId,
|
|
@@ -6024,9 +6141,9 @@ function apply(ctx, config = {}) {
|
|
|
6024
6141
|
}
|
|
6025
6142
|
const setSetting = async (key, value, expected) => {
|
|
6026
6143
|
if (value === void 0) return;
|
|
6027
|
-
const
|
|
6028
|
-
if (
|
|
6029
|
-
await
|
|
6144
|
+
const update = settingsService?.update;
|
|
6145
|
+
if (update === void 0) throw new Error(`settings service has no update(); ${key} was not saved`);
|
|
6146
|
+
await update.call(settingsService, WORKBUDDY_POOL_SETTINGS_NS, { [key]: value });
|
|
6030
6147
|
if (expected !== void 0) {
|
|
6031
6148
|
const stored = current()[key];
|
|
6032
6149
|
if (!stableJsonEqual(stored, expected)) throw new Error(`settings field "${key}" was not persisted`);
|
package/package.json
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
"name": "dsh-workbuddy-xdpool",
|
|
3
3
|
"displayName": "DSH WorkBuddy XD Pool",
|
|
4
4
|
"description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.6.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|
|
9
|
-
"bugs": {
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/XDTrees/dsh-workbuddy-xdpool/issues"
|
|
11
|
+
},
|
|
10
12
|
"repository": {
|
|
11
13
|
"type": "git",
|
|
12
14
|
"url": "git+https://github.com/XDTrees/dsh-workbuddy-xdpool.git"
|
|
@@ -73,19 +75,19 @@
|
|
|
73
75
|
},
|
|
74
76
|
"devDependencies": {
|
|
75
77
|
"@deepseek-ai/cordis": "4.0.2",
|
|
76
|
-
"@deepseek-ai/dsh-client-locale": "0.1.
|
|
78
|
+
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
|
|
77
79
|
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
|
|
78
|
-
"@deepseek-ai/dsh-client-ui-primitives": "0.1.
|
|
79
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": "0.1.
|
|
80
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.1.
|
|
81
|
-
"@deepseek-ai/dsh-credentials": "0.1.
|
|
82
|
-
"@deepseek-ai/dsh-host-webserver": "0.1.
|
|
83
|
-
"@deepseek-ai/dsh-launch-environment": "0.1.
|
|
84
|
-
"@deepseek-ai/dsh-llm": "0.1.
|
|
85
|
-
"@deepseek-ai/dsh-llm-pi-ai": "0.1.
|
|
86
|
-
"@deepseek-ai/dsh-settings": "0.1.
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2",
|
|
81
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "0.1.5-rc.2",
|
|
82
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
|
|
83
|
+
"@deepseek-ai/dsh-credentials": "0.1.5-rc.2",
|
|
84
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
|
|
85
|
+
"@deepseek-ai/dsh-launch-environment": "0.1.5-rc.2",
|
|
86
|
+
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
|
|
87
|
+
"@deepseek-ai/dsh-llm-pi-ai": "0.1.5-rc.2",
|
|
88
|
+
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
|
|
87
89
|
"@deepseek-ai/schemastery": "3.18.2",
|
|
88
|
-
"@earendil-works/pi-ai": "0.
|
|
90
|
+
"@earendil-works/pi-ai": "0.85.1",
|
|
89
91
|
"@types/node": "^26.4.1",
|
|
90
92
|
"@types/react": "~18.3.31",
|
|
91
93
|
"@types/react-dom": "~18.3.0",
|