@oh-my-pi/pi-ai 6.7.670 → 6.8.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.
@@ -2,6 +2,7 @@
2
2
  * GitHub Copilot OAuth flow
3
3
  */
4
4
 
5
+ import { abortableSleep } from "@oh-my-pi/pi-utils";
5
6
  import { getModels } from "../../models";
6
7
  import type { OAuthCredentials } from "./types";
7
8
 
@@ -136,29 +137,6 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
136
137
  };
137
138
  }
138
139
 
139
- /**
140
- * Sleep that can be interrupted by an AbortSignal
141
- */
142
- function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
143
- return new Promise((resolve, reject) => {
144
- if (signal?.aborted) {
145
- reject(new Error("Login cancelled"));
146
- return;
147
- }
148
-
149
- const timeout = setTimeout(resolve, ms);
150
-
151
- signal?.addEventListener(
152
- "abort",
153
- () => {
154
- clearTimeout(timeout);
155
- reject(new Error("Login cancelled"));
156
- },
157
- { once: true },
158
- );
159
- });
160
- }
161
-
162
140
  async function pollForGitHubAccessToken(
163
141
  domain: string,
164
142
  deviceCode: string,
@@ -1,24 +1,20 @@
1
1
  /**
2
2
  * Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud)
3
3
  * Uses different OAuth credentials than google-gemini-cli for access to additional models.
4
- *
5
- * NOTE: This module uses Node.js http.createServer for the OAuth callback.
6
- * It is only intended for CLI use, not browser environments.
7
4
  */
8
5
 
9
- import type { Server } from "http";
6
+ import { OAuthCallbackFlow } from "./callback-server";
10
7
  import { generatePKCE } from "./pkce";
11
- import type { OAuthCredentials } from "./types";
8
+ import type { OAuthController, OAuthCredentials } from "./types";
12
9
 
13
- // Antigravity OAuth credentials (different from Gemini CLI)
14
10
  const decode = (s: string) => atob(s);
15
11
  const CLIENT_ID = decode(
16
12
  "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
17
13
  );
18
14
  const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
19
- const REDIRECT_URI = "http://localhost:51121/oauth-callback";
15
+ const CALLBACK_PORT = 51121;
16
+ const CALLBACK_PATH = "/oauth-callback";
20
17
 
21
- // Antigravity requires additional scopes
22
18
  const SCOPES = [
23
19
  "https://www.googleapis.com/auth/cloud-platform",
24
20
  "https://www.googleapis.com/auth/userinfo.email",
@@ -29,110 +25,14 @@ const SCOPES = [
29
25
 
30
26
  const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
31
27
  const TOKEN_URL = "https://oauth2.googleapis.com/token";
32
-
33
- // Fallback project ID when discovery fails
34
28
  const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
35
29
 
36
- type CallbackServerInfo = {
37
- server: Server;
38
- cancelWait: () => void;
39
- waitForCode: () => Promise<{ code: string; state: string } | null>;
40
- };
41
-
42
- /**
43
- * Start a local HTTP server to receive the OAuth callback
44
- */
45
- async function startCallbackServer(): Promise<CallbackServerInfo> {
46
- const { createServer } = await import("http");
47
-
48
- return new Promise((resolve, reject) => {
49
- let result: { code: string; state: string } | null = null;
50
- let cancelled = false;
51
-
52
- const server = createServer((req, res) => {
53
- const url = new URL(req.url || "", `http://localhost:51121`);
54
-
55
- if (url.pathname === "/oauth-callback") {
56
- const code = url.searchParams.get("code");
57
- const state = url.searchParams.get("state");
58
- const error = url.searchParams.get("error");
59
-
60
- if (error) {
61
- res.writeHead(400, { "Content-Type": "text/html" });
62
- res.end(
63
- `<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
64
- );
65
- return;
66
- }
67
-
68
- if (code && state) {
69
- res.writeHead(200, { "Content-Type": "text/html" });
70
- res.end(
71
- `<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
72
- );
73
- result = { code, state };
74
- } else {
75
- res.writeHead(400, { "Content-Type": "text/html" });
76
- res.end(
77
- `<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
78
- );
79
- }
80
- } else {
81
- res.writeHead(404);
82
- res.end();
83
- }
84
- });
85
-
86
- server.on("error", (err) => {
87
- reject(err);
88
- });
89
-
90
- server.listen(51121, "127.0.0.1", () => {
91
- resolve({
92
- server,
93
- cancelWait: () => {
94
- cancelled = true;
95
- },
96
- waitForCode: async () => {
97
- const sleep = () => new Promise((r) => setTimeout(r, 100));
98
- while (!result && !cancelled) {
99
- await sleep();
100
- }
101
- return result;
102
- },
103
- });
104
- });
105
- });
106
- }
107
-
108
- /**
109
- * Parse redirect URL to extract code and state
110
- */
111
- function parseRedirectUrl(input: string): { code?: string; state?: string } {
112
- const value = input.trim();
113
- if (!value) return {};
114
-
115
- try {
116
- const url = new URL(value);
117
- return {
118
- code: url.searchParams.get("code") ?? undefined,
119
- state: url.searchParams.get("state") ?? undefined,
120
- };
121
- } catch {
122
- // Not a URL, return empty
123
- return {};
124
- }
125
- }
126
-
127
30
  interface LoadCodeAssistPayload {
128
31
  cloudaicompanionProject?: string | { id?: string };
129
32
  currentTier?: { id?: string };
130
33
  allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
131
34
  }
132
35
 
133
- /**
134
- * Discover or provision a project for the user
135
- */
136
36
  async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise<string> {
137
37
  const headers = {
138
38
  Authorization: `Bearer ${accessToken}`,
@@ -146,7 +46,6 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
146
46
  }),
147
47
  };
148
48
 
149
- // Try endpoints in order: prod first, then sandbox
150
49
  const endpoints = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"];
151
50
 
152
51
  onProgress?.("Checking for existing project...");
@@ -168,7 +67,6 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
168
67
  if (loadResponse.ok) {
169
68
  const data = (await loadResponse.json()) as LoadCodeAssistPayload;
170
69
 
171
- // Handle both string and object formats
172
70
  if (typeof data.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
173
71
  return data.cloudaicompanionProject;
174
72
  }
@@ -185,20 +83,14 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
185
83
  }
186
84
  }
187
85
 
188
- // Use fallback project ID
189
86
  onProgress?.("Using default project...");
190
87
  return DEFAULT_PROJECT_ID;
191
88
  }
192
89
 
193
- /**
194
- * Get user email from the access token
195
- */
196
90
  async function getUserEmail(accessToken: string): Promise<string | undefined> {
197
91
  try {
198
92
  const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
199
- headers: {
200
- Authorization: `Bearer ${accessToken}`,
201
- },
93
+ headers: { Authorization: `Bearer ${accessToken}` },
202
94
  });
203
95
 
204
96
  if (response.ok) {
@@ -211,165 +103,51 @@ async function getUserEmail(accessToken: string): Promise<string | undefined> {
211
103
  return undefined;
212
104
  }
213
105
 
214
- /**
215
- * Refresh Antigravity token
216
- */
217
- export async function refreshAntigravityToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
218
- const response = await fetch(TOKEN_URL, {
219
- method: "POST",
220
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
221
- body: new URLSearchParams({
222
- client_id: CLIENT_ID,
223
- client_secret: CLIENT_SECRET,
224
- refresh_token: refreshToken,
225
- grant_type: "refresh_token",
226
- }),
227
- });
106
+ class AntigravityOAuthFlow extends OAuthCallbackFlow {
107
+ private verifier: string = "";
108
+ private challenge: string = "";
228
109
 
229
- if (!response.ok) {
230
- const error = await response.text();
231
- throw new Error(`Antigravity token refresh failed: ${error}`);
110
+ constructor(ctrl: OAuthController) {
111
+ super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
232
112
  }
233
113
 
234
- const data = (await response.json()) as {
235
- access_token: string;
236
- expires_in: number;
237
- refresh_token?: string;
238
- };
239
-
240
- return {
241
- refresh: data.refresh_token || refreshToken,
242
- access: data.access_token,
243
- expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
244
- projectId,
245
- };
246
- }
114
+ protected async generateAuthUrl(
115
+ state: string,
116
+ redirectUri: string,
117
+ ): Promise<{ url: string; instructions?: string }> {
118
+ const pkce = await generatePKCE();
119
+ this.verifier = pkce.verifier;
120
+ this.challenge = pkce.challenge;
247
121
 
248
- /**
249
- * Login with Antigravity OAuth
250
- *
251
- * @param onAuth - Callback with URL and optional instructions
252
- * @param onProgress - Optional progress callback
253
- * @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL.
254
- * Races with browser callback - whichever completes first wins.
255
- */
256
- export async function loginAntigravity(
257
- onAuth: (info: { url: string; instructions?: string }) => void,
258
- onProgress?: (message: string) => void,
259
- onManualCodeInput?: () => Promise<string>,
260
- ): Promise<OAuthCredentials> {
261
- const { verifier, challenge } = await generatePKCE();
262
-
263
- // Start local server for callback
264
- onProgress?.("Starting local server for OAuth callback...");
265
- const server = await startCallbackServer();
266
-
267
- let code: string | undefined;
268
-
269
- try {
270
- // Build authorization URL
271
122
  const authParams = new URLSearchParams({
272
123
  client_id: CLIENT_ID,
273
124
  response_type: "code",
274
- redirect_uri: REDIRECT_URI,
125
+ redirect_uri: redirectUri,
275
126
  scope: SCOPES.join(" "),
276
- code_challenge: challenge,
127
+ code_challenge: this.challenge,
277
128
  code_challenge_method: "S256",
278
- state: verifier,
129
+ state,
279
130
  access_type: "offline",
280
131
  prompt: "consent",
281
132
  });
282
133
 
283
- const authUrl = `${AUTH_URL}?${authParams.toString()}`;
284
-
285
- // Notify caller with URL to open
286
- onAuth({
287
- url: authUrl,
288
- instructions: "Complete the sign-in in your browser.",
289
- });
290
-
291
- // Wait for the callback, racing with manual input if provided
292
- onProgress?.("Waiting for OAuth callback...");
293
-
294
- if (onManualCodeInput) {
295
- // Race between browser callback and manual input
296
- let manualInput: string | undefined;
297
- let manualError: Error | undefined;
298
- const manualPromise = onManualCodeInput()
299
- .then((input) => {
300
- manualInput = input;
301
- server.cancelWait();
302
- })
303
- .catch((err) => {
304
- manualError = err instanceof Error ? err : new Error(String(err));
305
- server.cancelWait();
306
- });
307
-
308
- const result = await server.waitForCode();
309
-
310
- // If manual input was cancelled, throw that error
311
- if (manualError) {
312
- throw manualError;
313
- }
314
-
315
- if (result?.code) {
316
- // Browser callback won - verify state
317
- if (result.state !== verifier) {
318
- throw new Error("OAuth state mismatch - possible CSRF attack");
319
- }
320
- code = result.code;
321
- } else if (manualInput) {
322
- // Manual input won
323
- const parsed = parseRedirectUrl(manualInput);
324
- if (parsed.state && parsed.state !== verifier) {
325
- throw new Error("OAuth state mismatch - possible CSRF attack");
326
- }
327
- code = parsed.code;
328
- }
329
-
330
- // If still no code, wait for manual promise and try that
331
- if (!code) {
332
- await manualPromise;
333
- if (manualError) {
334
- throw manualError;
335
- }
336
- if (manualInput) {
337
- const parsed = parseRedirectUrl(manualInput);
338
- if (parsed.state && parsed.state !== verifier) {
339
- throw new Error("OAuth state mismatch - possible CSRF attack");
340
- }
341
- code = parsed.code;
342
- }
343
- }
344
- } else {
345
- // Original flow: just wait for callback
346
- const result = await server.waitForCode();
347
- if (result?.code) {
348
- if (result.state !== verifier) {
349
- throw new Error("OAuth state mismatch - possible CSRF attack");
350
- }
351
- code = result.code;
352
- }
353
- }
134
+ const url = `${AUTH_URL}?${authParams.toString()}`;
135
+ return { url, instructions: "Complete the sign-in in your browser." };
136
+ }
354
137
 
355
- if (!code) {
356
- throw new Error("No authorization code received");
357
- }
138
+ protected async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
139
+ this.ctrl.onProgress?.("Exchanging authorization code for tokens...");
358
140
 
359
- // Exchange code for tokens
360
- onProgress?.("Exchanging authorization code for tokens...");
361
141
  const tokenResponse = await fetch(TOKEN_URL, {
362
142
  method: "POST",
363
- headers: {
364
- "Content-Type": "application/x-www-form-urlencoded",
365
- },
143
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
366
144
  body: new URLSearchParams({
367
145
  client_id: CLIENT_ID,
368
146
  client_secret: CLIENT_SECRET,
369
147
  code,
370
148
  grant_type: "authorization_code",
371
- redirect_uri: REDIRECT_URI,
372
- code_verifier: verifier,
149
+ redirect_uri: redirectUri,
150
+ code_verifier: this.verifier,
373
151
  }),
374
152
  });
375
153
 
@@ -388,26 +166,58 @@ export async function loginAntigravity(
388
166
  throw new Error("No refresh token received. Please try again.");
389
167
  }
390
168
 
391
- // Get user email
392
- onProgress?.("Getting user info...");
169
+ this.ctrl.onProgress?.("Getting user info...");
393
170
  const email = await getUserEmail(tokenData.access_token);
171
+ const projectId = await discoverProject(tokenData.access_token, this.ctrl.onProgress);
394
172
 
395
- // Discover project
396
- const projectId = await discoverProject(tokenData.access_token, onProgress);
397
-
398
- // Calculate expiry time (current time + expires_in seconds - 5 min buffer)
399
- const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
400
-
401
- const credentials: OAuthCredentials = {
173
+ return {
402
174
  refresh: tokenData.refresh_token,
403
175
  access: tokenData.access_token,
404
- expires: expiresAt,
176
+ expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
405
177
  projectId,
406
178
  email,
407
179
  };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Login with Antigravity OAuth
185
+ */
186
+ export async function loginAntigravity(ctrl: OAuthController): Promise<OAuthCredentials> {
187
+ const flow = new AntigravityOAuthFlow(ctrl);
188
+ return flow.login();
189
+ }
190
+
191
+ /**
192
+ * Refresh Antigravity token
193
+ */
194
+ export async function refreshAntigravityToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
195
+ const response = await fetch(TOKEN_URL, {
196
+ method: "POST",
197
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
198
+ body: new URLSearchParams({
199
+ client_id: CLIENT_ID,
200
+ client_secret: CLIENT_SECRET,
201
+ refresh_token: refreshToken,
202
+ grant_type: "refresh_token",
203
+ }),
204
+ });
408
205
 
409
- return credentials;
410
- } finally {
411
- server.server.close();
206
+ if (!response.ok) {
207
+ const error = await response.text();
208
+ throw new Error(`Antigravity token refresh failed: ${error}`);
412
209
  }
210
+
211
+ const data = (await response.json()) as {
212
+ access_token: string;
213
+ expires_in: number;
214
+ refresh_token?: string;
215
+ };
216
+
217
+ return {
218
+ refresh: data.refresh_token || refreshToken,
219
+ access: data.access_token,
220
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
221
+ projectId,
222
+ };
413
223
  }