zcode-workbuddy-connect 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,890 @@
1
+ /**
2
+ * WorkBuddy 5.6.x at-rest credential protection: classification, key
3
+ * resolution, and field decryption for the desktop app's encrypted auth file.
4
+ *
5
+ * Since WorkBuddy 5.6 the desktop app encrypts `auth.accessToken` and
6
+ * `auth.refreshToken` at rest (`buildPolicy: "fields"`, on by default), so the
7
+ * plugin reads `{$wbEncrypted:1, envelope}` wrappers instead of token strings
8
+ * (issues #39/#40 upstream). Everything needed to open them lives on the same
9
+ * machine:
10
+ *
11
+ * - the sealed payload (`{version:1, atRestSecretKey}`) comes from the
12
+ * WorkBuddy-modified Electron's private `workbuddyStorage` binding, reached
13
+ * by running *its own* binary once with `ELECTRON_RUN_AS_NODE=1`;
14
+ * - `protectorKey = sha256(atRestSecretKey, utf8)` opens the envelopes with
15
+ * AES-256-GCM; the AAD builder below is transcribed from the app's own
16
+ * `buildAuthenticatedContextAad` (verified live against 5.6.2 by the
17
+ * upstream author, and re-verified end to end on Windows against 5.6.2 by
18
+ * this port).
19
+ *
20
+ * The plugin process itself can never call `_linkedBinding` (it runs in
21
+ * ZCode's Node, not the forked Electron), so the helper is spawned. The key is
22
+ * cached in memory only, single-flight, and re-resolved when an envelope names
23
+ * a different key id. Neither the payload, the key, nor any token is ever
24
+ * logged; error messages carry sizes, ids, and exit codes only.
25
+ *
26
+ * @module zcode-workbuddy-connect/desktop-credential-protection
27
+ */
28
+
29
+ import { execFile } from 'node:child_process'
30
+ import { accessSync, constants, realpathSync, statSync } from 'node:fs'
31
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
32
+ import { join } from 'node:path'
33
+
34
+ /**
35
+ * Env variable that overrides the WorkBuddy Electron binary used as the key helper.
36
+ * @type {'WORKBUDDY_ELECTRON_BIN'}
37
+ */
38
+ export const WORKBUDDY_ELECTRON_BIN_ENV = 'WORKBUDDY_ELECTRON_BIN'
39
+
40
+ /**
41
+ * Reason codes a signed-out state can carry. The code travels with the error
42
+ * so the caller can promote it to a `reasonCode` without re-deriving the cause
43
+ * from prose. Mirrors the upstream `WorkBuddySignedOutReasonCode` set.
44
+ * @type {readonly string[]}
45
+ */
46
+ export const WORKBUDDY_SIGNED_OUT_REASON_CODES = [
47
+ 'no-credential',
48
+ 'credential-region-mismatch',
49
+ 'encrypted-credential-unreadable',
50
+ 'electron-binary-not-found',
51
+ 'electron-binary-ambiguous',
52
+ 'electron-binary-unavailable',
53
+ 'electron-path-invalid',
54
+ 'electron-discovery-incomplete',
55
+ ]
56
+
57
+ /** The four states a desktop auth document can be read as. */
58
+ export const DESKTOP_AUTH_FORMATS = ['absent', 'plaintext', 'encrypted', 'unrecognized']
59
+
60
+ /** Platform-default Electron binary for the CN app, confirmed only on macOS (5.6.2). */
61
+ const MACOS_CN_ELECTRON_PATH = '/Applications/WorkBuddy.app/Contents/MacOS/Electron'
62
+
63
+ /**
64
+ * Windows install locations for the two products. The desktop app ships a
65
+ * per-product directory under `C:\Program Files`, and the binary at its root is
66
+ * the very Electron the app runs on — the same one the upstream macOS default
67
+ * points at.
68
+ *
69
+ * DEVIATION FROM UPSTREAM (deliberate, documented in the goal contract):
70
+ * upstream refuses to guess app locations because on macOS it has a real
71
+ * discovery mechanism (Spotlight by bundle id) and a policy against static
72
+ * candidates. Neither applies here: there is no Windows discovery mechanism in
73
+ * upstream at all, so on Windows the only way to open an encrypted credential
74
+ * is a static default or `WORKBUDDY_ELECTRON_BIN`. These two paths were verified
75
+ * live on this machine (the helper returns `{version:1, atRestSecretKey}` from
76
+ * each). They are not a search — a wrong product is never selected, because the
77
+ * path names one product, and every failure is reported rather than retried
78
+ * against the other product's binary.
79
+ */
80
+ const WINDOWS_ELECTRON_RELATIVE_PATHS = {
81
+ cn: ['WorkBuddy', 'WorkBuddy.exe'],
82
+ ai: ['WorkBuddyAI', 'WorkBuddyAI.exe'],
83
+ }
84
+
85
+ /** The variant ids this port knows about. */
86
+ const VARIANTS = ['cn', 'ai']
87
+
88
+ /**
89
+ * Platform-default Electron binary candidates for one variant, in probe order.
90
+ *
91
+ * - `cn`: Windows + macOS. macOS keeps the upstream default path (and, with
92
+ * discovery enabled, Spotlight as the fallback); Windows uses the install
93
+ * location above.
94
+ * - `ai`: Windows only. On macOS and Linux there is no verified layout, so
95
+ * nothing is defaulted and `WORKBUDDY_ELECTRON_BIN` stays the only route —
96
+ * upstream's deliberate `discovery: 'none'` for the international product.
97
+ *
98
+ * @param {string} [variant] `'cn'` | `'ai'`
99
+ * @returns {string[]} zero or more absolute paths, in probe order
100
+ */
101
+ export function defaultWorkBuddyElectronPaths(variant = 'cn') {
102
+ if (!VARIANTS.includes(variant)) return []
103
+ if (process.platform === 'win32') {
104
+ const roots = [...new Set([
105
+ process.env['ProgramFiles'],
106
+ process.env['ProgramW6432'],
107
+ 'C:\\Program Files',
108
+ ].filter(root => typeof root === 'string' && root !== ''))]
109
+ return roots.map(root => join(root, ...WINDOWS_ELECTRON_RELATIVE_PATHS[variant]))
110
+ }
111
+ if (process.platform === 'darwin') {
112
+ return variant === 'cn' ? [MACOS_CN_ELECTRON_PATH] : []
113
+ }
114
+ return []
115
+ }
116
+
117
+ /**
118
+ * The Electron binary the default helper would use for diagnostics, or
119
+ * `undefined` where no default has been verified on this platform/variant.
120
+ *
121
+ * @param {string} [variant] `'cn'` | `'ai'`
122
+ * @returns {string | undefined}
123
+ */
124
+ export function defaultWorkBuddyElectronPath(variant = 'cn') {
125
+ return defaultWorkBuddyElectronPaths(variant)[0]
126
+ }
127
+
128
+ /** Whether any automatic discovery may run for a variant on this platform. */
129
+ export function defaultDiscoveryFor(variant) {
130
+ // Only the CN app has a verified layout and a verified credential chain.
131
+ // macOS Spotlight discovery applies only to it; Windows and Linux get no
132
+ // discovery at all (upstream issue #48 §3.6 keeps that scope).
133
+ if (variant === 'cn' && process.platform === 'darwin') return 'macos-workbuddy'
134
+ return 'none'
135
+ }
136
+
137
+ /**
138
+ * Whether a raw value is the 5.6 field wrapper, with its inner envelope
139
+ * decodable. The wrapper is `{$wbEncrypted:1, envelope:<base64 of a JSON
140
+ * {suite,keyId,nonce,authTag,ciphertext>}`; anything claiming the flag whose
141
+ * envelope cannot be decoded makes the whole document unrecognized rather
142
+ * than encrypted, because no key could ever open it.
143
+ *
144
+ * @param {'accessToken'|'refreshToken'} field
145
+ * @param {unknown} value
146
+ * @returns {{ field: 'accessToken'|'refreshToken', envelope: { suite: number, keyId: string, nonce: Buffer, authTag: Buffer, ciphertext: Buffer } } | undefined}
147
+ */
148
+ function parseWrappedField(field, value) {
149
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
150
+ const wrapped = /** @type {Record<string, unknown>} */ (value)
151
+ if (wrapped['$wbEncrypted'] !== 1 || typeof wrapped['envelope'] !== 'string') return undefined
152
+ let inner
153
+ try {
154
+ inner = JSON.parse(Buffer.from(wrapped['envelope'], 'base64').toString('utf8'))
155
+ } catch {
156
+ return undefined
157
+ }
158
+ if (typeof inner !== 'object' || inner === null || Array.isArray(inner)) return undefined
159
+ const parts = /** @type {Record<string, unknown>} */ (inner)
160
+ const nonce = parseBase64(parts['nonce'], 12)
161
+ const authTag = parseBase64(parts['authTag'], 16)
162
+ const ciphertext = parseBase64(parts['ciphertext'])
163
+ if (nonce === undefined || authTag === undefined || ciphertext === undefined) return undefined
164
+ if (typeof parts['suite'] !== 'number' || !Number.isInteger(parts['suite'])) return undefined
165
+ // Suite 1 is the only scheme WorkBuddy 5.6.x defines for credential fields.
166
+ // Anything else is a format this plugin has not seen, so the wrapper is not
167
+ // claimed as encrypted — the document then reads as unrecognized and the
168
+ // store reports a diagnosis instead of attempting a blind open.
169
+ if (parts['suite'] !== 1) return undefined
170
+ if (typeof parts['keyId'] !== 'string' || !/^[0-9a-f]{16}$/u.test(parts['keyId'])) return undefined
171
+ return {
172
+ field,
173
+ envelope: {
174
+ suite: parts['suite'],
175
+ keyId: parts['keyId'],
176
+ nonce,
177
+ authTag,
178
+ ciphertext,
179
+ },
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Decode a base64 value and check its exact byte length when given.
185
+ *
186
+ * @param {unknown} value
187
+ * @param {number} [length]
188
+ * @returns {Buffer | undefined}
189
+ */
190
+ function parseBase64(value, length) {
191
+ if (typeof value !== 'string' || value === '') return undefined
192
+ let decoded
193
+ try {
194
+ decoded = Buffer.from(value, 'base64')
195
+ } catch {
196
+ return undefined
197
+ }
198
+ // Buffer.from is lenient about stray characters; require the round-trip so a
199
+ // tampered envelope is rejected before any key material is involved.
200
+ if (decoded.length === 0 || decoded.toString('base64').replace(/=+$/u, '') !== value.replace(/=+$/u, '')) return undefined
201
+ return length === undefined || decoded.length === length ? decoded : undefined
202
+ }
203
+
204
+ const AUTH_FIELDS = ['accessToken', 'refreshToken']
205
+
206
+ /**
207
+ * Read a desktop auth document's format. `absent` is an empty file; `plaintext`
208
+ * is any document the regular parser could read (even one without a token);
209
+ * `encrypted` has at least one field in a decodable wrapper; everything else —
210
+ * unparsable JSON, non-objects, wrappers whose envelope will not decode — is
211
+ * `unrecognized`.
212
+ *
213
+ * @param {string} text
214
+ * @returns {{format: 'absent'} | {format: 'plaintext'} | {format: 'encrypted', wrapped: { document: Record<string, unknown>, fields: { field: string, envelope: { suite: number, keyId: string, nonce: Buffer, authTag: Buffer, ciphertext: Buffer } }[] }} | {format: 'unrecognized'}}
215
+ */
216
+ export function classifyDesktopAuthDocument(text) {
217
+ if (text.trim() === '') return { format: 'absent' }
218
+ let parsed
219
+ try {
220
+ parsed = JSON.parse(text)
221
+ } catch {
222
+ return { format: 'unrecognized' }
223
+ }
224
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return { format: 'unrecognized' }
225
+ const document = /** @type {Record<string, unknown>} */ (parsed)
226
+ const auth = typeof document['auth'] === 'object' && document['auth'] !== null
227
+ ? /** @type {Record<string, unknown>} */ (document['auth'])
228
+ : document
229
+ /** @type {{ field: string, envelope: { suite: number, keyId: string, nonce: Buffer, authTag: Buffer, ciphertext: Buffer } }[]} */
230
+ const fields = []
231
+ for (const field of AUTH_FIELDS) {
232
+ const value = auth[field]
233
+ if (typeof value === 'string') continue
234
+ const wrapped = parseWrappedField(/** @type {'accessToken'|'refreshToken'} */ (field), value)
235
+ // A field in *some* object that is not a decodable wrapper: not plaintext,
236
+ // not usable. Treated as unrecognized below unless another field wrapped.
237
+ if (wrapped === undefined && value !== undefined) return { format: 'unrecognized' }
238
+ if (wrapped !== undefined) fields.push(wrapped)
239
+ }
240
+ if (fields.length === 0) return { format: 'plaintext' }
241
+ return { format: 'encrypted', wrapped: { document, fields } }
242
+ }
243
+
244
+ /**
245
+ * Distinct key ids across the wrapped fields, in field order.
246
+ *
247
+ * @param {readonly { envelope: { keyId: string } }[]} fields
248
+ * @returns {string[]}
249
+ */
250
+ export function keyIdsOf(fields) {
251
+ return [...new Set(fields.map(wrapped => wrapped.envelope.keyId))]
252
+ }
253
+
254
+ /**
255
+ * Decrypt a wrapped document into the plaintext text the regular parser reads.
256
+ * The caller supplies `openField`; it must return the field's plaintext or
257
+ * throw, because a field that cannot be opened must surface, never pass through
258
+ * as an empty string.
259
+ *
260
+ * @param {{format: 'encrypted', wrapped: { document: Record<string, unknown>, fields: readonly { field: 'accessToken'|'refreshToken' }[] }}} classification
261
+ * @param {(field: { field: 'accessToken'|'refreshToken' }) => string} openField
262
+ * @returns {string}
263
+ */
264
+ export function unwrapDesktopAuthDocument(classification, openField) {
265
+ const wrapped = classification.wrapped
266
+ const rebuilt = /** @type {Record<string, unknown>} */ (structuredClone(wrapped.document))
267
+ const auth = typeof rebuilt['auth'] === 'object' && rebuilt['auth'] !== null
268
+ ? /** @type {Record<string, unknown>} */ (rebuilt['auth'])
269
+ : rebuilt
270
+ for (const field of wrapped.fields) {
271
+ auth[field.field] = openField(field)
272
+ }
273
+ return JSON.stringify(rebuilt)
274
+ }
275
+
276
+ /**
277
+ * The authenticated-context AAD for one field envelope, transcribed from the
278
+ * app bundle's `buildAuthenticatedContextAad`. Credential fields are always
279
+ * suite 1 under the `field` framing (WBEV1); the framing family's other
280
+ * members (WBEF1/WBER1/WBES1) belong to other document kinds and are
281
+ * deliberately not implemented — opening a field is not a place to guess at
282
+ * future formats.
283
+ *
284
+ * @param {string} keyId
285
+ * @param {number} suite
286
+ * @returns {Buffer}
287
+ */
288
+ export function buildAuthenticatedContextAad(keyId, suite) {
289
+ const prefix = Buffer.from('WB-AAD\0', 'ascii')
290
+ const lengthPrefixed = /** @param {string} value */ (value) => {
291
+ const bytes = Buffer.from(value, 'utf8')
292
+ const header = Buffer.allocUnsafe(4)
293
+ header.writeUInt32BE(bytes.length)
294
+ return Buffer.concat([header, bytes])
295
+ }
296
+ const suiteBytes = Buffer.allocUnsafe(4)
297
+ suiteBytes.writeUInt32BE(suite)
298
+ // No sequence numbers on credential fields; the final byte 0 mirrors the
299
+ // reference script's default context.
300
+ return Buffer.concat([
301
+ prefix, Buffer.from([1]),
302
+ lengthPrefixed('WBEV1'),
303
+ lengthPrefixed('sym-v1'),
304
+ suiteBytes,
305
+ lengthPrefixed(keyId),
306
+ Buffer.from([2]),
307
+ Buffer.from([0]),
308
+ Buffer.from([0]),
309
+ ])
310
+ }
311
+
312
+ /**
313
+ * Open one envelope with a protector key; `undefined` when it will not open.
314
+ * The accepted format is exactly what WorkBuddy 5.6.2 writes — suite 1 under
315
+ * the `field` framing — so a failure means "not this format / wrong key", and
316
+ * is reported as such rather than retried against other framings.
317
+ *
318
+ * @param {Buffer} key
319
+ * @param {{ keyId: string, suite: number, nonce: Buffer, authTag: Buffer, ciphertext: Buffer }} envelope
320
+ * @returns {string | undefined}
321
+ */
322
+ export function openAuthField(key, envelope) {
323
+ try {
324
+ const decipher = createDecipheriv('aes-256-gcm', key, envelope.nonce, { authTagLength: 16 })
325
+ decipher.setAAD(buildAuthenticatedContextAad(envelope.keyId, envelope.suite))
326
+ decipher.setAuthTag(envelope.authTag)
327
+ return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]).toString('utf8')
328
+ } catch {
329
+ return undefined
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Seal one field with the exact format `openAuthField` reads. Test helper — it
335
+ * exists so the round-trip test proves the port against itself and against a
336
+ * transcription of the reference builder, without ever committing a real key.
337
+ *
338
+ * @param {Buffer} key
339
+ * @param {string} plaintext
340
+ * @param {number} [suite]
341
+ * @returns {{ '$wbEncrypted': 1, envelope: string }}
342
+ */
343
+ export function sealAuthFieldForTest(key, plaintext, suite = 1) {
344
+ const keyId = createHash('sha256').update(key).digest('hex').slice(0, 16)
345
+ const nonce = randomBytes(12)
346
+ const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 })
347
+ cipher.setAAD(buildAuthenticatedContextAad(keyId, suite))
348
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()])
349
+ const inner = {
350
+ suite,
351
+ keyId,
352
+ nonce: nonce.toString('base64'),
353
+ authTag: cipher.getAuthTag().toString('base64'),
354
+ ciphertext: ciphertext.toString('base64'),
355
+ }
356
+ return { '$wbEncrypted': 1, envelope: Buffer.from(JSON.stringify(inner), 'utf8').toString('base64') }
357
+ }
358
+
359
+ /**
360
+ * Validate the helper's payload against the app's own rules: `version:1` and
361
+ * a canonical-base64 32-byte, non-all-zero secret. `undefined` otherwise.
362
+ *
363
+ * @param {string} text
364
+ * @returns {{ atRestSecretKey: string } | undefined}
365
+ */
366
+ export function parseAtRestPayload(text) {
367
+ let parsed
368
+ try {
369
+ parsed = JSON.parse(text)
370
+ } catch {
371
+ return undefined
372
+ }
373
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
374
+ const payload = /** @type {Record<string, unknown>} */ (parsed)
375
+ if (payload['version'] !== 1) return undefined
376
+ const secret = payload['atRestSecretKey']
377
+ if (typeof secret !== 'string' || secret === '') return undefined
378
+ let decoded
379
+ try {
380
+ decoded = Buffer.from(secret, 'base64')
381
+ } catch {
382
+ return undefined
383
+ }
384
+ if (decoded.length !== 32) return undefined
385
+ if (decoded.toString('base64') !== secret) return undefined
386
+ if (decoded.every(byte => byte === 0)) return undefined
387
+ return { atRestSecretKey: secret }
388
+ }
389
+
390
+ /**
391
+ * Derive the protector key from the payload's secret (sha256 over its UTF-8 string).
392
+ *
393
+ * @param {string} secret
394
+ * @returns {Buffer}
395
+ */
396
+ export function deriveProtectorKey(secret) {
397
+ return createHash('sha256').update(secret, 'utf8').digest()
398
+ }
399
+
400
+ /** The enveloped key id a protector key answers for, exactly as envelopes name it. */
401
+ function keyIdOfProtectorKey(key) {
402
+ return createHash('sha256').update(key).digest('hex').slice(0, 16)
403
+ }
404
+
405
+ /** Absolute tool paths: never resolved through PATH, which a user can change. */
406
+ const MDFIND_BIN = '/usr/bin/mdfind'
407
+ const PLUTIL_BIN = '/usr/bin/plutil'
408
+
409
+ /** The CN app's bundle id; the only one this port resolves by discovery. */
410
+ export const WORKBUDDY_CN_BUNDLE_ID = 'com.tencent.workbuddy.mac'
411
+
412
+ /** One discovery subprocess's own limits. */
413
+ export const WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS = 3_000
414
+ /** Whole-discovery budget, independent of the helper's own timeout. */
415
+ export const WORKBUDDY_DISCOVERY_BUDGET_MS = 10_000
416
+ const MDFIND_MAX_OUTPUT_BYTES = 1024 * 1024
417
+ const PLUTIL_MAX_OUTPUT_BYTES = 64 * 1024
418
+
419
+ /**
420
+ * Why a discovery step could not produce an answer. Every one of these means
421
+ * "we do not know", explicitly *not* "the candidate does not exist" — the
422
+ * distinction is what keeps a half-finished check from being mistaken for a
423
+ * unique candidate.
424
+ */
425
+ class DiscoveryIncompleteError extends Error {}
426
+
427
+ /**
428
+ * A failure the caller must be able to classify. The code travels with the
429
+ * error so the status layer can promote it to `reasonCode` without re-deriving
430
+ * the cause from prose.
431
+ */
432
+ export class WorkBuddyElectronPathError extends Error {
433
+ /**
434
+ * @param {string} reasonCode
435
+ * @param {string} message
436
+ */
437
+ constructor(reasonCode, message) {
438
+ super(message)
439
+ this.name = 'WorkBuddyElectronPathError'
440
+ this.reasonCode = reasonCode
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Read the reason code off an arbitrary thrown value, when it carries one.
446
+ *
447
+ * @param {unknown} error
448
+ * @returns {string | undefined}
449
+ */
450
+ export function reasonCodeOf(error) {
451
+ return error instanceof WorkBuddyElectronPathError ? error.reasonCode : undefined
452
+ }
453
+
454
+ /** Whether a filesystem error reports an absent path (`existsSync` cannot tell). */
455
+ function isENOENT(error) {
456
+ return (/** @type {NodeJS.ErrnoException | null} */ (error))?.code === 'ENOENT'
457
+ }
458
+
459
+ function discoveryIncomplete(detail) {
460
+ return new WorkBuddyElectronPathError(
461
+ 'electron-discovery-incomplete',
462
+ `the WorkBuddy application search did not finish (${detail});`
463
+ + ' this is not proof that the app is missing',
464
+ )
465
+ }
466
+
467
+ /** Whether a path exists and is executable; never throws. */
468
+ function isExecutable(path) {
469
+ try {
470
+ if (process.platform !== 'win32') {
471
+ accessSync(path, constants.X_OK)
472
+ return true
473
+ }
474
+ // Node maps X_OK to existence on Windows (there is no execute bit), so the
475
+ // meaningful check is "this is a file we can open" — a directory, a missing
476
+ // file, or a permission failure all read as unusable here.
477
+ statSync(path).isFile()
478
+ accessSync(path, constants.R_OK)
479
+ return true
480
+ } catch {
481
+ return false
482
+ }
483
+ }
484
+
485
+ /**
486
+ * The default discovery tools: Spotlight for the bundle, `/usr/bin/plutil` for
487
+ * identity. Every failure that means "we could not tell" — a missing tool, a
488
+ * timeout, an oversized answer — is raised as {@link DiscoveryIncompleteError}
489
+ * so it can never be silently read as "no such app".
490
+ *
491
+ * @returns {{ findApps: (signal: AbortSignal) => Promise<readonly string[]>, bundleIdentifier: (bundlePath: string, signal: AbortSignal) => Promise<string | undefined>, bundleVersion: (bundlePath: string, signal: AbortSignal) => Promise<string | undefined> }}
492
+ */
493
+ export function workBuddyDiscoveryTools() {
494
+ /**
495
+ * @param {string} bin
496
+ * @param {readonly string[]} args
497
+ * @param {number} maxBytes
498
+ * @param {AbortSignal} signal
499
+ * @returns {Promise<string>}
500
+ */
501
+ const runTool = (bin, args, maxBytes, signal) =>
502
+ new Promise((resolve, reject) => {
503
+ if (signal.aborted) {
504
+ reject(new DiscoveryIncompleteError(`${bin} was not started: the discovery budget was already spent`))
505
+ return
506
+ }
507
+ let settled = false
508
+ const child = execFile(bin, [...args], { maxBuffer: maxBytes, timeout: WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS }, (error, stdout) => {
509
+ if (settled) return
510
+ settled = true
511
+ if (error !== null && error !== undefined) {
512
+ reject(new DiscoveryIncompleteError(`${bin} could not complete (${error.killed === true ? 'timed out' : String(error.code ?? 'unavailable')})`))
513
+ return
514
+ }
515
+ resolve(stdout)
516
+ })
517
+ const abort = () => {
518
+ if (settled) return
519
+ settled = true
520
+ child.kill()
521
+ reject(new DiscoveryIncompleteError(`${bin} was abandoned: the discovery budget was spent`))
522
+ }
523
+ signal.addEventListener('abort', abort, { once: true })
524
+ child.on('close', () => { signal.removeEventListener('abort', abort) })
525
+ })
526
+
527
+ return {
528
+ findApps: async signal => {
529
+ const out = await runTool(
530
+ MDFIND_BIN,
531
+ [`kMDItemCFBundleIdentifier == '${WORKBUDDY_CN_BUNDLE_ID}'`],
532
+ MDFIND_MAX_OUTPUT_BYTES,
533
+ signal,
534
+ )
535
+ return out.split('\n').map(line => line.trim()).filter(line => line.endsWith('.app'))
536
+ },
537
+ bundleIdentifier: async (bundlePath, signal) => {
538
+ try {
539
+ const out = await runTool(
540
+ PLUTIL_BIN,
541
+ ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', join(bundlePath, 'Contents', 'Info.plist')],
542
+ PLUTIL_MAX_OUTPUT_BYTES,
543
+ signal,
544
+ )
545
+ return out.trim()
546
+ } catch {
547
+ // An unreadable plist is "we could not check this one", not "this one
548
+ // does not match" — the candidate stays unresolved and the whole
549
+ // discovery reports incomplete rather than quietly dropping it.
550
+ return undefined
551
+ }
552
+ },
553
+ bundleVersion: async (bundlePath, signal) => {
554
+ try {
555
+ const out = await runTool(
556
+ PLUTIL_BIN,
557
+ ['-extract', 'CFBundleShortVersionString', 'raw', '-o', '-', join(bundlePath, 'Contents', 'Info.plist')],
558
+ PLUTIL_MAX_OUTPUT_BYTES,
559
+ signal,
560
+ )
561
+ const version = out.trim()
562
+ return version === '' ? undefined : version
563
+ } catch {
564
+ return undefined
565
+ }
566
+ },
567
+ }
568
+ }
569
+
570
+ /**
571
+ * In-memory protector-key resolver: one spawn per key id, single-flight, never
572
+ * persisted. The cache is keyed by the id envelopes ask for, so an envelope
573
+ * sealed under a rotated key triggers exactly one fresh resolution.
574
+ */
575
+ export class WorkBuddyAtRestKeyProvider {
576
+ /**
577
+ * @param {{
578
+ * electronPath?: string,
579
+ * timeoutMs?: number,
580
+ * source?: () => Promise<string>,
581
+ * spawnHelper?: (electronPath: string) => Promise<string>,
582
+ * discovery?: 'none' | 'macos-workbuddy',
583
+ * defaultElectronPaths?: readonly string[] | null,
584
+ * tools?: { findApps: (signal: AbortSignal) => Promise<readonly string[]>, bundleIdentifier: (bundlePath: string, signal: AbortSignal) => Promise<string | undefined>, bundleVersion: (bundlePath: string, signal: AbortSignal) => Promise<string | undefined> },
585
+ * discoveryBudgetMs?: number,
586
+ * }} [options]
587
+ */
588
+ constructor(options = {}) {
589
+ const fromEnv = process.env[WORKBUDDY_ELECTRON_BIN_ENV]?.trim()
590
+ const envPath = fromEnv === undefined || fromEnv === '' ? undefined : fromEnv
591
+ // Explicit sources are authoritative and mutually exclusive with
592
+ // discovery: naming a binary means "use this one", so an unusable one is
593
+ // an error, not an invitation to go looking for another app.
594
+ this.explicitPath = options.electronPath ?? envPath
595
+ this.discovery = options.discovery ?? 'none'
596
+ // `null` is "no default here"; `undefined` means the caller did not say,
597
+ // and a port that was never told which product it serves must not reach for
598
+ // one. The composition root passes the variant's defaults explicitly.
599
+ this.defaultPaths = options.defaultElectronPaths === undefined
600
+ ? []
601
+ : [...options.defaultElectronPaths].filter(path => typeof path === 'string' && path !== '')
602
+ this.tools = options.tools ?? workBuddyDiscoveryTools()
603
+ this.discoveryBudgetMs = options.discoveryBudgetMs ?? WORKBUDDY_DISCOVERY_BUDGET_MS
604
+ this.timeoutMs = options.timeoutMs ?? 10_000
605
+ this.spawnHelper = options.spawnHelper ?? (path => this.spawnAt(path))
606
+ this.source = options.source ?? (() => this.spawnPayload())
607
+ /**
608
+ * The path discovery settled on, cached only on success. A failure leaves
609
+ * this unset so the next attempt tries again — the user may install or move
610
+ * the app without restarting ZCode.
611
+ */
612
+ this.discoveredPath = undefined
613
+ /** @type {{ key: Buffer, keyId: string } | undefined} */
614
+ this.cache = undefined
615
+ /** @type {Promise<{ key: Buffer, keyId: string }> | undefined} */
616
+ this.inflight = undefined
617
+ }
618
+
619
+ /**
620
+ * The binary the default helper would use, for diagnostics.
621
+ *
622
+ * Reports a *discovery result* once one exists, so diagnostics describe what
623
+ * would actually run rather than the default that was bypassed. Discovery
624
+ * itself stays in {@link resolveElectronPath}: this accessor never triggers a
625
+ * search (the constructor must remain I/O-free, and callers may ask before
626
+ * any resolution has happened).
627
+ *
628
+ * @returns {string | undefined}
629
+ */
630
+ helperPath() {
631
+ if (this.explicitPath !== undefined) return this.explicitPath
632
+ if (this.discovery === 'none') return this.defaultPaths[0]
633
+ return this.discoveredPath ?? this.defaultPaths[0]
634
+ }
635
+
636
+ /**
637
+ * A protector key matching one of the requested envelope key ids. The first
638
+ * id the cache answers wins; otherwise one spawn resolves the current key,
639
+ * which must match a request — a mismatch means the envelopes were sealed by
640
+ * a different install than the one this machine now runs, and no key we can
641
+ * reach will open them.
642
+ *
643
+ * @param {readonly string[]} requested
644
+ * @returns {Promise<Buffer>}
645
+ */
646
+ async protectorKeyFor(requested) {
647
+ if (requested.length === 0) {
648
+ throw new WorkBuddyElectronPathError(
649
+ 'encrypted-credential-unreadable',
650
+ 'encrypted desktop credential carries no key ids',
651
+ )
652
+ }
653
+ const cached = this.cache
654
+ if (cached !== undefined && requested.includes(cached.keyId)) return cached.key
655
+ this.inflight ??= this.source().then(text => this.ingest(text))
656
+ .finally(() => {
657
+ this.inflight = undefined
658
+ })
659
+ const resolved = await this.inflight
660
+ if (!requested.includes(resolved.keyId)) {
661
+ throw new WorkBuddyElectronPathError(
662
+ 'encrypted-credential-unreadable',
663
+ `WorkBuddy's current at-rest key (id ${resolved.keyId}) does not match the credential's envelope (id ${requested.join(' or ')});`
664
+ + ' the desktop credential was sealed by a different WorkBuddy installation',
665
+ )
666
+ }
667
+ return resolved.key
668
+ }
669
+
670
+ /**
671
+ * @param {string} text
672
+ * @returns {{ key: Buffer, keyId: string }}
673
+ */
674
+ ingest(text) {
675
+ const payload = parseAtRestPayload(text)
676
+ if (payload === undefined) {
677
+ throw new WorkBuddyElectronPathError(
678
+ 'encrypted-credential-unreadable',
679
+ 'WorkBuddy key helper returned an unusable at-rest payload (expected {version:1, atRestSecretKey})',
680
+ )
681
+ }
682
+ const key = deriveProtectorKey(payload.atRestSecretKey)
683
+ const resolved = { key, keyId: keyIdOfProtectorKey(key) }
684
+ this.cache = resolved
685
+ return resolved
686
+ }
687
+
688
+ /**
689
+ * The binary to spawn, or a diagnosable error saying why there is none.
690
+ *
691
+ * Order is the contract: an explicit path is used as-is and never falls back;
692
+ * discovery runs only for a provider that was configured for it, and only
693
+ * after the platform defaults have been tried and found unusable.
694
+ *
695
+ * @returns {Promise<string>}
696
+ */
697
+ async resolveElectronPath() {
698
+ if (this.explicitPath !== undefined) {
699
+ if (!isExecutable(this.explicitPath)) {
700
+ throw new WorkBuddyElectronPathError(
701
+ 'electron-path-invalid',
702
+ `the configured WorkBuddy Electron binary is not available at ${this.explicitPath};`
703
+ + ` check ${WORKBUDDY_ELECTRON_BIN_ENV} or unset it to let the plugin look for the app itself`,
704
+ )
705
+ }
706
+ return this.explicitPath
707
+ }
708
+ for (const defaultPath of this.defaultPaths) {
709
+ if (isExecutable(defaultPath)) return defaultPath
710
+ }
711
+ if (this.discovery === 'none') {
712
+ // Either the other product/platform with no default, or a platform with
713
+ // no verified layout. Both are "not configured", never "we searched and
714
+ // failed".
715
+ throw new WorkBuddyElectronPathError(
716
+ 'electron-binary-unavailable',
717
+ `no WorkBuddy Electron binary is configured for this platform;`
718
+ + ` set ${WORKBUDDY_ELECTRON_BIN_ENV} to the app's Electron binary`,
719
+ )
720
+ }
721
+ // A previously discovered path is re-checked rather than trusted: the app
722
+ // may have been moved or removed since, and a stale path must not win.
723
+ if (this.discoveredPath !== undefined) {
724
+ if (isExecutable(this.discoveredPath)) return this.discoveredPath
725
+ this.discoveredPath = undefined
726
+ }
727
+ const found = await this.discoverMacosApp()
728
+ this.discoveredPath = found
729
+ return found
730
+ }
731
+
732
+ /**
733
+ * Resolve the CN app through Spotlight, then prove each candidate's identity
734
+ * before it can be executed.
735
+ *
736
+ * @returns {Promise<string>}
737
+ */
738
+ async discoverMacosApp() {
739
+ if (process.platform !== 'darwin') {
740
+ throw new WorkBuddyElectronPathError(
741
+ 'electron-binary-unavailable',
742
+ `no WorkBuddy Electron binary is configured for this platform;`
743
+ + ` set ${WORKBUDDY_ELECTRON_BIN_ENV} to the app's Electron binary`,
744
+ )
745
+ }
746
+ const controller = new AbortController()
747
+ const timer = setTimeout(() => controller.abort(), this.discoveryBudgetMs)
748
+ try {
749
+ let candidates
750
+ try {
751
+ candidates = await this.tools.findApps(controller.signal)
752
+ } catch {
753
+ throw discoveryIncomplete('the app search did not complete')
754
+ }
755
+ // Several Spotlight rows can name one bundle (path aliases, the
756
+ // /System/Volumes/Data view). Identity + realpath collapse those into
757
+ // one candidate; only genuinely distinct apps may count as "more than
758
+ // one", or a single app would look ambiguous.
759
+ const seen = new Map()
760
+ let unresolved = false
761
+ for (const candidate of candidates) {
762
+ // A Spotlight index keeps rows for apps deleted since the last sweep,
763
+ // and a stale row is a *decidable* exclusion: the candidate is gone,
764
+ // which is not the same as "we could not check it". Skipping it here
765
+ // is what stops one dead row from sinking a live app beside it —
766
+ // upstream issue #48 §3.7.
767
+ //
768
+ // Only ENOENT qualifies. `existsSync` is not usable here: it answers
769
+ // `false` for *any* error, so an EACCES/EPERM parent (or an
770
+ // ENAMETOOLONG path) would be read as "this app was deleted" and a
771
+ // live sibling would be chosen over a candidate we merely could not
772
+ // inspect. `statSync` distinguishes them, and anything other than a
773
+ // confirmed absence stays unresolved.
774
+ try {
775
+ statSync(candidate)
776
+ } catch (error) {
777
+ if (isENOENT(error)) continue
778
+ unresolved = true
779
+ continue
780
+ }
781
+ let bundleIdentifier
782
+ try {
783
+ bundleIdentifier = await this.tools.bundleIdentifier(candidate, controller.signal)
784
+ } catch {
785
+ unresolved = true
786
+ continue
787
+ }
788
+ if (bundleIdentifier === undefined) {
789
+ unresolved = true
790
+ continue
791
+ }
792
+ if (bundleIdentifier !== WORKBUDDY_CN_BUNDLE_ID) continue
793
+ const electronPath = join(candidate, 'Contents', 'MacOS', 'Electron')
794
+ if (!isExecutable(electronPath)) continue
795
+ let identity
796
+ try {
797
+ identity = realpathSync(candidate)
798
+ } catch {
799
+ identity = candidate
800
+ }
801
+ if (seen.has(identity)) continue
802
+ let version
803
+ try {
804
+ version = await this.tools.bundleVersion(candidate, controller.signal)
805
+ } catch {
806
+ // Version is display-only; an unreadable one must not sink an
807
+ // otherwise identified candidate.
808
+ version = undefined
809
+ }
810
+ seen.set(identity, { bundlePath: candidate, electronPath, ...(version === undefined ? {} : { version }) })
811
+ }
812
+ if (seen.size > 1) {
813
+ const listed = [...seen.values()]
814
+ .map(app => ` - ${app.bundlePath}${app.version === undefined ? '' : ` (${app.version})`}`)
815
+ .join('\n')
816
+ throw new WorkBuddyElectronPathError(
817
+ 'electron-binary-ambiguous',
818
+ `more than one WorkBuddy application was found, so none was chosen:\n${listed}\n`
819
+ + ` set ${WORKBUDDY_ELECTRON_BIN_ENV} to the one to use`,
820
+ )
821
+ }
822
+ // A candidate nobody could check might have been a second copy, so its
823
+ // existence forbids claiming the rest are unique.
824
+ if (unresolved) throw discoveryIncomplete('some candidates could not be checked')
825
+ if (seen.size === 0) {
826
+ throw new WorkBuddyElectronPathError(
827
+ 'electron-binary-not-found',
828
+ 'no WorkBuddy application was found in the default location or the system index;'
829
+ + ' if WorkBuddy is installed elsewhere, it may not be indexed yet',
830
+ )
831
+ }
832
+ return [...seen.values()][0].electronPath
833
+ } finally {
834
+ clearTimeout(timer)
835
+ controller.abort()
836
+ }
837
+ }
838
+
839
+ /** @returns {Promise<string>} */
840
+ async spawnPayload() {
841
+ return await this.spawnHelper(await this.resolveElectronPath())
842
+ }
843
+
844
+ /**
845
+ * @param {string} electronPath
846
+ * @returns {Promise<string>}
847
+ */
848
+ async spawnAt(electronPath) {
849
+ return await new Promise((resolve, reject) => {
850
+ execFile(electronPath, [HELPER_SCRIPT_ARGUMENT_FLAG, HELPER_SCRIPT], {
851
+ timeout: this.timeoutMs,
852
+ maxBuffer: 1024 * 1024,
853
+ windowsHide: true,
854
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
855
+ }, (error, stdout) => {
856
+ if (error !== null && error !== undefined) {
857
+ // Code and reason only: stdout/stderr can carry paths or crash dumps,
858
+ // and the payload must never appear in a message.
859
+ const reason = error.killed === true
860
+ ? `timed out or was killed after ${String(this.timeoutMs)}ms`
861
+ : error.code !== undefined
862
+ ? `exited with code ${String(error.code)}`
863
+ : 'could not be started'
864
+ reject(new WorkBuddyElectronPathError(
865
+ 'encrypted-credential-unreadable',
866
+ `the WorkBuddy key helper (${electronPath}) ${reason}`,
867
+ ))
868
+ return
869
+ }
870
+ const output = stdout.trim()
871
+ if (output === '') {
872
+ reject(new WorkBuddyElectronPathError(
873
+ 'encrypted-credential-unreadable',
874
+ `the WorkBuddy key helper (${electronPath}) produced no payload`,
875
+ ))
876
+ return
877
+ }
878
+ resolve(output)
879
+ })
880
+ })
881
+ }
882
+ }
883
+
884
+ /**
885
+ * The helper: run inside WorkBuddy's Electron as plain Node, where the private
886
+ * `workbuddyStorage` binding exists, and print only the payload. It writes
887
+ * nothing else, so whatever reaches stdout is the payload.
888
+ */
889
+ const HELPER_SCRIPT = 'process.stdout.write(String(process._linkedBinding("electron_browser_workbuddy_storage").loggerGet()))'
890
+ const HELPER_SCRIPT_ARGUMENT_FLAG = '-e'