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.
- package/CHANGELOG.md +192 -0
- package/README.en.md +8 -1
- package/README.md +8 -1
- package/lib/bin.js +15 -2
- package/lib/client.js +425 -66
- package/lib/{host-heartbeat--iKDuwr5.js → host-heartbeat-BJdDmMSn.js} +738 -36
- package/lib/index.d.ts +541 -10
- package/lib/index.js +262 -27
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
705
|
-
|
|
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
|
-
|
|
731
|
-
|
|
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 =
|
|
736
|
-
const nickname =
|
|
737
|
-
const 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:
|
|
1236
|
+
refreshToken: refreshToken ?? "",
|
|
741
1237
|
expiresAtMs,
|
|
742
1238
|
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
743
|
-
domain:
|
|
744
|
-
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
|
-
/**
|
|
841
|
-
|
|
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
|
-
|
|
1372
|
+
text = await readFile(path, "utf8");
|
|
844
1373
|
} catch (error) {
|
|
845
|
-
|
|
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 ??
|
|
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
|
|
1472
|
-
* registered (its row still hosts the settings card
|
|
1473
|
-
* but it advertises nothing to pick. DSH drops empty
|
|
1474
|
-
* the picker (`buildModelCatalog` filters
|
|
1475
|
-
* disappears exactly when it would be pure
|
|
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.
|
|
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 {
|
|
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 };
|