@empirical-gauzza/cli 1.0.9

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/lib/auth.js ADDED
@@ -0,0 +1,804 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const http = require('http');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawnSync } = require('child_process');
8
+ const { log, warn, uiQuestion, uiDim, uiLink, ask } = require('./ui');
9
+ const { readJsonIfExists, writeFile, ensureDir, quotePowerShellArg, runResolvedCommand } = require('./fsUtils');
10
+ const { httpGet, httpJson, httpForm } = require('./http');
11
+ const { getEnv, parseFlag, parseOption } = require('./env');
12
+ const {
13
+ getLocalOAuthClientPath,
14
+ readStoredOAuthClient,
15
+ writeStoredOAuthClient,
16
+ clearStoredOAuthClient,
17
+ maskSecret,
18
+ generateRandomString,
19
+ generatePKCE,
20
+ isJwtToken,
21
+ isJwtExpired,
22
+ readHeadlessToken,
23
+ readOAuthSession,
24
+ writeOAuthSession,
25
+ clearOAuthSession,
26
+ hasFreshAccessToken,
27
+ writeHeadlessEnvFile
28
+ } = require('./oauthClient');
29
+ const { deriveBaseUrlFromMcpUrl } = require('./constants');
30
+
31
+ async function oauthPreflight(baseMcpUrl) {
32
+ try {
33
+ const base = new URL(baseMcpUrl);
34
+ const loginUrl = new URL('/oauth/authorize/login', `${base.protocol}//${base.host}`);
35
+ loginUrl.searchParams.set('client_id', 'installer-preflight');
36
+ loginUrl.searchParams.set('redirect_uri', 'http://127.0.0.1:63626/');
37
+ loginUrl.searchParams.set('state', 'installer-check');
38
+
39
+ const response = await httpGet(loginUrl.toString());
40
+ if (response.statusCode < 200 || response.statusCode >= 400) {
41
+ return {
42
+ ok: false,
43
+ message: `OAuth login endpoint returned HTTP ${response.statusCode}`
44
+ };
45
+ }
46
+
47
+ const csp = String(response.headers['content-security-policy'] || '');
48
+ const cspAllowsLoopback =
49
+ csp.includes('http://127.0.0.1:*') ||
50
+ csp.includes('http://localhost:*') ||
51
+ csp.includes('http://[::1]:*');
52
+
53
+ const locationHeader = String(response.headers.location || '');
54
+ const hasReactLoginRedirect =
55
+ response.statusCode >= 300 &&
56
+ response.statusCode < 400 &&
57
+ locationHeader.includes('/oauth-login');
58
+
59
+ const hasExpectedLoginAssets =
60
+ response.body.includes('Authorize Empirical MCP') &&
61
+ response.body.includes('oauth-user-id') &&
62
+ response.body.includes('oauth-api-key');
63
+
64
+ if (!hasReactLoginRedirect && !hasExpectedLoginAssets) {
65
+ return {
66
+ ok: false,
67
+ message: 'OAuth login page appears stale (missing current login assets).'
68
+ };
69
+ }
70
+
71
+ if (csp && !cspAllowsLoopback) {
72
+ return {
73
+ ok: false,
74
+ message: 'OAuth login CSP does not include loopback callbacks (127.0.0.1/localhost).'
75
+ };
76
+ }
77
+
78
+ return { ok: true };
79
+ } catch (error) {
80
+ return { ok: false, message: `OAuth preflight failed: ${error.message}` };
81
+ }
82
+ }
83
+
84
+ function openBrowser(url) {
85
+ if (process.platform === 'win32') {
86
+ const script = `Start-Process ${quotePowerShellArg(url)}`;
87
+ spawnSync('pwsh', ['-NoProfile', '-Command', script], { stdio: 'ignore' });
88
+ } else if (process.platform === 'darwin') {
89
+ spawnSync('open', [url], { stdio: 'ignore' });
90
+ } else {
91
+ spawnSync('xdg-open', [url], { stdio: 'ignore' });
92
+ }
93
+ }
94
+
95
+ async function runMcpOAuthFlow(baseUrl) {
96
+ return new Promise((resolve, reject) => {
97
+ const { codeVerifier, codeChallenge } = generatePKCE();
98
+ const state = generateRandomString(32);
99
+ let server = null;
100
+ let resolved = false;
101
+
102
+ const handleTimeout = setTimeout(() => {
103
+ if (server) server.close();
104
+ if (!resolved) {
105
+ resolved = true;
106
+ reject(new Error('OAuth callback timeout (5 minutes)'));
107
+ }
108
+ }, 5 * 60 * 1000);
109
+
110
+ server = http.createServer((req, res) => {
111
+ const url = new URL(`http://localhost${req.url}`);
112
+ const code = url.searchParams.get('code');
113
+ const returnedState = url.searchParams.get('state');
114
+
115
+ if (code && returnedState === state) {
116
+ res.writeHead(200, { 'Content-Type': 'text/html' });
117
+ res.end(
118
+ '<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;">' +
119
+ '<div style="text-align:center;"><h1>Authorization successful!</h1><p>You can close this window.</p></div></body></html>'
120
+ );
121
+
122
+ clearTimeout(handleTimeout);
123
+ if (server) server.close();
124
+
125
+ if (!resolved) {
126
+ resolved = true;
127
+ resolveOAuthCode(code, codeVerifier, baseUrl)
128
+ .then((token) => {
129
+ writeOAuthSession(token);
130
+ resolve(token);
131
+ })
132
+ .catch(reject);
133
+ }
134
+ } else {
135
+ res.writeHead(400, { 'Content-Type': 'text/html' });
136
+ res.end(
137
+ '<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;">' +
138
+ '<div style="text-align:center;"><h1>Authorization failed!</h1><p>Invalid or missing code. You can close this window.</p></div></body></html>'
139
+ );
140
+
141
+ clearTimeout(handleTimeout);
142
+ if (server) server.close();
143
+
144
+ if (!resolved) {
145
+ resolved = true;
146
+ reject(new Error('Invalid OAuth callback'));
147
+ }
148
+ }
149
+ });
150
+
151
+ server.listen(0, '127.0.0.1', async () => {
152
+ const port = server.address().port;
153
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
154
+
155
+ const authUrl = new URL('/oauth/authorize/login', baseUrl);
156
+ authUrl.searchParams.set('client_id', 'empirical-cli-mcp');
157
+ authUrl.searchParams.set('response_type', 'code');
158
+ authUrl.searchParams.set('redirect_uri', redirectUri);
159
+ authUrl.searchParams.set('state', state);
160
+ authUrl.searchParams.set('code_challenge', codeChallenge);
161
+ authUrl.searchParams.set('code_challenge_method', 'S256');
162
+ authUrl.searchParams.set('scope', 'memory:read memory:write memory:delete offline_access');
163
+
164
+ log(uiQuestion('Opening browser for Empirical MCP authentication...'));
165
+ openBrowser(authUrl.toString());
166
+ log(uiDim(`If browser doesn't open, visit: ${uiLink(authUrl.toString())}`));
167
+ });
168
+
169
+ server.on('error', (error) => {
170
+ clearTimeout(handleTimeout);
171
+ if (!resolved) {
172
+ resolved = true;
173
+ reject(error);
174
+ }
175
+ });
176
+ });
177
+ }
178
+
179
+ async function resolveOAuthCode(code, codeVerifier, baseUrl) {
180
+ const tokenUrl = new URL('/oauth/token', baseUrl).toString();
181
+ const tokenRequest = {
182
+ grant_type: 'authorization_code',
183
+ client_id: 'empirical-cli-mcp',
184
+ code,
185
+ code_verifier: codeVerifier,
186
+ redirect_uri: 'http://127.0.0.1:0/callback'
187
+ };
188
+
189
+ const token = await httpForm(tokenUrl, 'POST', tokenRequest);
190
+ if (!token.access_token) {
191
+ throw new Error('Token exchange succeeded but no access_token was returned.');
192
+ }
193
+
194
+ return {
195
+ baseUrl,
196
+ accessToken: token.access_token,
197
+ refreshToken: token.refresh_token || null,
198
+ expiresIn: token.expires_in || null,
199
+ tokenType: token.token_type || 'Bearer',
200
+ obtainedAt: new Date().toISOString()
201
+ };
202
+ }
203
+
204
+ function updateClaudeMcpServerAuth(serverName, accessToken) {
205
+ const home = os.homedir();
206
+ const claudeConfigPath = path.join(home, '.claude.json');
207
+
208
+ if (!fs.existsSync(claudeConfigPath)) {
209
+ throw new Error(`Claude config not found at ${claudeConfigPath}`);
210
+ }
211
+
212
+ const config = readJsonIfExists(claudeConfigPath);
213
+ if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
214
+ throw new Error(`MCP server '${serverName}' not found in Claude config`);
215
+ }
216
+
217
+ config.mcpServers[serverName].httpHeaders = {
218
+ Authorization: `Bearer ${accessToken}`
219
+ };
220
+
221
+ writeFile(claudeConfigPath, `${JSON.stringify(config, null, 2)}\n`);
222
+ }
223
+
224
+ // ─── Shared helpers for memory commands ──────────────────────────────────────
225
+
226
+ function resolveAccessToken() {
227
+ // Priority 1: env var — but only trust it if it isn't a stale, expired JWT.
228
+ // A permanent apiKey isn't a JWT and has no expiry, so it always passes
229
+ // through here unchanged. A session JWT can end up permanently written to
230
+ // this env var (e.g. via `setx` on Windows from a prior bootstrap) and
231
+ // otherwise silently shadow a perfectly good apiKey in the token file below
232
+ // once it expires, forcing a manual unset every ~hour.
233
+ const fromEnv = (process.env.EMPIRICAL_ACCESS_TOKEN || '').trim();
234
+ if (fromEnv && !(isJwtToken(fromEnv) && isJwtExpired(fromEnv))) return fromEnv;
235
+
236
+ // Priority 2/3: token file
237
+ const tokenFileCandidates = [
238
+ (process.env.EMPIRICAL_TOKEN_OUTPUT || '').trim(),
239
+ path.resolve(process.cwd(), '.empirical', 'headless-token.json'),
240
+ path.join(os.homedir(), '.empirical', 'headless-token.json')
241
+ ].filter(Boolean);
242
+
243
+ for (const candidate of tokenFileCandidates) {
244
+ if (!fs.existsSync(candidate)) continue;
245
+ try {
246
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
247
+ if (parsed && typeof parsed === 'object') {
248
+ const apiKey = (parsed.apiKey || '').trim();
249
+ if (apiKey) return apiKey;
250
+ const accessToken = (parsed.accessToken || '').trim();
251
+ if (accessToken) return accessToken;
252
+ }
253
+ } catch (_error) {
254
+ // ignore parse errors and try next
255
+ }
256
+ }
257
+
258
+ const session = readOAuthSession();
259
+ if (session?.accessToken && !(isJwtToken(session.accessToken) && isJwtExpired(session.accessToken))) {
260
+ return session.accessToken;
261
+ }
262
+
263
+ // Priority 4: EMPIRICAL_API_KEY / EMPIRICAL_CLIENT_SECRET env vars, used
264
+ // directly as a permanent Bearer credential. In a client_credentials setup
265
+ // the client secret IS the API key (see runHeadlessBootstrap's `apiKey:
266
+ // clientSecret`), and the server already accepts it as a raw Bearer token
267
+ // with no token exchange required. This lets a stateless/ephemeral agent
268
+ // environment (no persisted headless-token.json between runs) authenticate
269
+ // from client_id/secret alone, with nothing to expire and nothing to
270
+ // bootstrap first. Only kicks in when nothing above matched, so it never
271
+ // overrides a real token file or a still-valid env access token.
272
+ const apiKeyFromEnv = (process.env.EMPIRICAL_API_KEY || process.env.EMPIRICAL_CLIENT_SECRET || '').trim();
273
+ if (apiKeyFromEnv) return apiKeyFromEnv;
274
+
275
+ return null;
276
+ }
277
+
278
+ function resolveCachedSessionAccessToken() {
279
+ const tokenFileCandidates = [
280
+ (process.env.EMPIRICAL_TOKEN_OUTPUT || '').trim(),
281
+ path.resolve(process.cwd(), '.empirical', 'headless-token.json'),
282
+ path.join(os.homedir(), '.empirical', 'headless-token.json')
283
+ ].filter(Boolean);
284
+
285
+ for (const candidate of tokenFileCandidates) {
286
+ if (!fs.existsSync(candidate)) continue;
287
+ try {
288
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
289
+ const accessToken = (parsed?.accessToken || '').trim();
290
+ if (accessToken && isJwtToken(accessToken) && !isJwtExpired(accessToken)) {
291
+ return accessToken;
292
+ }
293
+ } catch (_error) {
294
+ // ignore parse errors and try next candidate
295
+ }
296
+ }
297
+ return null;
298
+ }
299
+
300
+ function resolveApiBaseUrl() {
301
+ const fromEnv = (process.env.EMPIRICAL_BASE_URL || '').trim();
302
+ if (fromEnv) return fromEnv;
303
+ const derived = deriveBaseUrlFromMcpUrl();
304
+ if (derived) return derived;
305
+ return 'https://empirical.gauzza.com';
306
+ }
307
+
308
+ function requireAccessToken() {
309
+ const token = resolveAccessToken();
310
+ if (!token) {
311
+ warn('No access token found. Run: empirical auth login, or configure headless credentials with empirical oauth bootstrap headless.');
312
+ process.exit(1);
313
+ }
314
+ return token;
315
+ }
316
+
317
+ function isAuthCommand(args) {
318
+ return args[0] === 'auth' && ['login', 'logout'].includes(args[1]);
319
+ }
320
+
321
+ async function runAuthCommand(args) {
322
+ const action = args[1];
323
+ if (action === 'login') {
324
+ const baseUrl = getEnv('EMPIRICAL_BASE_URL', 'https://empirical.gauzza.com');
325
+ await runMcpOAuthFlow(baseUrl);
326
+ log('Empirical OAuth login complete. CLI session saved.');
327
+ return;
328
+ }
329
+ clearOAuthSession();
330
+ log('Empirical OAuth session cleared.');
331
+ }
332
+
333
+ function buildApiAuthHeaders(token) {
334
+ const context = globalThis.__empiricalCliAnalyticsContext || {};
335
+ const workspaceId = globalThis.__empiricalCliWorkspaceId || null;
336
+ return {
337
+ Authorization: `Bearer ${token}`,
338
+ 'x-empirical-surface': 'cli',
339
+ ...(context.command ? { 'x-empirical-cli-command': context.command } : {}),
340
+ ...(context.subcommand ? { 'x-empirical-cli-subcommand': context.subcommand } : {}),
341
+ ...(workspaceId ? { 'x-workspace-id': workspaceId } : {})
342
+ };
343
+ }
344
+
345
+ async function ensureHeadlessToken(options = {}) {
346
+ const promptCredentials = options.promptCredentials === true;
347
+ const existing = readHeadlessToken();
348
+ if (existing?.apiKey) {
349
+ return existing;
350
+ }
351
+ if (hasFreshAccessToken(existing)) {
352
+ return existing;
353
+ }
354
+
355
+ if (existing?.refreshToken && existing?.clientId) {
356
+ try {
357
+ await runHeadlessRefresh(['oauth', 'refresh', 'headless', '--write-env']);
358
+ const refreshed = readHeadlessToken();
359
+ if (refreshed?.apiKey || hasFreshAccessToken(refreshed)) {
360
+ return refreshed;
361
+ }
362
+ } catch (_error) {
363
+ // Fall through to bootstrap if refresh is unavailable or rejected.
364
+ }
365
+ }
366
+
367
+ const bootstrapArgs = ['oauth', 'bootstrap', 'headless', '--write-env'];
368
+ if (promptCredentials) {
369
+ bootstrapArgs.push('--prompt-credentials');
370
+ }
371
+ await runHeadlessBootstrap(bootstrapArgs);
372
+ return readHeadlessToken();
373
+ }
374
+
375
+ function isHeadlessBootstrapCommand(args) {
376
+ if (args.length >= 3) {
377
+ return args[0] === 'oauth' && args[1] === 'bootstrap' && ['headless', 'docker', 'openclaw'].includes(args[2]);
378
+ }
379
+ return args.includes('--oauth-bootstrap-headless') || args.includes('--oauth-bootstrap-openclaw');
380
+ }
381
+
382
+ function isHeadlessRefreshCommand(args) {
383
+ if (args.length >= 3) {
384
+ return args[0] === 'oauth' && args[1] === 'refresh' && ['headless', 'docker', 'openclaw'].includes(args[2]);
385
+ }
386
+ return args.includes('--oauth-refresh-headless') || args.includes('--oauth-refresh-openclaw');
387
+ }
388
+
389
+ function isOAuthClientCommand(args) {
390
+ return args[0] === 'oauth' && args[1] === 'client' && ['set', 'show', 'clear'].includes(args[2]);
391
+ }
392
+
393
+ async function runOAuthClientCommand(args) {
394
+ const action = args[2];
395
+ const storePath = getLocalOAuthClientPath();
396
+
397
+ if (action === 'show') {
398
+ const stored = readStoredOAuthClient();
399
+ if (!stored) {
400
+ log(`No saved OAuth client credentials at ${storePath}.`);
401
+ return;
402
+ }
403
+ log('Saved OAuth client credentials:');
404
+ log(` - Path: ${storePath}`);
405
+ log(` - Client ID: ${stored.clientId}`);
406
+ log(` - Client Secret: ${maskSecret(stored.clientSecret)}`);
407
+ return;
408
+ }
409
+
410
+ if (action === 'clear') {
411
+ const existed = fs.existsSync(storePath);
412
+ clearStoredOAuthClient();
413
+ log(
414
+ existed
415
+ ? `Removed saved OAuth client credentials: ${storePath}`
416
+ : `No saved OAuth client credentials at ${storePath}.`
417
+ );
418
+
419
+ // `clear` means "forget my client credentials" — that has to include env
420
+ // vars too (env vars take priority over the file — see the clientId/
421
+ // clientSecret lookups in runHeadlessBootstrap), not just the JSON file,
422
+ // or a stale EMPIRICAL_ACCESS_TOKEN persisted via `setx` on Windows by a
423
+ // prior bootstrap silently keeps overriding a "cleared" state. Scrub
424
+ // everything we're actually able to reach automatically; only the
425
+ // current shell's already-exported values are outside a child process's
426
+ // reach on any OS.
427
+ const envVarsToScrub = [
428
+ 'EMPIRICAL_CLIENT_ID',
429
+ 'EMPIRICAL_CLIENT_SECRET',
430
+ 'DEFAULT_CLIENT_ID',
431
+ 'DEFAULT_CLIENT_SECRET',
432
+ 'EMPIRICAL_ACCESS_TOKEN'
433
+ ].filter((name) => getEnv(name));
434
+
435
+ if (envVarsToScrub.length > 0) {
436
+ for (const name of envVarsToScrub) {
437
+ delete process.env[name];
438
+ }
439
+ if (process.platform === 'win32') {
440
+ for (const name of envVarsToScrub) {
441
+ runResolvedCommand('setx', [name, ''], { stdio: 'ignore' });
442
+ }
443
+ log(` - Also cleared ${envVarsToScrub.join(', ')} from your persisted Windows user environment.`);
444
+ log(' Open a new terminal for that to take effect there too — this shell keeps the old value(s) until then.');
445
+ } else {
446
+ warn(
447
+ ` - ${envVarsToScrub.join(', ')} ${envVarsToScrub.length > 1 ? 'are' : 'is'} still exported in this shell and will override the cleared file for the rest of this session.`
448
+ );
449
+ warn(` Unset with: unset ${envVarsToScrub.join(' ')} (and remove from your shell profile if exported there).`);
450
+ }
451
+ }
452
+ return;
453
+ }
454
+
455
+ let clientId = parseOption(args, '--client-id', getEnv('EMPIRICAL_CLIENT_ID'));
456
+ let clientSecret = parseOption(args, '--client-secret', getEnv('EMPIRICAL_CLIENT_SECRET'));
457
+
458
+ if (!clientId && process.stdin.isTTY) {
459
+ clientId = (await ask('OAuth client ID: ')).trim();
460
+ }
461
+ if (!clientId) {
462
+ throw new Error('OAuth client ID is required. Provide --client-id or EMPIRICAL_CLIENT_ID.');
463
+ }
464
+
465
+ if (!clientSecret && process.stdin.isTTY) {
466
+ clientSecret = (await ask('OAuth client secret (optional): ')).trim();
467
+ }
468
+
469
+ writeStoredOAuthClient(clientId, clientSecret);
470
+ log(`Saved OAuth client credentials to ${storePath}`);
471
+ log('Use "empirical oauth client show" to verify and "empirical -install" to apply to supported CLIs.');
472
+ }
473
+
474
+ async function ensureOAuthClient({ baseUrl, redirectUri }) {
475
+ const configuredClientId = getEnv('EMPIRICAL_CLIENT_ID');
476
+ const configuredClientSecret = getEnv('EMPIRICAL_CLIENT_SECRET');
477
+ if (configuredClientId) {
478
+ return {
479
+ clientId: configuredClientId,
480
+ clientSecret: configuredClientSecret || ''
481
+ };
482
+ }
483
+
484
+ const storedClient = readStoredOAuthClient();
485
+ if (storedClient?.clientId) {
486
+ return storedClient;
487
+ }
488
+
489
+ const registerUrl = new URL('/oauth/register', baseUrl).toString();
490
+ const redirectUrisSet = new Set([redirectUri]);
491
+ ['http://127.0.0.1/', 'http://localhost/', 'http://[::1]/'].forEach((loopback) => {
492
+ redirectUrisSet.add(loopback);
493
+ });
494
+
495
+ const payload = {
496
+ client_name: getEnv('EMPIRICAL_CLIENT_NAME', 'Empirical Headless Client'),
497
+ redirect_uris: Array.from(redirectUrisSet),
498
+ grant_types: ['authorization_code', 'refresh_token'],
499
+ response_types: ['code'],
500
+ scope: getEnv('EMPIRICAL_SCOPE', 'memory:read memory:write memory:delete'),
501
+ token_endpoint_auth_method: getEnv('EMPIRICAL_TOKEN_ENDPOINT_AUTH_METHOD', 'none')
502
+ };
503
+
504
+ const created = await httpJson(registerUrl, 'POST', payload);
505
+ return {
506
+ clientId: created.client_id,
507
+ clientSecret: created.client_secret || ''
508
+ };
509
+ }
510
+
511
+ async function fetchSelfServiceCredentials(baseUrl, accessToken, { rotate = false, label } = {}) {
512
+ const url = new URL('/api/v1/users/me/credentials', baseUrl).toString();
513
+ const body = { rotate };
514
+ if (label) body.label = label;
515
+ return httpJson(url, 'POST', body, { Authorization: `Bearer ${accessToken}` });
516
+ }
517
+
518
+ async function fetchSelfServiceCredentialStatus(baseUrl, accessToken) {
519
+ const url = new URL('/api/v1/users/me/credentials', baseUrl).toString();
520
+ return httpJson(url, 'GET', undefined, { Authorization: `Bearer ${accessToken}` });
521
+ }
522
+
523
+ async function selfProvisionHeadlessCredentials({ baseUrl, allowBrowserLogin = true } = {}) {
524
+ let bearer = resolveAccessToken();
525
+
526
+ if (!bearer && allowBrowserLogin && process.stdin.isTTY) {
527
+ log(' - No local credentials found. Opening browser once to identify your account...');
528
+ const mcpToken = await runMcpOAuthFlow(baseUrl);
529
+ bearer = mcpToken.accessToken;
530
+ }
531
+
532
+ if (!bearer) {
533
+ return null;
534
+ }
535
+
536
+ try {
537
+ await fetchSelfServiceCredentialStatus(baseUrl, bearer);
538
+ } catch (error) {
539
+ // A stale/expired EMPIRICAL_ACCESS_TOKEN (e.g. persisted via `setx` by a
540
+ // prior bootstrap) shouldn't dead-end self-provisioning at a manual
541
+ // prompt — re-identify the account with one browser login instead.
542
+ const looksUnauthorized = error.statusCode === 401 || error.statusCode === 403;
543
+ if (!looksUnauthorized || !allowBrowserLogin || !process.stdin.isTTY) {
544
+ throw error;
545
+ }
546
+ log(' - Saved access token was rejected. Opening browser once to re-identify your account...');
547
+ const mcpToken = await runMcpOAuthFlow(baseUrl);
548
+ bearer = mcpToken.accessToken;
549
+ }
550
+
551
+ // Minting always requires a real session bearer, whether or not the account already has
552
+ // a key — the server rejects a permanent API key for this endpoint specifically (see
553
+ // requireSessionAuthForCredentialMutation server-side), so one leaked key can't mint
554
+ // itself unlimited independently-valid siblings. Minting an ADDITIONAL key never touches
555
+ // any existing key, so — unlike the old rotate-only model — there's no more "already has
556
+ // a key" consent branch needed here at all.
557
+ let sessionBearer = resolveCachedSessionAccessToken();
558
+ if (!sessionBearer) {
559
+ if (!allowBrowserLogin || !process.stdin.isTTY) {
560
+ warn(' - Creating an API key requires a signed-in session, and no valid cached session was found.');
561
+ warn(' Run this command in an interactive terminal to sign in once, or set EMPIRICAL_CLIENT_ID/EMPIRICAL_CLIENT_SECRET from Settings.');
562
+ return null;
563
+ }
564
+ log(' - Creating an API key requires a signed-in session. Opening browser once...');
565
+ const mcpToken = await runMcpOAuthFlow(baseUrl);
566
+ sessionBearer = mcpToken.accessToken;
567
+ }
568
+
569
+ const minted = await fetchSelfServiceCredentials(baseUrl, sessionBearer, {
570
+ rotate: false,
571
+ label: 'CLI self-provision'
572
+ });
573
+ const result = minted.data || minted;
574
+ if (!result.apiKey) return null;
575
+ return { clientId: result.clientId || result.userId, clientSecret: result.apiKey };
576
+ }
577
+
578
+ async function runHeadlessBootstrap(args) {
579
+ const printAccessToken = parseFlag(args, '--print-access-token');
580
+ const writeEnv = parseFlag(args, '--write-env');
581
+ const promptCredentials = parseFlag(args, '--prompt-credentials');
582
+ const baseUrl = getEnv('EMPIRICAL_BASE_URL', 'https://empirical.gauzza.com');
583
+ const storedClient = readStoredOAuthClient();
584
+ let clientId = parseOption(
585
+ args,
586
+ '--client-id',
587
+ getEnv('DEFAULT_CLIENT_ID') || getEnv('EMPIRICAL_CLIENT_ID') || storedClient?.clientId || ''
588
+ );
589
+ let clientSecret = parseOption(
590
+ args,
591
+ '--client-secret',
592
+ getEnv('DEFAULT_CLIENT_SECRET') || getEnv('EMPIRICAL_CLIENT_SECRET') || storedClient?.clientSecret || ''
593
+ );
594
+ const outputPath = getEnv('EMPIRICAL_TOKEN_OUTPUT', '.empirical/headless-token.json');
595
+ const envOutputPath = parseOption(args, '--env-output', getEnv('EMPIRICAL_ENV_OUTPUT', '.empirical/headless.env'));
596
+
597
+ if (!promptCredentials && !clientId) {
598
+ log('No headless credentials found. Self-provisioning from your account...');
599
+ try {
600
+ const provisioned = await selfProvisionHeadlessCredentials({ baseUrl });
601
+ if (provisioned?.clientId) {
602
+ clientId = provisioned.clientId;
603
+ clientSecret = provisioned.clientSecret || clientSecret;
604
+ writeStoredOAuthClient(clientId, clientSecret);
605
+ log(` - Saved credentials for ${clientId} to ${getLocalOAuthClientPath()}.`);
606
+ }
607
+ } catch (error) {
608
+ warn(` - Self-provisioning failed: ${error.message}`);
609
+ }
610
+ }
611
+
612
+ if (process.stdin.isTTY && (promptCredentials || !clientId)) {
613
+ const idPrompt = clientId
614
+ ? `Client ID (from Settings) [${clientId}]: `
615
+ : 'Client ID (from Settings): ';
616
+ const idAnswer = (await ask(idPrompt)).trim();
617
+ clientId = idAnswer || clientId;
618
+ }
619
+ if (process.stdin.isTTY && (promptCredentials || !clientSecret)) {
620
+ const secretPrompt = clientSecret
621
+ ? 'Client secret (from Settings) [press Enter to keep saved secret]: '
622
+ : 'Client secret (from Settings): ';
623
+ const secretAnswer = (await ask(secretPrompt)).trim();
624
+ clientSecret = secretAnswer || clientSecret;
625
+ }
626
+ if (!clientId || !clientSecret) {
627
+ throw new Error(
628
+ 'Client ID and client secret are required for headless bootstrap, and auto-provisioning did not succeed. ' +
629
+ 'Check EMPIRICAL_BASE_URL/network connectivity, or pass --client-id/--client-secret manually.'
630
+ );
631
+ }
632
+
633
+ const tokenUrl = new URL('/oauth/token', baseUrl).toString();
634
+ const explicitClientId = Boolean(parseOption(args, '--client-id', ''));
635
+ const requestToken = (id, secret) =>
636
+ httpForm(tokenUrl, 'POST', {
637
+ grant_type: 'client_credentials',
638
+ client_id: id,
639
+ client_secret: secret,
640
+ scope: getEnv('EMPIRICAL_SCOPE', 'memory:read memory:write memory:delete offline_access')
641
+ });
642
+
643
+ let token;
644
+ try {
645
+ token = await requestToken(clientId, clientSecret);
646
+ } catch (error) {
647
+ const looksInvalid = /invalid.?client/i.test(error.message);
648
+ if (!looksInvalid || explicitClientId) {
649
+ throw error;
650
+ }
651
+ log('Stored credentials were rejected by the server. Re-provisioning from your account...');
652
+ clearStoredOAuthClient();
653
+ let provisioned = null;
654
+ try {
655
+ provisioned = await selfProvisionHeadlessCredentials({ baseUrl, allowBrowserLogin: false });
656
+ } catch (_reprovisionError) {
657
+ throw error;
658
+ }
659
+ if (!provisioned?.clientId) {
660
+ throw error;
661
+ }
662
+ clientId = provisioned.clientId;
663
+ clientSecret = provisioned.clientSecret || '';
664
+ writeStoredOAuthClient(clientId, clientSecret);
665
+ log(` - Saved refreshed credentials for ${clientId}.`);
666
+ token = await requestToken(clientId, clientSecret);
667
+ }
668
+
669
+ if (!token.access_token) {
670
+ throw new Error('Token exchange succeeded but no access_token was returned.');
671
+ }
672
+
673
+ writeStoredOAuthClient(clientId, clientSecret);
674
+
675
+ const result = {
676
+ baseUrl,
677
+ obtainedAt: new Date().toISOString(),
678
+ tokenType: token.token_type || 'Bearer',
679
+ accessToken: token.access_token,
680
+ refreshToken: token.refresh_token || null,
681
+ expiresIn: token.expires_in || null,
682
+ clientId,
683
+ clientSecret: clientSecret || null,
684
+ // clientSecret IS the API key for client_credentials flows.
685
+ // Storing it as apiKey lets writeHeadlessEnvFile emit a permanent
686
+ // Bearer credential that never expires.
687
+ apiKey: clientSecret || null
688
+ };
689
+
690
+ ensureDir(path.dirname(path.resolve(outputPath)));
691
+ fs.writeFileSync(path.resolve(outputPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
692
+ if (writeEnv) {
693
+ writeHeadlessEnvFile(result, envOutputPath);
694
+ }
695
+
696
+ if (printAccessToken) {
697
+ log(result.accessToken);
698
+ return;
699
+ }
700
+
701
+ log('Headless OAuth bootstrap complete.');
702
+ log(` - Token file: ${path.resolve(outputPath)}`);
703
+ if (writeEnv) {
704
+ log(` - Env file: ${path.resolve(envOutputPath)}`);
705
+ }
706
+ log(` - Client ID: ${clientId}`);
707
+ log(` - Access token expires in: ${result.expiresIn || 'unknown'} seconds`);
708
+ }
709
+
710
+ async function runHeadlessRefresh(args) {
711
+ const printAccessToken = parseFlag(args, '--print-access-token');
712
+ const writeEnv = parseFlag(args, '--write-env');
713
+ const configuredOutput = getEnv('EMPIRICAL_TOKEN_OUTPUT', '.empirical/headless-token.json');
714
+ let outputPath = path.resolve(configuredOutput);
715
+ const legacyOutputPath = path.resolve('.empirical/openclaw-token.json');
716
+ if (!fs.existsSync(outputPath) && configuredOutput === '.empirical/headless-token.json' && fs.existsSync(legacyOutputPath)) {
717
+ outputPath = legacyOutputPath;
718
+ }
719
+ const envOutputPath = parseOption(args, '--env-output', getEnv('EMPIRICAL_ENV_OUTPUT', '.empirical/headless.env'));
720
+ if (!fs.existsSync(outputPath)) {
721
+ throw new Error(`Token file not found: ${outputPath}. Run bootstrap first.`);
722
+ }
723
+
724
+ const current = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
725
+ const baseUrl = String(current.baseUrl || getEnv('EMPIRICAL_BASE_URL', 'https://empirical.gauzza.com'));
726
+ const clientId = String(current.clientId || '');
727
+ const refreshToken = String(current.refreshToken || '');
728
+ const clientSecret = String(current.clientSecret || '');
729
+
730
+ if (!clientId || !refreshToken) {
731
+ throw new Error('Token file is missing clientId or refreshToken. Re-run bootstrap.');
732
+ }
733
+
734
+ const tokenUrl = new URL('/oauth/token', baseUrl).toString();
735
+ const tokenRequest = {
736
+ grant_type: 'refresh_token',
737
+ refresh_token: refreshToken,
738
+ client_id: clientId
739
+ };
740
+ if (clientSecret) {
741
+ tokenRequest.client_secret = clientSecret;
742
+ }
743
+
744
+ const token = await httpForm(tokenUrl, 'POST', tokenRequest);
745
+ if (!token.access_token) {
746
+ throw new Error('Refresh succeeded but no access_token was returned.');
747
+ }
748
+
749
+ const next = {
750
+ ...current,
751
+ baseUrl,
752
+ obtainedAt: new Date().toISOString(),
753
+ tokenType: token.token_type || current.tokenType || 'Bearer',
754
+ accessToken: token.access_token,
755
+ refreshToken: token.refresh_token || refreshToken,
756
+ expiresIn: token.expires_in || current.expiresIn || null,
757
+ clientId,
758
+ clientSecret: clientSecret || current.clientSecret || current.authClientSecret || null,
759
+ apiKey: current.apiKey || current.clientSecret || current.authClientSecret || clientSecret || null
760
+ };
761
+
762
+ fs.writeFileSync(outputPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
763
+ if (writeEnv) {
764
+ writeHeadlessEnvFile(next, envOutputPath);
765
+ }
766
+
767
+ if (printAccessToken) {
768
+ log(next.accessToken);
769
+ return;
770
+ }
771
+
772
+ log('Headless OAuth token refreshed.');
773
+ log(` - Token file: ${outputPath}`);
774
+ if (writeEnv) {
775
+ log(` - Env file: ${path.resolve(envOutputPath)}`);
776
+ }
777
+ log(` - Access token expires in: ${next.expiresIn || 'unknown'} seconds`);
778
+ }
779
+
780
+ module.exports = {
781
+ oauthPreflight,
782
+ openBrowser,
783
+ runMcpOAuthFlow,
784
+ isAuthCommand,
785
+ runAuthCommand,
786
+ resolveOAuthCode,
787
+ updateClaudeMcpServerAuth,
788
+ resolveAccessToken,
789
+ resolveCachedSessionAccessToken,
790
+ resolveApiBaseUrl,
791
+ requireAccessToken,
792
+ buildApiAuthHeaders,
793
+ ensureHeadlessToken,
794
+ isHeadlessBootstrapCommand,
795
+ isHeadlessRefreshCommand,
796
+ isOAuthClientCommand,
797
+ runOAuthClientCommand,
798
+ ensureOAuthClient,
799
+ fetchSelfServiceCredentials,
800
+ fetchSelfServiceCredentialStatus,
801
+ selfProvisionHeadlessCredentials,
802
+ runHeadlessBootstrap,
803
+ runHeadlessRefresh
804
+ };