@oh-my-pi/pi-ai 6.8.0 → 6.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "6.8.0",
3
+ "version": "6.8.2",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -17,7 +17,7 @@
17
17
  "test": "bun test"
18
18
  },
19
19
  "dependencies": {
20
- "@oh-my-pi/pi-utils": "6.8.0",
20
+ "@oh-my-pi/pi-utils": "6.8.2",
21
21
  "@anthropic-ai/sdk": "0.71.2",
22
22
  "@aws-sdk/client-bedrock-runtime": "^3.968.0",
23
23
  "@bufbuild/protobuf": "^2.10.2",
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import { createInterface } from "readline";
3
3
  import { CliAuthStorage } from "./storage";
4
4
  import "./utils/migrate-env";
5
5
  import { loginAnthropic } from "./utils/oauth/anthropic";
6
+ import { loginCursor } from "./utils/oauth/cursor";
6
7
  import { loginGitHubCopilot } from "./utils/oauth/github-copilot";
7
8
  import { loginAntigravity } from "./utils/oauth/google-antigravity";
8
9
  import { loginGeminiCli } from "./utils/oauth/google-gemini-cli";
@@ -88,6 +89,17 @@ async function login(provider: OAuthProvider): Promise<void> {
88
89
  });
89
90
  break;
90
91
 
92
+ case "cursor":
93
+ credentials = await loginCursor(
94
+ (url) => {
95
+ console.log(`\nOpen this URL in your browser:\n${url}\n`);
96
+ },
97
+ () => {
98
+ console.log("Waiting for browser authentication...");
99
+ },
100
+ );
101
+ break;
102
+
91
103
  default:
92
104
  throw new Error(`Unknown provider: ${provider}`);
93
105
  }
@@ -753,7 +753,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
753
753
 
754
754
  if (emptyAttempt > 0) {
755
755
  const backoffMs = EMPTY_STREAM_BASE_DELAY_MS * 2 ** (emptyAttempt - 1);
756
- await abortableSleep(backoffMs, options?.signal);
756
+ try {
757
+ await abortableSleep(backoffMs, options?.signal);
758
+ } catch {
759
+ // Normalize AbortError to expected message for consistent error handling
760
+ throw new Error("Request was aborted");
761
+ }
757
762
 
758
763
  if (!requestUrl) {
759
764
  throw new Error("Missing request URL");
@@ -1,4 +1,5 @@
1
1
  import os from "node:os";
2
+ import { abortableSleep } from "@oh-my-pi/pi-utils";
2
3
  import type {
3
4
  ResponseFunctionToolCall,
4
5
  ResponseInput,
@@ -440,13 +441,13 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
440
441
  }
441
442
  if (signal?.aborted) return response;
442
443
  const delay = getRetryDelayMs(response, attempt);
443
- await Bun.sleep(delay);
444
+ await abortableSleep(delay, signal);
444
445
  } catch (error) {
445
446
  if (attempt >= CODEX_MAX_RETRIES || signal?.aborted) {
446
447
  throw error;
447
448
  }
448
449
  const delay = CODEX_RETRY_DELAY_MS * (attempt + 1);
449
- await Bun.sleep(delay);
450
+ await abortableSleep(delay, signal);
450
451
  }
451
452
  attempt += 1;
452
453
  }
package/src/storage.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { Database } from "bun:sqlite";
7
- import { existsSync, mkdirSync } from "node:fs";
7
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { dirname, join } from "node:path";
10
10
  import type { OAuthCredentials } from "./utils/oauth/types";
@@ -88,13 +88,19 @@ export class CliAuthStorage {
88
88
  private deleteByProviderStmt: ReturnType<Database["prepare"]>;
89
89
 
90
90
  constructor(dbPath: string = getAgentDbPath()) {
91
- // Ensure directory exists
91
+ // Ensure directory exists with secure permissions
92
92
  const dir = dirname(dbPath);
93
93
  if (!existsSync(dir)) {
94
- mkdirSync(dir, { recursive: true });
94
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
95
95
  }
96
96
 
97
97
  this.db = new Database(dbPath);
98
+ // Harden database file permissions to prevent credential leakage
99
+ try {
100
+ chmodSync(dbPath, 0o600);
101
+ } catch {
102
+ // Ignore chmod failures (e.g., Windows)
103
+ }
98
104
  this.initializeSchema();
99
105
 
100
106
  this.insertStmt = this.db.prepare(
@@ -63,7 +63,12 @@ class AnthropicOAuthFlow extends OAuthCallbackFlow {
63
63
  });
64
64
 
65
65
  if (!tokenResponse.ok) {
66
- const error = await tokenResponse.text();
66
+ let error: string;
67
+ try {
68
+ error = await tokenResponse.text();
69
+ } catch {
70
+ error = `HTTP ${tokenResponse.status}`;
71
+ }
67
72
  throw new Error(`Token exchange failed: ${error}`);
68
73
  }
69
74
 
@@ -158,12 +158,14 @@ export abstract class OAuthCallbackFlow {
158
158
  resultState = { ok: true, code, state };
159
159
  }
160
160
 
161
- // Signal to waitForCallback
161
+ // Signal to waitForCallback - capture refs before they could be cleared
162
+ const resolve = this.callbackResolve;
163
+ const reject = this.callbackReject;
162
164
  queueMicrotask(() => {
163
165
  if (resultState.ok) {
164
- this.callbackResolve?.({ code: resultState.code, state: resultState.state });
166
+ resolve?.({ code: resultState.code, state: resultState.state });
165
167
  } else {
166
- this.callbackReject?.(resultState.error ?? "Unknown error");
168
+ reject?.(resultState.error ?? "Unknown error");
167
169
  }
168
170
  });
169
171
 
@@ -195,17 +197,24 @@ export abstract class OAuthCallbackFlow {
195
197
  });
196
198
 
197
199
  // Manual input race (if supported)
200
+ // Errors from manual input should not abort the flow - only successful input wins the race
198
201
  if (this.ctrl.onManualCodeInput) {
199
- const manualPromise = this.ctrl.onManualCodeInput().then((input): CallbackResult => {
200
- const parsed = parseCallbackInput(input);
201
- if (!parsed.code) {
202
- throw new Error("No authorization code found in input");
203
- }
204
- if (expectedState && parsed.state && parsed.state !== expectedState) {
205
- throw new Error("State mismatch - possible CSRF attack");
206
- }
207
- return { code: parsed.code, state: parsed.state ?? "" };
208
- });
202
+ const manualPromise = this.ctrl
203
+ .onManualCodeInput()
204
+ .then((input): CallbackResult => {
205
+ const parsed = parseCallbackInput(input);
206
+ if (!parsed.code) {
207
+ throw new Error("No authorization code found in input");
208
+ }
209
+ if (expectedState && parsed.state && parsed.state !== expectedState) {
210
+ throw new Error("State mismatch - possible CSRF attack");
211
+ }
212
+ return { code: parsed.code, state: parsed.state ?? "" };
213
+ })
214
+ .catch((): Promise<CallbackResult> => {
215
+ // On manual input error, wait forever - let callback or abort signal win
216
+ return new Promise(() => {});
217
+ });
209
218
 
210
219
  return Promise.race([callbackPromise, manualPromise]);
211
220
  }
@@ -126,12 +126,16 @@ export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<
126
126
 
127
127
  function getTokenExpiry(token: string): number {
128
128
  try {
129
- const [, payload] = token.split(".");
129
+ const parts = token.split(".");
130
+ if (parts.length !== 3) {
131
+ return Date.now() + 3600 * 1000;
132
+ }
133
+ const payload = parts[1];
130
134
  if (!payload) {
131
135
  return Date.now() + 3600 * 1000;
132
136
  }
133
137
  const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
134
- if (decoded.exp) {
138
+ if (decoded && typeof decoded === "object" && typeof decoded.exp === "number") {
135
139
  return decoded.exp * 1000 - 5 * 60 * 1000;
136
140
  }
137
141
  } catch {
@@ -174,20 +174,32 @@ async function pollForGitHubAccessToken(
174
174
  if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
175
175
  const err = (raw as DeviceTokenErrorResponse).error;
176
176
  if (err === "authorization_pending") {
177
- await abortableSleep(intervalMs, signal);
177
+ try {
178
+ await abortableSleep(intervalMs, signal);
179
+ } catch {
180
+ throw new Error("Login cancelled");
181
+ }
178
182
  continue;
179
183
  }
180
184
 
181
185
  if (err === "slow_down") {
182
186
  intervalMs += 5000;
183
- await abortableSleep(intervalMs, signal);
187
+ try {
188
+ await abortableSleep(intervalMs, signal);
189
+ } catch {
190
+ throw new Error("Login cancelled");
191
+ }
184
192
  continue;
185
193
  }
186
194
 
187
195
  throw new Error(`Device flow failed: ${err}`);
188
196
  }
189
197
 
190
- await abortableSleep(intervalMs, signal);
198
+ try {
199
+ await abortableSleep(intervalMs, signal);
200
+ } catch {
201
+ throw new Error("Login cancelled");
202
+ }
191
203
  }
192
204
 
193
205
  throw new Error("Device flow timed out");
@@ -2,7 +2,7 @@
2
2
  * OpenAI Codex (ChatGPT OAuth) flow
3
3
  */
4
4
 
5
- import { OAuthCallbackFlow, parseCallbackInput } from "./callback-server";
5
+ import { OAuthCallbackFlow } from "./callback-server";
6
6
  import { generatePKCE } from "./pkce";
7
7
  import type { OAuthController, OAuthCredentials } from "./types";
8
8
 
@@ -40,11 +40,16 @@ function getAccountId(accessToken: string): string | null {
40
40
  return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
41
41
  }
42
42
 
43
- class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
44
- private verifier: string = "";
45
- private challenge: string = "";
43
+ interface PKCE {
44
+ verifier: string;
45
+ challenge: string;
46
+ }
46
47
 
47
- constructor(ctrl: OAuthController) {
48
+ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
49
+ constructor(
50
+ ctrl: OAuthController,
51
+ private readonly pkce: PKCE,
52
+ ) {
48
53
  super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
49
54
  }
50
55
 
@@ -52,16 +57,12 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
52
57
  state: string,
53
58
  redirectUri: string,
54
59
  ): Promise<{ url: string; instructions?: string }> {
55
- const pkce = await generatePKCE();
56
- this.verifier = pkce.verifier;
57
- this.challenge = pkce.challenge;
58
-
59
60
  const searchParams = new URLSearchParams({
60
61
  response_type: "code",
61
62
  client_id: CLIENT_ID,
62
63
  redirect_uri: redirectUri,
63
64
  scope: SCOPE,
64
- code_challenge: this.challenge,
65
+ code_challenge: this.pkce.challenge,
65
66
  code_challenge_method: "S256",
66
67
  state,
67
68
  id_token_add_organizations: "true",
@@ -74,113 +75,58 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
74
75
  }
75
76
 
76
77
  protected async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
77
- const tokenResponse = await fetch(TOKEN_URL, {
78
- method: "POST",
79
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
80
- body: new URLSearchParams({
81
- grant_type: "authorization_code",
82
- client_id: CLIENT_ID,
83
- code,
84
- code_verifier: this.verifier,
85
- redirect_uri: redirectUri,
86
- }),
87
- });
78
+ return exchangeCodeForToken(code, this.pkce.verifier, redirectUri);
79
+ }
80
+ }
81
+
82
+ async function exchangeCodeForToken(code: string, verifier: string, redirectUri: string): Promise<OAuthCredentials> {
83
+ const tokenResponse = await fetch(TOKEN_URL, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
86
+ body: new URLSearchParams({
87
+ grant_type: "authorization_code",
88
+ client_id: CLIENT_ID,
89
+ code,
90
+ code_verifier: verifier,
91
+ redirect_uri: redirectUri,
92
+ }),
93
+ });
94
+
95
+ if (!tokenResponse.ok) {
96
+ throw new Error(`Token exchange failed: ${tokenResponse.status}`);
97
+ }
98
+
99
+ const tokenData = (await tokenResponse.json()) as {
100
+ access_token?: string;
101
+ refresh_token?: string;
102
+ expires_in?: number;
103
+ };
104
+
105
+ if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
106
+ throw new Error("Token response missing required fields");
107
+ }
88
108
 
89
- if (!tokenResponse.ok) {
90
- throw new Error(`Token exchange failed: ${tokenResponse.status}`);
91
- }
92
-
93
- const tokenData = (await tokenResponse.json()) as {
94
- access_token?: string;
95
- refresh_token?: string;
96
- expires_in?: number;
97
- };
98
-
99
- if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
100
- throw new Error("Token response missing required fields");
101
- }
102
-
103
- const accountId = getAccountId(tokenData.access_token);
104
- if (!accountId) {
105
- throw new Error("Failed to extract accountId from token");
106
- }
107
-
108
- return {
109
- access: tokenData.access_token,
110
- refresh: tokenData.refresh_token,
111
- expires: Date.now() + tokenData.expires_in * 1000,
112
- accountId,
113
- };
109
+ const accountId = getAccountId(tokenData.access_token);
110
+ if (!accountId) {
111
+ throw new Error("Failed to extract accountId from token");
114
112
  }
113
+
114
+ return {
115
+ access: tokenData.access_token,
116
+ refresh: tokenData.refresh_token,
117
+ expires: Date.now() + tokenData.expires_in * 1000,
118
+ accountId,
119
+ };
115
120
  }
116
121
 
117
122
  /**
118
123
  * Login with OpenAI Codex OAuth
119
124
  */
120
125
  export async function loginOpenAICodex(ctrl: OAuthController): Promise<OAuthCredentials> {
121
- const flow = new OpenAICodexOAuthFlow(ctrl);
122
-
123
- try {
124
- return await flow.login();
125
- } catch (error) {
126
- // Callback failed - fall back to onPrompt if available
127
- if (!ctrl.onPrompt) {
128
- throw error;
129
- }
130
-
131
- ctrl.onProgress?.("Callback server failed, falling back to manual input");
126
+ const pkce = await generatePKCE();
127
+ const flow = new OpenAICodexOAuthFlow(ctrl, pkce);
132
128
 
133
- const input = await ctrl.onPrompt({
134
- message: "Paste the authorization code (or full redirect URL):",
135
- });
136
-
137
- const parsed = parseCallbackInput(input);
138
- if (!parsed.code) {
139
- throw new Error("No authorization code found in input");
140
- }
141
-
142
- const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
143
-
144
- // Manual token exchange
145
- const pkce = await generatePKCE();
146
- const tokenResponse = await fetch(TOKEN_URL, {
147
- method: "POST",
148
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
149
- body: new URLSearchParams({
150
- grant_type: "authorization_code",
151
- client_id: CLIENT_ID,
152
- code: parsed.code,
153
- code_verifier: pkce.verifier,
154
- redirect_uri: redirectUri,
155
- }),
156
- });
157
-
158
- if (!tokenResponse.ok) {
159
- throw new Error(`Token exchange failed: ${tokenResponse.status}`);
160
- }
161
-
162
- const tokenData = (await tokenResponse.json()) as {
163
- access_token?: string;
164
- refresh_token?: string;
165
- expires_in?: number;
166
- };
167
-
168
- if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
169
- throw new Error("Token response missing required fields");
170
- }
171
-
172
- const accountId = getAccountId(tokenData.access_token);
173
- if (!accountId) {
174
- throw new Error("Failed to extract accountId from token");
175
- }
176
-
177
- return {
178
- access: tokenData.access_token,
179
- refresh: tokenData.refresh_token,
180
- expires: Date.now() + tokenData.expires_in * 1000,
181
- accountId,
182
- };
183
- }
129
+ return flow.login();
184
130
  }
185
131
 
186
132
  /**