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/index.js
CHANGED
|
@@ -3,8 +3,8 @@ import z from "@deepseek-ai/schemastery";
|
|
|
3
3
|
import { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { readFile, readdir } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
7
|
-
import { existsSync, readFileSync, readdirSync
|
|
6
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
8
8
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
9
9
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
10
10
|
import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
@@ -1747,10 +1747,29 @@ const AAD_DOMAIN = Buffer.from("WB-AAD\0", "ascii");
|
|
|
1747
1747
|
const SYMMETRIC_SCHEME = "sym-v1";
|
|
1748
1748
|
/** Env override pointing at the WorkBuddy desktop executable. */
|
|
1749
1749
|
const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
|
|
1750
|
-
/**
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1750
|
+
/**
|
|
1751
|
+
* How long the app is given to answer with its key payload.
|
|
1752
|
+
*
|
|
1753
|
+
* 30s, not 10s: the child is the WorkBuddy Electron binary running as plain
|
|
1754
|
+
* Node, and its FIRST spawn on a cold machine costs several seconds on its own
|
|
1755
|
+
* (measured 4.5s here) before the endpoint security stack has warmed its scan
|
|
1756
|
+
* cache. Under load — a concurrent `pnpm install` from the market, a running
|
|
1757
|
+
* full-disk scan — that first spawn crosses a 10s budget, the fetch rejects,
|
|
1758
|
+
* `readAtRestKey` returns undefined, and every encrypted credential then reads
|
|
1759
|
+
* as `WorkBuddyEncryptedCredentialError` until the 60s negative cache expires.
|
|
1760
|
+
* A successful fetch is cached for the process lifetime, so the longer budget
|
|
1761
|
+
* is only ever paid once per process, and only when the app is present but slow.
|
|
1762
|
+
*/
|
|
1763
|
+
const KEY_FETCH_TIMEOUT_MS = 3e4;
|
|
1764
|
+
/**
|
|
1765
|
+
* Executable file names the desktop app ships under, in probe order.
|
|
1766
|
+
*
|
|
1767
|
+
* `WorkBuddyAI.exe` is the INTERNATIONAL build; both apps can be installed side
|
|
1768
|
+
* by side (observed on a real machine: `D:\\workbuddy\\WorkBuddy.exe` for the
|
|
1769
|
+
* domestic one and `D:\\workbuddyai\\WorkBuddyAI.exe` for the international one),
|
|
1770
|
+
* so the name cannot be assumed.
|
|
1771
|
+
*/
|
|
1772
|
+
const APP_EXECUTABLE_NAMES = ["WorkBuddy.exe", "WorkBuddyAI.exe"];
|
|
1754
1773
|
/**
|
|
1755
1774
|
* macOS bundles the desktop app may be installed as, in probe order.
|
|
1756
1775
|
*
|
|
@@ -1859,6 +1878,23 @@ function openEncryptedField(field, key) {
|
|
|
1859
1878
|
return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]).toString("utf8");
|
|
1860
1879
|
}
|
|
1861
1880
|
/**
|
|
1881
|
+
* The key id an encrypted field envelope demands, or undefined when the
|
|
1882
|
+
* envelope cannot be read.
|
|
1883
|
+
*
|
|
1884
|
+
* The account pool uses it to pick the right desktop build's key when more
|
|
1885
|
+
* than one build (domestic and international) is installed on the same machine:
|
|
1886
|
+
* each `.info` file names the key id its fields were sealed under, so the opener
|
|
1887
|
+
* must select the matching derived key rather than assume one build exists.
|
|
1888
|
+
*/
|
|
1889
|
+
function encryptedFieldKeyId(field) {
|
|
1890
|
+
try {
|
|
1891
|
+
const record = JSON.parse(Buffer.from(field.envelope, "base64").toString("utf8"));
|
|
1892
|
+
return typeof record["keyId"] === "string" ? record["keyId"] : void 0;
|
|
1893
|
+
} catch {
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1862
1898
|
* The executable inside a macOS app bundle, read from the bundle's own
|
|
1863
1899
|
* `Info.plist`.
|
|
1864
1900
|
*
|
|
@@ -1884,116 +1920,140 @@ function macosBundleExecutable(bundle) {
|
|
|
1884
1920
|
return join(bundle, "Contents", "MacOS", name);
|
|
1885
1921
|
}
|
|
1886
1922
|
/**
|
|
1887
|
-
*
|
|
1923
|
+
* Windows install locations recorded by the app's own uninstaller.
|
|
1888
1924
|
*
|
|
1889
|
-
* The
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
1892
|
-
* and `
|
|
1925
|
+
* The registry is the authoritative answer: it survives a non-default drive, a
|
|
1926
|
+
* renamed folder and a differently-named executable, none of which any fixed
|
|
1927
|
+
* path list can predict. Real machines put the app at `D:\workbuddy\WorkBuddy.exe`
|
|
1928
|
+
* and `D:\workbuddyai\WorkBuddyAI.exe` — exactly the layouts a
|
|
1929
|
+
* `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see, which is why the
|
|
1930
|
+
* plugin reported "the desktop app could not provide the key" for an app that was
|
|
1931
|
+
* installed and running.
|
|
1893
1932
|
*
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
1933
|
+
* `DisplayIcon` is the field that actually carries the path (observed as
|
|
1934
|
+
* `D:\workbuddy\WorkBuddy.exe,0`); `InstallLocation` is usually empty for these
|
|
1935
|
+
* installers, so both are read and either may contribute.
|
|
1896
1936
|
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
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.
|
|
1937
|
+
* Returns [] on any failure — a missing registry key is the normal case on
|
|
1938
|
+
* non-Windows, not an error.
|
|
1902
1939
|
*/
|
|
1903
|
-
function
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1940
|
+
function windowsRegistryAppPaths() {
|
|
1941
|
+
if (process.platform !== "win32") return [];
|
|
1942
|
+
const roots = [
|
|
1943
|
+
["HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
|
|
1944
|
+
["HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
|
|
1945
|
+
["HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"]
|
|
1946
|
+
];
|
|
1947
|
+
const out = [];
|
|
1948
|
+
for (const [root] of roots) {
|
|
1949
|
+
let listing;
|
|
1950
|
+
try {
|
|
1951
|
+
listing = execFileSync("reg", [
|
|
1952
|
+
"query",
|
|
1953
|
+
root,
|
|
1954
|
+
"/s",
|
|
1955
|
+
"/v",
|
|
1956
|
+
"DisplayName"
|
|
1957
|
+
], {
|
|
1958
|
+
encoding: "utf8",
|
|
1959
|
+
timeout: 1e4,
|
|
1960
|
+
windowsHide: true,
|
|
1961
|
+
maxBuffer: 8388608
|
|
1962
|
+
});
|
|
1963
|
+
} catch {
|
|
1964
|
+
continue;
|
|
1965
|
+
}
|
|
1966
|
+
const keys = listing.split(/\r?\n(?=HKEY_)/u).filter((block) => /WorkBuddy|CodeBuddy/iu.test(block));
|
|
1967
|
+
for (const key of keys) {
|
|
1968
|
+
const keyPath = /^(HKEY_[^\r\n]+)/u.exec(key)?.[1]?.trim();
|
|
1969
|
+
if (keyPath === void 0) continue;
|
|
1970
|
+
for (const name of ["DisplayIcon", "InstallLocation"]) try {
|
|
1971
|
+
const value = execFileSync("reg", [
|
|
1972
|
+
"query",
|
|
1973
|
+
keyPath,
|
|
1974
|
+
"/v",
|
|
1975
|
+
name
|
|
1976
|
+
], {
|
|
1977
|
+
encoding: "utf8",
|
|
1978
|
+
timeout: 5e3,
|
|
1979
|
+
windowsHide: true
|
|
1980
|
+
});
|
|
1981
|
+
const raw = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(value)?.[1]?.trim();
|
|
1982
|
+
if (raw === void 0 || raw === "") continue;
|
|
1983
|
+
const cleaned = raw.replace(/^"/u, "").replace(/",-?\d+$/u, "").replace(/,-?\d+$/u, "").trim();
|
|
1984
|
+
out.push(cleaned);
|
|
1985
|
+
} catch {}
|
|
1986
|
+
}
|
|
1942
1987
|
}
|
|
1943
|
-
|
|
1944
|
-
if (identifier === void 0 || identifier === "") return false;
|
|
1945
|
-
return APP_BUNDLE_IDENTIFIER_PREFIXES.some((prefix) => identifier === prefix || identifier.startsWith(`${prefix}.`));
|
|
1988
|
+
return out;
|
|
1946
1989
|
}
|
|
1947
1990
|
/**
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
* Users do file apps into subfolders (`/Applications/IDE/WorkBuddy.app`), and
|
|
1952
|
-
* a hardcoded `/Applications/<name>` then reports the app as missing while it
|
|
1953
|
-
* is installed and signed in. The scan is deliberately ONE level deep and
|
|
1954
|
-
* matches the known bundle names only, so it stays predictable and cheap; each
|
|
1955
|
-
* candidate is then confirmed by {@link isWorkbuddyBundle} before use.
|
|
1991
|
+
* Windows fallbacks for an app the registry did not cover: the well-known
|
|
1992
|
+
* per-user and machine-wide locations, plus every fixed drive's `Program Files`.
|
|
1956
1993
|
*
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1994
|
+
* Drive enumeration matters because installing to a non-system drive is common
|
|
1995
|
+
* on Windows and no environment variable points there.
|
|
1959
1996
|
*/
|
|
1960
|
-
function
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
const
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1997
|
+
function windowsFallbackAppPaths(env) {
|
|
1998
|
+
const out = [];
|
|
1999
|
+
const roots = /* @__PURE__ */ new Set();
|
|
2000
|
+
for (const key of [
|
|
2001
|
+
"ProgramFiles",
|
|
2002
|
+
"ProgramW6432",
|
|
2003
|
+
"ProgramFiles(x86)",
|
|
2004
|
+
"LOCALAPPDATA"
|
|
2005
|
+
]) {
|
|
2006
|
+
const value = env[key]?.trim();
|
|
2007
|
+
if (value !== void 0 && value !== "") roots.add(value);
|
|
2008
|
+
}
|
|
2009
|
+
for (let code = 67; code <= 90; code += 1) {
|
|
2010
|
+
const drive = String.fromCharCode(code) + ":\\";
|
|
2011
|
+
try {
|
|
2012
|
+
if (!existsSync(drive)) continue;
|
|
2013
|
+
} catch {
|
|
2014
|
+
continue;
|
|
1978
2015
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
2016
|
+
roots.add(join(drive, "Program Files"));
|
|
2017
|
+
roots.add(join(drive, "Program Files (x86)"));
|
|
2018
|
+
roots.add(drive);
|
|
2019
|
+
}
|
|
2020
|
+
for (const root of roots) for (const name of APP_EXECUTABLE_NAMES) {
|
|
2021
|
+
out.push(join(root, "WorkBuddy", name));
|
|
2022
|
+
out.push(join(root, "WorkBuddy AI", name));
|
|
2023
|
+
out.push(join(root, "Programs", "WorkBuddy", name));
|
|
2024
|
+
}
|
|
2025
|
+
for (const root of roots) try {
|
|
2026
|
+
for (const entry of readdirSync(root)) {
|
|
2027
|
+
if (!/^(workbuddy|codebuddy)/iu.test(entry)) continue;
|
|
2028
|
+
for (const name of APP_EXECUTABLE_NAMES) out.push(join(root, entry, name));
|
|
2029
|
+
}
|
|
2030
|
+
} catch {}
|
|
2031
|
+
return out;
|
|
1981
2032
|
}
|
|
1982
2033
|
/**
|
|
1983
|
-
*
|
|
1984
|
-
*
|
|
2034
|
+
* Candidate paths of the WorkBuddy desktop executable, in probe order.
|
|
2035
|
+
*
|
|
2036
|
+
* Order is deliberate:
|
|
2037
|
+
* 1. the explicit override, because a user who set it knows where the app is;
|
|
2038
|
+
* 2. the registry, which is what the installer itself recorded;
|
|
2039
|
+
* 3. derived fallbacks (per-user, machine-wide, every fixed drive).
|
|
2040
|
+
*
|
|
2041
|
+
* Only the Windows branch consults the registry (it is the only platform with
|
|
2042
|
+
* one). macOS asks each bundle for its own `CFBundleExecutable` instead, because
|
|
2043
|
+
* the WorkBuddy bundles ship a binary named `Electron`, not after the app.
|
|
2044
|
+
*
|
|
2045
|
+
* `readBundleExecutable` and `registryPaths` are injectable in the same spirit as
|
|
2046
|
+
* `platform`/`home`/`env`: both consult the real machine, so without a seam the
|
|
2047
|
+
* expected candidates would depend on what happens to be installed where the
|
|
2048
|
+
* suite runs — passing on a developer's box and failing in CI.
|
|
1985
2049
|
*/
|
|
1986
|
-
function
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
try {
|
|
1994
|
-
if (existsSync(executable)) return executable;
|
|
1995
|
-
} catch {}
|
|
1996
|
-
}
|
|
2050
|
+
function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env, readBundleExecutable = macosBundleExecutable, registryPaths = windowsRegistryAppPaths) {
|
|
2051
|
+
const candidates = [env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim()];
|
|
2052
|
+
if (platform === "win32") {
|
|
2053
|
+
candidates.push(...registryPaths());
|
|
2054
|
+
candidates.push(...windowsFallbackAppPaths(env));
|
|
2055
|
+
} else if (platform === "darwin") for (const name of MACOS_APP_BUNDLE_NAMES) candidates.push(readBundleExecutable(join("/Applications", name)), readBundleExecutable(join(home, "Applications", name)));
|
|
2056
|
+
return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
|
|
1997
2057
|
}
|
|
1998
2058
|
/**
|
|
1999
2059
|
* Ask the installed desktop app for its key payload by running its own binary
|
|
@@ -2028,28 +2088,95 @@ function fetchAtRestKeyPayload(executable) {
|
|
|
2028
2088
|
});
|
|
2029
2089
|
});
|
|
2030
2090
|
}
|
|
2031
|
-
/** Process-lifetime cache of the derived key; never persisted. */
|
|
2032
|
-
let cachedKey;
|
|
2033
|
-
let inflightKey;
|
|
2034
2091
|
/**
|
|
2035
|
-
* The desktop app's at-rest
|
|
2036
|
-
* (
|
|
2037
|
-
*
|
|
2038
|
-
*
|
|
2039
|
-
*
|
|
2092
|
+
* The desktop app's at-rest keys, indexed by the key id each derived key
|
|
2093
|
+
* reports (the first 16 hex of its SHA-256).
|
|
2094
|
+
*
|
|
2095
|
+
* More than one build can be installed on one machine — the domestic
|
|
2096
|
+
* `WorkBuddy.exe` and the international `WorkBuddyAI.exe` share a key id on the
|
|
2097
|
+
* builds seen here, but a future build may rotate it, and the discovery below
|
|
2098
|
+
* must keep working if they ever diverge. A field envelope names the key id it
|
|
2099
|
+
* was sealed under, so the opener selects the matching derived key instead of
|
|
2100
|
+
* assuming a single build exists. Cached per process and never persisted.
|
|
2040
2101
|
*/
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2102
|
+
const atRestKeyById = /* @__PURE__ */ new Map();
|
|
2103
|
+
let inflightKeys;
|
|
2104
|
+
/**
|
|
2105
|
+
* When the last full key sweep failed, and how long that failure is trusted.
|
|
2106
|
+
*
|
|
2107
|
+
* Without this, EVERY credential read spawned the app and waited out the
|
|
2108
|
+
* 10-second timeout before giving up — which is what made "rescan accounts" and
|
|
2109
|
+
* every status poll crawl on a machine where the app could not be found. A
|
|
2110
|
+
* failure is negative-cached briefly: long enough that a burst of reads costs
|
|
2111
|
+
* one sweep, short enough that installing or starting the app is picked up
|
|
2112
|
+
* without restarting DSH.
|
|
2113
|
+
*/
|
|
2114
|
+
let lastKeyFailureAtMs = 0;
|
|
2115
|
+
const KEY_FAILURE_BACKOFF_MS = 6e4;
|
|
2116
|
+
/**
|
|
2117
|
+
* Load every desktop build's key id into {@link atRestKeyById}.
|
|
2118
|
+
*
|
|
2119
|
+
* Mirrors the reference `provideTheKey` shape: probe EVERY candidate executable
|
|
2120
|
+
* (not just the first that exists) and keep the key each one yields. A build
|
|
2121
|
+
* that fails to answer — a timeout, a single-instance lock, an older build
|
|
2122
|
+
* without the native module — is skipped on its own and does NOT poison the
|
|
2123
|
+
* other builds, which is exactly the failure mode the single-candidate path
|
|
2124
|
+
* had: one bad spawn cached `undefined` for the whole process and every
|
|
2125
|
+
* encrypted field then reported "no app could be located".
|
|
2126
|
+
*/
|
|
2127
|
+
function ensureAtRestKeys() {
|
|
2128
|
+
if (atRestKeyById.size > 0) return Promise.resolve();
|
|
2129
|
+
if (Date.now() - lastKeyFailureAtMs < KEY_FAILURE_BACKOFF_MS) return Promise.resolve();
|
|
2130
|
+
inflightKeys ??= (async () => {
|
|
2131
|
+
const candidates = workbuddyAppExecutableCandidates().filter((candidate) => {
|
|
2132
|
+
try {
|
|
2133
|
+
return existsSync(candidate);
|
|
2134
|
+
} catch {
|
|
2135
|
+
return false;
|
|
2136
|
+
}
|
|
2137
|
+
});
|
|
2138
|
+
if (candidates.length === 0) {
|
|
2139
|
+
lastKeyFailureAtMs = Date.now();
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
let anySuccess = false;
|
|
2143
|
+
await Promise.all(candidates.map(async (executable) => {
|
|
2144
|
+
try {
|
|
2145
|
+
const key = deriveAtRestKey(await fetchAtRestKeyPayload(executable));
|
|
2146
|
+
atRestKeyById.set(deriveAtRestKeyId(key), key);
|
|
2147
|
+
anySuccess = true;
|
|
2148
|
+
} catch {}
|
|
2149
|
+
}));
|
|
2150
|
+
if (anySuccess) lastKeyFailureAtMs = 0;
|
|
2151
|
+
else lastKeyFailureAtMs = Date.now();
|
|
2049
2152
|
})().finally(() => {
|
|
2050
|
-
|
|
2153
|
+
inflightKeys = void 0;
|
|
2154
|
+
});
|
|
2155
|
+
return inflightKeys;
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Synchronous key lookup for a key id already loaded by {@link ensureAtRestKeys}.
|
|
2159
|
+
*
|
|
2160
|
+
* The account pool warms the cache up front (via {@link readAtRestKey}) and then
|
|
2161
|
+
* opens each encrypted field through a synchronous closure, because the parser
|
|
2162
|
+
* runs `decrypt` inline. Lookups that race the warm-up, or ask for a key id no
|
|
2163
|
+
* installed build produced, return undefined and are reported as the
|
|
2164
|
+
* encrypted-but-unavailable error rather than a silently empty token.
|
|
2165
|
+
*/
|
|
2166
|
+
function atRestKeyFor(keyId) {
|
|
2167
|
+
return atRestKeyById.get(keyId);
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Backwards-compatible single-key view: the first key any build provided.
|
|
2171
|
+
*
|
|
2172
|
+
* Kept so callers that do not yet carry a key id (and the legacy tests) still
|
|
2173
|
+
* resolve to a usable key on single-build machines. Multi-build callers should
|
|
2174
|
+
* prefer {@link readAtRestKeyById} and select by the field's own key id.
|
|
2175
|
+
*/
|
|
2176
|
+
function readAtRestKey() {
|
|
2177
|
+
return ensureAtRestKeys().then(() => {
|
|
2178
|
+
for (const key of atRestKeyById.values()) return key;
|
|
2051
2179
|
});
|
|
2052
|
-
return inflightKey;
|
|
2053
2180
|
}
|
|
2054
2181
|
//#endregion
|
|
2055
2182
|
//#region src/accounts.ts
|
|
@@ -2124,7 +2251,7 @@ const ENCRYPTED_CREDENTIAL_CODE = "ENCRYPTED_CREDENTIAL";
|
|
|
2124
2251
|
var WorkBuddyEncryptedCredentialError = class extends Error {
|
|
2125
2252
|
code = ENCRYPTED_CREDENTIAL_CODE;
|
|
2126
2253
|
constructor(sourcePath) {
|
|
2127
|
-
super(`workbuddy: ${sourcePath} holds encrypted credentials but
|
|
2254
|
+
super(`workbuddy: ${sourcePath} holds encrypted credentials, but no WorkBuddy desktop app could be located to provide the key. If the app IS installed, it is simply outside the paths this plugin probes — set WORKBUDDY_APP_EXECUTABLE to its full .exe path (then restart DSH) and the credential will open. Signing in again will not help: the credential itself is intact. Run \`dsh-workbuddy-xdpool doctor\` to see which paths were probed.`);
|
|
2128
2255
|
this.name = "WorkBuddyEncryptedCredentialError";
|
|
2129
2256
|
}
|
|
2130
2257
|
};
|
|
@@ -2308,10 +2435,13 @@ async function readCredential(path) {
|
|
|
2308
2435
|
* error that would look like a corrupt file.
|
|
2309
2436
|
*/
|
|
2310
2437
|
async function encryptedFieldOpener() {
|
|
2311
|
-
|
|
2312
|
-
if (key === void 0) return void 0;
|
|
2438
|
+
await readAtRestKey().catch(() => void 0);
|
|
2313
2439
|
return (field) => {
|
|
2314
2440
|
if (!isEncryptedFieldWrapper(field)) throw new Error("workbuddy: not an encrypted field wrapper");
|
|
2441
|
+
const keyId = encryptedFieldKeyId(field);
|
|
2442
|
+
if (keyId === void 0) throw new Error("workbuddy: encrypted field has no key id");
|
|
2443
|
+
const key = atRestKeyFor(keyId);
|
|
2444
|
+
if (key === void 0) throw new Error("workbuddy: no at-rest key available for this encrypted field");
|
|
2315
2445
|
return openEncryptedField(field, key);
|
|
2316
2446
|
};
|
|
2317
2447
|
}
|
|
@@ -3103,10 +3233,10 @@ function createWorkBuddyAdapter(options) {
|
|
|
3103
3233
|
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
3104
3234
|
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
|
|
3105
3235
|
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
3236
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
3106
3237
|
...REQUEST_IMAGE_BUDGETS,
|
|
3107
3238
|
piProvider: provider
|
|
3108
3239
|
};
|
|
3109
|
-
Object.assign(profile, { modelErrors: /* @__PURE__ */ new Map() });
|
|
3110
3240
|
let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
3111
3241
|
return {
|
|
3112
3242
|
providerId,
|
|
@@ -4043,12 +4173,16 @@ var WorkBuddyScheduler = class {
|
|
|
4043
4173
|
*/
|
|
4044
4174
|
persistEarnings() {
|
|
4045
4175
|
if (this.saveEarningsFn === void 0) return;
|
|
4176
|
+
const accounts = {};
|
|
4177
|
+
for (const [id, entry] of this.earnings) accounts[id] = entry;
|
|
4178
|
+
const ledger = {
|
|
4179
|
+
date: this.earningsDate,
|
|
4180
|
+
accounts
|
|
4181
|
+
};
|
|
4046
4182
|
try {
|
|
4047
|
-
const
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
date: this.earningsDate,
|
|
4051
|
-
accounts
|
|
4183
|
+
const saved = this.saveEarningsFn(ledger);
|
|
4184
|
+
if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
|
|
4185
|
+
this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
|
|
4052
4186
|
});
|
|
4053
4187
|
} catch (error) {
|
|
4054
4188
|
this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
|
|
@@ -5845,6 +5979,56 @@ function automationOptions(automation) {
|
|
|
5845
5979
|
* region that has never been saved stays absent so `applyConfigFromSource` can
|
|
5846
5980
|
* fall back to the legacy flat keys.
|
|
5847
5981
|
*/
|
|
5982
|
+
function asVolatile(schema) {
|
|
5983
|
+
const candidate = schema;
|
|
5984
|
+
return typeof candidate.volatile === "function" ? candidate.volatile() : schema;
|
|
5985
|
+
}
|
|
5986
|
+
/**
|
|
5987
|
+
* Peel one live volatile reference.
|
|
5988
|
+
*
|
|
5989
|
+
* On 0.1.7 a volatile field is handed back as `{ get(): T }` rather than a
|
|
5990
|
+
* plain value, so a running instance observes a settings edit without being
|
|
5991
|
+
* remounted. Every read of a marked field therefore has to unwrap: passing the
|
|
5992
|
+
* reference onward compares an object against a string and reports the field as
|
|
5993
|
+
* unset. On 0.1.5 the field is already a plain value, so this is a no-op.
|
|
5994
|
+
*
|
|
5995
|
+
* The test is duck-typed on purpose — importing `isVolatile` would add a
|
|
5996
|
+
* dependency the 0.1.5 line does not carry.
|
|
5997
|
+
*/
|
|
5998
|
+
function unwrapVolatile(value) {
|
|
5999
|
+
if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
|
|
6000
|
+
return value;
|
|
6001
|
+
}
|
|
6002
|
+
/**
|
|
6003
|
+
* Deep copy of a config value with every live `{get(): T}` reference replaced
|
|
6004
|
+
* by the value it resolves to.
|
|
6005
|
+
*
|
|
6006
|
+
* {@link unwrapVolatile} peels only the one level an ordinary read needs. A
|
|
6007
|
+
* settings service is different: it validates and `structuredClone`s the WHOLE
|
|
6008
|
+
* object, so a reference surviving anywhere inside it fails schema validation
|
|
6009
|
+
* with a message that names the field but not the cause —
|
|
6010
|
+
* `$.authFile expected string but got [object Object]`. That is what makes the
|
|
6011
|
+
* namespace fail to register and the card silently disappear, which is why the
|
|
6012
|
+
* object handed to `installSection` goes through this first.
|
|
6013
|
+
*/
|
|
6014
|
+
function unwrapVolatileDeep(value) {
|
|
6015
|
+
const peeled = unwrapVolatile(value);
|
|
6016
|
+
if (Array.isArray(peeled)) return peeled.map((entry) => unwrapVolatileDeep(entry));
|
|
6017
|
+
if (peeled !== null && typeof peeled === "object") {
|
|
6018
|
+
const out = {};
|
|
6019
|
+
for (const [key, entry] of Object.entries(peeled)) out[key] = unwrapVolatileDeep(entry);
|
|
6020
|
+
return out;
|
|
6021
|
+
}
|
|
6022
|
+
return peeled;
|
|
6023
|
+
}
|
|
6024
|
+
/**
|
|
6025
|
+
* One region's model-selection schema.
|
|
6026
|
+
*
|
|
6027
|
+
* Every field is optional on purpose: an absent field keeps its documented
|
|
6028
|
+
* meaning ("all enabled" / "follow the upstream image flag" / "no cap"), and a
|
|
6029
|
+
* region that has never been saved stays absent so `applyConfigFromSource` can
|
|
6030
|
+
* fall back to the legacy flat keys.
|
|
6031
|
+
*/
|
|
5848
6032
|
const modelSelectionSchema = z.object({
|
|
5849
6033
|
enabledModelIds: z.array(z.string()).description("Model ids enabled in this region's picker (absent = all)"),
|
|
5850
6034
|
imageModelIds: z.array(z.string()).description("Model ids accepting image input in this region (absent = follow upstream)"),
|
|
@@ -5882,24 +6066,32 @@ const modelSelectionKeyFor = (region) => region === "cn" ? "modelSelectionCn" :
|
|
|
5882
6066
|
* also why `contextBudgets` is a real dictionary (`z.dict`) - an open object
|
|
5883
6067
|
* schema reads as "an object with no fields" and the fold then throws while
|
|
5884
6068
|
* the provider row is rendered.
|
|
6069
|
+
*
|
|
6070
|
+
* Every field is wrapped in {@link asVolatile}: on the 0.1.7 line the settings
|
|
6071
|
+
* write gate refuses an entry whose schema declares no volatile field at all
|
|
6072
|
+
* ("Plugin entry ... has no volatile fields") and `describe()` skips such an
|
|
6073
|
+
* entry — so an unmarked schema means the card can neither render nor save. On
|
|
6074
|
+
* the 0.1.5 line the wrapper degrades to an identity no-op (see its JSDoc), and
|
|
6075
|
+
* the value the running instance reads is a plain value either way once
|
|
6076
|
+
* unwrapped.
|
|
5885
6077
|
*/
|
|
5886
6078
|
const Config = z.object({
|
|
5887
|
-
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
|
|
5888
|
-
cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
|
|
5889
|
-
distribution: z.union([
|
|
6079
|
+
authFile: asVolatile(z.string().description("WorkBuddy desktop auth file (defaults to the app own location)")),
|
|
6080
|
+
cooldownMs: asVolatile(z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds")),
|
|
6081
|
+
distribution: asVolatile(z.union([
|
|
5890
6082
|
"priority",
|
|
5891
6083
|
"round-robin",
|
|
5892
6084
|
"balanced"
|
|
5893
|
-
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)"),
|
|
5894
|
-
disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
|
|
5895
|
-
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"),
|
|
5896
|
-
enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
|
|
5897
|
-
imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
|
|
5898
|
-
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"),
|
|
5899
|
-
modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
|
|
5900
|
-
modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
|
|
5901
|
-
automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)"),
|
|
5902
|
-
automationEarnings: z.any().description("Automation earnings ledger (written by the scheduler)")
|
|
6085
|
+
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)")),
|
|
6086
|
+
disabledAccountIds: asVolatile(z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)")),
|
|
6087
|
+
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")),
|
|
6088
|
+
enabledModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet")),
|
|
6089
|
+
imageModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet")),
|
|
6090
|
+
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")),
|
|
6091
|
+
modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
|
|
6092
|
+
modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
|
|
6093
|
+
automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
|
|
6094
|
+
automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
|
|
5903
6095
|
});
|
|
5904
6096
|
/** Live API, published for the CLI. */
|
|
5905
6097
|
let api;
|
|
@@ -5956,10 +6148,21 @@ function apply(ctx, config = {}) {
|
|
|
5956
6148
|
/** The domestic adapter, published for the CLI after registration. */
|
|
5957
6149
|
let builtAdapter;
|
|
5958
6150
|
let invalidateCatalog = () => {};
|
|
5959
|
-
|
|
6151
|
+
/**
|
|
6152
|
+
* The effective config with every live volatile reference peeled.
|
|
6153
|
+
*
|
|
6154
|
+
* On 0.1.7 a field marked volatile is handed to the plugin as `{get(): T}` so
|
|
6155
|
+
* a settings edit is observed without a remount, and the loader keeps the
|
|
6156
|
+
* reference live. Every read below therefore goes through this view: a raw
|
|
6157
|
+
* reference compared against a string reports the field as unset, which is
|
|
6158
|
+
* how a saved reserve would read back as "0" while the file holds the value.
|
|
6159
|
+
* On 0.1.5 there is nothing to peel and this is the identity.
|
|
6160
|
+
*/
|
|
6161
|
+
let rawCurrent = () => config;
|
|
6162
|
+
const current = () => unwrapVolatileDeep(rawCurrent());
|
|
5960
6163
|
const sectionHooks = {
|
|
5961
6164
|
setSource(source) {
|
|
5962
|
-
|
|
6165
|
+
rawCurrent = source;
|
|
5963
6166
|
},
|
|
5964
6167
|
onChange() {
|
|
5965
6168
|
applyConfigFromSource();
|
|
@@ -5985,8 +6188,12 @@ function apply(ctx, config = {}) {
|
|
|
5985
6188
|
core.scheduler.applyConfig(automationOptions(automation));
|
|
5986
6189
|
};
|
|
5987
6190
|
const settingsService = ctx.settings;
|
|
5988
|
-
if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
|
|
5989
|
-
|
|
6191
|
+
if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, unwrapVolatileDeep(config), sectionHooks);
|
|
6192
|
+
if (typeof settingsService.configure === "function") ctx.effect(() => settingsService.configure?.({ auto: true }, ctx.fiber) ?? (() => {}));
|
|
6193
|
+
if (typeof settingsService.installSection !== "function" && typeof settingsService.configure !== "function") ctx.logger.warn?.("dsh-workbuddy-xdpool: settings service exposes neither installSection nor configure; the card will not mount");
|
|
6194
|
+
ctx.on("loader/volatile-update", () => {
|
|
6195
|
+
applyConfigFromSource();
|
|
6196
|
+
});
|
|
5990
6197
|
/**
|
|
5991
6198
|
* Write one key of the plugin's own settings section. Only ever called with
|
|
5992
6199
|
* the three model-selection keys, so the settings file cannot be steered from
|
|
@@ -6022,11 +6229,31 @@ function apply(ctx, config = {}) {
|
|
|
6022
6229
|
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
6230
|
return JSON.stringify(value);
|
|
6024
6231
|
}
|
|
6232
|
+
/**
|
|
6233
|
+
* The namespace `settings.update` expects on the running line.
|
|
6234
|
+
*
|
|
6235
|
+
* 0.1.5 resolves writers by the NAMESPACE the plugin registered through
|
|
6236
|
+
* `installSection`, so the plugin picks it. 0.1.7 resolves them by the HOST
|
|
6237
|
+
* PLUGIN ENTRY ID instead (`configEditor.entries().find(row => row.options.id
|
|
6238
|
+
* === ns)`), and the profile patch chooses that id — the live Desktop host
|
|
6239
|
+
* mounts this plugin as `llm-workbuddy-xdpool`, but a marketplace install can
|
|
6240
|
+
* wrap it (`mkt-...`) or mount it through an `include` row. Asking the fiber
|
|
6241
|
+
* for its own entry id is therefore the only correct answer there; guessing
|
|
6242
|
+
* our own namespace makes every save throw `No configurable plugin entry`.
|
|
6243
|
+
*/
|
|
6244
|
+
const settingsWriteNs = typeof settingsService.installSection === "function" ? WORKBUDDY_POOL_SETTINGS_NS : (() => {
|
|
6245
|
+
try {
|
|
6246
|
+
const entryId = ctx.fiber?.entry?.options?.id;
|
|
6247
|
+
return entryId === void 0 || entryId === "" ? WORKBUDDY_POOL_SETTINGS_NS : entryId;
|
|
6248
|
+
} catch {
|
|
6249
|
+
return WORKBUDDY_POOL_SETTINGS_NS;
|
|
6250
|
+
}
|
|
6251
|
+
})();
|
|
6025
6252
|
const setSetting = async (key, value, expected) => {
|
|
6026
6253
|
if (value === void 0) return;
|
|
6027
|
-
const
|
|
6028
|
-
if (
|
|
6029
|
-
await
|
|
6254
|
+
const update = settingsService?.update;
|
|
6255
|
+
if (update === void 0) throw new Error(`settings service has no update(); ${key} was not saved`);
|
|
6256
|
+
await update.call(settingsService, settingsWriteNs, { [key]: value });
|
|
6030
6257
|
if (expected !== void 0) {
|
|
6031
6258
|
const stored = current()[key];
|
|
6032
6259
|
if (!stableJsonEqual(stored, expected)) throw new Error(`settings field "${key}" was not persisted`);
|
|
@@ -6222,7 +6449,9 @@ function apply(ctx, config = {}) {
|
|
|
6222
6449
|
ctx.logger.warn(`dsh-workbuddy-xdpool: ${region} model catalog unavailable; using static fallback`, error);
|
|
6223
6450
|
}
|
|
6224
6451
|
invalidateCatalog();
|
|
6225
|
-
})()
|
|
6452
|
+
})().catch((error) => {
|
|
6453
|
+
ctx.logger.warn("dsh-workbuddy-xdpool: account catalog seed failed", error);
|
|
6454
|
+
});
|
|
6226
6455
|
}, (error) => {
|
|
6227
6456
|
ctx.logger.error("dsh-workbuddy-xdpool: shim failed to listen", error);
|
|
6228
6457
|
});
|