@bitkyc08/opencodex 2.58.0 → 2.59.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 +28 -10
- package/gui/dist/assets/index-C5IebErG.js +136 -0
- package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +1 -1
- package/src/adapters/base.ts +11 -1
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +14 -3
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor.ts +42 -1
- package/src/adapters/devin/cloud-direct/chat.ts +11 -2
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
- package/src/adapters/devin.ts +75 -13
- package/src/adapters/google-antigravity-wire.ts +29 -2
- package/src/adapters/google-http.ts +8 -1
- package/src/adapters/google.ts +23 -4
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat.ts +5 -10
- package/src/adapters/openai-responses/passthrough.ts +10 -1
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +165 -24
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +22 -2
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +54 -0
- package/src/cli/doctor.ts +197 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +88 -20
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +4 -3
- package/src/client/machine-listener.ts +14 -1
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +81 -5
- package/src/codex/auth-api/pool-quota-probe.ts +14 -3
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +1 -0
- package/src/codex/catalog/model-hints.ts +37 -5
- package/src/codex/catalog/parsing.ts +83 -5
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +19 -0
- package/src/codex/catalog/routed-gather.ts +42 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/convergence.ts +5 -0
- package/src/codex/history-provider.ts +4 -1
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-entitlements.ts +60 -1
- package/src/codex/observed-model-denials.ts +137 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +282 -12
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +13 -15
- package/src/codex/subagent-model-fallback.ts +94 -0
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +122 -5
- package/src/config/diagnostics.ts +21 -0
- package/src/config/load-degrade.ts +15 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +23 -5
- package/src/config/schema/config-schema.ts +21 -0
- package/src/config/schema/leaf-validators.ts +64 -17
- package/src/generated/compatibility-version.json +235 -163
- package/src/generated/model-metadata.ts +1 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +3 -15
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/provider-outbound.ts +3 -3
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +631 -0
- package/src/lib/spend-reservation-ledger.ts +115 -9
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/generic-account-failover.ts +40 -27
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +261 -7
- package/src/providers/model-rename-migration.ts +1 -0
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/registry/entries-extended.ts +96 -0
- package/src/providers/registry/model-seeds.ts +78 -21
- package/src/responses/apply-patch-envelope.ts +44 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/schema.ts +9 -2
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +24 -2
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index.ts +15 -19
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/config-routes.ts +31 -6
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/relay.ts +17 -24
- package/src/server/request-log.ts +25 -1
- package/src/server/responses/adapter-continuation.ts +71 -27
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +52 -24
- package/src/server/responses/compact.ts +60 -11
- package/src/server/responses/core-codex-account.ts +83 -22
- package/src/server/responses/core-normalize.ts +12 -5
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +10 -1
- package/src/server/responses/passthrough-dispatch.ts +113 -48
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/request-prepare.ts +29 -0
- package/src/server/responses/request-send-budget.ts +84 -7
- package/src/server/responses/request-sidecar-auth.ts +16 -8
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +13 -10
- package/src/server/responses/run-turn-execution.ts +20 -5
- package/src/server/responses/sidecar-execution.ts +2 -0
- package/src/server/responses/ws-upstream.ts +2 -1
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +36 -9
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +100 -3
- package/src/types/provider.ts +19 -0
- package/src/types/request.ts +7 -1
- package/src/types/wire.ts +9 -1
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
package/src/codex/lineage.ts
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
*
|
|
15
15
|
* What it is for, and what it is not for:
|
|
16
16
|
*
|
|
17
|
-
* - FIRST PLACEMENT.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* - FIRST PLACEMENT. Largely subsumed by cohort keying (#4780): a member of a tree that any
|
|
18
|
+
* other member has already bound resolves to that same binding, so there is nothing to place.
|
|
19
|
+
* `pickLineageServingAccount` in ./routing remains for the one case cohort keying cannot
|
|
20
|
+
* unify -- a session-less chain whose parent is not recorded in this scope -- and is gated on
|
|
21
|
+
* the parent's key actually differing from this request's.
|
|
20
22
|
* - COST ATTRIBUTION. {@link codexThreadLineageLookup} and {@link codexLineageRootForRequest}
|
|
21
23
|
* answer which root workflow a conversation belongs to, so a grandchild's spend aggregates
|
|
22
24
|
* onto the root. No budget is implemented here.
|
|
@@ -155,30 +157,83 @@ export function codexConversationKeyFor(familyId: string, threadId: string): str
|
|
|
155
157
|
.digest("base64url")}`;
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The cohort anchor's key: the same string on both sides of the derivation.
|
|
162
|
+
*
|
|
163
|
+
* A cohort is identified by one value the whole tree shares, so the key is that value keyed
|
|
164
|
+
* against itself rather than against a member. Using {@link codexConversationKeyFor} keeps one
|
|
165
|
+
* derivation in the module, which is what guarantees a lineage record's `conversationKey` stays
|
|
166
|
+
* byte-identical to the key the thread actually binds under.
|
|
167
|
+
*/
|
|
168
|
+
function cohortKeyFromAnchor(sessionId: string | undefined, fallbackAnchor: string): string {
|
|
169
|
+
const anchor = sessionId ?? fallbackAnchor;
|
|
170
|
+
return codexConversationKeyFor(anchor, anchor);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The cohort this request belongs to, or undefined when it names no cohort at all.
|
|
175
|
+
*
|
|
176
|
+
* The session IS the cohort, which is exactly how upstream keys the prompt cache:
|
|
177
|
+
* `prompt_cache_key()` returns `responses_metadata.session_id` (or `{source}:{parent_thread_id}`
|
|
178
|
+
* for an internal session), and `AgentControl.session_id` is the root thread's id, shared with
|
|
179
|
+
* every sub-agent spawned from that root. Two requests carrying the same `prompt_cache_key` must
|
|
180
|
+
* not be served by different accounts, and keying on the session is what makes that structural
|
|
181
|
+
* rather than a hint (#4780).
|
|
182
|
+
*
|
|
183
|
+
* Without a session there is nothing in the headers that names the tree, so the cohort is
|
|
184
|
+
* whatever the parent is already bound to. That lookup is what keeps a chain of parent-only
|
|
185
|
+
* turns converging on one key: anchoring each depth on its own parent would split the cohort
|
|
186
|
+
* again at every hop. When the parent has not been seen in this scope the parent id anchors it,
|
|
187
|
+
* which is the same key that parent derives for itself.
|
|
188
|
+
*/
|
|
189
|
+
function cohortConversationKey(
|
|
190
|
+
headers: Headers,
|
|
191
|
+
sessionId: string | undefined,
|
|
192
|
+
parentThreadId: string | undefined,
|
|
193
|
+
now: number,
|
|
194
|
+
): string | undefined {
|
|
195
|
+
if (sessionId !== undefined) return cohortKeyFromAnchor(sessionId, sessionId);
|
|
196
|
+
if (parentThreadId === undefined) return undefined;
|
|
197
|
+
const recorded = liveLineageRecord(
|
|
198
|
+
lineageByScope.get(codexLineageScopeKey(headers)),
|
|
199
|
+
parentThreadId,
|
|
200
|
+
now,
|
|
201
|
+
);
|
|
202
|
+
return recorded?.conversationKey ?? cohortKeyFromAnchor(undefined, parentThreadId);
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
/**
|
|
159
206
|
* Resolve a request's conversation identity, or undefined when it carries no bindable thread
|
|
160
207
|
* identity at all.
|
|
161
208
|
*
|
|
162
|
-
* The set of requests that produce NO key is deliberately unchanged
|
|
163
|
-
* bare `thread-id` with neither a session nor a parent stays unbound, exactly as the
|
|
164
|
-
* fallback required both halves of its pair. Only the VALUE moves
|
|
165
|
-
*
|
|
209
|
+
* The set of requests that produce NO key is deliberately unchanged, through both #4546 and
|
|
210
|
+
* #4780: a bare `thread-id` with neither a session nor a parent stays unbound, exactly as the
|
|
211
|
+
* Desktop fallback required both halves of its pair. Only the VALUE moves.
|
|
212
|
+
*
|
|
213
|
+
* Every member of one tree resolves to the SAME key, because the cohort is the binding unit
|
|
214
|
+
* (#4780):
|
|
215
|
+
*
|
|
216
|
+
* - root (`session-id` + `thread-id`) -> the session's cohort key;
|
|
217
|
+
* - child and grandchild (parent + own `thread-id`) -> the same cohort key, since they carry the
|
|
218
|
+
* same session;
|
|
219
|
+
* - parent-only (no `thread-id`) -> the same cohort key, derived from the session or, without
|
|
220
|
+
* one, read from the parent's record.
|
|
166
221
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
222
|
+
* THIS IS NOT A REVERT OF #4546 wp8, and reading it as one would flip it straight back. wp8
|
|
223
|
+
* fixed a real incoherence: a child keyed under the RAW parent id, which is a different identity
|
|
224
|
+
* from the root's own `app:HMAC(session, thread)` binding, so siblings shared an entry unrelated
|
|
225
|
+
* to the root's and a grandchild keying on its own parent landed on a key nobody had ever bound.
|
|
226
|
+
* A cohort key cannot produce that, because the root's own binding IS the cohort key: there is
|
|
227
|
+
* one identity for the tree rather than two competing ones. What wp8 additionally gave each
|
|
228
|
+
* thread -- a binding of its own -- is what #4780 deliberately gives up, and the reason is that
|
|
229
|
+
* upstream never agreed to it: `prompt_cache_key` is keyed on the session the whole tree shares,
|
|
230
|
+
* so a proxy that splits the tree makes every split member assert a warm prefix that is cold on
|
|
231
|
+
* its account.
|
|
173
232
|
*
|
|
174
|
-
*
|
|
175
|
-
* conversation, so it
|
|
176
|
-
*
|
|
177
|
-
* and the
|
|
178
|
-
* exactly why deriving the key looks correct until a caller whose session differs from its thread
|
|
179
|
-
* starts a COLD conversation on every parent-only turn and overwrites the parent's record on the
|
|
180
|
-
* way through. So the recorded key wins, the session-derived key is the fallback that reproduces
|
|
181
|
-
* it when the parent has not been seen in this scope, and the raw parent id is never the answer.
|
|
233
|
+
* The parent-only case keeps the trap it always had. Such a turn belongs to the parent's
|
|
234
|
+
* conversation, so it must land on the binding the parent is already using. With a session in
|
|
235
|
+
* hand that is immediate, since both derive the same cohort key. Without one, the recorded key
|
|
236
|
+
* wins and the parent id anchors the fallback; the raw parent id is never the answer.
|
|
182
237
|
*/
|
|
183
238
|
export function codexConversationIdentity(
|
|
184
239
|
headers: Headers,
|
|
@@ -190,24 +245,20 @@ export function codexConversationIdentity(
|
|
|
190
245
|
|
|
191
246
|
if (threadId === undefined) {
|
|
192
247
|
if (parentThreadId === undefined) return undefined;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
parentThreadId,
|
|
196
|
-
now,
|
|
197
|
-
);
|
|
248
|
+
const parentOnlyKey = cohortConversationKey(headers, sessionId, parentThreadId, now);
|
|
249
|
+
if (parentOnlyKey === undefined) return undefined;
|
|
198
250
|
return {
|
|
199
|
-
conversationKey:
|
|
200
|
-
?? codexConversationKeyFor(sessionId ?? parentThreadId, parentThreadId),
|
|
251
|
+
conversationKey: parentOnlyKey,
|
|
201
252
|
recordThreadId: parentThreadId,
|
|
202
253
|
...(sessionId !== undefined ? { sessionId } : {}),
|
|
203
254
|
legacyConversationKey: parentThreadId,
|
|
204
255
|
declaresParent: false,
|
|
205
256
|
};
|
|
206
257
|
}
|
|
207
|
-
const
|
|
208
|
-
if (
|
|
258
|
+
const conversationKey = cohortConversationKey(headers, sessionId, parentThreadId, now);
|
|
259
|
+
if (conversationKey === undefined) return undefined;
|
|
209
260
|
return {
|
|
210
|
-
conversationKey
|
|
261
|
+
conversationKey,
|
|
211
262
|
recordThreadId: threadId,
|
|
212
263
|
...(sessionId !== undefined ? { sessionId } : {}),
|
|
213
264
|
...(parentThreadId !== undefined ? { parentThreadId } : {}),
|
|
@@ -313,7 +364,7 @@ function lineageFor(
|
|
|
313
364
|
const parentConversationKey = parentThreadId === undefined
|
|
314
365
|
? undefined
|
|
315
366
|
: parentRecord?.conversationKey
|
|
316
|
-
??
|
|
367
|
+
?? cohortKeyFromAnchor(identity.sessionId, parentThreadId);
|
|
317
368
|
const rootSessionKey = parentConversationKey === undefined
|
|
318
369
|
? identity.conversationKey
|
|
319
370
|
: parentRecord?.rootSessionKey ?? parentConversationKey;
|
|
@@ -5,6 +5,37 @@ import { NATIVE_RESERVE_MODEL } from "./catalog/native-models";
|
|
|
5
5
|
export const CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE =
|
|
6
6
|
"Luna Reserve compatibility is only available as a conversation model, not a vision helper. Choose another vision model.";
|
|
7
7
|
|
|
8
|
+
export const CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE =
|
|
9
|
+
"Luna Reserve (gpt-reserve) is not forwarded without the local Desktop authless opt-in."
|
|
10
|
+
+ " OpenCodex holds no Reserve entitlement to send for this request, so the upstream would answer with a"
|
|
11
|
+
+ " usage-limit error that names neither the cause nor the fix."
|
|
12
|
+
+ " Enable the opt-in with 'ocx system settings --desktop-authless on' (or set codexDesktopAuthless to true"
|
|
13
|
+
+ " in config.json), then retry. Choose another model to keep working without it.";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Strict complement of {@link isCodexReserveRequestEligible} for the FLAG reason ONLY (#4940).
|
|
17
|
+
*
|
|
18
|
+
* Read the two together. This answers a narrower question: Reserve was asked for, every ingress
|
|
19
|
+
* condition eligibility requires already holds, and the single missing piece is the operator
|
|
20
|
+
* opt-in. Flipping `codexDesktopAuthless` to true therefore always turns a true here into a true
|
|
21
|
+
* from {@link isCodexReserveRequestEligible}, which is what makes it honest for the refusal to
|
|
22
|
+
* name that one setting. The other two ineligibility reasons are deliberately not covered: a
|
|
23
|
+
* client role and a non-loopback admission source are different situations, and the correct
|
|
24
|
+
* answer for both is still to forward exactly as before.
|
|
25
|
+
*
|
|
26
|
+
* Callers classify the concrete destination as canonical forward before using this predicate, the
|
|
27
|
+
* same obligation {@link isCodexReserveHelperUnsupported} carries. An operator who has aliased or
|
|
28
|
+
* routed `gpt-reserve` onto some other provider owns a path that works, and it must keep working.
|
|
29
|
+
*/
|
|
30
|
+
export function isCodexReserveOptInMissing(
|
|
31
|
+
config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
|
|
32
|
+
modelId: string,
|
|
33
|
+
admission: Pick<DataPlaneAdmission, "source"> | undefined,
|
|
34
|
+
): boolean {
|
|
35
|
+
return modelId === NATIVE_RESERVE_MODEL && config.codexDesktopAuthless !== true
|
|
36
|
+
&& config.runtimeRole !== "client" && admission?.source === "loopback";
|
|
37
|
+
}
|
|
38
|
+
|
|
8
39
|
/** Callers classify the concrete destination as canonical forward before using this predicate. */
|
|
9
40
|
export function isCodexReserveHelperUnsupported(
|
|
10
41
|
config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { OcxConfig } from "../types";
|
|
2
2
|
import { getMainPolicyQuota } from "./quota";
|
|
3
|
+
import { MAIN_ACCOUNT_HARD_LOCK_PERCENT } from "./quota-types";
|
|
3
4
|
|
|
4
|
-
export
|
|
5
|
+
export { MAIN_ACCOUNT_HARD_LOCK_PERCENT };
|
|
5
6
|
|
|
6
7
|
export interface MainAccountHardLockStatus {
|
|
7
8
|
enabled: boolean;
|
|
@@ -296,7 +296,10 @@ function persistNativeMainReauthTokens(
|
|
|
296
296
|
* main account's reauth quarantine for the new credential generation.
|
|
297
297
|
*/
|
|
298
298
|
export function beginNativeMainReauth(): {
|
|
299
|
-
commit: (
|
|
299
|
+
commit: (
|
|
300
|
+
tokens: NativeMainReauthTokens,
|
|
301
|
+
options?: { signal?: AbortSignal },
|
|
302
|
+
) => Promise<{ chatgptAccountId: string }>;
|
|
300
303
|
} {
|
|
301
304
|
const expected = readMainAuthJsonCredential();
|
|
302
305
|
if (!expected || !expected.chatgptAccountId) {
|
|
@@ -305,7 +308,10 @@ export function beginNativeMainReauth(): {
|
|
|
305
308
|
);
|
|
306
309
|
}
|
|
307
310
|
return {
|
|
308
|
-
async commit(
|
|
311
|
+
async commit(
|
|
312
|
+
tokens: NativeMainReauthTokens,
|
|
313
|
+
options: { signal?: AbortSignal } = {},
|
|
314
|
+
): Promise<{ chatgptAccountId: string }> {
|
|
309
315
|
if (!tokens.accessToken || !tokens.refreshToken || !tokens.idToken) {
|
|
310
316
|
throw new NativeMainReauthUnavailableError("Device grant did not produce a complete token set");
|
|
311
317
|
}
|
|
@@ -322,11 +328,12 @@ export function beginNativeMainReauth(): {
|
|
|
322
328
|
"Native main traffic is blocked by startup or recovery state",
|
|
323
329
|
);
|
|
324
330
|
}
|
|
331
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
325
332
|
assertMainAuthJsonSnapshotUnchanged(expected);
|
|
326
333
|
persistNativeMainReauthTokens(expected, tokens);
|
|
327
334
|
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
328
335
|
return { chatgptAccountId: tokens.chatgptAccountId };
|
|
329
|
-
}, { waitMs: 30_000 });
|
|
336
|
+
}, { waitMs: 30_000, signal: options.signal });
|
|
330
337
|
},
|
|
331
338
|
};
|
|
332
339
|
}
|
|
@@ -54,7 +54,9 @@ interface ActiveFlow {
|
|
|
54
54
|
/** Set once auth.json has been replaced; cancellation can no longer win. */
|
|
55
55
|
published: boolean;
|
|
56
56
|
/** Snapshot-holding commit prepared at start; closure-private identity. */
|
|
57
|
-
prepared?: {
|
|
57
|
+
prepared?: {
|
|
58
|
+
commit: (tokens: NativeMainReauthTokens, options?: { signal?: AbortSignal }) => Promise<{ chatgptAccountId: string }>;
|
|
59
|
+
};
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
/** Bounded terminal retention so status/cancel stay answerable after completion. */
|
|
@@ -65,7 +67,9 @@ const terminalFlows = new Map<string, { status: MainDeviceReauthStatus; expiresA
|
|
|
65
67
|
|
|
66
68
|
export interface MainDeviceReauthDeps {
|
|
67
69
|
login?: (ctrl: OAuthController) => Promise<NativeDeviceLogin>;
|
|
68
|
-
beginCommit?: () => {
|
|
70
|
+
beginCommit?: () => {
|
|
71
|
+
commit: (tokens: NativeMainReauthTokens, options?: { signal?: AbortSignal }) => Promise<{ chatgptAccountId: string }>;
|
|
72
|
+
};
|
|
69
73
|
flowId?: () => string;
|
|
70
74
|
now?: () => number;
|
|
71
75
|
}
|
|
@@ -81,10 +85,13 @@ function sweepTerminal(now: number): void {
|
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
function finish(flow: ActiveFlow, status: MainDeviceReauthStatus, now: number): void {
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
+
// Terminal results are first-write-wins, with one exception (080): once the
|
|
89
|
+
// commit actually replaced auth.json, the honest terminal is succeeded. The
|
|
90
|
+
// signal now fences the claim wait and the pre-write recheck, but it cannot
|
|
91
|
+
// fence the gap between the synchronous write and this call — the claim
|
|
92
|
+
// teardown and the promise resolution both yield, so a cancel arriving there
|
|
93
|
+
// would otherwise report "cancelled" for a credential that was replaced and
|
|
94
|
+
// a reauth quarantine that was cleared.
|
|
88
95
|
if (isTerminal(flow.status)) {
|
|
89
96
|
if (!(flow.published && status.status === "succeeded")) return;
|
|
90
97
|
}
|
|
@@ -165,15 +172,16 @@ export function startMainDeviceReauth(deps: MainDeviceReauthDeps = {}): MainDevi
|
|
|
165
172
|
if (flow.controller.signal.aborted) return;
|
|
166
173
|
if (!isTerminal(flow.status)) flow.status = { flowId, status: "committing" };
|
|
167
174
|
// Recheck immediately before the write: a cancel that landed while the
|
|
168
|
-
// grant was resolving must not reach auth.json.
|
|
169
|
-
//
|
|
175
|
+
// grant was resolving must not reach auth.json. The commit itself is
|
|
176
|
+
// fenced by the same signal, so a cancel delivered while the claim
|
|
177
|
+
// waits aborts the publication instead of racing it.
|
|
170
178
|
if (activeFlow !== flow || flow.controller.signal.aborted || isTerminal(flow.status)) return;
|
|
171
179
|
await flow.prepared!.commit({
|
|
172
180
|
accessToken: grant.credential.access,
|
|
173
181
|
refreshToken: grant.credential.refresh,
|
|
174
182
|
idToken: grant.idToken,
|
|
175
183
|
chatgptAccountId: grant.credential.accountId!,
|
|
176
|
-
});
|
|
184
|
+
}, { signal: flow.controller.signal });
|
|
177
185
|
flow.published = true;
|
|
178
186
|
finish(flow, { flowId, status: "succeeded", credentialUpdated: true }, clock());
|
|
179
187
|
} catch (error) {
|
|
@@ -17,6 +17,13 @@ import { loadPersistedCodexRuntime } from "./runtime";
|
|
|
17
17
|
import { codexRuntimeStateEpoch } from "./runtime";
|
|
18
18
|
import upstreamModelsSnapshot from "./data/upstream-models.json";
|
|
19
19
|
import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
20
|
+
import {
|
|
21
|
+
clearObservedCodexModelDenial,
|
|
22
|
+
forgetObservedCodexModelDenialsForAccount,
|
|
23
|
+
observedDeniedCodexAccountIdsForModel,
|
|
24
|
+
recordObservedCodexModelDenial,
|
|
25
|
+
resetObservedCodexModelDenialsForTests,
|
|
26
|
+
} from "./observed-model-denials";
|
|
20
27
|
|
|
21
28
|
const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models";
|
|
22
29
|
|
|
@@ -870,6 +877,11 @@ function needsEntitlementRefresh(
|
|
|
870
877
|
const cached = accountModelsCache.get(cacheKeyFor(accountId, clientVersion));
|
|
871
878
|
if (cached && cached.credentialIdentity !== credentialIdentity) {
|
|
872
879
|
invalidateCodexModelEntitlementsForAccount(accountId);
|
|
880
|
+
// The credential itself changed, so evidence gathered under the previous one answers for a
|
|
881
|
+
// different subscription. This is the only call site that knows that: the two gated-model
|
|
882
|
+
// sites in `core-codex-account.ts` invalidate a STALE roster for an unchanged credential,
|
|
883
|
+
// and clearing observed refusals there would discard the very evidence #4906 is about.
|
|
884
|
+
forgetObservedCodexModelDenialsForAccount(accountId);
|
|
873
885
|
} else if (cached && cached.expiresAt > now) {
|
|
874
886
|
return false;
|
|
875
887
|
}
|
|
@@ -1280,17 +1292,63 @@ export function cachedDeniedCodexAccountIdsForModel(
|
|
|
1280
1292
|
if (state === "granted") granted.add(accountId);
|
|
1281
1293
|
else if (state === "denied") denied.add(accountId);
|
|
1282
1294
|
}
|
|
1295
|
+
// The roster is not the only evidence, and on this path it is usually the weaker one. A cached
|
|
1296
|
+
// roster expires in five minutes and nothing on the flagship request path refetches it, so
|
|
1297
|
+
// absent an ongoing catalog sync the loop above contributes nothing at all. An upstream
|
|
1298
|
+
// refusal does not expire on that schedule and is not a snapshot of a pending answer: it is
|
|
1299
|
+
// the account's own Codex surface naming this model and declining it (#4906).
|
|
1300
|
+
for (const accountId of observedDeniedCodexAccountIdsForModel(modelId, now) ?? []) {
|
|
1301
|
+
// Under the caller's read fence, like the roster loop above. Nothing here reads account
|
|
1302
|
+
// storage, but an excluded account must stay UNKNOWN rather than denied so a profile switch
|
|
1303
|
+
// or a request-owned credential produces the same selection it does today.
|
|
1304
|
+
if (options.excludeAccountIds?.has(accountId)) continue;
|
|
1305
|
+
denied.add(accountId);
|
|
1306
|
+
}
|
|
1283
1307
|
// One account holds one entry per client version, and upstream filters the roster by that
|
|
1284
1308
|
// version. So the same account can legitimately carry a granted entry under a current client
|
|
1285
1309
|
// and a denied one under an older client that predates the model. Positive evidence is
|
|
1286
1310
|
// authoritative regardless of which version asked for it -- the same rule
|
|
1287
1311
|
// `codexModelEntitlementStateForRoster` applies within a single entry -- so a grant anywhere
|
|
1288
1312
|
// clears the denial rather than being outvoted by whichever entry the map happened to yield
|
|
1289
|
-
// last.
|
|
1313
|
+
// last. It outranks an observed refusal for the same reason: a confirmed roster that lists the
|
|
1314
|
+
// model is the newer answer, and a rollout that reaches an account must not be held back by a
|
|
1315
|
+
// refusal it has already superseded.
|
|
1290
1316
|
for (const accountId of granted) denied.delete(accountId);
|
|
1291
1317
|
return denied.size > 0 ? denied : undefined;
|
|
1292
1318
|
}
|
|
1293
1319
|
|
|
1320
|
+
/**
|
|
1321
|
+
* Record an authenticated upstream refusal as this account's own evidence about `modelId`.
|
|
1322
|
+
*
|
|
1323
|
+
* Scoped to {@link ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS} because that is the set whose
|
|
1324
|
+
* availability varies per account while the row stays visible, and it is the set
|
|
1325
|
+
* {@link cachedDeniedCodexAccountIdsForModel} will read back. A model outside it either has no
|
|
1326
|
+
* per-account variance or is gated by the fail-closed roster path, where an ordering preference
|
|
1327
|
+
* would change nothing.
|
|
1328
|
+
*
|
|
1329
|
+
* The caller must have matched the exact allow-listed refusal body first. A status alone is not
|
|
1330
|
+
* admissible here: 400 covers every malformed request too, and remembering one of those as an
|
|
1331
|
+
* entitlement fact would steer routing away from a perfectly capable account.
|
|
1332
|
+
*/
|
|
1333
|
+
export function recordCodexModelDenialEvidence(
|
|
1334
|
+
accountId: string | null | undefined,
|
|
1335
|
+
modelId: string | undefined,
|
|
1336
|
+
now = Date.now(),
|
|
1337
|
+
): void {
|
|
1338
|
+
if (!accountId || !modelId) return;
|
|
1339
|
+
if (!ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return;
|
|
1340
|
+
recordObservedCodexModelDenial(accountId, modelId, now);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** Drop the refusal evidence for a pair the account has just served successfully. */
|
|
1344
|
+
export function clearCodexModelDenialEvidence(
|
|
1345
|
+
accountId: string | null | undefined,
|
|
1346
|
+
modelId: string | undefined,
|
|
1347
|
+
): void {
|
|
1348
|
+
if (!accountId || !modelId) return;
|
|
1349
|
+
clearObservedCodexModelDenial(accountId, modelId);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1294
1352
|
/** Synchronous projection for management/catalog readers after a discovery pass. */
|
|
1295
1353
|
export function cachedAvailableAccountGatedNativeModels(
|
|
1296
1354
|
now = Date.now(),
|
|
@@ -1351,6 +1409,7 @@ export function resetCodexModelEntitlementCacheForTests(): void {
|
|
|
1351
1409
|
negativeCredentialMemo.clear();
|
|
1352
1410
|
entitlementEnsureFlights.clear();
|
|
1353
1411
|
runtimeVersionMemo = null;
|
|
1412
|
+
resetObservedCodexModelDenialsForTests();
|
|
1354
1413
|
}
|
|
1355
1414
|
|
|
1356
1415
|
/** Test-only snapshot for proving publication fences, which cache lookup intentionally masks. */
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-account model denials observed from an authenticated upstream refusal.
|
|
3
|
+
*
|
|
4
|
+
* [Decision Log]
|
|
5
|
+
* - 목적과 의도: Keep the one piece of account-specific model evidence that is never stale --
|
|
6
|
+
* the upstream's own refusal -- instead of discarding it after a single retry.
|
|
7
|
+
* - 기존 구현 및 제약 조건: `cachedDeniedCodexAccountIdsForModel` reads authenticated `/models`
|
|
8
|
+
* rosters, and those entries expire five minutes after they are fetched
|
|
9
|
+
* (`MODEL_ROSTER_TTL_MS`). Nothing on the flagship request path refills them, because
|
|
10
|
+
* `resolveCodexModelEntitlements` is awaited only for `ACCOUNT_GATED_NATIVE_OPENAI_MODELS`,
|
|
11
|
+
* which no longer holds the flagships. So the denial set is usually absent, the #4797
|
|
12
|
+
* ordering rules become the identity function, and the pool picks on quota alone (#4906).
|
|
13
|
+
* - 검토한 주요 대안: Fetch a roster on the flagship request path, lengthen the roster TTL, or
|
|
14
|
+
* infer availability from the account's plan name.
|
|
15
|
+
* - 선택한 방식: Record the exact upstream unsupported-model refusal per (account, model) and
|
|
16
|
+
* let selection read it alongside the roster.
|
|
17
|
+
* - 다른 대안 대신 이 방식을 선택한 이유: A roster fetch on the request path puts an
|
|
18
|
+
* authenticated upstream call in front of the most commonly requested models in the product,
|
|
19
|
+
* which is what the cache-only contract exists to prevent. A longer TTL keeps a shard's
|
|
20
|
+
* stale absence around for longer without adding any evidence. A plan name proves nothing
|
|
21
|
+
* about a grant -- `available_in_plans` for `gpt-6-astra` lists `free` while free accounts
|
|
22
|
+
* are refused -- and #3022 is what happened the last time availability was inferred rather
|
|
23
|
+
* than observed.
|
|
24
|
+
* - 장점, 단점 및 영향: A refusal is spent once and then remembered, so the pool stops
|
|
25
|
+
* re-sending a model to the account that just refused it. The evidence is confirmed rather
|
|
26
|
+
* than inferred, it is still only an ordering preference, and it is overridden by any
|
|
27
|
+
* positive roster grant for the same pair.
|
|
28
|
+
*
|
|
29
|
+
* What this deliberately is NOT: an eligibility filter. Readers treat these ids exactly like
|
|
30
|
+
* roster denials -- `withoutModelDeniedAccounts` restores them when filtering would empty the
|
|
31
|
+
* candidate list, and `preferModelEntitledAccount` leaves the active account alone when no
|
|
32
|
+
* entitled alternative exists. No model is hidden from any catalog and no request is refused
|
|
33
|
+
* before dispatch, so the 2026-09-04 owner decision that the flagships fail open is untouched.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Six hours, against a five-minute roster TTL.
|
|
38
|
+
*
|
|
39
|
+
* The asymmetry is the point. A roster entry is a snapshot of an answer that may simply not
|
|
40
|
+
* have arrived yet, so it expires quickly and absence means "unknown". A refusal is an answer:
|
|
41
|
+
* upstream named this model and this account and said no. It still expires, because a rollout
|
|
42
|
+
* can reach an account between two requests, and the two faster paths back are a positive
|
|
43
|
+
* roster grant (which overrides this outright) and a successful response from the same account
|
|
44
|
+
* for the same model (which clears the entry).
|
|
45
|
+
*/
|
|
46
|
+
const OBSERVED_DENIAL_TTL_MS = 6 * 60 * 60_000;
|
|
47
|
+
|
|
48
|
+
/** Bounded like the roster cache: pool size times flagship count, with room to spare. */
|
|
49
|
+
const OBSERVED_DENIAL_MAX_ENTRIES = 512;
|
|
50
|
+
|
|
51
|
+
/** `accountId\u0000modelId` -> expiry. Insertion order is the eviction order. */
|
|
52
|
+
const observedDenials = new Map<string, number>();
|
|
53
|
+
|
|
54
|
+
function denialKey(accountId: string, modelId: string): string {
|
|
55
|
+
return `${accountId}\u0000${modelId}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function accountIdOfDenialKey(key: string): string {
|
|
59
|
+
return key.slice(0, key.indexOf("\u0000"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function modelIdOfDenialKey(key: string): string {
|
|
63
|
+
return key.slice(key.indexOf("\u0000") + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Remember that `accountId` was refused `modelId` by its own authenticated upstream.
|
|
68
|
+
*
|
|
69
|
+
* Re-recording refreshes the entry rather than extending an older one, so a pair that keeps
|
|
70
|
+
* being refused stays remembered and one that stops being refused ages out.
|
|
71
|
+
*/
|
|
72
|
+
export function recordObservedCodexModelDenial(
|
|
73
|
+
accountId: string,
|
|
74
|
+
modelId: string,
|
|
75
|
+
now = Date.now(),
|
|
76
|
+
): void {
|
|
77
|
+
const key = denialKey(accountId, modelId);
|
|
78
|
+
// Delete before set so the refreshed entry moves to the back of the eviction order.
|
|
79
|
+
observedDenials.delete(key);
|
|
80
|
+
observedDenials.set(key, now + OBSERVED_DENIAL_TTL_MS);
|
|
81
|
+
while (observedDenials.size > OBSERVED_DENIAL_MAX_ENTRIES) {
|
|
82
|
+
const oldest = observedDenials.keys().next();
|
|
83
|
+
if (oldest.done) break;
|
|
84
|
+
observedDenials.delete(oldest.value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Forget one pair, because the account just served the model.
|
|
90
|
+
*
|
|
91
|
+
* A success is newer and stronger evidence than the refusal that preceded it: whatever the
|
|
92
|
+
* entitlement was when upstream refused, it is not that now.
|
|
93
|
+
*/
|
|
94
|
+
export function clearObservedCodexModelDenial(accountId: string, modelId: string): void {
|
|
95
|
+
observedDenials.delete(denialKey(accountId, modelId));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Forget every pair for one account, because its credential changed.
|
|
100
|
+
*
|
|
101
|
+
* A reauthenticated account can be a different subscription entirely, so evidence gathered
|
|
102
|
+
* under the previous credential says nothing about this one -- the same reasoning
|
|
103
|
+
* `invalidateCodexModelEntitlementsForAccount` applies to cached rosters.
|
|
104
|
+
*/
|
|
105
|
+
export function forgetObservedCodexModelDenialsForAccount(accountId: string | null | undefined): void {
|
|
106
|
+
if (!accountId) return;
|
|
107
|
+
for (const key of [...observedDenials.keys()]) {
|
|
108
|
+
if (accountIdOfDenialKey(key) === accountId) observedDenials.delete(key);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Accounts refused `modelId` within the retention window.
|
|
114
|
+
*
|
|
115
|
+
* Returns `undefined` rather than an empty set when nothing is recorded, matching
|
|
116
|
+
* `cachedDeniedCodexAccountIdsForModel`: a caller must not be able to read "no evidence" as
|
|
117
|
+
* "nobody is denied".
|
|
118
|
+
*/
|
|
119
|
+
export function observedDeniedCodexAccountIdsForModel(
|
|
120
|
+
modelId: string | undefined,
|
|
121
|
+
now = Date.now(),
|
|
122
|
+
): ReadonlySet<string> | undefined {
|
|
123
|
+
if (!modelId) return undefined;
|
|
124
|
+
const denied = new Set<string>();
|
|
125
|
+
for (const [key, expiresAt] of [...observedDenials]) {
|
|
126
|
+
if (expiresAt <= now) {
|
|
127
|
+
observedDenials.delete(key);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (modelIdOfDenialKey(key) === modelId) denied.add(accountIdOfDenialKey(key));
|
|
131
|
+
}
|
|
132
|
+
return denied.size > 0 ? denied : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resetObservedCodexModelDenialsForTests(): void {
|
|
136
|
+
observedDenials.clear();
|
|
137
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const MAX_AUTH_BYTES = 256 * 1024;
|
|
5
|
+
export const ORCA_ACCOUNT_DIRECTORY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
|
|
7
|
+
export function sameLocalPath(left: string, right: string): boolean {
|
|
8
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Reject links at every component; never follow an external credential reference. */
|
|
12
|
+
export function assertPlainLocalPath(path: string): string {
|
|
13
|
+
if (/^(?:\\\\|\/\/)/.test(path)) throw new Error("Network and device credential paths are unsupported.");
|
|
14
|
+
const absolute = resolve(path);
|
|
15
|
+
let current = parse(absolute).root;
|
|
16
|
+
for (const component of relative(current, absolute).split(/[\\/]/).filter(Boolean)) {
|
|
17
|
+
current = join(current, component);
|
|
18
|
+
if (lstatSync(current).isSymbolicLink()) throw new Error("Unsafe local credential path.");
|
|
19
|
+
}
|
|
20
|
+
if (!sameLocalPath(realpathSync(absolute), absolute)) throw new Error("Unsafe local credential path.");
|
|
21
|
+
return absolute;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readBoundedLocalFile(path: string, limit = MAX_AUTH_BYTES): string {
|
|
25
|
+
assertPlainLocalPath(path);
|
|
26
|
+
const before = lstatSync(path);
|
|
27
|
+
if (!before.isFile() || before.size > limit) throw new Error("Invalid local credential file.");
|
|
28
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
29
|
+
try {
|
|
30
|
+
const opened = fstatSync(fd);
|
|
31
|
+
if (!opened.isFile() || opened.size > limit || before.dev !== opened.dev || before.ino !== opened.ino) {
|
|
32
|
+
throw new Error("Local credential file changed.");
|
|
33
|
+
}
|
|
34
|
+
// The cap bounds the read, not the allocation: tiny registry files should not
|
|
35
|
+
// allocate 32 MiB on each preview/recheck. One extra byte detects concurrent growth.
|
|
36
|
+
const bytes = Buffer.alloc(opened.size + 1);
|
|
37
|
+
let length = 0;
|
|
38
|
+
while (length < bytes.length) {
|
|
39
|
+
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
40
|
+
if (!count) break;
|
|
41
|
+
length += count;
|
|
42
|
+
}
|
|
43
|
+
if (length !== opened.size) throw new Error("Local credential file changed.");
|
|
44
|
+
assertPlainLocalPath(path);
|
|
45
|
+
const after = lstatSync(path);
|
|
46
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) {
|
|
47
|
+
throw new Error("Local credential file changed.");
|
|
48
|
+
}
|
|
49
|
+
return bytes.subarray(0, length).toString("utf8");
|
|
50
|
+
} finally { closeSync(fd); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function object(value: unknown): Record<string, unknown> {
|
|
54
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid local OAuth credential.");
|
|
55
|
+
return value as Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseOrcaAuth(raw: string, requireFresh = true) {
|
|
59
|
+
const auth = object(JSON.parse(raw));
|
|
60
|
+
if ((auth.auth_mode !== undefined && auth.auth_mode !== "chatgpt")
|
|
61
|
+
|| (auth.OPENAI_API_KEY !== undefined && auth.OPENAI_API_KEY !== null && auth.OPENAI_API_KEY !== "")) {
|
|
62
|
+
throw new Error("Expected a ChatGPT OAuth credential.");
|
|
63
|
+
}
|
|
64
|
+
const tokens = object(auth.tokens);
|
|
65
|
+
if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" || !tokens.account_id) {
|
|
66
|
+
throw new Error("Local OAuth credential lacks account identity.");
|
|
67
|
+
}
|
|
68
|
+
const parts = tokens.access_token.split(".");
|
|
69
|
+
if (parts.length !== 3) throw new Error("Invalid local OAuth bearer.");
|
|
70
|
+
const claims = object(JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")));
|
|
71
|
+
const identity = object(claims["https://api.openai.com/auth"]);
|
|
72
|
+
if (identity.chatgpt_account_id !== tokens.account_id || typeof claims.sub !== "string" || !claims.sub
|
|
73
|
+
|| typeof claims.exp !== "number" || !Number.isSafeInteger(claims.exp) || !Number.isSafeInteger(claims.exp * 1000)
|
|
74
|
+
|| (requireFresh && claims.exp * 1000 <= Date.now() + 60_000)) {
|
|
75
|
+
throw new Error("Local OAuth identity or expiry is invalid.");
|
|
76
|
+
}
|
|
77
|
+
return { accessToken: tokens.access_token, refreshToken: "", expiresAt: claims.exp * 1000,
|
|
78
|
+
chatgptAccountId: tokens.account_id, sourceSubject: claims.sub };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readOrcaAuthSource(path: string) {
|
|
82
|
+
if (!isAbsolute(path) || !ORCA_ACCOUNT_DIRECTORY.test(dirname(dirname(path)).split(/[\\/]/).pop() ?? "")
|
|
83
|
+
|| dirname(path).split(/[\\/]/).pop() !== "home"
|
|
84
|
+
|| dirname(dirname(dirname(path))).split(/[\\/]/).pop() !== "codex-accounts"
|
|
85
|
+
|| path.split(/[\\/]/).pop() !== "auth.json") throw new Error("Invalid Orca credential location.");
|
|
86
|
+
try {
|
|
87
|
+
const accountDirectory = dirname(dirname(path)).split(/[\\/]/).pop()!;
|
|
88
|
+
if (readBoundedLocalFile(join(dirname(path), ".orca-managed-home"), 128).trim() !== accountDirectory) {
|
|
89
|
+
throw new Error("Orca home ownership marker does not match.");
|
|
90
|
+
}
|
|
91
|
+
return { ...parseOrcaAuth(readBoundedLocalFile(path)), sourceAuthPath: path };
|
|
92
|
+
}
|
|
93
|
+
catch { throw new Error("Orca credential unavailable; update the account in Orca and retry."); }
|
|
94
|
+
}
|