@link-assistant/agent 0.5.3 → 0.6.1
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/package.json +4 -3
- package/src/auth/claude-oauth.ts +50 -24
- package/src/auth/plugins.ts +245 -28
- package/src/bun/index.ts +33 -27
- package/src/bus/index.ts +3 -5
- package/src/config/config.ts +39 -22
- package/src/file/ripgrep.ts +1 -1
- package/src/file/time.ts +1 -1
- package/src/file/watcher.ts +10 -5
- package/src/format/index.ts +12 -10
- package/src/index.js +30 -35
- package/src/mcp/index.ts +32 -15
- package/src/patch/index.ts +8 -4
- package/src/project/project.ts +1 -1
- package/src/project/state.ts +15 -7
- package/src/provider/models.ts +4 -5
- package/src/provider/provider.ts +29 -21
- package/src/server/server.ts +4 -5
- package/src/session/agent.js +16 -2
- package/src/session/compaction.ts +4 -6
- package/src/session/index.ts +2 -2
- package/src/session/processor.ts +3 -7
- package/src/session/prompt.ts +78 -49
- package/src/session/revert.ts +1 -1
- package/src/session/summary.ts +2 -2
- package/src/snapshot/index.ts +27 -12
- package/src/storage/storage.ts +18 -18
- package/src/util/log-lazy.ts +291 -0
- package/src/util/log.ts +205 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -68,6 +68,8 @@
|
|
|
68
68
|
"@opentui/solid": "^0.1.46",
|
|
69
69
|
"@parcel/watcher": "^2.5.1",
|
|
70
70
|
"@solid-primitives/event-bus": "^1.1.2",
|
|
71
|
+
"@standard-community/standard-json": "^0.3.5",
|
|
72
|
+
"@standard-community/standard-openapi": "^0.2.9",
|
|
71
73
|
"@zip.js/zip.js": "^2.8.10",
|
|
72
74
|
"ai": "6.0.0-beta.99",
|
|
73
75
|
"chokidar": "^4.0.3",
|
|
@@ -83,9 +85,8 @@
|
|
|
83
85
|
"ignore": "^7.0.5",
|
|
84
86
|
"jsonc-parser": "^3.3.1",
|
|
85
87
|
"lino-objects-codec": "^0.1.1",
|
|
88
|
+
"log-lazy": "^1.0.4",
|
|
86
89
|
"minimatch": "^10.1.1",
|
|
87
|
-
"open": "^11.0.0",
|
|
88
|
-
"partial-json": "^0.1.7",
|
|
89
90
|
"remeda": "^2.32.0",
|
|
90
91
|
"solid-js": "^1.9.10",
|
|
91
92
|
"strip-ansi": "^7.1.2",
|
package/src/auth/claude-oauth.ts
CHANGED
|
@@ -147,9 +147,10 @@ export namespace ClaudeOAuth {
|
|
|
147
147
|
*/
|
|
148
148
|
export async function saveState(state: OAuthState): Promise<void> {
|
|
149
149
|
await Bun.write(statePath, JSON.stringify(state, null, 2));
|
|
150
|
-
log.info(
|
|
150
|
+
log.info(() => ({
|
|
151
|
+
message: 'saved oauth state',
|
|
151
152
|
expiresAt: new Date(state.expiresAt).toISOString(),
|
|
152
|
-
});
|
|
153
|
+
}));
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
/**
|
|
@@ -165,14 +166,14 @@ export namespace ClaudeOAuth {
|
|
|
165
166
|
const parsed = OAuthState.parse(content);
|
|
166
167
|
|
|
167
168
|
if (parsed.expiresAt < Date.now()) {
|
|
168
|
-
log.warn('oauth state expired');
|
|
169
|
+
log.warn(() => ({ message: 'oauth state expired' }));
|
|
169
170
|
await clearState();
|
|
170
171
|
return undefined;
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
return parsed;
|
|
174
175
|
} catch (error) {
|
|
175
|
-
log.error('failed to load oauth state',
|
|
176
|
+
log.error(() => ({ message: 'failed to load oauth state', error }));
|
|
176
177
|
return undefined;
|
|
177
178
|
}
|
|
178
179
|
}
|
|
@@ -190,7 +191,7 @@ export namespace ClaudeOAuth {
|
|
|
190
191
|
await fs.unlink(statePath).catch(() => {});
|
|
191
192
|
}
|
|
192
193
|
} catch (error) {
|
|
193
|
-
log.error('failed to clear oauth state',
|
|
194
|
+
log.error(() => ({ message: 'failed to clear oauth state', error }));
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
197
|
|
|
@@ -213,7 +214,9 @@ export namespace ClaudeOAuth {
|
|
|
213
214
|
code_verifier: codeVerifier,
|
|
214
215
|
});
|
|
215
216
|
|
|
216
|
-
log.info(
|
|
217
|
+
log.info(() => ({
|
|
218
|
+
message: 'exchanging authorization code for tokens',
|
|
219
|
+
}));
|
|
217
220
|
|
|
218
221
|
const response = await fetch(Config.tokenUrl, {
|
|
219
222
|
method: 'POST',
|
|
@@ -225,7 +228,11 @@ export namespace ClaudeOAuth {
|
|
|
225
228
|
|
|
226
229
|
if (!response.ok) {
|
|
227
230
|
const error = await response.text();
|
|
228
|
-
log.error(
|
|
231
|
+
log.error(() => ({
|
|
232
|
+
message: 'token exchange failed',
|
|
233
|
+
status: response.status,
|
|
234
|
+
error,
|
|
235
|
+
}));
|
|
229
236
|
throw new Error(`Token exchange failed: ${response.status} ${error}`);
|
|
230
237
|
}
|
|
231
238
|
|
|
@@ -267,9 +274,10 @@ export namespace ClaudeOAuth {
|
|
|
267
274
|
};
|
|
268
275
|
|
|
269
276
|
await Bun.write(credentialsPath, JSON.stringify(credentials, null, 2));
|
|
270
|
-
log.info(
|
|
277
|
+
log.info(() => ({
|
|
278
|
+
message: 'saved credentials',
|
|
271
279
|
expiresAt: new Date(credentials.claudeAiOauth!.expiresAt).toISOString(),
|
|
272
|
-
});
|
|
280
|
+
}));
|
|
273
281
|
}
|
|
274
282
|
|
|
275
283
|
/**
|
|
@@ -283,7 +291,10 @@ export namespace ClaudeOAuth {
|
|
|
283
291
|
try {
|
|
284
292
|
const file = Bun.file(credentialsPath);
|
|
285
293
|
if (!(await file.exists())) {
|
|
286
|
-
log.info(
|
|
294
|
+
log.info(() => ({
|
|
295
|
+
message: 'credentials file not found',
|
|
296
|
+
path: credentialsPath,
|
|
297
|
+
}));
|
|
287
298
|
return undefined;
|
|
288
299
|
}
|
|
289
300
|
|
|
@@ -291,27 +302,31 @@ export namespace ClaudeOAuth {
|
|
|
291
302
|
const parsed = Credentials.parse(content);
|
|
292
303
|
|
|
293
304
|
if (!parsed.claudeAiOauth) {
|
|
294
|
-
log.info(
|
|
305
|
+
log.info(() => ({
|
|
306
|
+
message: 'no claudeAiOauth credentials found',
|
|
307
|
+
}));
|
|
295
308
|
return undefined;
|
|
296
309
|
}
|
|
297
310
|
|
|
298
311
|
// Check if token is expired
|
|
299
312
|
if (parsed.claudeAiOauth.expiresAt < Date.now()) {
|
|
300
|
-
log.warn(
|
|
313
|
+
log.warn(() => ({
|
|
314
|
+
message: 'token expired',
|
|
301
315
|
expiresAt: new Date(parsed.claudeAiOauth.expiresAt).toISOString(),
|
|
302
|
-
});
|
|
316
|
+
}));
|
|
303
317
|
// TODO: Implement token refresh using refreshToken
|
|
304
318
|
// For now, user needs to re-authenticate
|
|
305
319
|
}
|
|
306
320
|
|
|
307
|
-
log.info(
|
|
321
|
+
log.info(() => ({
|
|
322
|
+
message: 'loaded oauth credentials',
|
|
308
323
|
subscriptionType: parsed.claudeAiOauth.subscriptionType,
|
|
309
324
|
scopes: parsed.claudeAiOauth.scopes,
|
|
310
|
-
});
|
|
325
|
+
}));
|
|
311
326
|
|
|
312
327
|
return parsed.claudeAiOauth;
|
|
313
328
|
} catch (error) {
|
|
314
|
-
log.error('failed to read credentials',
|
|
329
|
+
log.error(() => ({ message: 'failed to read credentials', error }));
|
|
315
330
|
return undefined;
|
|
316
331
|
}
|
|
317
332
|
}
|
|
@@ -344,7 +359,9 @@ export namespace ClaudeOAuth {
|
|
|
344
359
|
export async function completeAuth(code: string): Promise<boolean> {
|
|
345
360
|
const state = await loadState();
|
|
346
361
|
if (!state) {
|
|
347
|
-
log.error(
|
|
362
|
+
log.error(() => ({
|
|
363
|
+
message: 'no oauth state found - please start login flow first',
|
|
364
|
+
}));
|
|
348
365
|
return false;
|
|
349
366
|
}
|
|
350
367
|
|
|
@@ -352,10 +369,15 @@ export namespace ClaudeOAuth {
|
|
|
352
369
|
const tokens = await exchangeCode(code, state.codeVerifier);
|
|
353
370
|
await saveCredentials(tokens);
|
|
354
371
|
await clearState();
|
|
355
|
-
log.info(
|
|
372
|
+
log.info(() => ({
|
|
373
|
+
message: 'authentication completed successfully',
|
|
374
|
+
}));
|
|
356
375
|
return true;
|
|
357
376
|
} catch (error) {
|
|
358
|
-
log.error(
|
|
377
|
+
log.error(() => ({
|
|
378
|
+
message: 'failed to complete authentication',
|
|
379
|
+
error,
|
|
380
|
+
}));
|
|
359
381
|
await clearState();
|
|
360
382
|
return false;
|
|
361
383
|
}
|
|
@@ -367,7 +389,7 @@ export namespace ClaudeOAuth {
|
|
|
367
389
|
export async function refreshToken(): Promise<boolean> {
|
|
368
390
|
const creds = await getCredentials();
|
|
369
391
|
if (!creds?.refreshToken) {
|
|
370
|
-
log.error('no refresh token available');
|
|
392
|
+
log.error(() => ({ message: 'no refresh token available' }));
|
|
371
393
|
return false;
|
|
372
394
|
}
|
|
373
395
|
|
|
@@ -377,7 +399,7 @@ export namespace ClaudeOAuth {
|
|
|
377
399
|
refresh_token: creds.refreshToken,
|
|
378
400
|
});
|
|
379
401
|
|
|
380
|
-
log.info('refreshing access token');
|
|
402
|
+
log.info(() => ({ message: 'refreshing access token' }));
|
|
381
403
|
|
|
382
404
|
try {
|
|
383
405
|
const response = await fetch(Config.tokenUrl, {
|
|
@@ -390,16 +412,20 @@ export namespace ClaudeOAuth {
|
|
|
390
412
|
|
|
391
413
|
if (!response.ok) {
|
|
392
414
|
const error = await response.text();
|
|
393
|
-
log.error(
|
|
415
|
+
log.error(() => ({
|
|
416
|
+
message: 'token refresh failed',
|
|
417
|
+
status: response.status,
|
|
418
|
+
error,
|
|
419
|
+
}));
|
|
394
420
|
return false;
|
|
395
421
|
}
|
|
396
422
|
|
|
397
423
|
const tokens = TokenResponse.parse(await response.json());
|
|
398
424
|
await saveCredentials(tokens);
|
|
399
|
-
log.info('token refreshed successfully');
|
|
425
|
+
log.info(() => ({ message: 'token refreshed successfully' }));
|
|
400
426
|
return true;
|
|
401
427
|
} catch (error) {
|
|
402
|
-
log.error('failed to refresh token',
|
|
428
|
+
log.error(() => ({ message: 'failed to refresh token', error }));
|
|
403
429
|
return false;
|
|
404
430
|
}
|
|
405
431
|
}
|
package/src/auth/plugins.ts
CHANGED
|
@@ -162,9 +162,10 @@ const AnthropicPlugin: AuthPlugin = {
|
|
|
162
162
|
);
|
|
163
163
|
|
|
164
164
|
if (!result.ok) {
|
|
165
|
-
log.error(
|
|
165
|
+
log.error(() => ({
|
|
166
|
+
message: 'anthropic oauth token exchange failed',
|
|
166
167
|
status: result.status,
|
|
167
|
-
});
|
|
168
|
+
}));
|
|
168
169
|
return { type: 'failed' };
|
|
169
170
|
}
|
|
170
171
|
|
|
@@ -229,9 +230,10 @@ const AnthropicPlugin: AuthPlugin = {
|
|
|
229
230
|
);
|
|
230
231
|
|
|
231
232
|
if (!tokenResult.ok) {
|
|
232
|
-
log.error(
|
|
233
|
+
log.error(() => ({
|
|
234
|
+
message: 'anthropic oauth token exchange failed',
|
|
233
235
|
status: tokenResult.status,
|
|
234
|
-
});
|
|
236
|
+
}));
|
|
235
237
|
return { type: 'failed' };
|
|
236
238
|
}
|
|
237
239
|
|
|
@@ -286,7 +288,9 @@ const AnthropicPlugin: AuthPlugin = {
|
|
|
286
288
|
|
|
287
289
|
// Refresh token if expired
|
|
288
290
|
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
|
289
|
-
log.info(
|
|
291
|
+
log.info(() => ({
|
|
292
|
+
message: 'refreshing anthropic oauth token',
|
|
293
|
+
}));
|
|
290
294
|
const response = await fetch(
|
|
291
295
|
'https://console.anthropic.com/v1/oauth/token',
|
|
292
296
|
{
|
|
@@ -566,7 +570,7 @@ const GitHubCopilotPlugin: AuthPlugin = {
|
|
|
566
570
|
: 'github.com';
|
|
567
571
|
const urls = getCopilotUrls(domain);
|
|
568
572
|
|
|
569
|
-
log.info('refreshing github copilot token');
|
|
573
|
+
log.info(() => ({ message: 'refreshing github copilot token' }));
|
|
570
574
|
const response = await fetch(urls.COPILOT_API_KEY_URL, {
|
|
571
575
|
headers: {
|
|
572
576
|
Accept: 'application/json',
|
|
@@ -720,7 +724,9 @@ const OpenAIPlugin: AuthPlugin = {
|
|
|
720
724
|
}
|
|
721
725
|
|
|
722
726
|
if (!code) {
|
|
723
|
-
log.error(
|
|
727
|
+
log.error(() => ({
|
|
728
|
+
message: 'openai oauth no code provided',
|
|
729
|
+
}));
|
|
724
730
|
return { type: 'failed' };
|
|
725
731
|
}
|
|
726
732
|
|
|
@@ -740,9 +746,10 @@ const OpenAIPlugin: AuthPlugin = {
|
|
|
740
746
|
});
|
|
741
747
|
|
|
742
748
|
if (!tokenResult.ok) {
|
|
743
|
-
log.error(
|
|
749
|
+
log.error(() => ({
|
|
750
|
+
message: 'openai oauth token exchange failed',
|
|
744
751
|
status: tokenResult.status,
|
|
745
|
-
});
|
|
752
|
+
}));
|
|
746
753
|
return { type: 'failed' };
|
|
747
754
|
}
|
|
748
755
|
|
|
@@ -752,7 +759,9 @@ const OpenAIPlugin: AuthPlugin = {
|
|
|
752
759
|
!json.refresh_token ||
|
|
753
760
|
typeof json.expires_in !== 'number'
|
|
754
761
|
) {
|
|
755
|
-
log.error(
|
|
762
|
+
log.error(() => ({
|
|
763
|
+
message: 'openai oauth token response missing fields',
|
|
764
|
+
}));
|
|
756
765
|
return { type: 'failed' };
|
|
757
766
|
}
|
|
758
767
|
|
|
@@ -787,7 +796,7 @@ const OpenAIPlugin: AuthPlugin = {
|
|
|
787
796
|
|
|
788
797
|
// Refresh token if expired
|
|
789
798
|
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
|
790
|
-
log.info('refreshing openai oauth token');
|
|
799
|
+
log.info(() => ({ message: 'refreshing openai oauth token' }));
|
|
791
800
|
const response = await fetch(OPENAI_TOKEN_URL, {
|
|
792
801
|
method: 'POST',
|
|
793
802
|
headers: {
|
|
@@ -858,30 +867,125 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
|
858
867
|
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
859
868
|
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
|
|
860
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Get an available port for the OAuth callback server.
|
|
872
|
+
* Supports configurable port via OAUTH_CALLBACK_PORT or GOOGLE_OAUTH_CALLBACK_PORT
|
|
873
|
+
* environment variable. Falls back to automatic port discovery (port 0) if not configured.
|
|
874
|
+
*
|
|
875
|
+
* Based on Gemini CLI implementation:
|
|
876
|
+
* https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
|
|
877
|
+
*/
|
|
878
|
+
async function getGoogleOAuthPort(): Promise<number> {
|
|
879
|
+
// Check for environment variable override (useful for containers/firewalls)
|
|
880
|
+
// Support both OAUTH_CALLBACK_PORT (Gemini CLI style) and GOOGLE_OAUTH_CALLBACK_PORT
|
|
881
|
+
const portStr =
|
|
882
|
+
process.env['OAUTH_CALLBACK_PORT'] ||
|
|
883
|
+
process.env['GOOGLE_OAUTH_CALLBACK_PORT'];
|
|
884
|
+
if (portStr) {
|
|
885
|
+
const port = parseInt(portStr, 10);
|
|
886
|
+
if (!isNaN(port) && port > 0 && port <= 65535) {
|
|
887
|
+
log.info(() => ({
|
|
888
|
+
message: 'using configured oauth callback port',
|
|
889
|
+
port,
|
|
890
|
+
}));
|
|
891
|
+
return port;
|
|
892
|
+
}
|
|
893
|
+
log.warn(() => ({
|
|
894
|
+
message: 'invalid OAUTH_CALLBACK_PORT, using auto discovery',
|
|
895
|
+
value: portStr,
|
|
896
|
+
}));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Discover an available port by binding to port 0
|
|
900
|
+
return new Promise((resolve, reject) => {
|
|
901
|
+
const server = net.createServer();
|
|
902
|
+
server.listen(0, () => {
|
|
903
|
+
const address = server.address() as net.AddressInfo;
|
|
904
|
+
const port = address.port;
|
|
905
|
+
server.close(() => resolve(port));
|
|
906
|
+
});
|
|
907
|
+
server.on('error', reject);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Check if browser launch should be suppressed.
|
|
913
|
+
* When NO_BROWSER=true, use manual code entry flow instead of localhost redirect.
|
|
914
|
+
*
|
|
915
|
+
* Based on Gemini CLI's config.isBrowserLaunchSuppressed() functionality.
|
|
916
|
+
*/
|
|
917
|
+
function isBrowserSuppressed(): boolean {
|
|
918
|
+
const noBrowser = process.env['NO_BROWSER'];
|
|
919
|
+
return noBrowser === 'true' || noBrowser === '1';
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Get the OAuth callback host for server binding.
|
|
924
|
+
* Defaults to 'localhost' but can be configured via OAUTH_CALLBACK_HOST.
|
|
925
|
+
* Use '0.0.0.0' in Docker containers to allow external connections.
|
|
926
|
+
*/
|
|
927
|
+
function getOAuthCallbackHost(): string {
|
|
928
|
+
return process.env['OAUTH_CALLBACK_HOST'] || 'localhost';
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Google Code Assist redirect URI for manual code entry flow
|
|
933
|
+
* This is used when NO_BROWSER=true or in headless environments
|
|
934
|
+
* Based on Gemini CLI implementation
|
|
935
|
+
*/
|
|
936
|
+
const GOOGLE_CODEASSIST_REDIRECT_URI = 'https://codeassist.google.com/authcode';
|
|
937
|
+
|
|
861
938
|
/**
|
|
862
939
|
* Google OAuth Plugin
|
|
863
940
|
* Supports:
|
|
864
|
-
* - Google AI Pro/Ultra OAuth login
|
|
941
|
+
* - Google AI Pro/Ultra OAuth login (browser mode with localhost redirect)
|
|
942
|
+
* - Google AI Pro/Ultra OAuth login (manual code entry for NO_BROWSER mode)
|
|
865
943
|
* - Manual API key entry
|
|
866
944
|
*
|
|
867
945
|
* Note: This plugin uses OAuth 2.0 with PKCE for Google AI subscription authentication.
|
|
868
946
|
* After authenticating, you can use Gemini models with subscription benefits.
|
|
947
|
+
*
|
|
948
|
+
* The OAuth flow supports two modes:
|
|
949
|
+
* 1. Browser mode (default): Opens browser, uses localhost redirect server
|
|
950
|
+
* 2. Manual code entry (NO_BROWSER=true): Shows URL, user pastes authorization code
|
|
951
|
+
*
|
|
952
|
+
* Based on Gemini CLI implementation:
|
|
953
|
+
* https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
|
|
869
954
|
*/
|
|
870
955
|
const GooglePlugin: AuthPlugin = {
|
|
871
956
|
provider: 'google',
|
|
872
957
|
methods: [
|
|
873
958
|
{
|
|
874
|
-
label: 'Google AI Pro/Ultra (OAuth)',
|
|
959
|
+
label: 'Google AI Pro/Ultra (OAuth - Browser)',
|
|
875
960
|
type: 'oauth',
|
|
876
961
|
async authorize() {
|
|
962
|
+
// Check if browser is suppressed - if so, recommend manual method
|
|
963
|
+
if (isBrowserSuppressed()) {
|
|
964
|
+
log.info(() => ({
|
|
965
|
+
message: 'NO_BROWSER is set, use manual code entry method instead',
|
|
966
|
+
}));
|
|
967
|
+
}
|
|
968
|
+
|
|
877
969
|
const pkce = await generatePKCE();
|
|
878
970
|
const state = generateRandomString(16);
|
|
879
971
|
|
|
880
|
-
//
|
|
972
|
+
// Get an available port BEFORE starting the server
|
|
973
|
+
// This fixes the race condition where port was 0 when building redirect URI
|
|
974
|
+
const serverPort = await getGoogleOAuthPort();
|
|
975
|
+
const host = getOAuthCallbackHost();
|
|
976
|
+
// The redirect URI sent to Google must use localhost (loopback IP)
|
|
977
|
+
// even if we bind to a different host (like 0.0.0.0 in Docker)
|
|
978
|
+
const redirectUri = `http://localhost:${serverPort}/oauth/callback`;
|
|
979
|
+
|
|
980
|
+
log.info(() => ({
|
|
981
|
+
message: 'starting google oauth server',
|
|
982
|
+
port: serverPort,
|
|
983
|
+
host,
|
|
984
|
+
redirectUri,
|
|
985
|
+
}));
|
|
986
|
+
|
|
987
|
+
// Create server to handle OAuth redirect
|
|
881
988
|
const server = http.createServer();
|
|
882
|
-
let serverPort = 0;
|
|
883
|
-
let authCode: string | null = null;
|
|
884
|
-
let authState: string | null = null;
|
|
885
989
|
|
|
886
990
|
const authPromise = new Promise<{ code: string; state: string }>(
|
|
887
991
|
(resolve, reject) => {
|
|
@@ -935,12 +1039,22 @@ const GooglePlugin: AuthPlugin = {
|
|
|
935
1039
|
res.end('Missing code or state parameter');
|
|
936
1040
|
});
|
|
937
1041
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1042
|
+
// Listen on the configured host and pre-determined port
|
|
1043
|
+
server.listen(serverPort, host, () => {
|
|
1044
|
+
log.info(() => ({
|
|
1045
|
+
message: 'google oauth server listening',
|
|
1046
|
+
port: serverPort,
|
|
1047
|
+
host,
|
|
1048
|
+
}));
|
|
941
1049
|
});
|
|
942
1050
|
|
|
943
|
-
server.on('error',
|
|
1051
|
+
server.on('error', (err) => {
|
|
1052
|
+
log.error(() => ({
|
|
1053
|
+
message: 'google oauth server error',
|
|
1054
|
+
error: err,
|
|
1055
|
+
}));
|
|
1056
|
+
reject(err);
|
|
1057
|
+
});
|
|
944
1058
|
|
|
945
1059
|
// Timeout after 5 minutes
|
|
946
1060
|
setTimeout(
|
|
@@ -953,8 +1067,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
953
1067
|
}
|
|
954
1068
|
);
|
|
955
1069
|
|
|
956
|
-
// Build authorization URL with
|
|
957
|
-
const redirectUri = `http://localhost:${serverPort}/oauth/callback`;
|
|
1070
|
+
// Build authorization URL with the redirect URI
|
|
958
1071
|
const url = new URL(GOOGLE_AUTH_URL);
|
|
959
1072
|
url.searchParams.set('client_id', GOOGLE_OAUTH_CLIENT_ID);
|
|
960
1073
|
url.searchParams.set('redirect_uri', redirectUri);
|
|
@@ -992,9 +1105,108 @@ const GooglePlugin: AuthPlugin = {
|
|
|
992
1105
|
});
|
|
993
1106
|
|
|
994
1107
|
if (!tokenResult.ok) {
|
|
995
|
-
log.error(
|
|
1108
|
+
log.error(() => ({
|
|
1109
|
+
message: 'google oauth token exchange failed',
|
|
1110
|
+
status: tokenResult.status,
|
|
1111
|
+
}));
|
|
1112
|
+
return { type: 'failed' };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const json = await tokenResult.json();
|
|
1116
|
+
if (
|
|
1117
|
+
!json.access_token ||
|
|
1118
|
+
!json.refresh_token ||
|
|
1119
|
+
typeof json.expires_in !== 'number'
|
|
1120
|
+
) {
|
|
1121
|
+
log.error(() => ({
|
|
1122
|
+
message: 'google oauth token response missing fields',
|
|
1123
|
+
}));
|
|
1124
|
+
return { type: 'failed' };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return {
|
|
1128
|
+
type: 'success',
|
|
1129
|
+
refresh: json.refresh_token,
|
|
1130
|
+
access: json.access_token,
|
|
1131
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
1132
|
+
};
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
log.error(() => ({ message: 'google oauth failed', error }));
|
|
1135
|
+
return { type: 'failed' };
|
|
1136
|
+
}
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
label: 'Google AI Pro/Ultra (OAuth - Manual Code Entry)',
|
|
1143
|
+
type: 'oauth',
|
|
1144
|
+
async authorize() {
|
|
1145
|
+
/**
|
|
1146
|
+
* Manual code entry flow for headless environments or when NO_BROWSER=true
|
|
1147
|
+
* Uses Google's Code Assist redirect URI which displays the auth code to the user
|
|
1148
|
+
*
|
|
1149
|
+
* Based on Gemini CLI's authWithUserCode function:
|
|
1150
|
+
* https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
|
|
1151
|
+
*/
|
|
1152
|
+
const pkce = await generatePKCE();
|
|
1153
|
+
const state = generateRandomString(16);
|
|
1154
|
+
const redirectUri = GOOGLE_CODEASSIST_REDIRECT_URI;
|
|
1155
|
+
|
|
1156
|
+
log.info(() => ({
|
|
1157
|
+
message: 'using manual code entry oauth flow',
|
|
1158
|
+
redirectUri,
|
|
1159
|
+
}));
|
|
1160
|
+
|
|
1161
|
+
// Build authorization URL with the Code Assist redirect URI
|
|
1162
|
+
const url = new URL(GOOGLE_AUTH_URL);
|
|
1163
|
+
url.searchParams.set('client_id', GOOGLE_OAUTH_CLIENT_ID);
|
|
1164
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
1165
|
+
url.searchParams.set('response_type', 'code');
|
|
1166
|
+
url.searchParams.set('scope', GOOGLE_OAUTH_SCOPES.join(' '));
|
|
1167
|
+
url.searchParams.set('access_type', 'offline');
|
|
1168
|
+
url.searchParams.set('code_challenge', pkce.challenge);
|
|
1169
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
1170
|
+
url.searchParams.set('state', state);
|
|
1171
|
+
url.searchParams.set('prompt', 'consent');
|
|
1172
|
+
|
|
1173
|
+
return {
|
|
1174
|
+
url: url.toString(),
|
|
1175
|
+
instructions:
|
|
1176
|
+
'Visit the URL above, complete authorization, then paste the authorization code here: ',
|
|
1177
|
+
method: 'code' as const,
|
|
1178
|
+
async callback(code?: string): Promise<AuthResult> {
|
|
1179
|
+
if (!code) {
|
|
1180
|
+
log.error(() => ({
|
|
1181
|
+
message: 'google oauth no code provided',
|
|
1182
|
+
}));
|
|
1183
|
+
return { type: 'failed' };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
try {
|
|
1187
|
+
// Exchange authorization code for tokens
|
|
1188
|
+
const tokenResult = await fetch(GOOGLE_TOKEN_URL, {
|
|
1189
|
+
method: 'POST',
|
|
1190
|
+
headers: {
|
|
1191
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1192
|
+
},
|
|
1193
|
+
body: new URLSearchParams({
|
|
1194
|
+
code: code.trim(),
|
|
1195
|
+
client_id: GOOGLE_OAUTH_CLIENT_ID,
|
|
1196
|
+
client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
|
|
1197
|
+
redirect_uri: redirectUri,
|
|
1198
|
+
grant_type: 'authorization_code',
|
|
1199
|
+
code_verifier: pkce.verifier,
|
|
1200
|
+
}),
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
if (!tokenResult.ok) {
|
|
1204
|
+
const errorText = await tokenResult.text();
|
|
1205
|
+
log.error(() => ({
|
|
1206
|
+
message: 'google oauth token exchange failed',
|
|
996
1207
|
status: tokenResult.status,
|
|
997
|
-
|
|
1208
|
+
error: errorText,
|
|
1209
|
+
}));
|
|
998
1210
|
return { type: 'failed' };
|
|
999
1211
|
}
|
|
1000
1212
|
|
|
@@ -1004,7 +1216,9 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1004
1216
|
!json.refresh_token ||
|
|
1005
1217
|
typeof json.expires_in !== 'number'
|
|
1006
1218
|
) {
|
|
1007
|
-
log.error(
|
|
1219
|
+
log.error(() => ({
|
|
1220
|
+
message: 'google oauth token response missing fields',
|
|
1221
|
+
}));
|
|
1008
1222
|
return { type: 'failed' };
|
|
1009
1223
|
}
|
|
1010
1224
|
|
|
@@ -1015,7 +1229,10 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1015
1229
|
expires: Date.now() + json.expires_in * 1000,
|
|
1016
1230
|
};
|
|
1017
1231
|
} catch (error) {
|
|
1018
|
-
log.error(
|
|
1232
|
+
log.error(() => ({
|
|
1233
|
+
message: 'google oauth manual code entry failed',
|
|
1234
|
+
error,
|
|
1235
|
+
}));
|
|
1019
1236
|
return { type: 'failed' };
|
|
1020
1237
|
}
|
|
1021
1238
|
},
|
|
@@ -1058,7 +1275,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1058
1275
|
!currentAuth.access ||
|
|
1059
1276
|
currentAuth.expires < Date.now() + FIVE_MIN_MS
|
|
1060
1277
|
) {
|
|
1061
|
-
log.info('refreshing google oauth token');
|
|
1278
|
+
log.info(() => ({ message: 'refreshing google oauth token' }));
|
|
1062
1279
|
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
1063
1280
|
method: 'POST',
|
|
1064
1281
|
headers: {
|