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.
- package/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +22 -0
- package/NOTICE.md +49 -0
- package/README.md +264 -2
- package/bin/cli.mjs +8 -0
- package/docs/adr/0001-loopback-provider-for-zcode.md +66 -0
- package/docs/adr/0002-widest-window-and-output-ceiling.md +98 -0
- package/docs/adr/0003-region-by-url-path.md +97 -0
- package/docs/adr/0004-workbuddy-56-at-rest-credentials.md +111 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/docs/verify-endpoint.mjs +162 -0
- package/hooks/service-ensure.mjs +133 -0
- package/package.json +59 -4
- package/skills/workbuddy-models/SKILL.md +107 -0
- package/src/app-version.js +119 -0
- package/src/auth.js +441 -0
- package/src/catalog.js +100 -0
- package/src/cli.js +1010 -0
- package/src/client-identity.js +213 -0
- package/src/config.js +91 -0
- package/src/desktop-credential-protection.js +890 -0
- package/src/loopback.js +40 -0
- package/src/server.js +873 -0
- package/src/upstream.js +762 -0
|
@@ -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'
|