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
@@ -0,0 +1,93 @@
1
+ /**
2
+ * One status projection for every OAuth login, shared by `ima2 status`, `ima2 gpt status`,
3
+ * `ima2 doctor`, and GET /api/oauth/status. Modelled on OpenCodex `getLoginStatus` +
4
+ * `src/oauth/health.ts`: a health verdict, the account (masked), and the one action that fixes
5
+ * it. No tokens and no full account ids ever leave this module.
6
+ *
7
+ * MUST stay a leaf module apart from the two credential stores.
8
+ */
9
+ import { resolveChatgptSession } from "./chatgptAuth.js";
10
+ import { loadGrokCredentials } from "./xaiAuth.js";
11
+ export function maskAccountId(id) {
12
+ if (!id)
13
+ return undefined;
14
+ return id.length <= 8 ? `${id.slice(0, 2)}…` : `${id.slice(0, 8)}…`;
15
+ }
16
+ /**
17
+ * An expired access token with a refresh token is still a usable session: the proxy refreshes
18
+ * it on the next request (OpenCodex getLoginStatus treats it the same way).
19
+ */
20
+ export function gptSessionState(session, now = Date.now()) {
21
+ if (!session)
22
+ return "none";
23
+ if (!session.refreshable)
24
+ return "no_refresh_token";
25
+ if (session.accessExpiresAt !== undefined && session.accessExpiresAt <= now)
26
+ return "access_expired";
27
+ return "ready";
28
+ }
29
+ export function gptAuthStatus(session = resolveChatgptSession(), options = {}) {
30
+ const state = gptSessionState(session, options.now);
31
+ if (!session) {
32
+ return { provider: "gpt", loggedIn: false, health: "not_logged_in", reason: "no_session", refreshable: false, action: "ima2 login" };
33
+ }
34
+ const base = {
35
+ provider: "gpt",
36
+ loggedIn: true,
37
+ health: "healthy",
38
+ source: session.source,
39
+ refreshable: session.refreshable,
40
+ ...(session.email ? { email: session.email } : {}),
41
+ ...(session.plan ? { plan: session.plan } : {}),
42
+ ...(session.accountId ? { accountId: maskAccountId(session.accountId) } : {}),
43
+ ...(session.accessExpiresAt !== undefined ? { expiresAt: new Date(session.accessExpiresAt).toISOString() } : {}),
44
+ };
45
+ if (state === "no_refresh_token") {
46
+ return { ...base, loggedIn: false, health: "reauth_required", reason: "no_refresh_token", action: "ima2 login" };
47
+ }
48
+ if (options.proxyStatus === "auth_required") {
49
+ return {
50
+ ...base,
51
+ loggedIn: false,
52
+ health: "reauth_required",
53
+ reason: "proxy_rejected_session",
54
+ action: "ima2 login",
55
+ note: "The GPT OAuth proxy was refused by ChatGPT with this session (revoked, or its refresh token was rotated by another client).",
56
+ };
57
+ }
58
+ if (session.source !== "ima2") {
59
+ return {
60
+ ...base,
61
+ health: "warning",
62
+ reason: "shared_with_codex_cli",
63
+ action: "ima2 login",
64
+ note: "Shared with the Codex CLI: whichever client refreshes second can be logged out. Logging in with ima2 gives it its own session.",
65
+ };
66
+ }
67
+ if (state === "access_expired")
68
+ return { ...base, note: "The access token refreshes on the next request." };
69
+ return base;
70
+ }
71
+ export function grokAuthStatus(creds = loadGrokCredentials(), now = Date.now()) {
72
+ if (!creds) {
73
+ return { provider: "grok", loggedIn: false, health: "not_logged_in", reason: "no_session", refreshable: false, action: "ima2 grok login" };
74
+ }
75
+ const refreshable = typeof creds.refreshToken === "string" && creds.refreshToken.length > 0;
76
+ const base = {
77
+ provider: "grok",
78
+ loggedIn: true,
79
+ health: "healthy",
80
+ source: "progrok",
81
+ refreshable,
82
+ ...(creds.email ? { email: creds.email } : {}),
83
+ ...(creds.accountId ? { accountId: maskAccountId(creds.accountId) } : {}),
84
+ ...(creds.expiresAt !== undefined ? { expiresAt: new Date(creds.expiresAt).toISOString() } : {}),
85
+ };
86
+ const expired = creds.expiresAt !== undefined && creds.expiresAt <= now;
87
+ if (expired && !refreshable) {
88
+ return { ...base, loggedIn: false, health: "reauth_required", reason: "session_expired", action: "ima2 grok login" };
89
+ }
90
+ if (expired)
91
+ return { ...base, note: "The access token refreshes on the next request." };
92
+ return base;
93
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * ChatGPT (GPT OAuth) credential store owned by ima2.
3
+ *
4
+ * Ported from OpenCodex `src/oauth/chatgpt.ts` (constants, JWT identity helpers) and its
5
+ * store discipline (atomic 0600 writes, 0700 directory). ima2 keeps its own session file so
6
+ * the openai-oauth proxy and the Codex CLI never rotate the same refresh token: a shared
7
+ * ~/.codex/auth.json made whichever process refreshed second lose the session
8
+ * (refresh_token_reused), which surfaced as "logged in, but GPT OAuth still says log in".
9
+ *
10
+ * The file keeps the Codex auth.json shape (`tokens.{id_token,access_token,refresh_token,
11
+ * account_id}` + `last_refresh`) because openai-oauth reads it through `--oauth-file` and
12
+ * writes refreshed tokens back into it.
13
+ *
14
+ * MUST stay a leaf module: only node:fs, node:crypto, node:os, node:path.
15
+ */
16
+ import { randomBytes } from "node:crypto";
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ export const CHATGPT_OAUTH_ISSUER = "https://auth.openai.com";
21
+ export const CHATGPT_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
22
+ export const CHATGPT_AUTHORIZE_URL = `${CHATGPT_OAUTH_ISSUER}/oauth/authorize`;
23
+ export const CHATGPT_TOKEN_URL = `${CHATGPT_OAUTH_ISSUER}/oauth/token`;
24
+ export const CHATGPT_OAUTH_SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
25
+ const CHATGPT_AUTH_NAMESPACE = "https://api.openai.com/auth";
26
+ const AUTH_FILENAME = "chatgpt-auth.json";
27
+ export function ima2ConfigDir() {
28
+ return process.env.IMA2_CONFIG_DIR || join(homedir(), ".ima2");
29
+ }
30
+ export function chatgptAuthFilePath(configDir = ima2ConfigDir()) {
31
+ return join(configDir, AUTH_FILENAME);
32
+ }
33
+ /** Candidate session files in priority order: ima2's own store first, then Codex CLI files. */
34
+ export function chatgptSessionCandidates(configDir) {
35
+ const home = homedir();
36
+ const codexHome = process.env.CODEX_HOME || join(home, ".codex");
37
+ return [
38
+ { source: "ima2", path: chatgptAuthFilePath(configDir) },
39
+ { source: "codex", path: join(codexHome, "auth.json") },
40
+ { source: "chatgpt-local", path: join(home, ".chatgpt-local", "auth.json") },
41
+ { source: "xdg-codex", path: join(home, ".config", "codex", "auth.json") },
42
+ ];
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // JWT identity (display/routing metadata only; signatures are never verified here)
46
+ // ---------------------------------------------------------------------------
47
+ export function decodeJwtPayload(token) {
48
+ if (!token)
49
+ return undefined;
50
+ const parts = token.split(".");
51
+ if (parts.length !== 3 || !parts[1])
52
+ return undefined;
53
+ try {
54
+ const parsed = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
55
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ function authNamespace(payload) {
62
+ const ns = payload[CHATGPT_AUTH_NAMESPACE];
63
+ return ns && typeof ns === "object" && !Array.isArray(ns) ? ns : undefined;
64
+ }
65
+ /** OpenCodex precedence: top-level claim, namespaced claim, then organizations[0].id. */
66
+ export function extractChatgptAccountId(idToken, accessToken) {
67
+ for (const token of [idToken, accessToken]) {
68
+ const payload = decodeJwtPayload(token);
69
+ if (!payload)
70
+ continue;
71
+ if (typeof payload.chatgpt_account_id === "string" && payload.chatgpt_account_id)
72
+ return payload.chatgpt_account_id;
73
+ const ns = authNamespace(payload);
74
+ if (typeof ns?.chatgpt_account_id === "string" && ns.chatgpt_account_id)
75
+ return ns.chatgpt_account_id;
76
+ const orgs = payload.organizations;
77
+ if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string")
78
+ return orgs[0].id;
79
+ }
80
+ return undefined;
81
+ }
82
+ export function extractChatgptEmail(idToken, accessToken) {
83
+ for (const token of [idToken, accessToken]) {
84
+ const payload = decodeJwtPayload(token);
85
+ if (typeof payload?.email === "string" && payload.email)
86
+ return payload.email.toLowerCase();
87
+ const profile = payload?.["https://api.openai.com/profile"];
88
+ if (profile && typeof profile === "object" && typeof profile.email === "string") {
89
+ return profile.email.toLowerCase();
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+ export function extractChatgptPlan(idToken, accessToken) {
95
+ for (const token of [idToken, accessToken]) {
96
+ const payload = decodeJwtPayload(token);
97
+ if (!payload)
98
+ continue;
99
+ if (typeof payload.chatgpt_plan_type === "string" && payload.chatgpt_plan_type)
100
+ return payload.chatgpt_plan_type;
101
+ const ns = authNamespace(payload);
102
+ if (typeof ns?.chatgpt_plan_type === "string" && ns.chatgpt_plan_type)
103
+ return ns.chatgpt_plan_type;
104
+ }
105
+ return undefined;
106
+ }
107
+ export function jwtExpiryMs(token) {
108
+ const exp = decodeJwtPayload(token)?.exp;
109
+ return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : undefined;
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // File I/O
113
+ // ---------------------------------------------------------------------------
114
+ function nonEmpty(value) {
115
+ return typeof value === "string" && value.length > 0 ? value : undefined;
116
+ }
117
+ /** Never throws: a missing, unreadable, or malformed file is simply "no session". */
118
+ export function readChatgptAuthFile(path) {
119
+ try {
120
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
121
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
122
+ return null;
123
+ const record = parsed;
124
+ const tokens = record.tokens;
125
+ if (!tokens || typeof tokens !== "object" || !nonEmpty(tokens.access_token))
126
+ return null;
127
+ return record;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ function describeSession(source, path, file) {
134
+ const tokens = file.tokens ?? {};
135
+ const accountId = nonEmpty(tokens.account_id) ?? extractChatgptAccountId(tokens.id_token, tokens.access_token);
136
+ const email = extractChatgptEmail(tokens.id_token, tokens.access_token);
137
+ const plan = extractChatgptPlan(tokens.id_token, tokens.access_token);
138
+ const accessExpiresAt = jwtExpiryMs(tokens.access_token);
139
+ const lastRefresh = nonEmpty(file.last_refresh);
140
+ return {
141
+ source,
142
+ path,
143
+ refreshable: Boolean(nonEmpty(tokens.refresh_token)),
144
+ ...(accountId ? { accountId } : {}),
145
+ ...(email ? { email } : {}),
146
+ ...(plan ? { plan } : {}),
147
+ ...(accessExpiresAt !== undefined ? { accessExpiresAt } : {}),
148
+ ...(lastRefresh ? { lastRefresh } : {}),
149
+ };
150
+ }
151
+ /** Path of the first candidate that holds a usable session (what the proxy must be given). */
152
+ export function resolveChatgptSessionFile(configDir) {
153
+ for (const candidate of chatgptSessionCandidates(configDir)) {
154
+ if (readChatgptAuthFile(candidate.path))
155
+ return candidate;
156
+ }
157
+ return null;
158
+ }
159
+ /** The session the GPT OAuth proxy will use: the first readable candidate file. */
160
+ export function resolveChatgptSession(configDir) {
161
+ for (const candidate of chatgptSessionCandidates(configDir)) {
162
+ const file = readChatgptAuthFile(candidate.path);
163
+ if (file)
164
+ return describeSession(candidate.source, candidate.path, file);
165
+ }
166
+ return null;
167
+ }
168
+ /** Access token + account id for direct backend calls (quota). Reads the resolved file. */
169
+ export function readChatgptAccess(configDir) {
170
+ for (const candidate of chatgptSessionCandidates(configDir)) {
171
+ const file = readChatgptAuthFile(candidate.path);
172
+ const accessToken = nonEmpty(file?.tokens?.access_token);
173
+ if (!file || !accessToken)
174
+ continue;
175
+ const accountId = nonEmpty(file.tokens?.account_id)
176
+ ?? extractChatgptAccountId(file.tokens?.id_token, accessToken)
177
+ ?? "";
178
+ return { accessToken, accountId, source: candidate.source };
179
+ }
180
+ return null;
181
+ }
182
+ /** Atomic 0600 write (tmp + rename); the directory is forced to 0700. */
183
+ function writeAtomic(target, data) {
184
+ const dir = dirname(target);
185
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
186
+ const tmp = join(dir, `.${AUTH_FILENAME}.tmp-${randomBytes(6).toString("hex")}`);
187
+ try {
188
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
189
+ renameSync(tmp, target);
190
+ }
191
+ catch (error) {
192
+ rmSync(tmp, { force: true });
193
+ throw error;
194
+ }
195
+ }
196
+ /**
197
+ * Persist a token endpoint response into ima2's store. Rejects a response that cannot drive
198
+ * the proxy (no access token, no refresh token, or no account identity) instead of writing
199
+ * a file the proxy would later refuse.
200
+ */
201
+ export function saveChatgptTokenResponse(payload, configDir) {
202
+ const accessToken = nonEmpty(payload.access_token);
203
+ const refreshToken = nonEmpty(payload.refresh_token);
204
+ const idToken = nonEmpty(payload.id_token);
205
+ if (!accessToken)
206
+ throw new Error("ChatGPT token response did not include an access token");
207
+ if (!refreshToken)
208
+ throw new Error("ChatGPT token response did not include a refresh token");
209
+ const accountId = extractChatgptAccountId(idToken, accessToken);
210
+ if (!accountId)
211
+ throw new Error("ChatGPT token response did not identify a ChatGPT account");
212
+ const target = chatgptAuthFilePath(configDir);
213
+ const file = {
214
+ auth_mode: "chatgpt",
215
+ OPENAI_API_KEY: null,
216
+ tokens: {
217
+ ...(idToken ? { id_token: idToken } : {}),
218
+ access_token: accessToken,
219
+ refresh_token: refreshToken,
220
+ account_id: accountId,
221
+ },
222
+ last_refresh: new Date().toISOString(),
223
+ };
224
+ writeAtomic(target, file);
225
+ return describeSession("ima2", target, file);
226
+ }
227
+ export function hasIma2ChatgptSession(configDir) {
228
+ return existsSync(chatgptAuthFilePath(configDir));
229
+ }
230
+ /** Explicit logout of ima2's own store. Codex CLI files are never touched. */
231
+ export function clearChatgptCredentials(configDir) {
232
+ const target = chatgptAuthFilePath(configDir);
233
+ const existed = existsSync(target);
234
+ rmSync(target, { force: true });
235
+ return existed;
236
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Native ChatGPT (GPT OAuth) login, ported from OpenCodex `src/oauth/chatgpt.ts` (browser
3
+ * PKCE on localhost:1455) and `src/oauth/chatgpt-device.ts` (deviceauth grant).
4
+ *
5
+ * Replaces spawning `codex login` and scraping its terminal output for the device code: that
6
+ * regex broke whenever the Codex CLI changed its banner, and the session it wrote landed in
7
+ * ~/.codex/auth.json, shared with the Codex CLI's own refreshes.
8
+ *
9
+ * Both flows write only through lib/chatgptAuth.ts. Nothing is persisted unless the token
10
+ * endpoint actually returned a usable session.
11
+ */
12
+ import { createHash, randomBytes } from "node:crypto";
13
+ import { createServer } from "node:http";
14
+ import { CHATGPT_AUTHORIZE_URL, CHATGPT_OAUTH_CLIENT_ID, CHATGPT_OAUTH_SCOPE, CHATGPT_TOKEN_URL, saveChatgptTokenResponse, } from "./chatgptAuth.js";
15
+ const USERCODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
16
+ const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
17
+ const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
18
+ export const CHATGPT_DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
19
+ export const CHATGPT_CALLBACK_PORT = 1455;
20
+ const CALLBACK_PATH = "/auth/callback";
21
+ const REDIRECT_URI = `http://localhost:${CHATGPT_CALLBACK_PORT}${CALLBACK_PATH}`;
22
+ const ORIGINATOR = "codex_cli_rs";
23
+ /** The device grant's own lifetime. */
24
+ export const CHATGPT_DEVICE_TTL_MS = 15 * 60 * 1000;
25
+ export const CHATGPT_BROWSER_TTL_MS = 5 * 60 * 1000;
26
+ /** A fresh deadline per fetch; one shared timeout would kill the 15-minute grant. */
27
+ const FETCH_TIMEOUT_MS = 30_000;
28
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
29
+ const MIN_POLL_INTERVAL_MS = 1_000;
30
+ function fetchSignal(signal) {
31
+ const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
32
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
33
+ }
34
+ function nonEmpty(value) {
35
+ return typeof value === "string" && value.length > 0 ? value : undefined;
36
+ }
37
+ function defaultSleep(ms, signal) {
38
+ if (signal?.aborted)
39
+ return Promise.reject(new Error("Login cancelled"));
40
+ return new Promise((resolve, reject) => {
41
+ const timer = setTimeout(() => {
42
+ signal?.removeEventListener("abort", onAbort);
43
+ resolve();
44
+ }, ms);
45
+ const onAbort = () => {
46
+ clearTimeout(timer);
47
+ reject(new Error("Login cancelled"));
48
+ };
49
+ signal?.addEventListener("abort", onAbort, { once: true });
50
+ });
51
+ }
52
+ /** Upstream sends `interval` as a number or a string; a string must not become a hot loop. */
53
+ function normalizeIntervalMs(raw) {
54
+ const seconds = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
55
+ if (!Number.isFinite(seconds) || seconds <= 0)
56
+ return DEFAULT_POLL_INTERVAL_MS;
57
+ return Math.min(CHATGPT_DEVICE_TTL_MS, Math.max(MIN_POLL_INTERVAL_MS, Math.round(seconds * 1000)));
58
+ }
59
+ /** Only the OAuth error code and description, never the raw body (it may echo request data). */
60
+ async function tokenError(stage, response) {
61
+ let detail = `HTTP ${response.status}`;
62
+ try {
63
+ const parsed = JSON.parse(await response.text());
64
+ const code = typeof parsed.error === "string" ? parsed.error : typeof parsed.error?.code === "string" ? parsed.error.code : undefined;
65
+ const description = typeof parsed.error_description === "string" ? parsed.error_description : undefined;
66
+ detail = [detail, code, description].filter(Boolean).join(" ");
67
+ }
68
+ catch {
69
+ // Non-JSON body: the status alone.
70
+ }
71
+ return new Error(`ChatGPT ${stage} failed: ${detail}`);
72
+ }
73
+ async function exchangeCode(doFetch, params, signal) {
74
+ const response = await doFetch(CHATGPT_TOKEN_URL, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
77
+ body: new URLSearchParams({
78
+ grant_type: "authorization_code",
79
+ client_id: CHATGPT_OAUTH_CLIENT_ID,
80
+ code: params.code,
81
+ code_verifier: params.codeVerifier,
82
+ redirect_uri: params.redirectUri,
83
+ }).toString(),
84
+ signal: fetchSignal(signal),
85
+ });
86
+ if (!response.ok)
87
+ throw await tokenError("token exchange", response);
88
+ return (await response.json());
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Device flow
92
+ // ---------------------------------------------------------------------------
93
+ async function runDeviceFlow(opts, doFetch) {
94
+ const sleep = opts.sleep ?? defaultSleep;
95
+ const start = await doFetch(USERCODE_URL, {
96
+ method: "POST",
97
+ headers: { "Content-Type": "application/json" },
98
+ body: JSON.stringify({ client_id: CHATGPT_OAUTH_CLIENT_ID }),
99
+ signal: fetchSignal(opts.signal),
100
+ });
101
+ if (!start.ok) {
102
+ // A 404 here is most often the account-level switch for device code login.
103
+ if (start.status === 404) {
104
+ throw new Error("ChatGPT device authorization request failed: HTTP 404. Device code login may be disabled for this account (ChatGPT Settings → Security); the browser login does not need it.");
105
+ }
106
+ throw await tokenError("device authorization request", start);
107
+ }
108
+ const payload = (await start.json());
109
+ const deviceAuthId = nonEmpty(payload.device_auth_id);
110
+ const userCode = nonEmpty(payload.user_code) ?? nonEmpty(payload.usercode);
111
+ if (!deviceAuthId || !userCode)
112
+ throw new Error("ChatGPT device authorization response missing required fields");
113
+ const intervalMs = normalizeIntervalMs(payload.interval);
114
+ opts.onPrompt({ flow: "device", url: CHATGPT_DEVICE_VERIFICATION_URL, userCode, expiresIn: CHATGPT_DEVICE_TTL_MS / 1000 });
115
+ // Pending is 403/404 rather than an authorization_pending body. The clock is the larger of
116
+ // wall time and slept time so an injected no-op sleep still reaches the deadline.
117
+ const startedAt = Date.now();
118
+ let slept = 0;
119
+ while (Math.max(Date.now() - startedAt, slept) < CHATGPT_DEVICE_TTL_MS) {
120
+ if (opts.signal?.aborted)
121
+ throw new Error("Login cancelled");
122
+ const poll = await doFetch(DEVICE_TOKEN_URL, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
126
+ signal: fetchSignal(opts.signal),
127
+ });
128
+ if (poll.status === 403 || poll.status === 404) {
129
+ await sleep(intervalMs, opts.signal);
130
+ slept += intervalMs;
131
+ continue;
132
+ }
133
+ if (!poll.ok)
134
+ throw await tokenError("device authorization poll", poll);
135
+ const grant = (await poll.json());
136
+ const code = nonEmpty(grant.authorization_code);
137
+ const codeVerifier = nonEmpty(grant.code_verifier);
138
+ if (!code || !codeVerifier)
139
+ throw new Error("ChatGPT device authorization response missing required fields");
140
+ return exchangeCode(doFetch, { code, codeVerifier, redirectUri: DEVICE_REDIRECT_URI }, opts.signal);
141
+ }
142
+ throw new Error("ChatGPT device authorization expired before it was approved");
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Browser (PKCE callback) flow
146
+ // ---------------------------------------------------------------------------
147
+ export function generatePkce() {
148
+ const verifier = randomBytes(64).toString("base64url");
149
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
150
+ return { verifier, challenge };
151
+ }
152
+ export function buildChatgptAuthorizeUrl(params) {
153
+ const query = new URLSearchParams({
154
+ response_type: "code",
155
+ client_id: CHATGPT_OAUTH_CLIENT_ID,
156
+ redirect_uri: params.redirectUri ?? REDIRECT_URI,
157
+ scope: CHATGPT_OAUTH_SCOPE,
158
+ code_challenge: params.challenge,
159
+ code_challenge_method: "S256",
160
+ id_token_add_organizations: "true",
161
+ codex_cli_simplified_flow: "true",
162
+ state: params.state,
163
+ originator: ORIGINATOR,
164
+ });
165
+ return `${CHATGPT_AUTHORIZE_URL}?${query}`;
166
+ }
167
+ function page(title, body) {
168
+ return "<!doctype html><html><head><meta charset='utf-8'><title>ima2</title></head>"
169
+ + "<body style='font-family:system-ui,sans-serif;text-align:center;padding:4rem;color:#111'>"
170
+ + `<h2>${title}</h2><p>${body}</p></body></html>`;
171
+ }
172
+ function listen(server, port) {
173
+ return new Promise((resolve, reject) => {
174
+ const onError = (error) => {
175
+ server.off("listening", onListening);
176
+ reject(error.code === "EADDRINUSE"
177
+ ? new Error(`Port ${port} is busy (another Codex or ima2 login may be open). Close it, or use the device-code login.`)
178
+ : error);
179
+ };
180
+ const onListening = () => {
181
+ server.off("error", onError);
182
+ resolve();
183
+ };
184
+ server.once("error", onError);
185
+ server.once("listening", onListening);
186
+ server.listen(port, "127.0.0.1");
187
+ });
188
+ }
189
+ async function runBrowserFlow(opts, doFetch) {
190
+ const pkce = generatePkce();
191
+ const state = randomBytes(32).toString("base64url");
192
+ const port = opts.callbackPort ?? CHATGPT_CALLBACK_PORT;
193
+ let settle;
194
+ const codePromise = new Promise((resolve, reject) => { settle = { resolve, reject }; });
195
+ const server = createServer((req, res) => {
196
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
197
+ res.setHeader("Connection", "close");
198
+ if (url.pathname !== CALLBACK_PATH) {
199
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
200
+ return;
201
+ }
202
+ // State first, for errors too: any page can navigate the browser to this loopback URL, and
203
+ // without the unguessable state it must not be able to end (or complete) this login.
204
+ if (url.searchParams.get("state") !== state) {
205
+ res.writeHead(400, { "Content-Type": "text/html" }).end(page("&#9888; Login failed", "State mismatch. Start the login again from ima2."));
206
+ return;
207
+ }
208
+ const error = url.searchParams.get("error");
209
+ if (error) {
210
+ res.writeHead(400, { "Content-Type": "text/html" }).end(page("&#9888; Login failed", "Return to ima2 and try again."));
211
+ settle?.reject(new Error(`ChatGPT login was not approved: ${error.replace(/[^\w.-]/g, "")}`));
212
+ return;
213
+ }
214
+ const code = url.searchParams.get("code");
215
+ if (!code) {
216
+ res.writeHead(400, { "Content-Type": "text/html" }).end(page("&#9888; Login failed", "The callback carried no code. Start the login again from ima2."));
217
+ return;
218
+ }
219
+ res.writeHead(200, { "Content-Type": "text/html" }).end(page("&#9989; Login complete", "You can close this tab and return to ima2."));
220
+ settle?.resolve(code);
221
+ });
222
+ await listen(server, port);
223
+ const timer = setTimeout(() => settle?.reject(new Error("ChatGPT browser login timed out")), CHATGPT_BROWSER_TTL_MS);
224
+ const onAbort = () => settle?.reject(new Error("Login cancelled"));
225
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
226
+ try {
227
+ opts.onPrompt({
228
+ flow: "browser",
229
+ url: buildChatgptAuthorizeUrl({ state, challenge: pkce.challenge }),
230
+ expiresIn: CHATGPT_BROWSER_TTL_MS / 1000,
231
+ });
232
+ const code = await codePromise;
233
+ return await exchangeCode(doFetch, { code, codeVerifier: pkce.verifier, redirectUri: REDIRECT_URI }, opts.signal);
234
+ }
235
+ finally {
236
+ clearTimeout(timer);
237
+ opts.signal?.removeEventListener("abort", onAbort);
238
+ server.closeAllConnections?.();
239
+ server.close();
240
+ }
241
+ }
242
+ /**
243
+ * Runs one complete ChatGPT login and writes the session into ima2's store
244
+ * (lib/chatgptAuth.ts). Rejects on cancel, expiry, or a token response that cannot drive the
245
+ * GPT OAuth proxy.
246
+ */
247
+ export async function runChatgptLogin(opts) {
248
+ const doFetch = opts.fetchImpl ?? ((...args) => fetch(...args));
249
+ const payload = opts.flow === "device"
250
+ ? await runDeviceFlow(opts, doFetch)
251
+ : await runBrowserFlow(opts, doFetch);
252
+ // A login that was cancelled or superseded while its token request was in flight must not
253
+ // overwrite the session a newer login just saved.
254
+ if (opts.signal?.aborted)
255
+ throw new Error("Login cancelled");
256
+ return saveChatgptTokenResponse(payload, opts.configDir);
257
+ }
@@ -10,6 +10,7 @@ import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { errInfo } from "./errInfo.js";
12
12
  import { resolvePackageBin } from "./packageCli.js";
13
+ import { chatgptAuthFilePath, resolveChatgptSessionFile } from "./chatgptAuth.js";
13
14
  const HOME = homedir();
14
15
  export const CODEX_FILE_AUTH_CONFIG = 'cli_auth_credentials_store="file"';
15
16
  export function codexFileLoginArgs(options = {}) {
@@ -23,6 +24,7 @@ export function codexFileLoginArgs(options = {}) {
23
24
  export function codexAuthPaths() {
24
25
  const codexHome = process.env.CODEX_HOME || join(HOME, ".codex");
25
26
  return {
27
+ ima2: chatgptAuthFilePath(),
26
28
  codex: join(codexHome, "auth.json"),
27
29
  chatgpt: join(HOME, ".chatgpt-local", "auth.json"),
28
30
  xdgCodex: join(HOME, ".config", "codex", "auth.json"),
@@ -30,7 +32,7 @@ export function codexAuthPaths() {
30
32
  }
31
33
  export function hasAuthFile() {
32
34
  const p = codexAuthPaths();
33
- return existsSync(p.codex) || existsSync(p.chatgpt) || existsSync(p.xdgCodex);
35
+ return existsSync(p.ima2) || existsSync(p.codex) || existsSync(p.chatgpt) || existsSync(p.xdgCodex);
34
36
  }
35
37
  function commandErrorText(error) {
36
38
  if (!error || typeof error !== "object")
@@ -89,21 +91,25 @@ export function codexLoginStatus(timeoutMs = 2000, options = {}) {
89
91
  }
90
92
  return sawError ? "error" : "missing";
91
93
  }
92
- export function detectCodexAuth() {
94
+ /**
95
+ * Which file the GPT OAuth proxy will read. ima2's own store (lib/chatgptAuth.ts, written by
96
+ * `ima2 login`) wins over Codex CLI files so the two never rotate one refresh token.
97
+ * The `codex login status` probe only runs when no file exists (keyring-only detection),
98
+ * because it spawns the Codex CLI synchronously.
99
+ */
100
+ export function detectCodexAuth(options = {}) {
93
101
  const files = codexAuthPaths();
94
102
  const fileHits = {
103
+ ima2: existsSync(files.ima2),
95
104
  codex: existsSync(files.codex),
96
105
  chatgpt: existsSync(files.chatgpt),
97
106
  xdgCodex: existsSync(files.xdgCodex),
98
107
  };
99
- const proxyAuthFile = fileHits.codex
100
- ? files.codex
101
- : fileHits.chatgpt
102
- ? files.chatgpt
103
- : fileHits.xdgCodex
104
- ? files.xdgCodex
105
- : null;
106
- const probe = codexLoginStatus();
108
+ // Same validated choice as status and quota (lib/chatgptAuth.ts): a malformed or
109
+ // token-less file must not win just because it exists.
110
+ const proxyAuthFile = resolveChatgptSessionFile()?.path ?? null;
111
+ const shouldProbe = options.probe ?? proxyAuthFile === null;
112
+ const probe = shouldProbe ? codexLoginStatus() : "skipped";
107
113
  const authed = probe === "authed" || proxyAuthFile !== null;
108
114
  return {
109
115
  authed,