@link-assistant/agent 0.6.0 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/auth/plugins.ts +217 -12
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.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",
@@ -867,30 +867,125 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
867
867
  const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
868
868
  const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
869
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
+
870
938
  /**
871
939
  * Google OAuth Plugin
872
940
  * Supports:
873
- * - 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)
874
943
  * - Manual API key entry
875
944
  *
876
945
  * Note: This plugin uses OAuth 2.0 with PKCE for Google AI subscription authentication.
877
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
878
954
  */
879
955
  const GooglePlugin: AuthPlugin = {
880
956
  provider: 'google',
881
957
  methods: [
882
958
  {
883
- label: 'Google AI Pro/Ultra (OAuth)',
959
+ label: 'Google AI Pro/Ultra (OAuth - Browser)',
884
960
  type: 'oauth',
885
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
+
886
969
  const pkce = await generatePKCE();
887
970
  const state = generateRandomString(16);
888
971
 
889
- // Start local server to handle OAuth redirect
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
890
988
  const server = http.createServer();
891
- let serverPort = 0;
892
- let authCode: string | null = null;
893
- let authState: string | null = null;
894
989
 
895
990
  const authPromise = new Promise<{ code: string; state: string }>(
896
991
  (resolve, reject) => {
@@ -944,12 +1039,22 @@ const GooglePlugin: AuthPlugin = {
944
1039
  res.end('Missing code or state parameter');
945
1040
  });
946
1041
 
947
- server.listen(0, () => {
948
- const address = server.address() as net.AddressInfo;
949
- serverPort = address.port;
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
+ }));
950
1049
  });
951
1050
 
952
- server.on('error', reject);
1051
+ server.on('error', (err) => {
1052
+ log.error(() => ({
1053
+ message: 'google oauth server error',
1054
+ error: err,
1055
+ }));
1056
+ reject(err);
1057
+ });
953
1058
 
954
1059
  // Timeout after 5 minutes
955
1060
  setTimeout(
@@ -962,8 +1067,7 @@ const GooglePlugin: AuthPlugin = {
962
1067
  }
963
1068
  );
964
1069
 
965
- // Build authorization URL with local redirect URI
966
- const redirectUri = `http://localhost:${serverPort}/oauth/callback`;
1070
+ // Build authorization URL with the redirect URI
967
1071
  const url = new URL(GOOGLE_AUTH_URL);
968
1072
  url.searchParams.set('client_id', GOOGLE_OAUTH_CLIENT_ID);
969
1073
  url.searchParams.set('redirect_uri', redirectUri);
@@ -1034,6 +1138,107 @@ const GooglePlugin: AuthPlugin = {
1034
1138
  };
1035
1139
  },
1036
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',
1207
+ status: tokenResult.status,
1208
+ error: errorText,
1209
+ }));
1210
+ return { type: 'failed' };
1211
+ }
1212
+
1213
+ const json = await tokenResult.json();
1214
+ if (
1215
+ !json.access_token ||
1216
+ !json.refresh_token ||
1217
+ typeof json.expires_in !== 'number'
1218
+ ) {
1219
+ log.error(() => ({
1220
+ message: 'google oauth token response missing fields',
1221
+ }));
1222
+ return { type: 'failed' };
1223
+ }
1224
+
1225
+ return {
1226
+ type: 'success',
1227
+ refresh: json.refresh_token,
1228
+ access: json.access_token,
1229
+ expires: Date.now() + json.expires_in * 1000,
1230
+ };
1231
+ } catch (error) {
1232
+ log.error(() => ({
1233
+ message: 'google oauth manual code entry failed',
1234
+ error,
1235
+ }));
1236
+ return { type: 'failed' };
1237
+ }
1238
+ },
1239
+ };
1240
+ },
1241
+ },
1037
1242
  {
1038
1243
  label: 'Manually enter API Key',
1039
1244
  type: 'api',