dsh-workbuddy-xdpool 1.4.0 → 1.5.2
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 +122 -0
- package/lib/bin.js +525 -27
- package/lib/client.js +89 -30
- package/lib/index.d.ts +15 -2
- package/lib/index.js +574 -46
- package/package.json +1 -1
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 } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync } 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,375 @@ 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
|
+
/** File name of the WorkBuddy desktop executable on Windows. */
|
|
1753
|
+
const APP_EXECUTABLE_NAME = "WorkBuddy.exe";
|
|
1754
|
+
/**
|
|
1755
|
+
* macOS bundles the desktop app may be installed as, in probe order.
|
|
1756
|
+
*
|
|
1757
|
+
* `WorkBuddy.app` is the domestic build; `WorkBuddy AI.app` is the
|
|
1758
|
+
* international one, and a machine may carry either or both. The user-level
|
|
1759
|
+
* `~/Applications` location is included because macOS lets an app live there,
|
|
1760
|
+
* and installs have been observed under a subdirectory of /Applications too —
|
|
1761
|
+
* hence {@link findWorkbuddyAppExecutable}'s parent scan, which covers those
|
|
1762
|
+
* without guessing any particular folder name.
|
|
1763
|
+
*/
|
|
1764
|
+
const MACOS_APP_BUNDLE_NAMES = ["WorkBuddy.app", "WorkBuddy AI.app"];
|
|
1765
|
+
function encodeUint32(value) {
|
|
1766
|
+
const bytes = Buffer.allocUnsafe(4);
|
|
1767
|
+
bytes.writeUInt32BE(value);
|
|
1768
|
+
return bytes;
|
|
1769
|
+
}
|
|
1770
|
+
/** Length-prefixed UTF-8 string: uint32 big-endian length followed by the bytes. */
|
|
1771
|
+
function encodeLengthPrefixed(value) {
|
|
1772
|
+
const bytes = Buffer.from(value, "utf8");
|
|
1773
|
+
return Buffer.concat([encodeUint32(bytes.length), bytes]);
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* The authenticated additional data for one `sym-v1` FIELD-framed envelope.
|
|
1777
|
+
*
|
|
1778
|
+
* Only the field framing is implemented: it is the shape the desktop app uses
|
|
1779
|
+
* for credential fields, and it is also the shape that cannot be confused with
|
|
1780
|
+
* a whole-file envelope, so an unexpected framing is a parse error rather than
|
|
1781
|
+
* a silently wrong transcript.
|
|
1782
|
+
*/
|
|
1783
|
+
function fieldAad(keyId, suite, scheme = SYMMETRIC_SCHEME) {
|
|
1784
|
+
if (!/^[0-9a-f]{16}$/u.test(keyId)) throw new Error(`workbuddy: envelope keyId is malformed`);
|
|
1785
|
+
return Buffer.concat([
|
|
1786
|
+
AAD_DOMAIN,
|
|
1787
|
+
Buffer.from([1]),
|
|
1788
|
+
encodeLengthPrefixed(STANDARD_FORMAT_ID["field"]),
|
|
1789
|
+
encodeLengthPrefixed(scheme),
|
|
1790
|
+
encodeUint32(suite),
|
|
1791
|
+
encodeLengthPrefixed(keyId),
|
|
1792
|
+
Buffer.from([FRAMING_CODE["field"]]),
|
|
1793
|
+
Buffer.from([0]),
|
|
1794
|
+
Buffer.from([0])
|
|
1795
|
+
]);
|
|
1796
|
+
}
|
|
1797
|
+
/** Whether a value is the app's encrypted-field wrapper. */
|
|
1798
|
+
function isEncryptedFieldWrapper(value) {
|
|
1799
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1800
|
+
const wrapper = value;
|
|
1801
|
+
const keys = Object.keys(wrapper).sort();
|
|
1802
|
+
return keys.length === 2 && keys[0] === "$wbEncrypted" && keys[1] === "envelope" && wrapper["$wbEncrypted"] === 1 && typeof wrapper["envelope"] === "string";
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* The at-rest key id for a derived 32-byte key: the first 16 hex characters of
|
|
1806
|
+
* its SHA-256. This is what the envelope's `keyId` is checked against, so a
|
|
1807
|
+
* mismatched key fails loudly instead of returning garbage.
|
|
1808
|
+
*/
|
|
1809
|
+
function deriveAtRestKeyId(key) {
|
|
1810
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Derive the 32-byte field key from the app's key payload JSON.
|
|
1814
|
+
*
|
|
1815
|
+
* The app hashes the payload's base64 STRING — not its decoded bytes — so the
|
|
1816
|
+
* same spelling is required here; hashing the decoded secret would produce a
|
|
1817
|
+
* different key and every field would fail to open.
|
|
1818
|
+
*/
|
|
1819
|
+
function deriveAtRestKey(payloadJson) {
|
|
1820
|
+
let payload;
|
|
1821
|
+
try {
|
|
1822
|
+
payload = JSON.parse(payloadJson);
|
|
1823
|
+
} catch {
|
|
1824
|
+
throw new Error("workbuddy: at-rest key payload is not valid JSON");
|
|
1825
|
+
}
|
|
1826
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("workbuddy: at-rest key payload is not an object");
|
|
1827
|
+
const secret = payload["atRestSecretKey"];
|
|
1828
|
+
if (typeof secret !== "string" || secret === "") throw new Error("workbuddy: at-rest key payload carries no atRestSecretKey");
|
|
1829
|
+
return createHash("sha256").update(secret, "utf8").digest();
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Open one encrypted field with a derived key and return its plaintext.
|
|
1833
|
+
*
|
|
1834
|
+
* Throws when the envelope is malformed, belongs to another key, or fails
|
|
1835
|
+
* authentication — a GCM tag mismatch is the signal that the transcript or the
|
|
1836
|
+
* key is wrong, and it must never degrade into a truncated token.
|
|
1837
|
+
*/
|
|
1838
|
+
function openEncryptedField(field, key) {
|
|
1839
|
+
let envelope;
|
|
1840
|
+
try {
|
|
1841
|
+
envelope = JSON.parse(Buffer.from(field.envelope, "base64").toString("utf8"));
|
|
1842
|
+
} catch {
|
|
1843
|
+
throw new Error("workbuddy: encrypted field envelope is not valid JSON");
|
|
1844
|
+
}
|
|
1845
|
+
if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) throw new Error("workbuddy: encrypted field envelope is not an object");
|
|
1846
|
+
const record = envelope;
|
|
1847
|
+
const suite = record["suite"];
|
|
1848
|
+
const keyId = record["keyId"];
|
|
1849
|
+
const nonce = record["nonce"];
|
|
1850
|
+
const authTag = record["authTag"];
|
|
1851
|
+
const ciphertext = record["ciphertext"];
|
|
1852
|
+
if (typeof suite !== "number" || typeof keyId !== "string") throw new Error("workbuddy: encrypted field envelope is missing suite or keyId");
|
|
1853
|
+
if (typeof nonce !== "string" || typeof authTag !== "string" || typeof ciphertext !== "string") throw new Error("workbuddy: encrypted field envelope is missing nonce, authTag or ciphertext");
|
|
1854
|
+
const expectedKeyId = deriveAtRestKeyId(key);
|
|
1855
|
+
if (keyId !== expectedKeyId) throw new Error(`workbuddy: encrypted field belongs to key ${keyId}, not the available key ${expectedKeyId}`);
|
|
1856
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "base64"), { authTagLength: 16 });
|
|
1857
|
+
decipher.setAAD(fieldAad(keyId, suite));
|
|
1858
|
+
decipher.setAuthTag(Buffer.from(authTag, "base64"));
|
|
1859
|
+
return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]).toString("utf8");
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* The executable inside a macOS app bundle, read from the bundle's own
|
|
1863
|
+
* `Info.plist`.
|
|
1864
|
+
*
|
|
1865
|
+
* The binary is NOT reliably named after the app: the WorkBuddy bundles ship
|
|
1866
|
+
* with `CFBundleExecutable` set to `Electron`, so a path assembled as
|
|
1867
|
+
* `<bundle>/Contents/MacOS/WorkBuddy` does not exist and the app looks absent
|
|
1868
|
+
* even when it is installed in the default location. Because the bundle
|
|
1869
|
+
* documents the real name, asking it is both correct and robust to a future
|
|
1870
|
+
* build that renames the binary.
|
|
1871
|
+
*
|
|
1872
|
+
* Returns undefined when the plist is absent, unreadable, or carries no usable
|
|
1873
|
+
* name — never a guessed path, so a caller can keep probing.
|
|
1874
|
+
*/
|
|
1875
|
+
function macosBundleExecutable(bundle) {
|
|
1876
|
+
let plist;
|
|
1877
|
+
try {
|
|
1878
|
+
plist = readFileSync(join(bundle, "Contents", "Info.plist"), "utf8");
|
|
1879
|
+
} catch {
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
const name = /<key>\s*CFBundleExecutable\s*<\/key>\s*<string>([^<]*)<\/string>/u.exec(plist)?.[1]?.trim();
|
|
1883
|
+
if (name === void 0 || name === "" || name.includes("/") || name.includes("\\") || name === "." || name === "..") return;
|
|
1884
|
+
return join(bundle, "Contents", "MacOS", name);
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Candidate paths of the WorkBuddy desktop executable, in probe order.
|
|
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`).
|
|
1917
|
+
*
|
|
1918
|
+
* Used to CONFIRM that a discovered bundle really is WorkBuddy before it is
|
|
1919
|
+
* launched. This matters because the discovery below scans directories and then
|
|
1920
|
+
* execs what it finds: every Electron app is built around a binary called
|
|
1921
|
+
* `Electron`, so a name-only match could pick a different product's bundle and
|
|
1922
|
+
* run it. The identifier is the app's own claim about itself, so it is the
|
|
1923
|
+
* check that makes the scan safe.
|
|
1924
|
+
*/
|
|
1925
|
+
const APP_BUNDLE_IDENTIFIER_PREFIXES = ["com.tencent.workbuddy", "com.workbuddy"];
|
|
1926
|
+
/**
|
|
1927
|
+
* Whether a bundle identifies itself as the WorkBuddy desktop app.
|
|
1928
|
+
*
|
|
1929
|
+
* The match is on dot boundaries, so a hypothetical `com.workbuddyish` cannot
|
|
1930
|
+
* pass as `com.workbuddy`.
|
|
1931
|
+
*
|
|
1932
|
+
* An unreadable or identifier-less plist is treated as NOT WorkBuddy: refusing
|
|
1933
|
+
* a candidate only costs a fallback to another path, whereas accepting the
|
|
1934
|
+
* wrong one would execute an unrelated application.
|
|
1935
|
+
*/
|
|
1936
|
+
function isWorkbuddyBundle(bundle) {
|
|
1937
|
+
let plist;
|
|
1938
|
+
try {
|
|
1939
|
+
plist = readFileSync(join(bundle, "Contents", "Info.plist"), "utf8");
|
|
1940
|
+
} catch {
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
const identifier = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>([^<]*)<\/string>/u.exec(plist)?.[1]?.trim().toLowerCase();
|
|
1944
|
+
if (identifier === void 0 || identifier === "") return false;
|
|
1945
|
+
return APP_BUNDLE_IDENTIFIER_PREFIXES.some((prefix) => identifier === prefix || identifier.startsWith(`${prefix}.`));
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Bundles of the desktop app found one level BELOW a macOS applications
|
|
1949
|
+
* directory.
|
|
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.
|
|
1956
|
+
*
|
|
1957
|
+
* Returns [] when the parent is absent or unreadable — a missing directory is
|
|
1958
|
+
* the normal case, not an error.
|
|
1959
|
+
*/
|
|
1960
|
+
function macosNestedAppBundles(parent) {
|
|
1961
|
+
let entries;
|
|
1962
|
+
try {
|
|
1963
|
+
entries = readdirSync(parent);
|
|
1964
|
+
} catch {
|
|
1965
|
+
return [];
|
|
1966
|
+
}
|
|
1967
|
+
const bundles = [];
|
|
1968
|
+
for (const entry of entries) {
|
|
1969
|
+
const nested = join(parent, entry);
|
|
1970
|
+
for (const name of MACOS_APP_BUNDLE_NAMES) {
|
|
1971
|
+
const bundle = join(nested, name);
|
|
1972
|
+
try {
|
|
1973
|
+
if (!statSync(bundle).isDirectory()) continue;
|
|
1974
|
+
} catch {
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
if (isWorkbuddyBundle(bundle)) bundles.push(bundle);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
return bundles;
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* The first candidate that exists as a file, or undefined when the desktop app
|
|
1984
|
+
* is not installed where this platform expects it.
|
|
1985
|
+
*/
|
|
1986
|
+
function findWorkbuddyAppExecutable(platform = process.platform, home = homedir(), env = process.env) {
|
|
1987
|
+
for (const candidate of workbuddyAppExecutableCandidates(platform, home, env)) try {
|
|
1988
|
+
if (existsSync(candidate)) return candidate;
|
|
1989
|
+
} catch {}
|
|
1990
|
+
if (platform === "darwin") for (const parent of ["/Applications", join(home, "Applications")]) for (const bundle of macosNestedAppBundles(parent)) {
|
|
1991
|
+
const executable = macosBundleExecutable(bundle);
|
|
1992
|
+
if (executable === void 0) continue;
|
|
1993
|
+
try {
|
|
1994
|
+
if (existsSync(executable)) return executable;
|
|
1995
|
+
} catch {}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Ask the installed desktop app for its key payload by running its own binary
|
|
2000
|
+
* as plain Node (`ELECTRON_RUN_AS_NODE`) and calling the native binding.
|
|
2001
|
+
*
|
|
2002
|
+
* The binding is the app's own public surface for this value, so the plugin
|
|
2003
|
+
* never has to carry a copy of a build-specific constant: it asks the very
|
|
2004
|
+
* build that wrote the file. The child is given no stdin and a hard timeout,
|
|
2005
|
+
* and its stdout is the only thing read.
|
|
2006
|
+
*/
|
|
2007
|
+
function fetchAtRestKeyPayload(executable) {
|
|
2008
|
+
return new Promise((resolve, reject) => {
|
|
2009
|
+
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))}"], {
|
|
2010
|
+
env: {
|
|
2011
|
+
...process.env,
|
|
2012
|
+
ELECTRON_RUN_AS_NODE: "1"
|
|
2013
|
+
},
|
|
2014
|
+
timeout: KEY_FETCH_TIMEOUT_MS,
|
|
2015
|
+
windowsHide: true,
|
|
2016
|
+
maxBuffer: 1048576
|
|
2017
|
+
}, (error, stdout, stderr) => {
|
|
2018
|
+
if (error !== null) {
|
|
2019
|
+
reject(/* @__PURE__ */ new Error(`workbuddy: the desktop app did not provide its at-rest key (${stderr.trim() || error.message})`));
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
const payload = stdout.trim();
|
|
2023
|
+
if (payload === "") {
|
|
2024
|
+
reject(/* @__PURE__ */ new Error("workbuddy: the desktop app returned an empty at-rest key payload"));
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
resolve(payload);
|
|
2028
|
+
});
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
/** Process-lifetime cache of the derived key; never persisted. */
|
|
2032
|
+
let cachedKey;
|
|
2033
|
+
let inflightKey;
|
|
2034
|
+
/**
|
|
2035
|
+
* The desktop app's at-rest field key, or undefined when it cannot be obtained
|
|
2036
|
+
* (app not installed, an older build without the native module, or a future
|
|
2037
|
+
* build that rotates the payload). Cached after the first success so the app is
|
|
2038
|
+
* spawned at most once per process; a failure is retried on the next call,
|
|
2039
|
+
* because the user may install or start the app between reads.
|
|
2040
|
+
*/
|
|
2041
|
+
function readAtRestKey() {
|
|
2042
|
+
if (cachedKey !== void 0) return Promise.resolve(cachedKey);
|
|
2043
|
+
inflightKey ??= (async () => {
|
|
2044
|
+
const executable = findWorkbuddyAppExecutable();
|
|
2045
|
+
if (executable === void 0) return void 0;
|
|
2046
|
+
const key = deriveAtRestKey(await fetchAtRestKeyPayload(executable));
|
|
2047
|
+
cachedKey = key;
|
|
2048
|
+
return key;
|
|
2049
|
+
})().finally(() => {
|
|
2050
|
+
inflightKey = void 0;
|
|
2051
|
+
});
|
|
2052
|
+
return inflightKey;
|
|
2053
|
+
}
|
|
2054
|
+
//#endregion
|
|
1684
2055
|
//#region src/accounts.ts
|
|
1685
2056
|
/**
|
|
1686
2057
|
* Account pool: discovers every WorkBuddy credential snapshot the desktop app
|
|
@@ -1728,11 +2099,81 @@ function optionalString(value) {
|
|
|
1728
2099
|
return typeof value === "string" && value !== "" ? value : void 0;
|
|
1729
2100
|
}
|
|
1730
2101
|
/**
|
|
2102
|
+
* Read one string-valued field that may arrive as a plain string (older builds)
|
|
2103
|
+
* or as the desktop app's `$wbEncrypted` envelope. The app started encrypting
|
|
2104
|
+
* `accessToken` / `refreshToken` / `nickname` in 5.6.0 on BOTH macOS and Windows
|
|
2105
|
+
* — the earlier "Windows first" reading was wrong, and it is why a signed-in Mac
|
|
2106
|
+
* showed no account at all: the value is an object, `typeof === 'string'` failed,
|
|
2107
|
+
* and the parser reported "no credential" for a perfectly good sign-in.
|
|
2108
|
+
*
|
|
2109
|
+
* `decrypt` is injected rather than called here so this parser stays synchronous
|
|
2110
|
+
* and testable; the async key fetch lives in `readCredential`. A field that IS
|
|
2111
|
+
* encrypted but could not be opened is reported as `failed` rather than as an
|
|
2112
|
+
* empty string — the caller must tell the user the app is missing or unreachable,
|
|
2113
|
+
* not send them to sign in again (the one action that cannot help).
|
|
2114
|
+
*/
|
|
2115
|
+
/**
|
|
2116
|
+
* Marker for "the credential is encrypted and we could not obtain the key".
|
|
2117
|
+
*
|
|
2118
|
+
* Carried as a `code` rather than left to `instanceof` because the value crosses
|
|
2119
|
+
* the packaged-plugin boundary; the same convention the sibling error types use.
|
|
2120
|
+
* This is deliberately NOT "not signed in": the user IS signed in, and telling
|
|
2121
|
+
* them to sign in again sends them to the one action that cannot help.
|
|
2122
|
+
*/
|
|
2123
|
+
const ENCRYPTED_CREDENTIAL_CODE = "ENCRYPTED_CREDENTIAL";
|
|
2124
|
+
var WorkBuddyEncryptedCredentialError = class extends Error {
|
|
2125
|
+
code = ENCRYPTED_CREDENTIAL_CODE;
|
|
2126
|
+
constructor(sourcePath) {
|
|
2127
|
+
super(`workbuddy: ${sourcePath} holds encrypted credentials but the desktop app could not provide the key. Install the WorkBuddy desktop app (or point WORKBUDDY_APP_EXECUTABLE at it) — signing in again will not help.`);
|
|
2128
|
+
this.name = "WorkBuddyEncryptedCredentialError";
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
/** True when a thrown value is the encrypted-credential marker (cross-bundle safe). */
|
|
2132
|
+
function isEncryptedCredentialError(value) {
|
|
2133
|
+
return typeof value === "object" && value !== null && value.code === "ENCRYPTED_CREDENTIAL";
|
|
2134
|
+
}
|
|
2135
|
+
function decryptableString(value, decrypt) {
|
|
2136
|
+
if (typeof value === "string") return {
|
|
2137
|
+
value,
|
|
2138
|
+
encrypted: false,
|
|
2139
|
+
failed: false
|
|
2140
|
+
};
|
|
2141
|
+
if (isEncryptedFieldWrapper(value)) {
|
|
2142
|
+
if (decrypt === void 0) return {
|
|
2143
|
+
value: "",
|
|
2144
|
+
encrypted: true,
|
|
2145
|
+
failed: true
|
|
2146
|
+
};
|
|
2147
|
+
try {
|
|
2148
|
+
return {
|
|
2149
|
+
value: decrypt(value),
|
|
2150
|
+
encrypted: true,
|
|
2151
|
+
failed: false
|
|
2152
|
+
};
|
|
2153
|
+
} catch {
|
|
2154
|
+
return {
|
|
2155
|
+
value: "",
|
|
2156
|
+
encrypted: true,
|
|
2157
|
+
failed: true
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return {
|
|
2162
|
+
value: "",
|
|
2163
|
+
encrypted: false,
|
|
2164
|
+
failed: false
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
/**
|
|
1731
2168
|
* Parse a WorkBuddy auth document. Accepts the nested desktop shape
|
|
1732
2169
|
* `{"auth":{...},"account":{...}}` and the flat panel shape; returns undefined
|
|
1733
2170
|
* when there is no usable access token.
|
|
2171
|
+
*
|
|
2172
|
+
* `decrypt` opens the desktop app's `$wbEncrypted` field wrapper (5.6.0+, both
|
|
2173
|
+
* platforms). Absent means "plain-string builds only", which is what every
|
|
2174
|
+
* caller without an at-rest key should pass.
|
|
1734
2175
|
*/
|
|
1735
|
-
function parseWorkBuddyAuth(text, sourcePath) {
|
|
2176
|
+
function parseWorkBuddyAuth(text, sourcePath, decrypt) {
|
|
1736
2177
|
let parsed;
|
|
1737
2178
|
try {
|
|
1738
2179
|
parsed = JSON.parse(text);
|
|
@@ -1750,18 +2191,20 @@ function parseWorkBuddyAuth(text, sourcePath) {
|
|
|
1750
2191
|
auth = document;
|
|
1751
2192
|
identity = document;
|
|
1752
2193
|
}
|
|
1753
|
-
const
|
|
2194
|
+
const accessField = decryptableString(auth["accessToken"], decrypt);
|
|
2195
|
+
if (accessField.encrypted && accessField.failed) throw new WorkBuddyEncryptedCredentialError(sourcePath);
|
|
2196
|
+
const accessToken = accessField.value;
|
|
1754
2197
|
if (accessToken === "") return void 0;
|
|
1755
2198
|
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
|
|
1756
2199
|
if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
|
|
1757
2200
|
const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
|
|
1758
2201
|
return {
|
|
1759
2202
|
accessToken,
|
|
1760
|
-
refreshToken:
|
|
2203
|
+
refreshToken: decryptableString(auth["refreshToken"], decrypt).value,
|
|
1761
2204
|
expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
|
|
1762
2205
|
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
1763
2206
|
...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
|
|
1764
|
-
...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
|
|
2207
|
+
...optionalString(decryptableString(identity["nickname"], decrypt).value) === void 0 ? {} : { nickname: optionalString(decryptableString(identity["nickname"], decrypt).value) },
|
|
1765
2208
|
...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
|
|
1766
2209
|
...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
|
|
1767
2210
|
...optionalString(identity["enterpriseId"]) === void 0 ? {} : { enterpriseId: optionalString(identity["enterpriseId"]) },
|
|
@@ -1843,11 +2286,34 @@ async function authFilesIn(dir) {
|
|
|
1843
2286
|
return files.map((name) => join(dir, name));
|
|
1844
2287
|
}
|
|
1845
2288
|
async function readCredential(path) {
|
|
2289
|
+
let text;
|
|
1846
2290
|
try {
|
|
1847
|
-
|
|
2291
|
+
text = await readFile(path, "utf8");
|
|
1848
2292
|
} catch {
|
|
1849
2293
|
return;
|
|
1850
2294
|
}
|
|
2295
|
+
const decrypt = text.includes("\"$wbEncrypted\"") ? await encryptedFieldOpener() : void 0;
|
|
2296
|
+
try {
|
|
2297
|
+
return parseWorkBuddyAuth(text, path, decrypt);
|
|
2298
|
+
} catch (error) {
|
|
2299
|
+
if (isEncryptedCredentialError(error)) throw error;
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Build the field opener, or undefined when the app cannot supply its key.
|
|
2305
|
+
*
|
|
2306
|
+
* Split out so the key lookup is testable without a real desktop install, and so
|
|
2307
|
+
* a lookup failure degrades to "encrypted, unopenable" rather than to a parse
|
|
2308
|
+
* error that would look like a corrupt file.
|
|
2309
|
+
*/
|
|
2310
|
+
async function encryptedFieldOpener() {
|
|
2311
|
+
const key = await readAtRestKey().catch(() => void 0);
|
|
2312
|
+
if (key === void 0) return void 0;
|
|
2313
|
+
return (field) => {
|
|
2314
|
+
if (!isEncryptedFieldWrapper(field)) throw new Error("workbuddy: not an encrypted field wrapper");
|
|
2315
|
+
return openEncryptedField(field, key);
|
|
2316
|
+
};
|
|
1851
2317
|
}
|
|
1852
2318
|
/** Every directory the pool should scan, in probe order. */
|
|
1853
2319
|
function candidateAuthDirs(env = process.env) {
|
|
@@ -2368,8 +2834,8 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
2368
2834
|
{
|
|
2369
2835
|
id: "hy3",
|
|
2370
2836
|
name: "Hy3",
|
|
2371
|
-
contextWindow:
|
|
2372
|
-
maxOutputTokens:
|
|
2837
|
+
contextWindow: 32e3,
|
|
2838
|
+
maxOutputTokens: 8e3,
|
|
2373
2839
|
supportsImages: true
|
|
2374
2840
|
},
|
|
2375
2841
|
{
|
|
@@ -3165,10 +3631,37 @@ function diffEarnings(before, after) {
|
|
|
3165
3631
|
accounts
|
|
3166
3632
|
};
|
|
3167
3633
|
}
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3634
|
+
/**
|
|
3635
|
+
* The timezone every hour in this file is interpreted in.
|
|
3636
|
+
*
|
|
3637
|
+
* The activity windows these jobs target are defined in Beijing time, but the
|
|
3638
|
+
* scheduler used `getHours()`, which answers in the host machine's local zone.
|
|
3639
|
+
* On a machine set to anything else, "09:00" was 09:00 local — a check-in that
|
|
3640
|
+
* simply never came due. Naming the zone makes the hour mean the same instant
|
|
3641
|
+
* everywhere DSH runs, and is also what lets a test pin the behaviour.
|
|
3642
|
+
*/
|
|
3643
|
+
const AUTOMATION_TIME_ZONE = "Asia/Shanghai";
|
|
3644
|
+
/** Calendar parts of `date` in `timeZone`, all as zero-padded strings. */
|
|
3645
|
+
function zonedParts(date, timeZone = AUTOMATION_TIME_ZONE) {
|
|
3646
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
3647
|
+
timeZone,
|
|
3648
|
+
year: "numeric",
|
|
3649
|
+
month: "2-digit",
|
|
3650
|
+
day: "2-digit",
|
|
3651
|
+
hour: "2-digit",
|
|
3652
|
+
hourCycle: "h23"
|
|
3653
|
+
}).formatToParts(date);
|
|
3654
|
+
const pick = (type) => parts.find((part) => part.type === type)?.value ?? "00";
|
|
3655
|
+
return {
|
|
3656
|
+
year: pick("year"),
|
|
3657
|
+
month: pick("month"),
|
|
3658
|
+
day: pick("day"),
|
|
3659
|
+
hour: pick("hour")
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
function dayKey(date, timeZone = AUTOMATION_TIME_ZONE) {
|
|
3663
|
+
const { year, month, day } = zonedParts(date, timeZone);
|
|
3664
|
+
return `${year}-${month}-${day}`;
|
|
3172
3665
|
}
|
|
3173
3666
|
/**
|
|
3174
3667
|
* The scheduled SLOT `date` falls in, as `YYYY-MM-DDTHH`.
|
|
@@ -3177,8 +3670,9 @@ function dayKey(date) {
|
|
|
3177
3670
|
* for several hours runs in each of them while a second tick inside the same
|
|
3178
3671
|
* hour is still refused.
|
|
3179
3672
|
*/
|
|
3180
|
-
function slotKey(date) {
|
|
3181
|
-
|
|
3673
|
+
function slotKey(date, timeZone = AUTOMATION_TIME_ZONE) {
|
|
3674
|
+
const { hour } = zonedParts(date, timeZone);
|
|
3675
|
+
return `${dayKey(date, timeZone)}T${hour}`;
|
|
3182
3676
|
}
|
|
3183
3677
|
/**
|
|
3184
3678
|
* Whether `now`'s local hour is one of `hours`.
|
|
@@ -3190,7 +3684,8 @@ function slotKey(date) {
|
|
|
3190
3684
|
* moment it wakes, on the same day.
|
|
3191
3685
|
*/
|
|
3192
3686
|
function isFireHour(now, hours) {
|
|
3193
|
-
|
|
3687
|
+
const { hour } = zonedParts(now);
|
|
3688
|
+
return hours.includes(Number(hour));
|
|
3194
3689
|
}
|
|
3195
3690
|
/** Whether this account may be used: not switched off, not cooling. */
|
|
3196
3691
|
function eligible(account, now) {
|
|
@@ -3453,6 +3948,22 @@ var WorkBuddyScheduler = class {
|
|
|
3453
3948
|
* rate-limits per account, so overlapping passes would only trip that limit.
|
|
3454
3949
|
* A job that throws is recorded and the loop continues.
|
|
3455
3950
|
*/
|
|
3951
|
+
/**
|
|
3952
|
+
* Whether `kind` is due at `now`: its earliest configured hour has passed in
|
|
3953
|
+
* the scheduling timezone, and no hour of today has been consumed yet.
|
|
3954
|
+
*
|
|
3955
|
+
* Hours are consumed per SLOT (one entry per configured hour), so a job with
|
|
3956
|
+
* two hours still runs twice a day — but a job whose hour passed while DSH was
|
|
3957
|
+
* closed runs immediately on the next tick instead of waiting for tomorrow.
|
|
3958
|
+
*/
|
|
3959
|
+
isDue(kind, now) {
|
|
3960
|
+
const hours = this.hoursOf(kind);
|
|
3961
|
+
if (hours.length === 0) return false;
|
|
3962
|
+
const { hour } = zonedParts(now);
|
|
3963
|
+
const current = Number(hour);
|
|
3964
|
+
const today = dayKey(now);
|
|
3965
|
+
return hours.some((candidate) => candidate <= current && this.states[kind].lastRunSlot !== `${today}T${String(candidate).padStart(2, "0")}`);
|
|
3966
|
+
}
|
|
3456
3967
|
async tick() {
|
|
3457
3968
|
if (!this.enabled || this.stopped || this.busy) return;
|
|
3458
3969
|
this.busy = true;
|
|
@@ -3463,7 +3974,7 @@ var WorkBuddyScheduler = class {
|
|
|
3463
3974
|
for (const kind of JOB_KINDS) {
|
|
3464
3975
|
if (this.stopped) return;
|
|
3465
3976
|
if (this.states[kind].lastRunSlot === slot) continue;
|
|
3466
|
-
if (!
|
|
3977
|
+
if (!this.isDue(kind, now)) continue;
|
|
3467
3978
|
await this.runJob(kind, today);
|
|
3468
3979
|
}
|
|
3469
3980
|
} catch (error) {
|
|
@@ -4495,7 +5006,8 @@ function createWorkBuddyShim(options) {
|
|
|
4495
5006
|
logger,
|
|
4496
5007
|
client,
|
|
4497
5008
|
pool,
|
|
4498
|
-
maxAttempts
|
|
5009
|
+
maxAttempts,
|
|
5010
|
+
contextWindow: modelId === void 0 ? void 0 : catalog.current().find((m) => m.id === modelId)?.contextWindow
|
|
4499
5011
|
});
|
|
4500
5012
|
if (recovered.ok) {
|
|
4501
5013
|
await serveSuccessfulStream(res, recovered.account, recovered.result, logger, refreshBalance, pool);
|
|
@@ -4599,7 +5111,8 @@ async function recoverFromContextOverrun(options) {
|
|
|
4599
5111
|
detail: "request carried no usable messages"
|
|
4600
5112
|
};
|
|
4601
5113
|
const overrunTokens = estimateMessagesTokens(messages);
|
|
4602
|
-
const
|
|
5114
|
+
const realWindow = options.contextWindow;
|
|
5115
|
+
const budget = realWindow !== void 0 && realWindow > 0 ? Math.max(512, Math.floor(realWindow * .8) - 2048) : Math.max(512, Math.floor(overrunTokens / 2));
|
|
4603
5116
|
logger?.warn(`dsh-workbuddy-xdpool: context overrun on ${modelId ?? "(no model)"} (~${overrunTokens} tokens); compacting to ~${budget} and retrying once`);
|
|
4604
5117
|
let summary;
|
|
4605
5118
|
let compacted = messages;
|
|
@@ -5480,33 +5993,47 @@ function apply(ctx, config = {}) {
|
|
|
5480
5993
|
* the browser; the catalog re-reads through `onChange` either way.
|
|
5481
5994
|
*/
|
|
5482
5995
|
/**
|
|
5483
|
-
*
|
|
5484
|
-
*
|
|
5485
|
-
* settings
|
|
5486
|
-
*
|
|
5996
|
+
* Persist one settings key, then VERIFY it landed.
|
|
5997
|
+
*
|
|
5998
|
+
* The settings service resolves `set()` even when the write did not stick, so a
|
|
5999
|
+
* fire-and-forget call reports success while the file keeps the old value — and
|
|
6000
|
+
* the card then shows a value that silently reverts on the next read. That is
|
|
6001
|
+
* exactly the "I typed a reserve, reopened, and it still says 0" report: the
|
|
6002
|
+
* write was reported as saved but never reached the document. Every write now
|
|
6003
|
+
* awaits the setter and re-reads the document; a mismatch throws so the caller
|
|
6004
|
+
* surfaces a real error instead of claiming success.
|
|
6005
|
+
*
|
|
6006
|
+
* `expected` is what the caller believes it just wrote. Comparison goes through
|
|
6007
|
+
* a JSON round-trip so key order cannot cause a false mismatch.
|
|
6008
|
+
*/
|
|
6009
|
+
/**
|
|
6010
|
+
* Deep equality that ignores key order, used to verify a settings write.
|
|
5487
6011
|
*
|
|
5488
|
-
* `
|
|
5489
|
-
*
|
|
5490
|
-
*
|
|
6012
|
+
* `JSON.stringify` is key-order sensitive, so comparing two equal objects whose
|
|
6013
|
+
* keys were inserted in a different order would report a false "not persisted"
|
|
6014
|
+
* failure — and a false failure on a write that DID land is as harmful as a
|
|
6015
|
+
* false success: it sends the user chasing a bug that is not there.
|
|
5491
6016
|
*/
|
|
5492
|
-
|
|
6017
|
+
function stableJsonEqual(left, right) {
|
|
6018
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
6019
|
+
}
|
|
6020
|
+
function canonicalJson(value) {
|
|
6021
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
6022
|
+
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(",")}}`;
|
|
6023
|
+
return JSON.stringify(value);
|
|
6024
|
+
}
|
|
6025
|
+
const setSetting = async (key, value, expected) => {
|
|
5493
6026
|
if (value === void 0) return;
|
|
5494
6027
|
const write = settingsService?.set;
|
|
5495
|
-
if (write === void 0) {
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
const result = write.call(settingsService, key, value);
|
|
5501
|
-
if (result !== void 0 && typeof result.then === "function") result.catch((error) => {
|
|
5502
|
-
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
|
|
5503
|
-
});
|
|
5504
|
-
} catch (error) {
|
|
5505
|
-
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
|
|
6028
|
+
if (write === void 0) throw new Error(`settings service unavailable; ${key} was not saved`);
|
|
6029
|
+
await write.call(settingsService, key, value);
|
|
6030
|
+
if (expected !== void 0) {
|
|
6031
|
+
const stored = current()[key];
|
|
6032
|
+
if (!stableJsonEqual(stored, expected)) throw new Error(`settings field "${key}" was not persisted`);
|
|
5506
6033
|
}
|
|
5507
6034
|
};
|
|
5508
|
-
core.scheduler.setEarningsPersistence((ledger) => {
|
|
5509
|
-
setSetting("automationEarnings", ledger);
|
|
6035
|
+
core.scheduler.setEarningsPersistence(async (ledger) => {
|
|
6036
|
+
await setSetting("automationEarnings", ledger, ledger);
|
|
5510
6037
|
});
|
|
5511
6038
|
const storedLedger = current().automationEarnings;
|
|
5512
6039
|
if (storedLedger !== void 0) core.scheduler.applyEarningsLedger(storedLedger);
|
|
@@ -5552,23 +6079,24 @@ function apply(ctx, config = {}) {
|
|
|
5552
6079
|
shim: () => shimInfo("cn"),
|
|
5553
6080
|
scheduler: () => core.scheduler.status(),
|
|
5554
6081
|
runAutomation: (_job, _force) => core.scheduler.startRunAll(),
|
|
5555
|
-
saveSelection: (region, selection) => {
|
|
5556
|
-
|
|
6082
|
+
saveSelection: async (region, selection) => {
|
|
6083
|
+
const payload = {
|
|
5557
6084
|
...selection.enabledModelIds === void 0 ? {} : { enabledModelIds: [...selection.enabledModelIds] },
|
|
5558
6085
|
...selection.imageModelIds === void 0 ? {} : { imageModelIds: [...selection.imageModelIds] },
|
|
5559
6086
|
...selection.contextBudgets === void 0 ? {} : { contextBudgets: { ...selection.contextBudgets } }
|
|
5560
|
-
}
|
|
6087
|
+
};
|
|
6088
|
+
await setSetting(modelSelectionKeyFor(region), payload, payload);
|
|
5561
6089
|
},
|
|
5562
|
-
setAccountDisabled: (accountId, disabled) => {
|
|
6090
|
+
setAccountDisabled: async (accountId, disabled) => {
|
|
5563
6091
|
const currentIds = current().disabledAccountIds ?? [];
|
|
5564
6092
|
const next = disabled ? currentIds.includes(accountId) ? currentIds : [...currentIds, accountId] : currentIds.filter((id) => id !== accountId);
|
|
5565
|
-
setSetting("disabledAccountIds", next);
|
|
6093
|
+
await setSetting("disabledAccountIds", next, next);
|
|
5566
6094
|
},
|
|
5567
|
-
setCreditReserve: (accountId, reserve) => {
|
|
6095
|
+
setCreditReserve: async (accountId, reserve) => {
|
|
5568
6096
|
const next = { ...current().creditReserves ?? {} };
|
|
5569
6097
|
if (reserve > 0) next[accountId] = reserve;
|
|
5570
6098
|
else delete next[accountId];
|
|
5571
|
-
setSetting("creditReserves", next);
|
|
6099
|
+
await setSetting("creditReserves", next, next);
|
|
5572
6100
|
}
|
|
5573
6101
|
}));
|
|
5574
6102
|
api = {
|