dsh-workbuddy-xdpool 1.4.1 → 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/lib/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { basename, dirname, join, resolve } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
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, execFileSync } from "node:child_process";
7
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
6
8
  import { createProvider } from "@earendil-works/pi-ai";
7
9
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
10
  import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
@@ -1681,6 +1683,489 @@ var WorkBuddyUpstreamClient = class {
1681
1683
  }
1682
1684
  };
1683
1685
  //#endregion
1686
+ //#region src/at-rest.ts
1687
+ /**
1688
+ * WorkBuddy desktop "at-rest" credential decryption.
1689
+ *
1690
+ * From 5.6.0 the WorkBuddy desktop app no longer stores `auth.accessToken` /
1691
+ * `auth.refreshToken` as plain strings. It writes a field wrapper:
1692
+ *
1693
+ * { "$wbEncrypted": 1, "envelope": "<base64 of a JSON envelope>" }
1694
+ *
1695
+ * where the envelope is `{suite, keyId, nonce, authTag, ciphertext}` for
1696
+ * AES-256-GCM with a 12-byte nonce and a 16-byte tag. The authenticated
1697
+ * additional data is a length-prefixed transcript over the scheme, suite,
1698
+ * keyId and framing, so the ciphertext can only be opened for the exact field
1699
+ * shape it was sealed for.
1700
+ *
1701
+ * The field key itself is NOT a user secret: it is a build-time constant
1702
+ * compiled into the app's own Electron native module
1703
+ * (`electron_browser_workbuddy_storage`). The app fetches it through
1704
+ * `loggerGet()` and hashes the returned base64 STRING (not the decoded bytes)
1705
+ * to obtain the 32-byte key; `keyId` is the first 16 hex characters of that
1706
+ * key's SHA-256.
1707
+ *
1708
+ * This module re-derives the same key by asking the installed app for the same
1709
+ * payload, and caches it in memory for the process lifetime. Nothing is ever
1710
+ * written to disk, and the payload is never logged.
1711
+ *
1712
+ * 溯源:本文件移植自 dingminhua/dsh-connect-workbuddy 的 src/at-rest.ts
1713
+ * (MIT,Copyright (c) 2026 LaoDing)——该模块最先定位并修复了「5.6.0 起
1714
+ * macOS 与 Windows 同样加密凭据」这一问题(其 issue #15 真机取证)。移植时
1715
+ * 保留其全部判定逻辑(CFBundleExecutable 向 bundle 自己问、按 bundle id
1716
+ * 确认身份后才 exec、field framing 的 AAD 转录、keyId 校验),未作改动。
1717
+ *
1718
+ * 改动:**「macOS 也加密」这一事实**(issue #15 真机取证)。本模块原先假设该
1719
+ * policy 是 Windows 先行、macOS 只是「将来可能」,于是 macOS 的可执行文件
1720
+ * 路径用 App 名拼成 `<bundle>/Contents/MacOS/WorkBuddy`——而两个真实 bundle
1721
+ * 的 `CFBundleExecutable` 都是 `Electron`,该路径并不存在。结果是 macOS 上
1722
+ * 加密凭据**永远**取不到密钥,用户却被报成「未登录」。现在二进制名向 bundle
1723
+ * 自己问(`macosBundleExecutable()`),候选含国际版 `WorkBuddy AI.app`,
1724
+ * 并允许 App 被归入 applications 目录的子目录——扫到的候选必须先用
1725
+ * `CFBundleIdentifier` 确认身份才 `execFile`,因为**每个 Electron 应用的
1726
+ * 二进制都叫 `Electron`**,只按名字匹配就可能启动另一个产品。
1727
+ *
1728
+ * @module dsh-workbuddy-xdpool/at-rest
1729
+ */
1730
+ /** Envelope framing names, mapped to the single-byte AAD framing code. */
1731
+ const FRAMING_CODE = {
1732
+ file: 1,
1733
+ field: 2,
1734
+ record: 3,
1735
+ stream: 4
1736
+ };
1737
+ /** Standard (symmetric) format identifiers, transcripted into the AAD. */
1738
+ const STANDARD_FORMAT_ID = {
1739
+ file: "WBEF1",
1740
+ field: "WBEV1",
1741
+ record: "WBER1",
1742
+ stream: "WBES1"
1743
+ };
1744
+ /** Domain separator the AAD transcript starts with. */
1745
+ const AAD_DOMAIN = Buffer.from("WB-AAD\0", "ascii");
1746
+ /** Scheme name of the symmetric envelope this module opens. */
1747
+ const SYMMETRIC_SCHEME = "sym-v1";
1748
+ /** Env override pointing at the WorkBuddy desktop executable. */
1749
+ const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
1750
+ /** How long the app is given to answer with its key payload. */
1751
+ const KEY_FETCH_TIMEOUT_MS = 1e4;
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"];
1761
+ /**
1762
+ * macOS bundles the desktop app may be installed as, in probe order.
1763
+ *
1764
+ * `WorkBuddy.app` is the domestic build; `WorkBuddy AI.app` is the
1765
+ * international one, and a machine may carry either or both. The user-level
1766
+ * `~/Applications` location is included because macOS lets an app live there,
1767
+ * and installs have been observed under a subdirectory of /Applications too —
1768
+ * hence {@link findWorkbuddyAppExecutable}'s parent scan, which covers those
1769
+ * without guessing any particular folder name.
1770
+ */
1771
+ const MACOS_APP_BUNDLE_NAMES = ["WorkBuddy.app", "WorkBuddy AI.app"];
1772
+ function encodeUint32(value) {
1773
+ const bytes = Buffer.allocUnsafe(4);
1774
+ bytes.writeUInt32BE(value);
1775
+ return bytes;
1776
+ }
1777
+ /** Length-prefixed UTF-8 string: uint32 big-endian length followed by the bytes. */
1778
+ function encodeLengthPrefixed(value) {
1779
+ const bytes = Buffer.from(value, "utf8");
1780
+ return Buffer.concat([encodeUint32(bytes.length), bytes]);
1781
+ }
1782
+ /**
1783
+ * The authenticated additional data for one `sym-v1` FIELD-framed envelope.
1784
+ *
1785
+ * Only the field framing is implemented: it is the shape the desktop app uses
1786
+ * for credential fields, and it is also the shape that cannot be confused with
1787
+ * a whole-file envelope, so an unexpected framing is a parse error rather than
1788
+ * a silently wrong transcript.
1789
+ */
1790
+ function fieldAad(keyId, suite, scheme = SYMMETRIC_SCHEME) {
1791
+ if (!/^[0-9a-f]{16}$/u.test(keyId)) throw new Error(`workbuddy: envelope keyId is malformed`);
1792
+ return Buffer.concat([
1793
+ AAD_DOMAIN,
1794
+ Buffer.from([1]),
1795
+ encodeLengthPrefixed(STANDARD_FORMAT_ID["field"]),
1796
+ encodeLengthPrefixed(scheme),
1797
+ encodeUint32(suite),
1798
+ encodeLengthPrefixed(keyId),
1799
+ Buffer.from([FRAMING_CODE["field"]]),
1800
+ Buffer.from([0]),
1801
+ Buffer.from([0])
1802
+ ]);
1803
+ }
1804
+ /** Whether a value is the app's encrypted-field wrapper. */
1805
+ function isEncryptedFieldWrapper(value) {
1806
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1807
+ const wrapper = value;
1808
+ const keys = Object.keys(wrapper).sort();
1809
+ return keys.length === 2 && keys[0] === "$wbEncrypted" && keys[1] === "envelope" && wrapper["$wbEncrypted"] === 1 && typeof wrapper["envelope"] === "string";
1810
+ }
1811
+ /**
1812
+ * The at-rest key id for a derived 32-byte key: the first 16 hex characters of
1813
+ * its SHA-256. This is what the envelope's `keyId` is checked against, so a
1814
+ * mismatched key fails loudly instead of returning garbage.
1815
+ */
1816
+ function deriveAtRestKeyId(key) {
1817
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
1818
+ }
1819
+ /**
1820
+ * Derive the 32-byte field key from the app's key payload JSON.
1821
+ *
1822
+ * The app hashes the payload's base64 STRING — not its decoded bytes — so the
1823
+ * same spelling is required here; hashing the decoded secret would produce a
1824
+ * different key and every field would fail to open.
1825
+ */
1826
+ function deriveAtRestKey(payloadJson) {
1827
+ let payload;
1828
+ try {
1829
+ payload = JSON.parse(payloadJson);
1830
+ } catch {
1831
+ throw new Error("workbuddy: at-rest key payload is not valid JSON");
1832
+ }
1833
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("workbuddy: at-rest key payload is not an object");
1834
+ const secret = payload["atRestSecretKey"];
1835
+ if (typeof secret !== "string" || secret === "") throw new Error("workbuddy: at-rest key payload carries no atRestSecretKey");
1836
+ return createHash("sha256").update(secret, "utf8").digest();
1837
+ }
1838
+ /**
1839
+ * Open one encrypted field with a derived key and return its plaintext.
1840
+ *
1841
+ * Throws when the envelope is malformed, belongs to another key, or fails
1842
+ * authentication — a GCM tag mismatch is the signal that the transcript or the
1843
+ * key is wrong, and it must never degrade into a truncated token.
1844
+ */
1845
+ function openEncryptedField(field, key) {
1846
+ let envelope;
1847
+ try {
1848
+ envelope = JSON.parse(Buffer.from(field.envelope, "base64").toString("utf8"));
1849
+ } catch {
1850
+ throw new Error("workbuddy: encrypted field envelope is not valid JSON");
1851
+ }
1852
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) throw new Error("workbuddy: encrypted field envelope is not an object");
1853
+ const record = envelope;
1854
+ const suite = record["suite"];
1855
+ const keyId = record["keyId"];
1856
+ const nonce = record["nonce"];
1857
+ const authTag = record["authTag"];
1858
+ const ciphertext = record["ciphertext"];
1859
+ if (typeof suite !== "number" || typeof keyId !== "string") throw new Error("workbuddy: encrypted field envelope is missing suite or keyId");
1860
+ if (typeof nonce !== "string" || typeof authTag !== "string" || typeof ciphertext !== "string") throw new Error("workbuddy: encrypted field envelope is missing nonce, authTag or ciphertext");
1861
+ const expectedKeyId = deriveAtRestKeyId(key);
1862
+ if (keyId !== expectedKeyId) throw new Error(`workbuddy: encrypted field belongs to key ${keyId}, not the available key ${expectedKeyId}`);
1863
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "base64"), { authTagLength: 16 });
1864
+ decipher.setAAD(fieldAad(keyId, suite));
1865
+ decipher.setAuthTag(Buffer.from(authTag, "base64"));
1866
+ return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]).toString("utf8");
1867
+ }
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
+ /**
1886
+ * The executable inside a macOS app bundle, read from the bundle's own
1887
+ * `Info.plist`.
1888
+ *
1889
+ * The binary is NOT reliably named after the app: the WorkBuddy bundles ship
1890
+ * with `CFBundleExecutable` set to `Electron`, so a path assembled as
1891
+ * `<bundle>/Contents/MacOS/WorkBuddy` does not exist and the app looks absent
1892
+ * even when it is installed in the default location. Because the bundle
1893
+ * documents the real name, asking it is both correct and robust to a future
1894
+ * build that renames the binary.
1895
+ *
1896
+ * Returns undefined when the plist is absent, unreadable, or carries no usable
1897
+ * name — never a guessed path, so a caller can keep probing.
1898
+ */
1899
+ function macosBundleExecutable(bundle) {
1900
+ let plist;
1901
+ try {
1902
+ plist = readFileSync(join(bundle, "Contents", "Info.plist"), "utf8");
1903
+ } catch {
1904
+ return;
1905
+ }
1906
+ const name = /<key>\s*CFBundleExecutable\s*<\/key>\s*<string>([^<]*)<\/string>/u.exec(plist)?.[1]?.trim();
1907
+ if (name === void 0 || name === "" || name.includes("/") || name.includes("\\") || name === "." || name === "..") return;
1908
+ return join(bundle, "Contents", "MacOS", name);
1909
+ }
1910
+ /**
1911
+ * Windows install locations recorded by the app's own uninstaller.
1912
+ *
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.
1920
+ *
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.
1924
+ *
1925
+ * Returns [] on any failure — a missing registry key is the normal case on
1926
+ * non-Windows, not an error.
1927
+ */
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
+ }
1975
+ }
1976
+ return out;
1977
+ }
1978
+ /**
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`.
1981
+ *
1982
+ * Drive enumeration matters because installing to a non-system drive is common
1983
+ * on Windows and no environment variable points there.
1984
+ */
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;
2003
+ }
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;
2019
+ }
2020
+ /**
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.
2036
+ */
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 !== "");
2044
+ }
2045
+ /**
2046
+ * Ask the installed desktop app for its key payload by running its own binary
2047
+ * as plain Node (`ELECTRON_RUN_AS_NODE`) and calling the native binding.
2048
+ *
2049
+ * The binding is the app's own public surface for this value, so the plugin
2050
+ * never has to carry a copy of a build-specific constant: it asks the very
2051
+ * build that wrote the file. The child is given no stdin and a hard timeout,
2052
+ * and its stdout is the only thing read.
2053
+ */
2054
+ function fetchAtRestKeyPayload(executable) {
2055
+ return new Promise((resolve, reject) => {
2056
+ execFile(executable, ["-e", "try{process.stdout.write(process._linkedBinding('electron_browser_workbuddy_storage').loggerGet())}catch(e){process.exitCode=3;process.stderr.write(String(e&&e.message||e))}"], {
2057
+ env: {
2058
+ ...process.env,
2059
+ ELECTRON_RUN_AS_NODE: "1"
2060
+ },
2061
+ timeout: KEY_FETCH_TIMEOUT_MS,
2062
+ windowsHide: true,
2063
+ maxBuffer: 1048576
2064
+ }, (error, stdout, stderr) => {
2065
+ if (error !== null) {
2066
+ reject(/* @__PURE__ */ new Error(`workbuddy: the desktop app did not provide its at-rest key (${stderr.trim() || error.message})`));
2067
+ return;
2068
+ }
2069
+ const payload = stdout.trim();
2070
+ if (payload === "") {
2071
+ reject(/* @__PURE__ */ new Error("workbuddy: the desktop app returned an empty at-rest key payload"));
2072
+ return;
2073
+ }
2074
+ resolve(payload);
2075
+ });
2076
+ });
2077
+ }
2078
+ /**
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.
2088
+ */
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();
2139
+ })().finally(() => {
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;
2166
+ });
2167
+ }
2168
+ //#endregion
1684
2169
  //#region src/accounts.ts
1685
2170
  /**
1686
2171
  * Account pool: discovers every WorkBuddy credential snapshot the desktop app
@@ -1728,11 +2213,81 @@ function optionalString(value) {
1728
2213
  return typeof value === "string" && value !== "" ? value : void 0;
1729
2214
  }
1730
2215
  /**
2216
+ * Read one string-valued field that may arrive as a plain string (older builds)
2217
+ * or as the desktop app's `$wbEncrypted` envelope. The app started encrypting
2218
+ * `accessToken` / `refreshToken` / `nickname` in 5.6.0 on BOTH macOS and Windows
2219
+ * — the earlier "Windows first" reading was wrong, and it is why a signed-in Mac
2220
+ * showed no account at all: the value is an object, `typeof === 'string'` failed,
2221
+ * and the parser reported "no credential" for a perfectly good sign-in.
2222
+ *
2223
+ * `decrypt` is injected rather than called here so this parser stays synchronous
2224
+ * and testable; the async key fetch lives in `readCredential`. A field that IS
2225
+ * encrypted but could not be opened is reported as `failed` rather than as an
2226
+ * empty string — the caller must tell the user the app is missing or unreachable,
2227
+ * not send them to sign in again (the one action that cannot help).
2228
+ */
2229
+ /**
2230
+ * Marker for "the credential is encrypted and we could not obtain the key".
2231
+ *
2232
+ * Carried as a `code` rather than left to `instanceof` because the value crosses
2233
+ * the packaged-plugin boundary; the same convention the sibling error types use.
2234
+ * This is deliberately NOT "not signed in": the user IS signed in, and telling
2235
+ * them to sign in again sends them to the one action that cannot help.
2236
+ */
2237
+ const ENCRYPTED_CREDENTIAL_CODE = "ENCRYPTED_CREDENTIAL";
2238
+ var WorkBuddyEncryptedCredentialError = class extends Error {
2239
+ code = ENCRYPTED_CREDENTIAL_CODE;
2240
+ constructor(sourcePath) {
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.`);
2242
+ this.name = "WorkBuddyEncryptedCredentialError";
2243
+ }
2244
+ };
2245
+ /** True when a thrown value is the encrypted-credential marker (cross-bundle safe). */
2246
+ function isEncryptedCredentialError(value) {
2247
+ return typeof value === "object" && value !== null && value.code === "ENCRYPTED_CREDENTIAL";
2248
+ }
2249
+ function decryptableString(value, decrypt) {
2250
+ if (typeof value === "string") return {
2251
+ value,
2252
+ encrypted: false,
2253
+ failed: false
2254
+ };
2255
+ if (isEncryptedFieldWrapper(value)) {
2256
+ if (decrypt === void 0) return {
2257
+ value: "",
2258
+ encrypted: true,
2259
+ failed: true
2260
+ };
2261
+ try {
2262
+ return {
2263
+ value: decrypt(value),
2264
+ encrypted: true,
2265
+ failed: false
2266
+ };
2267
+ } catch {
2268
+ return {
2269
+ value: "",
2270
+ encrypted: true,
2271
+ failed: true
2272
+ };
2273
+ }
2274
+ }
2275
+ return {
2276
+ value: "",
2277
+ encrypted: false,
2278
+ failed: false
2279
+ };
2280
+ }
2281
+ /**
1731
2282
  * Parse a WorkBuddy auth document. Accepts the nested desktop shape
1732
2283
  * `{"auth":{...},"account":{...}}` and the flat panel shape; returns undefined
1733
2284
  * when there is no usable access token.
2285
+ *
2286
+ * `decrypt` opens the desktop app's `$wbEncrypted` field wrapper (5.6.0+, both
2287
+ * platforms). Absent means "plain-string builds only", which is what every
2288
+ * caller without an at-rest key should pass.
1734
2289
  */
1735
- function parseWorkBuddyAuth(text, sourcePath) {
2290
+ function parseWorkBuddyAuth(text, sourcePath, decrypt) {
1736
2291
  let parsed;
1737
2292
  try {
1738
2293
  parsed = JSON.parse(text);
@@ -1750,18 +2305,20 @@ function parseWorkBuddyAuth(text, sourcePath) {
1750
2305
  auth = document;
1751
2306
  identity = document;
1752
2307
  }
1753
- const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
2308
+ const accessField = decryptableString(auth["accessToken"], decrypt);
2309
+ if (accessField.encrypted && accessField.failed) throw new WorkBuddyEncryptedCredentialError(sourcePath);
2310
+ const accessToken = accessField.value;
1754
2311
  if (accessToken === "") return void 0;
1755
2312
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
1756
2313
  if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
1757
2314
  const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
1758
2315
  return {
1759
2316
  accessToken,
1760
- refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
2317
+ refreshToken: decryptableString(auth["refreshToken"], decrypt).value,
1761
2318
  expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
1762
2319
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
1763
2320
  ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
1764
- ...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
2321
+ ...optionalString(decryptableString(identity["nickname"], decrypt).value) === void 0 ? {} : { nickname: optionalString(decryptableString(identity["nickname"], decrypt).value) },
1765
2322
  ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
1766
2323
  ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
1767
2324
  ...optionalString(identity["enterpriseId"]) === void 0 ? {} : { enterpriseId: optionalString(identity["enterpriseId"]) },
@@ -1843,11 +2400,37 @@ async function authFilesIn(dir) {
1843
2400
  return files.map((name) => join(dir, name));
1844
2401
  }
1845
2402
  async function readCredential(path) {
2403
+ let text;
1846
2404
  try {
1847
- return parseWorkBuddyAuth(await readFile(path, "utf8"), path);
2405
+ text = await readFile(path, "utf8");
1848
2406
  } catch {
1849
2407
  return;
1850
2408
  }
2409
+ const decrypt = text.includes("\"$wbEncrypted\"") ? await encryptedFieldOpener() : void 0;
2410
+ try {
2411
+ return parseWorkBuddyAuth(text, path, decrypt);
2412
+ } catch (error) {
2413
+ if (isEncryptedCredentialError(error)) throw error;
2414
+ return;
2415
+ }
2416
+ }
2417
+ /**
2418
+ * Build the field opener, or undefined when the app cannot supply its key.
2419
+ *
2420
+ * Split out so the key lookup is testable without a real desktop install, and so
2421
+ * a lookup failure degrades to "encrypted, unopenable" rather than to a parse
2422
+ * error that would look like a corrupt file.
2423
+ */
2424
+ async function encryptedFieldOpener() {
2425
+ await readAtRestKey().catch(() => void 0);
2426
+ return (field) => {
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");
2432
+ return openEncryptedField(field, key);
2433
+ };
1851
2434
  }
1852
2435
  /** Every directory the pool should scan, in probe order. */
1853
2436
  function candidateAuthDirs(env = process.env) {
@@ -2637,10 +3220,10 @@ function createWorkBuddyAdapter(options) {
2637
3220
  streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
2638
3221
  retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
2639
3222
  configuredMaxTokens: /* @__PURE__ */ new Map(),
3223
+ modelErrors: /* @__PURE__ */ new Map(),
2640
3224
  ...REQUEST_IMAGE_BUDGETS,
2641
3225
  piProvider: provider
2642
3226
  };
2643
- Object.assign(profile, { modelErrors: /* @__PURE__ */ new Map() });
2644
3227
  let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
2645
3228
  return {
2646
3229
  providerId,
@@ -3165,10 +3748,37 @@ function diffEarnings(before, after) {
3165
3748
  accounts
3166
3749
  };
3167
3750
  }
3168
- function dayKey(date) {
3169
- const month = String(date.getMonth() + 1).padStart(2, "0");
3170
- const day = String(date.getDate()).padStart(2, "0");
3171
- return `${date.getFullYear()}-${month}-${day}`;
3751
+ /**
3752
+ * The timezone every hour in this file is interpreted in.
3753
+ *
3754
+ * The activity windows these jobs target are defined in Beijing time, but the
3755
+ * scheduler used `getHours()`, which answers in the host machine's local zone.
3756
+ * On a machine set to anything else, "09:00" was 09:00 local — a check-in that
3757
+ * simply never came due. Naming the zone makes the hour mean the same instant
3758
+ * everywhere DSH runs, and is also what lets a test pin the behaviour.
3759
+ */
3760
+ const AUTOMATION_TIME_ZONE = "Asia/Shanghai";
3761
+ /** Calendar parts of `date` in `timeZone`, all as zero-padded strings. */
3762
+ function zonedParts(date, timeZone = AUTOMATION_TIME_ZONE) {
3763
+ const parts = new Intl.DateTimeFormat("en-CA", {
3764
+ timeZone,
3765
+ year: "numeric",
3766
+ month: "2-digit",
3767
+ day: "2-digit",
3768
+ hour: "2-digit",
3769
+ hourCycle: "h23"
3770
+ }).formatToParts(date);
3771
+ const pick = (type) => parts.find((part) => part.type === type)?.value ?? "00";
3772
+ return {
3773
+ year: pick("year"),
3774
+ month: pick("month"),
3775
+ day: pick("day"),
3776
+ hour: pick("hour")
3777
+ };
3778
+ }
3779
+ function dayKey(date, timeZone = AUTOMATION_TIME_ZONE) {
3780
+ const { year, month, day } = zonedParts(date, timeZone);
3781
+ return `${year}-${month}-${day}`;
3172
3782
  }
3173
3783
  /**
3174
3784
  * The scheduled SLOT `date` falls in, as `YYYY-MM-DDTHH`.
@@ -3177,8 +3787,9 @@ function dayKey(date) {
3177
3787
  * for several hours runs in each of them while a second tick inside the same
3178
3788
  * hour is still refused.
3179
3789
  */
3180
- function slotKey(date) {
3181
- return `${dayKey(date)}T${String(date.getHours()).padStart(2, "0")}`;
3790
+ function slotKey(date, timeZone = AUTOMATION_TIME_ZONE) {
3791
+ const { hour } = zonedParts(date, timeZone);
3792
+ return `${dayKey(date, timeZone)}T${hour}`;
3182
3793
  }
3183
3794
  /**
3184
3795
  * Whether `now`'s local hour is one of `hours`.
@@ -3190,7 +3801,8 @@ function slotKey(date) {
3190
3801
  * moment it wakes, on the same day.
3191
3802
  */
3192
3803
  function isFireHour(now, hours) {
3193
- return hours.includes(now.getHours());
3804
+ const { hour } = zonedParts(now);
3805
+ return hours.includes(Number(hour));
3194
3806
  }
3195
3807
  /** Whether this account may be used: not switched off, not cooling. */
3196
3808
  function eligible(account, now) {
@@ -3453,6 +4065,22 @@ var WorkBuddyScheduler = class {
3453
4065
  * rate-limits per account, so overlapping passes would only trip that limit.
3454
4066
  * A job that throws is recorded and the loop continues.
3455
4067
  */
4068
+ /**
4069
+ * Whether `kind` is due at `now`: its earliest configured hour has passed in
4070
+ * the scheduling timezone, and no hour of today has been consumed yet.
4071
+ *
4072
+ * Hours are consumed per SLOT (one entry per configured hour), so a job with
4073
+ * two hours still runs twice a day — but a job whose hour passed while DSH was
4074
+ * closed runs immediately on the next tick instead of waiting for tomorrow.
4075
+ */
4076
+ isDue(kind, now) {
4077
+ const hours = this.hoursOf(kind);
4078
+ if (hours.length === 0) return false;
4079
+ const { hour } = zonedParts(now);
4080
+ const current = Number(hour);
4081
+ const today = dayKey(now);
4082
+ return hours.some((candidate) => candidate <= current && this.states[kind].lastRunSlot !== `${today}T${String(candidate).padStart(2, "0")}`);
4083
+ }
3456
4084
  async tick() {
3457
4085
  if (!this.enabled || this.stopped || this.busy) return;
3458
4086
  this.busy = true;
@@ -3463,7 +4091,7 @@ var WorkBuddyScheduler = class {
3463
4091
  for (const kind of JOB_KINDS) {
3464
4092
  if (this.stopped) return;
3465
4093
  if (this.states[kind].lastRunSlot === slot) continue;
3466
- if (!isFireHour(now, this.hoursOf(kind))) continue;
4094
+ if (!this.isDue(kind, now)) continue;
3467
4095
  await this.runJob(kind, today);
3468
4096
  }
3469
4097
  } catch (error) {
@@ -5482,33 +6110,47 @@ function apply(ctx, config = {}) {
5482
6110
  * the browser; the catalog re-reads through `onChange` either way.
5483
6111
  */
5484
6112
  /**
5485
- * Write one key of the plugin's own settings section. Only ever called with the
5486
- * model-selection keys, the distribution, and the disabled-account list, so the
5487
- * settings file cannot be steered from the browser; the catalogs and the pool
5488
- * re-read through `onChange` either way.
6113
+ * Persist one settings key, then VERIFY it landed.
6114
+ *
6115
+ * The settings service resolves `set()` even when the write did not stick, so a
6116
+ * fire-and-forget call reports success while the file keeps the old value — and
6117
+ * the card then shows a value that silently reverts on the next read. That is
6118
+ * exactly the "I typed a reserve, reopened, and it still says 0" report: the
6119
+ * write was reported as saved but never reached the document. Every write now
6120
+ * awaits the setter and re-reads the document; a mismatch throws so the caller
6121
+ * surfaces a real error instead of claiming success.
5489
6122
  *
5490
- * `set` returns a promise, so a rejection must be caught explicitly: a bare
5491
- * `void write.call(...)` swallows it, and the card then looks like it saved
5492
- * while the value never reached the settings file.
6123
+ * `expected` is what the caller believes it just wrote. Comparison goes through
6124
+ * a JSON round-trip so key order cannot cause a false mismatch.
5493
6125
  */
5494
- const setSetting = (key, value) => {
6126
+ /**
6127
+ * Deep equality that ignores key order, used to verify a settings write.
6128
+ *
6129
+ * `JSON.stringify` is key-order sensitive, so comparing two equal objects whose
6130
+ * keys were inserted in a different order would report a false "not persisted"
6131
+ * failure — and a false failure on a write that DID land is as harmful as a
6132
+ * false success: it sends the user chasing a bug that is not there.
6133
+ */
6134
+ function stableJsonEqual(left, right) {
6135
+ return canonicalJson(left) === canonicalJson(right);
6136
+ }
6137
+ function canonicalJson(value) {
6138
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
6139
+ if (typeof value === "object" && value !== null) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
6140
+ return JSON.stringify(value);
6141
+ }
6142
+ const setSetting = async (key, value, expected) => {
5495
6143
  if (value === void 0) return;
5496
- const write = settingsService?.set;
5497
- if (write === void 0) {
5498
- ctx.logger.warn?.(`dsh-workbuddy-xdpool: no settings writer; ` + key + ` was not saved`);
5499
- return;
5500
- }
5501
- try {
5502
- const result = write.call(settingsService, key, value);
5503
- if (result !== void 0 && typeof result.then === "function") result.catch((error) => {
5504
- ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
5505
- });
5506
- } catch (error) {
5507
- ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
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 });
6147
+ if (expected !== void 0) {
6148
+ const stored = current()[key];
6149
+ if (!stableJsonEqual(stored, expected)) throw new Error(`settings field "${key}" was not persisted`);
5508
6150
  }
5509
6151
  };
5510
- core.scheduler.setEarningsPersistence((ledger) => {
5511
- setSetting("automationEarnings", ledger);
6152
+ core.scheduler.setEarningsPersistence(async (ledger) => {
6153
+ await setSetting("automationEarnings", ledger, ledger);
5512
6154
  });
5513
6155
  const storedLedger = current().automationEarnings;
5514
6156
  if (storedLedger !== void 0) core.scheduler.applyEarningsLedger(storedLedger);
@@ -5554,23 +6196,24 @@ function apply(ctx, config = {}) {
5554
6196
  shim: () => shimInfo("cn"),
5555
6197
  scheduler: () => core.scheduler.status(),
5556
6198
  runAutomation: (_job, _force) => core.scheduler.startRunAll(),
5557
- saveSelection: (region, selection) => {
5558
- setSetting(modelSelectionKeyFor(region), {
6199
+ saveSelection: async (region, selection) => {
6200
+ const payload = {
5559
6201
  ...selection.enabledModelIds === void 0 ? {} : { enabledModelIds: [...selection.enabledModelIds] },
5560
6202
  ...selection.imageModelIds === void 0 ? {} : { imageModelIds: [...selection.imageModelIds] },
5561
6203
  ...selection.contextBudgets === void 0 ? {} : { contextBudgets: { ...selection.contextBudgets } }
5562
- });
6204
+ };
6205
+ await setSetting(modelSelectionKeyFor(region), payload, payload);
5563
6206
  },
5564
- setAccountDisabled: (accountId, disabled) => {
6207
+ setAccountDisabled: async (accountId, disabled) => {
5565
6208
  const currentIds = current().disabledAccountIds ?? [];
5566
6209
  const next = disabled ? currentIds.includes(accountId) ? currentIds : [...currentIds, accountId] : currentIds.filter((id) => id !== accountId);
5567
- setSetting("disabledAccountIds", next);
6210
+ await setSetting("disabledAccountIds", next, next);
5568
6211
  },
5569
- setCreditReserve: (accountId, reserve) => {
6212
+ setCreditReserve: async (accountId, reserve) => {
5570
6213
  const next = { ...current().creditReserves ?? {} };
5571
6214
  if (reserve > 0) next[accountId] = reserve;
5572
6215
  else delete next[accountId];
5573
- setSetting("creditReserves", next);
6216
+ await setSetting("creditReserves", next, next);
5574
6217
  }
5575
6218
  }));
5576
6219
  api = {