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.
- package/README.md +114 -140
- package/package.json +6 -3
- package/src/cli.ts +54 -369
- package/src/commands/init.ts +15 -29
- package/src/db/memory-db.test.ts +45 -0
- package/src/db/memory-db.ts +47 -21
- package/src/db/sqlite-setup.ts +26 -3
- package/src/db/workflow-db.ts +12 -5
- package/src/parser/config-schema.ts +11 -13
- package/src/parser/schema.ts +4 -2
- package/src/runner/__test__/llm-mock-setup.ts +173 -0
- package/src/runner/__test__/llm-test-setup.ts +271 -0
- package/src/runner/engine-executor.test.ts +25 -18
- package/src/runner/executors/blueprint-executor.ts +0 -1
- package/src/runner/executors/dynamic-executor.ts +11 -6
- package/src/runner/executors/engine-executor.ts +5 -1
- package/src/runner/executors/llm-executor.ts +502 -1033
- package/src/runner/executors/memory-executor.ts +35 -19
- package/src/runner/executors/plan-executor.ts +0 -1
- package/src/runner/executors/types.ts +4 -4
- package/src/runner/llm-adapter.integration.test.ts +151 -0
- package/src/runner/llm-adapter.ts +263 -1401
- package/src/runner/llm-clarification.test.ts +91 -106
- package/src/runner/llm-executor.test.ts +217 -1181
- package/src/runner/memoization.test.ts +0 -1
- package/src/runner/recovery-security.test.ts +51 -20
- package/src/runner/reflexion.test.ts +55 -18
- package/src/runner/standard-tools-integration.test.ts +137 -87
- package/src/runner/step-executor.test.ts +36 -80
- package/src/runner/step-executor.ts +0 -2
- package/src/runner/test-harness.ts +3 -29
- package/src/runner/tool-integration.test.ts +122 -73
- package/src/runner/workflow-runner.ts +92 -35
- package/src/runner/workflow-scheduler.ts +11 -1
- package/src/runner/workflow-summary.ts +144 -0
- package/src/utils/auth-manager.test.ts +10 -520
- package/src/utils/auth-manager.ts +3 -756
- package/src/utils/config-loader.ts +12 -0
- package/src/utils/constants.ts +0 -17
- package/src/utils/process-sandbox.ts +15 -3
- package/src/runner/llm-adapter-runtime.test.ts +0 -209
- package/src/runner/llm-adapter.test.ts +0 -1012
|
@@ -1,28 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|