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,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
|
+
}
|