dsh-connect-workbuddy 2.0.6 → 2.0.11

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.
@@ -1,10 +1,379 @@
1
1
  import { readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
- import { createHash } from "node:crypto";
2
+ import { createDecipheriv, createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  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
- import { execFileSync } from "node:child_process";
7
+ import { execFile, execFileSync } from "node:child_process";
8
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
+ //#region src/at-rest.ts
10
+ /**
11
+ * WorkBuddy desktop "at-rest" credential decryption.
12
+ *
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:
15
+ *
16
+ * { "$wbEncrypted": 1, "envelope": "<base64 of a JSON envelope>" }
17
+ *
18
+ * where the envelope is `{suite, keyId, nonce, authTag, ciphertext}` for
19
+ * AES-256-GCM with a 12-byte nonce and a 16-byte tag. The authenticated
20
+ * additional data is a length-prefixed transcript over the scheme, suite,
21
+ * keyId and framing, so the ciphertext can only be opened for the exact field
22
+ * shape it was sealed for.
23
+ *
24
+ * The field key itself is NOT a user secret: it is a build-time constant
25
+ * compiled into the app's own Electron native module
26
+ * (`electron_browser_workbuddy_storage`). The app fetches it through
27
+ * `loggerGet()` and hashes the returned base64 STRING (not the decoded bytes)
28
+ * to obtain the 32-byte key; `keyId` is the first 16 hex characters of that
29
+ * key's SHA-256.
30
+ *
31
+ * This module re-derives the same key by asking the installed app for the same
32
+ * payload, and caches it in memory for the process lifetime. Nothing is ever
33
+ * written to disk, and the payload is never logged.
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
+ *
45
+ * @module dsh-connect-workbuddy/at-rest
46
+ */
47
+ /** Envelope framing names, mapped to the single-byte AAD framing code. */
48
+ const FRAMING_CODE = {
49
+ file: 1,
50
+ field: 2,
51
+ record: 3,
52
+ stream: 4
53
+ };
54
+ /** Standard (symmetric) format identifiers, transcripted into the AAD. */
55
+ const STANDARD_FORMAT_ID = {
56
+ file: "WBEF1",
57
+ field: "WBEV1",
58
+ record: "WBER1",
59
+ stream: "WBES1"
60
+ };
61
+ /** Domain separator the AAD transcript starts with. */
62
+ const AAD_DOMAIN = Buffer.from("WB-AAD\0", "ascii");
63
+ /** Scheme name of the symmetric envelope this module opens. */
64
+ const SYMMETRIC_SCHEME = "sym-v1";
65
+ /** Env override pointing at the WorkBuddy desktop executable. */
66
+ const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
67
+ /** How long the app is given to answer with its key payload. */
68
+ const KEY_FETCH_TIMEOUT_MS = 1e4;
69
+ /** File name of the WorkBuddy desktop executable on Windows. */
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"];
82
+ function encodeUint32(value) {
83
+ const bytes = Buffer.allocUnsafe(4);
84
+ bytes.writeUInt32BE(value);
85
+ return bytes;
86
+ }
87
+ /** Length-prefixed UTF-8 string: uint32 big-endian length followed by the bytes. */
88
+ function encodeLengthPrefixed(value) {
89
+ const bytes = Buffer.from(value, "utf8");
90
+ return Buffer.concat([encodeUint32(bytes.length), bytes]);
91
+ }
92
+ /**
93
+ * The authenticated additional data for one `sym-v1` FIELD-framed envelope.
94
+ *
95
+ * Only the field framing is implemented: it is the shape the desktop app uses
96
+ * for credential fields, and it is also the shape that cannot be confused with
97
+ * a whole-file envelope, so an unexpected framing is a parse error rather than
98
+ * a silently wrong transcript.
99
+ */
100
+ function fieldAad(keyId, suite, scheme = SYMMETRIC_SCHEME) {
101
+ if (!/^[0-9a-f]{16}$/u.test(keyId)) throw new Error(`workbuddy: envelope keyId is malformed`);
102
+ return Buffer.concat([
103
+ AAD_DOMAIN,
104
+ Buffer.from([1]),
105
+ encodeLengthPrefixed(STANDARD_FORMAT_ID["field"]),
106
+ encodeLengthPrefixed(scheme),
107
+ encodeUint32(suite),
108
+ encodeLengthPrefixed(keyId),
109
+ Buffer.from([FRAMING_CODE["field"]]),
110
+ Buffer.from([0]),
111
+ Buffer.from([0])
112
+ ]);
113
+ }
114
+ /** Whether a value is the app's encrypted-field wrapper. */
115
+ function isEncryptedFieldWrapper(value) {
116
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
117
+ const wrapper = value;
118
+ const keys = Object.keys(wrapper).sort();
119
+ return keys.length === 2 && keys[0] === "$wbEncrypted" && keys[1] === "envelope" && wrapper["$wbEncrypted"] === 1 && typeof wrapper["envelope"] === "string";
120
+ }
121
+ /**
122
+ * The at-rest key id for a derived 32-byte key: the first 16 hex characters of
123
+ * its SHA-256. This is what the envelope's `keyId` is checked against, so a
124
+ * mismatched key fails loudly instead of returning garbage.
125
+ */
126
+ function deriveAtRestKeyId(key) {
127
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
128
+ }
129
+ /**
130
+ * Derive the 32-byte field key from the app's key payload JSON.
131
+ *
132
+ * The app hashes the payload's base64 STRING — not its decoded bytes — so the
133
+ * same spelling is required here; hashing the decoded secret would produce a
134
+ * different key and every field would fail to open.
135
+ */
136
+ function deriveAtRestKey(payloadJson) {
137
+ let payload;
138
+ try {
139
+ payload = JSON.parse(payloadJson);
140
+ } catch {
141
+ throw new Error("workbuddy: at-rest key payload is not valid JSON");
142
+ }
143
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("workbuddy: at-rest key payload is not an object");
144
+ const secret = payload["atRestSecretKey"];
145
+ if (typeof secret !== "string" || secret === "") throw new Error("workbuddy: at-rest key payload carries no atRestSecretKey");
146
+ return createHash("sha256").update(secret, "utf8").digest();
147
+ }
148
+ /**
149
+ * Open one encrypted field with a derived key and return its plaintext.
150
+ *
151
+ * Throws when the envelope is malformed, belongs to another key, or fails
152
+ * authentication — a GCM tag mismatch is the signal that the transcript or the
153
+ * key is wrong, and it must never degrade into a truncated token.
154
+ */
155
+ function openEncryptedField(field, key) {
156
+ let envelope;
157
+ try {
158
+ envelope = JSON.parse(Buffer.from(field.envelope, "base64").toString("utf8"));
159
+ } catch {
160
+ throw new Error("workbuddy: encrypted field envelope is not valid JSON");
161
+ }
162
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) throw new Error("workbuddy: encrypted field envelope is not an object");
163
+ const record = envelope;
164
+ const suite = record["suite"];
165
+ const keyId = record["keyId"];
166
+ const nonce = record["nonce"];
167
+ const authTag = record["authTag"];
168
+ const ciphertext = record["ciphertext"];
169
+ if (typeof suite !== "number" || typeof keyId !== "string") throw new Error("workbuddy: encrypted field envelope is missing suite or keyId");
170
+ if (typeof nonce !== "string" || typeof authTag !== "string" || typeof ciphertext !== "string") throw new Error("workbuddy: encrypted field envelope is missing nonce, authTag or ciphertext");
171
+ const expectedKeyId = deriveAtRestKeyId(key);
172
+ if (keyId !== expectedKeyId) throw new Error(`workbuddy: encrypted field belongs to key ${keyId}, not the available key ${expectedKeyId}`);
173
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "base64"), { authTagLength: 16 });
174
+ decipher.setAAD(fieldAad(keyId, suite));
175
+ decipher.setAuthTag(Buffer.from(authTag, "base64"));
176
+ return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]).toString("utf8");
177
+ }
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
+ /**
204
+ * Candidate paths of the WorkBuddy desktop executable, in probe order.
205
+ *
206
+ * The Windows build is the one that encrypts credentials, so Windows leads;
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.
219
+ */
220
+ function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env, readBundleExecutable = macosBundleExecutable) {
221
+ const candidates = [env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim()];
222
+ if (platform === "win32") {
223
+ const local = env["LOCALAPPDATA"]?.trim();
224
+ const programFiles = env["ProgramFiles"]?.trim();
225
+ const programFilesX86 = env["ProgramFiles(x86)"]?.trim();
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)));
228
+ return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
229
+ }
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
+ /**
300
+ * The first candidate that exists as a file, or undefined when the desktop app
301
+ * is not installed where this platform expects it.
302
+ */
303
+ function findWorkbuddyAppExecutable(platform = process.platform, home = homedir(), env = process.env) {
304
+ for (const candidate of workbuddyAppExecutableCandidates(platform, home, env)) try {
305
+ if (existsSync(candidate)) return candidate;
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
+ }
314
+ }
315
+ /**
316
+ * Ask the installed desktop app for its key payload by running its own binary
317
+ * as plain Node (`ELECTRON_RUN_AS_NODE`) and calling the native binding.
318
+ *
319
+ * The binding is the app's own public surface for this value, so the plugin
320
+ * never has to carry a copy of a build-specific constant: it asks the very
321
+ * build that wrote the file. The child is given no stdin and a hard timeout,
322
+ * and its stdout is the only thing read.
323
+ */
324
+ function fetchAtRestKeyPayload(executable) {
325
+ return new Promise((resolve, reject) => {
326
+ 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))}"], {
327
+ env: {
328
+ ...process.env,
329
+ ELECTRON_RUN_AS_NODE: "1"
330
+ },
331
+ timeout: KEY_FETCH_TIMEOUT_MS,
332
+ windowsHide: true,
333
+ maxBuffer: 1048576
334
+ }, (error, stdout, stderr) => {
335
+ if (error !== null) {
336
+ reject(/* @__PURE__ */ new Error(`workbuddy: the desktop app did not provide its at-rest key (${stderr.trim() || error.message})`));
337
+ return;
338
+ }
339
+ const payload = stdout.trim();
340
+ if (payload === "") {
341
+ reject(/* @__PURE__ */ new Error("workbuddy: the desktop app returned an empty at-rest key payload"));
342
+ return;
343
+ }
344
+ resolve(payload);
345
+ });
346
+ });
347
+ }
348
+ /** Process-lifetime cache of the derived key; never persisted. */
349
+ let cachedKey;
350
+ let inflightKey;
351
+ /**
352
+ * The desktop app's at-rest field key, or undefined when it cannot be obtained
353
+ * (app not installed, an older build without the native module, or a future
354
+ * build that rotates the payload). Cached after the first success so the app is
355
+ * spawned at most once per process; a failure is retried on the next call,
356
+ * because the user may install or start the app between reads.
357
+ */
358
+ function readAtRestKey() {
359
+ if (cachedKey !== void 0) return Promise.resolve(cachedKey);
360
+ inflightKey ??= (async () => {
361
+ const executable = findWorkbuddyAppExecutable();
362
+ if (executable === void 0) return void 0;
363
+ const key = deriveAtRestKey(await fetchAtRestKeyPayload(executable));
364
+ cachedKey = key;
365
+ return key;
366
+ })().finally(() => {
367
+ inflightKey = void 0;
368
+ });
369
+ return inflightKey;
370
+ }
371
+ /** Drop the cached key; tests and diagnostics only. */
372
+ function clearAtRestKeyCache() {
373
+ cachedKey = void 0;
374
+ inflightKey = void 0;
375
+ }
376
+ //#endregion
8
377
  //#region src/upstream.ts
9
378
  const CN_CHAT_BASE = "https://copilot.tencent.com";
10
379
  const CN_BILLING_BASE = "https://www.codebuddy.cn";
@@ -235,13 +604,41 @@ function isGatewayAuthRejection(status, text) {
235
604
  const lower = text.toLowerCase();
236
605
  return lower.includes("openresty") || lower.includes("apisix") || lower.includes("authorization required");
237
606
  }
607
+ /** Marker carried by {@link WorkBuddyCredentialRejectedError}; survives bundling. */
608
+ const CREDENTIAL_REJECTED_CODE = "WORKBUDDY_CREDENTIAL_REJECTED";
609
+ /**
610
+ * The upstream refused the CREDENTIAL itself rather than failing the request.
611
+ *
612
+ * This is a distinct, actionable class: the token is not usable and no retry
613
+ * with the same token will help. Callers use it to tell the user what to do
614
+ * about it (switch accounts, or sign in again) instead of showing a raw HTTP
615
+ * error, and the card must never confuse it with a transient upstream fault.
616
+ *
617
+ * Identified by {@link CREDENTIAL_REJECTED_CODE} rather than `instanceof`, so
618
+ * the check keeps working when the caller and the thrower end up in different
619
+ * module instances (bundled host half vs. a test's source import).
620
+ */
621
+ var WorkBuddyCredentialRejectedError = class extends Error {
622
+ code = CREDENTIAL_REJECTED_CODE;
623
+ /** HTTP status the upstream answered with (401 or 403). */
624
+ status;
625
+ constructor(status) {
626
+ super(`workbuddy: the signed-in credential was rejected by the upstream gateway (http ${status}). The stored token is no longer accepted — most likely a stale credential file from an earlier sign-in was selected. Re-sign in to the WorkBuddy desktop app, then pick that account in the plugin card. Run \`dsh-connect-workbuddy doctor\` to list every discovered credential.`);
627
+ this.name = "WorkBuddyCredentialRejectedError";
628
+ this.status = status;
629
+ }
630
+ };
631
+ /** Whether an error reports that the upstream refused the credential itself. */
632
+ function isCredentialRejectedError(value) {
633
+ return typeof value === "object" && value !== null && value.code === "WORKBUDDY_CREDENTIAL_REJECTED";
634
+ }
238
635
  async function readEnvelope(response) {
239
636
  const text = await response.text();
240
637
  let parsed;
241
638
  try {
242
639
  parsed = JSON.parse(text);
243
640
  } catch {
244
- if (isGatewayAuthRejection(response.status, text)) throw new Error(`workbuddy: the signed-in credential was rejected by the upstream gateway (http ${response.status}). The stored token is no longer accepted — most likely a stale credential file from an earlier sign-in was selected. Re-sign in to the WorkBuddy desktop app, then pick that account in the plugin card. Run \`dsh-connect-workbuddy doctor\` to list every discovered credential.`);
641
+ if (isGatewayAuthRejection(response.status, text)) throw new WorkBuddyCredentialRejectedError(response.status);
245
642
  throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
246
643
  }
247
644
  if (typeof parsed !== "object" || parsed === null) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
@@ -252,8 +649,15 @@ async function readEnvelope(response) {
252
649
  data: "data" in document ? document["data"] : void 0
253
650
  };
254
651
  }
255
- /** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
652
+ /**
653
+ * Fail an envelope whose business code is non-zero, classified like HTTP errors.
654
+ *
655
+ * A JSON body on a 401/403 is still a credential refusal: the edge answered in
656
+ * the business shape, but the token is just as unusable, so it maps to the same
657
+ * actionable error instead of a generic "client" failure.
658
+ */
256
659
  function envelopeError(status, envelope) {
660
+ if (status === 401 || status === 403) return new WorkBuddyCredentialRejectedError(status);
257
661
  const kind = classifyUpstreamError(status, envelope.msg);
258
662
  return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
259
663
  }
@@ -631,9 +1035,52 @@ var WorkBuddyUpstreamClient = class {
631
1035
  * 一个 store,账号、刷新、选择完全隔离;插件自有刷新副本也按区域分
632
1036
  * 文件(`.workbuddy-auth.<region>.json`),双账号同时在线互不覆盖,
633
1037
  * 旧的单文件 `.workbuddy-auth.json` 作为迁移源保留读取。
1038
+ * 另(issue #15):`resolve()` 不再把「没登录」与「有登录但凭据读不出来」
1039
+ * 合并成同一句话。后者(加密字段 + 取不到密钥)改为抛
1040
+ * `WorkBuddyEncryptedCredentialError`,给出「装 App / 用
1041
+ * WORKBUDDY_APP_EXECUTABLE 指定」这条**可执行**的建议——对已登录的用户说
1042
+ * 「请重新登录一次」指向的是唯一无效的动作。判定只在失败路径上跑一次
1043
+ * `diagnose()`,健康路径不付任何代价。
634
1044
  *
635
1045
  * @module dsh-connect-workbuddy/auth
636
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
+ }
637
1084
  /** Legacy single-copy basename (pre-dual-provider); kept as migration source. */
638
1085
  const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
639
1086
  /** Env variable that overrides the desktop auth-file location. */
@@ -701,15 +1148,57 @@ function expiryToMs(value) {
701
1148
  if (value <= 0) return 0;
702
1149
  return value > 0xe8d4a51000 ? value : value * 1e3;
703
1150
  }
704
- function optionalString(value) {
705
- return typeof value === "string" && value !== "" ? value : void 0;
1151
+ /**
1152
+ * Resolve one string field, transparently opening the desktop app's
1153
+ * encrypted-field wrapper when present.
1154
+ *
1155
+ * Windows builds of the WorkBuddy desktop app store `accessToken` and
1156
+ * `refreshToken` as `{$wbEncrypted:1,envelope}` instead of plain strings; a
1157
+ * reader that only accepts strings sees no credential at all and reports the
1158
+ * account as signed out. The SAME treatment applies to `nickname`: it is
1159
+ * encrypted in those builds too, so a strings-only reader silently degrades the
1160
+ * account's display name to its uin (a bare number) even after tokens start
1161
+ * working — which reads as "the plugin does not know who this is".
1162
+ *
1163
+ * `key` is undefined when the at-rest key could not be obtained, in which case
1164
+ * an encrypted field resolves to undefined rather than to a fabricated value.
1165
+ */
1166
+ function credentialField(value, key) {
1167
+ if (typeof value === "string") return value;
1168
+ if (!isEncryptedFieldWrapper(value)) return void 0;
1169
+ if (key === void 0) return void 0;
1170
+ return openEncryptedField(value, key);
1171
+ }
1172
+ /**
1173
+ * An optional field that may legitimately be absent: an absent value, a
1174
+ * non-string that is not a wrapper, and an unopenable wrapper all mean
1175
+ * "unknown", which must not fail the whole document. Only the REQUIRED token
1176
+ * fields treat an unopenable wrapper as fatal (see {@link parseWorkBuddyAuth}).
1177
+ *
1178
+ * `account.phoneNumber` is deliberately never read: it is encrypted in these
1179
+ * builds, and the card's privacy contract admits nickname, masked uin, expiry
1180
+ * and credits only — a phone number is none of the plugin's business.
1181
+ */
1182
+ function optionalCredentialField(value, key) {
1183
+ if (typeof value === "string") return value === "" ? void 0 : value;
1184
+ if (!isEncryptedFieldWrapper(value)) return void 0;
1185
+ if (key === void 0) return void 0;
1186
+ try {
1187
+ return openEncryptedField(value, key);
1188
+ } catch {
1189
+ return;
1190
+ }
706
1191
  }
707
1192
  /**
708
1193
  * Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
709
1194
  * nested form `{"auth":{...},"account":{...}}` and the flat panel form.
710
1195
  * Returns undefined when the document carries no access token.
1196
+ *
1197
+ * `atRestKey` is required to read documents whose fields are encrypted
1198
+ * (see {@link credentialField}); pass the key obtained from
1199
+ * `readAtRestKey()` when the plain read reports no token.
711
1200
  */
712
- function parseWorkBuddyAuth(text, filePath) {
1201
+ function parseWorkBuddyAuth(text, filePath, atRestKey) {
713
1202
  let parsed;
714
1203
  try {
715
1204
  parsed = JSON.parse(text);
@@ -727,21 +1216,28 @@ function parseWorkBuddyAuth(text, filePath) {
727
1216
  auth = document;
728
1217
  identity = document;
729
1218
  }
730
- const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
731
- if (accessToken === "") return void 0;
1219
+ let accessToken;
1220
+ let refreshToken;
1221
+ try {
1222
+ accessToken = credentialField(auth["accessToken"], atRestKey);
1223
+ refreshToken = credentialField(auth["refreshToken"], atRestKey);
1224
+ } catch {
1225
+ return;
1226
+ }
1227
+ if (accessToken === void 0 || accessToken === "") return void 0;
732
1228
  const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
733
1229
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
734
1230
  const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
735
- const enterpriseId = optionalString(identity["enterpriseId"]);
736
- const nickname = optionalString(identity["nickname"]);
737
- const uin = optionalString(identity["uin"]);
1231
+ const enterpriseId = optionalCredentialField(identity["enterpriseId"], atRestKey);
1232
+ const nickname = optionalCredentialField(identity["nickname"], atRestKey);
1233
+ const uin = optionalCredentialField(identity["uin"], atRestKey);
738
1234
  return {
739
1235
  accessToken,
740
- refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
1236
+ refreshToken: refreshToken ?? "",
741
1237
  expiresAtMs,
742
1238
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
743
- domain: optionalString(auth["domain"]) ?? "",
744
- uid: optionalString(identity["uid"]) ?? "",
1239
+ domain: optionalCredentialField(auth["domain"], atRestKey) ?? "",
1240
+ uid: optionalCredentialField(identity["uid"], atRestKey) ?? "",
745
1241
  ...enterpriseId === void 0 ? {} : { enterpriseId },
746
1242
  ...nickname === void 0 ? {} : { nickname },
747
1243
  ...uin === void 0 ? {} : { uin },
@@ -751,6 +1247,30 @@ function parseWorkBuddyAuth(text, filePath) {
751
1247
  };
752
1248
  }
753
1249
  /**
1250
+ * Whether a document carries any field the reader must decrypt — the token
1251
+ * fields or the identity fields — i.e. whether reading it needs the at-rest key
1252
+ * at all.
1253
+ *
1254
+ * Deciding this BEFORE asking the app for its key keeps the plain case (macOS
1255
+ * and older Windows builds, and every plugin-owned copy) from paying for a
1256
+ * child process on every credential read. Identity fields are included because
1257
+ * a future build could encrypt the display name while leaving tokens plain;
1258
+ * gating on tokens alone would then silently drop the name again.
1259
+ */
1260
+ function hasEncryptedCredentialFields(text) {
1261
+ let parsed;
1262
+ try {
1263
+ parsed = JSON.parse(text);
1264
+ } catch {
1265
+ return false;
1266
+ }
1267
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
1268
+ const document = parsed;
1269
+ const auth = typeof document["auth"] === "object" && document["auth"] !== null ? document["auth"] : document;
1270
+ const identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : document;
1271
+ return isEncryptedFieldWrapper(auth["accessToken"]) || isEncryptedFieldWrapper(auth["refreshToken"]) || isEncryptedFieldWrapper(identity["nickname"]) || isEncryptedFieldWrapper(identity["uin"]) || isEncryptedFieldWrapper(identity["uid"]) || isEncryptedFieldWrapper(identity["enterpriseId"]) || isEncryptedFieldWrapper(auth["domain"]);
1272
+ }
1273
+ /**
754
1274
  * Rank two candidate files for the same account.
755
1275
  *
756
1276
  * The live `workbuddy-desktop.info` always wins: it is the app's current
@@ -837,14 +1357,70 @@ function parseOwnDocument(text, filePath) {
837
1357
  function isENOENT(error) {
838
1358
  return error?.code === "ENOENT";
839
1359
  }
840
- /** Read one auth file, tolerating absence and unparsable content. */
841
- async function readAuthFile(path) {
1360
+ /**
1361
+ * Probe one auth file, reporting WHY it yielded no credential.
1362
+ *
1363
+ * The desktop app encrypts its token fields on Windows builds. The plain read
1364
+ * is tried first and the app is only asked for its at-rest key when the
1365
+ * document actually carries encrypted wrappers, so the common case costs no
1366
+ * child process. `resolveAtRestKey` is injectable so the encrypted path is
1367
+ * testable without a real desktop install.
1368
+ */
1369
+ async function probeAuthFile(path, resolveAtRestKey) {
1370
+ let text;
842
1371
  try {
843
- return parseWorkBuddyAuth(await readFile(path, "utf8"), path);
1372
+ text = await readFile(path, "utf8");
844
1373
  } catch (error) {
845
- if (isENOENT(error)) return void 0;
846
- return;
1374
+ const missing = isENOENT(error);
1375
+ return { failure: {
1376
+ reason: missing ? "missing" : "unreadable",
1377
+ ...missing ? {} : { message: error instanceof Error ? error.message : String(error) }
1378
+ } };
1379
+ }
1380
+ const plain = parseWorkBuddyAuth(text, path);
1381
+ if (plain !== void 0) return { credential: plain };
1382
+ if (!hasEncryptedCredentialFields(text)) return isParseableJson(text) ? { failure: {
1383
+ reason: "invalid",
1384
+ message: "no access token in the document"
1385
+ } } : { failure: {
1386
+ reason: "invalid",
1387
+ message: "the file is not valid JSON"
1388
+ } };
1389
+ let key;
1390
+ try {
1391
+ key = await resolveAtRestKey();
1392
+ } catch (error) {
1393
+ return { failure: {
1394
+ reason: "encrypted",
1395
+ message: error instanceof Error ? error.message : String(error)
1396
+ } };
847
1397
  }
1398
+ if (key === void 0) return { failure: {
1399
+ reason: "encrypted",
1400
+ message: `the credential fields are encrypted and no key was available; the WorkBuddy desktop app must be present (or set ${WORKBUDDY_APP_EXECUTABLE_ENV})`
1401
+ } };
1402
+ const decrypted = parseWorkBuddyAuth(text, path, key);
1403
+ return decrypted === void 0 ? { failure: {
1404
+ reason: "invalid",
1405
+ message: "the encrypted credential fields could not be opened with the desktop app's key"
1406
+ } } : { credential: decrypted };
1407
+ }
1408
+ /** Whether text parses as JSON at all; distinguishes "wrong shape" from "not JSON". */
1409
+ function isParseableJson(text) {
1410
+ try {
1411
+ JSON.parse(text);
1412
+ return true;
1413
+ } catch {
1414
+ return false;
1415
+ }
1416
+ }
1417
+ /**
1418
+ * Read one auth file, tolerating absence and unparsable content. Kept as the
1419
+ * credential-only view for callers that do not need the failure reason.
1420
+ */
1421
+ async function readAuthFile(path, resolveAtRestKey) {
1422
+ const probe = await probeAuthFile(path, resolveAtRestKey);
1423
+ return "credential" in probe ? probe.credential : void 0;
848
1424
  }
849
1425
  /**
850
1426
  * Read-only credential store with demand-driven refresh and multi-account
@@ -864,6 +1440,7 @@ var WorkBuddyCredentialStore = class {
864
1440
  legacyOwnPath;
865
1441
  legacyOwnPathExplicit;
866
1442
  authDirs;
1443
+ resolveAtRestKey;
867
1444
  desktopPathOverride;
868
1445
  accountId;
869
1446
  inflight;
@@ -876,6 +1453,7 @@ var WorkBuddyCredentialStore = class {
876
1453
  this.legacyOwnPathExplicit = options.legacyOwnPath;
877
1454
  this.authDirs = options.authDirs;
878
1455
  this.desktopPathOverride = options.desktopPath;
1456
+ this.resolveAtRestKey = options.resolveAtRestKey ?? readAtRestKey;
879
1457
  }
880
1458
  /** Whether a credential's login domain belongs to this store's region. */
881
1459
  matchesRegion(domain) {
@@ -1005,7 +1583,7 @@ var WorkBuddyCredentialStore = class {
1005
1583
  const files = await this.candidateFiles();
1006
1584
  const byId = /* @__PURE__ */ new Map();
1007
1585
  for (const file of files) {
1008
- const credential = await readAuthFile(file);
1586
+ const credential = await readAuthFile(file, this.resolveAtRestKey);
1009
1587
  if (credential === void 0 || !this.matchesRegion(credential.domain)) continue;
1010
1588
  const id = workbuddyAccountId(credential);
1011
1589
  const existing = byId.get(id);
@@ -1059,8 +1637,7 @@ var WorkBuddyCredentialStore = class {
1059
1637
  const id = workbuddyAccountId(credential);
1060
1638
  return {
1061
1639
  id,
1062
- accountName: credential.nickname ?? credential.uin ?? credential.uid,
1063
- ...credential.uin === void 0 ? {} : { uin: credential.uin },
1640
+ accountName: credential.nickname ?? "",
1064
1641
  domain: credential.domain,
1065
1642
  source: credential.source,
1066
1643
  tokenExpiresAtMs: credential.expiresAtMs,
@@ -1091,20 +1668,53 @@ var WorkBuddyCredentialStore = class {
1091
1668
  if (this.accountId === void 0) return this.preferred(credentials);
1092
1669
  return credentials.find((credential) => workbuddyAccountId(credential) === this.accountId);
1093
1670
  }
1671
+ /**
1672
+ * One account's stored credential by id, WITHOUT consulting or changing the
1673
+ * current selection.
1674
+ *
1675
+ * Exists so a recovery probe can test whether some OTHER local account is
1676
+ * still accepted upstream while a rejected one stays selected: mutating the
1677
+ * live selection to find that out would be exactly the silent account switch
1678
+ * this store refuses to perform (it would bill the wrong account mid-probe).
1679
+ */
1680
+ async credentialFor(accountId) {
1681
+ return (await this.readAll()).find((credential) => workbuddyAccountId(credential) === accountId);
1682
+ }
1094
1683
  /** The credential to send upstream: {@link current}, refreshed on demand. */
1095
1684
  async resolve() {
1096
1685
  const credential = await this.current();
1097
- if (credential === void 0) {
1098
- const candidates = this.resolveDesktopCandidates();
1099
- const desktop = candidates.length > 0 ? candidates.join(" or ") : "(no desktop path on this platform)";
1100
- 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`);
1101
- }
1686
+ if (credential === void 0) throw await this.describeMissingCredential();
1102
1687
  if (!this.needsRefresh(credential)) return credential;
1103
1688
  this.inflight ??= this.refreshNow(credential).finally(() => {
1104
1689
  this.inflight = void 0;
1105
1690
  });
1106
1691
  return this.inflight;
1107
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
+ }
1108
1718
  /** Read-only sign-in summary; never refreshes and never throws. */
1109
1719
  async status() {
1110
1720
  try {
@@ -1190,6 +1800,66 @@ var WorkBuddyCredentialStore = class {
1190
1800
  } catch {}
1191
1801
  return false;
1192
1802
  }
1803
+ /**
1804
+ * Which paths were probed and why each one yielded no credential.
1805
+ *
1806
+ * Read-only and token-free: it exists so a signed-out card can explain
1807
+ * itself. A bare "not signed in" is undiagnosable on a machine whose layout
1808
+ * differs from the ones this plugin was written against — and on Windows it
1809
+ * is actively misleading, because encrypted token fields need the desktop app
1810
+ * present to be read at all. Only paths this store actually consults are
1811
+ * reported, and only files that failed: one healthy sibling would make the
1812
+ * whole list noise.
1813
+ */
1814
+ async diagnose() {
1815
+ const candidates = [];
1816
+ for (const path of await this.candidateFiles()) {
1817
+ const probe = await probeAuthFile(path, this.resolveAtRestKey);
1818
+ if ("credential" in probe) {
1819
+ if (!this.matchesRegion(probe.credential.domain)) candidates.push({
1820
+ path,
1821
+ source: "desktop",
1822
+ reason: "wrong-region",
1823
+ message: `holds a ${regionOf(probe.credential.domain)} sign-in, but this tab reads the ${this.region} region`
1824
+ });
1825
+ continue;
1826
+ }
1827
+ candidates.push({
1828
+ path,
1829
+ source: "desktop",
1830
+ ...probe.failure
1831
+ });
1832
+ }
1833
+ for (const path of this.ownCandidates()) {
1834
+ let text;
1835
+ try {
1836
+ text = await readFile(path, "utf8");
1837
+ } catch (error) {
1838
+ if (isENOENT(error)) continue;
1839
+ candidates.push({
1840
+ path,
1841
+ source: "dsh",
1842
+ reason: "unreadable",
1843
+ message: error instanceof Error ? error.message : String(error)
1844
+ });
1845
+ continue;
1846
+ }
1847
+ const parsed = parseOwnDocument(text, path);
1848
+ if (parsed === void 0 || !this.matchesRegion(parsed.domain)) {
1849
+ candidates.push({
1850
+ path,
1851
+ source: "dsh",
1852
+ reason: "invalid",
1853
+ message: "not a readable plugin-owned credential"
1854
+ });
1855
+ continue;
1856
+ }
1857
+ }
1858
+ return {
1859
+ tried: [...await this.candidateFiles(), ...this.ownCandidates()],
1860
+ failures: candidates
1861
+ };
1862
+ }
1193
1863
  };
1194
1864
  //#endregion
1195
1865
  //#region src/catalog.ts
@@ -1458,6 +2128,18 @@ var WorkBuddyCatalog = class {
1458
2128
  */
1459
2129
  usable = true;
1460
2130
  /**
2131
+ * Whether the user has switched this region's provider ON. Opt-out: defaults
2132
+ * to `true`, so a config predating this switch (and the pre-region-split flat
2133
+ * fields, which never carry `enabled`) keeps both providers running. Set to
2134
+ * `false` by `setRegionEnabled` to withdraw the region from DSH's model
2135
+ * picker entirely (its adapter route and configurable-provider entry are
2136
+ * pulled by the Host via `AdapterRegistrationHandle.replace([])` /
2137
+ * `DirectoryRegistrationHandle.replace([])`). This is a USER choice, distinct
2138
+ * from {@link usable}: a region the user switched off keeps its catalog,
2139
+ * accounts, and model picks intact and reappears the moment they re-check it.
2140
+ */
2141
+ enabled = true;
2142
+ /**
1461
2143
  * @param region Seeds the static fallback for this region; each region's
1462
2144
  * provider must never serve the other region's roster before its first
1463
2145
  * live refresh lands.
@@ -1468,19 +2150,25 @@ var WorkBuddyCatalog = class {
1468
2150
  /**
1469
2151
  * Current entries; the fallback list until the upstream answer lands.
1470
2152
  *
1471
- * Empty while this region has no local sign-in: the provider stays
1472
- * registered (its row still hosts the settings card and the account picker),
1473
- * but it advertises nothing to pick. DSH drops empty provider groups from
1474
- * the picker (`buildModelCatalog` filters `models.length > 0`), so the group
1475
- * disappears exactly when it would be pure noise.
2153
+ * Empty while this region has no local sign-in OR the user has switched it
2154
+ * off: the provider stays registered (its row still hosts the settings card
2155
+ * and the account picker), but it advertises nothing to pick. DSH drops empty
2156
+ * provider groups from the picker (`buildModelCatalog` filters
2157
+ * `models.length > 0`), so the group disappears exactly when it would be pure
2158
+ * noise. The two empty causes are independent: `usable` is the automatic
2159
+ * "no account" gate (issue #12), `enabled` is the user's explicit on/off.
1476
2160
  */
1477
2161
  current() {
1478
- return this.usable ? this.models : [];
2162
+ return this.usable && this.enabled ? this.models : [];
1479
2163
  }
1480
2164
  /** Whether this region currently advertises any model. */
1481
2165
  isRegionUsable() {
1482
2166
  return this.usable;
1483
2167
  }
2168
+ /** Whether the user has this region's provider switched on. */
2169
+ isRegionEnabled() {
2170
+ return this.enabled;
2171
+ }
1484
2172
  /**
1485
2173
  * Record whether this region has a local sign-in.
1486
2174
  *
@@ -1497,6 +2185,20 @@ var WorkBuddyCatalog = class {
1497
2185
  this.usable = usable;
1498
2186
  return true;
1499
2187
  }
2188
+ /**
2189
+ * Record the user's explicit on/off choice for this region's provider.
2190
+ *
2191
+ * Distinct from {@link setRegionUsable}: that is the automatic "no local
2192
+ * account" gate, while this is a deliberate user action (issue #11-style
2193
+ * region switch). The Host reads the same `enabled` flag through
2194
+ * `regionStateOf(config, region).enabled !== false`, so the two halves never
2195
+ * disagree. Returns whether the value changed.
2196
+ */
2197
+ setRegionEnabled(enabled) {
2198
+ if (this.enabled === enabled) return false;
2199
+ this.enabled = enabled;
2200
+ return true;
2201
+ }
1500
2202
  /** Replace the list; callers invalidate their adapter snapshot after this. */
1501
2203
  set(models) {
1502
2204
  if (models.length === 0) throw new Error("workbuddy model catalog cannot be empty");
@@ -1506,7 +2208,7 @@ var WorkBuddyCatalog = class {
1506
2208
  //#endregion
1507
2209
  //#region src/version.ts
1508
2210
  /** The npm package version this build was produced from. */
1509
- const WORKBUDDY_CONNECT_VERSION = "2.0.6";
2211
+ const WORKBUDDY_CONNECT_VERSION = "2.0.11";
1510
2212
  //#endregion
1511
2213
  //#region src/host-heartbeat.ts
1512
2214
  /**
@@ -1612,4 +2314,4 @@ async function clearHostHeartbeat() {
1612
2314
  await rm(workbuddyHostHeartbeatPath(), { force: true });
1613
2315
  }
1614
2316
  //#endregion
1615
- export { prepareChatBody as A, workbuddyAccountId as C, parseCreditMultiplier as D, classifyUpstreamError as E, parseReasoning as O, parseWorkBuddyAuth as S, WorkBuddyUpstreamClient as T, 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, regionOf as j, parseUpstreamModel 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, workbuddyOwnAuthPath as w, legacyWorkbuddyOwnAuthPath as x, defaultDesktopAuthDirs as y };
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 };