dsh-connect-workbuddy 2.0.10 → 2.0.12

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.
@@ -5,14 +5,13 @@ import { dirname, join } from "node:path";
5
5
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
6
6
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
7
7
  import { execFile, execFileSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
9
  //#region src/at-rest.ts
10
10
  /**
11
11
  * WorkBuddy desktop "at-rest" credential decryption.
12
12
  *
13
- * The WorkBuddy desktop app (2.x, Windows builds first) no longer stores
14
- * `auth.accessToken` / `auth.refreshToken` as plain strings. It now writes a
15
- * field wrapper:
13
+ * From 5.6.0 the WorkBuddy desktop app no longer stores `auth.accessToken` /
14
+ * `auth.refreshToken` as plain strings. It writes a field wrapper:
16
15
  *
17
16
  * { "$wbEncrypted": 1, "envelope": "<base64 of a JSON envelope>" }
18
17
  *
@@ -33,6 +32,16 @@ import { existsSync } from "node:fs";
33
32
  * payload, and caches it in memory for the process lifetime. Nothing is ever
34
33
  * written to disk, and the payload is never logged.
35
34
  *
35
+ * 改动:**「macOS 也加密」这一事实**(issue #15 真机取证)。本模块原先假设该
36
+ * policy 是 Windows 先行、macOS 只是「将来可能」,于是 macOS 的可执行文件
37
+ * 路径用 App 名拼成 `<bundle>/Contents/MacOS/WorkBuddy`——而两个真实 bundle
38
+ * 的 `CFBundleExecutable` 都是 `Electron`,该路径并不存在。结果是 macOS 上
39
+ * 加密凭据**永远**取不到密钥,用户却被报成「未登录」。现在二进制名向 bundle
40
+ * 自己问(`macosBundleExecutable()`),候选含国际版 `WorkBuddy AI.app`,
41
+ * 并允许 App 被归入 applications 目录的子目录——扫到的候选必须先用
42
+ * `CFBundleIdentifier` 确认身份才 `execFile`,因为**每个 Electron 应用的
43
+ * 二进制都叫 `Electron`**,只按名字匹配就可能启动另一个产品。
44
+ *
36
45
  * @module dsh-connect-workbuddy/at-rest
37
46
  */
38
47
  /** Envelope framing names, mapped to the single-byte AAD framing code. */
@@ -57,8 +66,19 @@ const SYMMETRIC_SCHEME = "sym-v1";
57
66
  const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
58
67
  /** How long the app is given to answer with its key payload. */
59
68
  const KEY_FETCH_TIMEOUT_MS = 1e4;
60
- /** File name of the WorkBuddy desktop executable, per platform. */
69
+ /** File name of the WorkBuddy desktop executable on Windows. */
61
70
  const APP_EXECUTABLE_NAME = "WorkBuddy.exe";
71
+ /**
72
+ * macOS bundles the desktop app may be installed as, in probe order.
73
+ *
74
+ * `WorkBuddy.app` is the domestic build; `WorkBuddy AI.app` is the
75
+ * international one, and a machine may carry either or both. The user-level
76
+ * `~/Applications` location is included because macOS lets an app live there,
77
+ * and installs have been observed under a subdirectory of /Applications too —
78
+ * hence {@link findWorkbuddyAppExecutable}'s parent scan, which covers those
79
+ * without guessing any particular folder name.
80
+ */
81
+ const MACOS_APP_BUNDLE_NAMES = ["WorkBuddy.app", "WorkBuddy AI.app"];
62
82
  function encodeUint32(value) {
63
83
  const bytes = Buffer.allocUnsafe(4);
64
84
  bytes.writeUInt32BE(value);
@@ -156,24 +176,127 @@ function openEncryptedField(field, key) {
156
176
  return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]).toString("utf8");
157
177
  }
158
178
  /**
179
+ * The executable inside a macOS app bundle, read from the bundle's own
180
+ * `Info.plist`.
181
+ *
182
+ * The binary is NOT reliably named after the app: the WorkBuddy bundles ship
183
+ * with `CFBundleExecutable` set to `Electron`, so a path assembled as
184
+ * `<bundle>/Contents/MacOS/WorkBuddy` does not exist and the app looks absent
185
+ * even when it is installed in the default location. Because the bundle
186
+ * documents the real name, asking it is both correct and robust to a future
187
+ * build that renames the binary.
188
+ *
189
+ * Returns undefined when the plist is absent, unreadable, or carries no usable
190
+ * name — never a guessed path, so a caller can keep probing.
191
+ */
192
+ function macosBundleExecutable(bundle) {
193
+ let plist;
194
+ try {
195
+ plist = readFileSync(join(bundle, "Contents", "Info.plist"), "utf8");
196
+ } catch {
197
+ return;
198
+ }
199
+ const name = /<key>\s*CFBundleExecutable\s*<\/key>\s*<string>([^<]*)<\/string>/u.exec(plist)?.[1]?.trim();
200
+ if (name === void 0 || name === "" || name.includes("/") || name.includes("\\") || name === "." || name === "..") return;
201
+ return join(bundle, "Contents", "MacOS", name);
202
+ }
203
+ /**
159
204
  * Candidate paths of the WorkBuddy desktop executable, in probe order.
160
205
  *
161
206
  * The Windows build is the one that encrypts credentials, so Windows leads;
162
- * the macOS bundle is listed because the same native module ships there and a
163
- * future build may enable the policy, and `undefined` entries (an unset env
164
- * variable) are dropped.
207
+ * the macOS bundles are listed because the same native module ships there and
208
+ * the encryption policy is enabled on macOS builds too (observed from 5.6.x),
209
+ * and `undefined` entries (an unset env variable) are dropped.
210
+ *
211
+ * On macOS the executable name comes from each bundle (see
212
+ * {@link macosBundleExecutable}) rather than being assembled from the app name.
213
+ *
214
+ * `readBundleExecutable` is injectable, in the same spirit as `platform`/`home`/
215
+ * `env`: the macOS branch consults the real filesystem, so without a seam the
216
+ * expected candidates would depend on whether the host machine happens to have
217
+ * the app installed — and the test would pass on a developer's Mac while
218
+ * failing in CI.
165
219
  */
166
- function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env) {
220
+ function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env, readBundleExecutable = macosBundleExecutable) {
167
221
  const candidates = [env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim()];
168
222
  if (platform === "win32") {
169
223
  const local = env["LOCALAPPDATA"]?.trim();
170
224
  const programFiles = env["ProgramFiles"]?.trim();
171
225
  const programFilesX86 = env["ProgramFiles(x86)"]?.trim();
172
- candidates.push(local === void 0 || local === "" ? void 0 : join(local, "Programs", "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));
173
- } else if (platform === "darwin") candidates.push("/Applications/WorkBuddy.app/Contents/MacOS/WorkBuddy", join(home, "Applications", "WorkBuddy.app", "Contents", "MacOS", "WorkBuddy"));
226
+ 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));
227
+ } else if (platform === "darwin") for (const name of MACOS_APP_BUNDLE_NAMES) candidates.push(readBundleExecutable(join("/Applications", name)), readBundleExecutable(join(home, "Applications", name)));
174
228
  return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
175
229
  }
176
230
  /**
231
+ * Bundle identifier PREFIXES the desktop app is signed with — `com.tencent.
232
+ * workbuddy` (domestic, observed as `…workbuddy.mac`) and `com.workbuddy`
233
+ * (international, observed as `com.workbuddy.workbuddy-ai`).
234
+ *
235
+ * Used to CONFIRM that a discovered bundle really is WorkBuddy before it is
236
+ * launched. This matters because the discovery below scans directories and then
237
+ * execs what it finds: every Electron app is built around a binary called
238
+ * `Electron`, so a name-only match could pick a different product's bundle and
239
+ * run it. The identifier is the app's own claim about itself, so it is the
240
+ * check that makes the scan safe.
241
+ */
242
+ const APP_BUNDLE_IDENTIFIER_PREFIXES = ["com.tencent.workbuddy", "com.workbuddy"];
243
+ /**
244
+ * Whether a bundle identifies itself as the WorkBuddy desktop app.
245
+ *
246
+ * The match is on dot boundaries, so a hypothetical `com.workbuddyish` cannot
247
+ * pass as `com.workbuddy`.
248
+ *
249
+ * An unreadable or identifier-less plist is treated as NOT WorkBuddy: refusing
250
+ * a candidate only costs a fallback to another path, whereas accepting the
251
+ * wrong one would execute an unrelated application.
252
+ */
253
+ function isWorkbuddyBundle(bundle) {
254
+ let plist;
255
+ try {
256
+ plist = readFileSync(join(bundle, "Contents", "Info.plist"), "utf8");
257
+ } catch {
258
+ return false;
259
+ }
260
+ const identifier = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>([^<]*)<\/string>/u.exec(plist)?.[1]?.trim().toLowerCase();
261
+ if (identifier === void 0 || identifier === "") return false;
262
+ return APP_BUNDLE_IDENTIFIER_PREFIXES.some((prefix) => identifier === prefix || identifier.startsWith(`${prefix}.`));
263
+ }
264
+ /**
265
+ * Bundles of the desktop app found one level BELOW a macOS applications
266
+ * directory.
267
+ *
268
+ * Users do file apps into subfolders (`/Applications/IDE/WorkBuddy.app`), and
269
+ * a hardcoded `/Applications/<name>` then reports the app as missing while it
270
+ * is installed and signed in. The scan is deliberately ONE level deep and
271
+ * matches the known bundle names only, so it stays predictable and cheap; each
272
+ * candidate is then confirmed by {@link isWorkbuddyBundle} before use.
273
+ *
274
+ * Returns [] when the parent is absent or unreadable — a missing directory is
275
+ * the normal case, not an error.
276
+ */
277
+ function macosNestedAppBundles(parent) {
278
+ let entries;
279
+ try {
280
+ entries = readdirSync(parent);
281
+ } catch {
282
+ return [];
283
+ }
284
+ const bundles = [];
285
+ for (const entry of entries) {
286
+ const nested = join(parent, entry);
287
+ for (const name of MACOS_APP_BUNDLE_NAMES) {
288
+ const bundle = join(nested, name);
289
+ try {
290
+ if (!statSync(bundle).isDirectory()) continue;
291
+ } catch {
292
+ continue;
293
+ }
294
+ if (isWorkbuddyBundle(bundle)) bundles.push(bundle);
295
+ }
296
+ }
297
+ return bundles;
298
+ }
299
+ /**
177
300
  * The first candidate that exists as a file, or undefined when the desktop app
178
301
  * is not installed where this platform expects it.
179
302
  */
@@ -181,6 +304,13 @@ function findWorkbuddyAppExecutable(platform = process.platform, home = homedir(
181
304
  for (const candidate of workbuddyAppExecutableCandidates(platform, home, env)) try {
182
305
  if (existsSync(candidate)) return candidate;
183
306
  } catch {}
307
+ if (platform === "darwin") for (const parent of ["/Applications", join(home, "Applications")]) for (const bundle of macosNestedAppBundles(parent)) {
308
+ const executable = macosBundleExecutable(bundle);
309
+ if (executable === void 0) continue;
310
+ try {
311
+ if (existsSync(executable)) return executable;
312
+ } catch {}
313
+ }
184
314
  }
185
315
  /**
186
316
  * Ask the installed desktop app for its key payload by running its own binary
@@ -905,9 +1035,52 @@ var WorkBuddyUpstreamClient = class {
905
1035
  * 一个 store,账号、刷新、选择完全隔离;插件自有刷新副本也按区域分
906
1036
  * 文件(`.workbuddy-auth.<region>.json`),双账号同时在线互不覆盖,
907
1037
  * 旧的单文件 `.workbuddy-auth.json` 作为迁移源保留读取。
1038
+ * 另(issue #15):`resolve()` 不再把「没登录」与「有登录但凭据读不出来」
1039
+ * 合并成同一句话。后者(加密字段 + 取不到密钥)改为抛
1040
+ * `WorkBuddyEncryptedCredentialError`,给出「装 App / 用
1041
+ * WORKBUDDY_APP_EXECUTABLE 指定」这条**可执行**的建议——对已登录的用户说
1042
+ * 「请重新登录一次」指向的是唯一无效的动作。判定只在失败路径上跑一次
1043
+ * `diagnose()`,健康路径不付任何代价。
908
1044
  *
909
1045
  * @module dsh-connect-workbuddy/auth
910
1046
  */
1047
+ /**
1048
+ * Marker for a credential that EXISTS but could not be decrypted.
1049
+ *
1050
+ * Identified by {@link ENCRYPTED_CREDENTIAL_CODE} rather than `instanceof`, so
1051
+ * the check keeps working when the thrower and the caller end up in different
1052
+ * module instances (bundled host half vs. a test's source import) — the same
1053
+ * convention `WorkBuddyCredentialRejectedError` uses.
1054
+ */
1055
+ const ENCRYPTED_CREDENTIAL_CODE = "WORKBUDDY_ENCRYPTED_CREDENTIAL";
1056
+ /**
1057
+ * The user IS signed in, but the credential file cannot be read.
1058
+ *
1059
+ * This exists because "signed out" and "signed in with an unreadable credential"
1060
+ * resolve to the same observable state — zero accounts — and were therefore
1061
+ * reported with the same message. That message told a correctly signed-in user
1062
+ * to sign in again, which cannot possibly help: from WorkBuddy 5.6.x the desktop
1063
+ * app encrypts its token fields, and reading them requires asking the installed
1064
+ * app for its key. If that app cannot be located, the fix is to point the plugin
1065
+ * at it (`WORKBUDDY_APP_EXECUTABLE`), never to sign in again.
1066
+ *
1067
+ * No token material is carried: only the paths and the environment variable.
1068
+ */
1069
+ var WorkBuddyEncryptedCredentialError = class extends Error {
1070
+ code = ENCRYPTED_CREDENTIAL_CODE;
1071
+ /** Files that hold an encrypted credential, for the user to recognize. */
1072
+ paths;
1073
+ constructor(paths) {
1074
+ const where = paths.length > 0 ? paths.join(", ") : "the credential file";
1075
+ super(`workbuddy: your WorkBuddy sign-in is present but encrypted, so it cannot be read (${where}). The WorkBuddy desktop app must be present to hand over its key; install it, or set ${WORKBUDDY_APP_EXECUTABLE_ENV} to its executable if it lives elsewhere. Signing in again will not change this.`);
1076
+ this.name = "WorkBuddyEncryptedCredentialError";
1077
+ this.paths = paths;
1078
+ }
1079
+ };
1080
+ /** Whether an error reports an unreadable (encrypted) credential. */
1081
+ function isEncryptedCredentialError(value) {
1082
+ return typeof value === "object" && value !== null && value.code === "WORKBUDDY_ENCRYPTED_CREDENTIAL";
1083
+ }
911
1084
  /** Legacy single-copy basename (pre-dual-provider); kept as migration source. */
912
1085
  const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
913
1086
  /** Env variable that overrides the desktop auth-file location. */
@@ -1510,17 +1683,38 @@ var WorkBuddyCredentialStore = class {
1510
1683
  /** The credential to send upstream: {@link current}, refreshed on demand. */
1511
1684
  async resolve() {
1512
1685
  const credential = await this.current();
1513
- if (credential === void 0) {
1514
- const candidates = this.resolveDesktopCandidates();
1515
- const desktop = candidates.length > 0 ? candidates.join(" or ") : "(no desktop path on this platform)";
1516
- throw new Error(`workbuddy: no signed-in WorkBuddy account found; sign in once in the WorkBuddy desktop app (expected ${desktop} or ${WORKBUDDY_AUTH_FILE_ENV}), or refresh an existing session`);
1517
- }
1686
+ if (credential === void 0) throw await this.describeMissingCredential();
1518
1687
  if (!this.needsRefresh(credential)) return credential;
1519
1688
  this.inflight ??= this.refreshNow(credential).finally(() => {
1520
1689
  this.inflight = void 0;
1521
1690
  });
1522
1691
  return this.inflight;
1523
1692
  }
1693
+ /**
1694
+ * WHY no credential resolved, as the error to throw.
1695
+ *
1696
+ * `resolve()` seeing zero accounts has two very different causes, and the
1697
+ * generic "sign in once" message is only correct for one of them. When a
1698
+ * probe finds a credential file whose fields are encrypted and the key could
1699
+ * not be obtained, the user is signed in and re-authenticating cannot help —
1700
+ * so that case gets {@link WorkBuddyEncryptedCredentialError} instead, naming
1701
+ * the file and the environment variable that actually fixes it.
1702
+ *
1703
+ * The probe is only run on the failure path, so the healthy case pays nothing.
1704
+ * A probe that itself throws must not replace the real error, hence the
1705
+ * fallback to the generic message.
1706
+ */
1707
+ async describeMissingCredential() {
1708
+ let failures = [];
1709
+ try {
1710
+ failures = (await this.diagnose()).failures;
1711
+ } catch {}
1712
+ const encrypted = failures.filter((failure) => failure.reason === "encrypted").map((failure) => failure.path);
1713
+ if (encrypted.length > 0) return new WorkBuddyEncryptedCredentialError(encrypted);
1714
+ const candidates = this.resolveDesktopCandidates();
1715
+ const desktop = candidates.length > 0 ? candidates.join(" or ") : "(no desktop path on this platform)";
1716
+ return /* @__PURE__ */ new Error(`workbuddy: no signed-in WorkBuddy account found; sign in once in the WorkBuddy desktop app (expected ${desktop} or ${WORKBUDDY_AUTH_FILE_ENV}), or refresh an existing session`);
1717
+ }
1524
1718
  /** Read-only sign-in summary; never refreshes and never throws. */
1525
1719
  async status() {
1526
1720
  try {
@@ -2014,7 +2208,7 @@ var WorkBuddyCatalog = class {
2014
2208
  //#endregion
2015
2209
  //#region src/version.ts
2016
2210
  /** The npm package version this build was produced from. */
2017
- const WORKBUDDY_CONNECT_VERSION = "2.0.10";
2211
+ const WORKBUDDY_CONNECT_VERSION = "2.0.12";
2018
2212
  //#endregion
2019
2213
  //#region src/host-heartbeat.ts
2020
2214
  /**
@@ -2120,4 +2314,4 @@ async function clearHostHeartbeat() {
2120
2314
  await rm(workbuddyHostHeartbeatPath(), { force: true });
2121
2315
  }
2122
2316
  //#endregion
2123
- export { isCredentialRejectedError as A, fetchAtRestKeyPayload as B, parseWorkBuddyAuth as C, WorkBuddyCredentialRejectedError as D, CREDENTIAL_REJECTED_CODE as E, regionOf as F, workbuddyAppExecutableCandidates as G, isEncryptedFieldWrapper as H, WORKBUDDY_APP_EXECUTABLE_ENV as I, clearAtRestKeyCache as L, parseReasoning as M, parseUpstreamModel as N, WorkBuddyUpstreamClient as O, prepareChatBody as P, deriveAtRestKey as R, legacyWorkbuddyOwnAuthPath as S, workbuddyOwnAuthPath as T, openEncryptedField as U, findWorkbuddyAppExecutable as V, readAtRestKey as W, authFileName as _, readHostHeartbeat as a, defaultDesktopAuthPath as b, WORKBUDDY_CONNECT_VERSION as c, WorkBuddyCatalog as d, deriveCatalog as f, WorkBuddyCredentialStore as g, WORKBUDDY_AUTH_FILE_ENV as h, processStartTimeMs as i, parseCreditMultiplier as j, classifyUpstreamError as k, FALLBACK_WORKBUDDY_MODELS as l, WORKBUDDY_AUTH_FILENAME as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, fallbackModelsFor as p, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS_GLOBAL as u, defaultDesktopAuthCandidates as v, workbuddyAccountId as w, hasEncryptedCredentialFields as x, defaultDesktopAuthDirs as y, deriveAtRestKeyId as z };
2317
+ export { WorkBuddyCredentialRejectedError as A, clearAtRestKeyCache as B, hasEncryptedCredentialFields as C, workbuddyAccountId as D, parseWorkBuddyAuth as E, parseReasoning as F, isEncryptedFieldWrapper as G, deriveAtRestKeyId as H, parseUpstreamModel as I, macosNestedAppBundles as J, isWorkbuddyBundle as K, prepareChatBody as L, classifyUpstreamError as M, isCredentialRejectedError as N, workbuddyOwnAuthPath as O, parseCreditMultiplier as P, regionOf as R, defaultDesktopAuthPath as S, legacyWorkbuddyOwnAuthPath as T, fetchAtRestKeyPayload as U, deriveAtRestKey as V, findWorkbuddyAppExecutable as W, readAtRestKey as X, openEncryptedField as Y, workbuddyAppExecutableCandidates as Z, WorkBuddyCredentialStore as _, readHostHeartbeat as a, defaultDesktopAuthCandidates as b, WORKBUDDY_CONNECT_VERSION as c, WorkBuddyCatalog as d, deriveCatalog as f, WORKBUDDY_AUTH_FILE_ENV as g, WORKBUDDY_AUTH_FILENAME as h, processStartTimeMs as i, WorkBuddyUpstreamClient as j, CREDENTIAL_REJECTED_CODE as k, FALLBACK_WORKBUDDY_MODELS as l, ENCRYPTED_CREDENTIAL_CODE as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, fallbackModelsFor as p, macosBundleExecutable as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS_GLOBAL as u, WorkBuddyEncryptedCredentialError as v, isEncryptedCredentialError as w, defaultDesktopAuthDirs as x, authFileName as y, WORKBUDDY_APP_EXECUTABLE_ENV as z };
package/lib/index.d.ts CHANGED
@@ -65,6 +65,36 @@ interface WorkBuddyAuthStatus {
65
65
  domain?: string;
66
66
  source?: 'desktop' | 'dsh';
67
67
  }
68
+ /**
69
+ * Marker for a credential that EXISTS but could not be decrypted.
70
+ *
71
+ * Identified by {@link ENCRYPTED_CREDENTIAL_CODE} rather than `instanceof`, so
72
+ * the check keeps working when the thrower and the caller end up in different
73
+ * module instances (bundled host half vs. a test's source import) — the same
74
+ * convention `WorkBuddyCredentialRejectedError` uses.
75
+ */
76
+ declare const ENCRYPTED_CREDENTIAL_CODE = "WORKBUDDY_ENCRYPTED_CREDENTIAL";
77
+ /**
78
+ * The user IS signed in, but the credential file cannot be read.
79
+ *
80
+ * This exists because "signed out" and "signed in with an unreadable credential"
81
+ * resolve to the same observable state — zero accounts — and were therefore
82
+ * reported with the same message. That message told a correctly signed-in user
83
+ * to sign in again, which cannot possibly help: from WorkBuddy 5.6.x the desktop
84
+ * app encrypts its token fields, and reading them requires asking the installed
85
+ * app for its key. If that app cannot be located, the fix is to point the plugin
86
+ * at it (`WORKBUDDY_APP_EXECUTABLE`), never to sign in again.
87
+ *
88
+ * No token material is carried: only the paths and the environment variable.
89
+ */
90
+ declare class WorkBuddyEncryptedCredentialError extends Error {
91
+ readonly code = "WORKBUDDY_ENCRYPTED_CREDENTIAL";
92
+ /** Files that hold an encrypted credential, for the user to recognize. */
93
+ readonly paths: readonly string[];
94
+ constructor(paths: readonly string[]);
95
+ }
96
+ /** Whether an error reports an unreadable (encrypted) credential. */
97
+ declare function isEncryptedCredentialError(value: unknown): value is WorkBuddyEncryptedCredentialError;
68
98
  /** Constructor options; only {@link refresh} is required. */
69
99
  interface WorkBuddyStoreOptions {
70
100
  /** Explicit desktop auth-file path, overriding env and platform defaults. */
@@ -328,6 +358,21 @@ declare class WorkBuddyCredentialStore {
328
358
  credentialFor(accountId: string): Promise<WorkBuddyCredential | undefined>;
329
359
  /** The credential to send upstream: {@link current}, refreshed on demand. */
330
360
  resolve(): Promise<WorkBuddyCredential>;
361
+ /**
362
+ * WHY no credential resolved, as the error to throw.
363
+ *
364
+ * `resolve()` seeing zero accounts has two very different causes, and the
365
+ * generic "sign in once" message is only correct for one of them. When a
366
+ * probe finds a credential file whose fields are encrypted and the key could
367
+ * not be obtained, the user is signed in and re-authenticating cannot help —
368
+ * so that case gets {@link WorkBuddyEncryptedCredentialError} instead, naming
369
+ * the file and the environment variable that actually fixes it.
370
+ *
371
+ * The probe is only run on the failure path, so the healthy case pays nothing.
372
+ * A probe that itself throws must not replace the real error, hence the
373
+ * fallback to the generic message.
374
+ */
375
+ private describeMissingCredential;
331
376
  /** Read-only sign-in summary; never refreshes and never throws. */
332
377
  status(): Promise<WorkBuddyAuthStatus>;
333
378
  /**
@@ -753,9 +798,8 @@ declare function createWorkBuddyAdapter(options: WorkBuddyAdapterOptions): WorkB
753
798
  /**
754
799
  * WorkBuddy desktop "at-rest" credential decryption.
755
800
  *
756
- * The WorkBuddy desktop app (2.x, Windows builds first) no longer stores
757
- * `auth.accessToken` / `auth.refreshToken` as plain strings. It now writes a
758
- * field wrapper:
801
+ * From 5.6.0 the WorkBuddy desktop app no longer stores `auth.accessToken` /
802
+ * `auth.refreshToken` as plain strings. It writes a field wrapper:
759
803
  *
760
804
  * { "$wbEncrypted": 1, "envelope": "<base64 of a JSON envelope>" }
761
805
  *
@@ -776,6 +820,16 @@ declare function createWorkBuddyAdapter(options: WorkBuddyAdapterOptions): WorkB
776
820
  * payload, and caches it in memory for the process lifetime. Nothing is ever
777
821
  * written to disk, and the payload is never logged.
778
822
  *
823
+ * 改动:**「macOS 也加密」这一事实**(issue #15 真机取证)。本模块原先假设该
824
+ * policy 是 Windows 先行、macOS 只是「将来可能」,于是 macOS 的可执行文件
825
+ * 路径用 App 名拼成 `<bundle>/Contents/MacOS/WorkBuddy`——而两个真实 bundle
826
+ * 的 `CFBundleExecutable` 都是 `Electron`,该路径并不存在。结果是 macOS 上
827
+ * 加密凭据**永远**取不到密钥,用户却被报成「未登录」。现在二进制名向 bundle
828
+ * 自己问(`macosBundleExecutable()`),候选含国际版 `WorkBuddy AI.app`,
829
+ * 并允许 App 被归入 applications 目录的子目录——扫到的候选必须先用
830
+ * `CFBundleIdentifier` 确认身份才 `execFile`,因为**每个 Electron 应用的
831
+ * 二进制都叫 `Electron`**,只按名字匹配就可能启动另一个产品。
832
+ *
779
833
  * @module dsh-connect-workbuddy/at-rest
780
834
  */
781
835
  /** A `{$wbEncrypted:1,envelope}` field wrapper, the only shape this module opens. */
@@ -809,15 +863,64 @@ declare function deriveAtRestKey(payloadJson: string): Buffer;
809
863
  * key is wrong, and it must never degrade into a truncated token.
810
864
  */
811
865
  declare function openEncryptedField(field: WorkBuddyEncryptedField, key: Buffer): string;
866
+ /**
867
+ * The executable inside a macOS app bundle, read from the bundle's own
868
+ * `Info.plist`.
869
+ *
870
+ * The binary is NOT reliably named after the app: the WorkBuddy bundles ship
871
+ * with `CFBundleExecutable` set to `Electron`, so a path assembled as
872
+ * `<bundle>/Contents/MacOS/WorkBuddy` does not exist and the app looks absent
873
+ * even when it is installed in the default location. Because the bundle
874
+ * documents the real name, asking it is both correct and robust to a future
875
+ * build that renames the binary.
876
+ *
877
+ * Returns undefined when the plist is absent, unreadable, or carries no usable
878
+ * name — never a guessed path, so a caller can keep probing.
879
+ */
880
+ declare function macosBundleExecutable(bundle: string): string | undefined;
812
881
  /**
813
882
  * Candidate paths of the WorkBuddy desktop executable, in probe order.
814
883
  *
815
884
  * The Windows build is the one that encrypts credentials, so Windows leads;
816
- * the macOS bundle is listed because the same native module ships there and a
817
- * future build may enable the policy, and `undefined` entries (an unset env
818
- * variable) are dropped.
885
+ * the macOS bundles are listed because the same native module ships there and
886
+ * the encryption policy is enabled on macOS builds too (observed from 5.6.x),
887
+ * and `undefined` entries (an unset env variable) are dropped.
888
+ *
889
+ * On macOS the executable name comes from each bundle (see
890
+ * {@link macosBundleExecutable}) rather than being assembled from the app name.
891
+ *
892
+ * `readBundleExecutable` is injectable, in the same spirit as `platform`/`home`/
893
+ * `env`: the macOS branch consults the real filesystem, so without a seam the
894
+ * expected candidates would depend on whether the host machine happens to have
895
+ * the app installed — and the test would pass on a developer's Mac while
896
+ * failing in CI.
819
897
  */
820
- declare function workbuddyAppExecutableCandidates(platform?: NodeJS.Platform, home?: string, env?: NodeJS.ProcessEnv): string[];
898
+ declare function workbuddyAppExecutableCandidates(platform?: NodeJS.Platform, home?: string, env?: NodeJS.ProcessEnv, readBundleExecutable?: (bundle: string) => string | undefined): string[];
899
+ /**
900
+ * Whether a bundle identifies itself as the WorkBuddy desktop app.
901
+ *
902
+ * The match is on dot boundaries, so a hypothetical `com.workbuddyish` cannot
903
+ * pass as `com.workbuddy`.
904
+ *
905
+ * An unreadable or identifier-less plist is treated as NOT WorkBuddy: refusing
906
+ * a candidate only costs a fallback to another path, whereas accepting the
907
+ * wrong one would execute an unrelated application.
908
+ */
909
+ declare function isWorkbuddyBundle(bundle: string): boolean;
910
+ /**
911
+ * Bundles of the desktop app found one level BELOW a macOS applications
912
+ * directory.
913
+ *
914
+ * Users do file apps into subfolders (`/Applications/IDE/WorkBuddy.app`), and
915
+ * a hardcoded `/Applications/<name>` then reports the app as missing while it
916
+ * is installed and signed in. The scan is deliberately ONE level deep and
917
+ * matches the known bundle names only, so it stays predictable and cheap; each
918
+ * candidate is then confirmed by {@link isWorkbuddyBundle} before use.
919
+ *
920
+ * Returns [] when the parent is absent or unreadable — a missing directory is
921
+ * the normal case, not an error.
922
+ */
923
+ declare function macosNestedAppBundles(parent: string): string[];
821
924
  /**
822
925
  * The first candidate that exists as a file, or undefined when the desktop app
823
926
  * is not installed where this platform expects it.
@@ -1242,6 +1345,23 @@ type WorkBuddyWebUsage =
1242
1345
  credentialRejected?: boolean;
1243
1346
  /** Present only alongside {@link credentialRejected}. */
1244
1347
  recovery?: WorkBuddyWebRecovery;
1348
+ /**
1349
+ * Diagnostic only: whether this Host build's Config carries the volatile
1350
+ * markers dsh-settings requires before it will persist any field. Exposed
1351
+ * so a save refusal can be attributed to the settings gate rather than
1352
+ * guessed at.
1353
+ */
1354
+ diagVolatile?: {
1355
+ regions: boolean;
1356
+ accounts: boolean;
1357
+ authFile: boolean;
1358
+ };
1359
+ /**
1360
+ * The persisted per-region context budgets as the Host reads them. The
1361
+ * browser settings mirror can lag a write made through the Host save
1362
+ * endpoint, so the card renders these rather than a possibly-stale snapshot.
1363
+ */
1364
+ contextBudgets?: Record<string, number>;
1245
1365
  } | {
1246
1366
  status: 'error';
1247
1367
  message: string;
@@ -1372,6 +1492,43 @@ interface Config {
1372
1492
  /** @deprecated See {@link Config.lastCatalog}. */
1373
1493
  contextBudgets?: Record<string, WorkBuddyContextBudget>;
1374
1494
  }
1495
+ /**
1496
+ * Mark a schema's field as volatile on the DSH lines that support it.
1497
+ *
1498
+ * `volatile()` exists from schemastery 3.18.3 (the DSH 0.1.7 line, which is the
1499
+ * only line whose settings write gate reads the marker). Older pinning (3.18.2,
1500
+ * the 0.1.5 line) has no such method, and the schema must stay byte-identical to
1501
+ * the unmarked original there: hand-writing `meta.volatile = true` would bypass
1502
+ * schemastery's own validateVolatileSchema checks and produce a schema no 0.1.5
1503
+ * consumer understands — so on that line this degrades to an identity no-op.
1504
+ *
1505
+ * Exported so BOTH arms are testable on any machine. The capability is decided
1506
+ * by whichever schemastery the dependency tree resolves, so without a seam the
1507
+ * no-op arm would silently stop being exercised the moment the pinned version
1508
+ * moved up — which is exactly how the volatile-path defect below went unseen.
1509
+ */
1510
+ declare function asVolatile<T>(schema: z<T>): z<T>;
1511
+ /**
1512
+ * Deep copy of a config value with every `{get(): T}` live reference replaced
1513
+ * by the value it resolves to.
1514
+ *
1515
+ * {@link unwrapVolatile} only peels the ONE level a caller reads, which is all
1516
+ * the ordinary read paths need. Handing a config object to a settings service
1517
+ * is different: the service validates and `structuredClone`s the WHOLE object,
1518
+ * so a reference surviving anywhere inside it fails schema validation with a
1519
+ * message that names the field but not the cause —
1520
+ * `$.authFile expected string but got [object Object]`.
1521
+ *
1522
+ * That is exactly what happened on the 0.1.5 line once volatile marking became
1523
+ * active: `installSection` received the raw config, whose volatile fields were
1524
+ * live references, and every field it validated threw. The namespace never
1525
+ * registered, so the card's settings silently disappeared.
1526
+ *
1527
+ * Non-reference values are recursed into so a nested volatile field is caught
1528
+ * too — arrays are rebuilt rather than mutated, so the caller's config object is
1529
+ * never touched.
1530
+ */
1531
+ declare function unwrapVolatileDeep<T>(value: T): T;
1375
1532
  declare const Config: z<Config>;
1376
1533
  /**
1377
1534
  * One region's saved model state. A config written before the region split has
@@ -1442,4 +1599,4 @@ declare function legacyAttributionRegion(value: Config, accountsFor: (region: Wo
1442
1599
  */
1443
1600
  declare function apply(ctx: Context, config: Config): void;
1444
1601
  //#endregion
1445
- export { CREDENTIAL_REJECTED_CODE, Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, type WorkBuddyCandidateFailure, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, type WorkBuddyCredentialRecovery, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, type WorkBuddyEncryptedField, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyProbeStore, type WorkBuddyReasoning, type WorkBuddyRecoveryCandidate, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyUsabilityProbeOptions, type WorkBuddyWebAccount, type WorkBuddyWebCheckin, type WorkBuddyWebCredits, type WorkBuddyWebModel, type WorkBuddyWebPackage, type WorkBuddyWebRegion, type WorkBuddyWebSearchPath, type WorkBuddyWebUsage, apply, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, inject, isCredentialRejectedError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, name, nextRegionEnabled, nextRegionSlots, openEncryptedField, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
1602
+ export { CREDENTIAL_REJECTED_CODE, Config, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, type WorkBuddyCandidateFailure, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, type WorkBuddyCredentialRecovery, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, WorkBuddyEncryptedCredentialError, type WorkBuddyEncryptedField, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyProbeStore, type WorkBuddyReasoning, type WorkBuddyRecoveryCandidate, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyUsabilityProbeOptions, type WorkBuddyWebAccount, type WorkBuddyWebCheckin, type WorkBuddyWebCredits, type WorkBuddyWebModel, type WorkBuddyWebPackage, type WorkBuddyWebRegion, type WorkBuddyWebSearchPath, type WorkBuddyWebUsage, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nextRegionEnabled, nextRegionSlots, openEncryptedField, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, toPersistedWorkBuddyModel, unwrapVolatileDeep, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };