@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.
- package/README.md +31 -31
- package/package.json +2 -1
- package/src/cli.ts +126 -52
- package/src/providers/google-gemini-cli.ts +4 -20
- package/src/providers/openai-codex/response-handler.ts +4 -43
- package/src/providers/openai-codex-responses.ts +3 -2
- package/src/storage.ts +185 -0
- package/src/utils/event-stream.ts +3 -3
- package/src/utils/oauth/anthropic.ts +73 -97
- package/src/utils/oauth/callback-server.ts +247 -0
- package/src/utils/oauth/cursor.ts +7 -7
- package/src/utils/oauth/github-copilot.ts +1 -23
- package/src/utils/oauth/google-antigravity.ts +73 -263
- package/src/utils/oauth/google-gemini-cli.ts +73 -281
- package/src/utils/oauth/oauth.html +199 -0
- package/src/utils/oauth/openai-codex.ts +108 -329
- package/src/utils/oauth/types.ts +8 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Authentication</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #09090b;
|
|
10
|
+
--card-bg: #18181b;
|
|
11
|
+
--text-main: #fafafa;
|
|
12
|
+
--text-muted: #a1a1aa;
|
|
13
|
+
--success: #22c55e;
|
|
14
|
+
--error: #ef4444;
|
|
15
|
+
--border: #27272a;
|
|
16
|
+
}
|
|
17
|
+
body {
|
|
18
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
19
|
+
background-color: var(--bg);
|
|
20
|
+
color: var(--text-main);
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
height: 100vh;
|
|
25
|
+
margin: 0;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
}
|
|
28
|
+
.container {
|
|
29
|
+
background: var(--card-bg);
|
|
30
|
+
border: 1px solid var(--border);
|
|
31
|
+
border-radius: 12px;
|
|
32
|
+
padding: 2.5rem;
|
|
33
|
+
width: 100%;
|
|
34
|
+
max-width: 400px;
|
|
35
|
+
text-align: center;
|
|
36
|
+
box-shadow:
|
|
37
|
+
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
|
38
|
+
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
39
|
+
opacity: 0;
|
|
40
|
+
transform: translateY(10px);
|
|
41
|
+
animation: fadeIn 0.4s ease-out forwards;
|
|
42
|
+
}
|
|
43
|
+
@keyframes fadeIn {
|
|
44
|
+
to {
|
|
45
|
+
opacity: 1;
|
|
46
|
+
transform: translateY(0);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
.icon-circle {
|
|
50
|
+
position: relative;
|
|
51
|
+
width: 64px;
|
|
52
|
+
height: 64px;
|
|
53
|
+
border-radius: 50%;
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
justify-content: center;
|
|
57
|
+
margin: 0 auto 1.5rem;
|
|
58
|
+
background: rgba(255, 255, 255, 0.05);
|
|
59
|
+
}
|
|
60
|
+
.icon {
|
|
61
|
+
width: 32px;
|
|
62
|
+
height: 32px;
|
|
63
|
+
z-index: 2;
|
|
64
|
+
}
|
|
65
|
+
.timer-svg {
|
|
66
|
+
position: absolute;
|
|
67
|
+
top: -2px;
|
|
68
|
+
left: -2px;
|
|
69
|
+
width: 68px;
|
|
70
|
+
height: 68px;
|
|
71
|
+
transform: rotate(-90deg);
|
|
72
|
+
z-index: 1;
|
|
73
|
+
pointer-events: none;
|
|
74
|
+
}
|
|
75
|
+
.timer-circle {
|
|
76
|
+
fill: none;
|
|
77
|
+
stroke-width: 2;
|
|
78
|
+
stroke-linecap: round;
|
|
79
|
+
stroke-dasharray: 201;
|
|
80
|
+
stroke-dashoffset: 0;
|
|
81
|
+
}
|
|
82
|
+
.countdown .timer-circle {
|
|
83
|
+
animation: countdown 3s linear forwards;
|
|
84
|
+
}
|
|
85
|
+
@keyframes countdown {
|
|
86
|
+
to {
|
|
87
|
+
stroke-dashoffset: 201;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
h1 {
|
|
91
|
+
font-size: 1.5rem;
|
|
92
|
+
font-weight: 600;
|
|
93
|
+
margin: 0 0 0.75rem;
|
|
94
|
+
letter-spacing: -0.025em;
|
|
95
|
+
}
|
|
96
|
+
p {
|
|
97
|
+
color: var(--text-muted);
|
|
98
|
+
line-height: 1.5;
|
|
99
|
+
margin: 0 0 1.5rem;
|
|
100
|
+
font-size: 0.95rem;
|
|
101
|
+
}
|
|
102
|
+
.btn {
|
|
103
|
+
display: inline-block;
|
|
104
|
+
background: var(--text-main);
|
|
105
|
+
color: var(--bg);
|
|
106
|
+
font-weight: 500;
|
|
107
|
+
padding: 0.6rem 1.2rem;
|
|
108
|
+
border-radius: 6px;
|
|
109
|
+
text-decoration: none;
|
|
110
|
+
font-size: 0.9rem;
|
|
111
|
+
transition: opacity 0.2s;
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
border: none;
|
|
114
|
+
}
|
|
115
|
+
.btn:hover {
|
|
116
|
+
opacity: 0.9;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* State: success */
|
|
120
|
+
.success .icon-circle {
|
|
121
|
+
background: rgba(34, 197, 94, 0.1);
|
|
122
|
+
}
|
|
123
|
+
.success .icon {
|
|
124
|
+
color: var(--success);
|
|
125
|
+
}
|
|
126
|
+
.success .timer-circle {
|
|
127
|
+
stroke: var(--success);
|
|
128
|
+
}
|
|
129
|
+
.success .icon-error {
|
|
130
|
+
display: none;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* State: error */
|
|
134
|
+
.error .icon-circle {
|
|
135
|
+
background: rgba(239, 68, 68, 0.1);
|
|
136
|
+
}
|
|
137
|
+
.error .icon {
|
|
138
|
+
color: var(--error);
|
|
139
|
+
}
|
|
140
|
+
.error .timer-circle {
|
|
141
|
+
stroke: var(--error);
|
|
142
|
+
}
|
|
143
|
+
.error .icon-success,
|
|
144
|
+
.error .timer-svg {
|
|
145
|
+
display: none;
|
|
146
|
+
}
|
|
147
|
+
</style>
|
|
148
|
+
</head>
|
|
149
|
+
<body>
|
|
150
|
+
<div id="app" class="container">
|
|
151
|
+
<div class="icon-circle">
|
|
152
|
+
<svg class="timer-svg" viewBox="0 0 68 68">
|
|
153
|
+
<circle class="timer-circle" cx="34" cy="34" r="32"></circle>
|
|
154
|
+
</svg>
|
|
155
|
+
<svg class="icon icon-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
156
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
157
|
+
</svg>
|
|
158
|
+
<svg class="icon icon-error" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
159
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
160
|
+
</svg>
|
|
161
|
+
</div>
|
|
162
|
+
<h1 id="title">Authentication</h1>
|
|
163
|
+
<p id="message"></p>
|
|
164
|
+
<button onclick="window.close()" class="btn">Close Window</button>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<script id="server-state" type="application/json">
|
|
168
|
+
__OAUTH_STATE__
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<script>
|
|
172
|
+
let serverState;
|
|
173
|
+
try {
|
|
174
|
+
serverState = JSON.parse(document.getElementById("server-state").textContent);
|
|
175
|
+
} catch {
|
|
176
|
+
const params = new URLSearchParams(window.location.search);
|
|
177
|
+
serverState = {
|
|
178
|
+
ok: params.get("ok") === "1",
|
|
179
|
+
error: params.get("error") || "Authentication failed",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const app = document.getElementById("app");
|
|
184
|
+
const title = document.getElementById("title");
|
|
185
|
+
const message = document.getElementById("message");
|
|
186
|
+
|
|
187
|
+
if (serverState.ok) {
|
|
188
|
+
app.classList.add("success", "countdown");
|
|
189
|
+
title.textContent = "Authentication Successful";
|
|
190
|
+
message.innerHTML = "You have successfully logged in.<br>This window will close automatically.";
|
|
191
|
+
setTimeout(() => window.close(), 3000);
|
|
192
|
+
} else {
|
|
193
|
+
app.classList.add("error");
|
|
194
|
+
title.textContent = "Authentication Failed";
|
|
195
|
+
message.textContent = serverState.error || "An error occurred";
|
|
196
|
+
}
|
|
197
|
+
</script>
|
|
198
|
+
</body>
|
|
199
|
+
</html>
|
|
@@ -2,34 +2,18 @@
|
|
|
2
2
|
* OpenAI Codex (ChatGPT OAuth) flow
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import http from "node:http";
|
|
5
|
+
import { OAuthCallbackFlow, parseCallbackInput } from "./callback-server";
|
|
7
6
|
import { generatePKCE } from "./pkce";
|
|
8
|
-
import type {
|
|
7
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
9
8
|
|
|
10
9
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
11
10
|
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
12
11
|
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
13
|
-
const
|
|
12
|
+
const CALLBACK_PORT = 1455;
|
|
13
|
+
const CALLBACK_PATH = "/auth/callback";
|
|
14
14
|
const SCOPE = "openid profile email offline_access";
|
|
15
15
|
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
16
16
|
|
|
17
|
-
const SUCCESS_HTML = `<!doctype html>
|
|
18
|
-
<html lang="en">
|
|
19
|
-
<head>
|
|
20
|
-
<meta charset="utf-8" />
|
|
21
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
22
|
-
<title>Authentication successful</title>
|
|
23
|
-
</head>
|
|
24
|
-
<body>
|
|
25
|
-
<p>Authentication successful. Return to your terminal to continue.</p>
|
|
26
|
-
</body>
|
|
27
|
-
</html>`;
|
|
28
|
-
|
|
29
|
-
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
|
|
30
|
-
type TokenFailure = { type: "failed" };
|
|
31
|
-
type TokenResult = TokenSuccess | TokenFailure;
|
|
32
|
-
|
|
33
17
|
type JwtPayload = {
|
|
34
18
|
[JWT_CLAIM_PATH]?: {
|
|
35
19
|
chatgpt_account_id?: string;
|
|
@@ -37,40 +21,6 @@ type JwtPayload = {
|
|
|
37
21
|
[key: string]: unknown;
|
|
38
22
|
};
|
|
39
23
|
|
|
40
|
-
function createState(): string {
|
|
41
|
-
return randomBytes(16).toString("hex");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
|
45
|
-
const value = input.trim();
|
|
46
|
-
if (!value) return {};
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
const url = new URL(value);
|
|
50
|
-
return {
|
|
51
|
-
code: url.searchParams.get("code") ?? undefined,
|
|
52
|
-
state: url.searchParams.get("state") ?? undefined,
|
|
53
|
-
};
|
|
54
|
-
} catch {
|
|
55
|
-
// not a URL
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (value.includes("#")) {
|
|
59
|
-
const [code, state] = value.split("#", 2);
|
|
60
|
-
return { code, state };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (value.includes("code=")) {
|
|
64
|
-
const params = new URLSearchParams(value);
|
|
65
|
-
return {
|
|
66
|
-
code: params.get("code") ?? undefined,
|
|
67
|
-
state: params.get("state") ?? undefined,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return { code: value };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
24
|
function decodeJwt(token: string): JwtPayload | null {
|
|
75
25
|
try {
|
|
76
26
|
const parts = token.split(".");
|
|
@@ -83,12 +33,54 @@ function decodeJwt(token: string): JwtPayload | null {
|
|
|
83
33
|
}
|
|
84
34
|
}
|
|
85
35
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
36
|
+
function getAccountId(accessToken: string): string | null {
|
|
37
|
+
const payload = decodeJwt(accessToken);
|
|
38
|
+
const auth = payload?.[JWT_CLAIM_PATH];
|
|
39
|
+
const accountId = auth?.chatgpt_account_id;
|
|
40
|
+
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PKCE {
|
|
44
|
+
verifier: string;
|
|
45
|
+
challenge: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
|
|
49
|
+
constructor(
|
|
50
|
+
ctrl: OAuthController,
|
|
51
|
+
private readonly pkce: PKCE,
|
|
52
|
+
) {
|
|
53
|
+
super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
protected async generateAuthUrl(
|
|
57
|
+
state: string,
|
|
58
|
+
redirectUri: string,
|
|
59
|
+
): Promise<{ url: string; instructions?: string }> {
|
|
60
|
+
const searchParams = new URLSearchParams({
|
|
61
|
+
response_type: "code",
|
|
62
|
+
client_id: CLIENT_ID,
|
|
63
|
+
redirect_uri: redirectUri,
|
|
64
|
+
scope: SCOPE,
|
|
65
|
+
code_challenge: this.pkce.challenge,
|
|
66
|
+
code_challenge_method: "S256",
|
|
67
|
+
state,
|
|
68
|
+
id_token_add_organizations: "true",
|
|
69
|
+
codex_cli_simplified_flow: "true",
|
|
70
|
+
originator: "opencode",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const url = `${AUTHORIZE_URL}?${searchParams.toString()}`;
|
|
74
|
+
return { url, instructions: "A browser window should open. Complete login to finish." };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
protected async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
|
|
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, {
|
|
92
84
|
method: "POST",
|
|
93
85
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
94
86
|
body: new URLSearchParams({
|
|
@@ -100,289 +92,60 @@ async function exchangeAuthorizationCode(
|
|
|
100
92
|
}),
|
|
101
93
|
});
|
|
102
94
|
|
|
103
|
-
if (!
|
|
104
|
-
|
|
105
|
-
console.error("[openai-codex] code->token failed:", response.status, text);
|
|
106
|
-
return { type: "failed" };
|
|
95
|
+
if (!tokenResponse.ok) {
|
|
96
|
+
throw new Error(`Token exchange failed: ${tokenResponse.status}`);
|
|
107
97
|
}
|
|
108
98
|
|
|
109
|
-
const
|
|
99
|
+
const tokenData = (await tokenResponse.json()) as {
|
|
110
100
|
access_token?: string;
|
|
111
101
|
refresh_token?: string;
|
|
112
102
|
expires_in?: number;
|
|
113
103
|
};
|
|
114
104
|
|
|
115
|
-
if (!
|
|
116
|
-
|
|
117
|
-
return { type: "failed" };
|
|
105
|
+
if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
|
|
106
|
+
throw new Error("Token response missing required fields");
|
|
118
107
|
}
|
|
119
108
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
refresh: json.refresh_token,
|
|
124
|
-
expires: Date.now() + json.expires_in * 1000,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
|
|
129
|
-
try {
|
|
130
|
-
const response = await fetch(TOKEN_URL, {
|
|
131
|
-
method: "POST",
|
|
132
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
133
|
-
body: new URLSearchParams({
|
|
134
|
-
grant_type: "refresh_token",
|
|
135
|
-
refresh_token: refreshToken,
|
|
136
|
-
client_id: CLIENT_ID,
|
|
137
|
-
}),
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
if (!response.ok) {
|
|
141
|
-
const text = await response.text().catch(() => "");
|
|
142
|
-
console.error("[openai-codex] Token refresh failed:", response.status, text);
|
|
143
|
-
return { type: "failed" };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const json = (await response.json()) as {
|
|
147
|
-
access_token?: string;
|
|
148
|
-
refresh_token?: string;
|
|
149
|
-
expires_in?: number;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
|
|
153
|
-
console.error("[openai-codex] Token refresh response missing fields:", json);
|
|
154
|
-
return { type: "failed" };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
type: "success",
|
|
159
|
-
access: json.access_token,
|
|
160
|
-
refresh: json.refresh_token,
|
|
161
|
-
expires: Date.now() + json.expires_in * 1000,
|
|
162
|
-
};
|
|
163
|
-
} catch (error) {
|
|
164
|
-
console.error("[openai-codex] Token refresh error:", error);
|
|
165
|
-
return { type: "failed" };
|
|
109
|
+
const accountId = getAccountId(tokenData.access_token);
|
|
110
|
+
if (!accountId) {
|
|
111
|
+
throw new Error("Failed to extract accountId from token");
|
|
166
112
|
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function createAuthorizationFlow(): Promise<{ verifier: string; state: string; url: string }> {
|
|
170
|
-
const { verifier, challenge } = await generatePKCE();
|
|
171
|
-
const state = createState();
|
|
172
|
-
|
|
173
|
-
const url = new URL(AUTHORIZE_URL);
|
|
174
|
-
url.searchParams.set("response_type", "code");
|
|
175
|
-
url.searchParams.set("client_id", CLIENT_ID);
|
|
176
|
-
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
177
|
-
url.searchParams.set("scope", SCOPE);
|
|
178
|
-
url.searchParams.set("code_challenge", challenge);
|
|
179
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
180
|
-
url.searchParams.set("state", state);
|
|
181
|
-
url.searchParams.set("id_token_add_organizations", "true");
|
|
182
|
-
url.searchParams.set("codex_cli_simplified_flow", "true");
|
|
183
|
-
url.searchParams.set("originator", "opencode");
|
|
184
|
-
|
|
185
|
-
return { verifier, state, url: url.toString() };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
type OAuthServerInfo = {
|
|
189
|
-
close: () => void;
|
|
190
|
-
cancelWait: () => void;
|
|
191
|
-
waitForCode: () => Promise<{ code: string } | null>;
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
|
|
195
|
-
let lastCode: string | null = null;
|
|
196
|
-
let cancelled = false;
|
|
197
|
-
const server = http.createServer((req, res) => {
|
|
198
|
-
try {
|
|
199
|
-
const url = new URL(req.url || "", "http://localhost");
|
|
200
|
-
if (url.pathname !== "/auth/callback") {
|
|
201
|
-
res.statusCode = 404;
|
|
202
|
-
res.end("Not found");
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
if (url.searchParams.get("state") !== state) {
|
|
206
|
-
res.statusCode = 400;
|
|
207
|
-
res.end("State mismatch");
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const code = url.searchParams.get("code");
|
|
211
|
-
if (!code) {
|
|
212
|
-
res.statusCode = 400;
|
|
213
|
-
res.end("Missing authorization code");
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
res.statusCode = 200;
|
|
217
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
218
|
-
res.end(SUCCESS_HTML);
|
|
219
|
-
lastCode = code;
|
|
220
|
-
} catch {
|
|
221
|
-
res.statusCode = 500;
|
|
222
|
-
res.end("Internal error");
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
return new Promise((resolve) => {
|
|
227
|
-
server
|
|
228
|
-
.listen(1455, "127.0.0.1", () => {
|
|
229
|
-
resolve({
|
|
230
|
-
close: () => server.close(),
|
|
231
|
-
cancelWait: () => {
|
|
232
|
-
cancelled = true;
|
|
233
|
-
},
|
|
234
|
-
waitForCode: async () => {
|
|
235
|
-
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
|
236
|
-
for (let i = 0; i < 600; i += 1) {
|
|
237
|
-
if (lastCode) return { code: lastCode };
|
|
238
|
-
if (cancelled) return null;
|
|
239
|
-
await sleep();
|
|
240
|
-
}
|
|
241
|
-
return null;
|
|
242
|
-
},
|
|
243
|
-
});
|
|
244
|
-
})
|
|
245
|
-
.on("error", (err: NodeJS.ErrnoException) => {
|
|
246
|
-
console.error(
|
|
247
|
-
"[openai-codex] Failed to bind http://127.0.0.1:1455 (",
|
|
248
|
-
err.code,
|
|
249
|
-
") Falling back to manual paste.",
|
|
250
|
-
);
|
|
251
|
-
resolve({
|
|
252
|
-
close: () => {
|
|
253
|
-
try {
|
|
254
|
-
server.close();
|
|
255
|
-
} catch {
|
|
256
|
-
// ignore
|
|
257
|
-
}
|
|
258
|
-
},
|
|
259
|
-
cancelWait: () => {},
|
|
260
|
-
waitForCode: async () => null,
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
113
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
114
|
+
return {
|
|
115
|
+
access: tokenData.access_token,
|
|
116
|
+
refresh: tokenData.refresh_token,
|
|
117
|
+
expires: Date.now() + tokenData.expires_in * 1000,
|
|
118
|
+
accountId,
|
|
119
|
+
};
|
|
271
120
|
}
|
|
272
121
|
|
|
273
122
|
/**
|
|
274
123
|
* Login with OpenAI Codex OAuth
|
|
275
|
-
*
|
|
276
|
-
* @param options.onAuth - Called with URL and instructions when auth starts
|
|
277
|
-
* @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput)
|
|
278
|
-
* @param options.onProgress - Optional progress messages
|
|
279
|
-
* @param options.onManualCodeInput - Optional promise that resolves with user-pasted code.
|
|
280
|
-
* Races with browser callback - whichever completes first wins.
|
|
281
|
-
* Useful for showing paste input immediately alongside browser flow.
|
|
282
124
|
*/
|
|
283
|
-
export async function loginOpenAICodex(
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
onManualCodeInput?: () => Promise<string>;
|
|
288
|
-
}): Promise<OAuthCredentials> {
|
|
289
|
-
const { verifier, state, url } = await createAuthorizationFlow();
|
|
290
|
-
const server = await startLocalOAuthServer(state);
|
|
291
|
-
|
|
292
|
-
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
|
|
125
|
+
export async function loginOpenAICodex(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
126
|
+
const pkce = await generatePKCE();
|
|
127
|
+
const flow = new OpenAICodexOAuthFlow(ctrl, pkce);
|
|
128
|
+
const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
293
129
|
|
|
294
|
-
let code: string | undefined;
|
|
295
130
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const manualPromise = options
|
|
301
|
-
.onManualCodeInput()
|
|
302
|
-
.then((input) => {
|
|
303
|
-
manualCode = input;
|
|
304
|
-
server.cancelWait();
|
|
305
|
-
})
|
|
306
|
-
.catch((err) => {
|
|
307
|
-
manualError = err instanceof Error ? err : new Error(String(err));
|
|
308
|
-
server.cancelWait();
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
const result = await server.waitForCode();
|
|
312
|
-
|
|
313
|
-
// If manual input was cancelled, throw that error
|
|
314
|
-
if (manualError) {
|
|
315
|
-
throw manualError;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
if (result?.code) {
|
|
319
|
-
// Browser callback won
|
|
320
|
-
code = result.code;
|
|
321
|
-
} else if (manualCode) {
|
|
322
|
-
// Manual input won (or callback timed out and user had entered code)
|
|
323
|
-
const parsed = parseAuthorizationInput(manualCode);
|
|
324
|
-
if (parsed.state && parsed.state !== state) {
|
|
325
|
-
throw new Error("State mismatch");
|
|
326
|
-
}
|
|
327
|
-
code = parsed.code;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// If still no code, wait for manual promise to complete and try that
|
|
331
|
-
if (!code) {
|
|
332
|
-
await manualPromise;
|
|
333
|
-
if (manualError) {
|
|
334
|
-
throw manualError;
|
|
335
|
-
}
|
|
336
|
-
if (manualCode) {
|
|
337
|
-
const parsed = parseAuthorizationInput(manualCode);
|
|
338
|
-
if (parsed.state && parsed.state !== state) {
|
|
339
|
-
throw new Error("State mismatch");
|
|
340
|
-
}
|
|
341
|
-
code = parsed.code;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
} else {
|
|
345
|
-
// Original flow: wait for callback, then prompt if needed
|
|
346
|
-
const result = await server.waitForCode();
|
|
347
|
-
if (result?.code) {
|
|
348
|
-
code = result.code;
|
|
349
|
-
}
|
|
131
|
+
return await flow.login();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!ctrl.onPrompt) {
|
|
134
|
+
throw error;
|
|
350
135
|
}
|
|
351
136
|
|
|
352
|
-
|
|
353
|
-
if (!code) {
|
|
354
|
-
const input = await options.onPrompt({
|
|
355
|
-
message: "Paste the authorization code (or full redirect URL):",
|
|
356
|
-
});
|
|
357
|
-
const parsed = parseAuthorizationInput(input);
|
|
358
|
-
if (parsed.state && parsed.state !== state) {
|
|
359
|
-
throw new Error("State mismatch");
|
|
360
|
-
}
|
|
361
|
-
code = parsed.code;
|
|
362
|
-
}
|
|
137
|
+
ctrl.onProgress?.("Callback server failed, falling back to manual input");
|
|
363
138
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const tokenResult = await exchangeAuthorizationCode(code, verifier);
|
|
369
|
-
if (tokenResult.type !== "success") {
|
|
370
|
-
throw new Error("Token exchange failed");
|
|
371
|
-
}
|
|
139
|
+
const input = await ctrl.onPrompt({
|
|
140
|
+
message: "Paste the authorization code (or full redirect URL):",
|
|
141
|
+
});
|
|
372
142
|
|
|
373
|
-
const
|
|
374
|
-
if (!
|
|
375
|
-
throw new Error("
|
|
143
|
+
const parsed = parseCallbackInput(input);
|
|
144
|
+
if (!parsed.code) {
|
|
145
|
+
throw new Error("No authorization code found in input");
|
|
376
146
|
}
|
|
377
147
|
|
|
378
|
-
return
|
|
379
|
-
access: tokenResult.access,
|
|
380
|
-
refresh: tokenResult.refresh,
|
|
381
|
-
expires: tokenResult.expires,
|
|
382
|
-
accountId,
|
|
383
|
-
};
|
|
384
|
-
} finally {
|
|
385
|
-
server.close();
|
|
148
|
+
return exchangeCodeForToken(parsed.code, pkce.verifier, redirectUri);
|
|
386
149
|
}
|
|
387
150
|
}
|
|
388
151
|
|
|
@@ -390,20 +153,36 @@ export async function loginOpenAICodex(options: {
|
|
|
390
153
|
* Refresh OpenAI Codex OAuth token
|
|
391
154
|
*/
|
|
392
155
|
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
156
|
+
const response = await fetch(TOKEN_URL, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
159
|
+
body: new URLSearchParams({
|
|
160
|
+
grant_type: "refresh_token",
|
|
161
|
+
refresh_token: refreshToken,
|
|
162
|
+
client_id: CLIENT_ID,
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`OpenAI Codex token refresh failed: ${response.status}`);
|
|
396
168
|
}
|
|
397
169
|
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
170
|
+
const tokenData = (await response.json()) as {
|
|
171
|
+
access_token?: string;
|
|
172
|
+
refresh_token?: string;
|
|
173
|
+
expires_in?: number;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
|
|
177
|
+
throw new Error("Token response missing required fields");
|
|
401
178
|
}
|
|
402
179
|
|
|
180
|
+
const accountId = getAccountId(tokenData.access_token);
|
|
181
|
+
|
|
403
182
|
return {
|
|
404
|
-
access:
|
|
405
|
-
refresh:
|
|
406
|
-
expires:
|
|
407
|
-
accountId,
|
|
183
|
+
access: tokenData.access_token,
|
|
184
|
+
refresh: tokenData.refresh_token || refreshToken,
|
|
185
|
+
expires: Date.now() + tokenData.expires_in * 1000,
|
|
186
|
+
accountId: accountId ?? undefined,
|
|
408
187
|
};
|
|
409
188
|
}
|