dsh-connect-workbuddy 2.0.5 → 2.0.10

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,249 @@
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 } from "node:fs";
9
+ //#region src/at-rest.ts
10
+ /**
11
+ * WorkBuddy desktop "at-rest" credential decryption.
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:
16
+ *
17
+ * { "$wbEncrypted": 1, "envelope": "<base64 of a JSON envelope>" }
18
+ *
19
+ * where the envelope is `{suite, keyId, nonce, authTag, ciphertext}` for
20
+ * AES-256-GCM with a 12-byte nonce and a 16-byte tag. The authenticated
21
+ * additional data is a length-prefixed transcript over the scheme, suite,
22
+ * keyId and framing, so the ciphertext can only be opened for the exact field
23
+ * shape it was sealed for.
24
+ *
25
+ * The field key itself is NOT a user secret: it is a build-time constant
26
+ * compiled into the app's own Electron native module
27
+ * (`electron_browser_workbuddy_storage`). The app fetches it through
28
+ * `loggerGet()` and hashes the returned base64 STRING (not the decoded bytes)
29
+ * to obtain the 32-byte key; `keyId` is the first 16 hex characters of that
30
+ * key's SHA-256.
31
+ *
32
+ * This module re-derives the same key by asking the installed app for the same
33
+ * payload, and caches it in memory for the process lifetime. Nothing is ever
34
+ * written to disk, and the payload is never logged.
35
+ *
36
+ * @module dsh-connect-workbuddy/at-rest
37
+ */
38
+ /** Envelope framing names, mapped to the single-byte AAD framing code. */
39
+ const FRAMING_CODE = {
40
+ file: 1,
41
+ field: 2,
42
+ record: 3,
43
+ stream: 4
44
+ };
45
+ /** Standard (symmetric) format identifiers, transcripted into the AAD. */
46
+ const STANDARD_FORMAT_ID = {
47
+ file: "WBEF1",
48
+ field: "WBEV1",
49
+ record: "WBER1",
50
+ stream: "WBES1"
51
+ };
52
+ /** Domain separator the AAD transcript starts with. */
53
+ const AAD_DOMAIN = Buffer.from("WB-AAD\0", "ascii");
54
+ /** Scheme name of the symmetric envelope this module opens. */
55
+ const SYMMETRIC_SCHEME = "sym-v1";
56
+ /** Env override pointing at the WorkBuddy desktop executable. */
57
+ const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
58
+ /** How long the app is given to answer with its key payload. */
59
+ const KEY_FETCH_TIMEOUT_MS = 1e4;
60
+ /** File name of the WorkBuddy desktop executable, per platform. */
61
+ const APP_EXECUTABLE_NAME = "WorkBuddy.exe";
62
+ function encodeUint32(value) {
63
+ const bytes = Buffer.allocUnsafe(4);
64
+ bytes.writeUInt32BE(value);
65
+ return bytes;
66
+ }
67
+ /** Length-prefixed UTF-8 string: uint32 big-endian length followed by the bytes. */
68
+ function encodeLengthPrefixed(value) {
69
+ const bytes = Buffer.from(value, "utf8");
70
+ return Buffer.concat([encodeUint32(bytes.length), bytes]);
71
+ }
72
+ /**
73
+ * The authenticated additional data for one `sym-v1` FIELD-framed envelope.
74
+ *
75
+ * Only the field framing is implemented: it is the shape the desktop app uses
76
+ * for credential fields, and it is also the shape that cannot be confused with
77
+ * a whole-file envelope, so an unexpected framing is a parse error rather than
78
+ * a silently wrong transcript.
79
+ */
80
+ function fieldAad(keyId, suite, scheme = SYMMETRIC_SCHEME) {
81
+ if (!/^[0-9a-f]{16}$/u.test(keyId)) throw new Error(`workbuddy: envelope keyId is malformed`);
82
+ return Buffer.concat([
83
+ AAD_DOMAIN,
84
+ Buffer.from([1]),
85
+ encodeLengthPrefixed(STANDARD_FORMAT_ID["field"]),
86
+ encodeLengthPrefixed(scheme),
87
+ encodeUint32(suite),
88
+ encodeLengthPrefixed(keyId),
89
+ Buffer.from([FRAMING_CODE["field"]]),
90
+ Buffer.from([0]),
91
+ Buffer.from([0])
92
+ ]);
93
+ }
94
+ /** Whether a value is the app's encrypted-field wrapper. */
95
+ function isEncryptedFieldWrapper(value) {
96
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
97
+ const wrapper = value;
98
+ const keys = Object.keys(wrapper).sort();
99
+ return keys.length === 2 && keys[0] === "$wbEncrypted" && keys[1] === "envelope" && wrapper["$wbEncrypted"] === 1 && typeof wrapper["envelope"] === "string";
100
+ }
101
+ /**
102
+ * The at-rest key id for a derived 32-byte key: the first 16 hex characters of
103
+ * its SHA-256. This is what the envelope's `keyId` is checked against, so a
104
+ * mismatched key fails loudly instead of returning garbage.
105
+ */
106
+ function deriveAtRestKeyId(key) {
107
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
108
+ }
109
+ /**
110
+ * Derive the 32-byte field key from the app's key payload JSON.
111
+ *
112
+ * The app hashes the payload's base64 STRING — not its decoded bytes — so the
113
+ * same spelling is required here; hashing the decoded secret would produce a
114
+ * different key and every field would fail to open.
115
+ */
116
+ function deriveAtRestKey(payloadJson) {
117
+ let payload;
118
+ try {
119
+ payload = JSON.parse(payloadJson);
120
+ } catch {
121
+ throw new Error("workbuddy: at-rest key payload is not valid JSON");
122
+ }
123
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("workbuddy: at-rest key payload is not an object");
124
+ const secret = payload["atRestSecretKey"];
125
+ if (typeof secret !== "string" || secret === "") throw new Error("workbuddy: at-rest key payload carries no atRestSecretKey");
126
+ return createHash("sha256").update(secret, "utf8").digest();
127
+ }
128
+ /**
129
+ * Open one encrypted field with a derived key and return its plaintext.
130
+ *
131
+ * Throws when the envelope is malformed, belongs to another key, or fails
132
+ * authentication — a GCM tag mismatch is the signal that the transcript or the
133
+ * key is wrong, and it must never degrade into a truncated token.
134
+ */
135
+ function openEncryptedField(field, key) {
136
+ let envelope;
137
+ try {
138
+ envelope = JSON.parse(Buffer.from(field.envelope, "base64").toString("utf8"));
139
+ } catch {
140
+ throw new Error("workbuddy: encrypted field envelope is not valid JSON");
141
+ }
142
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) throw new Error("workbuddy: encrypted field envelope is not an object");
143
+ const record = envelope;
144
+ const suite = record["suite"];
145
+ const keyId = record["keyId"];
146
+ const nonce = record["nonce"];
147
+ const authTag = record["authTag"];
148
+ const ciphertext = record["ciphertext"];
149
+ if (typeof suite !== "number" || typeof keyId !== "string") throw new Error("workbuddy: encrypted field envelope is missing suite or keyId");
150
+ if (typeof nonce !== "string" || typeof authTag !== "string" || typeof ciphertext !== "string") throw new Error("workbuddy: encrypted field envelope is missing nonce, authTag or ciphertext");
151
+ const expectedKeyId = deriveAtRestKeyId(key);
152
+ if (keyId !== expectedKeyId) throw new Error(`workbuddy: encrypted field belongs to key ${keyId}, not the available key ${expectedKeyId}`);
153
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "base64"), { authTagLength: 16 });
154
+ decipher.setAAD(fieldAad(keyId, suite));
155
+ decipher.setAuthTag(Buffer.from(authTag, "base64"));
156
+ return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64")), decipher.final()]).toString("utf8");
157
+ }
158
+ /**
159
+ * Candidate paths of the WorkBuddy desktop executable, in probe order.
160
+ *
161
+ * 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.
165
+ */
166
+ function workbuddyAppExecutableCandidates(platform = process.platform, home = homedir(), env = process.env) {
167
+ const candidates = [env[WORKBUDDY_APP_EXECUTABLE_ENV]?.trim()];
168
+ if (platform === "win32") {
169
+ const local = env["LOCALAPPDATA"]?.trim();
170
+ const programFiles = env["ProgramFiles"]?.trim();
171
+ 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"));
174
+ return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
175
+ }
176
+ /**
177
+ * The first candidate that exists as a file, or undefined when the desktop app
178
+ * is not installed where this platform expects it.
179
+ */
180
+ function findWorkbuddyAppExecutable(platform = process.platform, home = homedir(), env = process.env) {
181
+ for (const candidate of workbuddyAppExecutableCandidates(platform, home, env)) try {
182
+ if (existsSync(candidate)) return candidate;
183
+ } catch {}
184
+ }
185
+ /**
186
+ * Ask the installed desktop app for its key payload by running its own binary
187
+ * as plain Node (`ELECTRON_RUN_AS_NODE`) and calling the native binding.
188
+ *
189
+ * The binding is the app's own public surface for this value, so the plugin
190
+ * never has to carry a copy of a build-specific constant: it asks the very
191
+ * build that wrote the file. The child is given no stdin and a hard timeout,
192
+ * and its stdout is the only thing read.
193
+ */
194
+ function fetchAtRestKeyPayload(executable) {
195
+ return new Promise((resolve, reject) => {
196
+ 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))}"], {
197
+ env: {
198
+ ...process.env,
199
+ ELECTRON_RUN_AS_NODE: "1"
200
+ },
201
+ timeout: KEY_FETCH_TIMEOUT_MS,
202
+ windowsHide: true,
203
+ maxBuffer: 1048576
204
+ }, (error, stdout, stderr) => {
205
+ if (error !== null) {
206
+ reject(/* @__PURE__ */ new Error(`workbuddy: the desktop app did not provide its at-rest key (${stderr.trim() || error.message})`));
207
+ return;
208
+ }
209
+ const payload = stdout.trim();
210
+ if (payload === "") {
211
+ reject(/* @__PURE__ */ new Error("workbuddy: the desktop app returned an empty at-rest key payload"));
212
+ return;
213
+ }
214
+ resolve(payload);
215
+ });
216
+ });
217
+ }
218
+ /** Process-lifetime cache of the derived key; never persisted. */
219
+ let cachedKey;
220
+ let inflightKey;
221
+ /**
222
+ * The desktop app's at-rest field key, or undefined when it cannot be obtained
223
+ * (app not installed, an older build without the native module, or a future
224
+ * build that rotates the payload). Cached after the first success so the app is
225
+ * spawned at most once per process; a failure is retried on the next call,
226
+ * because the user may install or start the app between reads.
227
+ */
228
+ function readAtRestKey() {
229
+ if (cachedKey !== void 0) return Promise.resolve(cachedKey);
230
+ inflightKey ??= (async () => {
231
+ const executable = findWorkbuddyAppExecutable();
232
+ if (executable === void 0) return void 0;
233
+ const key = deriveAtRestKey(await fetchAtRestKeyPayload(executable));
234
+ cachedKey = key;
235
+ return key;
236
+ })().finally(() => {
237
+ inflightKey = void 0;
238
+ });
239
+ return inflightKey;
240
+ }
241
+ /** Drop the cached key; tests and diagnostics only. */
242
+ function clearAtRestKeyCache() {
243
+ cachedKey = void 0;
244
+ inflightKey = void 0;
245
+ }
246
+ //#endregion
8
247
  //#region src/upstream.ts
9
248
  const CN_CHAT_BASE = "https://copilot.tencent.com";
10
249
  const CN_BILLING_BASE = "https://www.codebuddy.cn";
@@ -235,13 +474,41 @@ function isGatewayAuthRejection(status, text) {
235
474
  const lower = text.toLowerCase();
236
475
  return lower.includes("openresty") || lower.includes("apisix") || lower.includes("authorization required");
237
476
  }
477
+ /** Marker carried by {@link WorkBuddyCredentialRejectedError}; survives bundling. */
478
+ const CREDENTIAL_REJECTED_CODE = "WORKBUDDY_CREDENTIAL_REJECTED";
479
+ /**
480
+ * The upstream refused the CREDENTIAL itself rather than failing the request.
481
+ *
482
+ * This is a distinct, actionable class: the token is not usable and no retry
483
+ * with the same token will help. Callers use it to tell the user what to do
484
+ * about it (switch accounts, or sign in again) instead of showing a raw HTTP
485
+ * error, and the card must never confuse it with a transient upstream fault.
486
+ *
487
+ * Identified by {@link CREDENTIAL_REJECTED_CODE} rather than `instanceof`, so
488
+ * the check keeps working when the caller and the thrower end up in different
489
+ * module instances (bundled host half vs. a test's source import).
490
+ */
491
+ var WorkBuddyCredentialRejectedError = class extends Error {
492
+ code = CREDENTIAL_REJECTED_CODE;
493
+ /** HTTP status the upstream answered with (401 or 403). */
494
+ status;
495
+ constructor(status) {
496
+ 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.`);
497
+ this.name = "WorkBuddyCredentialRejectedError";
498
+ this.status = status;
499
+ }
500
+ };
501
+ /** Whether an error reports that the upstream refused the credential itself. */
502
+ function isCredentialRejectedError(value) {
503
+ return typeof value === "object" && value !== null && value.code === "WORKBUDDY_CREDENTIAL_REJECTED";
504
+ }
238
505
  async function readEnvelope(response) {
239
506
  const text = await response.text();
240
507
  let parsed;
241
508
  try {
242
509
  parsed = JSON.parse(text);
243
510
  } 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.`);
511
+ if (isGatewayAuthRejection(response.status, text)) throw new WorkBuddyCredentialRejectedError(response.status);
245
512
  throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
246
513
  }
247
514
  if (typeof parsed !== "object" || parsed === null) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
@@ -252,8 +519,15 @@ async function readEnvelope(response) {
252
519
  data: "data" in document ? document["data"] : void 0
253
520
  };
254
521
  }
255
- /** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
522
+ /**
523
+ * Fail an envelope whose business code is non-zero, classified like HTTP errors.
524
+ *
525
+ * A JSON body on a 401/403 is still a credential refusal: the edge answered in
526
+ * the business shape, but the token is just as unusable, so it maps to the same
527
+ * actionable error instead of a generic "client" failure.
528
+ */
256
529
  function envelopeError(status, envelope) {
530
+ if (status === 401 || status === 403) return new WorkBuddyCredentialRejectedError(status);
257
531
  const kind = classifyUpstreamError(status, envelope.msg);
258
532
  return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
259
533
  }
@@ -701,15 +975,57 @@ function expiryToMs(value) {
701
975
  if (value <= 0) return 0;
702
976
  return value > 0xe8d4a51000 ? value : value * 1e3;
703
977
  }
704
- function optionalString(value) {
705
- return typeof value === "string" && value !== "" ? value : void 0;
978
+ /**
979
+ * Resolve one string field, transparently opening the desktop app's
980
+ * encrypted-field wrapper when present.
981
+ *
982
+ * Windows builds of the WorkBuddy desktop app store `accessToken` and
983
+ * `refreshToken` as `{$wbEncrypted:1,envelope}` instead of plain strings; a
984
+ * reader that only accepts strings sees no credential at all and reports the
985
+ * account as signed out. The SAME treatment applies to `nickname`: it is
986
+ * encrypted in those builds too, so a strings-only reader silently degrades the
987
+ * account's display name to its uin (a bare number) even after tokens start
988
+ * working — which reads as "the plugin does not know who this is".
989
+ *
990
+ * `key` is undefined when the at-rest key could not be obtained, in which case
991
+ * an encrypted field resolves to undefined rather than to a fabricated value.
992
+ */
993
+ function credentialField(value, key) {
994
+ if (typeof value === "string") return value;
995
+ if (!isEncryptedFieldWrapper(value)) return void 0;
996
+ if (key === void 0) return void 0;
997
+ return openEncryptedField(value, key);
998
+ }
999
+ /**
1000
+ * An optional field that may legitimately be absent: an absent value, a
1001
+ * non-string that is not a wrapper, and an unopenable wrapper all mean
1002
+ * "unknown", which must not fail the whole document. Only the REQUIRED token
1003
+ * fields treat an unopenable wrapper as fatal (see {@link parseWorkBuddyAuth}).
1004
+ *
1005
+ * `account.phoneNumber` is deliberately never read: it is encrypted in these
1006
+ * builds, and the card's privacy contract admits nickname, masked uin, expiry
1007
+ * and credits only — a phone number is none of the plugin's business.
1008
+ */
1009
+ function optionalCredentialField(value, key) {
1010
+ if (typeof value === "string") return value === "" ? void 0 : value;
1011
+ if (!isEncryptedFieldWrapper(value)) return void 0;
1012
+ if (key === void 0) return void 0;
1013
+ try {
1014
+ return openEncryptedField(value, key);
1015
+ } catch {
1016
+ return;
1017
+ }
706
1018
  }
707
1019
  /**
708
1020
  * Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
709
1021
  * nested form `{"auth":{...},"account":{...}}` and the flat panel form.
710
1022
  * Returns undefined when the document carries no access token.
1023
+ *
1024
+ * `atRestKey` is required to read documents whose fields are encrypted
1025
+ * (see {@link credentialField}); pass the key obtained from
1026
+ * `readAtRestKey()` when the plain read reports no token.
711
1027
  */
712
- function parseWorkBuddyAuth(text, filePath) {
1028
+ function parseWorkBuddyAuth(text, filePath, atRestKey) {
713
1029
  let parsed;
714
1030
  try {
715
1031
  parsed = JSON.parse(text);
@@ -727,21 +1043,28 @@ function parseWorkBuddyAuth(text, filePath) {
727
1043
  auth = document;
728
1044
  identity = document;
729
1045
  }
730
- const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
731
- if (accessToken === "") return void 0;
1046
+ let accessToken;
1047
+ let refreshToken;
1048
+ try {
1049
+ accessToken = credentialField(auth["accessToken"], atRestKey);
1050
+ refreshToken = credentialField(auth["refreshToken"], atRestKey);
1051
+ } catch {
1052
+ return;
1053
+ }
1054
+ if (accessToken === void 0 || accessToken === "") return void 0;
732
1055
  const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
733
1056
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
734
1057
  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"]);
1058
+ const enterpriseId = optionalCredentialField(identity["enterpriseId"], atRestKey);
1059
+ const nickname = optionalCredentialField(identity["nickname"], atRestKey);
1060
+ const uin = optionalCredentialField(identity["uin"], atRestKey);
738
1061
  return {
739
1062
  accessToken,
740
- refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
1063
+ refreshToken: refreshToken ?? "",
741
1064
  expiresAtMs,
742
1065
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
743
- domain: optionalString(auth["domain"]) ?? "",
744
- uid: optionalString(identity["uid"]) ?? "",
1066
+ domain: optionalCredentialField(auth["domain"], atRestKey) ?? "",
1067
+ uid: optionalCredentialField(identity["uid"], atRestKey) ?? "",
745
1068
  ...enterpriseId === void 0 ? {} : { enterpriseId },
746
1069
  ...nickname === void 0 ? {} : { nickname },
747
1070
  ...uin === void 0 ? {} : { uin },
@@ -751,6 +1074,30 @@ function parseWorkBuddyAuth(text, filePath) {
751
1074
  };
752
1075
  }
753
1076
  /**
1077
+ * Whether a document carries any field the reader must decrypt — the token
1078
+ * fields or the identity fields — i.e. whether reading it needs the at-rest key
1079
+ * at all.
1080
+ *
1081
+ * Deciding this BEFORE asking the app for its key keeps the plain case (macOS
1082
+ * and older Windows builds, and every plugin-owned copy) from paying for a
1083
+ * child process on every credential read. Identity fields are included because
1084
+ * a future build could encrypt the display name while leaving tokens plain;
1085
+ * gating on tokens alone would then silently drop the name again.
1086
+ */
1087
+ function hasEncryptedCredentialFields(text) {
1088
+ let parsed;
1089
+ try {
1090
+ parsed = JSON.parse(text);
1091
+ } catch {
1092
+ return false;
1093
+ }
1094
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
1095
+ const document = parsed;
1096
+ const auth = typeof document["auth"] === "object" && document["auth"] !== null ? document["auth"] : document;
1097
+ const identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : document;
1098
+ return isEncryptedFieldWrapper(auth["accessToken"]) || isEncryptedFieldWrapper(auth["refreshToken"]) || isEncryptedFieldWrapper(identity["nickname"]) || isEncryptedFieldWrapper(identity["uin"]) || isEncryptedFieldWrapper(identity["uid"]) || isEncryptedFieldWrapper(identity["enterpriseId"]) || isEncryptedFieldWrapper(auth["domain"]);
1099
+ }
1100
+ /**
754
1101
  * Rank two candidate files for the same account.
755
1102
  *
756
1103
  * The live `workbuddy-desktop.info` always wins: it is the app's current
@@ -837,16 +1184,72 @@ function parseOwnDocument(text, filePath) {
837
1184
  function isENOENT(error) {
838
1185
  return error?.code === "ENOENT";
839
1186
  }
840
- /** Read one auth file, tolerating absence and unparsable content. */
841
- async function readAuthFile(path) {
1187
+ /**
1188
+ * Probe one auth file, reporting WHY it yielded no credential.
1189
+ *
1190
+ * The desktop app encrypts its token fields on Windows builds. The plain read
1191
+ * is tried first and the app is only asked for its at-rest key when the
1192
+ * document actually carries encrypted wrappers, so the common case costs no
1193
+ * child process. `resolveAtRestKey` is injectable so the encrypted path is
1194
+ * testable without a real desktop install.
1195
+ */
1196
+ async function probeAuthFile(path, resolveAtRestKey) {
1197
+ let text;
842
1198
  try {
843
- return parseWorkBuddyAuth(await readFile(path, "utf8"), path);
1199
+ text = await readFile(path, "utf8");
844
1200
  } catch (error) {
845
- if (isENOENT(error)) return void 0;
846
- return;
1201
+ const missing = isENOENT(error);
1202
+ return { failure: {
1203
+ reason: missing ? "missing" : "unreadable",
1204
+ ...missing ? {} : { message: error instanceof Error ? error.message : String(error) }
1205
+ } };
1206
+ }
1207
+ const plain = parseWorkBuddyAuth(text, path);
1208
+ if (plain !== void 0) return { credential: plain };
1209
+ if (!hasEncryptedCredentialFields(text)) return isParseableJson(text) ? { failure: {
1210
+ reason: "invalid",
1211
+ message: "no access token in the document"
1212
+ } } : { failure: {
1213
+ reason: "invalid",
1214
+ message: "the file is not valid JSON"
1215
+ } };
1216
+ let key;
1217
+ try {
1218
+ key = await resolveAtRestKey();
1219
+ } catch (error) {
1220
+ return { failure: {
1221
+ reason: "encrypted",
1222
+ message: error instanceof Error ? error.message : String(error)
1223
+ } };
1224
+ }
1225
+ if (key === void 0) return { failure: {
1226
+ reason: "encrypted",
1227
+ message: `the credential fields are encrypted and no key was available; the WorkBuddy desktop app must be present (or set ${WORKBUDDY_APP_EXECUTABLE_ENV})`
1228
+ } };
1229
+ const decrypted = parseWorkBuddyAuth(text, path, key);
1230
+ return decrypted === void 0 ? { failure: {
1231
+ reason: "invalid",
1232
+ message: "the encrypted credential fields could not be opened with the desktop app's key"
1233
+ } } : { credential: decrypted };
1234
+ }
1235
+ /** Whether text parses as JSON at all; distinguishes "wrong shape" from "not JSON". */
1236
+ function isParseableJson(text) {
1237
+ try {
1238
+ JSON.parse(text);
1239
+ return true;
1240
+ } catch {
1241
+ return false;
847
1242
  }
848
1243
  }
849
1244
  /**
1245
+ * Read one auth file, tolerating absence and unparsable content. Kept as the
1246
+ * credential-only view for callers that do not need the failure reason.
1247
+ */
1248
+ async function readAuthFile(path, resolveAtRestKey) {
1249
+ const probe = await probeAuthFile(path, resolveAtRestKey);
1250
+ return "credential" in probe ? probe.credential : void 0;
1251
+ }
1252
+ /**
850
1253
  * Read-only credential store with demand-driven refresh and multi-account
851
1254
  * discovery.
852
1255
  *
@@ -864,6 +1267,7 @@ var WorkBuddyCredentialStore = class {
864
1267
  legacyOwnPath;
865
1268
  legacyOwnPathExplicit;
866
1269
  authDirs;
1270
+ resolveAtRestKey;
867
1271
  desktopPathOverride;
868
1272
  accountId;
869
1273
  inflight;
@@ -876,6 +1280,7 @@ var WorkBuddyCredentialStore = class {
876
1280
  this.legacyOwnPathExplicit = options.legacyOwnPath;
877
1281
  this.authDirs = options.authDirs;
878
1282
  this.desktopPathOverride = options.desktopPath;
1283
+ this.resolveAtRestKey = options.resolveAtRestKey ?? readAtRestKey;
879
1284
  }
880
1285
  /** Whether a credential's login domain belongs to this store's region. */
881
1286
  matchesRegion(domain) {
@@ -1005,7 +1410,7 @@ var WorkBuddyCredentialStore = class {
1005
1410
  const files = await this.candidateFiles();
1006
1411
  const byId = /* @__PURE__ */ new Map();
1007
1412
  for (const file of files) {
1008
- const credential = await readAuthFile(file);
1413
+ const credential = await readAuthFile(file, this.resolveAtRestKey);
1009
1414
  if (credential === void 0 || !this.matchesRegion(credential.domain)) continue;
1010
1415
  const id = workbuddyAccountId(credential);
1011
1416
  const existing = byId.get(id);
@@ -1059,8 +1464,7 @@ var WorkBuddyCredentialStore = class {
1059
1464
  const id = workbuddyAccountId(credential);
1060
1465
  return {
1061
1466
  id,
1062
- accountName: credential.nickname ?? credential.uin ?? credential.uid,
1063
- ...credential.uin === void 0 ? {} : { uin: credential.uin },
1467
+ accountName: credential.nickname ?? "",
1064
1468
  domain: credential.domain,
1065
1469
  source: credential.source,
1066
1470
  tokenExpiresAtMs: credential.expiresAtMs,
@@ -1091,6 +1495,18 @@ var WorkBuddyCredentialStore = class {
1091
1495
  if (this.accountId === void 0) return this.preferred(credentials);
1092
1496
  return credentials.find((credential) => workbuddyAccountId(credential) === this.accountId);
1093
1497
  }
1498
+ /**
1499
+ * One account's stored credential by id, WITHOUT consulting or changing the
1500
+ * current selection.
1501
+ *
1502
+ * Exists so a recovery probe can test whether some OTHER local account is
1503
+ * still accepted upstream while a rejected one stays selected: mutating the
1504
+ * live selection to find that out would be exactly the silent account switch
1505
+ * this store refuses to perform (it would bill the wrong account mid-probe).
1506
+ */
1507
+ async credentialFor(accountId) {
1508
+ return (await this.readAll()).find((credential) => workbuddyAccountId(credential) === accountId);
1509
+ }
1094
1510
  /** The credential to send upstream: {@link current}, refreshed on demand. */
1095
1511
  async resolve() {
1096
1512
  const credential = await this.current();
@@ -1190,6 +1606,66 @@ var WorkBuddyCredentialStore = class {
1190
1606
  } catch {}
1191
1607
  return false;
1192
1608
  }
1609
+ /**
1610
+ * Which paths were probed and why each one yielded no credential.
1611
+ *
1612
+ * Read-only and token-free: it exists so a signed-out card can explain
1613
+ * itself. A bare "not signed in" is undiagnosable on a machine whose layout
1614
+ * differs from the ones this plugin was written against — and on Windows it
1615
+ * is actively misleading, because encrypted token fields need the desktop app
1616
+ * present to be read at all. Only paths this store actually consults are
1617
+ * reported, and only files that failed: one healthy sibling would make the
1618
+ * whole list noise.
1619
+ */
1620
+ async diagnose() {
1621
+ const candidates = [];
1622
+ for (const path of await this.candidateFiles()) {
1623
+ const probe = await probeAuthFile(path, this.resolveAtRestKey);
1624
+ if ("credential" in probe) {
1625
+ if (!this.matchesRegion(probe.credential.domain)) candidates.push({
1626
+ path,
1627
+ source: "desktop",
1628
+ reason: "wrong-region",
1629
+ message: `holds a ${regionOf(probe.credential.domain)} sign-in, but this tab reads the ${this.region} region`
1630
+ });
1631
+ continue;
1632
+ }
1633
+ candidates.push({
1634
+ path,
1635
+ source: "desktop",
1636
+ ...probe.failure
1637
+ });
1638
+ }
1639
+ for (const path of this.ownCandidates()) {
1640
+ let text;
1641
+ try {
1642
+ text = await readFile(path, "utf8");
1643
+ } catch (error) {
1644
+ if (isENOENT(error)) continue;
1645
+ candidates.push({
1646
+ path,
1647
+ source: "dsh",
1648
+ reason: "unreadable",
1649
+ message: error instanceof Error ? error.message : String(error)
1650
+ });
1651
+ continue;
1652
+ }
1653
+ const parsed = parseOwnDocument(text, path);
1654
+ if (parsed === void 0 || !this.matchesRegion(parsed.domain)) {
1655
+ candidates.push({
1656
+ path,
1657
+ source: "dsh",
1658
+ reason: "invalid",
1659
+ message: "not a readable plugin-owned credential"
1660
+ });
1661
+ continue;
1662
+ }
1663
+ }
1664
+ return {
1665
+ tried: [...await this.candidateFiles(), ...this.ownCandidates()],
1666
+ failures: candidates
1667
+ };
1668
+ }
1193
1669
  };
1194
1670
  //#endregion
1195
1671
  //#region src/catalog.ts
@@ -1440,10 +1916,36 @@ function applyContextBudgets(catalog, budgets = {}) {
1440
1916
  function deriveCatalog(catalog, enabled, budgets = {}) {
1441
1917
  return applyContextBudgets(enabled.size === 0 ? catalog : catalog.filter((model) => enabled.has(model.id)), budgets);
1442
1918
  }
1443
- /** Mutable catalog shared by the shim's `/v1/models` and the adapter. */
1919
+ /**
1920
+ * Mutable catalog shared by the shim's `/v1/models` and the adapter.
1921
+ *
1922
+ * The static fallback exists so an OFFLINE upstream never leaves a provider
1923
+ * empty — but "offline" presumes the region is usable at all. A region with no
1924
+ * local sign-in cannot serve one single request, so seeding it with a roster
1925
+ * puts models in the picker that are guaranteed to 401 (issue #12). The host
1926
+ * distinguishes the two cases through {@link WorkBuddyCatalog.setRegionUsable}.
1927
+ */
1444
1928
  var WorkBuddyCatalog = class {
1445
1929
  models;
1446
1930
  /**
1931
+ * Whether this region has any local sign-in. Defaults to `true` — the
1932
+ * permissive direction — so a scan that has not run yet (or failed) keeps
1933
+ * serving the fallback rather than blanking a region that may be fine.
1934
+ */
1935
+ usable = true;
1936
+ /**
1937
+ * Whether the user has switched this region's provider ON. Opt-out: defaults
1938
+ * to `true`, so a config predating this switch (and the pre-region-split flat
1939
+ * fields, which never carry `enabled`) keeps both providers running. Set to
1940
+ * `false` by `setRegionEnabled` to withdraw the region from DSH's model
1941
+ * picker entirely (its adapter route and configurable-provider entry are
1942
+ * pulled by the Host via `AdapterRegistrationHandle.replace([])` /
1943
+ * `DirectoryRegistrationHandle.replace([])`). This is a USER choice, distinct
1944
+ * from {@link usable}: a region the user switched off keeps its catalog,
1945
+ * accounts, and model picks intact and reappears the moment they re-check it.
1946
+ */
1947
+ enabled = true;
1948
+ /**
1447
1949
  * @param region Seeds the static fallback for this region; each region's
1448
1950
  * provider must never serve the other region's roster before its first
1449
1951
  * live refresh lands.
@@ -1451,9 +1953,57 @@ var WorkBuddyCatalog = class {
1451
1953
  constructor(region = "cn") {
1452
1954
  this.models = fallbackModelsFor(region);
1453
1955
  }
1454
- /** Current entries; the fallback list until the upstream answer lands. */
1956
+ /**
1957
+ * Current entries; the fallback list until the upstream answer lands.
1958
+ *
1959
+ * Empty while this region has no local sign-in OR the user has switched it
1960
+ * off: the provider stays registered (its row still hosts the settings card
1961
+ * and the account picker), but it advertises nothing to pick. DSH drops empty
1962
+ * provider groups from the picker (`buildModelCatalog` filters
1963
+ * `models.length > 0`), so the group disappears exactly when it would be pure
1964
+ * noise. The two empty causes are independent: `usable` is the automatic
1965
+ * "no account" gate (issue #12), `enabled` is the user's explicit on/off.
1966
+ */
1455
1967
  current() {
1456
- return this.models;
1968
+ return this.usable && this.enabled ? this.models : [];
1969
+ }
1970
+ /** Whether this region currently advertises any model. */
1971
+ isRegionUsable() {
1972
+ return this.usable;
1973
+ }
1974
+ /** Whether the user has this region's provider switched on. */
1975
+ isRegionEnabled() {
1976
+ return this.enabled;
1977
+ }
1978
+ /**
1979
+ * Record whether this region has a local sign-in.
1980
+ *
1981
+ * Separate from {@link set} on purpose: `set()` carries a live upstream
1982
+ * answer and must never be empty, while "this region has no account" is a
1983
+ * legitimate empty state that must survive across refreshes. Keeping them
1984
+ * apart is what lets the non-empty guard stay strict.
1985
+ *
1986
+ * @returns whether the value changed, so the caller can skip invalidating
1987
+ * its adapter snapshots when nothing moved.
1988
+ */
1989
+ setRegionUsable(usable) {
1990
+ if (this.usable === usable) return false;
1991
+ this.usable = usable;
1992
+ return true;
1993
+ }
1994
+ /**
1995
+ * Record the user's explicit on/off choice for this region's provider.
1996
+ *
1997
+ * Distinct from {@link setRegionUsable}: that is the automatic "no local
1998
+ * account" gate, while this is a deliberate user action (issue #11-style
1999
+ * region switch). The Host reads the same `enabled` flag through
2000
+ * `regionStateOf(config, region).enabled !== false`, so the two halves never
2001
+ * disagree. Returns whether the value changed.
2002
+ */
2003
+ setRegionEnabled(enabled) {
2004
+ if (this.enabled === enabled) return false;
2005
+ this.enabled = enabled;
2006
+ return true;
1457
2007
  }
1458
2008
  /** Replace the list; callers invalidate their adapter snapshot after this. */
1459
2009
  set(models) {
@@ -1464,7 +2014,7 @@ var WorkBuddyCatalog = class {
1464
2014
  //#endregion
1465
2015
  //#region src/version.ts
1466
2016
  /** The npm package version this build was produced from. */
1467
- const WORKBUDDY_CONNECT_VERSION = "2.0.5";
2017
+ const WORKBUDDY_CONNECT_VERSION = "2.0.10";
1468
2018
  //#endregion
1469
2019
  //#region src/host-heartbeat.ts
1470
2020
  /**
@@ -1570,4 +2120,4 @@ async function clearHostHeartbeat() {
1570
2120
  await rm(workbuddyHostHeartbeatPath(), { force: true });
1571
2121
  }
1572
2122
  //#endregion
1573
- 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 };
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 };