keystone-cli 1.3.0 → 2.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.
Files changed (42) hide show
  1. package/README.md +114 -140
  2. package/package.json +6 -3
  3. package/src/cli.ts +54 -369
  4. package/src/commands/init.ts +15 -29
  5. package/src/db/memory-db.test.ts +45 -0
  6. package/src/db/memory-db.ts +47 -21
  7. package/src/db/sqlite-setup.ts +26 -3
  8. package/src/db/workflow-db.ts +12 -5
  9. package/src/parser/config-schema.ts +11 -13
  10. package/src/parser/schema.ts +4 -2
  11. package/src/runner/__test__/llm-mock-setup.ts +173 -0
  12. package/src/runner/__test__/llm-test-setup.ts +271 -0
  13. package/src/runner/engine-executor.test.ts +25 -18
  14. package/src/runner/executors/blueprint-executor.ts +0 -1
  15. package/src/runner/executors/dynamic-executor.ts +11 -6
  16. package/src/runner/executors/engine-executor.ts +5 -1
  17. package/src/runner/executors/llm-executor.ts +502 -1033
  18. package/src/runner/executors/memory-executor.ts +35 -19
  19. package/src/runner/executors/plan-executor.ts +0 -1
  20. package/src/runner/executors/types.ts +4 -4
  21. package/src/runner/llm-adapter.integration.test.ts +151 -0
  22. package/src/runner/llm-adapter.ts +263 -1401
  23. package/src/runner/llm-clarification.test.ts +91 -106
  24. package/src/runner/llm-executor.test.ts +217 -1181
  25. package/src/runner/memoization.test.ts +0 -1
  26. package/src/runner/recovery-security.test.ts +51 -20
  27. package/src/runner/reflexion.test.ts +55 -18
  28. package/src/runner/standard-tools-integration.test.ts +137 -87
  29. package/src/runner/step-executor.test.ts +36 -80
  30. package/src/runner/step-executor.ts +0 -2
  31. package/src/runner/test-harness.ts +3 -29
  32. package/src/runner/tool-integration.test.ts +122 -73
  33. package/src/runner/workflow-runner.ts +92 -35
  34. package/src/runner/workflow-scheduler.ts +11 -1
  35. package/src/runner/workflow-summary.ts +144 -0
  36. package/src/utils/auth-manager.test.ts +10 -520
  37. package/src/utils/auth-manager.ts +3 -756
  38. package/src/utils/config-loader.ts +12 -0
  39. package/src/utils/constants.ts +0 -17
  40. package/src/utils/process-sandbox.ts +15 -3
  41. package/src/runner/llm-adapter-runtime.test.ts +0 -209
  42. package/src/runner/llm-adapter.test.ts +0 -1012
@@ -1,28 +1,9 @@
1
- import { createHash, randomBytes } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
2
  import { homedir } from 'node:os';
4
3
  import { join } from 'node:path';
5
- import { TIMEOUTS } from './constants';
6
4
  import { ConsoleLogger, type Logger } from './logger';
7
5
 
8
6
  export interface AuthData {
9
- github_token?: string;
10
- copilot_token?: string;
11
- copilot_expires_at?: number;
12
- openai_api_key?: string;
13
- anthropic_api_key?: string;
14
- google_gemini?: {
15
- access_token: string;
16
- refresh_token: string;
17
- expires_at: number;
18
- email?: string;
19
- project_id?: string;
20
- };
21
- anthropic_claude?: {
22
- access_token: string;
23
- refresh_token: string;
24
- expires_at: number;
25
- };
26
7
  mcp_tokens?: Record<
27
8
  string,
28
9
  {
@@ -31,62 +12,11 @@ export interface AuthData {
31
12
  refresh_token?: string;
32
13
  }
33
14
  >;
34
- openai_chatgpt?: {
35
- access_token: string;
36
- refresh_token: string;
37
- expires_at: number;
38
- account_id?: string;
39
- };
40
15
  }
41
16
 
42
- export const COPILOT_HEADERS = {
43
- 'Editor-Version': 'vscode/1.96.2',
44
- 'Editor-Plugin-Version': 'copilot-chat/0.23.1',
45
- 'User-Agent': 'GithubCopilot/1.255.0',
46
- };
47
-
48
- // OAuth Client IDs - configurable via environment variables for different deployment environments
49
- const GITHUB_CLIENT_ID: string = process.env.KEYSTONE_GITHUB_CLIENT_ID ?? '013444988716b5155f4c';
50
- const TOKEN_REFRESH_BUFFER_SECONDS = 300;
51
- const OPENAI_CHATGPT_CLIENT_ID: string =
52
- process.env.KEYSTONE_OPENAI_CLIENT_ID ?? 'app_EMoamEEZ73f0CkXaXp7hrann';
53
- const ANTHROPIC_OAUTH_CLIENT_ID: string =
54
- process.env.KEYSTONE_ANTHROPIC_CLIENT_ID ?? '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
55
- const ANTHROPIC_OAUTH_REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback';
56
- const ANTHROPIC_OAUTH_SCOPE = 'org:create_api_key user:profile user:inference';
57
- const GOOGLE_GEMINI_OAUTH_CLIENT_ID: string =
58
- process.env.KEYSTONE_GOOGLE_CLIENT_ID ??
59
- '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
60
- // Redirect URI is dynamically constructed based on the ephemeral port
61
- const GOOGLE_GEMINI_OAUTH_SCOPES = [
62
- 'https://www.googleapis.com/auth/cloud-platform',
63
- 'https://www.googleapis.com/auth/userinfo.email',
64
- 'https://www.googleapis.com/auth/userinfo.profile',
65
- ];
66
- const GOOGLE_GEMINI_LOAD_ENDPOINTS = [
67
- 'https://cloudcode-pa.googleapis.com',
68
- 'https://daily-cloudcode-pa.sandbox.googleapis.com',
69
- 'https://autopush-cloudcode-pa.sandbox.googleapis.com',
70
- ];
71
- const GOOGLE_GEMINI_METADATA_HEADER =
72
- '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
73
-
74
17
  export class AuthManager {
75
18
  private static logger: Logger = new ConsoleLogger();
76
19
 
77
- // Mockable browser opener for testing
78
- static openBrowser(url: string): void {
79
- try {
80
- const { platform } = process;
81
- const command = platform === 'win32' ? 'start' : platform === 'darwin' ? 'open' : 'xdg-open';
82
- const { spawn } = require('node:child_process');
83
- spawn(command, [url]);
84
- } catch (e) {
85
- // Silently ignore - browser open is best-effort, user can manually open URL
86
- AuthManager.logger.debug?.(`Browser open failed: ${e}`);
87
- }
88
- }
89
-
90
20
  private static getAuthPath(): string {
91
21
  if (process.env.KEYSTONE_AUTH_PATH) {
92
22
  return process.env.KEYSTONE_AUTH_PATH;
@@ -97,8 +27,7 @@ export class AuthManager {
97
27
  }
98
28
  // Ensure dir perms are correct even if it exists
99
29
  try {
100
- const fs = require('node:fs');
101
- fs.chmodSync(dir, 0o700);
30
+ chmodSync(dir, 0o700);
102
31
  } catch (e) {
103
32
  AuthManager.logger.debug?.(`Failed to set directory permissions: ${e}`);
104
33
  }
@@ -106,7 +35,7 @@ export class AuthManager {
106
35
  const authPath = join(dir, 'auth.json');
107
36
  if (existsSync(authPath)) {
108
37
  try {
109
- require('node:fs').chmodSync(authPath, 0o600);
38
+ chmodSync(authPath, 0o600);
110
39
  } catch (e) {
111
40
  AuthManager.logger.debug?.(`Failed to set auth file permissions: ${e}`);
112
41
  }
@@ -193,686 +122,4 @@ export class AuthManager {
193
122
  static setLogger(logger: Logger): void {
194
123
  AuthManager.logger = logger;
195
124
  }
196
-
197
- static async initGitHubDeviceLogin(): Promise<{
198
- device_code: string;
199
- user_code: string;
200
- verification_uri: string;
201
- expires_in: number;
202
- interval: number;
203
- }> {
204
- const response = await fetch('https://github.com/login/device/code', {
205
- method: 'POST',
206
- headers: {
207
- 'Content-Type': 'application/json',
208
- Accept: 'application/json',
209
- },
210
- body: JSON.stringify({
211
- client_id: GITHUB_CLIENT_ID,
212
- scope: 'read:user workflow repo',
213
- }),
214
- });
215
-
216
- if (!response.ok) {
217
- throw new Error(`Failed to initialize device login: ${response.statusText}`);
218
- }
219
-
220
- return response.json() as Promise<{
221
- device_code: string;
222
- user_code: string;
223
- verification_uri: string;
224
- expires_in: number;
225
- interval: number;
226
- }>;
227
- }
228
-
229
- static async pollGitHubDeviceLogin(
230
- deviceCode: string,
231
- intervalSeconds = 5,
232
- expiresInSeconds = 900
233
- ): Promise<string> {
234
- let currentInterval = intervalSeconds;
235
- const poll = async (): Promise<string> => {
236
- const response = await fetch('https://github.com/login/oauth/access_token', {
237
- method: 'POST',
238
- headers: {
239
- 'Content-Type': 'application/json',
240
- Accept: 'application/json',
241
- },
242
- body: JSON.stringify({
243
- client_id: GITHUB_CLIENT_ID,
244
- device_code: deviceCode,
245
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
246
- }),
247
- });
248
-
249
- if (!response.ok) {
250
- throw new Error(`Failed to poll device login: ${response.statusText}`);
251
- }
252
-
253
- const data = (await response.json()) as {
254
- access_token?: string;
255
- error?: string;
256
- error_description?: string;
257
- };
258
-
259
- if (data.access_token) {
260
- return data.access_token;
261
- }
262
-
263
- if (data.error === 'authorization_pending') {
264
- return ''; // Continue polling
265
- }
266
-
267
- if (data.error === 'slow_down') {
268
- // According to GitHub docs, "slow_down" means wait 5 seconds more
269
- currentInterval += 5;
270
- return '';
271
- }
272
-
273
- throw new Error(data.error_description || data.error || 'Failed to get access token');
274
- };
275
-
276
- // Use interval and expiration from parameters
277
- const startTime = Date.now();
278
- const timeout = expiresInSeconds * 1000;
279
-
280
- while (Date.now() - startTime < timeout) {
281
- const token = await poll();
282
- if (token) return token;
283
- // Convert seconds to milliseconds
284
- await new Promise((resolve) => setTimeout(resolve, currentInterval * 1000));
285
- }
286
-
287
- throw new Error('Device login timed out');
288
- }
289
-
290
- static async getCopilotToken(): Promise<string | undefined> {
291
- const auth = AuthManager.load();
292
-
293
- // Check if we have a valid cached token
294
- if (
295
- auth.copilot_token &&
296
- auth.copilot_expires_at &&
297
- auth.copilot_expires_at > Date.now() / 1000 + TOKEN_REFRESH_BUFFER_SECONDS
298
- ) {
299
- return auth.copilot_token;
300
- }
301
-
302
- if (!auth.github_token) {
303
- return undefined;
304
- }
305
-
306
- // Exchange GitHub token for Copilot token
307
- try {
308
- const response = await fetch('https://api.github.com/copilot_internal/v2/token', {
309
- headers: {
310
- Authorization: `token ${auth.github_token}`,
311
- ...COPILOT_HEADERS,
312
- },
313
- });
314
-
315
- if (!response.ok) {
316
- throw new Error(`Failed to get Copilot token: ${response.statusText}`);
317
- }
318
-
319
- const data = (await response.json()) as { token: string; expires_at: number };
320
- AuthManager.save({
321
- copilot_token: data.token,
322
- copilot_expires_at: data.expires_at,
323
- });
324
-
325
- return data.token;
326
- } catch (error) {
327
- AuthManager.logger.error(
328
- `Error refreshing Copilot token: ${AuthManager.sanitizeError(error)}`
329
- );
330
- return undefined;
331
- }
332
- }
333
-
334
- private static generateCodeVerifier(): string {
335
- return randomBytes(32).toString('hex');
336
- }
337
-
338
- private static createCodeChallenge(verifier: string): string {
339
- const hash = createHash('sha256').update(verifier).digest();
340
- return hash.toString('base64url');
341
- }
342
-
343
- private static getGoogleGeminiClientSecret(): string {
344
- const secret =
345
- process.env.GOOGLE_GEMINI_OAUTH_CLIENT_SECRET || process.env.KEYSTONE_GEMINI_CLIENT_SECRET;
346
- if (!secret) {
347
- throw new Error(
348
- 'Missing Google Gemini OAuth client secret. Set GOOGLE_GEMINI_OAUTH_CLIENT_SECRET or KEYSTONE_GEMINI_CLIENT_SECRET.'
349
- );
350
- }
351
- return secret;
352
- }
353
-
354
- static createAnthropicClaudeAuth(): { url: string; verifier: string } {
355
- const verifier = AuthManager.generateCodeVerifier();
356
- const challenge = AuthManager.createCodeChallenge(verifier);
357
-
358
- const authUrl = `https://claude.ai/oauth/authorize?${new URLSearchParams({
359
- code: 'true',
360
- client_id: ANTHROPIC_OAUTH_CLIENT_ID,
361
- response_type: 'code',
362
- redirect_uri: ANTHROPIC_OAUTH_REDIRECT_URI,
363
- scope: ANTHROPIC_OAUTH_SCOPE,
364
- code_challenge: challenge,
365
- code_challenge_method: 'S256',
366
- state: verifier,
367
- }).toString()}`;
368
-
369
- return { url: authUrl, verifier };
370
- }
371
-
372
- static async exchangeAnthropicClaudeCode(
373
- code: string,
374
- verifier: string
375
- ): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
376
- const [authCode, stateFromCode] = code.split('#');
377
- // Validate state is present and matches verifier for security
378
- if (!stateFromCode || stateFromCode !== verifier) {
379
- throw new Error('Invalid OAuth state');
380
- }
381
- const response = await fetch('https://console.anthropic.com/v1/oauth/token', {
382
- method: 'POST',
383
- headers: { 'Content-Type': 'application/json' },
384
- body: JSON.stringify({
385
- code: authCode,
386
- state: stateFromCode || verifier,
387
- grant_type: 'authorization_code',
388
- client_id: ANTHROPIC_OAUTH_CLIENT_ID,
389
- redirect_uri: ANTHROPIC_OAUTH_REDIRECT_URI,
390
- code_verifier: verifier,
391
- }),
392
- });
393
-
394
- if (!response.ok) {
395
- const error = await response.text();
396
- throw new Error(`Failed to exchange Claude auth code: ${response.status} - ${error}`);
397
- }
398
-
399
- return (await response.json()) as {
400
- access_token: string;
401
- refresh_token: string;
402
- expires_in: number;
403
- };
404
- }
405
-
406
- private static async fetchGoogleGeminiProjectId(
407
- accessToken: string
408
- ): Promise<string | undefined> {
409
- const loadHeaders: Record<string, string> = {
410
- Authorization: `Bearer ${accessToken}`,
411
- 'Content-Type': 'application/json',
412
- 'User-Agent': 'google-api-nodejs-client/9.15.1',
413
- 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
414
- 'Client-Metadata': GOOGLE_GEMINI_METADATA_HEADER,
415
- };
416
-
417
- for (const baseEndpoint of GOOGLE_GEMINI_LOAD_ENDPOINTS) {
418
- try {
419
- const response = await fetch(`${baseEndpoint}/v1internal:loadCodeAssist`, {
420
- method: 'POST',
421
- headers: loadHeaders,
422
- body: JSON.stringify({
423
- metadata: {
424
- ideType: 'IDE_UNSPECIFIED',
425
- platform: 'PLATFORM_UNSPECIFIED',
426
- pluginType: 'GEMINI',
427
- },
428
- }),
429
- });
430
-
431
- if (!response.ok) {
432
- continue;
433
- }
434
-
435
- const data = (await response.json()) as {
436
- cloudaicompanionProject?: string | { id?: string };
437
- };
438
-
439
- if (typeof data.cloudaicompanionProject === 'string' && data.cloudaicompanionProject) {
440
- return data.cloudaicompanionProject;
441
- }
442
- if (
443
- data.cloudaicompanionProject &&
444
- typeof data.cloudaicompanionProject === 'object' &&
445
- typeof data.cloudaicompanionProject.id === 'string' &&
446
- data.cloudaicompanionProject.id
447
- ) {
448
- return data.cloudaicompanionProject.id;
449
- }
450
- } catch {}
451
- }
452
-
453
- return undefined;
454
- }
455
-
456
- static async loginGoogleGemini(projectId?: string): Promise<void> {
457
- const verifier = AuthManager.generateCodeVerifier();
458
- const challenge = AuthManager.createCodeChallenge(verifier);
459
- const state = randomBytes(16).toString('hex');
460
-
461
- return new Promise((resolve, reject) => {
462
- const serverRef: { current?: ReturnType<typeof Bun.serve> } = {};
463
- const stopServer = () => {
464
- serverRef.current?.stop();
465
- };
466
- const timeout = setTimeout(() => {
467
- stopServer();
468
- reject(new Error('Login timed out after 5 minutes'));
469
- }, TIMEOUTS.OAUTH_LOGIN_TIMEOUT_MS);
470
-
471
- serverRef.current = Bun.serve({
472
- port: 0, // Use ephemeral port to avoid conflicts
473
- async fetch(req, server) {
474
- const url = new URL(req.url);
475
- const redirectUri = `http://localhost:${server.port}/oauth-callback`;
476
-
477
- if (url.pathname === '/oauth-callback') {
478
- const error = url.searchParams.get('error');
479
- if (error) {
480
- clearTimeout(timeout);
481
- setTimeout(stopServer, 100);
482
- reject(new Error(`Authorization error: ${error}`));
483
- return new Response(`Error: ${error}`, { status: 400 });
484
- }
485
-
486
- const code = url.searchParams.get('code');
487
- const returnedState = url.searchParams.get('state');
488
- if (!code) {
489
- return new Response('Missing code parameter', { status: 400 });
490
- }
491
- if (returnedState && returnedState !== state) {
492
- clearTimeout(timeout);
493
- setTimeout(stopServer, 100);
494
- reject(new Error('Invalid OAuth state'));
495
- return new Response('Invalid state parameter', { status: 400 });
496
- }
497
-
498
- try {
499
- const response = await fetch('https://oauth2.googleapis.com/token', {
500
- method: 'POST',
501
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
502
- body: new URLSearchParams({
503
- client_id: GOOGLE_GEMINI_OAUTH_CLIENT_ID,
504
- client_secret: AuthManager.getGoogleGeminiClientSecret(),
505
- code,
506
- grant_type: 'authorization_code',
507
- redirect_uri: redirectUri,
508
- code_verifier: verifier,
509
- }),
510
- signal: AbortSignal.timeout(30000),
511
- });
512
-
513
- if (!response.ok) {
514
- const errorText = await response.text();
515
- throw new Error(`Failed to exchange code: ${response.status} - ${errorText}`);
516
- }
517
-
518
- const data = (await response.json()) as {
519
- access_token: string;
520
- refresh_token?: string;
521
- expires_in: number;
522
- };
523
-
524
- if (!data.refresh_token) {
525
- throw new Error('Missing refresh token in response. Try re-authenticating.');
526
- }
527
-
528
- let email: string | undefined;
529
- try {
530
- const userInfoResponse = await fetch(
531
- 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
532
- { headers: { Authorization: `Bearer ${data.access_token}` } }
533
- );
534
- if (userInfoResponse.ok) {
535
- const userInfo = (await userInfoResponse.json()) as { email?: string };
536
- email = userInfo.email;
537
- }
538
- } catch {
539
- // Ignore user info lookup failures
540
- }
541
-
542
- let resolvedProjectId =
543
- projectId ||
544
- process.env.GOOGLE_GEMINI_PROJECT_ID ||
545
- process.env.KEYSTONE_GEMINI_PROJECT_ID;
546
- if (!resolvedProjectId) {
547
- resolvedProjectId = await AuthManager.fetchGoogleGeminiProjectId(data.access_token);
548
- }
549
-
550
- AuthManager.save({
551
- google_gemini: {
552
- access_token: data.access_token,
553
- refresh_token: data.refresh_token,
554
- expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
555
- email,
556
- project_id: resolvedProjectId,
557
- },
558
- });
559
-
560
- clearTimeout(timeout);
561
- setTimeout(stopServer, 100);
562
- resolve();
563
- return new Response(
564
- '<h1>Authentication Successful!</h1><p>You can close this window and return to the terminal.</p>',
565
- { headers: { 'Content-Type': 'text/html' } }
566
- );
567
- } catch (err) {
568
- clearTimeout(timeout);
569
- setTimeout(stopServer, 100);
570
- reject(err);
571
- return new Response(`Error: ${err instanceof Error ? err.message : String(err)}`, {
572
- status: 500,
573
- });
574
- }
575
- }
576
- return new Response('Not Found', { status: 404 });
577
- },
578
- });
579
-
580
- // serverRef.current is set by Bun.serve but might not be immediately available if we accessed it too early
581
- // typically Bun.serve returns the server instance synchronously
582
- const port = serverRef.current?.port;
583
- if (!port) {
584
- reject(new Error('Failed to start local server for OAuth'));
585
- return;
586
- }
587
- const redirectUri = `http://localhost:${port}/oauth-callback`;
588
-
589
- const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({
590
- client_id: GOOGLE_GEMINI_OAUTH_CLIENT_ID,
591
- response_type: 'code',
592
- redirect_uri: redirectUri,
593
- scope: GOOGLE_GEMINI_OAUTH_SCOPES.join(' '),
594
- code_challenge: challenge,
595
- code_challenge_method: 'S256',
596
- access_type: 'offline',
597
- prompt: 'consent',
598
- state,
599
- }).toString()}`;
600
-
601
- AuthManager.logger.log('\nTo login with Google Gemini (OAuth):');
602
- AuthManager.logger.log('1. Visit the following URL in your browser:');
603
- AuthManager.logger.log(` ${authUrl}\n`);
604
- AuthManager.logger.log('Waiting for authorization...');
605
-
606
- AuthManager.openBrowser(authUrl);
607
- });
608
- }
609
-
610
- static async loginOpenAIChatGPT(): Promise<void> {
611
- const verifier = AuthManager.generateCodeVerifier();
612
- const challenge = AuthManager.createCodeChallenge(verifier);
613
- const state = randomBytes(16).toString('hex');
614
-
615
- return new Promise((resolve, reject) => {
616
- const serverRef: { current?: ReturnType<typeof Bun.serve> } = {};
617
- const stopServer = () => {
618
- serverRef.current?.stop();
619
- };
620
- const timeout = setTimeout(() => {
621
- stopServer();
622
- reject(new Error('Login timed out after 5 minutes'));
623
- }, TIMEOUTS.OAUTH_LOGIN_TIMEOUT_MS);
624
-
625
- // Use ephemeral port (0) like Google OAuth - dynamically construct redirect URI
626
- serverRef.current = Bun.serve({
627
- port: 0, // Ephemeral port to avoid conflicts
628
- async fetch(req) {
629
- const url = new URL(req.url);
630
- if (url.pathname === '/auth/callback') {
631
- const code = url.searchParams.get('code');
632
- const returnedState = url.searchParams.get('state');
633
- if (!returnedState || returnedState !== state) {
634
- clearTimeout(timeout);
635
- setTimeout(stopServer, 100);
636
- reject(new Error('Invalid OAuth state'));
637
- return new Response('Invalid state parameter', { status: 400 });
638
- }
639
- if (code) {
640
- try {
641
- // Construct redirect URI from actual server port
642
- const actualPort = serverRef.current?.port ?? 0;
643
- const redirectUri = `http://localhost:${actualPort}/auth/callback`;
644
-
645
- const response = await fetch('https://auth.openai.com/oauth/token', {
646
- method: 'POST',
647
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
648
- body: new URLSearchParams({
649
- client_id: OPENAI_CHATGPT_CLIENT_ID,
650
- grant_type: 'authorization_code',
651
- code,
652
- redirect_uri: redirectUri,
653
- code_verifier: verifier,
654
- }),
655
- signal: AbortSignal.timeout(30000),
656
- });
657
-
658
- if (!response.ok) {
659
- const error = await response.text();
660
- throw new Error(`Failed to exchange code: ${response.status} - ${error}`);
661
- }
662
-
663
- const data = (await response.json()) as {
664
- access_token: string;
665
- refresh_token: string;
666
- expires_in: number;
667
- };
668
-
669
- AuthManager.save({
670
- openai_chatgpt: {
671
- access_token: data.access_token,
672
- refresh_token: data.refresh_token,
673
- expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
674
- },
675
- });
676
-
677
- clearTimeout(timeout);
678
- setTimeout(stopServer, 100);
679
- resolve();
680
- return new Response(
681
- '<h1>Authentication Successful!</h1><p>You can close this window and return to the terminal.</p>',
682
- { headers: { 'Content-Type': 'text/html' } }
683
- );
684
- } catch (err) {
685
- clearTimeout(timeout);
686
- setTimeout(stopServer, 100);
687
- reject(err);
688
- return new Response(`Error: ${err instanceof Error ? err.message : String(err)}`, {
689
- status: 500,
690
- });
691
- }
692
- } else {
693
- return new Response('Missing code parameter', { status: 400 });
694
- }
695
- }
696
- return new Response('Not Found', { status: 404 });
697
- },
698
- });
699
-
700
- // Construct redirect URI from dynamically assigned port
701
- const actualPort = serverRef.current.port;
702
- const redirectUri = `http://localhost:${actualPort}/auth/callback`;
703
-
704
- const authUrl = `https://auth.openai.com/oauth/authorize?${new URLSearchParams({
705
- client_id: OPENAI_CHATGPT_CLIENT_ID,
706
- code_challenge: challenge,
707
- code_challenge_method: 'S256',
708
- redirect_uri: redirectUri,
709
- response_type: 'code',
710
- scope: 'openid profile email offline_access',
711
- state,
712
- }).toString()}`;
713
-
714
- AuthManager.logger.log('\nTo login with OpenAI ChatGPT:');
715
- AuthManager.logger.log('1. Visit the following URL in your browser:');
716
- AuthManager.logger.log(` ${authUrl}\n`);
717
- AuthManager.logger.log('Waiting for authorization...');
718
-
719
- // Attempt to open the browser
720
- AuthManager.openBrowser(authUrl);
721
- });
722
- }
723
-
724
- static async getOpenAIChatGPTToken(): Promise<string | undefined> {
725
- const auth = AuthManager.load();
726
- if (!auth.openai_chatgpt) return undefined;
727
-
728
- const { access_token, refresh_token, expires_at } = auth.openai_chatgpt;
729
-
730
- // Check if valid
731
- if (expires_at > Date.now() / 1000 + TOKEN_REFRESH_BUFFER_SECONDS) {
732
- return access_token;
733
- }
734
-
735
- // Refresh
736
- try {
737
- const response = await fetch('https://auth.openai.com/oauth/token', {
738
- method: 'POST',
739
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
740
- body: new URLSearchParams({
741
- client_id: OPENAI_CHATGPT_CLIENT_ID,
742
- grant_type: 'refresh_token',
743
- refresh_token,
744
- }),
745
- signal: AbortSignal.timeout(30000),
746
- });
747
-
748
- if (!response.ok) {
749
- throw new Error(`Failed to refresh token: ${response.statusText}`);
750
- }
751
-
752
- const data = (await response.json()) as {
753
- access_token: string;
754
- refresh_token: string;
755
- expires_in: number;
756
- };
757
-
758
- AuthManager.save({
759
- openai_chatgpt: {
760
- access_token: data.access_token,
761
- refresh_token: data.refresh_token,
762
- expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
763
- },
764
- });
765
-
766
- return data.access_token;
767
- } catch (error) {
768
- AuthManager.logger.error(
769
- `Error refreshing OpenAI ChatGPT token: ${AuthManager.sanitizeError(error)}`
770
- );
771
- return undefined;
772
- }
773
- }
774
-
775
- static async getGoogleGeminiToken(): Promise<string | undefined> {
776
- const auth = AuthManager.load();
777
- if (!auth.google_gemini) return undefined;
778
-
779
- const { access_token, refresh_token, expires_at } = auth.google_gemini;
780
-
781
- if (expires_at > Date.now() / 1000 + TOKEN_REFRESH_BUFFER_SECONDS) {
782
- return access_token;
783
- }
784
-
785
- if (!refresh_token) {
786
- return undefined;
787
- }
788
-
789
- try {
790
- const response = await fetch('https://oauth2.googleapis.com/token', {
791
- method: 'POST',
792
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
793
- body: new URLSearchParams({
794
- client_id: GOOGLE_GEMINI_OAUTH_CLIENT_ID,
795
- client_secret: AuthManager.getGoogleGeminiClientSecret(),
796
- grant_type: 'refresh_token',
797
- refresh_token,
798
- }),
799
- signal: AbortSignal.timeout(30000),
800
- });
801
-
802
- if (!response.ok) {
803
- throw new Error(`Failed to refresh token: ${response.statusText}`);
804
- }
805
-
806
- const data = (await response.json()) as {
807
- access_token: string;
808
- refresh_token?: string;
809
- expires_in: number;
810
- };
811
-
812
- AuthManager.save({
813
- google_gemini: {
814
- ...auth.google_gemini,
815
- access_token: data.access_token,
816
- refresh_token: data.refresh_token || refresh_token,
817
- expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
818
- },
819
- });
820
-
821
- return data.access_token;
822
- } catch (error) {
823
- AuthManager.logger.error(
824
- `Error refreshing Google Gemini token: ${AuthManager.sanitizeError(error)}`
825
- );
826
- return undefined;
827
- }
828
- }
829
-
830
- static async getAnthropicClaudeToken(): Promise<string | undefined> {
831
- const auth = AuthManager.load();
832
- if (!auth.anthropic_claude) return undefined;
833
-
834
- const { access_token, refresh_token, expires_at } = auth.anthropic_claude;
835
-
836
- if (expires_at > Date.now() / 1000 + TOKEN_REFRESH_BUFFER_SECONDS) {
837
- return access_token;
838
- }
839
-
840
- try {
841
- const response = await fetch('https://console.anthropic.com/v1/oauth/token', {
842
- method: 'POST',
843
- headers: { 'Content-Type': 'application/json' },
844
- body: JSON.stringify({
845
- grant_type: 'refresh_token',
846
- refresh_token,
847
- client_id: ANTHROPIC_OAUTH_CLIENT_ID,
848
- }),
849
- signal: AbortSignal.timeout(30000),
850
- });
851
-
852
- if (!response.ok) {
853
- throw new Error(`Failed to refresh token: ${response.statusText}`);
854
- }
855
-
856
- const data = (await response.json()) as {
857
- access_token: string;
858
- refresh_token: string;
859
- expires_in: number;
860
- };
861
-
862
- AuthManager.save({
863
- anthropic_claude: {
864
- access_token: data.access_token,
865
- refresh_token: data.refresh_token,
866
- expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
867
- },
868
- });
869
-
870
- return data.access_token;
871
- } catch (error) {
872
- AuthManager.logger.error(
873
- `Error refreshing Anthropic Claude token: ${AuthManager.sanitizeError(error)}`
874
- );
875
- return undefined;
876
- }
877
- }
878
125
  }