oc-codex-multi-auth 6.15.0 → 6.16.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 (65) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +145 -55
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/accounts/persistence.d.ts +53 -3
  6. package/dist/lib/accounts/persistence.d.ts.map +1 -1
  7. package/dist/lib/accounts/persistence.js +110 -4
  8. package/dist/lib/accounts/persistence.js.map +1 -1
  9. package/dist/lib/accounts/state.d.ts +1 -0
  10. package/dist/lib/accounts/state.d.ts.map +1 -1
  11. package/dist/lib/accounts/state.js +11 -0
  12. package/dist/lib/accounts/state.js.map +1 -1
  13. package/dist/lib/accounts.d.ts +1 -1
  14. package/dist/lib/accounts.d.ts.map +1 -1
  15. package/dist/lib/accounts.js +1 -1
  16. package/dist/lib/accounts.js.map +1 -1
  17. package/dist/lib/auth/login-runner.d.ts +2 -0
  18. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  19. package/dist/lib/auth/login-runner.js +44 -12
  20. package/dist/lib/auth/login-runner.js.map +1 -1
  21. package/dist/lib/auth/loopback-flow.d.ts +124 -0
  22. package/dist/lib/auth/loopback-flow.d.ts.map +1 -0
  23. package/dist/lib/auth/loopback-flow.js +112 -0
  24. package/dist/lib/auth/loopback-flow.js.map +1 -0
  25. package/dist/lib/auth/plan-tier.d.ts +21 -0
  26. package/dist/lib/auth/plan-tier.d.ts.map +1 -0
  27. package/dist/lib/auth/plan-tier.js +92 -0
  28. package/dist/lib/auth/plan-tier.js.map +1 -0
  29. package/dist/lib/auth/token-utils.d.ts +8 -10
  30. package/dist/lib/auth/token-utils.d.ts.map +1 -1
  31. package/dist/lib/auth/token-utils.js +27 -18
  32. package/dist/lib/auth/token-utils.js.map +1 -1
  33. package/dist/lib/constants.d.ts +5 -2
  34. package/dist/lib/constants.d.ts.map +1 -1
  35. package/dist/lib/constants.js +5 -2
  36. package/dist/lib/constants.js.map +1 -1
  37. package/dist/lib/quota-notifications.d.ts +15 -0
  38. package/dist/lib/quota-notifications.d.ts.map +1 -1
  39. package/dist/lib/quota-notifications.js +42 -12
  40. package/dist/lib/quota-notifications.js.map +1 -1
  41. package/dist/lib/schemas.d.ts +3 -0
  42. package/dist/lib/schemas.d.ts.map +1 -1
  43. package/dist/lib/schemas.js +1 -0
  44. package/dist/lib/schemas.js.map +1 -1
  45. package/dist/lib/storage/flagged.d.ts.map +1 -1
  46. package/dist/lib/storage/flagged.js +5 -0
  47. package/dist/lib/storage/flagged.js.map +1 -1
  48. package/dist/lib/storage/migrations.d.ts +2 -0
  49. package/dist/lib/storage/migrations.d.ts.map +1 -1
  50. package/dist/lib/storage/migrations.js.map +1 -1
  51. package/dist/lib/tools/codex-limits.d.ts.map +1 -1
  52. package/dist/lib/tools/codex-limits.js +7 -4
  53. package/dist/lib/tools/codex-limits.js.map +1 -1
  54. package/dist/lib/tools/codex-list.d.ts.map +1 -1
  55. package/dist/lib/tools/codex-list.js +13 -1
  56. package/dist/lib/tools/codex-list.js.map +1 -1
  57. package/dist/lib/tools/codex-status.d.ts.map +1 -1
  58. package/dist/lib/tools/codex-status.js +8 -0
  59. package/dist/lib/tools/codex-status.js.map +1 -1
  60. package/dist/lib/tui-status.d.ts.map +1 -1
  61. package/dist/lib/tui-status.js +6 -2
  62. package/dist/lib/tui-status.js.map +1 -1
  63. package/dist/lib/types.d.ts +1 -0
  64. package/dist/lib/types.d.ts.map +1 -1
  65. package/package.json +1 -1
package/README.md CHANGED
@@ -263,6 +263,8 @@ Most of these also run as a **direct CLI** with no agent/model involvement (no t
263
263
  - unsupported-model handling is strict by default, with opt-in fallback controls
264
264
  - TUI quota status follows the account/workspace used by the latest request
265
265
  - Business workspace memberships and Personal accounts keep separate usage and quota windows. Business members sharing one workspace are distinguished by their member/seat identity, so their usage is not collapsed into one row.
266
+ - An account identifies itself by its own ChatGPT email and the last 6 characters of its account id, with the email masked when `maskEmail` is on. The OAuth id_token also lists the API-platform organizations the login belongs to; those are not ChatGPT workspaces and are never used to name an account, so logging in clears a label left behind by one. A label you set with `codex-label` is always kept.
267
+ - The ChatGPT plan (`Free`, `Plus`, `Pro`, `Business`, `Business Premium`) is read from the access token, refreshed on every token refresh, and shown by `codex-list` and `codex-status`. `codex-limits` and the TUI read the plan live from the usage endpoint and name it the same way. An unrecognized plan is reported verbatim rather than renamed.
266
268
 
267
269
  ---
268
270
 
@@ -302,12 +304,13 @@ The feature is disabled by default.
302
304
 
303
305
  Each line reports the enabled account with the most headroom in that window,
304
306
  together with that same account's reset time, so the pair always describes a
305
- quota that one account actually has. Windows a plan has switched off are
306
- skipped rather than counted as full. Account identities are omitted for
307
- readability and lock-screen privacy:
307
+ quota that one account actually has. When a different account recovers sooner,
308
+ that reset is appended under its own label rather than folded into the first
309
+ one. Windows a plan has switched off are skipped rather than counted as full.
310
+ Account identities are omitted for readability and lock-screen privacy:
308
311
 
309
312
  ```text
310
- 5h: 10% | resets 22:30
313
+ 5h: 10% | resets 02:00 | another account resets 22:30
311
314
  Weekly: 72% | resets 22:30 on Aug 30
312
315
  ```
313
316
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AAuU/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAq9H/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AA2U/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAqjI/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -27,11 +27,11 @@ import { createHash } from "node:crypto";
27
27
  import { homedir } from "node:os";
28
28
  import { join } from "node:path";
29
29
  import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, } from "./lib/auth/auth.js";
30
+ import { formatPlanType } from "./lib/auth/plan-tier.js";
31
+ import { startLoopbackFlow, } from "./lib/auth/loopback-flow.js";
30
32
  import { buildDeviceCodeInstructions, completeDeviceCodeSession, createDeviceCodeSession, } from "./lib/auth/device-code.js";
31
33
  import { applyAccountSelectionFallbacks, persistResolvedAccountSelection, persistAccountPool, resolveAndPersistAccountSelection, resolveAccountSelection, } from "./lib/auth/login-runner.js";
32
34
  import { coordinateFlaggedPersistedRefresh, coordinatePersistedRefresh, } from "./lib/storage/coordinated-refresh.js";
33
- import { openBrowserUrl } from "./lib/auth/browser.js";
34
- import { startLocalOAuthServer } from "./lib/auth/server.js";
35
35
  import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
36
36
  import { getCodexMode, getRequestTransformMode, getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, getRetryProfile, getRetryBudgetOverrides, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getFallbackToGpt52OnUnsupportedGpt53, getUnsupportedCodexPolicy, getUnsupportedCodexFallbackChain, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getAutoUpdate, getToastDurationMs, getAccountToastsEnabled, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getRotationStrategy, getModelAccountPool, getModelAccountPoolMode, getFetchTimeoutMs, getStreamStallTimeoutMs, getCodexTuiV2, getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, getCodexTuiMaskEmail, loadPluginConfig, } from "./lib/config.js";
37
37
  import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
@@ -305,39 +305,76 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
305
305
  }
306
306
  }
307
307
  };
308
+ const listenerUnavailableMessage = () => `OAuth callback server failed to start on localhost loopback port 1455. ` +
309
+ `Retry with "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}".`;
310
+ const callbackCancelledMessage = () => `OAuth callback timed out or was cancelled. ` +
311
+ `If you are on SSH, WSL, or a headless machine, retry with "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}".`;
312
+ // The login is still live when this fires: the listener is bound and
313
+ // waiting, so opening the URL printed above finishes it. That is the
314
+ // whole recovery path on a host with no opener on PATH.
315
+ const browserOpenFailedMessage = () => `Could not launch your default browser. Open the OAuth URL above in any browser to finish signing in; ` +
316
+ `this login is still waiting on the localhost callback. ` +
317
+ `"${AUTH_LABELS.OAUTH_MANUAL_BROWSER}" does the same thing without trying to launch a browser, ` +
318
+ `and "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}" avoid the callback entirely.`;
319
+ const unavailableMessage = (lifecycle) => {
320
+ switch (lifecycle) {
321
+ case "listener_unavailable":
322
+ return listenerUnavailableMessage();
323
+ default: {
324
+ const unreachable = lifecycle;
325
+ return unreachable;
326
+ }
327
+ }
328
+ };
329
+ /**
330
+ * Every accepted paste must carry this attempt's `state`, a raw code
331
+ * included.
332
+ *
333
+ * That comparison is the manual flow's only in-plugin binding between
334
+ * the pasted value and this login. Accepting a bare code delegates the
335
+ * binding entirely to the authorization server's PKCE enforcement, and
336
+ * hands anything a user is talked into pasting to
337
+ * `exchangeAuthorizationCode` with this attempt's verifier — which is
338
+ * the shape of the attack the state check exists to stop. The raw
339
+ * branch of `parseAuthorizationInput` also returns the whole trimmed
340
+ * input as the code, so a noisy paste would be forwarded verbatim.
341
+ */
342
+ const manualInputRejection = (parsed, expectedState) => {
343
+ if (!parsed.code) {
344
+ return "No authorization code found. Paste the full callback URL (e.g., http://localhost:1455/auth/callback?code=...&state=...). If browser callback keeps failing, retry with Device Code.";
345
+ }
346
+ switch (parsed.source) {
347
+ case "raw":
348
+ return "That is a bare authorization code. This flow needs the full callback URL, including the state parameter (e.g., http://localhost:1455/auth/callback?code=...&state=...), which is what ties the code to this login attempt. If needed, retry with Device Code.";
349
+ case "url":
350
+ case "query":
351
+ case "fragment":
352
+ if (!parsed.state) {
353
+ return "That callback URL carries no OAuth state, so it cannot be matched to this login attempt. Paste the complete callback URL including its state parameter, or retry with Device Code.";
354
+ }
355
+ if (parsed.state !== expectedState) {
356
+ return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt, or retry with Device Code.";
357
+ }
358
+ return undefined;
359
+ default: {
360
+ const unreachable = parsed;
361
+ return unreachable;
362
+ }
363
+ }
364
+ };
308
365
  const buildManualOAuthFlow = (pkce, url, expectedState, replaceAll) => ({
309
366
  url,
310
367
  method: "code",
311
368
  instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL,
312
- validate: (input) => {
313
- const parsed = parseAuthorizationInput(input);
314
- if (!parsed.code) {
315
- return "No authorization code found. Paste the full callback URL (e.g., http://localhost:1455/auth/callback?code=...). If browser callback keeps failing, retry with Device Code.";
316
- }
317
- if (!parsed.state) {
318
- return parsed.source === "raw"
319
- ? "That is a bare authorization code. This flow needs the full callback URL, including the state parameter (e.g., http://localhost:1455/auth/callback?code=...&state=...). If needed, retry with Device Code."
320
- : "Missing OAuth state. Paste the full callback URL including both code and state parameters. If needed, retry with Device Code.";
321
- }
322
- if (parsed.state !== expectedState) {
323
- return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt, or retry with Device Code.";
324
- }
325
- return undefined;
326
- },
369
+ validate: (input) => manualInputRejection(parseAuthorizationInput(input), expectedState),
327
370
  callback: async (input) => {
328
371
  const parsed = parseAuthorizationInput(input);
329
- if (!parsed.code || !parsed.state) {
330
- return {
331
- type: "failed",
332
- reason: "invalid_response",
333
- message: "Missing authorization code or OAuth state",
334
- };
335
- }
336
- if (parsed.state !== expectedState) {
372
+ const rejection = manualInputRejection(parsed, expectedState);
373
+ if (rejection !== undefined || !parsed.code) {
337
374
  return {
338
375
  type: "failed",
339
376
  reason: "invalid_response",
340
- message: "OAuth state mismatch. Restart login and try again, or retry with Device Code.",
377
+ message: rejection ?? "Missing authorization code",
341
378
  };
342
379
  }
343
380
  const tokens = await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
@@ -354,21 +391,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
354
391
  },
355
392
  });
356
393
  const runOAuthFlow = async (forceNewLogin = false) => {
357
- const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin });
358
- logInfo(`OAuth URL: ${url}`);
359
- let serverInfo = null;
360
- try {
361
- serverInfo = await startLocalOAuthServer({ state });
362
- }
363
- catch (err) {
364
- logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server: ${err?.message ?? String(err)}`);
365
- serverInfo = null;
366
- }
367
- openBrowserUrl(url);
368
- if (!serverInfo || !serverInfo.ready) {
369
- serverInfo?.close();
370
- const message = `OAuth callback server failed to start on localhost loopback port 1455. ` +
371
- `Retry with "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}".`;
394
+ const session = await startLoopbackFlow({
395
+ openBrowser: true,
396
+ forceNewLogin,
397
+ });
398
+ if (session.type === "unavailable") {
399
+ const message = unavailableMessage(session.lifecycle);
372
400
  logWarn(`\n[${PLUGIN_NAME}] ${message}\n`);
373
401
  return {
374
402
  type: "failed",
@@ -376,17 +404,21 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
376
404
  message,
377
405
  };
378
406
  }
379
- const result = await serverInfo.waitForCode(state);
380
- serverInfo.close();
381
- if (!result) {
407
+ logInfo(`OAuth URL: ${session.url}`);
408
+ // Printed first, so the recovery instruction points at a URL the
409
+ // user can already see. The session stays open either way.
410
+ if (!session.browserOpened) {
411
+ logWarn(`\n[${PLUGIN_NAME}] ${browserOpenFailedMessage()}\n`);
412
+ }
413
+ const result = await session.waitAndExchange();
414
+ if (result.type === "cancelled") {
382
415
  return {
383
416
  type: "failed",
384
417
  reason: "unknown",
385
- message: `OAuth callback timed out or was cancelled. ` +
386
- `If you are on SSH, WSL, or a headless machine, retry with "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}".`,
418
+ message: callbackCancelledMessage(),
387
419
  };
388
420
  }
389
- return await exchangeAuthorizationCode(result.code, pkce.verifier, REDIRECT_URI);
421
+ return result;
390
422
  };
391
423
  const showToast = async (message, variant = "success", options) => {
392
424
  try {
@@ -2544,9 +2576,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2544
2576
  const authPerProjectAccounts = getPerProjectAccounts(authPluginConfig);
2545
2577
  setStoragePath(authPerProjectAccounts ? process.cwd() : null);
2546
2578
  const accounts = [];
2579
+ // Programmatic input, not a menu choice: a headless caller or
2580
+ // script passes it to say "never launch a browser". Ignoring it
2581
+ // would silently do the opposite — enter the multi-account loop,
2582
+ // try to launch a browser, and bind port 1455 — and would also
2583
+ // drop the replaceAll semantics the manual path carries.
2547
2584
  const noBrowser = inputs?.noBrowser === "true" ||
2548
2585
  inputs?.["no-browser"] === "true";
2549
- const useManualMode = noBrowser;
2550
2586
  const explicitLoginMode = inputs?.loginMode === "fresh" || inputs?.loginMode === "add"
2551
2587
  ? inputs.loginMode
2552
2588
  : null;
@@ -2594,8 +2630,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2594
2630
  parts.push(`${limit.name} ${formatUsageLimitSummary(limit.window)}`);
2595
2631
  }
2596
2632
  }
2597
- if (usage.planType)
2598
- parts.push(`plan:${usage.planType}`);
2633
+ const planLabel = formatPlanType(usage.planType);
2634
+ if (planLabel)
2635
+ parts.push(`plan:${planLabel}`);
2599
2636
  if (usage.credits)
2600
2637
  parts.push(`credits:${usage.credits}`);
2601
2638
  return parts.length > 0 ? parts.join(", ") : "quota unavailable";
@@ -3180,10 +3217,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
3180
3217
  if (refreshAccountIndex !== undefined) {
3181
3218
  targetCount = 1;
3182
3219
  }
3183
- if (useManualMode) {
3220
+ if (noBrowser) {
3184
3221
  targetCount = 1;
3185
- }
3186
- if (useManualMode) {
3222
+ // Paste-the-code, not the loopback listener: this is the one
3223
+ // path that needs neither a browser on this host nor a
3224
+ // reachable localhost:1455 from wherever the browser runs.
3187
3225
  const { pkce, state, url } = await createAuthorizationFlow();
3188
3226
  return buildManualOAuthFlow(pkce, url, state, startFresh);
3189
3227
  }
@@ -3274,6 +3312,57 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
3274
3312
  };
3275
3313
  },
3276
3314
  },
3315
+ {
3316
+ label: AUTH_LABELS.OAUTH_MANUAL_BROWSER,
3317
+ type: "oauth",
3318
+ authorize: async () => {
3319
+ // Must happen BEFORE the callback persists, to ensure correct
3320
+ // storage location: OpenCode invokes callback() separately from
3321
+ // authorize(), so the path has to be pinned here.
3322
+ const manualBrowserPluginConfig = loadPluginConfig();
3323
+ applyUiRuntimeFromConfig(manualBrowserPluginConfig);
3324
+ const manualBrowserPerProjectAccounts = getPerProjectAccounts(manualBrowserPluginConfig);
3325
+ setStoragePath(manualBrowserPerProjectAccounts ? process.cwd() : null);
3326
+ const session = await startLoopbackFlow({ openBrowser: false });
3327
+ if (session.type === "unavailable") {
3328
+ const message = unavailableMessage(session.lifecycle);
3329
+ logWarn(`\n[${PLUGIN_NAME}] ${message}\n`);
3330
+ return {
3331
+ url: "",
3332
+ instructions: message,
3333
+ method: "auto",
3334
+ callback: () => Promise.resolve({
3335
+ type: "failed",
3336
+ reason: "invalid_response",
3337
+ message,
3338
+ }),
3339
+ };
3340
+ }
3341
+ return {
3342
+ url: session.url,
3343
+ instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL_BROWSER,
3344
+ method: "auto",
3345
+ callback: async () => {
3346
+ const result = await session.waitAndExchange();
3347
+ if (result.type === "cancelled") {
3348
+ return {
3349
+ type: "failed",
3350
+ reason: "unknown",
3351
+ message: callbackCancelledMessage(),
3352
+ };
3353
+ }
3354
+ if (result.type !== "success") {
3355
+ return result;
3356
+ }
3357
+ const resolved = await resolveAndPersistAccountSelection(result, {
3358
+ persistSelections: persistAuthenticatedSelections,
3359
+ replaceAll: false,
3360
+ });
3361
+ return resolved.primary;
3362
+ },
3363
+ };
3364
+ },
3365
+ },
3277
3366
  {
3278
3367
  label: AUTH_LABELS.OAUTH_DEVICE_CODE,
3279
3368
  type: "oauth",
@@ -3313,8 +3402,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
3313
3402
  label: AUTH_LABELS.OAUTH_MANUAL,
3314
3403
  type: "oauth",
3315
3404
  authorize: async () => {
3316
- // Initialize storage path for manual OAuth flow
3317
- // Must happen BEFORE persistAccountPool to ensure correct storage location
3405
+ // Must happen BEFORE the callback persists, to ensure correct
3406
+ // storage location: OpenCode invokes callback() separately from
3407
+ // authorize(), so the path has to be pinned here.
3318
3408
  const manualPluginConfig = loadPluginConfig();
3319
3409
  applyUiRuntimeFromConfig(manualPluginConfig);
3320
3410
  const manualPerProjectAccounts = getPerProjectAccounts(manualPluginConfig);