oc-codex-multi-auth 6.12.1 → 6.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +319 -50
- package/dist/index.js.map +1 -1
- package/dist/lib/accounts/persistence.d.ts.map +1 -1
- package/dist/lib/accounts/persistence.js +1 -0
- package/dist/lib/accounts/persistence.js.map +1 -1
- package/dist/lib/accounts/pool-identity.d.ts +14 -0
- package/dist/lib/accounts/pool-identity.d.ts.map +1 -0
- package/dist/lib/accounts/pool-identity.js +45 -0
- package/dist/lib/accounts/pool-identity.js.map +1 -0
- package/dist/lib/accounts/recovery.d.ts.map +1 -1
- package/dist/lib/accounts/recovery.js +15 -1
- package/dist/lib/accounts/recovery.js.map +1 -1
- package/dist/lib/accounts/rotation.d.ts.map +1 -1
- package/dist/lib/accounts/rotation.js +2 -3
- package/dist/lib/accounts/rotation.js.map +1 -1
- package/dist/lib/accounts/stale-state.d.ts +14 -0
- package/dist/lib/accounts/stale-state.d.ts.map +1 -1
- package/dist/lib/accounts/stale-state.js +38 -0
- package/dist/lib/accounts/stale-state.js.map +1 -1
- package/dist/lib/accounts/state.d.ts +1 -0
- package/dist/lib/accounts/state.d.ts.map +1 -1
- package/dist/lib/accounts/state.js +114 -13
- package/dist/lib/accounts/state.js.map +1 -1
- package/dist/lib/auth/auth.d.ts.map +1 -1
- package/dist/lib/auth/auth.js +26 -3
- package/dist/lib/auth/auth.js.map +1 -1
- package/dist/lib/auth/login-runner.d.ts +1 -0
- package/dist/lib/auth/login-runner.d.ts.map +1 -1
- package/dist/lib/auth/login-runner.js +74 -8
- package/dist/lib/auth/login-runner.js.map +1 -1
- package/dist/lib/auth/token-utils.d.ts +8 -0
- package/dist/lib/auth/token-utils.d.ts.map +1 -1
- package/dist/lib/auth/token-utils.js +16 -0
- package/dist/lib/auth/token-utils.js.map +1 -1
- package/dist/lib/codex-usage.d.ts +9 -7
- package/dist/lib/codex-usage.d.ts.map +1 -1
- package/dist/lib/codex-usage.js +30 -71
- package/dist/lib/codex-usage.js.map +1 -1
- package/dist/lib/config.d.ts +6 -4
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +12 -4
- package/dist/lib/config.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/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +2 -0
- package/dist/lib/logger.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/schemas.d.ts +8 -1
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +4 -1
- package/dist/lib/schemas.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 +16 -3
- package/dist/lib/storage/flagged.js.map +1 -1
- package/dist/lib/storage/identity.d.ts +10 -7
- package/dist/lib/storage/identity.d.ts.map +1 -1
- package/dist/lib/storage/identity.js +82 -8
- package/dist/lib/storage/identity.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/migrations.d.ts +4 -0
- package/dist/lib/storage/migrations.d.ts.map +1 -1
- package/dist/lib/storage/migrations.js +1 -0
- package/dist/lib/storage/migrations.js.map +1 -1
- package/dist/lib/storage/normalize.d.ts.map +1 -1
- package/dist/lib/storage/normalize.js +8 -1
- package/dist/lib/storage/normalize.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-diff.d.ts.map +1 -1
- package/dist/lib/tools/codex-diff.js +2 -0
- package/dist/lib/tools/codex-diff.js.map +1 -1
- package/dist/lib/tools/codex-doctor.d.ts.map +1 -1
- package/dist/lib/tools/codex-doctor.js +15 -1
- package/dist/lib/tools/codex-doctor.js.map +1 -1
- package/dist/lib/tools/codex-health.d.ts.map +1 -1
- package/dist/lib/tools/codex-health.js +7 -1
- package/dist/lib/tools/codex-health.js.map +1 -1
- 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-pool.d.ts.map +1 -1
- package/dist/lib/tools/codex-pool.js +49 -12
- package/dist/lib/tools/codex-pool.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 +6 -15
- package/dist/lib/tools/refresh-account.d.ts.map +1 -1
- package/dist/lib/tools/refresh-account.js +19 -106
- package/dist/lib/tools/refresh-account.js.map +1 -1
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/package.json +3 -5
package/README.md
CHANGED
|
@@ -257,10 +257,12 @@ Most of these also run as a **direct CLI** with no agent/model involvement (no t
|
|
|
257
257
|
- `reasoning.encrypted_content` is preserved for multi-turn continuity
|
|
258
258
|
- GPT-5.6 tiers use the responses-lite request shape and default client identity `opencode`; other models default to `codex_cli_rs`
|
|
259
259
|
- account rotation is health-aware (`rotationStrategy` default `hybrid`) and avoids repeatedly selecting cooling accounts
|
|
260
|
+
- same-host OpenCode processes sharing an account file serialize refresh-token exchange and commit so one current single-use token is exchanged once
|
|
260
261
|
- 5xx bursts, network failures, and quota responses penalize account health
|
|
261
262
|
- token refresh is queued to avoid refresh races
|
|
262
263
|
- unsupported-model handling is strict by default, with opt-in fallback controls
|
|
263
264
|
- TUI quota status follows the account/workspace used by the latest request
|
|
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.
|
|
264
266
|
|
|
265
267
|
---
|
|
266
268
|
|
|
@@ -293,9 +295,11 @@ Primary config files:
|
|
|
293
295
|
|
|
294
296
|
### Route models to preferred accounts
|
|
295
297
|
|
|
296
|
-
Use `modelAccountPools` to assign one or more preferred ChatGPT accounts to a
|
|
297
|
-
model. Account references use stable account
|
|
298
|
-
reordering accounts does not silently change a model's
|
|
298
|
+
Use `modelAccountPools` to assign one or more preferred ChatGPT accounts or Business seats to a
|
|
299
|
+
model. Account references use stable account or Business-seat identities, so
|
|
300
|
+
adding, removing, or reordering accounts does not silently change a model's
|
|
301
|
+
routing. A Business membership and a Personal account remain separate pool and
|
|
302
|
+
usage identities even when they belong to the same login.
|
|
299
303
|
|
|
300
304
|
```json
|
|
301
305
|
{
|
|
@@ -353,6 +357,8 @@ Selected runtime/environment overrides:
|
|
|
353
357
|
|
|
354
358
|
| Variable | Effect |
|
|
355
359
|
| --- | --- |
|
|
360
|
+
| `OPENAI_BASE_URL=https://gateway.example/v1` | OpenAI-compatible OAuth inference gateway; requires `CODEX_AUTH_ALLOW_OPENAI_BASE_URL=1` |
|
|
361
|
+
| `CODEX_AUTH_ALLOW_OPENAI_BASE_URL=1` | Explicitly allow the trusted gateway to receive the ChatGPT OAuth access token; remote gateways require HTTPS, while HTTP is accepted only on literal loopback IPs |
|
|
356
362
|
| `CODEX_AUTH_REQUEST_TRANSFORM_MODE=legacy` | Re-enable legacy Codex request rewriting |
|
|
357
363
|
| `CODEX_MODE=0/1` | Disable/enable bridge prompt behavior |
|
|
358
364
|
| `CODEX_TUI_V2=0/1` | Disable/enable codex-style tool output |
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AA8S/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAg2H/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -23,12 +23,13 @@
|
|
|
23
23
|
|
|
24
24
|
*/
|
|
25
25
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
26
27
|
import { homedir } from "node:os";
|
|
27
28
|
import { join } from "node:path";
|
|
28
29
|
import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, } from "./lib/auth/auth.js";
|
|
29
30
|
import { buildDeviceCodeInstructions, completeDeviceCodeSession, createDeviceCodeSession, } from "./lib/auth/device-code.js";
|
|
30
31
|
import { applyAccountSelectionFallbacks, persistResolvedAccountSelection, persistAccountPool, resolveAndPersistAccountSelection, resolveAccountSelection, } from "./lib/auth/login-runner.js";
|
|
31
|
-
import {
|
|
32
|
+
import { coordinateFlaggedPersistedRefresh, coordinatePersistedRefresh, } from "./lib/storage/coordinated-refresh.js";
|
|
32
33
|
import { openBrowserUrl } from "./lib/auth/browser.js";
|
|
33
34
|
import { startLocalOAuthServer } from "./lib/auth/server.js";
|
|
34
35
|
import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
|
|
@@ -38,9 +39,11 @@ import { initLogger, logRequest, logDebug, logInfo, logWarn, logError, setCorrel
|
|
|
38
39
|
import { checkAndNotify } from "./lib/auto-update-checker.js";
|
|
39
40
|
import { handleContextOverflow } from "./lib/context-overflow.js";
|
|
40
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";
|
|
41
43
|
import { resolveDisplayEmail } from "./lib/account-display.js";
|
|
42
44
|
import { CodexAuthError } from "./lib/errors.js";
|
|
43
|
-
import { getStoragePath, loadAccounts,
|
|
45
|
+
import { getStoragePath, loadAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, loadFlaggedAccounts, withFlaggedAccountStorageTransaction, clearFlaggedAccounts, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
|
|
46
|
+
import { getWorkspaceIdentityKey } from "./lib/storage/identity.js";
|
|
44
47
|
import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, isDeactivatedWorkspaceError, isInvalidatedAuthTokenError, createAbortError, getUnsupportedCodexModelInfo, resolveUnsupportedCodexFallbackModel, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
|
|
45
48
|
import { shapeBodyForModel } from "./lib/request/helpers/responses-lite.js";
|
|
46
49
|
import { DEACTIVATED_WORKSPACE_ERROR_CODE, isDeactivatedWorkspaceErrorMessage, isInvalidatedAuthTokenMessage, } from "./lib/error-sentinels.js";
|
|
@@ -61,6 +64,83 @@ import { createToolRegistry, } from "./lib/tools/index.js";
|
|
|
61
64
|
import { createUsageAccountFingerprint, fetchCodexUsage, formatUsageLimitSummary, formatUsageLimitTitle, hasUsageWindow, parseCodexUsagePayload, } from "./lib/codex-usage.js";
|
|
62
65
|
import { getQuotaExhaustedResetAtMs } from "./lib/quota-windows.js";
|
|
63
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
|
+
function invalidBaseURL(reason) {
|
|
107
|
+
return new Error(`[oc-codex-multi-auth] OPENAI_BASE_URL is invalid: ${reason}. Fix the value, or unset ` +
|
|
108
|
+
"CODEX_AUTH_ALLOW_OPENAI_BASE_URL to fall back to the default ChatGPT Codex endpoint.");
|
|
109
|
+
}
|
|
110
|
+
function resolveOpenAIBaseURL() {
|
|
111
|
+
if (process.env.CODEX_AUTH_ALLOW_OPENAI_BASE_URL !== "1")
|
|
112
|
+
return undefined;
|
|
113
|
+
const raw = process.env.OPENAI_BASE_URL?.trim();
|
|
114
|
+
if (!raw)
|
|
115
|
+
return undefined;
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = new URL(raw);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// `new URL()` throws a bare `TypeError: Invalid URL` that names neither the
|
|
122
|
+
// plugin nor the variable, which is useless when it surfaces out of the
|
|
123
|
+
// auth loader. A scheme-less value such as `gateway.example/v1` is the
|
|
124
|
+
// common way to hit this. The value itself is NOT echoed: it can carry a
|
|
125
|
+
// token in a query string, and this message reaches a toast.
|
|
126
|
+
throw invalidBaseURL("the value is not a valid absolute URL (an explicit https:// or http:// scheme is required)");
|
|
127
|
+
}
|
|
128
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
129
|
+
throw invalidBaseURL("it must use http:// or https://");
|
|
130
|
+
}
|
|
131
|
+
if (parsed.username || parsed.password) {
|
|
132
|
+
throw invalidBaseURL("it must not embed credentials");
|
|
133
|
+
}
|
|
134
|
+
if (parsed.search || parsed.hash || raw.includes("?") || raw.includes("#")) {
|
|
135
|
+
throw invalidBaseURL("it must not include a query string or fragment");
|
|
136
|
+
}
|
|
137
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
138
|
+
if (parsed.protocol === "http:" && !isLoopbackGatewayHost(hostname)) {
|
|
139
|
+
throw invalidBaseURL(`https:// is required for non-loopback host "${hostname}" so the ChatGPT OAuth ` +
|
|
140
|
+
"access token is never sent in cleartext");
|
|
141
|
+
}
|
|
142
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
143
|
+
}
|
|
64
144
|
/**
|
|
65
145
|
* OpenAI Codex OAuth authentication plugin for opencode
|
|
66
146
|
*
|
|
@@ -79,6 +159,8 @@ import { clearTuiQuotaSnapshot, parseTuiQuotaSnapshotFromHeaders, writeTuiQuotaS
|
|
|
79
159
|
*/
|
|
80
160
|
export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
81
161
|
initLogger(client);
|
|
162
|
+
let customBaseURLWarningShown = false;
|
|
163
|
+
let customBaseURLErrorShown = false;
|
|
82
164
|
let cachedAccountManager = null;
|
|
83
165
|
let accountManagerPromise = null;
|
|
84
166
|
let loaderMutex = null;
|
|
@@ -437,16 +519,15 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
437
519
|
process.env.OPENCODE_SKIP_EMAIL_HYDRATE === "1";
|
|
438
520
|
if (skipHydrate)
|
|
439
521
|
return storage;
|
|
440
|
-
|
|
522
|
+
let accountsCopy = storage.accounts.map((account) => account ? { ...account } : account);
|
|
441
523
|
const accountsToHydrate = accountsCopy.filter((account) => account && !account.email);
|
|
442
524
|
if (accountsToHydrate.length === 0)
|
|
443
525
|
return storage;
|
|
444
526
|
let changed = false;
|
|
445
|
-
// Record hydrated field updates keyed by the account's ORIGINAL
|
|
446
|
-
// refresh token (captured before the network call) so we can
|
|
447
|
-
// re-apply them onto a freshly-loaded snapshot inside a storage
|
|
448
|
-
// transaction — avoiding a lost-update race with concurrent saves.
|
|
449
527
|
const hydrationUpdates = new Map();
|
|
528
|
+
// Keyed by stable workspace identity rather than by refresh token, for
|
|
529
|
+
// accounts whose token the coordinator rotated on disk mid-loop.
|
|
530
|
+
const hydrationUpdatesByIdentity = new Map();
|
|
450
531
|
// process in chunks of 3 to avoid auth0 rate limits (429) on startup
|
|
451
532
|
const chunkSize = 3;
|
|
452
533
|
for (let i = 0; i < accountsToHydrate.length; i += chunkSize) {
|
|
@@ -454,7 +535,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
454
535
|
await Promise.all(chunk.map(async (account) => {
|
|
455
536
|
const originalRefreshToken = account.refreshToken;
|
|
456
537
|
try {
|
|
457
|
-
const refreshed = await
|
|
538
|
+
const refreshed = await coordinatePersistedRefresh(account);
|
|
458
539
|
if (refreshed.type !== "success")
|
|
459
540
|
return;
|
|
460
541
|
const update = hydrationUpdates.get(originalRefreshToken) ?? {};
|
|
@@ -494,8 +575,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
494
575
|
update.refreshToken = refreshed.refresh;
|
|
495
576
|
changed = true;
|
|
496
577
|
}
|
|
578
|
+
const rotatedAt = refreshed.rotatedAt ?? account.tokenRotatedAt;
|
|
579
|
+
if (typeof rotatedAt === "number") {
|
|
580
|
+
account.tokenRotatedAt = rotatedAt;
|
|
581
|
+
update.tokenRotatedAt = rotatedAt;
|
|
582
|
+
}
|
|
497
583
|
if (Object.keys(update).length > 0) {
|
|
498
584
|
hydrationUpdates.set(originalRefreshToken, update);
|
|
585
|
+
// The coordinator has already committed the rotated token to
|
|
586
|
+
// disk, so matching this update by refresh token alone would
|
|
587
|
+
// miss this account and — worse — hit any SIBLING whose token
|
|
588
|
+
// was rotated in place to the same value. Siblings can belong
|
|
589
|
+
// to distinct orgs, so that would hand one workspace another
|
|
590
|
+
// workspace's access token. Register the update under this
|
|
591
|
+
// account's stable workspace identity instead; that key is
|
|
592
|
+
// built from org/account/member ids and does not rotate.
|
|
593
|
+
const identityKey = getWorkspaceIdentityKey(account);
|
|
594
|
+
if (!identityKey.startsWith("refreshToken:")) {
|
|
595
|
+
hydrationUpdatesByIdentity.set(identityKey, update);
|
|
596
|
+
}
|
|
499
597
|
}
|
|
500
598
|
}
|
|
501
599
|
catch {
|
|
@@ -512,7 +610,14 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
512
610
|
if (!current)
|
|
513
611
|
return;
|
|
514
612
|
for (const acc of current.accounts) {
|
|
515
|
-
|
|
613
|
+
// Identity first: it survives a rotation the coordinator already
|
|
614
|
+
// committed. The refresh-token key is the fallback for records
|
|
615
|
+
// that carry no org/account/member id at all.
|
|
616
|
+
const identityKey = getWorkspaceIdentityKey(acc);
|
|
617
|
+
const update = (identityKey.startsWith("refreshToken:")
|
|
618
|
+
? undefined
|
|
619
|
+
: hydrationUpdatesByIdentity.get(identityKey)) ??
|
|
620
|
+
hydrationUpdates.get(acc.refreshToken);
|
|
516
621
|
if (!update)
|
|
517
622
|
continue;
|
|
518
623
|
if (update.accountId !== undefined)
|
|
@@ -521,18 +626,32 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
521
626
|
acc.accountIdSource = update.accountIdSource;
|
|
522
627
|
if (update.email !== undefined)
|
|
523
628
|
acc.email = update.email;
|
|
524
|
-
if (update.accessToken !== undefined)
|
|
525
|
-
acc.accessToken = update.accessToken;
|
|
526
|
-
if (update.expiresAt !== undefined)
|
|
527
|
-
acc.expiresAt = update.expiresAt;
|
|
528
629
|
if (update.oauthScope !== undefined)
|
|
529
630
|
acc.oauthScope = update.oauthScope;
|
|
530
|
-
//
|
|
531
|
-
// (
|
|
532
|
-
|
|
533
|
-
|
|
631
|
+
// The credential triple is single-use. This transaction runs after
|
|
632
|
+
// the whole (multi-second) hydration loop, so a concurrent process
|
|
633
|
+
// may have rotated the account in the meantime; writing our older
|
|
634
|
+
// tuple back would restore a consumed token and cost a re-login.
|
|
635
|
+
const updateRotatedAt = update.tokenRotatedAt ?? 0;
|
|
636
|
+
const diskRotatedAt = acc.tokenRotatedAt ?? 0;
|
|
637
|
+
if (updateRotatedAt >= diskRotatedAt) {
|
|
638
|
+
if (update.accessToken !== undefined)
|
|
639
|
+
acc.accessToken = update.accessToken;
|
|
640
|
+
if (update.expiresAt !== undefined)
|
|
641
|
+
acc.expiresAt = update.expiresAt;
|
|
642
|
+
// Apply the rotated refresh token LAST so the map key
|
|
643
|
+
// (original token) still matches above.
|
|
644
|
+
if (update.refreshToken !== undefined)
|
|
645
|
+
acc.refreshToken = update.refreshToken;
|
|
646
|
+
if (update.tokenRotatedAt !== undefined)
|
|
647
|
+
acc.tokenRotatedAt = update.tokenRotatedAt;
|
|
648
|
+
}
|
|
534
649
|
}
|
|
535
650
|
await persist(current);
|
|
651
|
+
// Return what actually landed on disk, not the in-memory copy: a
|
|
652
|
+
// credential we declined to write above must not resurface through
|
|
653
|
+
// the returned storage either.
|
|
654
|
+
accountsCopy = current.accounts.map((acc) => ({ ...acc }));
|
|
536
655
|
});
|
|
537
656
|
storage.accounts = accountsCopy;
|
|
538
657
|
}
|
|
@@ -998,16 +1117,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
998
1117
|
}
|
|
999
1118
|
const now = Date.now();
|
|
1000
1119
|
const account = storage.accounts[index];
|
|
1001
|
-
if (account)
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1120
|
+
if (!account)
|
|
1121
|
+
return;
|
|
1122
|
+
const identityKey = getWorkspaceIdentityKey(account);
|
|
1123
|
+
await withAccountStorageTransaction(async (current, persist) => {
|
|
1124
|
+
if (!current)
|
|
1125
|
+
return;
|
|
1126
|
+
const currentIndex = current.accounts.findIndex((candidate) => getWorkspaceIdentityKey(candidate) === identityKey);
|
|
1127
|
+
const currentAccount = current.accounts[currentIndex];
|
|
1128
|
+
if (!currentAccount || currentIndex < 0)
|
|
1129
|
+
return;
|
|
1130
|
+
currentAccount.lastUsed = now;
|
|
1131
|
+
currentAccount.lastSwitchReason = "rotation";
|
|
1132
|
+
current.activeIndex = currentIndex;
|
|
1133
|
+
current.activeIndexByFamily = current.activeIndexByFamily ?? {};
|
|
1134
|
+
for (const family of MODEL_FAMILIES) {
|
|
1135
|
+
current.activeIndexByFamily[family] = currentIndex;
|
|
1136
|
+
}
|
|
1137
|
+
await persist(current);
|
|
1138
|
+
});
|
|
1011
1139
|
await clearPromptQuotaCache();
|
|
1012
1140
|
// Reload manager from disk so we don't overwrite newer rotated
|
|
1013
1141
|
// refresh tokens with stale in-memory state.
|
|
@@ -1099,6 +1227,28 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1099
1227
|
async loader(getAuth, provider) {
|
|
1100
1228
|
const auth = await getAuth();
|
|
1101
1229
|
const pluginConfig = loadPluginConfig();
|
|
1230
|
+
let openAIBaseURL;
|
|
1231
|
+
try {
|
|
1232
|
+
openAIBaseURL = resolveOpenAIBaseURL();
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
// Fail closed rather than silently falling back to the default
|
|
1236
|
+
// endpoint: the operator asked for a gateway, so quietly bypassing
|
|
1237
|
+
// it would send ChatGPT traffic somewhere they did not choose.
|
|
1238
|
+
// Surface the reason the same way storage failures are surfaced.
|
|
1239
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1240
|
+
if (!customBaseURLErrorShown) {
|
|
1241
|
+
customBaseURLErrorShown = true;
|
|
1242
|
+
await showToast(message, "error");
|
|
1243
|
+
}
|
|
1244
|
+
throw err;
|
|
1245
|
+
}
|
|
1246
|
+
if (openAIBaseURL && !customBaseURLWarningShown) {
|
|
1247
|
+
customBaseURLWarningShown = true;
|
|
1248
|
+
logWarn("Routing ChatGPT OAuth inference through OPENAI_BASE_URL", {
|
|
1249
|
+
origin: new URL(openAIBaseURL).origin,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1102
1252
|
applyUiRuntimeFromConfig(pluginConfig);
|
|
1103
1253
|
const perProjectAccounts = getPerProjectAccounts(pluginConfig);
|
|
1104
1254
|
setStoragePath(perProjectAccounts ? process.cwd() : null);
|
|
@@ -1252,7 +1402,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1252
1402
|
// Return SDK configuration
|
|
1253
1403
|
return {
|
|
1254
1404
|
apiKey: DUMMY_API_KEY,
|
|
1255
|
-
baseURL: CODEX_BASE_URL,
|
|
1405
|
+
baseURL: openAIBaseURL ?? CODEX_BASE_URL,
|
|
1256
1406
|
/**
|
|
1257
1407
|
* Custom fetch implementation for Codex API
|
|
1258
1408
|
*
|
|
@@ -1275,7 +1425,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1275
1425
|
}
|
|
1276
1426
|
// Step 1: Extract and rewrite URL for Codex backend
|
|
1277
1427
|
const originalUrl = extractRequestUrl(input);
|
|
1278
|
-
const url = rewriteUrlForCodex(originalUrl);
|
|
1428
|
+
const url = openAIBaseURL ? originalUrl : rewriteUrlForCodex(originalUrl);
|
|
1279
1429
|
// Step 3: Transform request body with model-specific Codex instructions
|
|
1280
1430
|
// Instructions are fetched per model family (codex-max, codex, gpt-5.4, etc.)
|
|
1281
1431
|
// Capture original stream value before transformation
|
|
@@ -1479,8 +1629,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1479
1629
|
? "strict"
|
|
1480
1630
|
: preferredAccountIds.length === 0
|
|
1481
1631
|
? "general"
|
|
1482
|
-
:
|
|
1483
|
-
preferredAccountIds.includes(account.accountId)
|
|
1632
|
+
: preferredAccountIds.some((key) => matchesModelPoolAccountKey(account, key))
|
|
1484
1633
|
? "preferred"
|
|
1485
1634
|
: "general-fallback",
|
|
1486
1635
|
configuredAccountPoolSize: preferredAccountIds.length,
|
|
@@ -1491,7 +1640,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1491
1640
|
let accountAuth = accountManager.toAuthDetails(account);
|
|
1492
1641
|
try {
|
|
1493
1642
|
if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
|
|
1494
|
-
accountAuth = (await refreshAndUpdateToken(accountAuth, client
|
|
1643
|
+
accountAuth = (await refreshAndUpdateToken(accountAuth, client, {
|
|
1644
|
+
organizationId: account.organizationId,
|
|
1645
|
+
accountId: account.accountId,
|
|
1646
|
+
accountUserId: account.accountUserId,
|
|
1647
|
+
}));
|
|
1495
1648
|
accountManager.updateFromAuth(account, accountAuth);
|
|
1496
1649
|
accountManager.clearAuthFailures(account);
|
|
1497
1650
|
accountManager.saveToDiskDebounced();
|
|
@@ -1619,7 +1772,16 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1619
1772
|
// RC-8: per-(account, family) circuit-breaker key. The breaker gates
|
|
1620
1773
|
// upstream calls so that repeated failures short-circuit to the
|
|
1621
1774
|
// rotation path instead of hammering a degraded endpoint.
|
|
1622
|
-
const
|
|
1775
|
+
const stableWorkspaceIdentity = account.accountId || account.accountUserId || account.organizationId
|
|
1776
|
+
? getWorkspaceIdentityKey(account)
|
|
1777
|
+
: undefined;
|
|
1778
|
+
const breakerOwner = stableWorkspaceIdentity
|
|
1779
|
+
? `identity-${createHash("sha256")
|
|
1780
|
+
.update(stableWorkspaceIdentity)
|
|
1781
|
+
.digest("hex")
|
|
1782
|
+
.slice(0, 16)}`
|
|
1783
|
+
: `index-${account.index}`;
|
|
1784
|
+
const circuitBreakerKey = `${accountId}:${breakerOwner}:${modelFamily}`;
|
|
1623
1785
|
const circuitBreaker = getCircuitBreaker(circuitBreakerKey);
|
|
1624
1786
|
while (true) {
|
|
1625
1787
|
let response;
|
|
@@ -1665,6 +1827,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1665
1827
|
...requestInit,
|
|
1666
1828
|
headers,
|
|
1667
1829
|
signal: fetchController.signal,
|
|
1830
|
+
...(openAIBaseURL ? { redirect: "manual" } : {}),
|
|
1668
1831
|
});
|
|
1669
1832
|
}
|
|
1670
1833
|
catch (networkError) {
|
|
@@ -1710,6 +1873,42 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1710
1873
|
}
|
|
1711
1874
|
}
|
|
1712
1875
|
const fetchLatencyMs = Math.round(performance.now() - fetchStart);
|
|
1876
|
+
// `redirect: "manual"` hands back the raw 3xx instead of following it,
|
|
1877
|
+
// so it has to be rejected explicitly. Left alone it would reach
|
|
1878
|
+
// ensureContentType/convertSseToJson as an empty-bodied "success" and
|
|
1879
|
+
// be reported as a content-type or empty-response error that says
|
|
1880
|
+
// nothing about the redirect. Only the Location *origin* is logged;
|
|
1881
|
+
// the full target can carry credentials in its query string.
|
|
1882
|
+
if (openAIBaseURL && response.status >= 300 && response.status < 400) {
|
|
1883
|
+
let redirectTarget = "an unparseable location";
|
|
1884
|
+
const location = response.headers.get("location");
|
|
1885
|
+
if (location) {
|
|
1886
|
+
try {
|
|
1887
|
+
redirectTarget = new URL(location, url).origin;
|
|
1888
|
+
}
|
|
1889
|
+
catch {
|
|
1890
|
+
// keep the placeholder
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
else {
|
|
1894
|
+
redirectTarget = "no location header";
|
|
1895
|
+
}
|
|
1896
|
+
logWarn(`OPENAI_BASE_URL gateway attempted a redirect (${response.status}) to ${redirectTarget}`, { origin: new URL(openAIBaseURL).origin });
|
|
1897
|
+
accountManager.refundToken(account, modelFamily, model);
|
|
1898
|
+
return new Response(JSON.stringify({
|
|
1899
|
+
error: {
|
|
1900
|
+
message: `The OPENAI_BASE_URL gateway responded with a ${response.status} redirect to ` +
|
|
1901
|
+
`${redirectTarget}. Redirects are not followed, because the ChatGPT OAuth ` +
|
|
1902
|
+
"access token must never be replayed to an endpoint that was not explicitly " +
|
|
1903
|
+
"configured. Point OPENAI_BASE_URL at the final endpoint instead.",
|
|
1904
|
+
},
|
|
1905
|
+
}), {
|
|
1906
|
+
status: 502,
|
|
1907
|
+
headers: {
|
|
1908
|
+
"content-type": "application/json; charset=utf-8",
|
|
1909
|
+
},
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1713
1912
|
logRequest(LOG_STAGES.RESPONSE, {
|
|
1714
1913
|
status: response.status,
|
|
1715
1914
|
ok: response.ok,
|
|
@@ -2390,7 +2589,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2390
2589
|
}
|
|
2391
2590
|
}
|
|
2392
2591
|
if (!accessToken) {
|
|
2393
|
-
const refreshResult = await
|
|
2592
|
+
const refreshResult = await coordinatePersistedRefresh(account);
|
|
2394
2593
|
if (refreshResult.type !== "success") {
|
|
2395
2594
|
errors += 1;
|
|
2396
2595
|
const message = refreshResult.message ?? refreshResult.reason ?? "refresh failed";
|
|
@@ -2414,6 +2613,14 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2414
2613
|
account.refreshToken = refreshResult.refresh;
|
|
2415
2614
|
storageChanged = true;
|
|
2416
2615
|
}
|
|
2616
|
+
// Carry the coordinator's rotation stamp onto the working
|
|
2617
|
+
// copy so the merge below can tell this snapshot's token
|
|
2618
|
+
// apart from one a concurrent process rotated later.
|
|
2619
|
+
if (typeof refreshResult.rotatedAt === "number" &&
|
|
2620
|
+
refreshResult.rotatedAt !== account.tokenRotatedAt) {
|
|
2621
|
+
account.tokenRotatedAt = refreshResult.rotatedAt;
|
|
2622
|
+
storageChanged = true;
|
|
2623
|
+
}
|
|
2417
2624
|
if (refreshResult.access && refreshResult.access !== account.accessToken) {
|
|
2418
2625
|
account.accessToken = refreshResult.access;
|
|
2419
2626
|
storageChanged = true;
|
|
@@ -2540,11 +2747,24 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2540
2747
|
// concurrent edit during the network loop is not reverted.
|
|
2541
2748
|
acc.accountId = updated.accountId;
|
|
2542
2749
|
acc.accountIdSource = updated.accountIdSource;
|
|
2543
|
-
acc.refreshToken = updated.refreshToken;
|
|
2544
|
-
acc.accessToken = updated.accessToken;
|
|
2545
|
-
acc.expiresAt = updated.expiresAt;
|
|
2546
2750
|
acc.oauthScope = updated.oauthScope;
|
|
2547
2751
|
acc.email = updated.email;
|
|
2752
|
+
// The credential triple is single-use and must only move
|
|
2753
|
+
// backwards-in-time never. `workingStorage` was snapshotted
|
|
2754
|
+
// before the (multi-second) network loop, so its token can
|
|
2755
|
+
// already be consumed: a sibling's refresh rotates every
|
|
2756
|
+
// record sharing that token *on disk*, and a concurrent
|
|
2757
|
+
// process can rotate it too. Writing the stale token back
|
|
2758
|
+
// would resurrect a dead credential and cost the user a
|
|
2759
|
+
// re-login with `refresh_token_reused`.
|
|
2760
|
+
const workingRotatedAt = updated.tokenRotatedAt ?? 0;
|
|
2761
|
+
const diskRotatedAt = acc.tokenRotatedAt ?? 0;
|
|
2762
|
+
if (workingRotatedAt >= diskRotatedAt) {
|
|
2763
|
+
acc.refreshToken = updated.refreshToken;
|
|
2764
|
+
acc.accessToken = updated.accessToken;
|
|
2765
|
+
acc.expiresAt = updated.expiresAt;
|
|
2766
|
+
acc.tokenRotatedAt = updated.tokenRotatedAt;
|
|
2767
|
+
}
|
|
2548
2768
|
}
|
|
2549
2769
|
merged.push(acc);
|
|
2550
2770
|
}
|
|
@@ -2583,6 +2803,15 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2583
2803
|
const maskEmailEnabled = getCodexTuiMaskEmail(loadPluginConfig());
|
|
2584
2804
|
const remaining = [];
|
|
2585
2805
|
const restored = [];
|
|
2806
|
+
const processedIdentityKeys = new Set(flaggedStorage.accounts.map((account) => getWorkspaceIdentityKey(account)));
|
|
2807
|
+
const processedRefreshTokens = new Set(flaggedStorage.accounts.map((account) => account.refreshToken));
|
|
2808
|
+
// Only a record we positively restored may be deleted below. A record
|
|
2809
|
+
// can *look* processed without having been restored — its token can be
|
|
2810
|
+
// rotated in place by a sibling's refresh, so neither replacement
|
|
2811
|
+
// lookup finds it — and dropping that record would erase the account
|
|
2812
|
+
// from flagged-accounts.json with no trace anywhere.
|
|
2813
|
+
const restoredIdentityKeys = new Set();
|
|
2814
|
+
const restoredRefreshTokens = new Set();
|
|
2586
2815
|
for (let i = 0; i < flaggedStorage.accounts.length; i += 1) {
|
|
2587
2816
|
const flagged = flaggedStorage.accounts[i];
|
|
2588
2817
|
if (!flagged)
|
|
@@ -2618,10 +2847,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2618
2847
|
accountLabel: flagged.accountLabel,
|
|
2619
2848
|
});
|
|
2620
2849
|
restored.push(...resolved.variantsForPersistence);
|
|
2850
|
+
restoredIdentityKeys.add(getWorkspaceIdentityKey(flagged));
|
|
2851
|
+
restoredRefreshTokens.add(flagged.refreshToken);
|
|
2621
2852
|
console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`);
|
|
2622
2853
|
continue;
|
|
2623
2854
|
}
|
|
2624
|
-
const refreshResult = await
|
|
2855
|
+
const refreshResult = await coordinateFlaggedPersistedRefresh(flagged);
|
|
2625
2856
|
if (refreshResult.type !== "success") {
|
|
2626
2857
|
console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`);
|
|
2627
2858
|
remaining.push(flagged);
|
|
@@ -2634,6 +2865,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2634
2865
|
accountLabel: flagged.accountLabel,
|
|
2635
2866
|
});
|
|
2636
2867
|
restored.push(...resolved.variantsForPersistence);
|
|
2868
|
+
processedRefreshTokens.add(refreshResult.refresh);
|
|
2869
|
+
restoredIdentityKeys.add(getWorkspaceIdentityKey(flagged));
|
|
2870
|
+
restoredRefreshTokens.add(flagged.refreshToken);
|
|
2637
2871
|
console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`);
|
|
2638
2872
|
}
|
|
2639
2873
|
catch (error) {
|
|
@@ -2649,9 +2883,28 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2649
2883
|
await persistAccountPool(restored, false);
|
|
2650
2884
|
invalidateAccountManagerCache();
|
|
2651
2885
|
}
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2886
|
+
const remainingByIdentity = new Map(remaining.map((account) => [getWorkspaceIdentityKey(account), account]));
|
|
2887
|
+
const remainingByRefreshToken = new Map(remaining.map((account) => [account.refreshToken, account]));
|
|
2888
|
+
await withFlaggedAccountStorageTransaction(async (current, persist) => {
|
|
2889
|
+
current.accounts = current.accounts.flatMap((account) => {
|
|
2890
|
+
const identityKey = getWorkspaceIdentityKey(account);
|
|
2891
|
+
const processed = processedIdentityKeys.has(identityKey) ||
|
|
2892
|
+
processedRefreshTokens.has(account.refreshToken);
|
|
2893
|
+
if (!processed)
|
|
2894
|
+
return [account];
|
|
2895
|
+
const replacement = remainingByIdentity.get(identityKey) ??
|
|
2896
|
+
remainingByRefreshToken.get(account.refreshToken);
|
|
2897
|
+
if (replacement) {
|
|
2898
|
+
return [{ ...account, lastError: replacement.lastError }];
|
|
2899
|
+
}
|
|
2900
|
+
// Drop only what was positively restored. Anything else that
|
|
2901
|
+
// merely looks processed is kept: a stale flagged entry is
|
|
2902
|
+
// recoverable, a silently deleted account is not.
|
|
2903
|
+
const wasRestored = restoredIdentityKeys.has(identityKey) ||
|
|
2904
|
+
restoredRefreshTokens.has(account.refreshToken);
|
|
2905
|
+
return wasRestored ? [] : [account];
|
|
2906
|
+
});
|
|
2907
|
+
await persist(current);
|
|
2655
2908
|
});
|
|
2656
2909
|
console.log("");
|
|
2657
2910
|
console.log(`Results: ${restored.length} restored, ${remaining.length} still flagged`);
|
|
@@ -2736,12 +2989,20 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2736
2989
|
if (typeof menuResult.deleteAccountIndex === "number") {
|
|
2737
2990
|
const target = workingStorage.accounts[menuResult.deleteAccountIndex];
|
|
2738
2991
|
if (target) {
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2992
|
+
const identityKey = getWorkspaceIdentityKey(target);
|
|
2993
|
+
await withAccountStorageTransaction(async (current, persist) => {
|
|
2994
|
+
if (!current)
|
|
2995
|
+
return;
|
|
2996
|
+
const currentIndex = current.accounts.findIndex((candidate) => getWorkspaceIdentityKey(candidate) === identityKey);
|
|
2997
|
+
if (currentIndex < 0)
|
|
2998
|
+
return;
|
|
2999
|
+
current.accounts.splice(currentIndex, 1);
|
|
3000
|
+
clampActiveIndices(current);
|
|
3001
|
+
await persist(current);
|
|
3002
|
+
});
|
|
3003
|
+
await withFlaggedAccountStorageTransaction(async (current, persist) => {
|
|
3004
|
+
current.accounts = current.accounts.filter((flagged) => !matchesWorkspaceIdentity(flagged, identityKey));
|
|
3005
|
+
await persist(current);
|
|
2745
3006
|
});
|
|
2746
3007
|
invalidateAccountManagerCache();
|
|
2747
3008
|
console.log(`\nDeleted ${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`);
|
|
@@ -2751,10 +3012,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
2751
3012
|
if (typeof menuResult.toggleAccountIndex === "number") {
|
|
2752
3013
|
const target = workingStorage.accounts[menuResult.toggleAccountIndex];
|
|
2753
3014
|
if (target) {
|
|
2754
|
-
|
|
2755
|
-
|
|
3015
|
+
const identityKey = getWorkspaceIdentityKey(target);
|
|
3016
|
+
let enabled = target.enabled !== false;
|
|
3017
|
+
await withAccountStorageTransaction(async (current, persist) => {
|
|
3018
|
+
const currentTarget = current?.accounts.find((candidate) => getWorkspaceIdentityKey(candidate) === identityKey);
|
|
3019
|
+
if (!current || !currentTarget)
|
|
3020
|
+
return;
|
|
3021
|
+
currentTarget.enabled = currentTarget.enabled === false;
|
|
3022
|
+
enabled = currentTarget.enabled;
|
|
3023
|
+
await persist(current);
|
|
3024
|
+
});
|
|
2756
3025
|
invalidateAccountManagerCache();
|
|
2757
|
-
console.log(`\n${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${
|
|
3026
|
+
console.log(`\n${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${enabled ? "enabled" : "disabled"}.\n`);
|
|
2758
3027
|
}
|
|
2759
3028
|
continue;
|
|
2760
3029
|
}
|