dsh-model 0.0.1 → 0.14.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/README.en.md +123 -0
- package/README.md +92 -43
- package/bin/dsh-model.js +8 -0
- package/dist/bridge/aggregate.js +91 -0
- package/dist/bridge/login.js +127 -0
- package/dist/bridge/runtime.js +136 -0
- package/dist/bridge/server.js +178 -0
- package/dist/bridge/workbuddy/_compat.js +52 -0
- package/dist/bridge/workbuddy/app-version.js +141 -0
- package/dist/bridge/workbuddy/auth.js +548 -0
- package/dist/bridge/workbuddy/catalog.js +154 -0
- package/dist/bridge/workbuddy/client-identity.js +234 -0
- package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
- package/dist/bridge/workbuddy/loopback.js +52 -0
- package/dist/bridge/workbuddy/probe.js +153 -0
- package/dist/bridge/workbuddy/status-paths.js +49 -0
- package/dist/bridge/workbuddy/upstream.js +1180 -0
- package/dist/bridge/workbuddy/variants.js +101 -0
- package/dist/cli.js +207 -0
- package/dist/commands/bridge.js +68 -0
- package/dist/commands/connect.js +67 -0
- package/dist/commands/engine.js +122 -0
- package/dist/commands/key.js +75 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logs.js +19 -0
- package/dist/commands/opencode.js +77 -0
- package/dist/commands/remote.js +129 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/setup.js +216 -0
- package/dist/commands/sources.js +196 -0
- package/dist/commands/status.js +216 -0
- package/dist/commands/uninstall.js +97 -0
- package/dist/commands/workbuddy.js +117 -0
- package/dist/context.js +86 -0
- package/dist/daemon/client.js +27 -0
- package/dist/daemon/core.js +790 -0
- package/dist/daemon/service.js +33 -0
- package/dist/daemon/stats.js +126 -0
- package/dist/daemon/usage.js +213 -0
- package/dist/dsh/cli.js +72 -0
- package/dist/dsh/connect.js +257 -0
- package/dist/dsh/credentials.js +55 -0
- package/dist/dsh/locate.js +129 -0
- package/dist/dsh/patch.js +106 -0
- package/dist/dsh/pick.js +59 -0
- package/dist/engine/auth.js +40 -0
- package/dist/engine/client.js +104 -0
- package/dist/engine/compat.js +94 -0
- package/dist/engine/config.js +47 -0
- package/dist/engine/install.js +118 -0
- package/dist/engine/mgmt.js +99 -0
- package/dist/engine/sourcemap.js +48 -0
- package/dist/errors.js +14 -0
- package/dist/i18n.js +39 -0
- package/dist/integrations/dshplugin.js +42 -0
- package/dist/integrations/opencode.js +101 -0
- package/dist/integrations/workbuddy.js +114 -0
- package/dist/keys.js +54 -0
- package/dist/ops.js +178 -0
- package/dist/remote/public.js +109 -0
- package/dist/secrets.js +13 -0
- package/dist/service/index.js +46 -0
- package/dist/service/launchd.js +89 -0
- package/dist/service/systemd.js +83 -0
- package/dist/sources.js +36 -0
- package/dist/state.js +90 -0
- package/dist/upstreams.js +23 -0
- package/dist/util/diff.js +30 -0
- package/dist/util/exec.js +42 -0
- package/dist/util/fs.js +107 -0
- package/dist/util/output.js +50 -0
- package/dist/util/pkg.js +13 -0
- package/dist/util/port.js +16 -0
- package/dist/util/prompt.js +40 -0
- package/dist/util/proxy.js +73 -0
- package/dist/util/redact.js +7 -0
- package/engine-manifest.json +28 -0
- package/package.json +77 -7
- package/plugin/client.js +549 -0
- package/plugin/cordis.patch.yml +4 -0
- package/plugin/index.js +177 -0
- package/docs/DESIGN.md +0 -610
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
// Ported from dsh-workbuddy-connect v0.7.1 (commit 390582e04da4e8e8bf746d71997440e4b716ec47),
|
|
2
|
+
// https://github.com/corrinehu/dsh-workbuddy-connect — MIT License, Copyright (c) 2026 Corrine Hu.
|
|
3
|
+
// See LICENSE.dsh-workbuddy-connect in this directory. Changes: imports adapted to dsh-model (./_compat.js).
|
|
4
|
+
/**
|
|
5
|
+
* WorkBuddy credential resolution. The primary source is the WorkBuddy
|
|
6
|
+
* desktop app's own auth file, read-only; a plugin-owned copy under
|
|
7
|
+
* `$DSH_HOME` holds token refreshes so the desktop file is never written.
|
|
8
|
+
* The effective credential is whichever of the two expires later, so a
|
|
9
|
+
* refresh by either side wins.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-workbuddy-connect/auth
|
|
12
|
+
*/
|
|
13
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
14
|
+
import { homedir, release } from 'node:os';
|
|
15
|
+
import { basename, dirname, join } from 'node:path';
|
|
16
|
+
import { withFileLock, writeFileAtomic } from './_compat.js';
|
|
17
|
+
import { resolveDshHome } from './_compat.js';
|
|
18
|
+
import { regionOf } from './upstream.js';
|
|
19
|
+
import { WorkBuddyAtRestKeyProvider, WorkBuddyElectronPathError, classifyDesktopAuthDocument, keyIdsOf, openAuthField, reasonCodeOf, unwrapDesktopAuthDocument, } from './desktop-credential-protection.js';
|
|
20
|
+
import { electronProfileFor } from './variants.js';
|
|
21
|
+
/** Basename of the plugin-owned credential copy inside the Harness home. */
|
|
22
|
+
export const WORKBUDDY_AUTH_FILENAME = '.workbuddy-auth.json';
|
|
23
|
+
/** Env variable that overrides the desktop auth-file location. */
|
|
24
|
+
export const WORKBUDDY_AUTH_FILE_ENV = 'WORKBUDDY_AUTH_FILE';
|
|
25
|
+
/** Current on-disk format of the plugin-owned copy; readers reject others. */
|
|
26
|
+
const OWN_FORMAT_VERSION = 1;
|
|
27
|
+
/** Plugin-owned copy path inside the Harness home. */
|
|
28
|
+
export function workbuddyOwnAuthPath() {
|
|
29
|
+
return join(resolveDshHome(), WORKBUDDY_AUTH_FILENAME);
|
|
30
|
+
}
|
|
31
|
+
const DESKTOP_AUTH_RELATIVE_PATH = ['CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info'];
|
|
32
|
+
/** Whether this Linux process is running inside Windows Subsystem for Linux. */
|
|
33
|
+
function isWsl() {
|
|
34
|
+
if (process.platform !== 'linux')
|
|
35
|
+
return false;
|
|
36
|
+
if (process.env['WSL_DISTRO_NAME'] !== undefined || process.env['WSL_INTEROP'] !== undefined)
|
|
37
|
+
return true;
|
|
38
|
+
return release().toLowerCase().includes('microsoft');
|
|
39
|
+
}
|
|
40
|
+
/** Convert a Windows drive path to WSL's conventional `/mnt/<drive>` form. */
|
|
41
|
+
function windowsPathForWsl(value) {
|
|
42
|
+
const path = value?.trim();
|
|
43
|
+
if (!path)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (path.startsWith('/'))
|
|
46
|
+
return path;
|
|
47
|
+
const drivePath = /^([a-z]):[\\/](.*)$/iu.exec(path);
|
|
48
|
+
if (drivePath === null)
|
|
49
|
+
return undefined;
|
|
50
|
+
return join('/mnt', drivePath[1].toLowerCase(), ...drivePath[2].split(/[\\/]+/u));
|
|
51
|
+
}
|
|
52
|
+
/** Windows desktop credential candidates visible from a WSL process. */
|
|
53
|
+
function wslDesktopAuthCandidates(home) {
|
|
54
|
+
const profile = windowsPathForWsl(process.env['USERPROFILE'])
|
|
55
|
+
?? join('/mnt/c/Users', basename(home));
|
|
56
|
+
const localAppData = windowsPathForWsl(process.env['LOCALAPPDATA'])
|
|
57
|
+
?? join(profile, 'AppData', 'Local');
|
|
58
|
+
const roamingAppData = windowsPathForWsl(process.env['APPDATA'])
|
|
59
|
+
?? join(profile, 'AppData', 'Roaming');
|
|
60
|
+
return [
|
|
61
|
+
join(localAppData, ...DESKTOP_AUTH_RELATIVE_PATH),
|
|
62
|
+
join(roamingAppData, ...DESKTOP_AUTH_RELATIVE_PATH),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Platform-default candidates for the WorkBuddy desktop app's auth file, in
|
|
67
|
+
* probe order. Windows probes both AppData roots: current builds write under
|
|
68
|
+
* `%LOCALAPPDATA%` (Local), older ones under `%APPDATA%` (Roaming). Linux
|
|
69
|
+
* probes both XDG bases — most distributions write under the config home,
|
|
70
|
+
* but UOS/deepin builds write under the data home (issue #43), and probing
|
|
71
|
+
* only one silently reads a signed-in app as signed out. WSL probes those
|
|
72
|
+
* same Windows locations through its mounted Windows profile before the
|
|
73
|
+
* native Linux locations.
|
|
74
|
+
*/
|
|
75
|
+
export function defaultDesktopAuthCandidates() {
|
|
76
|
+
const home = homedir();
|
|
77
|
+
if (process.platform === 'darwin') {
|
|
78
|
+
return [join(home, 'Library', 'Application Support', 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info')];
|
|
79
|
+
}
|
|
80
|
+
if (process.platform === 'win32') {
|
|
81
|
+
return [
|
|
82
|
+
join(home, 'AppData', 'Local', 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info'),
|
|
83
|
+
join(home, 'AppData', 'Roaming', 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info'),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
if (process.platform === 'linux') {
|
|
87
|
+
// An XDG override is adopted only as a non-empty absolute path; an
|
|
88
|
+
// invalid value falls back to the platform default rather than joining a
|
|
89
|
+
// relative path onto it.
|
|
90
|
+
const configHome = xdgBase('XDG_CONFIG_HOME', join(home, '.config'));
|
|
91
|
+
const dataHome = xdgBase('XDG_DATA_HOME', join(home, '.local', 'share'));
|
|
92
|
+
// Config home first, keeping the probe order existing installs hit.
|
|
93
|
+
const linux = dedupeCandidates([
|
|
94
|
+
join(configHome, ...DESKTOP_AUTH_RELATIVE_PATH),
|
|
95
|
+
join(dataHome, ...DESKTOP_AUTH_RELATIVE_PATH),
|
|
96
|
+
]);
|
|
97
|
+
return isWsl() ? dedupeCandidates([...wslDesktopAuthCandidates(home), ...linux]) : linux;
|
|
98
|
+
}
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
/** The XDG base directory for one env variable, or its platform default. */
|
|
102
|
+
function xdgBase(envName, fallback) {
|
|
103
|
+
const value = process.env[envName]?.trim();
|
|
104
|
+
if (value !== undefined && value !== '' && value.startsWith('/'))
|
|
105
|
+
return value;
|
|
106
|
+
return fallback;
|
|
107
|
+
}
|
|
108
|
+
/** Drop duplicate candidates while keeping probe order. */
|
|
109
|
+
function dedupeCandidates(candidates) {
|
|
110
|
+
return [...new Set(candidates)];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The platform-default candidates for one variant, in probe order.
|
|
114
|
+
*
|
|
115
|
+
* Both apps write into the *same* shared `CodeBuddyExtension` auth directory
|
|
116
|
+
* and differ only in the file's basename, so the per-platform ordering above
|
|
117
|
+
* is reused verbatim and just the filename is swapped.
|
|
118
|
+
*/
|
|
119
|
+
export function desktopAuthCandidatesFor(variant) {
|
|
120
|
+
return dedupeCandidates(defaultDesktopAuthCandidates().map(path => join(dirname(path), variant.desktopFilename)));
|
|
121
|
+
}
|
|
122
|
+
/** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
|
|
123
|
+
export function defaultDesktopAuthPath(variant) {
|
|
124
|
+
const candidates = variant === undefined ? defaultDesktopAuthCandidates() : desktopAuthCandidatesFor(variant);
|
|
125
|
+
return candidates[0];
|
|
126
|
+
}
|
|
127
|
+
/** Normalize an expiry that may arrive in seconds or milliseconds. */
|
|
128
|
+
function expiryToMs(value) {
|
|
129
|
+
if (value <= 0)
|
|
130
|
+
return 0;
|
|
131
|
+
return value > 1e12 ? value : value * 1000;
|
|
132
|
+
}
|
|
133
|
+
function optionalString(value) {
|
|
134
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
|
|
138
|
+
* nested form `{"auth":{...},"account":{...}}` and the flat panel form.
|
|
139
|
+
* Returns undefined when the document carries no access token.
|
|
140
|
+
*/
|
|
141
|
+
export function parseWorkBuddyAuth(text) {
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(text);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
150
|
+
return undefined;
|
|
151
|
+
const document = parsed;
|
|
152
|
+
let auth;
|
|
153
|
+
let identity;
|
|
154
|
+
if (typeof document['auth'] === 'object' && document['auth'] !== null) {
|
|
155
|
+
auth = document['auth'];
|
|
156
|
+
identity = typeof document['account'] === 'object' && document['account'] !== null
|
|
157
|
+
? document['account']
|
|
158
|
+
: {};
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
auth = document;
|
|
162
|
+
identity = document;
|
|
163
|
+
}
|
|
164
|
+
const accessToken = typeof auth['accessToken'] === 'string' ? auth['accessToken'] : '';
|
|
165
|
+
if (accessToken === '')
|
|
166
|
+
return undefined;
|
|
167
|
+
const expiresAtMs = typeof auth['expiresAt'] === 'number' ? expiryToMs(auth['expiresAt']) : 0;
|
|
168
|
+
const refreshExpiresAtMs = typeof auth['refreshExpiresAt'] === 'number' ? expiryToMs(auth['refreshExpiresAt']) : undefined;
|
|
169
|
+
const enterpriseId = optionalString(identity['enterpriseId']);
|
|
170
|
+
const nickname = optionalString(identity['nickname']);
|
|
171
|
+
const credential = {
|
|
172
|
+
accessToken,
|
|
173
|
+
refreshToken: typeof auth['refreshToken'] === 'string' ? auth['refreshToken'] : '',
|
|
174
|
+
expiresAtMs,
|
|
175
|
+
...refreshExpiresAtMs === undefined ? {} : { refreshExpiresAtMs },
|
|
176
|
+
domain: optionalString(auth['domain']) ?? '',
|
|
177
|
+
uid: optionalString(identity['uid']) ?? '',
|
|
178
|
+
...enterpriseId === undefined ? {} : { enterpriseId },
|
|
179
|
+
...nickname === undefined ? {} : { nickname },
|
|
180
|
+
source: 'desktop',
|
|
181
|
+
};
|
|
182
|
+
return credential;
|
|
183
|
+
}
|
|
184
|
+
/** Serialize the plugin-owned copy. */
|
|
185
|
+
function ownDocument(credential) {
|
|
186
|
+
return { version: OWN_FORMAT_VERSION, credential };
|
|
187
|
+
}
|
|
188
|
+
/** Parse the plugin-owned copy; other versions and shapes are rejected. */
|
|
189
|
+
function parseOwnDocument(text) {
|
|
190
|
+
let parsed;
|
|
191
|
+
try {
|
|
192
|
+
parsed = JSON.parse(text);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
198
|
+
return undefined;
|
|
199
|
+
const document = parsed;
|
|
200
|
+
if (document['version'] !== OWN_FORMAT_VERSION)
|
|
201
|
+
return undefined;
|
|
202
|
+
if (typeof document['credential'] !== 'object' || document['credential'] === null)
|
|
203
|
+
return undefined;
|
|
204
|
+
// The owned copy stores the normalized credential itself (camelCase
|
|
205
|
+
// `expiresAtMs`, identity fields at the top level), not the desktop
|
|
206
|
+
// document shape. Round-tripping through parseWorkBuddyAuth reads
|
|
207
|
+
// `expiresAt` and an `account` object, finds neither, zeroes the expiry,
|
|
208
|
+
// and drops uid/enterprise/nickname — so a surviving copy refreshed on
|
|
209
|
+
// every request and lost its identity headers.
|
|
210
|
+
const stored = document['credential'];
|
|
211
|
+
const accessToken = typeof stored['accessToken'] === 'string' ? stored['accessToken'] : '';
|
|
212
|
+
if (accessToken === '')
|
|
213
|
+
return undefined;
|
|
214
|
+
const refreshExpiresAtMs = typeof stored['refreshExpiresAtMs'] === 'number' ? stored['refreshExpiresAtMs'] : undefined;
|
|
215
|
+
const enterpriseId = optionalString(stored['enterpriseId']);
|
|
216
|
+
const nickname = optionalString(stored['nickname']);
|
|
217
|
+
return {
|
|
218
|
+
accessToken,
|
|
219
|
+
refreshToken: typeof stored['refreshToken'] === 'string' ? stored['refreshToken'] : '',
|
|
220
|
+
expiresAtMs: typeof stored['expiresAtMs'] === 'number' ? stored['expiresAtMs'] : 0,
|
|
221
|
+
...refreshExpiresAtMs === undefined ? {} : { refreshExpiresAtMs },
|
|
222
|
+
domain: optionalString(stored['domain']) ?? '',
|
|
223
|
+
uid: optionalString(stored['uid']) ?? '',
|
|
224
|
+
...enterpriseId === undefined ? {} : { enterpriseId },
|
|
225
|
+
...nickname === undefined ? {} : { nickname },
|
|
226
|
+
source: 'dsh',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Whether a filesystem error reports an absent path. */
|
|
230
|
+
function isENOENT(error) {
|
|
231
|
+
return error?.code === 'ENOENT';
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Read-only credential store with demand-driven refresh.
|
|
235
|
+
*
|
|
236
|
+
* Refresh policy: refresh only when the access token is inside the margin
|
|
237
|
+
* (or already expired), keep the refreshed credential in the plugin-owned
|
|
238
|
+
* copy, and never write the desktop app's file. A failed refresh still
|
|
239
|
+
* returns a not-yet-expired token so an unreachable refresh endpoint does
|
|
240
|
+
* not take down a working session.
|
|
241
|
+
*/
|
|
242
|
+
export class WorkBuddyCredentialStore {
|
|
243
|
+
variant;
|
|
244
|
+
refresh;
|
|
245
|
+
refreshMarginMs;
|
|
246
|
+
ownPath;
|
|
247
|
+
keyProvider;
|
|
248
|
+
desktopPathOverride;
|
|
249
|
+
inflight;
|
|
250
|
+
constructor(options) {
|
|
251
|
+
this.variant = options.variant;
|
|
252
|
+
this.refresh = options.refresh;
|
|
253
|
+
this.refreshMarginMs = options.refreshMarginMs ?? 5 * 60 * 1000;
|
|
254
|
+
this.ownPath = options.ownPath ?? (options.variant ? join(resolveDshHome(), options.variant.ownFilename) : workbuddyOwnAuthPath());
|
|
255
|
+
// Legacy stores without a variant stay on the CN product the store's other
|
|
256
|
+
// defaults already assume (own-copy filename, auth env var); a variant
|
|
257
|
+
// without an electron profile falls back by id, keeping externally
|
|
258
|
+
// assembled descriptors source-compatible.
|
|
259
|
+
this.keyProvider = options.keyProvider
|
|
260
|
+
?? new WorkBuddyAtRestKeyProvider({ product: electronProfileFor(options.variant) });
|
|
261
|
+
this.desktopPathOverride = options.desktopPath;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Configuration precedence for the desktop file: the plugin's configured
|
|
265
|
+
* path, then the environment variable, then the platform defaults. An
|
|
266
|
+
* explicit path is used verbatim; the defaults are a probe order.
|
|
267
|
+
*/
|
|
268
|
+
resolveDesktopCandidates() {
|
|
269
|
+
const fromEnv = process.env[this.variant?.env ?? WORKBUDDY_AUTH_FILE_ENV];
|
|
270
|
+
const explicit = this.desktopPathOverride
|
|
271
|
+
?? (fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : undefined);
|
|
272
|
+
if (explicit !== undefined)
|
|
273
|
+
return [explicit];
|
|
274
|
+
return this.variant === undefined
|
|
275
|
+
? defaultDesktopAuthCandidates()
|
|
276
|
+
: desktopAuthCandidatesFor(this.variant);
|
|
277
|
+
}
|
|
278
|
+
resolveDesktopPath() {
|
|
279
|
+
return this.resolveDesktopCandidates()[0];
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Repoint the desktop file; a settings change applies on the next read.
|
|
283
|
+
*/
|
|
284
|
+
setDesktopPath(path) {
|
|
285
|
+
this.desktopPathOverride = path;
|
|
286
|
+
}
|
|
287
|
+
/** The resolved desktop auth-file path, for diagnostics. */
|
|
288
|
+
desktopAuthPath() {
|
|
289
|
+
return this.resolveDesktopPath();
|
|
290
|
+
}
|
|
291
|
+
/** The plugin-owned copy path, for diagnostics. */
|
|
292
|
+
ownAuthPath() {
|
|
293
|
+
return this.ownPath;
|
|
294
|
+
}
|
|
295
|
+
/** Read the freshest stored credential without refreshing anything. */
|
|
296
|
+
async current() {
|
|
297
|
+
const [desktop, own] = await Promise.all([this.readDesktop(), this.readOwn()]);
|
|
298
|
+
// A credential belonging to the other product is refused rather than used:
|
|
299
|
+
// the two apps share one auth directory and differ only by filename, so a
|
|
300
|
+
// misconfigured `authFile` / env var is a realistic mistake, and sending one
|
|
301
|
+
// region's token to the other's endpoint would leak it across products.
|
|
302
|
+
// Naming the file and the expected region is what makes it fixable.
|
|
303
|
+
if (this.variant !== undefined) {
|
|
304
|
+
for (const [label, credential] of [['desktop file', desktop], ['plugin copy', own]]) {
|
|
305
|
+
if (credential === undefined)
|
|
306
|
+
continue;
|
|
307
|
+
const region = regionOf(credential.domain);
|
|
308
|
+
if (region !== this.variant.region) {
|
|
309
|
+
throw new WorkBuddyElectronPathError('credential-region-mismatch', `${this.variant.displayName} received a ${region === 'cn' ? 'WorkBuddy (CN)' : 'WorkBuddy AI'} credential`
|
|
310
|
+
+ ` in its ${label} (domain ${JSON.stringify(credential.domain)});`
|
|
311
|
+
+ ` point ${this.variant.env} at the ${this.variant.appName} sign-in, or remove the mismatched file`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (desktop === undefined)
|
|
316
|
+
return own;
|
|
317
|
+
if (own === undefined)
|
|
318
|
+
return desktop;
|
|
319
|
+
// Identity beats expiry. The plugin's own copy is written by its own
|
|
320
|
+
// refreshes, so after the user switches accounts in the desktop app the copy
|
|
321
|
+
// still belongs to the *previous* account — and may well expire later,
|
|
322
|
+
// because the plugin refreshed it. Preferring it by expiry would send the old
|
|
323
|
+
// account's uid in `X-User-Id` and answer as the wrong user. The desktop
|
|
324
|
+
// file is the authority on who is signed in now; a differing identity means
|
|
325
|
+
// the copy is stale regardless of its timestamp.
|
|
326
|
+
if (desktop.uid !== own.uid || desktop.enterpriseId !== own.enterpriseId)
|
|
327
|
+
return desktop;
|
|
328
|
+
return own.expiresAtMs > desktop.expiresAtMs ? own : desktop;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The credential to send upstream: {@link current}, refreshed on demand.
|
|
332
|
+
* Single-flight, so parallel requests share one refresh.
|
|
333
|
+
*/
|
|
334
|
+
async resolve() {
|
|
335
|
+
const credential = await this.current();
|
|
336
|
+
if (credential === undefined) {
|
|
337
|
+
const candidates = this.resolveDesktopCandidates();
|
|
338
|
+
const desktop = candidates.length > 0 ? candidates.join(' or ') : '(no desktop path on this platform)';
|
|
339
|
+
const app = this.variant?.appName ?? 'WorkBuddy';
|
|
340
|
+
throw new Error(`workbuddy: no signed-in ${app} account found; sign in once in the ${app} desktop app`
|
|
341
|
+
+ ` (expected ${desktop} or ${this.variant?.env ?? WORKBUDDY_AUTH_FILE_ENV}), or refresh an existing session`);
|
|
342
|
+
}
|
|
343
|
+
if (!this.needsRefresh(credential))
|
|
344
|
+
return credential;
|
|
345
|
+
this.inflight ??= this.refreshNow(credential)
|
|
346
|
+
.finally(() => {
|
|
347
|
+
this.inflight = undefined;
|
|
348
|
+
});
|
|
349
|
+
return this.inflight;
|
|
350
|
+
}
|
|
351
|
+
/** Read-only sign-in summary; never refreshes and never throws. */
|
|
352
|
+
async status() {
|
|
353
|
+
try {
|
|
354
|
+
const credential = await this.current();
|
|
355
|
+
if (credential === undefined)
|
|
356
|
+
return { state: 'signed-out', reasonCode: 'no-credential' };
|
|
357
|
+
return {
|
|
358
|
+
state: 'signed-in',
|
|
359
|
+
expiresAtMs: credential.expiresAtMs,
|
|
360
|
+
...credential.refreshExpiresAtMs === undefined ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
|
|
361
|
+
...credential.nickname === undefined ? {} : { nickname: credential.nickname },
|
|
362
|
+
...credential.domain === '' ? {} : { domain: credential.domain },
|
|
363
|
+
source: credential.source,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
// A region mismatch (or an unreadable file, or an unusable key helper) is
|
|
368
|
+
// a *diagnosable* signed-out state, not a silent one: the user needs the
|
|
369
|
+
// path to the file that is wrong, and which provider it actually belongs
|
|
370
|
+
// to. Reported as a status rather than thrown, because `status()` is
|
|
371
|
+
// documented never to throw and the card renders `reason` verbatim.
|
|
372
|
+
//
|
|
373
|
+
// The code travels beside the prose so the card can branch on the cause
|
|
374
|
+
// without ever matching the message text.
|
|
375
|
+
return {
|
|
376
|
+
state: 'signed-out',
|
|
377
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
378
|
+
...reasonCodeOf(error) === undefined
|
|
379
|
+
? {}
|
|
380
|
+
: { reasonCode: reasonCodeOf(error) },
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Remove the plugin-owned copy; the desktop file is untouched. */
|
|
385
|
+
async logout() {
|
|
386
|
+
await rm(this.ownPath, { force: true });
|
|
387
|
+
await rm(`${this.ownPath}.lock`, { force: true });
|
|
388
|
+
}
|
|
389
|
+
needsRefresh(credential) {
|
|
390
|
+
if (credential.expiresAtMs <= 0)
|
|
391
|
+
return true;
|
|
392
|
+
return Date.now() + this.refreshMarginMs >= credential.expiresAtMs;
|
|
393
|
+
}
|
|
394
|
+
async refreshNow(credential) {
|
|
395
|
+
if (credential.refreshToken === '') {
|
|
396
|
+
if (credential.expiresAtMs > Date.now() + 30_000)
|
|
397
|
+
return credential;
|
|
398
|
+
throw new Error('workbuddy: access token expired and no refresh token is stored; sign in again in the WorkBuddy desktop app');
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
const outcome = await this.refresh(credential);
|
|
402
|
+
const refreshed = {
|
|
403
|
+
...credential,
|
|
404
|
+
accessToken: outcome.accessToken,
|
|
405
|
+
...outcome.refreshToken === undefined ? {} : { refreshToken: outcome.refreshToken },
|
|
406
|
+
expiresAtMs: outcome.expiresInSec !== undefined
|
|
407
|
+
? Date.now() + outcome.expiresInSec * 1000
|
|
408
|
+
: credential.expiresAtMs,
|
|
409
|
+
...outcome.domain === undefined || outcome.domain === '' ? {} : { domain: outcome.domain },
|
|
410
|
+
source: 'dsh',
|
|
411
|
+
};
|
|
412
|
+
await this.saveOwn(refreshed);
|
|
413
|
+
return refreshed;
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (credential.expiresAtMs > Date.now() + 30_000)
|
|
417
|
+
return credential;
|
|
418
|
+
throw new Error(`workbuddy: token refresh failed and the access token is expired (${String(error)});`
|
|
419
|
+
+ ' open the WorkBuddy desktop app once to sign in again');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async saveOwn(credential) {
|
|
423
|
+
await withFileLock(this.ownPath, async () => {
|
|
424
|
+
await writeFileAtomic(this.ownPath, `${JSON.stringify(ownDocument(credential), null, 2)}\n`, {
|
|
425
|
+
mode: 0o600,
|
|
426
|
+
dirMode: 0o700,
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Read the first desktop candidate that exists. Only an absent file
|
|
432
|
+
* (ENOENT) falls through to the next candidate; a file that is present
|
|
433
|
+
* but unparsable is authoritative for its slot, so a stale older-version
|
|
434
|
+
* file never silently wins over a broken newer one.
|
|
435
|
+
*
|
|
436
|
+
* Since WorkBuddy 5.6 the token fields may arrive in at-rest envelopes, so
|
|
437
|
+
* the text is classified before the regular parser sees it. An encrypted
|
|
438
|
+
* document must be *opened*, never skipped; an unrecognized one must fail
|
|
439
|
+
* loudly. The desktop file, as long as it exists, is the identity
|
|
440
|
+
* authority — a document this plugin cannot read must surface as a
|
|
441
|
+
* diagnosis rather than be papered over by the plugin-owned copy, which
|
|
442
|
+
* belongs to whatever account was signed in when it was last refreshed.
|
|
443
|
+
* Only an absent (or empty) file lets the probe continue.
|
|
444
|
+
*/
|
|
445
|
+
async readDesktop() {
|
|
446
|
+
for (const desktopPath of this.resolveDesktopCandidates()) {
|
|
447
|
+
let text;
|
|
448
|
+
try {
|
|
449
|
+
text = await readFile(desktopPath, 'utf8');
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (!isENOENT(error))
|
|
453
|
+
throw error;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const classification = classifyDesktopAuthDocument(text);
|
|
457
|
+
if (classification.format === 'plaintext')
|
|
458
|
+
return parseWorkBuddyAuth(text);
|
|
459
|
+
if (classification.format === 'absent')
|
|
460
|
+
continue;
|
|
461
|
+
if (classification.format === 'unrecognized') {
|
|
462
|
+
throw new Error(`the desktop auth file at ${desktopPath} exists but is unreadable`
|
|
463
|
+
+ ' (neither a plaintext credential nor a decodable WorkBuddy 5.6 envelope);'
|
|
464
|
+
+ ' fix or remove the file — it outranks the plugin-owned credential copy');
|
|
465
|
+
}
|
|
466
|
+
return await this.openEncryptedDesktop(classification);
|
|
467
|
+
}
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
/** Open a 5.6 encrypted desktop document into the regular credential shape. */
|
|
471
|
+
async openEncryptedDesktop(classification) {
|
|
472
|
+
const wrapped = classification.wrapped;
|
|
473
|
+
const key = await this.keyProvider.protectorKeyFor(keyIdsOf(wrapped.fields));
|
|
474
|
+
const text = unwrapDesktopAuthDocument(classification, field => {
|
|
475
|
+
const plaintext = openAuthField(key, field.envelope);
|
|
476
|
+
if (plaintext === undefined) {
|
|
477
|
+
throw new WorkBuddyElectronPathError('encrypted-credential-unreadable', `the encrypted desktop credential's ${field.field} could not be decrypted`
|
|
478
|
+
+ ` (envelope key id ${field.envelope.keyId});`
|
|
479
|
+
+ ' the WorkBuddy app may hold a different at-rest key — open it once to reseal the sign-in');
|
|
480
|
+
}
|
|
481
|
+
return plaintext;
|
|
482
|
+
});
|
|
483
|
+
return parseWorkBuddyAuth(text);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Classify the first desktop candidate that exists and carries content;
|
|
487
|
+
* `absent` when none does. An empty first file is skipped so it cannot mask
|
|
488
|
+
* a real document on the next candidate. Diagnostics only — it never spawns
|
|
489
|
+
* the key helper and never decrypts, so doctor can describe the file
|
|
490
|
+
* without attempting the unlock.
|
|
491
|
+
*/
|
|
492
|
+
async desktopAuthFormat() {
|
|
493
|
+
for (const desktopPath of this.resolveDesktopCandidates()) {
|
|
494
|
+
let text;
|
|
495
|
+
try {
|
|
496
|
+
text = await readFile(desktopPath, 'utf8');
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
if (!isENOENT(error))
|
|
500
|
+
throw error;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const format = classifyDesktopAuthDocument(text).format;
|
|
504
|
+
if (format !== 'absent')
|
|
505
|
+
return format;
|
|
506
|
+
}
|
|
507
|
+
return 'absent';
|
|
508
|
+
}
|
|
509
|
+
async readOwn() {
|
|
510
|
+
try {
|
|
511
|
+
return parseOwnDocument(await readFile(this.ownPath, 'utf8'));
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
if (isENOENT(error))
|
|
515
|
+
return undefined;
|
|
516
|
+
return undefined;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* The first desktop candidate the probe would actually read from; `undefined`
|
|
521
|
+
* when none qualifies. Semantics deliberately match the probe: empty files
|
|
522
|
+
* are skipped (the probe classifies them as absent and moves on), so on an
|
|
523
|
+
* XDG layout where the config-home file is empty but the data-home file
|
|
524
|
+
* holds the credential, diagnostics name the *data-home* file — the one
|
|
525
|
+
* authentication really uses. Like the probe it never parses or decrypts.
|
|
526
|
+
*/
|
|
527
|
+
async resolvedDesktopAuthPath() {
|
|
528
|
+
for (const desktopPath of this.resolveDesktopCandidates()) {
|
|
529
|
+
let text;
|
|
530
|
+
try {
|
|
531
|
+
text = await readFile(desktopPath, 'utf8');
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
if (!isENOENT(error))
|
|
535
|
+
throw error;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (text.trim() === '')
|
|
539
|
+
continue;
|
|
540
|
+
return desktopPath;
|
|
541
|
+
}
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
/** Whether any desktop-file candidate exists as a regular file; diagnostics only. */
|
|
545
|
+
async desktopFilePresent() {
|
|
546
|
+
return await this.resolvedDesktopAuthPath() !== undefined;
|
|
547
|
+
}
|
|
548
|
+
}
|