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,107 @@
1
+ ---
2
+ name: workbuddy-models
3
+ description: Operate the WorkBuddy model provider — start/stop the local endpoint, check sign-in and remaining credit, list or refresh the model roster, and troubleshoot "model fails to respond" errors. Use when the user asks about WorkBuddy models in ZCode, the provider shows no models, a WorkBuddy model errors out, credit balance, or the login state of the WorkBuddy desktop app.
4
+ when_to_use: The user mentions WorkBuddy, the WorkBuddy provider, a WorkBuddy model (GLM-5.3, DeepSeek-V4-Pro, Kimi-K3, MiniMax-M3, Hy3, GLM-5v-Turbo), or reports that a model from this provider fails.
5
+ ---
6
+
7
+ # WorkBuddy models in ZCode
8
+
9
+ This plugin exposes the models inside the **WorkBuddy desktop app** (CN) as a
10
+ ZCode model provider. The desktop app's models are reachable only with the app's
11
+ own login, and only through an internal API that rejects ordinary OpenAI
12
+ clients, so a small local service translates between the two:
13
+
14
+ ```
15
+ ZCode ──(OpenAI chat-completions)──▶ 127.0.0.1:39271 ──▶ copilot.tencent.com
16
+ local endpoint (WorkBuddy)
17
+ ```
18
+
19
+ The service reads the WorkBuddy desktop app's sign-in file (read-only), refreshes
20
+ tokens into its own state directory, and runs the model roster as a provider.
21
+ Everything lives outside the app: nothing here writes to WorkBuddy's files.
22
+
23
+ ## Where things are
24
+
25
+ | Thing | Path |
26
+ |---|---|
27
+ | Service + CLI | `${ZCODE_PLUGIN_ROOT}` |
28
+ | State (token copy, endpoint bearer, logs) | `%USERPROFILE%\.zcode-workbuddy-connect` |
29
+ | Provider entry | `%USERPROFILE%\.zcode\v2\provider_config.json` |
30
+ | WorkBuddy sign-in (read-only) | `%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\workbuddy-desktop.info` |
31
+
32
+ All CLI commands below are run as `node "${ZCODE_PLUGIN_ROOT}/bin/cli.mjs" <command>`.
33
+
34
+ ## The one thing that usually matters
35
+
36
+ The provider only works while the local endpoint is running. A `SessionStart`
37
+ hook starts it automatically, but a locked-down host can refuse a detached
38
+ spawn, so the first check for any "model not responding" report is always:
39
+
40
+ ```sh
41
+ node "${ZCODE_PLUGIN_ROOT}/bin/cli.mjs" service-status
42
+ ```
43
+
44
+ - **`endpoint … up (N models)`** — the service is fine; look elsewhere.
45
+ - **`endpoint … NOT reachable`** — start it in a terminal:
46
+ `node "${ZCODE_PLUGIN_ROOT}/bin/cli.mjs" serve`
47
+
48
+ ## Commands
49
+
50
+ | Command | What it does |
51
+ |---|---|
52
+ | `serve` | Run the endpoint in the foreground (`--port` to change, default 39271) |
53
+ | `service-status` | Is the endpoint reachable; is autostart registered |
54
+ | `status` | Sign-in state, token expiry, remaining credit, the whole model roster |
55
+ | `refresh` | Re-pull the model roster from the upstream |
56
+ | `doctor` | Credentials, upstream reachability, catalog, credit — for diagnosis |
57
+ | `setup` | Rewrite the ZCode provider entry from the live roster |
58
+ | `install-service` / `uninstall-service` | Register/remove a logon autostart launcher |
59
+ | `logout` | Drop the service's own token copy (the desktop app is untouched) |
60
+
61
+ ## Diagnosing a failure
62
+
63
+ Work through these in order; each one is cheap and they are ordered by how often
64
+ they are the cause.
65
+
66
+ 1. **Is the endpoint up?** `service-status`. If not reachable, start it.
67
+ 2. **Is WorkBuddy still signed in?** `doctor`. The desktop app's token expires;
68
+ when it does, the fix is to open the WorkBuddy app once. The plugin cannot
69
+ sign in on the user's behalf and must never be asked to.
70
+ 3. **Does the model still exist?** `refresh` then `status`. The upstream roster
71
+ changes often — models appear and disappear — so a model that worked last
72
+ month may simply be gone.
73
+ 4. **Is there credit left?** `status` prints the remaining credit and any
74
+ promotional badges. `402` from the endpoint means insufficient credit, and no
75
+ local fix exists.
76
+ 5. **Read the log.** `%USERPROFILE%\.zcode-workbuddy-connect\service.log` records hook
77
+ and startup decisions; the running service also prints to its console.
78
+
79
+ ## Boundaries — do not cross these
80
+
81
+ - **Never write to the WorkBuddy desktop app's own files.** The sign-in file is
82
+ read-only input. Token refreshes go to
83
+ `%USERPROFILE%\.zcode-workbuddy-connect\.workbuddy-auth.json` and nowhere else; that
84
+ separation is what stops this plugin from breaking the desktop app's login.
85
+ - **Never ask for or handle the user's WorkBuddy password.** The plugin reuses
86
+ the desktop app's existing session by design; there is no credential to type.
87
+ - **Do not hand-edit the token** in `endpoint.json` while the service is running
88
+ — the service holds the bearer in memory from startup, so a hand-edited value
89
+ will not match and every request will 401. Change it with `--token` and
90
+ restart, or delete `endpoint.json` and let it regenerate.
91
+ - **Do not "fix" a missing model by adding it to the provider config by hand.**
92
+ Run `setup`: it writes the roster the upstream actually reports, including the
93
+ per-model context window and vision flags.
94
+
95
+ ## Notes on behaviour worth knowing
96
+
97
+ - **Reasoning models think for a long time before their first token.** The
98
+ endpoint emits SSE heartbeat comments during that silence so the connection
99
+ and the client's read timeout survive it. A long pause before the first word
100
+ is normal, not a hang.
101
+ - **Tool calls are repaired in transit.** The upstream streams tool-call
102
+ fragments in an unusual shape (the id and function name ride on the first
103
+ fragment only, and the finish event repeats). The endpoint reassembles them,
104
+ so a client sees one clean `tool_calls` array.
105
+ - **Prices are informational.** Rates come from the upstream roster and are
106
+ cached; a promotion that expires is dropped rather than left claiming a
107
+ discount. `rateUnknown` means the cached rate can no longer be trusted.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Installed-App version resolution, used for the international catalog's
3
+ * App-shaped User-Agent. Only the CN variant is wired up in this service, so
4
+ * this module serves the shared validator and the fallback constant; the
5
+ * international probe is kept because the region gate still routes an
6
+ * international credential to it.
7
+ *
8
+ * @module zcode-workbuddy-connect/app-version
9
+ */
10
+
11
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
12
+ import { dirname, join } from 'node:path'
13
+ import { savedVersionPath } from './config.js'
14
+
15
+ /** Compiled-in international fallback. */
16
+ export const FALLBACK_APP_VERSION = '5.5.6'
17
+
18
+ /**
19
+ * Compiled-in CN fallback for the `WorkBuddy/<v>` tokens.
20
+ *
21
+ * Observed on the CN desktop app; like the international fallback it is a
22
+ * shape requirement, not a currency claim — the gateway has not been observed
23
+ * to branch on it.
24
+ */
25
+ export const FALLBACK_CN_APP_VERSION = '5.5.6'
26
+
27
+ /** Basename of the international saved-version cache. */
28
+ export const GLOBAL_APP_VERSION_FILENAME = '.workbuddy-ai-version.json'
29
+
30
+ /**
31
+ * Whether a value is an App version that may reach a header. Tolerates a
32
+ * prerelease suffix and a four-part build number; anything with whitespace,
33
+ * CR or LF never passes — the value is interpolated into an HTTP header.
34
+ */
35
+ export function validAppVersion(value) {
36
+ return typeof value === 'string' && /^\d{1,6}(?:\.\d{1,6}){1,3}(?:-[0-9A-Za-z.]+)?$/u.test(value)
37
+ }
38
+
39
+ /** The App-shaped User-Agent for the international catalog request. */
40
+ export function appUserAgent(version) {
41
+ return `WorkBuddy AI/${version}`
42
+ }
43
+
44
+ /**
45
+ * Read a version out of an App manifest.
46
+ *
47
+ * Handles the three shapes actually seen in the wild: a JSON `package.json`
48
+ * with a `version` field, an XML `Info.plist` with `CFBundleShortVersionString`,
49
+ * and undefined for anything else.
50
+ */
51
+ export async function readBundleVersion(path) {
52
+ let text
53
+ try {
54
+ text = await readFile(path, 'utf8')
55
+ } catch {
56
+ return undefined
57
+ }
58
+ if (text.trimStart().startsWith('{')) {
59
+ try {
60
+ const pkg = JSON.parse(text)
61
+ if (validAppVersion(pkg?.['version'])) return pkg['version']
62
+ const product = pkg?.['productVersion'] ?? pkg?.['buildVersion']
63
+ if (validAppVersion(product)) return product
64
+ } catch {
65
+ return undefined
66
+ }
67
+ return undefined
68
+ }
69
+ const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/u.exec(text)
70
+ return match !== null && validAppVersion(match[1]) ? match[1] : undefined
71
+ }
72
+
73
+ /**
74
+ * International App version: installed bundle → saved cache → compiled-in
75
+ * fallback. Never throws; a missing App or unreadable manifest degrades to
76
+ * the constant.
77
+ */
78
+ export async function resolveAppVersion() {
79
+ const candidates = []
80
+ if (process.platform === 'darwin') {
81
+ candidates.push('/Applications/WorkBuddy AI.app')
82
+ } else if (process.platform === 'win32') {
83
+ const local = process.env['LOCALAPPDATA']
84
+ const programFiles = process.env['ProgramFiles'] ?? 'C:/Program Files'
85
+ if (local) candidates.push(join(local, 'Programs', 'WorkBuddy AI'))
86
+ candidates.push(join(programFiles, 'WorkBuddy AI'))
87
+ }
88
+ for (const root of candidates) {
89
+ for (const manifest of [
90
+ join(root, 'resources', 'app', 'package.json'),
91
+ join(root, 'Contents', 'Info.plist'),
92
+ join(root, 'package.json'),
93
+ ]) {
94
+ const version = await readBundleVersion(manifest)
95
+ if (version !== undefined) {
96
+ await rememberVersion(version)
97
+ return { version, root }
98
+ }
99
+ }
100
+ }
101
+ const savedPath = savedVersionPath()
102
+ try {
103
+ const saved = JSON.parse(await readFile(savedPath, 'utf8'))
104
+ if (validAppVersion(saved?.['version'])) return { version: saved['version'] }
105
+ } catch {
106
+ // absent or malformed cache
107
+ }
108
+ return { version: FALLBACK_APP_VERSION }
109
+ }
110
+
111
+ async function rememberVersion(version) {
112
+ try {
113
+ const savedPath = savedVersionPath()
114
+ await mkdir(dirname(savedPath), { recursive: true })
115
+ await writeFile(savedPath, `${JSON.stringify({ version, observedAt: Date.now() }, null, 2)}\n`, { mode: 0o600 })
116
+ } catch {
117
+ // Best-effort memory; not a failure.
118
+ }
119
+ }
package/src/auth.js ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * WorkBuddy credential resolution. The primary source is the WorkBuddy
3
+ * desktop app's own auth file, read-only; a plugin-owned copy under the
4
+ * service's state directory holds token refreshes so the desktop file is
5
+ * never written. The effective credential is whichever of the two belongs to
6
+ * the currently signed-in account and expires later.
7
+ *
8
+ * @module zcode-workbuddy-connect/auth
9
+ */
10
+
11
+ import { readFile, rm, stat, writeFile, mkdir, rename } from 'node:fs/promises'
12
+ import { homedir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+ import { regionOf } from './upstream.js'
15
+ import {
16
+ WorkBuddyAtRestKeyProvider,
17
+ WorkBuddyElectronPathError,
18
+ classifyDesktopAuthDocument,
19
+ keyIdsOf,
20
+ openAuthField,
21
+ reasonCodeOf,
22
+ unwrapDesktopAuthDocument,
23
+ } from './desktop-credential-protection.js'
24
+
25
+ /** Current on-disk format of the plugin-owned copy; readers reject others. */
26
+ const OWN_FORMAT_VERSION = 1
27
+
28
+ /** Basename of the desktop app's own auth file in the shared auth directory. */
29
+ export const DESKTOP_AUTH_FILENAME = 'workbuddy-desktop.info'
30
+ /** Basename of the international app's auth file (same directory, different app). */
31
+ export const DESKTOP_AUTH_AI_FILENAME = 'workbuddy-desktop-ai.info'
32
+
33
+ const DESKTOP_AUTH_RELATIVE_PATH = ['CodeBuddyExtension', 'Data', 'Public', 'auth', DESKTOP_AUTH_FILENAME]
34
+
35
+ /** Env variable that overrides the desktop auth-file location. */
36
+ export const WORKBUDDY_AUTH_FILE_ENV = 'WORKBUDDY_AUTH_FILE'
37
+ /** Env variable overriding the international desktop auth-file location. */
38
+ export const WORKBUDDY_AI_AUTH_FILE_ENV = 'WORKBUDDY_AI_AUTH_FILE'
39
+
40
+ /** Normalize an expiry that may arrive in seconds or milliseconds. */
41
+ function expiryToMs(value) {
42
+ if (typeof value !== 'number' || value <= 0) return 0
43
+ return value > 1e12 ? value : value * 1000
44
+ }
45
+
46
+ function optionalString(value) {
47
+ return typeof value === 'string' && value !== '' ? value : undefined
48
+ }
49
+
50
+ function isObject(value) {
51
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
52
+ }
53
+
54
+ /**
55
+ * Platform-default candidates for one variant's auth file, in probe order.
56
+ * Windows probes both AppData roots: current builds write under
57
+ * `%LOCALAPPDATA%` (Local), older ones under `%APPDATA%` (Roaming). The two
58
+ * apps share the directory and differ only in basename.
59
+ *
60
+ * @param {string} desktopFilename which variant's file to look for
61
+ */
62
+ export function defaultDesktopAuthCandidates(desktopFilename = DESKTOP_AUTH_FILENAME) {
63
+ const relative = ['CodeBuddyExtension', 'Data', 'Public', 'auth', desktopFilename]
64
+ const home = homedir()
65
+ if (process.platform === 'win32') {
66
+ const local = process.env['LOCALAPPDATA'] ?? join(home, 'AppData', 'Local')
67
+ const roaming = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming')
68
+ return [
69
+ join(local, ...relative),
70
+ join(roaming, ...relative),
71
+ ]
72
+ }
73
+ if (process.platform === 'darwin') {
74
+ return [join(home, 'Library', 'Application Support', ...relative)]
75
+ }
76
+ return [join(home, '.config', ...relative)]
77
+ }
78
+
79
+ /**
80
+ * Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
81
+ * nested form `{"auth":{...},"account":{...}}` and the flat panel form.
82
+ * Returns undefined when the document carries no access token.
83
+ */
84
+ export function parseWorkBuddyAuth(text) {
85
+ let parsed
86
+ try {
87
+ parsed = JSON.parse(text)
88
+ } catch {
89
+ return undefined
90
+ }
91
+ if (!isObject(parsed)) return undefined
92
+ let auth
93
+ let identity
94
+ if (isObject(parsed['auth'])) {
95
+ auth = parsed['auth']
96
+ identity = isObject(parsed['account']) ? parsed['account'] : {}
97
+ } else {
98
+ auth = parsed
99
+ identity = parsed
100
+ }
101
+ const accessToken = typeof auth['accessToken'] === 'string' ? auth['accessToken'] : ''
102
+ if (accessToken === '') return undefined
103
+ const refreshExpiresAtMs = typeof auth['refreshExpiresAt'] === 'number' ? expiryToMs(auth['refreshExpiresAt']) : undefined
104
+ const enterpriseId = optionalString(identity['enterpriseId'])
105
+ const nickname = optionalString(identity['nickname'])
106
+ return {
107
+ accessToken,
108
+ refreshToken: typeof auth['refreshToken'] === 'string' ? auth['refreshToken'] : '',
109
+ expiresAtMs: typeof auth['expiresAt'] === 'number' ? expiryToMs(auth['expiresAt']) : 0,
110
+ ...(refreshExpiresAtMs === undefined ? {} : { refreshExpiresAtMs }),
111
+ domain: optionalString(auth['domain']) ?? '',
112
+ uid: optionalString(identity['uid']) ?? '',
113
+ ...(enterpriseId === undefined ? {} : { enterpriseId }),
114
+ ...(nickname === undefined ? {} : { nickname }),
115
+ source: 'desktop',
116
+ }
117
+ }
118
+
119
+ /** Parse the plugin-owned copy; other versions and shapes are rejected. */
120
+ function parseOwnDocument(text) {
121
+ let parsed
122
+ try {
123
+ parsed = JSON.parse(text)
124
+ } catch {
125
+ return undefined
126
+ }
127
+ if (!isObject(parsed)) return undefined
128
+ if (parsed['version'] !== OWN_FORMAT_VERSION) return undefined
129
+ if (!isObject(parsed['credential'])) return undefined
130
+ // The owned copy stores the normalized credential itself (camelCase
131
+ // `expiresAtMs`, identity fields at the top level), NOT the desktop document
132
+ // shape. Round-tripping through parseWorkBuddyAuth would read `expiresAt`
133
+ // and an `account` object, find neither, and drop uid/enterprise/nickname.
134
+ const stored = parsed['credential']
135
+ const accessToken = typeof stored['accessToken'] === 'string' ? stored['accessToken'] : ''
136
+ if (accessToken === '') return undefined
137
+ const refreshExpiresAtMs = typeof stored['refreshExpiresAtMs'] === 'number' ? stored['refreshExpiresAtMs'] : undefined
138
+ const enterpriseId = optionalString(stored['enterpriseId'])
139
+ const nickname = optionalString(stored['nickname'])
140
+ return {
141
+ accessToken,
142
+ refreshToken: typeof stored['refreshToken'] === 'string' ? stored['refreshToken'] : '',
143
+ expiresAtMs: typeof stored['expiresAtMs'] === 'number' ? stored['expiresAtMs'] : 0,
144
+ ...(refreshExpiresAtMs === undefined ? {} : { refreshExpiresAtMs }),
145
+ domain: optionalString(stored['domain']) ?? '',
146
+ uid: optionalString(stored['uid']) ?? '',
147
+ ...(enterpriseId === undefined ? {} : { enterpriseId }),
148
+ ...(nickname === undefined ? {} : { nickname }),
149
+ source: 'own',
150
+ }
151
+ }
152
+
153
+ function isENOENT(error) {
154
+ return (error?.code) === 'ENOENT'
155
+ }
156
+
157
+ /**
158
+ * Read-only credential store with demand-driven refresh.
159
+ *
160
+ * Refresh policy: refresh only when the access token is inside the margin (or
161
+ * already expired), keep the refreshed credential in the plugin-owned copy,
162
+ * and never write the desktop app's file. A failed refresh still returns a
163
+ * not-yet-expired token so an unreachable refresh endpoint does not take down
164
+ * a working session.
165
+ */
166
+ export class WorkBuddyCredentialStore {
167
+ /**
168
+ * @param {object} options
169
+ * @param {(credential: object) => Promise<object>} options.refresh performs the upstream token refresh
170
+ * @param {string} options.ownPath plugin-owned credential copy path
171
+ * @param {string} [options.desktopPath] explicit desktop auth-file path
172
+ * @param {string} [options.desktopFilename] basename for the platform-default probe (variant selection)
173
+ * @param {string} [options.authFileEnv] env var overriding the desktop path (default WORKBUDDY_AUTH_FILE)
174
+ * @param {string} [options.appName] display name for diagnostics ('WorkBuddy' | 'WorkBuddy AI')
175
+ * @param {number} [options.refreshMarginMs] refresh this long before expiry
176
+ * @param {{ protectorKeyFor: (requested: readonly string[]) => Promise<Buffer>, helperPath: () => string | undefined }} [options.keyProvider]
177
+ * opens WorkBuddy 5.6 at-rest envelopes by spawning the app's own
178
+ * Electron; injected per variant by the composition root
179
+ */
180
+ constructor(options) {
181
+ this.refresh = options.refresh
182
+ this.refreshMarginMs = options.refreshMarginMs ?? 5 * 60 * 1000
183
+ this.ownPath = options.ownPath
184
+ this.desktopPathOverride = options.desktopPath
185
+ this.desktopFilename = options.desktopFilename ?? DESKTOP_AUTH_FILENAME
186
+ this.authFileEnv = options.authFileEnv ?? WORKBUDDY_AUTH_FILE_ENV
187
+ this.appName = options.appName ?? 'WorkBuddy'
188
+ // Injected per variant by the composition root: the provider knows nothing
189
+ // about which product it serves, only how it was configured. The no-arg
190
+ // default is deliberately the safe one — no default binary, no discovery —
191
+ // so a store built without a provider never reaches for another product's
192
+ // app; it reports that no binary is configured instead.
193
+ this.keyProvider = options.keyProvider ?? new WorkBuddyAtRestKeyProvider()
194
+ this.inflight = undefined
195
+ }
196
+
197
+ /**
198
+ * Configuration precedence for the desktop file: the configured path, then
199
+ * the environment variable, then the platform defaults.
200
+ */
201
+ resolveDesktopCandidates() {
202
+ const fromEnv = process.env[this.authFileEnv]
203
+ const explicit = this.desktopPathOverride
204
+ ?? (fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : undefined)
205
+ if (explicit !== undefined) return [explicit]
206
+ return defaultDesktopAuthCandidates(this.desktopFilename)
207
+ }
208
+
209
+ /** The resolved desktop auth-file path, for diagnostics. */
210
+ desktopAuthPath() {
211
+ return this.resolveDesktopCandidates()[0]
212
+ }
213
+
214
+ /** The plugin-owned copy path, for diagnostics. */
215
+ ownAuthPath() {
216
+ return this.ownPath
217
+ }
218
+
219
+ /** Read the freshest stored credential without refreshing anything. */
220
+ async current() {
221
+ const [desktop, own] = await Promise.all([this.readDesktop(), this.readOwn()])
222
+ if (desktop === undefined) return own
223
+ if (own === undefined) return desktop
224
+ // Identity beats expiry. The own copy is written by this service's own
225
+ // refreshes, so after the user switches accounts in the desktop app the
226
+ // copy still belongs to the *previous* account — and may well expire
227
+ // later, because we refreshed it. Preferring it by expiry would send the
228
+ // old account's uid in `X-User-Id` and answer as the wrong user. The
229
+ // desktop file is the authority on who is signed in now.
230
+ if (desktop.uid !== own.uid || desktop.enterpriseId !== own.enterpriseId) return desktop
231
+ return own.expiresAtMs > desktop.expiresAtMs ? own : desktop
232
+ }
233
+
234
+ /**
235
+ * The credential to send upstream: {@link current}, refreshed on demand.
236
+ * Single-flight, so parallel requests share one refresh.
237
+ */
238
+ async resolve() {
239
+ const credential = await this.current()
240
+ if (credential === undefined) {
241
+ const candidates = this.resolveDesktopCandidates()
242
+ const desktop = candidates.length > 0 ? candidates.join(' or ') : '(no desktop path on this platform)'
243
+ throw new Error(
244
+ `workbuddy: no signed-in ${this.appName} account found; sign in once in the ${this.appName} desktop app`
245
+ + ` (expected ${desktop} or ${this.authFileEnv})`,
246
+ )
247
+ }
248
+ if (!this.needsRefresh(credential)) return credential
249
+ this.inflight ??= this.refreshNow(credential).finally(() => {
250
+ this.inflight = undefined
251
+ })
252
+ return this.inflight
253
+ }
254
+
255
+ /** Read-only sign-in summary; never refreshes and never throws. */
256
+ async status() {
257
+ try {
258
+ const credential = await this.current()
259
+ if (credential === undefined) return { state: 'signed-out' }
260
+ return {
261
+ state: 'signed-in',
262
+ expiresAtMs: credential.expiresAtMs,
263
+ ...(credential.refreshExpiresAtMs === undefined ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs }),
264
+ ...(credential.nickname === undefined ? {} : { nickname: credential.nickname }),
265
+ ...(credential.domain === '' ? {} : { domain: credential.domain }),
266
+ ...(credential.enterpriseId === undefined ? {} : { enterpriseId: credential.enterpriseId }),
267
+ uid: credential.uid,
268
+ region: regionOf(credential.domain),
269
+ source: credential.source,
270
+ }
271
+ } catch (error) {
272
+ // A region mismatch, an unreadable desktop file, or a failed envelope
273
+ // open is a *diagnosable* signed-out state, not a silent one. The reason
274
+ // stays human-readable; the code is the machine-readable counterpart, so
275
+ // a caller never has to parse the prose.
276
+ const reason = error instanceof Error ? error.message : String(error)
277
+ const reasonCode = reasonCodeOf(error)
278
+ return {
279
+ state: 'signed-out',
280
+ reason,
281
+ ...(reasonCode === undefined ? {} : { reasonCode }),
282
+ }
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Classify the first desktop candidate that exists and carries content;
288
+ * `absent` when none does. An empty first file is skipped so it cannot mask
289
+ * a real document on the next candidate. Diagnostics only — it never spawns
290
+ * the key helper and never decrypts, so doctor can describe the file without
291
+ * attempting the unlock.
292
+ */
293
+ async desktopAuthFormat() {
294
+ for (const desktopPath of this.resolveDesktopCandidates()) {
295
+ let text
296
+ try {
297
+ text = await readFile(desktopPath, 'utf8')
298
+ } catch (error) {
299
+ if (!isENOENT(error)) throw error
300
+ continue
301
+ }
302
+ const format = classifyDesktopAuthDocument(text).format
303
+ if (format !== 'absent') return format
304
+ }
305
+ return 'absent'
306
+ }
307
+
308
+ /** The Electron binary the key helper would use; diagnostics only. */
309
+ keyHelperPath() {
310
+ return this.keyProvider.helperPath()
311
+ }
312
+
313
+ /** Remove the plugin-owned copy; the desktop file is untouched. */
314
+ async logout() {
315
+ await rm(this.ownPath, { force: true })
316
+ }
317
+
318
+ needsRefresh(credential) {
319
+ if (credential.expiresAtMs <= 0) return true
320
+ return Date.now() + this.refreshMarginMs >= credential.expiresAtMs
321
+ }
322
+
323
+ async refreshNow(credential) {
324
+ if (credential.refreshToken === '') {
325
+ if (credential.expiresAtMs > Date.now() + 30_000) return credential
326
+ throw new Error('workbuddy: access token expired and no refresh token is stored; sign in again in the WorkBuddy desktop app')
327
+ }
328
+ try {
329
+ const outcome = await this.refresh(credential)
330
+ const refreshed = {
331
+ ...credential,
332
+ accessToken: outcome.accessToken,
333
+ ...(outcome.refreshToken === undefined ? {} : { refreshToken: outcome.refreshToken }),
334
+ expiresAtMs: outcome.expiresInSec !== undefined
335
+ ? Date.now() + outcome.expiresInSec * 1000
336
+ : credential.expiresAtMs,
337
+ ...(outcome.domain === undefined || outcome.domain === '' ? {} : { domain: outcome.domain }),
338
+ source: 'own',
339
+ }
340
+ await this.saveOwn(refreshed)
341
+ return refreshed
342
+ } catch (error) {
343
+ if (credential.expiresAtMs > Date.now() + 30_000) return credential
344
+ throw new Error(
345
+ `workbuddy: token refresh failed and the access token is expired (${String(error)});`
346
+ + ' open the WorkBuddy desktop app once to sign in again',
347
+ )
348
+ }
349
+ }
350
+
351
+ /** Atomic write of the plugin-owned copy (temp file + rename). */
352
+ async saveOwn(credential) {
353
+ const document = { version: OWN_FORMAT_VERSION, credential }
354
+ await mkdir(dirname(this.ownPath), { recursive: true })
355
+ const temp = `${this.ownPath}.${process.pid}.tmp`
356
+ await writeFile(temp, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 })
357
+ await rename(temp, this.ownPath)
358
+ }
359
+
360
+ /**
361
+ * Read the first desktop candidate that exists. Only an absent file (ENOENT)
362
+ * falls through to the next candidate; a file that is present but unreadable
363
+ * is authoritative for its slot, so a stale older-version file never
364
+ * silently wins over a broken newer one.
365
+ *
366
+ * Since WorkBuddy 5.6 the token fields may arrive in at-rest envelopes, so the
367
+ * text is classified before the regular parser sees it. An encrypted
368
+ * document must be *opened*, never skipped; an unrecognized one must fail
369
+ * loudly. The desktop file, as long as it exists, is the identity authority —
370
+ * a document this plugin cannot read must surface as a diagnosis rather than
371
+ * be papered over by the plugin-owned copy, which belongs to whatever account
372
+ * was signed in when it was last refreshed. Only an absent (or empty) file
373
+ * lets the probe continue.
374
+ */
375
+ async readDesktop() {
376
+ for (const desktopPath of this.resolveDesktopCandidates()) {
377
+ let text
378
+ try {
379
+ text = await readFile(desktopPath, 'utf8')
380
+ } catch (error) {
381
+ if (!isENOENT(error)) throw error
382
+ continue
383
+ }
384
+ const classification = classifyDesktopAuthDocument(text)
385
+ if (classification.format === 'plaintext') return parseWorkBuddyAuth(text)
386
+ if (classification.format === 'absent') continue
387
+ if (classification.format === 'unrecognized') {
388
+ throw new Error(
389
+ `the desktop auth file at ${desktopPath} exists but is unreadable`
390
+ + ' (neither a plaintext credential nor a decodable WorkBuddy 5.6 envelope);'
391
+ + ' fix or remove the file — it outranks the plugin-owned credential copy',
392
+ )
393
+ }
394
+ return await this.openEncryptedDesktop(classification)
395
+ }
396
+ return undefined
397
+ }
398
+
399
+ /**
400
+ * Open a 5.6 encrypted desktop document into the regular credential shape.
401
+ * The rebuilt plaintext goes through the same parser a plaintext document
402
+ * uses, so identity and expiry need no second code path.
403
+ */
404
+ async openEncryptedDesktop(classification) {
405
+ const wrapped = classification.wrapped
406
+ const key = await this.keyProvider.protectorKeyFor(keyIdsOf(wrapped.fields))
407
+ const text = unwrapDesktopAuthDocument(classification, field => {
408
+ const plaintext = openAuthField(key, field.envelope)
409
+ if (plaintext === undefined) {
410
+ throw new WorkBuddyElectronPathError(
411
+ 'encrypted-credential-unreadable',
412
+ `the encrypted desktop credential's ${field.field} could not be decrypted`
413
+ + ` (envelope key id ${field.envelope.keyId});`
414
+ + ' the WorkBuddy app may hold a different at-rest key — open it once to reseal the sign-in',
415
+ )
416
+ }
417
+ return plaintext
418
+ })
419
+ return parseWorkBuddyAuth(text)
420
+ }
421
+
422
+ async readOwn() {
423
+ try {
424
+ return parseOwnDocument(await readFile(this.ownPath, 'utf8'))
425
+ } catch {
426
+ return undefined
427
+ }
428
+ }
429
+
430
+ /** Whether any desktop-file candidate exists as a regular file; diagnostics only. */
431
+ async desktopFilePresent() {
432
+ for (const desktopPath of this.resolveDesktopCandidates()) {
433
+ try {
434
+ if ((await stat(desktopPath)).isFile()) return true
435
+ } catch {
436
+ // absent or not a regular file — try the next candidate
437
+ }
438
+ }
439
+ return false
440
+ }
441
+ }