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.
Files changed (49) hide show
  1. package/bin/commands/doctor.js +13 -8
  2. package/bin/commands/gpt.js +216 -0
  3. package/bin/ima2.js +89 -53
  4. package/bin/lib/doctor-providers.js +19 -4
  5. package/docs/API.md +6 -3
  6. package/docs/migration/runtime-test-inventory.md +4 -1
  7. package/lib/authStatus.js +93 -0
  8. package/lib/chatgptAuth.js +236 -0
  9. package/lib/chatgptLogin.js +257 -0
  10. package/lib/codexDetect.js +16 -10
  11. package/lib/oauthLauncher.js +32 -0
  12. package/lib/oauthProxy/runtime.js +3 -0
  13. package/node_modules/openai-oauth/dist/chunk-2AENSHRT.js +84 -14
  14. package/node_modules/openai-oauth/package.json +2 -2
  15. package/package.json +3 -3
  16. package/routes/auth.js +111 -204
  17. package/routes/health.js +23 -9
  18. package/routes/quota.js +4 -9
  19. package/server.js +56 -11
  20. package/ui/dist/.vite/manifest.json +64 -64
  21. package/ui/dist/assets/{AgentWorkspace-CxbtxZYs.js → AgentWorkspace-C6ke6Eo4.js} +1 -1
  22. package/ui/dist/assets/{App-x_fzCXKb.js → App-pMN1OjNW.js} +8 -8
  23. package/ui/dist/assets/{AssetGenWorkspace-BIYh26wL.js → AssetGenWorkspace-DzEfpUy_.js} +2 -2
  24. package/ui/dist/assets/{AssetMediaLightbox-ordAqhjX.js → AssetMediaLightbox-Bx0azN0S.js} +1 -1
  25. package/ui/dist/assets/AssetsWorkspace-CHw1u9VC.js +1 -0
  26. package/ui/dist/assets/{CardNewsWorkspace-BfR-FMwo.js → CardNewsWorkspace-DsjqhaYO.js} +2 -2
  27. package/ui/dist/assets/{GenerationRequestLogPanel-CaVBCWhI.js → GenerationRequestLogPanel-EYEiHIr9.js} +1 -1
  28. package/ui/dist/assets/{HomeWorkspace-x-oJypd7.js → HomeWorkspace-DbnY1YF2.js} +1 -1
  29. package/ui/dist/assets/{KeyingPanel-SsAIeDy3.js → KeyingPanel-8J_RMrxg.js} +1 -1
  30. package/ui/dist/assets/{NodeCanvas-B1R-fOYq.js → NodeCanvas-DmjbNcTa.js} +1 -1
  31. package/ui/dist/assets/{PromptBuilderPanel-gGHOSb6l.js → PromptBuilderPanel-CL34Jpso.js} +1 -1
  32. package/ui/dist/assets/{PromptImportDialog-B5Wv2cie.js → PromptImportDialog-B24cvEkK.js} +2 -2
  33. package/ui/dist/assets/{PromptImportDiscoverySection-B5AdpMnV.js → PromptImportDiscoverySection-Bbw1_J_3.js} +1 -1
  34. package/ui/dist/assets/{PromptImportFolderSection-DgIWI0j2.js → PromptImportFolderSection-CMpxuE8e.js} +1 -1
  35. package/ui/dist/assets/PromptLibraryPanel-Ct98TJ61.js +2 -0
  36. package/ui/dist/assets/SettingsWorkspace-DL9Tm90P.js +1 -0
  37. package/ui/dist/assets/{SpriteRecipeWorkspace-ghZGUlM8.js → SpriteRecipeWorkspace-BJUH1ddP.js} +1 -1
  38. package/ui/dist/assets/{index-B-iNVdeL.js → index-CHeVGnoy.js} +6 -6
  39. package/ui/dist/assets/{index-Co3ZHk9P.js → index-D_LpLGWV.js} +3 -3
  40. package/ui/dist/assets/{pptxgen.es-D6s2Ip8Q.js → pptxgen.es-s4xfPXLT.js} +1 -1
  41. package/ui/dist/assets/{promptBuilderStore-CQxHJQz4.js → promptBuilderStore-DQR29LwN.js} +1 -1
  42. package/ui/dist/assets/useAgentDialogFocus-DAeS9IWG.js +1 -0
  43. package/ui/dist/index.html +1 -1
  44. package/vendor/openai-oauth-1.0.2-ima2.2.tgz +0 -0
  45. package/ui/dist/assets/AssetsWorkspace-BuiwpbQQ.js +0 -1
  46. package/ui/dist/assets/PromptLibraryPanel-CGcqRRsJ.js +0 -2
  47. package/ui/dist/assets/SettingsWorkspace-BhbAnrYw.js +0 -1
  48. package/ui/dist/assets/useAgentDialogFocus-ALfcs_Em.js +0 -1
  49. package/vendor/openai-oauth-1.0.2-ima2.1.tgz +0 -0
@@ -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
- await fs.writeFile(filePath, JSON.stringify(data, null, 2), {
164
- encoding: "utf-8",
165
- mode: 384
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 = ensureFresh && typeof refreshToken === "string" && shouldRefreshAccessToken(accessToken, lastRefresh, now());
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
- if (refreshed) {
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.inflight = loadAuthTokens({
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
- return this.inflight;
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 = this.current ?? await this.ensure();
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
- const response = await fetch(target.toString(), {
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.1",
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.18.0",
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.1.tgz",
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": "38f2724a181d452015a9a0ea3d6f317513d86114"
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 { codexFileLoginArgs, detectCodexAuth } from "../lib/codexDetect.js";
4
- import { packageCliCommand } from "../lib/packageCli.js";
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 cleanup(id) {
10
+ function finish(id, status, error) {
13
11
  const s = sessions.get(id);
14
- if (s?.pollTimer)
15
- clearInterval(s.pollTimer);
16
- if (s?.child && !s.child.killed)
17
- s.child.kill();
18
- if (s)
19
- delete s.deviceCode;
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
- let email;
30
- if (typeof tokens.id_token === "string") {
31
- try {
32
- const segment = tokens.id_token.split(".")[1];
33
- if (!segment)
34
- throw new Error("missing jwt payload");
35
- const payload = JSON.parse(Buffer.from(segment, "base64url").toString());
36
- email = payload.email;
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
- async function startGrokDeviceCode() {
54
- const discovery = await fetch("https://auth.x.ai/.well-known/openid-configuration", { signal: AbortSignal.timeout(10000) });
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
- function startCodexDeviceCode() {
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
- // Don't hand other providers' secrets to the codex child — it only needs
120
- // PATH/HOME/codex config to run the ChatGPT device-code login.
121
- const childEnv = { ...process.env };
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
- const clean = stripAnsi(stdout);
158
- const urlMatch = clean.match(/https:\/\/auth\.openai\.com\/codex\/device/);
159
- const codeMatch = clean.match(/^\s+([A-Z0-9]{4}-[A-Z0-9]{4,5})\s*$/m);
160
- if (urlMatch && codeMatch) {
161
- resolved = true;
162
- const userCode = codeMatch[1];
163
- if (!userCode)
164
- return;
165
- session.userCode = userCode;
166
- session.verificationUrl = urlMatch[0];
167
- resolve({
168
- sessionId: id,
169
- userCode,
170
- verificationUrl: urlMatch[0],
171
- expiresIn: 900,
172
- });
173
- }
174
- });
175
- child.stderr?.on("data", () => { });
176
- child.on("close", (code) => {
177
- if (!resolved) {
178
- sessions.delete(id);
179
- reject(new Error(`codex login exited with code ${code} before providing device code`));
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
- const proxyReady = code === 0 && detectCodexAuth().proxyReady;
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
- child.on("error", (err) => {
191
- if (!resolved) {
192
- sessions.delete(id);
193
- reject(new Error(`codex not found: ${err.message}`));
194
- return;
195
- }
196
- session.status = "error";
197
- session.error = err.message;
198
- cleanup(id);
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
- setTimeout(() => {
201
- if (!resolved) {
202
- sessions.delete(id);
203
- if (!child.killed)
204
- child.kill();
205
- reject(new Error("Timed out waiting for codex device code output"));
206
- }
207
- }, 30000);
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
- /** `_ctx` is accepted only to keep the registration signature uniform in
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
- const result = provider === "grok"
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.message });
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
- session.status = "expired";
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
  }