@bitkyc08/opencodex 2.58.0 → 2.60.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-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- 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 +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- 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 +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- 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 +28 -35
- 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 +26 -6
- 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 +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- 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 +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- 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 +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- 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/internal/catalog-writer.ts +33 -1
- 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-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -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 +289 -16
- 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 +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -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 +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- 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 +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- 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/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -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/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- 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/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
package/src/codex/model-cache.ts
CHANGED
|
@@ -71,6 +71,8 @@ function deleteCachedProvider(provider: string): number {
|
|
|
71
71
|
cache.delete(provider);
|
|
72
72
|
cacheBytes = Math.max(0, cacheBytes - entry.sizeBytes);
|
|
73
73
|
if (oldestCachedProvider === provider) recomputeOldestCachedProvider();
|
|
74
|
+
// A removal changes this provider's content as surely as a publication does.
|
|
75
|
+
bumpProviderCacheRevision(provider);
|
|
74
76
|
return entry.sizeBytes;
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -177,6 +179,34 @@ export function isModelCacheGenerationCurrent(provider: string, generation: stri
|
|
|
177
179
|
return generation === captureModelCacheGeneration(provider);
|
|
178
180
|
}
|
|
179
181
|
|
|
182
|
+
/**
|
|
183
|
+
* How many times this provider's cached content has actually changed.
|
|
184
|
+
*
|
|
185
|
+
* Deliberately separate from the generation. A generation revokes an in-flight discovery's right
|
|
186
|
+
* to publish, so it advances on an authority clear and must not be repurposed: moving it on a
|
|
187
|
+
* successful publication would cancel writes that are still legitimate. This counts accepted
|
|
188
|
+
* publications and removals instead, which is what a reader holding a derived roster needs to
|
|
189
|
+
* know, and it advances on exactly the event a generation does not: a discovery that succeeded
|
|
190
|
+
* and changed the rows.
|
|
191
|
+
*
|
|
192
|
+
* Reading is passive. An unseen provider reads as `0` rather than seeding an entry, so observing
|
|
193
|
+
* one cannot alter what a later capture or publication sees.
|
|
194
|
+
*/
|
|
195
|
+
const providerCacheRevisions = new Map<string, number>();
|
|
196
|
+
let globalContentRevision = 0;
|
|
197
|
+
|
|
198
|
+
function bumpProviderCacheRevision(provider: string): void {
|
|
199
|
+
providerCacheRevisions.set(provider, (providerCacheRevisions.get(provider) ?? 0) + 1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function observeModelCacheRevision(provider: string): string {
|
|
203
|
+
// The global term covers a clear that empties the map wholesale, which per-provider counters
|
|
204
|
+
// cannot express: without it, wiping every entry and republishing identical-looking rows would
|
|
205
|
+
// read as unchanged. It also survives pruning, so a retired provider cannot come back with a
|
|
206
|
+
// counter that matches a roster built before it left.
|
|
207
|
+
return `${globalContentRevision}:${providerCacheRevisions.get(provider) ?? 0}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
180
210
|
/**
|
|
181
211
|
* Store a live result unless the cache was cleared while that asynchronous discovery was running.
|
|
182
212
|
* The optional generation keeps existing direct cache writers unchanged while discovery callers can
|
|
@@ -200,6 +230,8 @@ export function setCached(
|
|
|
200
230
|
oldestCachedAt = now;
|
|
201
231
|
}
|
|
202
232
|
enforceAppOwnedMemoryBudget();
|
|
233
|
+
// Published and accepted, so anything derived from this provider's rows is now out of date.
|
|
234
|
+
bumpProviderCacheRevision(provider);
|
|
203
235
|
return true;
|
|
204
236
|
}
|
|
205
237
|
|
|
@@ -220,6 +252,11 @@ export function clearModelCache(
|
|
|
220
252
|
} else {
|
|
221
253
|
if (revokesInFlightDiscovery) globalCacheGeneration += 1;
|
|
222
254
|
cache.clear();
|
|
255
|
+
// A wholesale clear changes every provider's content at once, and clearing the map means no
|
|
256
|
+
// per-provider counter can record it. Advancing the global term retires every derived roster
|
|
257
|
+
// and lets the per-provider entries be dropped without an ABA on the way back.
|
|
258
|
+
globalContentRevision += 1;
|
|
259
|
+
providerCacheRevisions.clear();
|
|
223
260
|
cacheBytes = 0;
|
|
224
261
|
oldestCachedProvider = undefined;
|
|
225
262
|
oldestCachedAt = null;
|
|
@@ -237,6 +274,7 @@ export function reconcileModelCacheProviders(
|
|
|
237
274
|
const removedProviders = new Set<string>();
|
|
238
275
|
const trackedProviders = new Set([
|
|
239
276
|
...providerCacheGenerations.keys(),
|
|
277
|
+
...providerCacheRevisions.keys(),
|
|
240
278
|
...failureAt.keys(),
|
|
241
279
|
...discoveryStatus.keys(),
|
|
242
280
|
...liveModelCounts.keys(),
|
|
@@ -247,6 +285,10 @@ export function reconcileModelCacheProviders(
|
|
|
247
285
|
if (validProviders.has(provider)) continue;
|
|
248
286
|
if (!revokedRemovedProviderAuthority) {
|
|
249
287
|
globalCacheGeneration += 1;
|
|
288
|
+
// Advanced BEFORE any revision entry is dropped, so a provider that comes back cannot
|
|
289
|
+
// present the counter a roster was built against. Without this the delete below is an ABA:
|
|
290
|
+
// the entry returns at zero and an old stamp matches again.
|
|
291
|
+
globalContentRevision += 1;
|
|
250
292
|
revokedRemovedProviderAuthority = true;
|
|
251
293
|
}
|
|
252
294
|
providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1);
|
|
@@ -255,6 +297,11 @@ export function reconcileModelCacheProviders(
|
|
|
255
297
|
failureAt.delete(provider);
|
|
256
298
|
discoveryStatus.delete(provider);
|
|
257
299
|
liveModelCounts.delete(provider);
|
|
300
|
+
// AFTER the cache deletion, which bumps this provider's revision and would otherwise recreate
|
|
301
|
+
// the entry we just removed. Dropped rather than left behind: a provider the configuration no
|
|
302
|
+
// longer has must not keep an entry alive for the life of the process merely because nothing
|
|
303
|
+
// cleared the whole cache. The global epoch advanced above, so the removal is not an ABA.
|
|
304
|
+
providerCacheRevisions.delete(provider);
|
|
258
305
|
removedProviders.add(provider);
|
|
259
306
|
}
|
|
260
307
|
lastReconciledGeneration = generation;
|
|
@@ -2,7 +2,11 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
3
3
|
import type { CodexAccountCredentialRecord, OcxConfig } from "../types";
|
|
4
4
|
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
beginCodexAccountGenerationLiveCheck,
|
|
7
|
+
getValidCodexToken,
|
|
8
|
+
loadCodexAccountRecordSnapshot,
|
|
9
|
+
} from "./account-store";
|
|
6
10
|
import {
|
|
7
11
|
getMainAccountToken,
|
|
8
12
|
getValidMainAccountToken,
|
|
@@ -17,6 +21,14 @@ import { loadPersistedCodexRuntime } from "./runtime";
|
|
|
17
21
|
import { codexRuntimeStateEpoch } from "./runtime";
|
|
18
22
|
import upstreamModelsSnapshot from "./data/upstream-models.json";
|
|
19
23
|
import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
24
|
+
import {
|
|
25
|
+
clearObservedCodexModelDenial,
|
|
26
|
+
forgetObservedCodexModelDenialsForAccount,
|
|
27
|
+
observedDeniedCodexAccountIdsForModel,
|
|
28
|
+
recordObservedCodexModelDenial,
|
|
29
|
+
setObservedDenialGenerationCheck,
|
|
30
|
+
resetObservedCodexModelDenialsForTests,
|
|
31
|
+
} from "./observed-model-denials";
|
|
20
32
|
|
|
21
33
|
const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models";
|
|
22
34
|
|
|
@@ -870,6 +882,11 @@ function needsEntitlementRefresh(
|
|
|
870
882
|
const cached = accountModelsCache.get(cacheKeyFor(accountId, clientVersion));
|
|
871
883
|
if (cached && cached.credentialIdentity !== credentialIdentity) {
|
|
872
884
|
invalidateCodexModelEntitlementsForAccount(accountId);
|
|
885
|
+
// The credential itself changed, so evidence gathered under the previous one answers for a
|
|
886
|
+
// different subscription. This is the only call site that knows that: the two gated-model
|
|
887
|
+
// sites in `core-codex-account.ts` invalidate a STALE roster for an unchanged credential,
|
|
888
|
+
// and clearing observed refusals there would discard the very evidence #4906 is about.
|
|
889
|
+
forgetObservedCodexModelDenialsForAccount(accountId);
|
|
873
890
|
} else if (cached && cached.expiresAt > now) {
|
|
874
891
|
return false;
|
|
875
892
|
}
|
|
@@ -1280,17 +1297,77 @@ export function cachedDeniedCodexAccountIdsForModel(
|
|
|
1280
1297
|
if (state === "granted") granted.add(accountId);
|
|
1281
1298
|
else if (state === "denied") denied.add(accountId);
|
|
1282
1299
|
}
|
|
1300
|
+
// The roster is not the only evidence, and on this path it is usually the weaker one. A cached
|
|
1301
|
+
// roster expires in five minutes and nothing on the flagship request path refetches it, so
|
|
1302
|
+
// absent an ongoing catalog sync the loop above contributes nothing at all. An upstream
|
|
1303
|
+
// refusal does not expire on that schedule and is not a snapshot of a pending answer: it is
|
|
1304
|
+
// the account's own Codex surface naming this model and declining it (#4906).
|
|
1305
|
+
// The caller's read fence is passed IN rather than applied to the result, so an excluded
|
|
1306
|
+
// account is skipped before the credential-generation validation reads account storage
|
|
1307
|
+
// (#4952). An excluded account must stay UNKNOWN rather than denied, so a profile switch or
|
|
1308
|
+
// a request-owned credential produces the same selection it does today.
|
|
1309
|
+
const observedDenied = observedDeniedCodexAccountIdsForModel(modelId, now, {
|
|
1310
|
+
...(options.excludeAccountIds ? { excludeAccountIds: options.excludeAccountIds } : {}),
|
|
1311
|
+
});
|
|
1312
|
+
for (const accountId of observedDenied ?? []) denied.add(accountId);
|
|
1283
1313
|
// One account holds one entry per client version, and upstream filters the roster by that
|
|
1284
1314
|
// version. So the same account can legitimately carry a granted entry under a current client
|
|
1285
1315
|
// and a denied one under an older client that predates the model. Positive evidence is
|
|
1286
1316
|
// authoritative regardless of which version asked for it -- the same rule
|
|
1287
1317
|
// `codexModelEntitlementStateForRoster` applies within a single entry -- so a grant anywhere
|
|
1288
1318
|
// clears the denial rather than being outvoted by whichever entry the map happened to yield
|
|
1289
|
-
// last.
|
|
1319
|
+
// last. It outranks an observed refusal for the same reason: a confirmed roster that lists the
|
|
1320
|
+
// model is the newer answer, and a rollout that reaches an account must not be held back by a
|
|
1321
|
+
// refusal it has already superseded.
|
|
1290
1322
|
for (const accountId of granted) denied.delete(accountId);
|
|
1291
1323
|
return denied.size > 0 ? denied : undefined;
|
|
1292
1324
|
}
|
|
1293
1325
|
|
|
1326
|
+
/**
|
|
1327
|
+
* Record an authenticated upstream refusal as this account's own evidence about `modelId`.
|
|
1328
|
+
*
|
|
1329
|
+
* Scoped to {@link ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS} because that is the set whose
|
|
1330
|
+
* availability varies per account while the row stays visible, and it is the set
|
|
1331
|
+
* {@link cachedDeniedCodexAccountIdsForModel} will read back. A model outside it either has no
|
|
1332
|
+
* per-account variance or is gated by the fail-closed roster path, where an ordering preference
|
|
1333
|
+
* would change nothing.
|
|
1334
|
+
*
|
|
1335
|
+
* The caller must have matched the exact allow-listed refusal body first. A status alone is not
|
|
1336
|
+
* admissible here: 400 covers every malformed request too, and remembering one of those as an
|
|
1337
|
+
* entitlement fact would steer routing away from a perfectly capable account.
|
|
1338
|
+
*/
|
|
1339
|
+
// Denial evidence is credential-scoped (#4952). The store stays a leaf module, so the
|
|
1340
|
+
// liveness predicate is injected here, where the account store is already a dependency.
|
|
1341
|
+
setObservedDenialGenerationCheck(beginCodexAccountGenerationLiveCheck);
|
|
1342
|
+
|
|
1343
|
+
export function recordCodexModelDenialEvidence(
|
|
1344
|
+
accountId: string | null | undefined,
|
|
1345
|
+
modelId: string | undefined,
|
|
1346
|
+
generation: number | null | undefined,
|
|
1347
|
+
now = Date.now(),
|
|
1348
|
+
): void {
|
|
1349
|
+
if (!accountId || !modelId) return;
|
|
1350
|
+
if (!ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return;
|
|
1351
|
+
// A caller that cannot name a credential generation records ACCOUNT-scoped evidence rather
|
|
1352
|
+
// than none. The one production context in that position is `main-pool`, whose credential
|
|
1353
|
+
// lives in `auth.json` and has no pool generation; discarding its refusals would revert
|
|
1354
|
+
// #4906 for the stored main login (#4952). Request-owned `main` never reaches here — its
|
|
1355
|
+
// `accountId` is null and the guard above returns.
|
|
1356
|
+
recordObservedCodexModelDenial(accountId, modelId, typeof generation === "number" ? generation : undefined, now);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** Drop the refusal evidence for a pair the account has just served successfully. */
|
|
1360
|
+
export function clearCodexModelDenialEvidence(
|
|
1361
|
+
accountId: string | null | undefined,
|
|
1362
|
+
modelId: string | undefined,
|
|
1363
|
+
generation: number | null | undefined,
|
|
1364
|
+
): void {
|
|
1365
|
+
if (!accountId || !modelId) return;
|
|
1366
|
+
// Mirror of the write: an account-scoped success clears account-scoped evidence. It cannot
|
|
1367
|
+
// clear a credential-scoped entry that names a newer generation, and vice versa (#4952).
|
|
1368
|
+
clearObservedCodexModelDenial(accountId, modelId, typeof generation === "number" ? generation : undefined);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1294
1371
|
/** Synchronous projection for management/catalog readers after a discovery pass. */
|
|
1295
1372
|
export function cachedAvailableAccountGatedNativeModels(
|
|
1296
1373
|
now = Date.now(),
|
|
@@ -1351,6 +1428,7 @@ export function resetCodexModelEntitlementCacheForTests(): void {
|
|
|
1351
1428
|
negativeCredentialMemo.clear();
|
|
1352
1429
|
entitlementEnsureFlights.clear();
|
|
1353
1430
|
runtimeVersionMemo = null;
|
|
1431
|
+
resetObservedCodexModelDenialsForTests();
|
|
1354
1432
|
}
|
|
1355
1433
|
|
|
1356
1434
|
/** Test-only snapshot for proving publication fences, which cache lookup intentionally masks. */
|
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
/**
|
|
52
|
+
* One entry of refusal evidence.
|
|
53
|
+
*
|
|
54
|
+
* `generation` is the credential generation the refusal was observed under (#4952). Evidence
|
|
55
|
+
* is about a CREDENTIAL, not an account id: reauthenticating the same internal account can
|
|
56
|
+
* swap the subscription underneath it, so a refusal from the previous credential says nothing
|
|
57
|
+
* about the replacement. Carrying the generation lets a reader ignore superseded evidence and
|
|
58
|
+
* lets a late reply from an older generation be refused rather than applied.
|
|
59
|
+
*
|
|
60
|
+
* It is OPTIONAL because not every account that can be refused has one. A `main-pool` context
|
|
61
|
+
* — the stored main login taking part in rotation — carries a real `accountId` and a
|
|
62
|
+
* `writerGeneration`, but no pool credential generation, because its credential lives in
|
|
63
|
+
* `auth.json` rather than the pool store. Dropping its evidence would have silently reverted
|
|
64
|
+
* #4906 for that account: the pool would re-send the same model to the login that just refused
|
|
65
|
+
* it, on every request, forever. An entry without a generation is account-scoped, and the
|
|
66
|
+
* generation fences below simply do not apply to it. This is the same rule the quota writer
|
|
67
|
+
* already uses in `core-codex-account.ts`, where an absent credential generation skips the
|
|
68
|
+
* liveness check instead of discarding the write.
|
|
69
|
+
*/
|
|
70
|
+
interface ObservedDenial {
|
|
71
|
+
expiresAt: number;
|
|
72
|
+
generation?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `accountId\u0000modelId` -> entry. Insertion order is the eviction order. */
|
|
76
|
+
const observedDenials = new Map<string, ObservedDenial>();
|
|
77
|
+
|
|
78
|
+
/** Whether `generation` is still the account's live credential. */
|
|
79
|
+
type GenerationLiveCheck = (accountId: string, generation: number) => boolean;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Opens one liveness check.
|
|
83
|
+
*
|
|
84
|
+
* A FACTORY rather than a bare predicate because the production implementation reads the
|
|
85
|
+
* credential store, and a lookup can ask about several accounts. Loading once per lookup and
|
|
86
|
+
* closing over that snapshot is the shape `loadCodexAccountRecordSnapshot` exists for; a bare
|
|
87
|
+
* predicate would reload and reparse the whole store per row, on the request path.
|
|
88
|
+
*
|
|
89
|
+
* Injected rather than imported at module scope so this stays a leaf module: `account-store`
|
|
90
|
+
* reads the credential file, and a unit test of this map should not have to stand one up.
|
|
91
|
+
*/
|
|
92
|
+
type GenerationLiveCheckFactory = () => GenerationLiveCheck;
|
|
93
|
+
|
|
94
|
+
let beginGenerationLiveCheck: GenerationLiveCheckFactory = () => () => true;
|
|
95
|
+
|
|
96
|
+
/** Wire the liveness predicate. Called once at startup; tests substitute their own. */
|
|
97
|
+
export function setObservedDenialGenerationCheck(begin: GenerationLiveCheckFactory): void {
|
|
98
|
+
beginGenerationLiveCheck = begin;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function denialKey(accountId: string, modelId: string): string {
|
|
102
|
+
return `${accountId}\u0000${modelId}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function accountIdOfDenialKey(key: string): string {
|
|
106
|
+
return key.slice(0, key.indexOf("\u0000"));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function modelIdOfDenialKey(key: string): string {
|
|
110
|
+
return key.slice(key.indexOf("\u0000") + 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Remember that `accountId` was refused `modelId` by its own authenticated upstream.
|
|
115
|
+
*
|
|
116
|
+
* Re-recording refreshes the entry rather than extending an older one, so a pair that keeps
|
|
117
|
+
* being refused stays remembered and one that stops being refused ages out.
|
|
118
|
+
*/
|
|
119
|
+
export function recordObservedCodexModelDenial(
|
|
120
|
+
accountId: string,
|
|
121
|
+
modelId: string,
|
|
122
|
+
generation: number | undefined,
|
|
123
|
+
now = Date.now(),
|
|
124
|
+
): void {
|
|
125
|
+
// A refusal dispatched under generation G can arrive after G+1 has been saved. Reject it
|
|
126
|
+
// BEFORE touching the map at all, not merely when this key already holds newer evidence:
|
|
127
|
+
// with no entry for this key the stale row would otherwise be inserted, and at the entry
|
|
128
|
+
// bound it would evict a valid row that nothing can restore (#4952).
|
|
129
|
+
if (generation !== undefined && !beginGenerationLiveCheck()(accountId, generation)) return;
|
|
130
|
+
const key = denialKey(accountId, modelId);
|
|
131
|
+
const existing = observedDenials.get(key);
|
|
132
|
+
// Second fence, for the window where the replacement credential has been dispatched but the
|
|
133
|
+
// store read above still answers live. Both sides must name a generation to be comparable;
|
|
134
|
+
// an account-scoped entry is not older or newer than a credential-scoped one.
|
|
135
|
+
if (existing !== undefined && existing.generation !== undefined && generation !== undefined
|
|
136
|
+
&& existing.generation > generation) return;
|
|
137
|
+
// Delete before set so the refreshed entry moves to the back of the eviction order.
|
|
138
|
+
observedDenials.delete(key);
|
|
139
|
+
observedDenials.set(key, { expiresAt: now + OBSERVED_DENIAL_TTL_MS, generation });
|
|
140
|
+
while (observedDenials.size > OBSERVED_DENIAL_MAX_ENTRIES) {
|
|
141
|
+
const oldest = observedDenials.keys().next();
|
|
142
|
+
if (oldest.done) break;
|
|
143
|
+
observedDenials.delete(oldest.value);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Forget one pair, because the account just served the model.
|
|
149
|
+
*
|
|
150
|
+
* A success is newer and stronger evidence than the refusal that preceded it: whatever the
|
|
151
|
+
* entitlement was when upstream refused, it is not that now.
|
|
152
|
+
*/
|
|
153
|
+
export function clearObservedCodexModelDenial(
|
|
154
|
+
accountId: string,
|
|
155
|
+
modelId: string,
|
|
156
|
+
generation: number | undefined,
|
|
157
|
+
): void {
|
|
158
|
+
const key = denialKey(accountId, modelId);
|
|
159
|
+
const existing = observedDenials.get(key);
|
|
160
|
+
if (!existing) return;
|
|
161
|
+
// The mirror of the write fence: a success dispatched under G arriving after G+1 was
|
|
162
|
+
// refused must not clear the replacement's evidence (#4952). Equal generations clear,
|
|
163
|
+
// because that is the ordinary "this account just served this model" case.
|
|
164
|
+
if (existing.generation !== undefined && generation !== undefined
|
|
165
|
+
&& existing.generation > generation) return;
|
|
166
|
+
observedDenials.delete(key);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Forget every pair for one account, because its credential changed.
|
|
171
|
+
*
|
|
172
|
+
* A reauthenticated account can be a different subscription entirely, so evidence gathered
|
|
173
|
+
* under the previous credential says nothing about this one -- the same reasoning
|
|
174
|
+
* `invalidateCodexModelEntitlementsForAccount` applies to cached rosters.
|
|
175
|
+
*/
|
|
176
|
+
export function forgetObservedCodexModelDenialsForAccount(accountId: string | null | undefined): void {
|
|
177
|
+
if (!accountId) return;
|
|
178
|
+
for (const key of [...observedDenials.keys()]) {
|
|
179
|
+
if (accountIdOfDenialKey(key) === accountId) observedDenials.delete(key);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Accounts refused `modelId` within the retention window.
|
|
185
|
+
*
|
|
186
|
+
* Returns `undefined` rather than an empty set when nothing is recorded, matching
|
|
187
|
+
* `cachedDeniedCodexAccountIdsForModel`: a caller must not be able to read "no evidence" as
|
|
188
|
+
* "nobody is denied".
|
|
189
|
+
*/
|
|
190
|
+
export function observedDeniedCodexAccountIdsForModel(
|
|
191
|
+
modelId: string | undefined,
|
|
192
|
+
now = Date.now(),
|
|
193
|
+
options: { excludeAccountIds?: ReadonlySet<string> } = {},
|
|
194
|
+
): ReadonlySet<string> | undefined {
|
|
195
|
+
if (!modelId) return undefined;
|
|
196
|
+
const denied = new Set<string>();
|
|
197
|
+
// Opened lazily and at most once: a lookup that matches no credential-scoped row must not
|
|
198
|
+
// read the credential store at all.
|
|
199
|
+
let isLive: GenerationLiveCheck | undefined;
|
|
200
|
+
for (const [key, entry] of [...observedDenials]) {
|
|
201
|
+
if (entry.expiresAt <= now) {
|
|
202
|
+
observedDenials.delete(key);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Model and the caller's exclusion fence FIRST. The issue asks for identity validation
|
|
206
|
+
// after the exclusion read, and an excluded account — a draining profile switch, or a
|
|
207
|
+
// request-owned credential — must produce no credential-store read on its behalf.
|
|
208
|
+
if (modelIdOfDenialKey(key) !== modelId) continue;
|
|
209
|
+
const accountId = accountIdOfDenialKey(key);
|
|
210
|
+
if (options.excludeAccountIds?.has(accountId)) continue;
|
|
211
|
+
if (entry.generation !== undefined) {
|
|
212
|
+
isLive ??= beginGenerationLiveCheck();
|
|
213
|
+
// Superseded evidence stops denying the replacement. It is SKIPPED, not deleted: the
|
|
214
|
+
// predicate cannot tell "this account reauthenticated" from "the credential store could
|
|
215
|
+
// not be read", and deleting on the second would throw away valid evidence that a
|
|
216
|
+
// transient read failure was never entitled to touch. Skipping already delivers the
|
|
217
|
+
// routing outcome the issue asks for, on every read, without depending on the
|
|
218
|
+
// conditional account-wide forget that cannot run when no roster was ever cached.
|
|
219
|
+
// Superseded rows still leave by TTL, by eviction, and by the account-wide forget.
|
|
220
|
+
if (!isLive(accountId, entry.generation)) continue;
|
|
221
|
+
}
|
|
222
|
+
denied.add(accountId);
|
|
223
|
+
}
|
|
224
|
+
return denied.size > 0 ? denied : undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function resetObservedCodexModelDenialsForTests(): void {
|
|
228
|
+
observedDenials.clear();
|
|
229
|
+
beginGenerationLiveCheck = () => () => true;
|
|
230
|
+
}
|
|
@@ -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
|
+
}
|