@link-assistant/agent 0.6.0 → 0.6.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/auth/plugins.ts +217 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",
@@ -858,8 +858,6 @@ const GOOGLE_OAUTH_SCOPES = [
858
858
  'https://www.googleapis.com/auth/cloud-platform',
859
859
  'https://www.googleapis.com/auth/userinfo.email',
860
860
  'https://www.googleapis.com/auth/userinfo.profile',
861
- 'https://www.googleapis.com/auth/generative-language.tuning',
862
- 'https://www.googleapis.com/auth/generative-language.retriever',
863
861
  ];
864
862
 
865
863
  // Google OAuth endpoints
@@ -867,30 +865,125 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
867
865
  const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
868
866
  const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
869
867
 
868
+ /**
869
+ * Get an available port for the OAuth callback server.
870
+ * Supports configurable port via OAUTH_CALLBACK_PORT or GOOGLE_OAUTH_CALLBACK_PORT
871
+ * environment variable. Falls back to automatic port discovery (port 0) if not configured.
872
+ *
873
+ * Based on Gemini CLI implementation:
874
+ * https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
875
+ */
876
+ async function getGoogleOAuthPort(): Promise<number> {
877
+ // Check for environment variable override (useful for containers/firewalls)
878
+ // Support both OAUTH_CALLBACK_PORT (Gemini CLI style) and GOOGLE_OAUTH_CALLBACK_PORT
879
+ const portStr =
880
+ process.env['OAUTH_CALLBACK_PORT'] ||
881
+ process.env['GOOGLE_OAUTH_CALLBACK_PORT'];
882
+ if (portStr) {
883
+ const port = parseInt(portStr, 10);
884
+ if (!isNaN(port) && port > 0 && port <= 65535) {
885
+ log.info(() => ({
886
+ message: 'using configured oauth callback port',
887
+ port,
888
+ }));
889
+ return port;
890
+ }
891
+ log.warn(() => ({
892
+ message: 'invalid OAUTH_CALLBACK_PORT, using auto discovery',
893
+ value: portStr,
894
+ }));
895
+ }
896
+
897
+ // Discover an available port by binding to port 0
898
+ return new Promise((resolve, reject) => {
899
+ const server = net.createServer();
900
+ server.listen(0, () => {
901
+ const address = server.address() as net.AddressInfo;
902
+ const port = address.port;
903
+ server.close(() => resolve(port));
904
+ });
905
+ server.on('error', reject);
906
+ });
907
+ }
908
+
909
+ /**
910
+ * Check if browser launch should be suppressed.
911
+ * When NO_BROWSER=true, use manual code entry flow instead of localhost redirect.
912
+ *
913
+ * Based on Gemini CLI's config.isBrowserLaunchSuppressed() functionality.
914
+ */
915
+ function isBrowserSuppressed(): boolean {
916
+ const noBrowser = process.env['NO_BROWSER'];
917
+ return noBrowser === 'true' || noBrowser === '1';
918
+ }
919
+
920
+ /**
921
+ * Get the OAuth callback host for server binding.
922
+ * Defaults to 'localhost' but can be configured via OAUTH_CALLBACK_HOST.
923
+ * Use '0.0.0.0' in Docker containers to allow external connections.
924
+ */
925
+ function getOAuthCallbackHost(): string {
926
+ return process.env['OAUTH_CALLBACK_HOST'] || 'localhost';
927
+ }
928
+
929
+ /**
930
+ * Google Code Assist redirect URI for manual code entry flow
931
+ * This is used when NO_BROWSER=true or in headless environments
932
+ * Based on Gemini CLI implementation
933
+ */
934
+ const GOOGLE_CODEASSIST_REDIRECT_URI = 'https://codeassist.google.com/authcode';
935
+
870
936
  /**
871
937
  * Google OAuth Plugin
872
938
  * Supports:
873
- * - Google AI Pro/Ultra OAuth login
939
+ * - Google AI Pro/Ultra OAuth login (browser mode with localhost redirect)
940
+ * - Google AI Pro/Ultra OAuth login (manual code entry for NO_BROWSER mode)
874
941
  * - Manual API key entry
875
942
  *
876
943
  * Note: This plugin uses OAuth 2.0 with PKCE for Google AI subscription authentication.
877
944
  * After authenticating, you can use Gemini models with subscription benefits.
945
+ *
946
+ * The OAuth flow supports two modes:
947
+ * 1. Browser mode (default): Opens browser, uses localhost redirect server
948
+ * 2. Manual code entry (NO_BROWSER=true): Shows URL, user pastes authorization code
949
+ *
950
+ * Based on Gemini CLI implementation:
951
+ * https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
878
952
  */
879
953
  const GooglePlugin: AuthPlugin = {
880
954
  provider: 'google',
881
955
  methods: [
882
956
  {
883
- label: 'Google AI Pro/Ultra (OAuth)',
957
+ label: 'Google AI Pro/Ultra (OAuth - Browser)',
884
958
  type: 'oauth',
885
959
  async authorize() {
960
+ // Check if browser is suppressed - if so, recommend manual method
961
+ if (isBrowserSuppressed()) {
962
+ log.info(() => ({
963
+ message: 'NO_BROWSER is set, use manual code entry method instead',
964
+ }));
965
+ }
966
+
886
967
  const pkce = await generatePKCE();
887
968
  const state = generateRandomString(16);
888
969
 
889
- // Start local server to handle OAuth redirect
970
+ // Get an available port BEFORE starting the server
971
+ // This fixes the race condition where port was 0 when building redirect URI
972
+ const serverPort = await getGoogleOAuthPort();
973
+ const host = getOAuthCallbackHost();
974
+ // The redirect URI sent to Google must use localhost (loopback IP)
975
+ // even if we bind to a different host (like 0.0.0.0 in Docker)
976
+ const redirectUri = `http://localhost:${serverPort}/oauth/callback`;
977
+
978
+ log.info(() => ({
979
+ message: 'starting google oauth server',
980
+ port: serverPort,
981
+ host,
982
+ redirectUri,
983
+ }));
984
+
985
+ // Create server to handle OAuth redirect
890
986
  const server = http.createServer();
891
- let serverPort = 0;
892
- let authCode: string | null = null;
893
- let authState: string | null = null;
894
987
 
895
988
  const authPromise = new Promise<{ code: string; state: string }>(
896
989
  (resolve, reject) => {
@@ -944,12 +1037,22 @@ const GooglePlugin: AuthPlugin = {
944
1037
  res.end('Missing code or state parameter');
945
1038
  });
946
1039
 
947
- server.listen(0, () => {
948
- const address = server.address() as net.AddressInfo;
949
- serverPort = address.port;
1040
+ // Listen on the configured host and pre-determined port
1041
+ server.listen(serverPort, host, () => {
1042
+ log.info(() => ({
1043
+ message: 'google oauth server listening',
1044
+ port: serverPort,
1045
+ host,
1046
+ }));
950
1047
  });
951
1048
 
952
- server.on('error', reject);
1049
+ server.on('error', (err) => {
1050
+ log.error(() => ({
1051
+ message: 'google oauth server error',
1052
+ error: err,
1053
+ }));
1054
+ reject(err);
1055
+ });
953
1056
 
954
1057
  // Timeout after 5 minutes
955
1058
  setTimeout(
@@ -962,8 +1065,7 @@ const GooglePlugin: AuthPlugin = {
962
1065
  }
963
1066
  );
964
1067
 
965
- // Build authorization URL with local redirect URI
966
- const redirectUri = `http://localhost:${serverPort}/oauth/callback`;
1068
+ // Build authorization URL with the redirect URI
967
1069
  const url = new URL(GOOGLE_AUTH_URL);
968
1070
  url.searchParams.set('client_id', GOOGLE_OAUTH_CLIENT_ID);
969
1071
  url.searchParams.set('redirect_uri', redirectUri);
@@ -1034,6 +1136,107 @@ const GooglePlugin: AuthPlugin = {
1034
1136
  };
1035
1137
  },
1036
1138
  },
1139
+ {
1140
+ label: 'Google AI Pro/Ultra (OAuth - Manual Code Entry)',
1141
+ type: 'oauth',
1142
+ async authorize() {
1143
+ /**
1144
+ * Manual code entry flow for headless environments or when NO_BROWSER=true
1145
+ * Uses Google's Code Assist redirect URI which displays the auth code to the user
1146
+ *
1147
+ * Based on Gemini CLI's authWithUserCode function:
1148
+ * https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
1149
+ */
1150
+ const pkce = await generatePKCE();
1151
+ const state = generateRandomString(16);
1152
+ const redirectUri = GOOGLE_CODEASSIST_REDIRECT_URI;
1153
+
1154
+ log.info(() => ({
1155
+ message: 'using manual code entry oauth flow',
1156
+ redirectUri,
1157
+ }));
1158
+
1159
+ // Build authorization URL with the Code Assist redirect URI
1160
+ const url = new URL(GOOGLE_AUTH_URL);
1161
+ url.searchParams.set('client_id', GOOGLE_OAUTH_CLIENT_ID);
1162
+ url.searchParams.set('redirect_uri', redirectUri);
1163
+ url.searchParams.set('response_type', 'code');
1164
+ url.searchParams.set('scope', GOOGLE_OAUTH_SCOPES.join(' '));
1165
+ url.searchParams.set('access_type', 'offline');
1166
+ url.searchParams.set('code_challenge', pkce.challenge);
1167
+ url.searchParams.set('code_challenge_method', 'S256');
1168
+ url.searchParams.set('state', state);
1169
+ url.searchParams.set('prompt', 'consent');
1170
+
1171
+ return {
1172
+ url: url.toString(),
1173
+ instructions:
1174
+ 'Visit the URL above, complete authorization, then paste the authorization code here: ',
1175
+ method: 'code' as const,
1176
+ async callback(code?: string): Promise<AuthResult> {
1177
+ if (!code) {
1178
+ log.error(() => ({
1179
+ message: 'google oauth no code provided',
1180
+ }));
1181
+ return { type: 'failed' };
1182
+ }
1183
+
1184
+ try {
1185
+ // Exchange authorization code for tokens
1186
+ const tokenResult = await fetch(GOOGLE_TOKEN_URL, {
1187
+ method: 'POST',
1188
+ headers: {
1189
+ 'Content-Type': 'application/x-www-form-urlencoded',
1190
+ },
1191
+ body: new URLSearchParams({
1192
+ code: code.trim(),
1193
+ client_id: GOOGLE_OAUTH_CLIENT_ID,
1194
+ client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
1195
+ redirect_uri: redirectUri,
1196
+ grant_type: 'authorization_code',
1197
+ code_verifier: pkce.verifier,
1198
+ }),
1199
+ });
1200
+
1201
+ if (!tokenResult.ok) {
1202
+ const errorText = await tokenResult.text();
1203
+ log.error(() => ({
1204
+ message: 'google oauth token exchange failed',
1205
+ status: tokenResult.status,
1206
+ error: errorText,
1207
+ }));
1208
+ return { type: 'failed' };
1209
+ }
1210
+
1211
+ const json = await tokenResult.json();
1212
+ if (
1213
+ !json.access_token ||
1214
+ !json.refresh_token ||
1215
+ typeof json.expires_in !== 'number'
1216
+ ) {
1217
+ log.error(() => ({
1218
+ message: 'google oauth token response missing fields',
1219
+ }));
1220
+ return { type: 'failed' };
1221
+ }
1222
+
1223
+ return {
1224
+ type: 'success',
1225
+ refresh: json.refresh_token,
1226
+ access: json.access_token,
1227
+ expires: Date.now() + json.expires_in * 1000,
1228
+ };
1229
+ } catch (error) {
1230
+ log.error(() => ({
1231
+ message: 'google oauth manual code entry failed',
1232
+ error,
1233
+ }));
1234
+ return { type: 'failed' };
1235
+ }
1236
+ },
1237
+ };
1238
+ },
1239
+ },
1037
1240
  {
1038
1241
  label: 'Manually enter API Key',
1039
1242
  type: 'api',