dsh-workbuddy-xdpool 1.5.2 → 1.7.0
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 +226 -0
- package/lib/bin.js +288 -142
- package/lib/client.js +597 -416
- package/lib/index.d.ts +9 -1
- package/lib/index.js +380 -151
- package/package.json +17 -15
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";
|
|
@@ -1640,10 +1640,29 @@ const AAD_DOMAIN = Buffer.from("WB-AAD\0", "ascii");
|
|
|
1640
1640
|
const SYMMETRIC_SCHEME = "sym-v1";
|
|
1641
1641
|
/** Env override pointing at the WorkBuddy desktop executable. */
|
|
1642
1642
|
const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
|
|
1643
|
-
/**
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1643
|
+
/**
|
|
1644
|
+
* How long the app is given to answer with its key payload.
|
|
1645
|
+
*
|
|
1646
|
+
* 30s, not 10s: the child is the WorkBuddy Electron binary running as plain
|
|
1647
|
+
* Node, and its FIRST spawn on a cold machine costs several seconds on its own
|
|
1648
|
+
* (measured 4.5s here) before the endpoint security stack has warmed its scan
|
|
1649
|
+
* cache. Under load — a concurrent `pnpm install` from the market, a running
|
|
1650
|
+
* full-disk scan — that first spawn crosses a 10s budget, the fetch rejects,
|
|
1651
|
+
* `readAtRestKey` returns undefined, and every encrypted credential then reads
|
|
1652
|
+
* as `WorkBuddyEncryptedCredentialError` until the 60s negative cache expires.
|
|
1653
|
+
* A successful fetch is cached for the process lifetime, so the longer budget
|
|
1654
|
+
* is only ever paid once per process, and only when the app is present but slow.
|
|
1655
|
+
*/
|
|
1656
|
+
const KEY_FETCH_TIMEOUT_MS = 3e4;
|
|
1657
|
+
/**
|
|
1658
|
+
* Executable file names the desktop app ships under, in probe order.
|
|
1659
|
+
*
|
|
1660
|
+
* `WorkBuddyAI.exe` is the INTERNATIONAL build; both apps can be installed side
|
|
1661
|
+
* by side (observed on a real machine: `D:\\workbuddy\\WorkBuddy.exe` for the
|
|
1662
|
+
* domestic one and `D:\\workbuddyai\\WorkBuddyAI.exe` for the international one),
|
|
1663
|
+
* so the name cannot be assumed.
|
|
1664
|
+
*/
|
|
1665
|
+
const APP_EXECUTABLE_NAMES = ["WorkBuddy.exe", "WorkBuddyAI.exe"];
|
|
1647
1666
|
/**
|
|
1648
1667
|
* macOS bundles the desktop app may be installed as, in probe order.
|
|
1649
1668
|
*
|
|
@@ -1752,6 +1771,23 @@ function openEncryptedField(field, key) {
|
|
|
1752
1771
|
return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]).toString("utf8");
|
|
1753
1772
|
}
|
|
1754
1773
|
/**
|
|
1774
|
+
* The key id an encrypted field envelope demands, or undefined when the
|
|
1775
|
+
* envelope cannot be read.
|
|
1776
|
+
*
|
|
1777
|
+
* The account pool uses it to pick the right desktop build's key when more
|
|
1778
|
+
* than one build (domestic and international) is installed on the same machine:
|
|
1779
|
+
* each `.info` file names the key id its fields were sealed under, so the opener
|
|
1780
|
+
* must select the matching derived key rather than assume one build exists.
|
|
1781
|
+
*/
|
|
1782
|
+
function encryptedFieldKeyId(field) {
|
|
1783
|
+
try {
|
|
1784
|
+
const record = JSON.parse(Buffer.from(field.envelope, "base64").toString("utf8"));
|
|
1785
|
+
return typeof record["keyId"] === "string" ? record["keyId"] : void 0;
|
|
1786
|
+
} catch {
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1755
1791
|
* The executable inside a macOS app bundle, read from the bundle's own
|
|
1756
1792
|
* `Info.plist`.
|
|
1757
1793
|
*
|
|
@@ -1777,116 +1813,140 @@ function macosBundleExecutable(bundle) {
|
|
|
1777
1813
|
return join(bundle, "Contents", "MacOS", name);
|
|
1778
1814
|
}
|
|
1779
1815
|
/**
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
* The Windows build is the one that encrypts credentials, so Windows leads;
|
|
1783
|
-
* the macOS bundles are listed because the same native module ships there and
|
|
1784
|
-
* the encryption policy is enabled on macOS builds too (observed from 5.6.x),
|
|
1785
|
-
* and `undefined` entries (an unset env variable) are dropped.
|
|
1816
|
+
* Windows install locations recorded by the app's own uninstaller.
|
|
1786
1817
|
*
|
|
1787
|
-
*
|
|
1788
|
-
*
|
|
1789
|
-
*
|
|
1790
|
-
* `
|
|
1791
|
-
* `
|
|
1792
|
-
*
|
|
1793
|
-
*
|
|
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.
|
|
1818
|
+
* The registry is the authoritative answer: it survives a non-default drive, a
|
|
1819
|
+
* renamed folder and a differently-named executable, none of which any fixed
|
|
1820
|
+
* path list can predict. Real machines put the app at `D:\workbuddy\WorkBuddy.exe`
|
|
1821
|
+
* and `D:\workbuddyai\WorkBuddyAI.exe` — exactly the layouts a
|
|
1822
|
+
* `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see, which is why the
|
|
1823
|
+
* plugin reported "the desktop app could not provide the key" for an app that was
|
|
1824
|
+
* installed and running.
|
|
1821
1825
|
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1826
|
+
* `DisplayIcon` is the field that actually carries the path (observed as
|
|
1827
|
+
* `D:\workbuddy\WorkBuddy.exe,0`); `InstallLocation` is usually empty for these
|
|
1828
|
+
* installers, so both are read and either may contribute.
|
|
1824
1829
|
*
|
|
1825
|
-
*
|
|
1826
|
-
*
|
|
1827
|
-
* wrong one would execute an unrelated application.
|
|
1830
|
+
* Returns [] on any failure — a missing registry key is the normal case on
|
|
1831
|
+
* non-Windows, not an error.
|
|
1828
1832
|
*/
|
|
1829
|
-
function
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1833
|
+
function windowsRegistryAppPaths() {
|
|
1834
|
+
if (process.platform !== "win32") return [];
|
|
1835
|
+
const roots = [
|
|
1836
|
+
["HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
|
|
1837
|
+
["HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
|
|
1838
|
+
["HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"]
|
|
1839
|
+
];
|
|
1840
|
+
const out = [];
|
|
1841
|
+
for (const [root] of roots) {
|
|
1842
|
+
let listing;
|
|
1843
|
+
try {
|
|
1844
|
+
listing = execFileSync("reg", [
|
|
1845
|
+
"query",
|
|
1846
|
+
root,
|
|
1847
|
+
"/s",
|
|
1848
|
+
"/v",
|
|
1849
|
+
"DisplayName"
|
|
1850
|
+
], {
|
|
1851
|
+
encoding: "utf8",
|
|
1852
|
+
timeout: 1e4,
|
|
1853
|
+
windowsHide: true,
|
|
1854
|
+
maxBuffer: 8388608
|
|
1855
|
+
});
|
|
1856
|
+
} catch {
|
|
1857
|
+
continue;
|
|
1858
|
+
}
|
|
1859
|
+
const keys = listing.split(/\r?\n(?=HKEY_)/u).filter((block) => /WorkBuddy|CodeBuddy/iu.test(block));
|
|
1860
|
+
for (const key of keys) {
|
|
1861
|
+
const keyPath = /^(HKEY_[^\r\n]+)/u.exec(key)?.[1]?.trim();
|
|
1862
|
+
if (keyPath === void 0) continue;
|
|
1863
|
+
for (const name of ["DisplayIcon", "InstallLocation"]) try {
|
|
1864
|
+
const value = execFileSync("reg", [
|
|
1865
|
+
"query",
|
|
1866
|
+
keyPath,
|
|
1867
|
+
"/v",
|
|
1868
|
+
name
|
|
1869
|
+
], {
|
|
1870
|
+
encoding: "utf8",
|
|
1871
|
+
timeout: 5e3,
|
|
1872
|
+
windowsHide: true
|
|
1873
|
+
});
|
|
1874
|
+
const raw = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(value)?.[1]?.trim();
|
|
1875
|
+
if (raw === void 0 || raw === "") continue;
|
|
1876
|
+
const cleaned = raw.replace(/^"/u, "").replace(/",-?\d+$/u, "").replace(/,-?\d+$/u, "").trim();
|
|
1877
|
+
out.push(cleaned);
|
|
1878
|
+
} catch {}
|
|
1879
|
+
}
|
|
1835
1880
|
}
|
|
1836
|
-
|
|
1837
|
-
if (identifier === void 0 || identifier === "") return false;
|
|
1838
|
-
return APP_BUNDLE_IDENTIFIER_PREFIXES.some((prefix) => identifier === prefix || identifier.startsWith(`${prefix}.`));
|
|
1881
|
+
return out;
|
|
1839
1882
|
}
|
|
1840
1883
|
/**
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
1844
|
-
* Users do file apps into subfolders (`/Applications/IDE/WorkBuddy.app`), and
|
|
1845
|
-
* a hardcoded `/Applications/<name>` then reports the app as missing while it
|
|
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.
|
|
1884
|
+
* Windows fallbacks for an app the registry did not cover: the well-known
|
|
1885
|
+
* per-user and machine-wide locations, plus every fixed drive's `Program Files`.
|
|
1849
1886
|
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
1887
|
+
* Drive enumeration matters because installing to a non-system drive is common
|
|
1888
|
+
* on Windows and no environment variable points there.
|
|
1852
1889
|
*/
|
|
1853
|
-
function
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
const
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1890
|
+
function windowsFallbackAppPaths(env) {
|
|
1891
|
+
const out = [];
|
|
1892
|
+
const roots = /* @__PURE__ */ new Set();
|
|
1893
|
+
for (const key of [
|
|
1894
|
+
"ProgramFiles",
|
|
1895
|
+
"ProgramW6432",
|
|
1896
|
+
"ProgramFiles(x86)",
|
|
1897
|
+
"LOCALAPPDATA"
|
|
1898
|
+
]) {
|
|
1899
|
+
const value = env[key]?.trim();
|
|
1900
|
+
if (value !== void 0 && value !== "") roots.add(value);
|
|
1901
|
+
}
|
|
1902
|
+
for (let code = 67; code <= 90; code += 1) {
|
|
1903
|
+
const drive = String.fromCharCode(code) + ":\\";
|
|
1904
|
+
try {
|
|
1905
|
+
if (!existsSync(drive)) continue;
|
|
1906
|
+
} catch {
|
|
1907
|
+
continue;
|
|
1871
1908
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1909
|
+
roots.add(join(drive, "Program Files"));
|
|
1910
|
+
roots.add(join(drive, "Program Files (x86)"));
|
|
1911
|
+
roots.add(drive);
|
|
1912
|
+
}
|
|
1913
|
+
for (const root of roots) for (const name of APP_EXECUTABLE_NAMES) {
|
|
1914
|
+
out.push(join(root, "WorkBuddy", name));
|
|
1915
|
+
out.push(join(root, "WorkBuddy AI", name));
|
|
1916
|
+
out.push(join(root, "Programs", "WorkBuddy", name));
|
|
1917
|
+
}
|
|
1918
|
+
for (const root of roots) try {
|
|
1919
|
+
for (const entry of readdirSync(root)) {
|
|
1920
|
+
if (!/^(workbuddy|codebuddy)/iu.test(entry)) continue;
|
|
1921
|
+
for (const name of APP_EXECUTABLE_NAMES) out.push(join(root, entry, name));
|
|
1922
|
+
}
|
|
1923
|
+
} catch {}
|
|
1924
|
+
return out;
|
|
1874
1925
|
}
|
|
1875
1926
|
/**
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
1927
|
+
* Candidate paths of the WorkBuddy desktop executable, in probe order.
|
|
1928
|
+
*
|
|
1929
|
+
* Order is deliberate:
|
|
1930
|
+
* 1. the explicit override, because a user who set it knows where the app is;
|
|
1931
|
+
* 2. the registry, which is what the installer itself recorded;
|
|
1932
|
+
* 3. derived fallbacks (per-user, machine-wide, every fixed drive).
|
|
1933
|
+
*
|
|
1934
|
+
* Only the Windows branch consults the registry (it is the only platform with
|
|
1935
|
+
* one). macOS asks each bundle for its own `CFBundleExecutable` instead, because
|
|
1936
|
+
* the WorkBuddy bundles ship a binary named `Electron`, not after the app.
|
|
1937
|
+
*
|
|
1938
|
+
* `readBundleExecutable` and `registryPaths` are injectable in the same spirit as
|
|
1939
|
+
* `platform`/`home`/`env`: both consult the real machine, so without a seam the
|
|
1940
|
+
* expected candidates would depend on what happens to be installed where the
|
|
1941
|
+
* suite runs — passing on a developer's box and failing in CI.
|
|
1878
1942
|
*/
|
|
1879
|
-
function
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
try {
|
|
1887
|
-
if (existsSync(executable)) return executable;
|
|
1888
|
-
} catch {}
|
|
1889
|
-
}
|
|
1943
|
+
function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env, readBundleExecutable = macosBundleExecutable, registryPaths = windowsRegistryAppPaths) {
|
|
1944
|
+
const candidates = [env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim()];
|
|
1945
|
+
if (platform === "win32") {
|
|
1946
|
+
candidates.push(...registryPaths());
|
|
1947
|
+
candidates.push(...windowsFallbackAppPaths(env));
|
|
1948
|
+
} else if (platform === "darwin") for (const name of MACOS_APP_BUNDLE_NAMES) candidates.push(readBundleExecutable(join("/Applications", name)), readBundleExecutable(join(home, "Applications", name)));
|
|
1949
|
+
return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
|
|
1890
1950
|
}
|
|
1891
1951
|
/**
|
|
1892
1952
|
* Ask the installed desktop app for its key payload by running its own binary
|
|
@@ -1921,28 +1981,95 @@ function fetchAtRestKeyPayload(executable) {
|
|
|
1921
1981
|
});
|
|
1922
1982
|
});
|
|
1923
1983
|
}
|
|
1924
|
-
/** Process-lifetime cache of the derived key; never persisted. */
|
|
1925
|
-
let cachedKey;
|
|
1926
|
-
let inflightKey;
|
|
1927
1984
|
/**
|
|
1928
|
-
* The desktop app's at-rest
|
|
1929
|
-
* (
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1985
|
+
* The desktop app's at-rest keys, indexed by the key id each derived key
|
|
1986
|
+
* reports (the first 16 hex of its SHA-256).
|
|
1987
|
+
*
|
|
1988
|
+
* More than one build can be installed on one machine — the domestic
|
|
1989
|
+
* `WorkBuddy.exe` and the international `WorkBuddyAI.exe` share a key id on the
|
|
1990
|
+
* builds seen here, but a future build may rotate it, and the discovery below
|
|
1991
|
+
* must keep working if they ever diverge. A field envelope names the key id it
|
|
1992
|
+
* was sealed under, so the opener selects the matching derived key instead of
|
|
1993
|
+
* assuming a single build exists. Cached per process and never persisted.
|
|
1933
1994
|
*/
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1995
|
+
const atRestKeyById = /* @__PURE__ */ new Map();
|
|
1996
|
+
let inflightKeys;
|
|
1997
|
+
/**
|
|
1998
|
+
* When the last full key sweep failed, and how long that failure is trusted.
|
|
1999
|
+
*
|
|
2000
|
+
* Without this, EVERY credential read spawned the app and waited out the
|
|
2001
|
+
* 10-second timeout before giving up — which is what made "rescan accounts" and
|
|
2002
|
+
* every status poll crawl on a machine where the app could not be found. A
|
|
2003
|
+
* failure is negative-cached briefly: long enough that a burst of reads costs
|
|
2004
|
+
* one sweep, short enough that installing or starting the app is picked up
|
|
2005
|
+
* without restarting DSH.
|
|
2006
|
+
*/
|
|
2007
|
+
let lastKeyFailureAtMs = 0;
|
|
2008
|
+
const KEY_FAILURE_BACKOFF_MS = 6e4;
|
|
2009
|
+
/**
|
|
2010
|
+
* Load every desktop build's key id into {@link atRestKeyById}.
|
|
2011
|
+
*
|
|
2012
|
+
* Mirrors the reference `provideTheKey` shape: probe EVERY candidate executable
|
|
2013
|
+
* (not just the first that exists) and keep the key each one yields. A build
|
|
2014
|
+
* that fails to answer — a timeout, a single-instance lock, an older build
|
|
2015
|
+
* without the native module — is skipped on its own and does NOT poison the
|
|
2016
|
+
* other builds, which is exactly the failure mode the single-candidate path
|
|
2017
|
+
* had: one bad spawn cached `undefined` for the whole process and every
|
|
2018
|
+
* encrypted field then reported "no app could be located".
|
|
2019
|
+
*/
|
|
2020
|
+
function ensureAtRestKeys() {
|
|
2021
|
+
if (atRestKeyById.size > 0) return Promise.resolve();
|
|
2022
|
+
if (Date.now() - lastKeyFailureAtMs < KEY_FAILURE_BACKOFF_MS) return Promise.resolve();
|
|
2023
|
+
inflightKeys ??= (async () => {
|
|
2024
|
+
const candidates = workbuddyAppExecutableCandidates().filter((candidate) => {
|
|
2025
|
+
try {
|
|
2026
|
+
return existsSync(candidate);
|
|
2027
|
+
} catch {
|
|
2028
|
+
return false;
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
if (candidates.length === 0) {
|
|
2032
|
+
lastKeyFailureAtMs = Date.now();
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
let anySuccess = false;
|
|
2036
|
+
await Promise.all(candidates.map(async (executable) => {
|
|
2037
|
+
try {
|
|
2038
|
+
const key = deriveAtRestKey(await fetchAtRestKeyPayload(executable));
|
|
2039
|
+
atRestKeyById.set(deriveAtRestKeyId(key), key);
|
|
2040
|
+
anySuccess = true;
|
|
2041
|
+
} catch {}
|
|
2042
|
+
}));
|
|
2043
|
+
if (anySuccess) lastKeyFailureAtMs = 0;
|
|
2044
|
+
else lastKeyFailureAtMs = Date.now();
|
|
1942
2045
|
})().finally(() => {
|
|
1943
|
-
|
|
2046
|
+
inflightKeys = void 0;
|
|
2047
|
+
});
|
|
2048
|
+
return inflightKeys;
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Synchronous key lookup for a key id already loaded by {@link ensureAtRestKeys}.
|
|
2052
|
+
*
|
|
2053
|
+
* The account pool warms the cache up front (via {@link readAtRestKey}) and then
|
|
2054
|
+
* opens each encrypted field through a synchronous closure, because the parser
|
|
2055
|
+
* runs `decrypt` inline. Lookups that race the warm-up, or ask for a key id no
|
|
2056
|
+
* installed build produced, return undefined and are reported as the
|
|
2057
|
+
* encrypted-but-unavailable error rather than a silently empty token.
|
|
2058
|
+
*/
|
|
2059
|
+
function atRestKeyFor(keyId) {
|
|
2060
|
+
return atRestKeyById.get(keyId);
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Backwards-compatible single-key view: the first key any build provided.
|
|
2064
|
+
*
|
|
2065
|
+
* Kept so callers that do not yet carry a key id (and the legacy tests) still
|
|
2066
|
+
* resolve to a usable key on single-build machines. Multi-build callers should
|
|
2067
|
+
* prefer {@link readAtRestKeyById} and select by the field's own key id.
|
|
2068
|
+
*/
|
|
2069
|
+
function readAtRestKey() {
|
|
2070
|
+
return ensureAtRestKeys().then(() => {
|
|
2071
|
+
for (const key of atRestKeyById.values()) return key;
|
|
1944
2072
|
});
|
|
1945
|
-
return inflightKey;
|
|
1946
2073
|
}
|
|
1947
2074
|
//#endregion
|
|
1948
2075
|
//#region src/accounts.ts
|
|
@@ -2017,7 +2144,7 @@ const ENCRYPTED_CREDENTIAL_CODE = "ENCRYPTED_CREDENTIAL";
|
|
|
2017
2144
|
var WorkBuddyEncryptedCredentialError = class extends Error {
|
|
2018
2145
|
code = ENCRYPTED_CREDENTIAL_CODE;
|
|
2019
2146
|
constructor(sourcePath) {
|
|
2020
|
-
super(`workbuddy: ${sourcePath} holds encrypted credentials but
|
|
2147
|
+
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
2148
|
this.name = "WorkBuddyEncryptedCredentialError";
|
|
2022
2149
|
}
|
|
2023
2150
|
};
|
|
@@ -2201,10 +2328,13 @@ async function readCredential(path) {
|
|
|
2201
2328
|
* error that would look like a corrupt file.
|
|
2202
2329
|
*/
|
|
2203
2330
|
async function encryptedFieldOpener() {
|
|
2204
|
-
|
|
2205
|
-
if (key === void 0) return void 0;
|
|
2331
|
+
await readAtRestKey().catch(() => void 0);
|
|
2206
2332
|
return (field) => {
|
|
2207
2333
|
if (!isEncryptedFieldWrapper(field)) throw new Error("workbuddy: not an encrypted field wrapper");
|
|
2334
|
+
const keyId = encryptedFieldKeyId(field);
|
|
2335
|
+
if (keyId === void 0) throw new Error("workbuddy: encrypted field has no key id");
|
|
2336
|
+
const key = atRestKeyFor(keyId);
|
|
2337
|
+
if (key === void 0) throw new Error("workbuddy: no at-rest key available for this encrypted field");
|
|
2208
2338
|
return openEncryptedField(field, key);
|
|
2209
2339
|
};
|
|
2210
2340
|
}
|
|
@@ -3713,12 +3843,16 @@ var WorkBuddyScheduler = class {
|
|
|
3713
3843
|
*/
|
|
3714
3844
|
persistEarnings() {
|
|
3715
3845
|
if (this.saveEarningsFn === void 0) return;
|
|
3846
|
+
const accounts = {};
|
|
3847
|
+
for (const [id, entry] of this.earnings) accounts[id] = entry;
|
|
3848
|
+
const ledger = {
|
|
3849
|
+
date: this.earningsDate,
|
|
3850
|
+
accounts
|
|
3851
|
+
};
|
|
3716
3852
|
try {
|
|
3717
|
-
const
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
date: this.earningsDate,
|
|
3721
|
-
accounts
|
|
3853
|
+
const saved = this.saveEarningsFn(ledger);
|
|
3854
|
+
if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
|
|
3855
|
+
this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
|
|
3722
3856
|
});
|
|
3723
3857
|
} catch (error) {
|
|
3724
3858
|
this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
|
|
@@ -4150,6 +4284,18 @@ function formatRates(status) {
|
|
|
4150
4284
|
* region that has never been saved stays absent so `applyConfigFromSource` can
|
|
4151
4285
|
* fall back to the legacy flat keys.
|
|
4152
4286
|
*/
|
|
4287
|
+
function asVolatile(schema) {
|
|
4288
|
+
const candidate = schema;
|
|
4289
|
+
return typeof candidate.volatile === "function" ? candidate.volatile() : schema;
|
|
4290
|
+
}
|
|
4291
|
+
/**
|
|
4292
|
+
* One region's model-selection schema.
|
|
4293
|
+
*
|
|
4294
|
+
* Every field is optional on purpose: an absent field keeps its documented
|
|
4295
|
+
* meaning ("all enabled" / "follow the upstream image flag" / "no cap"), and a
|
|
4296
|
+
* region that has never been saved stays absent so `applyConfigFromSource` can
|
|
4297
|
+
* fall back to the legacy flat keys.
|
|
4298
|
+
*/
|
|
4153
4299
|
const modelSelectionSchema = z.object({
|
|
4154
4300
|
enabledModelIds: z.array(z.string()).description("Model ids enabled in this region's picker (absent = all)"),
|
|
4155
4301
|
imageModelIds: z.array(z.string()).description("Model ids accepting image input in this region (absent = follow upstream)"),
|
|
@@ -4177,22 +4323,22 @@ const automationSchema = z.object({
|
|
|
4177
4323
|
exhaustCooldownMs: z.number().step(1).min(1e3).description("How long a spent account rests, in milliseconds")
|
|
4178
4324
|
});
|
|
4179
4325
|
z.object({
|
|
4180
|
-
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
|
|
4181
|
-
cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
|
|
4182
|
-
distribution: z.union([
|
|
4326
|
+
authFile: asVolatile(z.string().description("WorkBuddy desktop auth file (defaults to the app own location)")),
|
|
4327
|
+
cooldownMs: asVolatile(z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds")),
|
|
4328
|
+
distribution: asVolatile(z.union([
|
|
4183
4329
|
"priority",
|
|
4184
4330
|
"round-robin",
|
|
4185
4331
|
"balanced"
|
|
4186
|
-
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)"),
|
|
4187
|
-
disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
|
|
4188
|
-
creditReserves: z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value"),
|
|
4189
|
-
enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
|
|
4190
|
-
imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
|
|
4191
|
-
contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet"),
|
|
4192
|
-
modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
|
|
4193
|
-
modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
|
|
4194
|
-
automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)"),
|
|
4195
|
-
automationEarnings: z.any().description("Automation earnings ledger (written by the scheduler)")
|
|
4332
|
+
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)")),
|
|
4333
|
+
disabledAccountIds: asVolatile(z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)")),
|
|
4334
|
+
creditReserves: asVolatile(z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value")),
|
|
4335
|
+
enabledModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet")),
|
|
4336
|
+
imageModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet")),
|
|
4337
|
+
contextBudgets: asVolatile(z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet")),
|
|
4338
|
+
modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
|
|
4339
|
+
modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
|
|
4340
|
+
automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
|
|
4341
|
+
automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
|
|
4196
4342
|
});
|
|
4197
4343
|
/** Assemble the runtime objects without registering anything. */
|
|
4198
4344
|
/**
|