ima2-gen 3.18.0 → 3.19.0
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/bin/commands/doctor.js +13 -8
- package/bin/commands/gpt.js +216 -0
- package/bin/ima2.js +89 -53
- package/bin/lib/doctor-providers.js +19 -4
- package/docs/API.md +6 -3
- package/docs/migration/runtime-test-inventory.md +4 -1
- package/lib/authStatus.js +93 -0
- package/lib/chatgptAuth.js +236 -0
- package/lib/chatgptLogin.js +257 -0
- package/lib/codexDetect.js +16 -10
- package/lib/oauthLauncher.js +32 -0
- package/lib/oauthProxy/runtime.js +3 -0
- package/node_modules/openai-oauth/dist/chunk-2AENSHRT.js +84 -14
- package/node_modules/openai-oauth/package.json +2 -2
- package/package.json +3 -3
- package/routes/auth.js +111 -204
- package/routes/health.js +23 -9
- package/routes/quota.js +4 -9
- package/server.js +56 -11
- package/ui/dist/.vite/manifest.json +64 -64
- package/ui/dist/assets/{AgentWorkspace-CxbtxZYs.js → AgentWorkspace-C6ke6Eo4.js} +1 -1
- package/ui/dist/assets/{App-x_fzCXKb.js → App-pMN1OjNW.js} +8 -8
- package/ui/dist/assets/{AssetGenWorkspace-BIYh26wL.js → AssetGenWorkspace-DzEfpUy_.js} +2 -2
- package/ui/dist/assets/{AssetMediaLightbox-ordAqhjX.js → AssetMediaLightbox-Bx0azN0S.js} +1 -1
- package/ui/dist/assets/AssetsWorkspace-CHw1u9VC.js +1 -0
- package/ui/dist/assets/{CardNewsWorkspace-BfR-FMwo.js → CardNewsWorkspace-DsjqhaYO.js} +2 -2
- package/ui/dist/assets/{GenerationRequestLogPanel-CaVBCWhI.js → GenerationRequestLogPanel-EYEiHIr9.js} +1 -1
- package/ui/dist/assets/{HomeWorkspace-x-oJypd7.js → HomeWorkspace-DbnY1YF2.js} +1 -1
- package/ui/dist/assets/{KeyingPanel-SsAIeDy3.js → KeyingPanel-8J_RMrxg.js} +1 -1
- package/ui/dist/assets/{NodeCanvas-B1R-fOYq.js → NodeCanvas-DmjbNcTa.js} +1 -1
- package/ui/dist/assets/{PromptBuilderPanel-gGHOSb6l.js → PromptBuilderPanel-CL34Jpso.js} +1 -1
- package/ui/dist/assets/{PromptImportDialog-B5Wv2cie.js → PromptImportDialog-B24cvEkK.js} +2 -2
- package/ui/dist/assets/{PromptImportDiscoverySection-B5AdpMnV.js → PromptImportDiscoverySection-Bbw1_J_3.js} +1 -1
- package/ui/dist/assets/{PromptImportFolderSection-DgIWI0j2.js → PromptImportFolderSection-CMpxuE8e.js} +1 -1
- package/ui/dist/assets/PromptLibraryPanel-Ct98TJ61.js +2 -0
- package/ui/dist/assets/SettingsWorkspace-DL9Tm90P.js +1 -0
- package/ui/dist/assets/{SpriteRecipeWorkspace-ghZGUlM8.js → SpriteRecipeWorkspace-BJUH1ddP.js} +1 -1
- package/ui/dist/assets/{index-B-iNVdeL.js → index-CHeVGnoy.js} +6 -6
- package/ui/dist/assets/{index-Co3ZHk9P.js → index-D_LpLGWV.js} +3 -3
- package/ui/dist/assets/{pptxgen.es-D6s2Ip8Q.js → pptxgen.es-s4xfPXLT.js} +1 -1
- package/ui/dist/assets/{promptBuilderStore-CQxHJQz4.js → promptBuilderStore-DQR29LwN.js} +1 -1
- package/ui/dist/assets/useAgentDialogFocus-DAeS9IWG.js +1 -0
- package/ui/dist/index.html +1 -1
- package/vendor/openai-oauth-1.0.2-ima2.2.tgz +0 -0
- package/ui/dist/assets/AssetsWorkspace-BuiwpbQQ.js +0 -1
- package/ui/dist/assets/PromptLibraryPanel-CGcqRRsJ.js +0 -2
- package/ui/dist/assets/SettingsWorkspace-BhbAnrYw.js +0 -1
- package/ui/dist/assets/useAgentDialogFocus-ALfcs_Em.js +0 -1
- package/vendor/openai-oauth-1.0.2-ima2.1.tgz +0 -0
package/lib/oauthLauncher.js
CHANGED
|
@@ -11,6 +11,7 @@ export function startOAuthProxy(options = {}) {
|
|
|
11
11
|
let restartTimer = null;
|
|
12
12
|
let hasBeenReady = false;
|
|
13
13
|
let restartCount = 0;
|
|
14
|
+
let launchedAuthFile = null;
|
|
14
15
|
const MAX_RESTARTS = 3;
|
|
15
16
|
const detectAuth = options.detectAuth ?? detectCodexAuth;
|
|
16
17
|
const execPath = options.execPath ?? process.execPath;
|
|
@@ -20,6 +21,7 @@ export function startOAuthProxy(options = {}) {
|
|
|
20
21
|
// Guard: don't start if no auth file exists (avoids pointless crash loops
|
|
21
22
|
// and prevents openai-oauth from corrupting state on refresh failure)
|
|
22
23
|
const auth = detectAuth();
|
|
24
|
+
launchedAuthFile = typeof auth.proxyAuthFile === "string" ? auth.proxyAuthFile : null;
|
|
23
25
|
if (!auth.proxyReady || typeof auth.proxyAuthFile !== "string") {
|
|
24
26
|
console.log("[gpt-oauth] No file-backed Codex session found. Run `ima2 login` to enable GPT OAuth.");
|
|
25
27
|
options.onExit?.({ code: 0, reason: "missing-auth-file" });
|
|
@@ -116,5 +118,35 @@ export function startOAuthProxy(options = {}) {
|
|
|
116
118
|
}
|
|
117
119
|
catch { }
|
|
118
120
|
},
|
|
121
|
+
/**
|
|
122
|
+
* Stop and wait until the child has exited so its port is free. SIGTERM first; after
|
|
123
|
+
* timeoutMs, SIGKILL and wait again. Rejects if the child is still alive after both, so a
|
|
124
|
+
* caller never starts a replacement that would fall back to another port.
|
|
125
|
+
*/
|
|
126
|
+
stopAndWait(timeoutMs = 3000) {
|
|
127
|
+
const child = currentChild;
|
|
128
|
+
this.stop();
|
|
129
|
+
if (!child || child.exitCode !== null || child.signalCode !== null)
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
const exited = new Promise((resolve) => { child.once("exit", () => resolve()); });
|
|
132
|
+
const within = (ms) => Promise.race([
|
|
133
|
+
exited.then(() => true),
|
|
134
|
+
new Promise((resolve) => { setTimeout(() => resolve(false), ms); }),
|
|
135
|
+
]);
|
|
136
|
+
return within(timeoutMs).then(async (done) => {
|
|
137
|
+
if (done)
|
|
138
|
+
return;
|
|
139
|
+
try {
|
|
140
|
+
child.kill("SIGKILL");
|
|
141
|
+
}
|
|
142
|
+
catch { }
|
|
143
|
+
if (!(await within(2000)))
|
|
144
|
+
throw new Error("GPT OAuth proxy did not exit after SIGKILL");
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
/** The session file this launcher handed to the proxy (null when it found none). */
|
|
148
|
+
get authFile() {
|
|
149
|
+
return launchedAuthFile;
|
|
150
|
+
},
|
|
119
151
|
};
|
|
120
152
|
}
|
|
@@ -54,6 +54,9 @@ export function createOAuthGenerationTimeout(ctx = {}, requestId = null, scope =
|
|
|
54
54
|
export async function waitForOAuthReady(ctx = {}) {
|
|
55
55
|
if (!ctx || ctx.oauthReadyState === undefined)
|
|
56
56
|
return;
|
|
57
|
+
// A new login may have landed since the proxy started (or gave up), possibly from a CLI that
|
|
58
|
+
// could not reach this server; restart onto the new session file first. Cheap: a few stats.
|
|
59
|
+
ctx.syncOAuthProxySession?.();
|
|
57
60
|
const initialState = ctx.oauthReadyState;
|
|
58
61
|
if (initialState === "ready" || initialState === "disabled")
|
|
59
62
|
return;
|
|
@@ -148,7 +148,7 @@ var readAuthFile = async (candidates) => {
|
|
|
148
148
|
const content = await fs.readFile(candidate, "utf-8");
|
|
149
149
|
const parsed = JSON.parse(content);
|
|
150
150
|
if (isRecord(parsed)) {
|
|
151
|
-
return { path: candidate, data: toAuthFile(parsed) };
|
|
151
|
+
return { path: candidate, data: toAuthFile(parsed), raw: parsed };
|
|
152
152
|
}
|
|
153
153
|
} catch {
|
|
154
154
|
}
|
|
@@ -160,10 +160,17 @@ var ensureDirectory = async (filePath) => {
|
|
|
160
160
|
};
|
|
161
161
|
var writeAuthFile = async (filePath, data) => {
|
|
162
162
|
await ensureDirectory(filePath);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
163
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now().toString(36)}`;
|
|
164
|
+
try {
|
|
165
|
+
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), {
|
|
166
|
+
encoding: "utf-8",
|
|
167
|
+
mode: 384
|
|
168
|
+
});
|
|
169
|
+
await fs.rename(tmpPath, filePath);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
await fs.rm(tmpPath, { force: true }).catch(() => {});
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
167
174
|
};
|
|
168
175
|
var refreshChatGptTokens = async (refreshToken, clientId, issuer, tokenUrl, fetchFn) => {
|
|
169
176
|
const resolvedTokenUrl = tokenUrl ?? `${issuer.replace(/\/$/, "")}/oauth/token`;
|
|
@@ -216,6 +223,7 @@ var loadAuthTokens = async (options) => {
|
|
|
216
223
|
authFilePath,
|
|
217
224
|
fetch,
|
|
218
225
|
ensureFresh = true,
|
|
226
|
+
forceRefresh = false,
|
|
219
227
|
now = () => /* @__PURE__ */ new Date()
|
|
220
228
|
} = options;
|
|
221
229
|
if (typeof fetch !== "function") {
|
|
@@ -231,8 +239,9 @@ var loadAuthTokens = async (options) => {
|
|
|
231
239
|
let refreshToken = tokens.refresh_token;
|
|
232
240
|
let accountId = tokens.account_id ?? deriveAccountId(idToken);
|
|
233
241
|
let lastRefresh = authData.last_refresh;
|
|
234
|
-
const needsRefresh =
|
|
242
|
+
const needsRefresh = typeof refreshToken === "string" && (forceRefresh || ensureFresh && shouldRefreshAccessToken(accessToken, lastRefresh, now()));
|
|
235
243
|
if (needsRefresh && typeof refreshToken === "string") {
|
|
244
|
+
const usedRefreshToken = refreshToken;
|
|
236
245
|
const refreshed = await refreshChatGptTokens(
|
|
237
246
|
refreshToken,
|
|
238
247
|
clientId,
|
|
@@ -240,16 +249,29 @@ var loadAuthTokens = async (options) => {
|
|
|
240
249
|
tokenUrl,
|
|
241
250
|
fetch
|
|
242
251
|
);
|
|
243
|
-
|
|
252
|
+
// ima2 patch: compare-before-write. If another writer (a new `ima2 login`, Codex CLI)
|
|
253
|
+
// replaced the file while the token request was in flight, its session wins: use it and
|
|
254
|
+
// do not overwrite it with this now-obsolete refresh.
|
|
255
|
+
const latest = refreshed ? await readAuthFile([resolveWritePath(readResult.path ?? authFilePath)]) : {};
|
|
256
|
+
const latestTokens = normalizeTokens(latest.data?.tokens);
|
|
257
|
+
if (refreshed && latest.data && latestTokens.refresh_token !== usedRefreshToken && latestTokens.access_token) {
|
|
258
|
+
accessToken = latestTokens.access_token;
|
|
259
|
+
idToken = latestTokens.id_token;
|
|
260
|
+
refreshToken = latestTokens.refresh_token;
|
|
261
|
+
accountId = latestTokens.account_id ?? deriveAccountId(idToken);
|
|
262
|
+
lastRefresh = latest.data.last_refresh;
|
|
263
|
+
} else if (refreshed) {
|
|
244
264
|
accessToken = refreshed.accessToken;
|
|
245
265
|
idToken = refreshed.idToken ?? idToken;
|
|
246
266
|
refreshToken = refreshed.refreshToken ?? refreshToken;
|
|
247
267
|
accountId = refreshed.accountId ?? accountId;
|
|
248
268
|
lastRefresh = now().toISOString();
|
|
249
269
|
const writePath = resolveWritePath(readResult.path ?? authFilePath);
|
|
270
|
+
const rawTokens = isRecord(readResult.raw?.tokens) ? readResult.raw.tokens : {};
|
|
250
271
|
await writeAuthFile(writePath, {
|
|
251
|
-
...authData,
|
|
272
|
+
...(readResult.raw ?? authData),
|
|
252
273
|
tokens: {
|
|
274
|
+
...rawTokens,
|
|
253
275
|
id_token: idToken,
|
|
254
276
|
access_token: accessToken,
|
|
255
277
|
refresh_token: refreshToken,
|
|
@@ -17759,30 +17781,56 @@ var AuthManager = class {
|
|
|
17759
17781
|
this.settings = settings;
|
|
17760
17782
|
this.fetch = fetch;
|
|
17761
17783
|
}
|
|
17784
|
+
// ima2 patch: the session file is the source of truth. Another client (Codex CLI, a new
|
|
17785
|
+
// `ima2 login`) can rotate or replace it at any time, so every request re-reads it
|
|
17786
|
+
// (single-flight) instead of reusing the first token forever.
|
|
17762
17787
|
async ensure() {
|
|
17763
17788
|
if (this.inflight) {
|
|
17764
17789
|
return this.inflight;
|
|
17765
17790
|
}
|
|
17766
|
-
this.
|
|
17791
|
+
return this.load(false);
|
|
17792
|
+
}
|
|
17793
|
+
// One load at a time: ordinary reads and forced refreshes share a single in-flight promise,
|
|
17794
|
+
// so two requests can never spend the same (rotating) refresh token concurrently.
|
|
17795
|
+
load(forceRefresh) {
|
|
17796
|
+
const pending = loadAuthTokens({
|
|
17767
17797
|
authFilePath: this.settings.authFilePath,
|
|
17768
17798
|
clientId: this.settings.clientId,
|
|
17769
17799
|
issuer: this.settings.issuer,
|
|
17770
17800
|
tokenUrl: this.settings.tokenUrl,
|
|
17771
17801
|
fetch: this.fetch,
|
|
17772
17802
|
ensureFresh: this.settings.ensureFresh,
|
|
17803
|
+
forceRefresh,
|
|
17773
17804
|
now: this.settings.now
|
|
17774
17805
|
}).then((auth) => {
|
|
17806
|
+
if (this.inflight === pending) this.inflight = void 0;
|
|
17775
17807
|
this.current = auth;
|
|
17776
|
-
this.inflight = void 0;
|
|
17777
17808
|
return auth;
|
|
17778
17809
|
}).catch((error40) => {
|
|
17779
|
-
this.inflight = void 0;
|
|
17810
|
+
if (this.inflight === pending) this.inflight = void 0;
|
|
17780
17811
|
throw error40;
|
|
17781
17812
|
});
|
|
17782
|
-
|
|
17813
|
+
this.inflight = pending;
|
|
17814
|
+
return pending;
|
|
17815
|
+
}
|
|
17816
|
+
// Called after upstream rejected `rejectedToken`. Wait for any load already running, re-read
|
|
17817
|
+
// the file (another request may have refreshed already), and only force a refresh if the file
|
|
17818
|
+
// still holds the rejected token.
|
|
17819
|
+
async refresh(rejectedToken) {
|
|
17820
|
+
if (this.inflight) {
|
|
17821
|
+
await this.inflight.catch(() => {});
|
|
17822
|
+
}
|
|
17823
|
+
const fresh = await this.ensure();
|
|
17824
|
+
if (fresh.accessToken !== rejectedToken) {
|
|
17825
|
+
return fresh;
|
|
17826
|
+
}
|
|
17827
|
+
if (this.inflight) {
|
|
17828
|
+
return this.inflight;
|
|
17829
|
+
}
|
|
17830
|
+
return this.load(true);
|
|
17783
17831
|
}
|
|
17784
17832
|
async headers() {
|
|
17785
|
-
const auth =
|
|
17833
|
+
const auth = await this.ensure();
|
|
17786
17834
|
return {
|
|
17787
17835
|
Authorization: `Bearer ${auth.accessToken}`,
|
|
17788
17836
|
"chatgpt-account-id": auth.accountId,
|
|
@@ -17961,12 +18009,34 @@ var createCodexOAuthFetch = (settings = {}) => {
|
|
|
17961
18009
|
settings,
|
|
17962
18010
|
responsesState
|
|
17963
18011
|
);
|
|
17964
|
-
|
|
18012
|
+
let response = await fetch(target.toString(), {
|
|
17965
18013
|
method: request.method ?? init?.method,
|
|
17966
18014
|
headers,
|
|
17967
18015
|
body: preparedBody.body,
|
|
17968
18016
|
signal: request.signal ?? void 0
|
|
17969
18017
|
});
|
|
18018
|
+
const replayable = preparedBody.body == null || typeof preparedBody.body === "string" || preparedBody.body instanceof Uint8Array || preparedBody.body instanceof ArrayBuffer;
|
|
18019
|
+
if (response.status === 401 && replayable) {
|
|
18020
|
+
let refreshed;
|
|
18021
|
+
try {
|
|
18022
|
+
refreshed = await authManager.refresh(headers.get("authorization")?.slice(7));
|
|
18023
|
+
} catch {
|
|
18024
|
+
refreshed = void 0;
|
|
18025
|
+
}
|
|
18026
|
+
if (refreshed && refreshed.accessToken && refreshed.accessToken !== headers.get("authorization")?.slice(7)) {
|
|
18027
|
+
await response.body?.cancel().catch(() => {});
|
|
18028
|
+
const retryHeaders = await authManager.headers();
|
|
18029
|
+
for (const [key, value] of Object.entries(retryHeaders)) {
|
|
18030
|
+
headers.set(key, value);
|
|
18031
|
+
}
|
|
18032
|
+
response = await fetch(target.toString(), {
|
|
18033
|
+
method: request.method ?? init?.method,
|
|
18034
|
+
headers,
|
|
18035
|
+
body: preparedBody.body,
|
|
18036
|
+
signal: request.signal ?? void 0
|
|
18037
|
+
});
|
|
18038
|
+
}
|
|
18039
|
+
}
|
|
17970
18040
|
return captureResponsesState(
|
|
17971
18041
|
response,
|
|
17972
18042
|
preparedBody.requestBody,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openai-oauth",
|
|
3
|
-
"version": "1.0.2-ima2.
|
|
3
|
+
"version": "1.0.2-ima2.2",
|
|
4
4
|
"description": "Free OpenAI API access with your ChatGPT account.",
|
|
5
|
-
"ima2Patch": "adds originator/version headers + codex client version floor 0.144.0 for GPT-5.6 rollout",
|
|
5
|
+
"ima2Patch": "adds originator/version headers + codex client version floor 0.144.0 for GPT-5.6 rollout; re-reads the session file per request (no stale cached token), one serialized forced refresh + retry on upstream 401, compare-before-write so a concurrent login is never overwritten, atomic auth.json write-back that keeps unknown keys",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"openai-oauth": "./dist/cli.js"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ima2-gen",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"packageManager": "npm@11.18.0",
|
|
5
5
|
"description": "Local-first visual generation runtime and studio for people and coding agents, with reproducible image and video workflows across multiple providers.",
|
|
6
6
|
"type": "module",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"express": "^5.1.0",
|
|
100
100
|
"google-auth-library": "^11.0.1",
|
|
101
101
|
"openai": "^7.4.0",
|
|
102
|
-
"openai-oauth": "file:vendor/openai-oauth-1.0.2-ima2.
|
|
102
|
+
"openai-oauth": "file:vendor/openai-oauth-1.0.2-ima2.2.tgz",
|
|
103
103
|
"sharp": "^0.35.3",
|
|
104
104
|
"trash": "^10.1.1",
|
|
105
105
|
"ulid": "^3.0.2",
|
|
@@ -126,5 +126,5 @@
|
|
|
126
126
|
"typescript": "^5.9.3",
|
|
127
127
|
"yaml": "2.9.0"
|
|
128
128
|
},
|
|
129
|
-
"gitHead": "
|
|
129
|
+
"gitHead": "b00ac61d6a11bd1d14a3ee987fbdb978f366c85f"
|
|
130
130
|
}
|
package/routes/auth.js
CHANGED
|
@@ -1,232 +1,128 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { XAI_OAUTH_CLIENT_ID as GROK_CLIENT_ID, XAI_OAUTH_SCOPE as GROK_SCOPE, XAI_TOKEN_ENDPOINT_FALLBACK as GROK_TOKEN_URL, loadGrokCredentials, saveGrokCredentials, } from "../lib/xaiAuth.js";
|
|
6
|
-
const CODEX_DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
2
|
+
import { runChatgptLogin } from "../lib/chatgptLogin.js";
|
|
3
|
+
import { runXaiDeviceLogin } from "../lib/xaiDeviceLogin.js";
|
|
7
4
|
const MAX_CONCURRENT_SESSIONS = 20;
|
|
5
|
+
const PROMPT_TIMEOUT_MS = 30_000;
|
|
8
6
|
const sessions = new Map();
|
|
9
7
|
function sid() {
|
|
10
8
|
return randomBytes(16).toString("hex");
|
|
11
9
|
}
|
|
12
|
-
function
|
|
10
|
+
function finish(id, status, error) {
|
|
13
11
|
const s = sessions.get(id);
|
|
14
|
-
if (s
|
|
15
|
-
|
|
16
|
-
if (s
|
|
17
|
-
s.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
setTimeout(() => sessions.delete(id), 120_000);
|
|
21
|
-
}
|
|
22
|
-
function stripAnsi(s) {
|
|
23
|
-
return s.replace(/\x1B\[[0-9;]*m/g, "");
|
|
24
|
-
}
|
|
25
|
-
function saveGrokTokens(tokens) {
|
|
26
|
-
if (typeof tokens.access_token !== "string" || tokens.access_token.length === 0) {
|
|
27
|
-
throw new Error("xAI token response did not include an access token");
|
|
12
|
+
if (!s)
|
|
13
|
+
return;
|
|
14
|
+
if (s.status === "pending") {
|
|
15
|
+
s.status = status;
|
|
16
|
+
if (error)
|
|
17
|
+
s.error = error;
|
|
28
18
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
catch { /* ignore */ }
|
|
19
|
+
s.abort.abort();
|
|
20
|
+
setTimeout(() => sessions.delete(id), 120_000).unref?.();
|
|
21
|
+
}
|
|
22
|
+
/** Only one ChatGPT browser login can own localhost:1455; a new start retires the old ones. */
|
|
23
|
+
function cancelPending(provider) {
|
|
24
|
+
for (const [id, s] of sessions) {
|
|
25
|
+
if (s.provider === provider && s.status === "pending")
|
|
26
|
+
finish(id, "error", "superseded by a newer login");
|
|
39
27
|
}
|
|
40
|
-
// Merge over the existing file so a field only progrok writes (idToken) survives a
|
|
41
|
-
// fresh login; the atomic 0600 write itself lives in lib/xaiAuth.ts.
|
|
42
|
-
const data = {
|
|
43
|
-
...(loadGrokCredentials() ?? {}),
|
|
44
|
-
accessToken: tokens.access_token,
|
|
45
|
-
...(typeof tokens.refresh_token === "string" ? { refreshToken: tokens.refresh_token } : {}),
|
|
46
|
-
...(typeof tokens.expires_in === "number" ? { expiresAt: Date.now() + tokens.expires_in * 1000 } : {}),
|
|
47
|
-
tokenEndpoint: GROK_TOKEN_URL,
|
|
48
|
-
...(typeof tokens.id_token === "string" ? { idToken: tokens.id_token } : {}),
|
|
49
|
-
...(email ? { email } : {}),
|
|
50
|
-
};
|
|
51
|
-
saveGrokCredentials(data);
|
|
52
28
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const disc = await discovery.json();
|
|
56
|
-
if (!disc.device_authorization_endpoint)
|
|
57
|
-
throw new Error("xAI does not expose device_authorization_endpoint");
|
|
58
|
-
const res = await fetch(disc.device_authorization_endpoint, {
|
|
59
|
-
method: "POST",
|
|
60
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
61
|
-
body: new URLSearchParams({ client_id: GROK_CLIENT_ID, scope: GROK_SCOPE }),
|
|
62
|
-
signal: AbortSignal.timeout(15000),
|
|
63
|
-
});
|
|
64
|
-
if (!res.ok)
|
|
65
|
-
throw new Error(`Device code request failed: ${res.status}`);
|
|
66
|
-
const dc = await res.json();
|
|
67
|
-
const id = sid();
|
|
68
|
-
const session = {
|
|
69
|
-
provider: "grok",
|
|
70
|
-
userCode: dc.user_code,
|
|
71
|
-
verificationUrl: dc.verification_uri_complete || dc.verification_uri,
|
|
72
|
-
expiresAt: Date.now() + dc.expires_in * 1000,
|
|
73
|
-
status: "pending",
|
|
74
|
-
deviceCode: dc.device_code,
|
|
75
|
-
};
|
|
76
|
-
sessions.set(id, session);
|
|
77
|
-
const interval = Math.max((dc.interval || 5) * 1000, 5000);
|
|
78
|
-
session.pollTimer = setInterval(async () => {
|
|
79
|
-
if (session.status !== "pending") {
|
|
80
|
-
cleanup(id);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (Date.now() > session.expiresAt) {
|
|
84
|
-
session.status = "expired";
|
|
85
|
-
cleanup(id);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
try {
|
|
89
|
-
const tokenRes = await fetch(disc.token_endpoint, {
|
|
90
|
-
method: "POST",
|
|
91
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
92
|
-
body: new URLSearchParams({
|
|
93
|
-
grant_type: CODEX_DEVICE_CODE_GRANT,
|
|
94
|
-
client_id: GROK_CLIENT_ID,
|
|
95
|
-
device_code: dc.device_code,
|
|
96
|
-
}),
|
|
97
|
-
signal: AbortSignal.timeout(10000),
|
|
98
|
-
});
|
|
99
|
-
if (tokenRes.ok) {
|
|
100
|
-
const tokens = await tokenRes.json();
|
|
101
|
-
saveGrokTokens(tokens);
|
|
102
|
-
session.status = "complete";
|
|
103
|
-
cleanup(id);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const err = await tokenRes.json();
|
|
107
|
-
if (err.error !== "authorization_pending" && err.error !== "slow_down") {
|
|
108
|
-
session.status = "error";
|
|
109
|
-
session.error = err.error || "unknown";
|
|
110
|
-
cleanup(id);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
catch { /* network error, keep polling */ }
|
|
114
|
-
}, interval);
|
|
115
|
-
return { sessionId: id, userCode: dc.user_code, verificationUrl: session.verificationUrl, expiresIn: dc.expires_in };
|
|
29
|
+
function errorMessage(error) {
|
|
30
|
+
return error instanceof Error ? error.message : String(error);
|
|
116
31
|
}
|
|
117
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Starts a flow and resolves as soon as the user has something to open. The flow keeps
|
|
34
|
+
* running in the background and records its outcome on the session.
|
|
35
|
+
*/
|
|
36
|
+
function startSession(provider, run, onComplete, live) {
|
|
37
|
+
const id = sid();
|
|
38
|
+
const abort = new AbortController();
|
|
39
|
+
live?.add(abort);
|
|
118
40
|
return new Promise((resolve, reject) => {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for (const k of ["OPENAI_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "VERTEX_SERVICE_ACCOUNT_JSON", "ATLASCLOUD_API_KEY", "MINIMAX_API_KEY"]) {
|
|
123
|
-
delete childEnv[k];
|
|
124
|
-
}
|
|
125
|
-
const codex = packageCliCommand("@openai/codex", "codex", codexFileLoginArgs({ deviceAuth: true }));
|
|
126
|
-
const child = spawn(codex.command, codex.args, {
|
|
127
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
128
|
-
env: childEnv,
|
|
129
|
-
shell: false,
|
|
130
|
-
windowsHide: true,
|
|
131
|
-
});
|
|
132
|
-
let stdout = "";
|
|
133
|
-
let resolved = false;
|
|
134
|
-
const id = sid();
|
|
135
|
-
const session = {
|
|
136
|
-
provider: "codex",
|
|
137
|
-
userCode: "",
|
|
138
|
-
verificationUrl: "",
|
|
139
|
-
expiresAt: Date.now() + 15 * 60 * 1000,
|
|
140
|
-
status: "pending",
|
|
141
|
-
child,
|
|
142
|
-
};
|
|
143
|
-
sessions.set(id, session);
|
|
144
|
-
// Server-side reaper: if the client abandons the flow (closes browser, stops
|
|
145
|
-
// polling), kill the lingering codex child instead of waiting for it to self-exit.
|
|
146
|
-
const reaper = setTimeout(() => {
|
|
147
|
-
if (session.status === "pending") {
|
|
148
|
-
session.status = "expired";
|
|
149
|
-
cleanup(id);
|
|
150
|
-
}
|
|
151
|
-
}, 16 * 60 * 1000);
|
|
152
|
-
reaper.unref?.();
|
|
153
|
-
child.stdout?.on("data", (chunk) => {
|
|
154
|
-
stdout += chunk.toString();
|
|
155
|
-
if (resolved)
|
|
41
|
+
let prompted = false;
|
|
42
|
+
const promptTimer = setTimeout(() => {
|
|
43
|
+
if (prompted)
|
|
156
44
|
return;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
45
|
+
abort.abort();
|
|
46
|
+
reject(new Error("Timed out starting the login"));
|
|
47
|
+
}, PROMPT_TIMEOUT_MS);
|
|
48
|
+
run(abort.signal, (prompt) => {
|
|
49
|
+
prompted = true;
|
|
50
|
+
clearTimeout(promptTimer);
|
|
51
|
+
const session = {
|
|
52
|
+
provider,
|
|
53
|
+
flow: prompt.flow,
|
|
54
|
+
userCode: prompt.userCode ?? "",
|
|
55
|
+
verificationUrl: prompt.url,
|
|
56
|
+
expiresAt: Date.now() + prompt.expiresIn * 1000,
|
|
57
|
+
status: "pending",
|
|
58
|
+
abort,
|
|
59
|
+
};
|
|
60
|
+
sessions.set(id, session);
|
|
61
|
+
// Server-side reaper: an abandoned flow must not hold its poll loop or port forever.
|
|
62
|
+
setTimeout(() => finish(id, "expired"), prompt.expiresIn * 1000 + 5_000).unref?.();
|
|
63
|
+
resolve({ sessionId: id, flow: prompt.flow, userCode: session.userCode, verificationUrl: prompt.url, expiresIn: prompt.expiresIn });
|
|
64
|
+
}).finally(() => live?.delete(abort)).then((result) => {
|
|
65
|
+
const session = sessions.get(id);
|
|
66
|
+
if (session && result.email)
|
|
67
|
+
session.email = result.email;
|
|
68
|
+
if (session?.status === "pending")
|
|
69
|
+
onComplete?.();
|
|
70
|
+
finish(id, "complete");
|
|
71
|
+
}, (error) => {
|
|
72
|
+
clearTimeout(promptTimer);
|
|
73
|
+
if (!prompted) {
|
|
74
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
180
75
|
return;
|
|
181
76
|
}
|
|
182
|
-
|
|
183
|
-
session.status = proxyReady ? "complete" : "error";
|
|
184
|
-
if (code !== 0)
|
|
185
|
-
session.error = `codex exited with code ${code}`;
|
|
186
|
-
else if (!proxyReady)
|
|
187
|
-
session.error = "Codex login did not create a file-backed GPT OAuth session";
|
|
188
|
-
cleanup(id);
|
|
77
|
+
finish(id, abort.signal.aborted ? "expired" : "error", errorMessage(error));
|
|
189
78
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Every codex login that has started but not settled, including ones still waiting for their
|
|
83
|
+
* first prompt (not yet in `sessions`). A new start aborts all of them synchronously, before
|
|
84
|
+
* its own first await, so two near-simultaneous starts can never both save a session.
|
|
85
|
+
*/
|
|
86
|
+
const liveCodexLogins = new Set();
|
|
87
|
+
function startCodexLogin(flow, ctx) {
|
|
88
|
+
cancelPending("codex");
|
|
89
|
+
for (const controller of liveCodexLogins)
|
|
90
|
+
controller.abort();
|
|
91
|
+
liveCodexLogins.clear();
|
|
92
|
+
return startSession("codex", async (signal, onPrompt) => {
|
|
93
|
+
const session = await runChatgptLogin({
|
|
94
|
+
flow,
|
|
95
|
+
signal,
|
|
96
|
+
onPrompt: (prompt) => onPrompt(prompt),
|
|
199
97
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
98
|
+
return session.email ? { email: session.email } : {};
|
|
99
|
+
}, () => { ctx?.restartOAuthProxy?.(); }, liveCodexLogins);
|
|
100
|
+
}
|
|
101
|
+
function startGrokLogin(ctx) {
|
|
102
|
+
return startSession("grok", async (signal, onPrompt) => {
|
|
103
|
+
const creds = await runXaiDeviceLogin({
|
|
104
|
+
signal,
|
|
105
|
+
...(ctx?.grokAuthHomeDir ? { homeDir: ctx.grokAuthHomeDir } : {}),
|
|
106
|
+
onUserCode: (info) => onPrompt({ flow: "device", url: info.verificationUrl, userCode: info.userCode, expiresIn: info.expiresIn }),
|
|
107
|
+
});
|
|
108
|
+
return creds.email ? { email: creds.email } : {};
|
|
208
109
|
});
|
|
209
110
|
}
|
|
210
|
-
|
|
211
|
-
* routes/index.ts; the device-code flow writes credentials to disk and no
|
|
212
|
-
* longer notifies any in-process supervisor. */
|
|
213
|
-
export function registerAuthRoutes(app, _ctx) {
|
|
111
|
+
export function registerAuthRoutes(app, ctx) {
|
|
214
112
|
app.post("/api/auth/switch", async (req, res) => {
|
|
215
113
|
const provider = req.body?.provider;
|
|
216
114
|
if (provider !== "grok" && provider !== "codex") {
|
|
217
115
|
return res.status(400).json({ error: "provider must be grok or codex" });
|
|
218
116
|
}
|
|
117
|
+
const flow = req.body?.flow === "device" ? "device" : "browser";
|
|
219
118
|
if (sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
220
119
|
return res.status(429).json({ error: "Too many pending auth sessions" });
|
|
221
120
|
}
|
|
222
121
|
try {
|
|
223
|
-
|
|
224
|
-
? await startGrokDeviceCode()
|
|
225
|
-
: await startCodexDeviceCode();
|
|
226
|
-
res.json(result);
|
|
122
|
+
res.json(provider === "grok" ? await startGrokLogin(ctx) : await startCodexLogin(flow, ctx));
|
|
227
123
|
}
|
|
228
124
|
catch (e) {
|
|
229
|
-
res.status(502).json({ error: e
|
|
125
|
+
res.status(502).json({ error: errorMessage(e) });
|
|
230
126
|
}
|
|
231
127
|
});
|
|
232
128
|
app.get("/api/auth/switch/:sessionId", (req, res) => {
|
|
@@ -234,14 +130,25 @@ export function registerAuthRoutes(app, _ctx) {
|
|
|
234
130
|
if (!session)
|
|
235
131
|
return res.status(404).json({ status: "expired" });
|
|
236
132
|
if (session.status === "complete")
|
|
237
|
-
return res.json({ status: "complete" });
|
|
133
|
+
return res.json({ status: "complete", ...(session.email ? { email: session.email } : {}) });
|
|
238
134
|
if (session.status === "error")
|
|
239
135
|
return res.json({ status: "error", error: session.error });
|
|
240
|
-
if (Date.now() > session.expiresAt) {
|
|
241
|
-
|
|
242
|
-
cleanup(req.params.sessionId);
|
|
136
|
+
if (session.status === "expired" || Date.now() > session.expiresAt) {
|
|
137
|
+
finish(req.params.sessionId, "expired");
|
|
243
138
|
return res.json({ status: "expired" });
|
|
244
139
|
}
|
|
245
|
-
res.json({ status: "pending" });
|
|
140
|
+
res.json({ status: "pending", flow: session.flow });
|
|
141
|
+
});
|
|
142
|
+
app.delete("/api/auth/switch/:sessionId", (req, res) => {
|
|
143
|
+
const session = sessions.get(req.params.sessionId);
|
|
144
|
+
if (!session)
|
|
145
|
+
return res.status(404).json({ status: "expired" });
|
|
146
|
+
finish(req.params.sessionId, "expired", "cancelled");
|
|
147
|
+
res.json({ status: "expired" });
|
|
148
|
+
});
|
|
149
|
+
/** CLI logins write the session file directly; this lets them tell a running server. */
|
|
150
|
+
app.post("/api/oauth/restart", (_req, res) => {
|
|
151
|
+
const result = ctx?.restartOAuthProxy?.() ?? { restarted: false, reason: "oauth proxy is not managed by this server" };
|
|
152
|
+
res.json(result);
|
|
246
153
|
});
|
|
247
154
|
}
|