oc-codex-multi-auth 6.13.0 → 6.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +376 -52
- package/dist/index.js.map +1 -1
- package/dist/lib/accounts/rotation.d.ts.map +1 -1
- package/dist/lib/accounts/rotation.js +10 -3
- package/dist/lib/accounts/rotation.js.map +1 -1
- package/dist/lib/accounts/state.d.ts.map +1 -1
- package/dist/lib/accounts/state.js +2 -2
- package/dist/lib/accounts/state.js.map +1 -1
- package/dist/lib/codex-usage.d.ts.map +1 -1
- package/dist/lib/codex-usage.js +14 -79
- package/dist/lib/codex-usage.js.map +1 -1
- package/dist/lib/errors.d.ts +7 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +14 -0
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/proactive-refresh.d.ts.map +1 -1
- package/dist/lib/proactive-refresh.js +14 -3
- package/dist/lib/proactive-refresh.js.map +1 -1
- package/dist/lib/request/fetch-helpers.d.ts +2 -1
- package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
- package/dist/lib/request/fetch-helpers.js +17 -4
- package/dist/lib/request/fetch-helpers.js.map +1 -1
- package/dist/lib/storage/coordinated-refresh.d.ts +15 -0
- package/dist/lib/storage/coordinated-refresh.d.ts.map +1 -0
- package/dist/lib/storage/coordinated-refresh.js +246 -0
- package/dist/lib/storage/coordinated-refresh.js.map +1 -0
- package/dist/lib/storage/flagged.d.ts.map +1 -1
- package/dist/lib/storage/flagged.js +13 -3
- package/dist/lib/storage/flagged.js.map +1 -1
- package/dist/lib/storage/load-save.d.ts.map +1 -1
- package/dist/lib/storage/load-save.js +6 -3
- package/dist/lib/storage/load-save.js.map +1 -1
- package/dist/lib/storage/transaction-lock.d.ts +21 -0
- package/dist/lib/storage/transaction-lock.d.ts.map +1 -0
- package/dist/lib/storage/transaction-lock.js +194 -0
- package/dist/lib/storage/transaction-lock.js.map +1 -0
- package/dist/lib/tools/codex-note.d.ts.map +1 -1
- package/dist/lib/tools/codex-note.js +19 -9
- package/dist/lib/tools/codex-note.js.map +1 -1
- package/dist/lib/tools/codex-tag.d.ts.map +1 -1
- package/dist/lib/tools/codex-tag.js +23 -12
- package/dist/lib/tools/codex-tag.js.map +1 -1
- package/dist/lib/tools/refresh-account.d.ts +5 -15
- package/dist/lib/tools/refresh-account.d.ts.map +1 -1
- package/dist/lib/tools/refresh-account.js +17 -110
- package/dist/lib/tools/refresh-account.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import { join } from "node:path";
|
|
|
29
29
|
import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, } from "./lib/auth/auth.js";
|
|
30
30
|
import { buildDeviceCodeInstructions, completeDeviceCodeSession, createDeviceCodeSession, } from "./lib/auth/device-code.js";
|
|
31
31
|
import { applyAccountSelectionFallbacks, persistResolvedAccountSelection, persistAccountPool, resolveAndPersistAccountSelection, resolveAccountSelection, } from "./lib/auth/login-runner.js";
|
|
32
|
-
import {
|
|
32
|
+
import { coordinateFlaggedPersistedRefresh, coordinatePersistedRefresh, } from "./lib/storage/coordinated-refresh.js";
|
|
33
33
|
import { openBrowserUrl } from "./lib/auth/browser.js";
|
|
34
34
|
import { startLocalOAuthServer } from "./lib/auth/server.js";
|
|
35
35
|
import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
|
|
@@ -39,10 +39,10 @@ import { initLogger, logRequest, logDebug, logInfo, logWarn, logError, setCorrel
|
|
|
39
39
|
import { checkAndNotify } from "./lib/auto-update-checker.js";
|
|
40
40
|
import { handleContextOverflow } from "./lib/context-overflow.js";
|
|
41
41
|
import { AccountManager, extractAccountEmail, extractAccountId, formatAccountLabel, formatWaitTime, sanitizeEmail, shouldUpdateAccountIdFromToken, resolveRequestAccountId, parseRateLimitReason, lookupCodexCliTokensByEmail, } from "./lib/accounts.js";
|
|
42
|
-
import { matchesModelPoolAccountKey } from "./lib/accounts/pool-identity.js";
|
|
42
|
+
import { getModelPoolAccountKey, matchesModelPoolAccountKey, } from "./lib/accounts/pool-identity.js";
|
|
43
43
|
import { resolveDisplayEmail } from "./lib/account-display.js";
|
|
44
44
|
import { CodexAuthError } from "./lib/errors.js";
|
|
45
|
-
import { getStoragePath, loadAccounts,
|
|
45
|
+
import { getStoragePath, loadAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, loadFlaggedAccounts, withFlaggedAccountStorageTransaction, clearFlaggedAccounts, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
|
|
46
46
|
import { getWorkspaceIdentityKey } from "./lib/storage/identity.js";
|
|
47
47
|
import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, isDeactivatedWorkspaceError, isInvalidatedAuthTokenError, createAbortError, getUnsupportedCodexModelInfo, resolveUnsupportedCodexFallbackModel, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
|
|
48
48
|
import { shapeBodyForModel } from "./lib/request/helpers/responses-lite.js";
|
|
@@ -64,6 +64,109 @@ import { createToolRegistry, } from "./lib/tools/index.js";
|
|
|
64
64
|
import { createUsageAccountFingerprint, fetchCodexUsage, formatUsageLimitSummary, formatUsageLimitTitle, hasUsageWindow, parseCodexUsagePayload, } from "./lib/codex-usage.js";
|
|
65
65
|
import { getQuotaExhaustedResetAtMs } from "./lib/quota-windows.js";
|
|
66
66
|
import { clearTuiQuotaSnapshot, parseTuiQuotaSnapshotFromHeaders, writeTuiQuotaSnapshot, } from "./lib/tui-quota-cache.js";
|
|
67
|
+
/**
|
|
68
|
+
* IPv6 loopback literal. WHATWG URL parsing compresses every equivalent
|
|
69
|
+
* spelling (`0:0:0:0:0:0:0:1` -> `::1`), so only the canonical form is listed.
|
|
70
|
+
*/
|
|
71
|
+
const LOOPBACK_GATEWAY_HOSTS = new Set(["::1"]);
|
|
72
|
+
const IPV4_LITERAL_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
73
|
+
/**
|
|
74
|
+
* IPv4-mapped IPv6, which WHATWG serializes in hex rather than dotted form:
|
|
75
|
+
* `::ffff:127.0.0.1` -> `::ffff:7f00:1`, `::ffff:127.1.1.1` -> `::ffff:7f01:101`.
|
|
76
|
+
* The high byte of the first group is the first IPv4 octet.
|
|
77
|
+
*/
|
|
78
|
+
const IPV4_MAPPED_PATTERN = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/;
|
|
79
|
+
/**
|
|
80
|
+
* Cleartext HTTP is only safe for a peer that cannot leave the host, so the check
|
|
81
|
+
* is deliberately restricted to *literal* loopback addresses. Hostnames such as
|
|
82
|
+
* `localhost` are rejected even though they usually resolve to 127.0.0.1: a host
|
|
83
|
+
* file or resolver can point them at a remote peer, which would leak the ChatGPT
|
|
84
|
+
* OAuth access token over the wire.
|
|
85
|
+
*
|
|
86
|
+
* The whole 127.0.0.0/8 range is accepted, not just 127.0.0.1 — running several
|
|
87
|
+
* local services on 127.0.0.2, 127.0.1.1, and friends is a common pattern, and
|
|
88
|
+
* every address in the block is equally unroutable.
|
|
89
|
+
*/
|
|
90
|
+
function isLoopbackGatewayHost(hostname) {
|
|
91
|
+
const ipv4 = IPV4_LITERAL_PATTERN.exec(hostname);
|
|
92
|
+
if (ipv4) {
|
|
93
|
+
const octets = ipv4.slice(1).map((part) => Number(part));
|
|
94
|
+
if (octets.some((octet) => !Number.isInteger(octet) || octet > 255))
|
|
95
|
+
return false;
|
|
96
|
+
return octets[0] === 127;
|
|
97
|
+
}
|
|
98
|
+
const mapped = IPV4_MAPPED_PATTERN.exec(hostname);
|
|
99
|
+
if (mapped?.[1]) {
|
|
100
|
+
// `::ffff:7f00:2` is 127.0.0.2 — just as unroutable as the dotted
|
|
101
|
+
// spelling, so rejecting it would be an inconsistency, not a safeguard.
|
|
102
|
+
return Number.parseInt(mapped[1], 16) >>> 8 === 0x7f;
|
|
103
|
+
}
|
|
104
|
+
return LOOPBACK_GATEWAY_HOSTS.has(hostname);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Stable per-account identity for the terminal-error diagnostics counters.
|
|
108
|
+
*
|
|
109
|
+
* `account.index` is not usable here: `AccountManager.removeAccount` reindexes
|
|
110
|
+
* the survivors, so an index recorded before a mid-traversal removal silently
|
|
111
|
+
* refers to a different account afterwards. The model-pool identity survives
|
|
112
|
+
* that reindex; the refresh token is the last-resort fallback for records that
|
|
113
|
+
* have not resolved an account id yet.
|
|
114
|
+
*/
|
|
115
|
+
function getAccountDiagnosticsKey(account) {
|
|
116
|
+
return getModelPoolAccountKey(account) ?? `refresh:${account.refreshToken}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* How many live accounts the configured pool keys actually resolve to.
|
|
120
|
+
*
|
|
121
|
+
* Pool keys and accounts are not 1:1 — a legacy `accountId` key matches every
|
|
122
|
+
* Business seat in that workspace, and two keys can resolve to the same seat —
|
|
123
|
+
* so pool size must be counted over accounts, never over `poolKeys.length`.
|
|
124
|
+
*/
|
|
125
|
+
function countResolvedPoolAccounts(accounts, poolKeys) {
|
|
126
|
+
return accounts.filter((account) => poolKeys.some((key) => matchesModelPoolAccountKey(account, key))).length;
|
|
127
|
+
}
|
|
128
|
+
/** Configured pool keys matching no live account (typo, or account removed). */
|
|
129
|
+
function countUnresolvedPoolKeys(accounts, poolKeys) {
|
|
130
|
+
return poolKeys.filter((key) => !accounts.some((account) => matchesModelPoolAccountKey(account, key))).length;
|
|
131
|
+
}
|
|
132
|
+
function invalidBaseURL(reason) {
|
|
133
|
+
return new Error(`[oc-codex-multi-auth] OPENAI_BASE_URL is invalid: ${reason}. Fix the value, or unset ` +
|
|
134
|
+
"CODEX_AUTH_ALLOW_OPENAI_BASE_URL to fall back to the default ChatGPT Codex endpoint.");
|
|
135
|
+
}
|
|
136
|
+
function resolveOpenAIBaseURL() {
|
|
137
|
+
if (process.env.CODEX_AUTH_ALLOW_OPENAI_BASE_URL !== "1")
|
|
138
|
+
return undefined;
|
|
139
|
+
const raw = process.env.OPENAI_BASE_URL?.trim();
|
|
140
|
+
if (!raw)
|
|
141
|
+
return undefined;
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = new URL(raw);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// `new URL()` throws a bare `TypeError: Invalid URL` that names neither the
|
|
148
|
+
// plugin nor the variable, which is useless when it surfaces out of the
|
|
149
|
+
// auth loader. A scheme-less value such as `gateway.example/v1` is the
|
|
150
|
+
// common way to hit this. The value itself is NOT echoed: it can carry a
|
|
151
|
+
// token in a query string, and this message reaches a toast.
|
|
152
|
+
throw invalidBaseURL("the value is not a valid absolute URL (an explicit https:// or http:// scheme is required)");
|
|
153
|
+
}
|
|
154
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
155
|
+
throw invalidBaseURL("it must use http:// or https://");
|
|
156
|
+
}
|
|
157
|
+
if (parsed.username || parsed.password) {
|
|
158
|
+
throw invalidBaseURL("it must not embed credentials");
|
|
159
|
+
}
|
|
160
|
+
if (parsed.search || parsed.hash || raw.includes("?") || raw.includes("#")) {
|
|
161
|
+
throw invalidBaseURL("it must not include a query string or fragment");
|
|
162
|
+
}
|
|
163
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
164
|
+
if (parsed.protocol === "http:" && !isLoopbackGatewayHost(hostname)) {
|
|
165
|
+
throw invalidBaseURL(`https:// is required for non-loopback host "${hostname}" so the ChatGPT OAuth ` +
|
|
166
|
+
"access token is never sent in cleartext");
|
|
167
|
+
}
|
|
168
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
169
|
+
}
|
|
67
170
|
/**
|
|
68
171
|
* OpenAI Codex OAuth authentication plugin for opencode
|
|
69
172
|
*
|
|
@@ -82,6 +185,8 @@ import { clearTuiQuotaSnapshot, parseTuiQuotaSnapshotFromHeaders, writeTuiQuotaS
|
|
|
82
185
|
*/
|
|
83
186
|
export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
84
187
|
initLogger(client);
|
|
188
|
+
let customBaseURLWarningShown = false;
|
|
189
|
+
let customBaseURLErrorShown = false;
|
|
85
190
|
let cachedAccountManager = null;
|
|
86
191
|
let accountManagerPromise = null;
|
|
87
192
|
let loaderMutex = null;
|
|
@@ -440,16 +545,15 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
440
545
|
process.env.OPENCODE_SKIP_EMAIL_HYDRATE === "1";
|
|
441
546
|
if (skipHydrate)
|
|
442
547
|
return storage;
|
|
443
|
-
|
|
548
|
+
let accountsCopy = storage.accounts.map((account) => account ? { ...account } : account);
|
|
444
549
|
const accountsToHydrate = accountsCopy.filter((account) => account && !account.email);
|
|
445
550
|
if (accountsToHydrate.length === 0)
|
|
446
551
|
return storage;
|
|
447
552
|
let changed = false;
|
|
448
|
-
// Record hydrated field updates keyed by the account's ORIGINAL
|
|
449
|
-
// refresh token (captured before the network call) so we can
|
|
450
|
-
// re-apply them onto a freshly-loaded snapshot inside a storage
|
|
451
|
-
// transaction — avoiding a lost-update race with concurrent saves.
|
|
452
553
|
const hydrationUpdates = new Map();
|
|
554
|
+
// Keyed by stable workspace identity rather than by refresh token, for
|
|
555
|
+
// accounts whose token the coordinator rotated on disk mid-loop.
|
|
556
|
+
const hydrationUpdatesByIdentity = new Map();
|
|
453
557
|
// process in chunks of 3 to avoid auth0 rate limits (429) on startup
|
|
454
558
|
const chunkSize = 3;
|
|
455
559
|
for (let i = 0; i < accountsToHydrate.length; i += chunkSize) {
|
|
@@ -457,7 +561,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
457
561
|
await Promise.all(chunk.map(async (account) => {
|
|
458
562
|
const originalRefreshToken = account.refreshToken;
|
|
459
563
|
try {
|
|
460
|
-
const refreshed = await
|
|
564
|
+
const refreshed = await coordinatePersistedRefresh(account);
|
|
461
565
|
if (refreshed.type !== "success")
|
|
462
566
|
return;
|
|
463
567
|
const update = hydrationUpdates.get(originalRefreshToken) ?? {};
|
|
@@ -497,8 +601,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
497
601
|
update.refreshToken = refreshed.refresh;
|
|
498
602
|
changed = true;
|
|
499
603
|
}
|
|
604
|
+
const rotatedAt = refreshed.rotatedAt ?? account.tokenRotatedAt;
|
|
605
|
+
if (typeof rotatedAt === "number") {
|
|
606
|
+
account.tokenRotatedAt = rotatedAt;
|
|
607
|
+
update.tokenRotatedAt = rotatedAt;
|
|
608
|
+
}
|
|
500
609
|
if (Object.keys(update).length > 0) {
|
|
501
610
|
hydrationUpdates.set(originalRefreshToken, update);
|
|
611
|
+
// The coordinator has already committed the rotated token to
|
|
612
|
+
// disk, so matching this update by refresh token alone would
|
|
613
|
+
// miss this account and — worse — hit any SIBLING whose token
|
|
614
|
+
// was rotated in place to the same value. Siblings can belong
|
|
615
|
+
// to distinct orgs, so that would hand one workspace another
|
|
616
|
+
// workspace's access token. Register the update under this
|
|
617
|
+
// account's stable workspace identity instead; that key is
|
|
618
|
+
// built from org/account/member ids and does not rotate.
|
|
619
|
+
const identityKey = getWorkspaceIdentityKey(account);
|
|
620
|
+
if (!identityKey.startsWith("refreshToken:")) {
|
|
621
|
+
hydrationUpdatesByIdentity.set(identityKey, update);
|
|
622
|
+
}
|
|
502
623
|
}
|
|
503
624
|
}
|
|
504
625
|
catch {
|
|
@@ -515,7 +636,14 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
515
636
|
if (!current)
|
|
516
637
|
return;
|
|
517
638
|
for (const acc of current.accounts) {
|
|
518
|
-
|
|
639
|
+
// Identity first: it survives a rotation the coordinator already
|
|
640
|
+
// committed. The refresh-token key is the fallback for records
|
|
641
|
+
// that carry no org/account/member id at all.
|
|
642
|
+
const identityKey = getWorkspaceIdentityKey(acc);
|
|
643
|
+
const update = (identityKey.startsWith("refreshToken:")
|
|
644
|
+
? undefined
|
|
645
|
+
: hydrationUpdatesByIdentity.get(identityKey)) ??
|
|
646
|
+
hydrationUpdates.get(acc.refreshToken);
|
|
519
647
|
if (!update)
|
|
520
648
|
continue;
|
|
521
649
|
if (update.accountId !== undefined)
|
|
@@ -524,18 +652,32 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
524
652
|
acc.accountIdSource = update.accountIdSource;
|
|
525
653
|
if (update.email !== undefined)
|
|
526
654
|
acc.email = update.email;
|
|
527
|
-
if (update.accessToken !== undefined)
|
|
528
|
-
acc.accessToken = update.accessToken;
|
|
529
|
-
if (update.expiresAt !== undefined)
|
|
530
|
-
acc.expiresAt = update.expiresAt;
|
|
531
655
|
if (update.oauthScope !== undefined)
|
|
532
656
|
acc.oauthScope = update.oauthScope;
|
|
533
|
-
//
|
|
534
|
-
// (
|
|
535
|
-
|
|
536
|
-
|
|
657
|
+
// The credential triple is single-use. This transaction runs after
|
|
658
|
+
// the whole (multi-second) hydration loop, so a concurrent process
|
|
659
|
+
// may have rotated the account in the meantime; writing our older
|
|
660
|
+
// tuple back would restore a consumed token and cost a re-login.
|
|
661
|
+
const updateRotatedAt = update.tokenRotatedAt ?? 0;
|
|
662
|
+
const diskRotatedAt = acc.tokenRotatedAt ?? 0;
|
|
663
|
+
if (updateRotatedAt >= diskRotatedAt) {
|
|
664
|
+
if (update.accessToken !== undefined)
|
|
665
|
+
acc.accessToken = update.accessToken;
|
|
666
|
+
if (update.expiresAt !== undefined)
|
|
667
|
+
acc.expiresAt = update.expiresAt;
|
|
668
|
+
// Apply the rotated refresh token LAST so the map key
|
|
669
|
+
// (original token) still matches above.
|
|
670
|
+
if (update.refreshToken !== undefined)
|
|
671
|
+
acc.refreshToken = update.refreshToken;
|
|
672
|
+
if (update.tokenRotatedAt !== undefined)
|
|
673
|
+
acc.tokenRotatedAt = update.tokenRotatedAt;
|
|
674
|
+
}
|
|
537
675
|
}
|
|
538
676
|
await persist(current);
|
|
677
|
+
// Return what actually landed on disk, not the in-memory copy: a
|
|
678
|
+
// credential we declined to write above must not resurface through
|
|
679
|
+
// the returned storage either.
|
|
680
|
+
accountsCopy = current.accounts.map((acc) => ({ ...acc }));
|
|
539
681
|
});
|
|
540
682
|
storage.accounts = accountsCopy;
|
|
541
683
|
}
|
|
@@ -1001,16 +1143,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1001
1143
|
}
|
|
1002
1144
|
const now = Date.now();
|
|
1003
1145
|
const account = storage.accounts[index];
|
|
1004
|
-
if (account)
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1146
|
+
if (!account)
|
|
1147
|
+
return;
|
|
1148
|
+
const identityKey = getWorkspaceIdentityKey(account);
|
|
1149
|
+
await withAccountStorageTransaction(async (current, persist) => {
|
|
1150
|
+
if (!current)
|
|
1151
|
+
return;
|
|
1152
|
+
const currentIndex = current.accounts.findIndex((candidate) => getWorkspaceIdentityKey(candidate) === identityKey);
|
|
1153
|
+
const currentAccount = current.accounts[currentIndex];
|
|
1154
|
+
if (!currentAccount || currentIndex < 0)
|
|
1155
|
+
return;
|
|
1156
|
+
currentAccount.lastUsed = now;
|
|
1157
|
+
currentAccount.lastSwitchReason = "rotation";
|
|
1158
|
+
current.activeIndex = currentIndex;
|
|
1159
|
+
current.activeIndexByFamily = current.activeIndexByFamily ?? {};
|
|
1160
|
+
for (const family of MODEL_FAMILIES) {
|
|
1161
|
+
current.activeIndexByFamily[family] = currentIndex;
|
|
1162
|
+
}
|
|
1163
|
+
await persist(current);
|
|
1164
|
+
});
|
|
1014
1165
|
await clearPromptQuotaCache();
|
|
1015
1166
|
// Reload manager from disk so we don't overwrite newer rotated
|
|
1016
1167
|
// refresh tokens with stale in-memory state.
|
|
@@ -1102,6 +1253,28 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1102
1253
|
async loader(getAuth, provider) {
|
|
1103
1254
|
const auth = await getAuth();
|
|
1104
1255
|
const pluginConfig = loadPluginConfig();
|
|
1256
|
+
let openAIBaseURL;
|
|
1257
|
+
try {
|
|
1258
|
+
openAIBaseURL = resolveOpenAIBaseURL();
|
|
1259
|
+
}
|
|
1260
|
+
catch (err) {
|
|
1261
|
+
// Fail closed rather than silently falling back to the default
|
|
1262
|
+
// endpoint: the operator asked for a gateway, so quietly bypassing
|
|
1263
|
+
// it would send ChatGPT traffic somewhere they did not choose.
|
|
1264
|
+
// Surface the reason the same way storage failures are surfaced.
|
|
1265
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1266
|
+
if (!customBaseURLErrorShown) {
|
|
1267
|
+
customBaseURLErrorShown = true;
|
|
1268
|
+
await showToast(message, "error");
|
|
1269
|
+
}
|
|
1270
|
+
throw err;
|
|
1271
|
+
}
|
|
1272
|
+
if (openAIBaseURL && !customBaseURLWarningShown) {
|
|
1273
|
+
customBaseURLWarningShown = true;
|
|
1274
|
+
logWarn("Routing ChatGPT OAuth inference through OPENAI_BASE_URL", {
|
|
1275
|
+
origin: new URL(openAIBaseURL).origin,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1105
1278
|
applyUiRuntimeFromConfig(pluginConfig);
|
|
1106
1279
|
const perProjectAccounts = getPerProjectAccounts(pluginConfig);
|
|
1107
1280
|
setStoragePath(perProjectAccounts ? process.cwd() : null);
|
|
@@ -1255,7 +1428,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1255
1428
|
// Return SDK configuration
|
|
1256
1429
|
return {
|
|
1257
1430
|
apiKey: DUMMY_API_KEY,
|
|
1258
|
-
baseURL: CODEX_BASE_URL,
|
|
1431
|
+
baseURL: openAIBaseURL ?? CODEX_BASE_URL,
|
|
1259
1432
|
/**
|
|
1260
1433
|
* Custom fetch implementation for Codex API
|
|
1261
1434
|
*
|
|
@@ -1278,7 +1451,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1278
1451
|
}
|
|
1279
1452
|
// Step 1: Extract and rewrite URL for Codex backend
|
|
1280
1453
|
const originalUrl = extractRequestUrl(input);
|
|
1281
|
-
const url = rewriteUrlForCodex(originalUrl);
|
|
1454
|
+
const url = openAIBaseURL ? originalUrl : rewriteUrlForCodex(originalUrl);
|
|
1282
1455
|
// Step 3: Transform request body with model-specific Codex instructions
|
|
1283
1456
|
// Instructions are fetched per model family (codex-max, codex, gpt-5.4, etc.)
|
|
1284
1457
|
// Capture original stream value before transformation
|
|
@@ -1437,6 +1610,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1437
1610
|
while (true) {
|
|
1438
1611
|
let accountCount = accountManager.getAccountCount();
|
|
1439
1612
|
const attempted = new Set();
|
|
1613
|
+
// Diagnostics for the terminal error message below. Keyed on a stable
|
|
1614
|
+
// account identity, not `account.index`, so the two `attempted.clear()`
|
|
1615
|
+
// sites that follow a mid-traversal account removal cannot desync them.
|
|
1616
|
+
// Deliberately scoped to this traversal: a fallback restart re-enters
|
|
1617
|
+
// the loop with a different model, and the message names that model.
|
|
1618
|
+
const fetchedAccountKeys = new Set();
|
|
1619
|
+
const unsupportedAccountKeys = new Set();
|
|
1440
1620
|
let restartAccountTraversalWithFallback = false;
|
|
1441
1621
|
let restartAccountTraversalAfterWorkspaceDeactivation = false;
|
|
1442
1622
|
const preferredAccountIds = getModelAccountPool(pluginConfig, model);
|
|
@@ -1493,7 +1673,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1493
1673
|
let accountAuth = accountManager.toAuthDetails(account);
|
|
1494
1674
|
try {
|
|
1495
1675
|
if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
|
|
1496
|
-
accountAuth = (await refreshAndUpdateToken(accountAuth, client
|
|
1676
|
+
accountAuth = (await refreshAndUpdateToken(accountAuth, client, {
|
|
1677
|
+
organizationId: account.organizationId,
|
|
1678
|
+
accountId: account.accountId,
|
|
1679
|
+
accountUserId: account.accountUserId,
|
|
1680
|
+
}));
|
|
1497
1681
|
accountManager.updateFromAuth(account, accountAuth);
|
|
1498
1682
|
accountManager.clearAuthFailures(account);
|
|
1499
1683
|
accountManager.saveToDiskDebounced();
|
|
@@ -1672,10 +1856,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1672
1856
|
// in-memory only and run on Node's single-threaded event loop, so no
|
|
1673
1857
|
// filesystem locking or token-redaction concerns are introduced here.
|
|
1674
1858
|
runtimeMetrics.totalRequests++;
|
|
1859
|
+
fetchedAccountKeys.add(getAccountDiagnosticsKey(account));
|
|
1675
1860
|
response = await fetch(url, {
|
|
1676
1861
|
...requestInit,
|
|
1677
1862
|
headers,
|
|
1678
1863
|
signal: fetchController.signal,
|
|
1864
|
+
...(openAIBaseURL ? { redirect: "manual" } : {}),
|
|
1679
1865
|
});
|
|
1680
1866
|
}
|
|
1681
1867
|
catch (networkError) {
|
|
@@ -1721,6 +1907,42 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1721
1907
|
}
|
|
1722
1908
|
}
|
|
1723
1909
|
const fetchLatencyMs = Math.round(performance.now() - fetchStart);
|
|
1910
|
+
// `redirect: "manual"` hands back the raw 3xx instead of following it,
|
|
1911
|
+
// so it has to be rejected explicitly. Left alone it would reach
|
|
1912
|
+
// ensureContentType/convertSseToJson as an empty-bodied "success" and
|
|
1913
|
+
// be reported as a content-type or empty-response error that says
|
|
1914
|
+
// nothing about the redirect. Only the Location *origin* is logged;
|
|
1915
|
+
// the full target can carry credentials in its query string.
|
|
1916
|
+
if (openAIBaseURL && response.status >= 300 && response.status < 400) {
|
|
1917
|
+
let redirectTarget = "an unparseable location";
|
|
1918
|
+
const location = response.headers.get("location");
|
|
1919
|
+
if (location) {
|
|
1920
|
+
try {
|
|
1921
|
+
redirectTarget = new URL(location, url).origin;
|
|
1922
|
+
}
|
|
1923
|
+
catch {
|
|
1924
|
+
// keep the placeholder
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
else {
|
|
1928
|
+
redirectTarget = "no location header";
|
|
1929
|
+
}
|
|
1930
|
+
logWarn(`OPENAI_BASE_URL gateway attempted a redirect (${response.status}) to ${redirectTarget}`, { origin: new URL(openAIBaseURL).origin });
|
|
1931
|
+
accountManager.refundToken(account, modelFamily, model);
|
|
1932
|
+
return new Response(JSON.stringify({
|
|
1933
|
+
error: {
|
|
1934
|
+
message: `The OPENAI_BASE_URL gateway responded with a ${response.status} redirect to ` +
|
|
1935
|
+
`${redirectTarget}. Redirects are not followed, because the ChatGPT OAuth ` +
|
|
1936
|
+
"access token must never be replayed to an endpoint that was not explicitly " +
|
|
1937
|
+
"configured. Point OPENAI_BASE_URL at the final endpoint instead.",
|
|
1938
|
+
},
|
|
1939
|
+
}), {
|
|
1940
|
+
status: 502,
|
|
1941
|
+
headers: {
|
|
1942
|
+
"content-type": "application/json; charset=utf-8",
|
|
1943
|
+
},
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1724
1946
|
logRequest(LOG_STAGES.RESPONSE, {
|
|
1725
1947
|
status: response.status,
|
|
1726
1948
|
ok: response.ok,
|
|
@@ -1792,6 +2014,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1792
2014
|
break;
|
|
1793
2015
|
}
|
|
1794
2016
|
const unsupportedModelInfo = getUnsupportedCodexModelInfo(errorBody);
|
|
2017
|
+
if (unsupportedModelInfo.isUnsupported) {
|
|
2018
|
+
unsupportedAccountKeys.add(getAccountDiagnosticsKey(account));
|
|
2019
|
+
}
|
|
1795
2020
|
const hasRemainingAccounts = attempted.size < Math.max(1, accountCount);
|
|
1796
2021
|
// Entitlements can differ by account/workspace, so try remaining
|
|
1797
2022
|
// accounts before degrading the model via fallback.
|
|
@@ -2151,14 +2376,37 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2151
2376
|
if (restartAccountTraversalAfterWorkspaceDeactivation) {
|
|
2152
2377
|
continue;
|
|
2153
2378
|
}
|
|
2379
|
+
// Shared by both terminal messages below: how many distinct accounts
|
|
2380
|
+
// this traversal actually sent upstream, and how many of those the
|
|
2381
|
+
// backend rejected as not entitled to the model.
|
|
2382
|
+
const attemptedCount = fetchedAccountKeys.size;
|
|
2383
|
+
const unsupportedCount = unsupportedAccountKeys.size;
|
|
2154
2384
|
if (strictAccountPool) {
|
|
2155
2385
|
const poolWaitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model, preferredAccountIds);
|
|
2156
2386
|
const effectiveModel = model ?? requestedModel ?? "requested model";
|
|
2387
|
+
const poolAccounts = accountManager.getAccountsSnapshot();
|
|
2388
|
+
const poolAccountCount = countResolvedPoolAccounts(poolAccounts, preferredAccountIds);
|
|
2389
|
+
const unresolvedKeyCount = countUnresolvedPoolKeys(poolAccounts, preferredAccountIds);
|
|
2390
|
+
const unavailableCount = Math.max(0, poolAccountCount - attemptedCount);
|
|
2157
2391
|
const waitDetail = poolWaitMs > 0
|
|
2158
2392
|
? ` Try again in ${formatWaitTime(poolWaitMs)}.`
|
|
2159
2393
|
: " Check the pooled accounts with `codex-health`.";
|
|
2394
|
+
const attemptDetail = unsupportedCount > 0
|
|
2395
|
+
? ` The model was unsupported on ${unsupportedCount} of ${attemptedCount} attempted pooled account(s).`
|
|
2396
|
+
: attemptedCount > 0
|
|
2397
|
+
? ` ${attemptedCount} pooled account(s) were attempted without success.`
|
|
2398
|
+
: "";
|
|
2399
|
+
const unresolvedDetail = unresolvedKeyCount > 0
|
|
2400
|
+
? ` ${unresolvedKeyCount} configured pool key(s) matched no known account.`
|
|
2401
|
+
: "";
|
|
2402
|
+
const unavailableDetail = unavailableCount > 0
|
|
2403
|
+
? ` ${unavailableCount} pooled account(s) were never attempted (rate-limited, cooling down, or disabled).`
|
|
2404
|
+
: "";
|
|
2160
2405
|
const message = `Strict account pool unavailable for ${effectiveModel}. ` +
|
|
2161
|
-
|
|
2406
|
+
`${preferredAccountIds.length} configured pool key(s) resolved to ${poolAccountCount} account(s).` +
|
|
2407
|
+
attemptDetail +
|
|
2408
|
+
unresolvedDetail +
|
|
2409
|
+
unavailableDetail +
|
|
2162
2410
|
waitDetail;
|
|
2163
2411
|
if (runtimeMetrics.lastSelectionSnapshot) {
|
|
2164
2412
|
runtimeMetrics.lastSelectionSnapshot = {
|
|
@@ -2183,6 +2431,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2183
2431
|
}
|
|
2184
2432
|
const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
|
|
2185
2433
|
const count = accountManager.getAccountCount();
|
|
2434
|
+
const unavailableCount = Math.max(0, count - attemptedCount);
|
|
2186
2435
|
if (retryAllAccountsRateLimited &&
|
|
2187
2436
|
count > 0 &&
|
|
2188
2437
|
waitMs > 0 &&
|
|
@@ -2196,19 +2445,24 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2196
2445
|
continue;
|
|
2197
2446
|
}
|
|
2198
2447
|
const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
|
|
2199
|
-
|
|
2448
|
+
// `runtimeMetrics` is plugin-scoped, so `lastErrorCategory` alone
|
|
2449
|
+
// can still hold "unsupported-model" from an *earlier* request.
|
|
2450
|
+
// Require an unsupported response from this traversal as well, or
|
|
2451
|
+
// a request that never reached an account renders "0 of 0".
|
|
2452
|
+
const wasEntitlementExhaustion = unsupportedCount > 0 &&
|
|
2453
|
+
runtimeMetrics.lastErrorCategory === "unsupported-model";
|
|
2200
2454
|
const entitlementModel = typeof runtimeMetrics.lastError === "string"
|
|
2201
2455
|
? runtimeMetrics.lastError.replace(/^Unsupported model.*?:\s*/i, "").trim()
|
|
2202
2456
|
: "";
|
|
2203
2457
|
const entitlementDetail = entitlementModel.length > 0
|
|
2204
|
-
? ` The backend rejected '${entitlementModel}' as not entitled for Codex OAuth on
|
|
2458
|
+
? ` The backend rejected '${entitlementModel}' as not entitled for Codex OAuth on ${unsupportedCount} of ${attemptedCount} attempted account(s).${unavailableCount > 0 ? ` ${unavailableCount} configured account(s) were unavailable or excluded.` : ""}`
|
|
2205
2459
|
: "";
|
|
2206
2460
|
const message = count === 0
|
|
2207
2461
|
? "No Codex accounts configured. Run `opencode auth login`."
|
|
2208
2462
|
: waitMs > 0
|
|
2209
2463
|
? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`opencode auth login\`.`
|
|
2210
2464
|
: wasEntitlementExhaustion
|
|
2211
|
-
? `
|
|
2465
|
+
? `No selectable account succeeded for the requested model across ${count} configured account(s).${entitlementDetail} Codex model access is account/workspace gated; default gpt-5.6-sol/terra/luna selectors auto-fallback down the 5.6 tiers to gpt-5.5, and gpt-5.5/gpt-5-codex through the GPT-5.4 family when possible. Set \`unsupportedCodexPolicy: "fallback"\` for the full manual fallback chain, or see \`codex-health\` for per-account details.`
|
|
2212
2466
|
: `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`;
|
|
2213
2467
|
runtimeMetrics.failedRequests++;
|
|
2214
2468
|
runtimeMetrics.lastError = message;
|
|
@@ -2401,7 +2655,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2401
2655
|
}
|
|
2402
2656
|
}
|
|
2403
2657
|
if (!accessToken) {
|
|
2404
|
-
const refreshResult = await
|
|
2658
|
+
const refreshResult = await coordinatePersistedRefresh(account);
|
|
2405
2659
|
if (refreshResult.type !== "success") {
|
|
2406
2660
|
errors += 1;
|
|
2407
2661
|
const message = refreshResult.message ?? refreshResult.reason ?? "refresh failed";
|
|
@@ -2425,6 +2679,14 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2425
2679
|
account.refreshToken = refreshResult.refresh;
|
|
2426
2680
|
storageChanged = true;
|
|
2427
2681
|
}
|
|
2682
|
+
// Carry the coordinator's rotation stamp onto the working
|
|
2683
|
+
// copy so the merge below can tell this snapshot's token
|
|
2684
|
+
// apart from one a concurrent process rotated later.
|
|
2685
|
+
if (typeof refreshResult.rotatedAt === "number" &&
|
|
2686
|
+
refreshResult.rotatedAt !== account.tokenRotatedAt) {
|
|
2687
|
+
account.tokenRotatedAt = refreshResult.rotatedAt;
|
|
2688
|
+
storageChanged = true;
|
|
2689
|
+
}
|
|
2428
2690
|
if (refreshResult.access && refreshResult.access !== account.accessToken) {
|
|
2429
2691
|
account.accessToken = refreshResult.access;
|
|
2430
2692
|
storageChanged = true;
|
|
@@ -2551,11 +2813,24 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2551
2813
|
// concurrent edit during the network loop is not reverted.
|
|
2552
2814
|
acc.accountId = updated.accountId;
|
|
2553
2815
|
acc.accountIdSource = updated.accountIdSource;
|
|
2554
|
-
acc.refreshToken = updated.refreshToken;
|
|
2555
|
-
acc.accessToken = updated.accessToken;
|
|
2556
|
-
acc.expiresAt = updated.expiresAt;
|
|
2557
2816
|
acc.oauthScope = updated.oauthScope;
|
|
2558
2817
|
acc.email = updated.email;
|
|
2818
|
+
// The credential triple is single-use and must only move
|
|
2819
|
+
// backwards-in-time never. `workingStorage` was snapshotted
|
|
2820
|
+
// before the (multi-second) network loop, so its token can
|
|
2821
|
+
// already be consumed: a sibling's refresh rotates every
|
|
2822
|
+
// record sharing that token *on disk*, and a concurrent
|
|
2823
|
+
// process can rotate it too. Writing the stale token back
|
|
2824
|
+
// would resurrect a dead credential and cost the user a
|
|
2825
|
+
// re-login with `refresh_token_reused`.
|
|
2826
|
+
const workingRotatedAt = updated.tokenRotatedAt ?? 0;
|
|
2827
|
+
const diskRotatedAt = acc.tokenRotatedAt ?? 0;
|
|
2828
|
+
if (workingRotatedAt >= diskRotatedAt) {
|
|
2829
|
+
acc.refreshToken = updated.refreshToken;
|
|
2830
|
+
acc.accessToken = updated.accessToken;
|
|
2831
|
+
acc.expiresAt = updated.expiresAt;
|
|
2832
|
+
acc.tokenRotatedAt = updated.tokenRotatedAt;
|
|
2833
|
+
}
|
|
2559
2834
|
}
|
|
2560
2835
|
merged.push(acc);
|
|
2561
2836
|
}
|
|
@@ -2594,6 +2869,15 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2594
2869
|
const maskEmailEnabled = getCodexTuiMaskEmail(loadPluginConfig());
|
|
2595
2870
|
const remaining = [];
|
|
2596
2871
|
const restored = [];
|
|
2872
|
+
const processedIdentityKeys = new Set(flaggedStorage.accounts.map((account) => getWorkspaceIdentityKey(account)));
|
|
2873
|
+
const processedRefreshTokens = new Set(flaggedStorage.accounts.map((account) => account.refreshToken));
|
|
2874
|
+
// Only a record we positively restored may be deleted below. A record
|
|
2875
|
+
// can *look* processed without having been restored — its token can be
|
|
2876
|
+
// rotated in place by a sibling's refresh, so neither replacement
|
|
2877
|
+
// lookup finds it — and dropping that record would erase the account
|
|
2878
|
+
// from flagged-accounts.json with no trace anywhere.
|
|
2879
|
+
const restoredIdentityKeys = new Set();
|
|
2880
|
+
const restoredRefreshTokens = new Set();
|
|
2597
2881
|
for (let i = 0; i < flaggedStorage.accounts.length; i += 1) {
|
|
2598
2882
|
const flagged = flaggedStorage.accounts[i];
|
|
2599
2883
|
if (!flagged)
|
|
@@ -2629,10 +2913,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2629
2913
|
accountLabel: flagged.accountLabel,
|
|
2630
2914
|
});
|
|
2631
2915
|
restored.push(...resolved.variantsForPersistence);
|
|
2916
|
+
restoredIdentityKeys.add(getWorkspaceIdentityKey(flagged));
|
|
2917
|
+
restoredRefreshTokens.add(flagged.refreshToken);
|
|
2632
2918
|
console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`);
|
|
2633
2919
|
continue;
|
|
2634
2920
|
}
|
|
2635
|
-
const refreshResult = await
|
|
2921
|
+
const refreshResult = await coordinateFlaggedPersistedRefresh(flagged);
|
|
2636
2922
|
if (refreshResult.type !== "success") {
|
|
2637
2923
|
console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`);
|
|
2638
2924
|
remaining.push(flagged);
|
|
@@ -2645,6 +2931,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2645
2931
|
accountLabel: flagged.accountLabel,
|
|
2646
2932
|
});
|
|
2647
2933
|
restored.push(...resolved.variantsForPersistence);
|
|
2934
|
+
processedRefreshTokens.add(refreshResult.refresh);
|
|
2935
|
+
restoredIdentityKeys.add(getWorkspaceIdentityKey(flagged));
|
|
2936
|
+
restoredRefreshTokens.add(flagged.refreshToken);
|
|
2648
2937
|
console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`);
|
|
2649
2938
|
}
|
|
2650
2939
|
catch (error) {
|
|
@@ -2660,9 +2949,28 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2660
2949
|
await persistAccountPool(restored, false);
|
|
2661
2950
|
invalidateAccountManagerCache();
|
|
2662
2951
|
}
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2952
|
+
const remainingByIdentity = new Map(remaining.map((account) => [getWorkspaceIdentityKey(account), account]));
|
|
2953
|
+
const remainingByRefreshToken = new Map(remaining.map((account) => [account.refreshToken, account]));
|
|
2954
|
+
await withFlaggedAccountStorageTransaction(async (current, persist) => {
|
|
2955
|
+
current.accounts = current.accounts.flatMap((account) => {
|
|
2956
|
+
const identityKey = getWorkspaceIdentityKey(account);
|
|
2957
|
+
const processed = processedIdentityKeys.has(identityKey) ||
|
|
2958
|
+
processedRefreshTokens.has(account.refreshToken);
|
|
2959
|
+
if (!processed)
|
|
2960
|
+
return [account];
|
|
2961
|
+
const replacement = remainingByIdentity.get(identityKey) ??
|
|
2962
|
+
remainingByRefreshToken.get(account.refreshToken);
|
|
2963
|
+
if (replacement) {
|
|
2964
|
+
return [{ ...account, lastError: replacement.lastError }];
|
|
2965
|
+
}
|
|
2966
|
+
// Drop only what was positively restored. Anything else that
|
|
2967
|
+
// merely looks processed is kept: a stale flagged entry is
|
|
2968
|
+
// recoverable, a silently deleted account is not.
|
|
2969
|
+
const wasRestored = restoredIdentityKeys.has(identityKey) ||
|
|
2970
|
+
restoredRefreshTokens.has(account.refreshToken);
|
|
2971
|
+
return wasRestored ? [] : [account];
|
|
2972
|
+
});
|
|
2973
|
+
await persist(current);
|
|
2666
2974
|
});
|
|
2667
2975
|
console.log("");
|
|
2668
2976
|
console.log(`Results: ${restored.length} restored, ${remaining.length} still flagged`);
|
|
@@ -2747,12 +3055,20 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2747
3055
|
if (typeof menuResult.deleteAccountIndex === "number") {
|
|
2748
3056
|
const target = workingStorage.accounts[menuResult.deleteAccountIndex];
|
|
2749
3057
|
if (target) {
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
3058
|
+
const identityKey = getWorkspaceIdentityKey(target);
|
|
3059
|
+
await withAccountStorageTransaction(async (current, persist) => {
|
|
3060
|
+
if (!current)
|
|
3061
|
+
return;
|
|
3062
|
+
const currentIndex = current.accounts.findIndex((candidate) => getWorkspaceIdentityKey(candidate) === identityKey);
|
|
3063
|
+
if (currentIndex < 0)
|
|
3064
|
+
return;
|
|
3065
|
+
current.accounts.splice(currentIndex, 1);
|
|
3066
|
+
clampActiveIndices(current);
|
|
3067
|
+
await persist(current);
|
|
3068
|
+
});
|
|
3069
|
+
await withFlaggedAccountStorageTransaction(async (current, persist) => {
|
|
3070
|
+
current.accounts = current.accounts.filter((flagged) => !matchesWorkspaceIdentity(flagged, identityKey));
|
|
3071
|
+
await persist(current);
|
|
2756
3072
|
});
|
|
2757
3073
|
invalidateAccountManagerCache();
|
|
2758
3074
|
console.log(`\nDeleted ${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`);
|
|
@@ -2762,10 +3078,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2762
3078
|
if (typeof menuResult.toggleAccountIndex === "number") {
|
|
2763
3079
|
const target = workingStorage.accounts[menuResult.toggleAccountIndex];
|
|
2764
3080
|
if (target) {
|
|
2765
|
-
|
|
2766
|
-
|
|
3081
|
+
const identityKey = getWorkspaceIdentityKey(target);
|
|
3082
|
+
let enabled = target.enabled !== false;
|
|
3083
|
+
await withAccountStorageTransaction(async (current, persist) => {
|
|
3084
|
+
const currentTarget = current?.accounts.find((candidate) => getWorkspaceIdentityKey(candidate) === identityKey);
|
|
3085
|
+
if (!current || !currentTarget)
|
|
3086
|
+
return;
|
|
3087
|
+
currentTarget.enabled = currentTarget.enabled === false;
|
|
3088
|
+
enabled = currentTarget.enabled;
|
|
3089
|
+
await persist(current);
|
|
3090
|
+
});
|
|
2767
3091
|
invalidateAccountManagerCache();
|
|
2768
|
-
console.log(`\n${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${
|
|
3092
|
+
console.log(`\n${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${enabled ? "enabled" : "disabled"}.\n`);
|
|
2769
3093
|
}
|
|
2770
3094
|
continue;
|
|
2771
3095
|
}
|