@bitkyc08/opencodex 2.89.0 → 2.90.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/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +48 -6
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +8 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/generated/compatibility-version.json +168 -108
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +6 -2
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/server/chat-native.ts +8 -7
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +2 -0
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +3 -0
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/service/orchestration.ts +17 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
readDesktopLibraryText,
|
|
20
20
|
resolveDesktop3pConfigLibraryPath,
|
|
21
21
|
SAFE_DESKTOP_PROFILE_ID,
|
|
22
|
+
serializeDesktopLibraryJson,
|
|
22
23
|
type Desktop3pConfigLibraryOptions,
|
|
23
24
|
type Desktop3pMetadata,
|
|
24
25
|
type Desktop3pMetadataEntry,
|
|
@@ -48,7 +49,7 @@ function metadataPath(libraryPath: string): string {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
function metadataJson(metadata: Desktop3pMetadata): string {
|
|
51
|
-
return
|
|
52
|
+
return serializeDesktopLibraryJson(metadata);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
function isValidMetadata(metadata: Desktop3pMetadata): boolean {
|
|
@@ -190,6 +191,10 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto
|
|
|
190
191
|
const changed = !alreadySelected || !oldProfile.exists || oldProfile.content !== target;
|
|
191
192
|
if (!changed) return { ok: true, changed: false, path: profile };
|
|
192
193
|
|
|
194
|
+
const entries = existing
|
|
195
|
+
? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry)
|
|
196
|
+
: [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }];
|
|
197
|
+
const nextMetadataJson = metadataJson({ ...metadata, appliedId: entryId, entries });
|
|
193
198
|
mkdirSync(join(configDir, PICKER_DIRECTORY), { recursive: true, mode: 0o700 });
|
|
194
199
|
try {
|
|
195
200
|
// Keep the same backup convention as the existing Desktop writer. It is removed with the
|
|
@@ -199,10 +204,7 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto
|
|
|
199
204
|
if (!alreadySelected) {
|
|
200
205
|
atomicWriteFile(previousStatePath, JSON.stringify({ entryId, previousAppliedId }) + "\n");
|
|
201
206
|
}
|
|
202
|
-
|
|
203
|
-
? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry)
|
|
204
|
-
: [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }];
|
|
205
|
-
atomicWriteFile(metaPath, metadataJson({ ...metadata, appliedId: entryId, entries }));
|
|
207
|
+
atomicWriteFile(metaPath, nextMetadataJson);
|
|
206
208
|
} catch {
|
|
207
209
|
try {
|
|
208
210
|
restoreFile(metaPath, oldMeta);
|
|
@@ -246,6 +248,8 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
|
|
|
246
248
|
try { state = readPickerState(statePath); } catch { return { ok: false, reason: "profile_state_unreadable" }; }
|
|
247
249
|
|
|
248
250
|
let metadataAfterPivot = metadata;
|
|
251
|
+
let standardPath: string | undefined;
|
|
252
|
+
let pivotMetadataJson: string | undefined;
|
|
249
253
|
if (selected) {
|
|
250
254
|
const previous = state?.entryId === picker.id ? state.previousAppliedId : null;
|
|
251
255
|
const previousExists = previous !== null && previous !== picker.id && metadata.entries.some(entry => entry.id === previous);
|
|
@@ -253,16 +257,22 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
|
|
|
253
257
|
metadataAfterPivot = { ...metadata, appliedId: previous };
|
|
254
258
|
} else {
|
|
255
259
|
const standardId = randomUUID();
|
|
256
|
-
|
|
260
|
+
standardPath = profilePath(libraryPath, standardId);
|
|
257
261
|
metadataAfterPivot = {
|
|
258
262
|
...metadata,
|
|
259
263
|
appliedId: standardId,
|
|
260
264
|
entries: [...metadata.entries, { id: standardId, name: "opencodex-standard" }],
|
|
261
265
|
};
|
|
262
266
|
}
|
|
263
|
-
|
|
267
|
+
pivotMetadataJson = metadataJson(metadataAfterPivot);
|
|
264
268
|
}
|
|
265
269
|
|
|
270
|
+
const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id);
|
|
271
|
+
const finalMetadataJson = metadataJson({ ...metadataAfterPivot, entries });
|
|
272
|
+
|
|
273
|
+
if (standardPath !== undefined) atomicWriteFile(standardPath, "{}\n");
|
|
274
|
+
if (pivotMetadataJson !== undefined) atomicWriteFile(metaPath, pivotMetadataJson);
|
|
275
|
+
|
|
266
276
|
const residualPaths: string[] = [];
|
|
267
277
|
for (const path of [profilePath(libraryPath, picker.id), `${profilePath(libraryPath, picker.id)}.bak`]) {
|
|
268
278
|
try { unlinkIfPresent(path); } catch { /* report the path without exposing file contents */ }
|
|
@@ -272,9 +282,8 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
|
|
|
272
282
|
return { ok: false, reason: "cleanup_incomplete", residualPaths };
|
|
273
283
|
}
|
|
274
284
|
|
|
275
|
-
const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id);
|
|
276
285
|
try {
|
|
277
|
-
atomicWriteFile(metaPath,
|
|
286
|
+
atomicWriteFile(metaPath, finalMetadataJson);
|
|
278
287
|
try { unlinkIfPresent(statePath); } catch { /* residual is reported below */ }
|
|
279
288
|
if (existsSync(statePath)) return { ok: false, reason: "cleanup_incomplete", residualPaths: [statePath] };
|
|
280
289
|
} catch {
|
|
@@ -2,7 +2,7 @@ import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:
|
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
4
4
|
import { hardenSecretDir } from "../lib/windows-secret-acl";
|
|
5
|
-
import { isRecord, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
|
|
5
|
+
import { isRecord, MAX_DESKTOP_PROFILE_FILE_BYTES, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
|
|
6
6
|
import { DesktopStoreError, digest } from "./desktop-remote-store-state";
|
|
7
7
|
|
|
8
8
|
export const MAX_DESKTOP_METADATA_ENTRIES = 256;
|
|
@@ -45,7 +45,7 @@ export class StoreIO {
|
|
|
45
45
|
try {
|
|
46
46
|
// The shared verified regular-file read keeps a swapped FIFO from parking this
|
|
47
47
|
// open before the post-read identity recheck below can run.
|
|
48
|
-
const bytes = readDesktopLibraryBytes(path);
|
|
48
|
+
const bytes = readDesktopLibraryBytes(path, Math.min(maxBytes, MAX_DESKTOP_PROFILE_FILE_BYTES));
|
|
49
49
|
if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict");
|
|
50
50
|
const value: unknown = JSON.parse(bytes.toString("utf8"));
|
|
51
51
|
if (!isRecord(value)) throw new DesktopStoreError("unsafe");
|
|
@@ -241,12 +241,12 @@ function consentedDesktopBundle(home: string, installId: string, run: Execute):
|
|
|
241
241
|
const plistPath = realpathSync(join(home, "Library", "LaunchAgents", `${DESKTOP_PLIST_LABEL}.plist`));
|
|
242
242
|
const registration = JSON.parse(
|
|
243
243
|
run("/usr/bin/plutil", ["-convert", "json", "-o", "-", plistPath], 3_000),
|
|
244
|
-
) as { Label?: unknown; ProgramArguments?: unknown; Program?: unknown };
|
|
244
|
+
) as { Label?: unknown; RunAtLoad?: unknown; ProgramArguments?: unknown; Program?: unknown };
|
|
245
245
|
const args = registration.ProgramArguments;
|
|
246
246
|
// The shapes auto-launch writes: [program] or [program, "--autostart"]; see the migration
|
|
247
247
|
// rules in desktop/src-tauri/src/login_registration.rs, which classify anything else Leave.
|
|
248
248
|
// This is a macOS plist path, regardless of the host running an injected launch probe.
|
|
249
|
-
if (registration.Label !== DESKTOP_PLIST_LABEL || !Array.isArray(args)
|
|
249
|
+
if (registration.Label !== DESKTOP_PLIST_LABEL || registration.RunAtLoad !== true || !Array.isArray(args)
|
|
250
250
|
|| (args.length !== 1 && args.length !== 2) || typeof args[0] !== "string" || !posix.isAbsolute(args[0])
|
|
251
251
|
|| (args.length === 2 && args[1] !== "--autostart")
|
|
252
252
|
|| (registration.Program !== undefined && registration.Program !== args[0])) return null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { observeSignedInCodexIdentity } from "./signed-in-identity";
|
|
2
|
+
import { observeMainCodexCollisionIdentity } from "./collision-identity";
|
|
2
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
4
|
import {
|
|
4
5
|
deleteConfigTopLevelKey,
|
|
@@ -9,11 +10,12 @@ import {
|
|
|
9
10
|
import { captureConfigTopLevelRollback } from "../config/rebase-provenance";
|
|
10
11
|
import { removeCodexAccountCredential } from "./account-store";
|
|
11
12
|
import { clearAccountNeedsReauth } from "./account-runtime-state";
|
|
12
|
-
import {
|
|
13
|
+
import { readMainAuth } from "./auth-collision";
|
|
13
14
|
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
14
15
|
import { clearAccountQuota } from "./quota";
|
|
15
16
|
import { forgetManualPreference } from "./routing/active-account";
|
|
16
17
|
import { deleteAllHealthForAccount } from "./routing/health-store";
|
|
18
|
+
import { forgetCodexFailureWindow } from "./routing/failure-window";
|
|
17
19
|
import { clearThreadAccountMapForAccount } from "./routing/thread-affinity";
|
|
18
20
|
|
|
19
21
|
import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff";
|
|
@@ -49,11 +51,15 @@ export function purgeCodexAccountRuntimeState(accountId: string): void {
|
|
|
49
51
|
clearAccountNeedsReauth(accountId);
|
|
50
52
|
clearAccountQuota(accountId);
|
|
51
53
|
clearThreadAccountMapForAccount(accountId);
|
|
52
|
-
// Deliberately not via `./routing`: the account-delete path only needs the health rows
|
|
53
|
-
//
|
|
54
|
-
// routing/health-store and routing/active-account.
|
|
54
|
+
// Deliberately not via `./routing`: the account-delete path only needs the health rows
|
|
55
|
+
// and their sibling routing state gone, and importing the router barrel here closed a
|
|
56
|
+
// cycle through routing/health-store and routing/active-account. The leaf imports below
|
|
57
|
+
// carry every per-account forget that barrel helper used to perform — including the
|
|
58
|
+
// failure window, whose degrade and pin-warning state must not leak into a replacement
|
|
59
|
+
// account or a new physical login behind __main__.
|
|
55
60
|
deleteAllHealthForAccount(accountId);
|
|
56
61
|
forgetManualPreference(accountId);
|
|
62
|
+
forgetCodexFailureWindow(accountId);
|
|
57
63
|
clearCodexPoolRefreshFailure(accountId);
|
|
58
64
|
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
59
65
|
clearMainAccountInfoCache();
|
|
@@ -74,7 +80,8 @@ function purgeMainCodexAccountRuntimeState(): void {
|
|
|
74
80
|
* cooldown, quota, reauth, and thread affinity do not leak across accounts.
|
|
75
81
|
*/
|
|
76
82
|
export function reconcileMainCodexAccountRuntimeState(): boolean {
|
|
77
|
-
const read =
|
|
83
|
+
const read = readMainAuth();
|
|
84
|
+
if (read.status === "missing" || read.status === "api-key-only") observeMainCodexCollisionIdentity();
|
|
78
85
|
const currentAccountId = read.status === "ok"
|
|
79
86
|
? extractAccountId(read.tokens.id_token, read.tokens.access_token) ?? (read.tokens.account_id || null)
|
|
80
87
|
: null;
|
|
@@ -86,11 +93,13 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
|
|
|
86
93
|
observedMainChatgptAccountId = currentAccountId;
|
|
87
94
|
if (previousAccountId === undefined || previousAccountId === currentAccountId) {
|
|
88
95
|
observeMainQuotaIdentity(currentAccountId);
|
|
96
|
+
if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
|
|
89
97
|
return false;
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
purgeMainCodexAccountRuntimeState();
|
|
93
101
|
observeMainQuotaIdentity(currentAccountId);
|
|
102
|
+
if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
|
|
94
103
|
return true;
|
|
95
104
|
}
|
|
96
105
|
|
|
@@ -103,7 +112,8 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
|
|
|
103
112
|
export function initializeMainAccountPolicyBinding(authPath: string): boolean {
|
|
104
113
|
// Startup observes the pinned owned path inside the exclusive claim: bound the read so a
|
|
105
114
|
// replaced non-regular or oversized file cannot stall startup inside that claim.
|
|
106
|
-
const result =
|
|
115
|
+
const result = readMainAuth(authPath);
|
|
116
|
+
if (result.status === "missing" || result.status === "api-key-only") observeMainCodexCollisionIdentity();
|
|
107
117
|
if (result.status !== "ok") return false;
|
|
108
118
|
const { tokens } = result;
|
|
109
119
|
if (typeof tokens.access_token !== "string" || !tokens.access_token
|
|
@@ -124,6 +134,7 @@ export function initializeMainAccountPolicyBinding(authPath: string): boolean {
|
|
|
124
134
|
if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState();
|
|
125
135
|
observeSignedInCodexIdentity(tokens);
|
|
126
136
|
observeMainQuotaIdentity(accountId);
|
|
137
|
+
observeMainCodexCollisionIdentity(tokens);
|
|
127
138
|
return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined;
|
|
128
139
|
}
|
|
129
140
|
|
|
@@ -136,6 +147,7 @@ export function applyConfirmedMainCodexAccountTransition(
|
|
|
136
147
|
toAccountId: string,
|
|
137
148
|
): boolean {
|
|
138
149
|
observeSignedInCodexIdentity();
|
|
150
|
+
observeMainCodexCollisionIdentity();
|
|
139
151
|
if (!fromAccountId || !toAccountId || fromAccountId === toAccountId) {
|
|
140
152
|
if (toAccountId) {
|
|
141
153
|
observedMainChatgptAccountId = toAccountId;
|
|
@@ -150,6 +162,7 @@ export function applyConfirmedMainCodexAccountTransition(
|
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
export function resetMainCodexAccountIdentityTrackingForTests(): void {
|
|
165
|
+
observeMainCodexCollisionIdentity();
|
|
153
166
|
observedMainChatgptAccountId = undefined;
|
|
154
167
|
observeSignedInCodexIdentity();
|
|
155
168
|
clearMainAccountCredentialPresence();
|
|
@@ -136,25 +136,37 @@ export function peekCodexAccountRecordStore(): Record<string, CodexAccountCreden
|
|
|
136
136
|
|
|
137
137
|
/** Read the Codex account record store from disk with hardened permissions, and hand it to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
|
|
138
138
|
function loadCodexAccountRecordStore(): CodexAccountStore {
|
|
139
|
+
const result = loadCodexAccountRecordStoreResult();
|
|
140
|
+
return result.status === "ok" ? result.records : {};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type CodexAccountRecordStoreResult = { status: "ok"; records: CodexAccountStore } | { status: "failed" };
|
|
144
|
+
|
|
145
|
+
/** Retain read/parse failure separately for consumers that must preserve existing evidence. */
|
|
146
|
+
function loadCodexAccountRecordStoreResult(): CodexAccountRecordStoreResult {
|
|
139
147
|
const path = codexAccountsPath();
|
|
140
148
|
hardenConfigDir();
|
|
141
149
|
hardenExistingSecret(path);
|
|
142
|
-
if (!existsSync(path)) { notifyCodexStoreLoaded({}); return {}; }
|
|
143
150
|
const normalized: CodexAccountStore = {};
|
|
144
151
|
try {
|
|
145
152
|
const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore;
|
|
153
|
+
if (!isObject(raw)) throw new Error("invalid Codex account store shape");
|
|
146
154
|
for (const [id, value] of Object.entries(raw)) {
|
|
147
155
|
const record = normalizeRecord(value);
|
|
148
156
|
if (record) normalized[id] = record;
|
|
149
157
|
}
|
|
150
|
-
} catch {
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
160
|
+
notifyCodexStoreLoaded({});
|
|
161
|
+
return { status: "ok", records: {} };
|
|
162
|
+
}
|
|
151
163
|
backupInvalidConfig(path);
|
|
152
164
|
notifyCodexStoreLoaded({});
|
|
153
|
-
return {};
|
|
165
|
+
return { status: "failed" };
|
|
154
166
|
}
|
|
155
167
|
// Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path.
|
|
156
168
|
notifyCodexStoreLoaded(normalized);
|
|
157
|
-
return normalized;
|
|
169
|
+
return { status: "ok", records: normalized };
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
/** Atomically write the Codex account store, then hand it to the opt-in decision-quota slot so active advisory evidence tracks the persisted credentials. */
|
|
@@ -400,6 +412,10 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
|
|
|
400
412
|
return !!record?.credential && record.deletedAt == null && record.generation === generation;
|
|
401
413
|
}
|
|
402
414
|
|
|
415
|
+
/** Callable for existing liveness consumers; stateful consumers can distinguish unknown snapshots. */
|
|
416
|
+
export type CodexAccountGenerationLiveCheck = ((id: string, generation: number) => boolean)
|
|
417
|
+
& { readonly snapshotStatus: "ok" | "failed" };
|
|
418
|
+
|
|
403
419
|
/**
|
|
404
420
|
* The same verdict as {@link isCodexAccountGenerationLive}, over ONE store load.
|
|
405
421
|
*
|
|
@@ -409,12 +425,15 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
|
|
|
409
425
|
* several accounts in one synchronous pass on the request path, so it opens one checker and
|
|
410
426
|
* closes over the snapshot instead.
|
|
411
427
|
*/
|
|
412
|
-
export function beginCodexAccountGenerationLiveCheck():
|
|
413
|
-
|
|
414
|
-
|
|
428
|
+
export function beginCodexAccountGenerationLiveCheck(): CodexAccountGenerationLiveCheck {
|
|
429
|
+
let result: CodexAccountRecordStoreResult;
|
|
430
|
+
try { result = loadCodexAccountRecordStoreResult(); }
|
|
431
|
+
catch { result = { status: "failed" }; }
|
|
432
|
+
const snapshot = result.status === "ok" ? result.records : {};
|
|
433
|
+
return Object.assign((id: string, generation: number) => {
|
|
415
434
|
const record = snapshot[id];
|
|
416
435
|
return !!record?.credential && record.deletedAt == null && record.generation === generation;
|
|
417
|
-
};
|
|
436
|
+
}, { snapshotStatus: result.status });
|
|
418
437
|
}
|
|
419
438
|
|
|
420
439
|
export function saveCodexAccountCredentialIfGeneration(
|
|
@@ -9,6 +9,7 @@ import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./a
|
|
|
9
9
|
import type { OcxConfig } from "../types";
|
|
10
10
|
import { isNativeMainTrafficBlocked } from "./native-profile-startup";
|
|
11
11
|
import { isMainAccountHardLocked } from "./main-account-hard-lock";
|
|
12
|
+
import { configuredCodexMainDuplicate } from "./collision-identity";
|
|
12
13
|
|
|
13
14
|
export interface CodexAccountUsabilityOptions {
|
|
14
15
|
/** Route using cached runtime state only; the caller must reject selected main before auth. */
|
|
@@ -64,7 +65,8 @@ export type CodexAccountUnusableReason =
|
|
|
64
65
|
| "not_in_pool"
|
|
65
66
|
| "missing_credential"
|
|
66
67
|
| "deleted"
|
|
67
|
-
| "validation_pending"
|
|
68
|
+
| "validation_pending"
|
|
69
|
+
| "duplicate_main";
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
72
|
* The single source of truth for both selection and its explanation. `isCodexAccountUsable()` is
|
|
@@ -110,6 +112,7 @@ export function codexAccountUnusableReason(
|
|
|
110
112
|
if (!record?.credential) return "missing_credential";
|
|
111
113
|
if (record.deletedAt != null) return "deleted";
|
|
112
114
|
if (record.codexValidationPending) return "validation_pending";
|
|
115
|
+
if (configuredCodexMainDuplicate(config, accountId)) return "duplicate_main";
|
|
113
116
|
return undefined;
|
|
114
117
|
}
|
|
115
118
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { getMainAccountExternalUsageWarning, type MainAccountExternalUsageWarning } from "../main-account-external-usage";
|
|
2
2
|
import { codexCreditsDtoField, hasCodexCreditsObservation, pruneCodexCredits } from "../credits";
|
|
3
3
|
import type { CodexCredits } from "../credits";
|
|
4
|
-
import { getMainChatgptAccountId } from "../auth-collision";
|
|
5
4
|
import { codexAccountLogLabel } from "../account-label";
|
|
6
5
|
import { poolQuotaHistoryIdentity, getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store";
|
|
7
6
|
import { getAccountQuota, isCodexQuotaExhausted, setAccountQuotaFromParsed, withoutRetiredCodexQuota } from "../quota";
|
|
@@ -35,6 +34,7 @@ import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONC
|
|
|
35
34
|
import type { PoolQuotaResult } from "./pool-quota-probe";
|
|
36
35
|
import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
|
|
37
36
|
import { isPassiveRead } from "../../lib/passive-read";
|
|
37
|
+
import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate, getObservedMainCodexCollisionIdentity } from "../collision-identity";
|
|
38
38
|
|
|
39
39
|
export function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
|
|
40
40
|
quota: T,
|
|
@@ -133,6 +133,7 @@ export function poolAccountDto(
|
|
|
133
133
|
// health projection. Emitting only the boolean is what left #4212's reporter guessing which
|
|
134
134
|
// account took their model away and why, so name the cause they actually have to act on.
|
|
135
135
|
const reauthReason: CodexAccountReauthReason | undefined = rawReauthReason ?? (health.status === "reauth_required" ? health.reason : undefined);
|
|
136
|
+
const duplicateMain = configuredCodexMainDuplicate(config, account.id);
|
|
136
137
|
return {
|
|
137
138
|
id: account.id,
|
|
138
139
|
email: projectEmail(account.email, maskEmails) ?? account.email,
|
|
@@ -150,7 +151,10 @@ export function poolAccountDto(
|
|
|
150
151
|
...codexCreditsDtoField(config, account.id, poolQuotaHistoryIdentity(account.id) ?? null),
|
|
151
152
|
needsReauth: needsReauth || health.status === "reauth_required",
|
|
152
153
|
...(reauthReason !== undefined ? { reauthReason } : {}),
|
|
153
|
-
...(
|
|
154
|
+
...(duplicateMain ? {
|
|
155
|
+
selectionExcludedReason: "duplicate_main" as const,
|
|
156
|
+
selectionExcludedAction: CODEX_MAIN_DUPLICATE_ACTION,
|
|
157
|
+
} : isCodexAccountPlanExcluded(config, account.id) ? {
|
|
154
158
|
selectionExcludedReason: "plan_excluded" as const,
|
|
155
159
|
selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
|
|
156
160
|
} : {}),
|
|
@@ -184,8 +188,9 @@ export interface CodexAuthAccountDto {
|
|
|
184
188
|
* needs the operator; `/api/oauth/accounts` already carries the same field name.
|
|
185
189
|
*/
|
|
186
190
|
reauthReason?: CodexAccountReauthReason;
|
|
187
|
-
/**
|
|
188
|
-
selectionExcludedReason?: "plan_excluded";
|
|
191
|
+
/** Plan policy is automatic-only; duplicate-main exclusions also apply to explicit routes. */
|
|
192
|
+
selectionExcludedReason?: "plan_excluded" | "duplicate_main";
|
|
193
|
+
selectionExcludedAction?: string;
|
|
189
194
|
selectionExcludedPlan?: string;
|
|
190
195
|
hasCredential: boolean;
|
|
191
196
|
health: OAuthAccountHealth;
|
|
@@ -418,7 +423,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
418
423
|
quota: mainInfo.quota
|
|
419
424
|
? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
|
|
420
425
|
: null,
|
|
421
|
-
...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID,
|
|
426
|
+
...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getObservedMainCodexCollisionIdentity()?.accountId ?? null),
|
|
422
427
|
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
423
428
|
};
|
|
424
429
|
return {
|
|
@@ -1,35 +1,15 @@
|
|
|
1
1
|
import { observeSignedInCodexIdentity } from "../signed-in-identity";
|
|
2
2
|
import { lstatSync } from "node:fs";
|
|
3
3
|
import type { OcxConfig } from "../../types";
|
|
4
|
-
import {
|
|
5
|
-
import type { CodexTokenReadResult } from "../auth-collision";
|
|
4
|
+
import { readMainAuth } from "../auth-collision";
|
|
6
5
|
import { linkedCodexAccountIds } from "../account-identity";
|
|
7
6
|
import { resolveCodexHomeDir } from "../home";
|
|
8
7
|
import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
|
|
9
8
|
import { withNativeMainSharedClaim } from "../native-main-claim";
|
|
10
|
-
import {
|
|
9
|
+
import { resolveNativeProfileContext } from "../native-profile-store";
|
|
11
10
|
import { NativeProfileError } from "../native-profile-types";
|
|
12
11
|
import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http";
|
|
13
12
|
|
|
14
|
-
type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
|
|
15
|
-
|
|
16
|
-
/** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
|
|
17
|
-
function readMainAuth(authPath: string): MainAuth {
|
|
18
|
-
const main = readCodexTokensResult(authPath, { bounded: true });
|
|
19
|
-
if (main.status !== "invalid") return main;
|
|
20
|
-
try {
|
|
21
|
-
const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
|
|
22
|
-
if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
|
|
23
|
-
const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
|
|
24
|
-
const apiKeyOnly = envelope.tokens == null
|
|
25
|
-
&& (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
|
|
26
|
-
&& typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
|
|
27
|
-
return apiKeyOnly ? { status: "api-key-only" } : main;
|
|
28
|
-
} catch {
|
|
29
|
-
return main;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
13
|
/** Hold admission throughout; claim the physical main whenever its home exists. */
|
|
34
14
|
export async function withCodexAccountPauseGroup(
|
|
35
15
|
config: OcxConfig,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { extractAccountId, extractAccountIdClaims, extractEmail } from "../../oauth/chatgpt";
|
|
3
|
+
import { checkAccountIdCollision, readMainAuth } from "../auth-collision";
|
|
4
|
+
import { resolveCodexHomeDir } from "../home";
|
|
5
|
+
import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
|
|
6
|
+
import { withNativeMainSharedClaim } from "../native-main-claim";
|
|
7
|
+
import { resolveNativeProfileContext } from "../native-profile-store";
|
|
8
|
+
import { NativeProfileError } from "../native-profile-types";
|
|
9
|
+
import { extractChatgptPlanType } from "../plan";
|
|
10
|
+
import { isNativeMainClaimUnavailable } from "./http";
|
|
11
|
+
|
|
12
|
+
export class CodexCollisionReadBusyError extends Error {
|
|
13
|
+
constructor() { super("Native Codex login is busy or unreadable; retry login shortly."); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Only admission owns physical reads; synchronous collision consumers use observed identity. */
|
|
17
|
+
export async function checkAccountIdCollisionWithNativeMain(
|
|
18
|
+
accountId: string, email?: string | null, plan?: unknown, excludeAccountId?: string | null,
|
|
19
|
+
): Promise<ReturnType<typeof checkAccountIdCollision>> {
|
|
20
|
+
const lease = tryAcquireNativeMainProfileClaim();
|
|
21
|
+
if (!lease) throw new CodexCollisionReadBusyError();
|
|
22
|
+
try {
|
|
23
|
+
const codexHome = resolveCodexHomeDir();
|
|
24
|
+
try { lstatSync(codexHome); } catch (error) {
|
|
25
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
26
|
+
return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
|
|
27
|
+
}
|
|
28
|
+
throw new CodexCollisionReadBusyError();
|
|
29
|
+
}
|
|
30
|
+
const context = resolveNativeProfileContext({ codexHome });
|
|
31
|
+
return await withNativeMainSharedClaim(context, async () => {
|
|
32
|
+
const main = readMainAuth(context.authPath);
|
|
33
|
+
if (main.status === "missing" || main.status === "api-key-only") {
|
|
34
|
+
return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
|
|
35
|
+
}
|
|
36
|
+
if (main.status !== "ok") throw new CodexCollisionReadBusyError();
|
|
37
|
+
const tokens = main.tokens;
|
|
38
|
+
const nativeId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id;
|
|
39
|
+
const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)];
|
|
40
|
+
const emails = [extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)].filter(Boolean);
|
|
41
|
+
if (!nativeId || typeof tokens.account_id !== "string" || typeof tokens.access_token !== "string"
|
|
42
|
+
|| (tokens.id_token !== undefined && typeof tokens.id_token !== "string")
|
|
43
|
+
|| claims.some(claim => claim.conflict || (claim.accountId && claim.accountId !== nativeId))
|
|
44
|
+
|| (tokens.account_id && tokens.account_id !== nativeId) || new Set(emails).size > 1) {
|
|
45
|
+
throw new CodexCollisionReadBusyError();
|
|
46
|
+
}
|
|
47
|
+
return checkAccountIdCollision(accountId, email, plan, excludeAccountId, {
|
|
48
|
+
accountId: nativeId, email: extractEmail(tokens.id_token, tokens.access_token),
|
|
49
|
+
plan: extractChatgptPlanType(tokens.id_token, tokens.access_token),
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (isNativeMainClaimUnavailable(error)
|
|
54
|
+
|| (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) {
|
|
55
|
+
throw new CodexCollisionReadBusyError();
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
} finally { lease.release(); }
|
|
59
|
+
}
|
|
@@ -5,7 +5,8 @@ import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
|
|
|
5
5
|
import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config";
|
|
6
6
|
import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces";
|
|
7
7
|
import { catalogRefreshIsPending, normalizeCatalogDisposition } from "../catalog-refresh-status";
|
|
8
|
-
import { checkAccountIdCollision } from "
|
|
8
|
+
import { checkAccountIdCollisionWithNativeMain as checkAccountIdCollision, CodexCollisionReadBusyError } from "./login-collision";
|
|
9
|
+
import { extractChatgptPlanType } from "../plan";
|
|
9
10
|
import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
|
|
10
11
|
import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff";
|
|
11
12
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
@@ -262,8 +263,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
262
263
|
break;
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
let email = cred.email ||
|
|
266
|
-
let plan
|
|
266
|
+
let email = cred.email?.trim() || "";
|
|
267
|
+
let plan = extractChatgptPlanType(undefined, cred.access);
|
|
267
268
|
let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
268
269
|
let policyQuota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
269
270
|
try {
|
|
@@ -274,8 +275,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
274
275
|
});
|
|
275
276
|
if (resp.ok) {
|
|
276
277
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
277
|
-
email = data.email
|
|
278
|
-
plan = nonEmptyPlan(data.plan_type) ??
|
|
278
|
+
email = data.email?.trim() || email;
|
|
279
|
+
plan = nonEmptyPlan(data.plan_type) ?? plan;
|
|
279
280
|
quota = parseUsageQuota(data);
|
|
280
281
|
policyQuota = isValidWhamHistoryObservation(data) ? quota : null;
|
|
281
282
|
}
|
|
@@ -322,7 +323,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
322
323
|
}
|
|
323
324
|
|
|
324
325
|
// 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
325
|
-
const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
326
|
+
const collision = await checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
326
327
|
if (collision.collision) {
|
|
327
328
|
setCodexLoginState(flowId, {
|
|
328
329
|
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
@@ -331,6 +332,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
331
332
|
break;
|
|
332
333
|
}
|
|
333
334
|
|
|
335
|
+
email ||= accountId;
|
|
336
|
+
|
|
334
337
|
// A successful authenticated WHAM read can prove quota is exhausted without
|
|
335
338
|
// spending an inference request. Store the account, but defer inference validation
|
|
336
339
|
// and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
|
|
@@ -470,7 +473,9 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
470
473
|
});
|
|
471
474
|
}
|
|
472
475
|
} catch (error) {
|
|
473
|
-
const message = error instanceof
|
|
476
|
+
const message = error instanceof CodexCollisionReadBusyError
|
|
477
|
+
? "Native Codex login is busy or unreadable; retry login shortly."
|
|
478
|
+
: error instanceof ConfigMutationLockError
|
|
474
479
|
|| error instanceof CodexCredentialRefreshLockTimeoutError
|
|
475
480
|
? "Configuration is busy; retry login shortly."
|
|
476
481
|
: error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
|