@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005
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/bin/ocx.mjs +11 -10
- package/gui/dist/assets/App-CppMz-6w.js +54 -0
- package/gui/dist/assets/App-lCIslZ1L.css +1 -0
- package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
- package/gui/dist/assets/index-DQt9srNi.js +94 -0
- package/gui/dist/assets/index-DmQB8D_R.css +1 -0
- package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
- package/gui/dist/index.html +4 -3
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +19 -2
- package/src/adapters/anthropic/client-identity.ts +13 -4
- package/src/adapters/anthropic/native-client-preamble.ts +31 -0
- package/src/adapters/anthropic/passthrough.ts +103 -25
- package/src/adapters/antigravity-refusal.ts +28 -0
- package/src/adapters/cursor/protobuf-request.ts +6 -0
- package/src/adapters/cursor/request-builder.ts +3 -0
- package/src/adapters/cursor/structured-output.ts +13 -0
- package/src/adapters/cursor/types.ts +2 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -2
- package/src/adapters/devin/trajectory.ts +49 -0
- package/src/adapters/devin.ts +13 -0
- package/src/adapters/ollama-native.ts +62 -19
- package/src/adapters/openai-responses/passthrough.ts +133 -103
- package/src/adapters/openai-responses/request-strips.ts +10 -4
- package/src/adapters/zed.ts +26 -4
- package/src/bridge/response-json.ts +14 -14
- package/src/bridge/sse.ts +15 -5
- package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
- package/src/claude/agents-inject.ts +4 -43
- package/src/claude/gateway-cache.ts +16 -13
- package/src/claude/inbound-model-options.ts +9 -2
- package/src/claude/intercept/cli-catalog.ts +27 -16
- package/src/claude/intercept/client-hello.ts +103 -0
- package/src/claude/intercept/model-bindings.ts +3 -2
- package/src/claude/intercept/picker-bootstrap.ts +37 -16
- package/src/claude/intercept/picker-budget.ts +41 -0
- package/src/claude/intercept/picker-listener.ts +279 -66
- package/src/claude/outbound.ts +2 -0
- package/src/claude/subagent-force-status.ts +53 -0
- package/src/claude/subagent-model.ts +93 -0
- package/src/cli/access-audio-input.ts +84 -0
- package/src/cli/access-audio-live.ts +99 -0
- package/src/cli/access-audio.ts +123 -0
- package/src/cli/access-data-client.ts +127 -0
- package/src/cli/access-data-plane.ts +104 -0
- package/src/cli/access-data-response.ts +76 -0
- package/src/cli/access.ts +65 -2
- package/src/cli/account-api.ts +33 -6
- package/src/cli/account-auth.ts +111 -42
- package/src/cli/account-key-quota.ts +118 -0
- package/src/cli/account-main.ts +11 -4
- package/src/cli/account-next-actions.ts +63 -0
- package/src/cli/account-policy-dto.ts +68 -0
- package/src/cli/account-policy.ts +167 -0
- package/src/cli/account-target.ts +8 -0
- package/src/cli/account.ts +37 -9
- package/src/cli/agent-runtime-settings.ts +130 -0
- package/src/cli/agent-settings.ts +101 -0
- package/src/cli/agent.ts +29 -2
- package/src/cli/alias.ts +7 -2
- package/src/cli/aside-profiles.ts +2 -2
- package/src/cli/capabilities-access-remote.ts +147 -0
- package/src/cli/capabilities-accounts.ts +195 -0
- package/src/cli/capabilities-agents-routing.ts +414 -0
- package/src/cli/capabilities-base.ts +783 -0
- package/src/cli/capabilities-command.ts +1 -0
- package/src/cli/capabilities-integrations.ts +264 -0
- package/src/cli/capabilities-lab.ts +214 -0
- package/src/cli/capabilities-observe-system.ts +662 -0
- package/src/cli/capabilities-provider-models.ts +302 -0
- package/src/cli/capabilities.ts +30 -1190
- package/src/cli/capability-types.ts +54 -0
- package/src/cli/catalog-command-result.ts +57 -0
- package/src/cli/chatgpt-command.ts +35 -9
- package/src/cli/claude-agent-startup-sync.ts +17 -0
- package/src/cli/claude-desktop-profile.ts +100 -0
- package/src/cli/claude-desktop.ts +39 -6
- package/src/cli/claude.ts +22 -6
- package/src/cli/codex-shim-autorestore.ts +2 -1
- package/src/cli/codex-shim-readiness.ts +109 -0
- package/src/cli/combo-input.ts +88 -0
- package/src/cli/combo-result.ts +52 -0
- package/src/cli/combo-stats.ts +123 -0
- package/src/cli/combo.ts +37 -14
- package/src/cli/companion-timeline.ts +142 -0
- package/src/cli/companion-usage.ts +146 -0
- package/src/cli/companion.ts +12 -3
- package/src/cli/config-command.ts +24 -8
- package/src/cli/connect.ts +19 -0
- package/src/cli/dispatch.ts +55 -82
- package/src/cli/doctor.ts +22 -4
- package/src/cli/export-command.ts +20 -9
- package/src/cli/gui.ts +8 -3
- package/src/cli/help-catalog.ts +101 -0
- package/src/cli/help-models-context.ts +17 -0
- package/src/cli/help-navigation.ts +50 -0
- package/src/cli/help-recovery.ts +66 -0
- package/src/cli/help.ts +69 -11
- package/src/cli/index.ts +38 -27
- package/src/cli/injection-follow.ts +35 -0
- package/src/cli/inspect.ts +5 -0
- package/src/cli/integration-aside-sync.ts +69 -0
- package/src/cli/integration-cursor.ts +106 -0
- package/src/cli/integration-input.ts +60 -0
- package/src/cli/integration-journal.ts +49 -0
- package/src/cli/integration-plan-dto.ts +156 -0
- package/src/cli/integration-preview.ts +215 -0
- package/src/cli/integrations.ts +24 -18
- package/src/cli/json-input.ts +134 -0
- package/src/cli/link.ts +32 -4
- package/src/cli/local-sync-result.ts +41 -0
- package/src/cli/log-follow.ts +84 -0
- package/src/cli/log-view-filter.ts +146 -0
- package/src/cli/logout-command.ts +44 -0
- package/src/cli/model-picker-ordering.ts +137 -0
- package/src/cli/models-custom-input.ts +107 -0
- package/src/cli/models-custom-runtime.ts +112 -0
- package/src/cli/models-order.ts +194 -0
- package/src/cli/models-runtime-subcommands.ts +2 -0
- package/src/cli/models-runtime.ts +9 -1
- package/src/cli/models.ts +66 -81
- package/src/cli/observe-stream.ts +142 -0
- package/src/cli/observe.ts +75 -34
- package/src/cli/opencode.ts +54 -10
- package/src/cli/provider-batch.ts +77 -0
- package/src/cli/provider-lifecycle-runtime.ts +170 -0
- package/src/cli/provider-result.ts +124 -0
- package/src/cli/provider-runtime.ts +15 -12
- package/src/cli/provider-settings.ts +143 -0
- package/src/cli/provider.ts +116 -64
- package/src/cli/registry.ts +90 -24
- package/src/cli/remote-workspace-hub.ts +83 -0
- package/src/cli/remote-workspace.ts +6 -1
- package/src/cli/restart-failure.ts +27 -0
- package/src/cli/root.ts +42 -9
- package/src/cli/route-policy-write.ts +137 -0
- package/src/cli/route-policy.ts +8 -0
- package/src/cli/runtime-api.ts +57 -80
- package/src/cli/secret-args.ts +149 -0
- package/src/cli/settings-result.ts +30 -0
- package/src/cli/storage.ts +77 -7
- package/src/cli/system-command.ts +60 -2
- package/src/cli/system-restart-client.ts +20 -3
- package/src/cli/system-settings-parity.ts +83 -0
- package/src/cli/tray-proxy.ts +3 -2
- package/src/cli/update-restart-candidate.ts +21 -0
- package/src/cli/update-restart-child.ts +81 -0
- package/src/cli/update-restart-home.ts +38 -0
- package/src/cli/update-restart-transport.ts +193 -0
- package/src/cli/update-restart.ts +260 -0
- package/src/cli/usage-model-search.ts +58 -0
- package/src/cli/usage-report.ts +13 -5
- package/src/cli/v2-input.ts +66 -0
- package/src/cli/v2-local-output.ts +37 -0
- package/src/cli/v2-runtime.ts +90 -0
- package/src/cli/v2.ts +102 -144
- package/src/clients/config-export/contracts.ts +100 -0
- package/src/clients/config-export/droid.ts +54 -2
- package/src/clients/config-export/dsh.ts +21 -0
- package/src/clients/config-export/kilo.ts +12 -2
- package/src/clients/config-export/model-metadata.ts +10 -4
- package/src/clients/config-export/reasoning-metadata.ts +47 -0
- package/src/clients/config-export.ts +53 -67
- package/src/clients/omo-role-models.ts +17 -3
- package/src/codex/account-credit-use.ts +4 -2
- package/src/codex/account-runtime-state.ts +21 -0
- package/src/codex/account-store.ts +11 -47
- package/src/codex/account-usability.ts +11 -8
- package/src/codex/auth-api/account-list.ts +73 -63
- package/src/codex/auth-api/main-account-probe.ts +25 -12
- package/src/codex/auth-api/pool-mode-gate.ts +6 -9
- package/src/codex/auth-api/pool-quota-probe.ts +2 -4
- package/src/codex/auth-api/reset-credit-service.ts +3 -2
- package/src/codex/auth-context.ts +30 -11
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/build-entries.ts +1 -1
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/heal-observation.ts +66 -0
- package/src/codex/catalog/model-hints.ts +2 -1
- package/src/codex/catalog/model-visibility.ts +6 -3
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-models.ts +13 -4
- package/src/codex/catalog/publication-observer.ts +19 -0
- package/src/codex/catalog/remote.ts +13 -15
- package/src/codex/catalog/restore.ts +19 -6
- package/src/codex/catalog/retained-sync.ts +60 -11
- package/src/codex/catalog/routed-removal.ts +116 -0
- package/src/codex/catalog/write-audit-contract.ts +21 -0
- package/src/codex/catalog/write-audit.ts +171 -0
- package/src/codex/catalog-self-heal.ts +284 -0
- package/src/codex/catalog-write-serialization.ts +71 -7
- package/src/codex/chatgpt-refresh-failure.ts +56 -0
- package/src/codex/codex-home-owner.ts +124 -0
- package/src/codex/convergence.ts +57 -16
- package/src/codex/features.ts +8 -1
- package/src/codex/inject/restore.ts +46 -4
- package/src/codex/inject.ts +11 -1
- package/src/codex/internal/catalog-writer.ts +120 -13
- package/src/codex/journal.ts +49 -13
- package/src/codex/main-account.ts +144 -11
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/quota-refresh-outcome.ts +17 -2
- package/src/codex/quota-types.ts +36 -1
- package/src/codex/quota.ts +67 -16
- package/src/codex/refresh.ts +20 -9
- package/src/codex/routing/cooldown-math.ts +10 -4
- package/src/codex/routing/selection.ts +11 -4
- package/src/codex/routing.ts +9 -9
- package/src/codex/runtime.ts +7 -1
- package/src/codex/shim-diagnostics.ts +80 -0
- package/src/codex/shim-fingerprint.ts +7 -2
- package/src/codex/shim-migration.ts +153 -0
- package/src/codex/shim-overlay.ts +509 -0
- package/src/codex/shim-path-resolution.ts +145 -0
- package/src/codex/shim-probe.ts +4 -3
- package/src/codex/shim-state-file.ts +93 -7
- package/src/codex/shim.ts +54 -107
- package/src/codex/subagent-model-fallback.ts +2 -1
- package/src/codex/sync.ts +24 -4
- package/src/combos/failover.ts +101 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/jev.ts +13 -3
- package/src/combos/resolve.ts +12 -0
- package/src/config/atomic-write.ts +13 -8
- package/src/config/diagnostics.ts +23 -1
- package/src/config/load-degrade.ts +7 -0
- package/src/config/provider-validation.ts +3 -0
- package/src/config/schema/chatgpt-desktop.ts +22 -0
- package/src/config/schema/compaction-triggers.ts +8 -0
- package/src/config/schema/config-schema.ts +43 -14
- package/src/config/schema/leaf-validators.ts +8 -3
- package/src/config/subagent-models.ts +12 -0
- package/src/generated/compatibility-version.json +645 -237
- package/src/grok/session-identity.ts +33 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/merge.ts +13 -5
- package/src/integrations/mutation-plan.ts +50 -5
- package/src/integrations/omp-yaml-source.ts +181 -1
- package/src/integrations/ownership-policy.ts +66 -0
- package/src/integrations/registry.ts +50 -1
- package/src/integrations/state.ts +106 -19
- package/src/integrations/target.ts +25 -2
- package/src/integrations/writer.ts +107 -35
- package/src/lib/gui-pair-capability.ts +17 -0
- package/src/lib/package-tree-integrity.ts +42 -1
- package/src/lib/provider-client-headers.ts +62 -0
- package/src/lib/proxy-env.ts +3 -0
- package/src/lib/test-home-guard.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +19 -8
- package/src/oauth/anthropic-continuity.ts +65 -0
- package/src/oauth/anthropic-identity.ts +75 -0
- package/src/oauth/anthropic-routing.ts +20 -15
- package/src/oauth/anthropic.ts +34 -3
- package/src/oauth/generic-account-failover.ts +21 -0
- package/src/oauth/health.ts +32 -5
- package/src/oauth/index.ts +42 -35
- package/src/oauth/local-token-detect.ts +7 -0
- package/src/oauth/pool-settings-capability.ts +9 -2
- package/src/oauth/refresh-policy.ts +2 -0
- package/src/oauth/store.ts +55 -6
- package/src/oauth/types.ts +9 -0
- package/src/protocols/encoders/messages.ts +4 -3
- package/src/protocols/plan-snapshot.ts +1 -1
- package/src/protocols/settings.ts +28 -4
- package/src/providers/antigravity-effort-families.ts +75 -0
- package/src/providers/antigravity-models.ts +54 -16
- package/src/providers/antigravity-wire-snapshot.ts +66 -0
- package/src/providers/derive.ts +2 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/new-model-policy.ts +48 -10
- package/src/providers/registry/entries-extended.ts +3 -0
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/model-seeds.ts +5 -0
- package/src/providers/registry/types.ts +4 -0
- package/src/responses/compaction.ts +19 -0
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +9 -0
- package/src/server/caller-session-identity.ts +32 -0
- package/src/server/chat-completions.ts +10 -0
- package/src/server/claude-messages.ts +29 -15
- package/src/server/droid-reasoning-default.ts +50 -0
- package/src/server/github-copilot-responses-repair.ts +3 -6
- package/src/server/grok-responses-control-frame.ts +2 -2
- package/src/server/grok-responses-snapshot-repair.ts +4 -5
- package/src/server/gui-session.ts +33 -11
- package/src/server/index/claude-intercept-lifecycle.ts +4 -0
- package/src/server/index/serve-options.ts +13 -7
- package/src/server/index.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +12 -134
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +8 -1
- package/src/server/management/cursor-integration-routes.ts +3 -3
- package/src/server/management/integration-routes.ts +69 -11
- package/src/server/management/link-routes.ts +20 -13
- package/src/server/management/model-routes.ts +3 -3
- package/src/server/management/model-row-export-metadata.ts +150 -0
- package/src/server/management/model-rows.ts +148 -9
- package/src/server/management/native-integration-routes.ts +2 -2
- package/src/server/management/oauth-account-routes.ts +96 -30
- package/src/server/management/route-registry.ts +29 -27
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/subagent-model-routes.ts +214 -0
- package/src/server/messages-native-eligibility.ts +3 -6
- package/src/server/messages-native-oauth.ts +82 -35
- package/src/server/messages-native.ts +78 -16
- package/src/server/relay.ts +14 -44
- package/src/server/request-log-generation-window.ts +38 -0
- package/src/server/request-log.ts +13 -0
- package/src/server/responses/adapter-dispatch.ts +152 -28
- package/src/server/responses/codex-auth-error.ts +10 -2
- package/src/server/responses/combo-stream-preflight.ts +29 -3
- package/src/server/responses/compaction-routing.ts +10 -1
- package/src/server/responses/core-auth.ts +67 -30
- package/src/server/responses/core-codex-account.ts +6 -3
- package/src/server/responses/core-combo-failure.ts +5 -1
- package/src/server/responses/core-combo-native.ts +7 -1
- package/src/server/responses/core-combo.ts +64 -7
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/fetch-helpers.ts +9 -0
- package/src/server/responses/passthrough-delivery.ts +1 -1
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/server/responses/request-prepare.ts +5 -0
- package/src/server/responses/request-spend.ts +30 -30
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/responses-hosted-image-display.ts +70 -9
- package/src/server/responses-terminal-repair.ts +15 -2
- package/src/server/sse-frame-buffer.ts +38 -36
- package/src/server/sse-payload-rewrite.ts +78 -49
- package/src/server/ws-bridge.ts +1 -11
- package/src/service/cli.ts +6 -1
- package/src/service/ownership-mutation-lease.d.mts +26 -0
- package/src/service/ownership-mutation-lease.mjs +76 -1
- package/src/service/windows-taskxml.ts +1 -2
- package/src/service-manager-probe.ts +36 -2
- package/src/types/config.ts +6 -0
- package/src/types/provider.ts +28 -10
- package/src/update/index.ts +2 -1
- package/src/update/npm-invocation.mjs +35 -3
- package/src/update/proxy-liveness-probe.mjs +38 -8
- package/src/update/update-failure-guidance.d.mts +6 -0
- package/src/update/update-failure-guidance.mjs +30 -5
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +7 -0
- package/src/usage/summary.ts +82 -1
- package/src/web-search/loop.ts +11 -8
- package/src/web-search/run-turn-loop.ts +10 -7
- package/gui/dist/assets/App-CddorHdA.js +0 -51
- package/gui/dist/assets/App-DMudtISW.css +0 -1
- package/gui/dist/assets/index-Btze1I3T.css +0 -1
- package/gui/dist/assets/index-CvnY7E6i.js +0 -86
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
|
@@ -5,12 +5,21 @@ import {
|
|
|
5
5
|
AtomicWriteResidualTempError,
|
|
6
6
|
AtomicWriteSecretResidualError,
|
|
7
7
|
atomicWriteFile,
|
|
8
|
-
getConfigDir,
|
|
9
8
|
resolveWriteTarget,
|
|
10
9
|
type AtomicWriteIO,
|
|
11
|
-
} from "../../config";
|
|
10
|
+
} from "../../config/atomic-write";
|
|
11
|
+
import { getConfigDir } from "../../config/paths";
|
|
12
|
+
import { readConfigAdmissionSnapshot } from "../../config/diagnostics";
|
|
13
|
+
import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config/mutation-lock";
|
|
14
|
+
import { codexCatalogAuditPath } from "../catalog/write-audit";
|
|
15
|
+
import type { CatalogAuditConfigSource, CatalogAuditRefusalReason, CatalogWriteAuditDetails } from "../catalog/write-audit-contract";
|
|
16
|
+
import { ocxRoutedRowCount } from "../catalog/routed-removal";
|
|
17
|
+
import { notifyCatalogPublication } from "../catalog/publication-observer";
|
|
12
18
|
import {
|
|
13
19
|
assertCatalogWritePermit,
|
|
20
|
+
auditCatalogWriteWithPermit,
|
|
21
|
+
catalogWritePermitContext,
|
|
22
|
+
CatalogWritePermitRefusal,
|
|
14
23
|
type CatalogWritePermit,
|
|
15
24
|
} from "../catalog-write-serialization";
|
|
16
25
|
import {
|
|
@@ -61,14 +70,15 @@ let backupTempSequence = 0;
|
|
|
61
70
|
* An unreadable or absent file reports "differs", so the caller performs the real
|
|
62
71
|
* write; that also converges a file that does not exist yet.
|
|
63
72
|
*/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
function readExistingCatalogBytes(path: string): Buffer | null {
|
|
74
|
+
try { return readFileSync(path); } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function preparedBytesDifferFromDisk(
|
|
78
|
+
prepared: PreparedCatalogFileWrite,
|
|
79
|
+
onDisk: Buffer | null = readExistingCatalogBytes(prepared.path),
|
|
80
|
+
): boolean {
|
|
81
|
+
return onDisk === null || !onDisk.equals(Buffer.from(prepared.content, "utf8"));
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
function isMissingPathError(error: unknown): boolean {
|
|
@@ -199,16 +209,105 @@ function publishCatalogBackup(
|
|
|
199
209
|
return "written";
|
|
200
210
|
}
|
|
201
211
|
|
|
202
|
-
|
|
212
|
+
export type CatalogFileReplacement =
|
|
213
|
+
| { readonly kind: "written" }
|
|
214
|
+
| { readonly kind: "unchanged" }
|
|
215
|
+
| { readonly kind: "refused"; readonly reason: "unbacked-routed-clear" };
|
|
216
|
+
|
|
217
|
+
function routedRowsFromJson(content: string): number | null {
|
|
218
|
+
try { return ocxRoutedRowCount(JSON.parse(content)); } catch { return null; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function configSourceNow(): CatalogAuditConfigSource {
|
|
222
|
+
try {
|
|
223
|
+
const snapshot = readConfigAdmissionSnapshot();
|
|
224
|
+
return snapshot.kind === "read" ? snapshot.diagnostics.source
|
|
225
|
+
: snapshot.diagnostics.source === "default" ? "default" : "unreadable";
|
|
226
|
+
} catch { return "unreadable"; }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function routedRowsOnDisk(path: string): number | null {
|
|
230
|
+
try { return routedRowsFromJson(readFileSync(path, "utf8")); } catch { return null; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function auditWrite(
|
|
234
|
+
permit: CatalogWritePermit,
|
|
235
|
+
home: string,
|
|
236
|
+
details: CatalogWriteAuditDetails,
|
|
237
|
+
register: boolean,
|
|
238
|
+
): void {
|
|
239
|
+
try {
|
|
240
|
+
if (auditCatalogWriteWithPermit(permit, home, details) !== "created"
|
|
241
|
+
|| !register || details.configSource !== "file") return;
|
|
242
|
+
// The manifest rejects external CODEX_HOME paths. Such audit files remain uninstall
|
|
243
|
+
// residuals; diagnostic creation does not widen config ownership or claim cleanup.
|
|
244
|
+
if (!recordOwnedConfigPath(getConfigDir(), codexCatalogAuditPath(home))) return;
|
|
245
|
+
} catch { /* Audit and registration failures cannot change the publication outcome. */ }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Audit an actual refusal before the replacement funnel. Caller must avoid a second funnel event. */
|
|
249
|
+
export function auditRefusedCatalogReplacement(
|
|
250
|
+
permit: CatalogWritePermit,
|
|
251
|
+
home: string,
|
|
252
|
+
prepared: PreparedCatalogFileWrite,
|
|
253
|
+
reason: CatalogAuditRefusalReason,
|
|
254
|
+
): void {
|
|
255
|
+
assertCatalogWritePermit(permit, home);
|
|
256
|
+
auditWrite(permit, home, {
|
|
257
|
+
target: "catalog", outcome: "refused", reason,
|
|
258
|
+
routedBefore: routedRowsOnDisk(prepared.path), routedAfter: routedRowsFromJson(prepared.content),
|
|
259
|
+
configSource: configSourceNow(),
|
|
260
|
+
}, true);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** One funnel for exact-byte idempotence and intent admission, under a live K permit. */
|
|
203
264
|
export function replaceActiveCodexCatalog(
|
|
204
265
|
permit: CatalogWritePermit,
|
|
205
266
|
owningCodexHome: string,
|
|
206
267
|
prepared: PreparedCatalogFileWrite,
|
|
207
268
|
io?: AtomicWriteIO,
|
|
208
|
-
):
|
|
269
|
+
): CatalogFileReplacement {
|
|
209
270
|
assertCatalogWritePermit(permit, owningCodexHome);
|
|
271
|
+
const { intent } = catalogWritePermitContext(permit);
|
|
272
|
+
if (intent === "cache") {
|
|
273
|
+
throw new CatalogWritePermitRefusal("A models-cache permit cannot replace the Codex catalog.");
|
|
274
|
+
}
|
|
275
|
+
const onDisk = readExistingCatalogBytes(prepared.path);
|
|
276
|
+
if (!preparedBytesDifferFromDisk(prepared, onDisk)) {
|
|
277
|
+
notifyCatalogPublication({ kind: "published", path: prepared.path, intent });
|
|
278
|
+
return { kind: "unchanged" };
|
|
279
|
+
}
|
|
280
|
+
const routedBefore = onDisk === null ? null : routedRowsFromJson(onDisk.toString("utf8"));
|
|
281
|
+
const routedAfter = routedRowsFromJson(prepared.content);
|
|
282
|
+
const finish = (result: CatalogFileReplacement): CatalogFileReplacement => {
|
|
283
|
+
if (result.kind !== "unchanged") auditWrite(permit, owningCodexHome, {
|
|
284
|
+
target: "catalog", outcome: result.kind,
|
|
285
|
+
reason: result.kind === "refused" ? result.reason : undefined,
|
|
286
|
+
routedBefore, routedAfter, configSource: configSourceNow(),
|
|
287
|
+
}, io === undefined);
|
|
288
|
+
if (result.kind !== "refused") notifyCatalogPublication({ kind: "published", path: prepared.path, intent });
|
|
289
|
+
return result;
|
|
290
|
+
};
|
|
291
|
+
if (intent === "refresh" && (routedBefore ?? 0) > 0 && routedAfter === 0) {
|
|
292
|
+
// K -> C, including the replacement: a config save cannot race the authority check.
|
|
293
|
+
try {
|
|
294
|
+
return finish(withConfigMutationLockSync(() => {
|
|
295
|
+
const snapshot = readConfigAdmissionSnapshot();
|
|
296
|
+
if (snapshot.kind !== "read" || snapshot.diagnostics.source !== "file") {
|
|
297
|
+
return { kind: "refused", reason: "unbacked-routed-clear" } as const;
|
|
298
|
+
}
|
|
299
|
+
atomicWriteFile(prepared.path, prepared.content, io);
|
|
300
|
+
resetCodexAppServerCatalogStateCache();
|
|
301
|
+
return { kind: "written" } as const;
|
|
302
|
+
}));
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof ConfigMutationLockError) return finish({ kind: "refused", reason: "unbacked-routed-clear" });
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
210
308
|
atomicWriteFile(prepared.path, prepared.content, io);
|
|
211
309
|
resetCodexAppServerCatalogStateCache();
|
|
310
|
+
return finish({ kind: "written" });
|
|
212
311
|
}
|
|
213
312
|
|
|
214
313
|
/** Atomically publish the catalog-path-keyed immutable backup without clobbering. */
|
|
@@ -242,8 +341,16 @@ export function replaceCodexModelsCache(
|
|
|
242
341
|
owningCodexHome: string,
|
|
243
342
|
prepared: PreparedCatalogFileWrite,
|
|
244
343
|
io?: AtomicWriteIO,
|
|
245
|
-
):
|
|
344
|
+
): Exclude<CatalogFileReplacement, { kind: "refused" }> {
|
|
246
345
|
assertCatalogWritePermit(permit, owningCodexHome);
|
|
346
|
+
catalogWritePermitContext(permit);
|
|
347
|
+
if (!preparedBytesDifferFromDisk(prepared)) return { kind: "unchanged" };
|
|
348
|
+
const routedBefore = routedRowsOnDisk(prepared.path);
|
|
247
349
|
atomicWriteFile(prepared.path, prepared.content, io);
|
|
248
350
|
resetCodexAppServerCatalogStateCache();
|
|
351
|
+
auditWrite(permit, owningCodexHome, {
|
|
352
|
+
target: "cache", outcome: "written", routedBefore,
|
|
353
|
+
routedAfter: routedRowsFromJson(prepared.content), configSource: configSourceNow(),
|
|
354
|
+
}, io === undefined);
|
|
355
|
+
return { kind: "written" };
|
|
249
356
|
}
|
package/src/codex/journal.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { atomicWriteFile } from "../config";
|
|
5
|
+
import { assertCodexHomeOwner, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
|
|
5
6
|
import { hasInjectedCodexRouting } from "./injected-marker";
|
|
6
7
|
import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
|
|
7
8
|
|
|
@@ -14,7 +15,7 @@ import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
|
|
|
14
15
|
* agreed with the producer, and the pair stayed green. One exported constant
|
|
15
16
|
* removes the opportunity.
|
|
16
17
|
*/
|
|
17
|
-
export const JOURNAL_PATH = join(CODEX_HOME,
|
|
18
|
+
export const JOURNAL_PATH = join(CODEX_HOME, CODEX_HOME_JOURNAL_FILE);
|
|
18
19
|
|
|
19
20
|
export type JournalOwner =
|
|
20
21
|
| { kind: "process"; pid: number }
|
|
@@ -71,12 +72,17 @@ interface Journal {
|
|
|
71
72
|
* file we actually touched.
|
|
72
73
|
*/
|
|
73
74
|
injectedCatalogPath?: string | null;
|
|
75
|
+
/** Injection binding survives native snapshot replacement and is released by native restore. */
|
|
76
|
+
opencodexHome?: string;
|
|
74
77
|
pid: number;
|
|
75
78
|
owner?: JournalOwner;
|
|
76
79
|
timestamp: string;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
export interface RestoreJournalResult {
|
|
83
|
+
/** An unchanged generated profile could not be restored; distinct from a preserved user edit. */
|
|
84
|
+
profileRestoreFailed?: true;
|
|
85
|
+
ownershipRefusal?: CodexHomeOwnerRefusalReason;
|
|
80
86
|
configRestored: boolean;
|
|
81
87
|
profileRestored: boolean;
|
|
82
88
|
configChanged: boolean;
|
|
@@ -146,6 +152,7 @@ export interface WriteJournalOptions {
|
|
|
146
152
|
* plugins, model choice, and trusted projects.
|
|
147
153
|
*/
|
|
148
154
|
export function writeJournal(options: WriteJournalOptions = {}): void {
|
|
155
|
+
assertCodexHomeOwner(CODEX_HOME);
|
|
149
156
|
if (!existsSync(CODEX_CONFIG_PATH)) return;
|
|
150
157
|
const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
151
158
|
// Ownership is decided HERE, from the bytes about to be journaled — never taken
|
|
@@ -155,7 +162,12 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
|
|
|
155
162
|
// The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than
|
|
156
163
|
// the check above (it may describe bytes read a moment earlier), so an
|
|
157
164
|
// unclassified call creates a first snapshot but never overwrites one.
|
|
158
|
-
|
|
165
|
+
const inspection = readCodexHomeJournal(JOURNAL_PATH);
|
|
166
|
+
if (inspection.kind === "unknown") {
|
|
167
|
+
throw new CodexHomeOwnerRefusal("owner-unknown");
|
|
168
|
+
}
|
|
169
|
+
const previous = inspection.kind === "read" ? inspection.journal as unknown as Journal : null;
|
|
170
|
+
if (previous && options.currentStateIsNative !== true) return;
|
|
159
171
|
const profile = existsSync(CODEX_PROFILE_PATH)
|
|
160
172
|
? readFileSync(CODEX_PROFILE_PATH, "utf-8")
|
|
161
173
|
: null;
|
|
@@ -163,6 +175,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
|
|
|
163
175
|
version: 1,
|
|
164
176
|
originalConfig: Buffer.from(config).toString("base64"),
|
|
165
177
|
originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null,
|
|
178
|
+
opencodexHome: opencodexHomeForInjection(previous?.opencodexHome),
|
|
166
179
|
pid: process.pid,
|
|
167
180
|
owner: options.owner?.kind === "client"
|
|
168
181
|
? { kind: "client", apiKeyId: options.owner.apiKeyId }
|
|
@@ -187,7 +200,10 @@ export function markJournalInjectedState(
|
|
|
187
200
|
profile: string | null,
|
|
188
201
|
ownership: InjectedJournalOwnership,
|
|
189
202
|
): void {
|
|
190
|
-
|
|
203
|
+
assertCodexHomeOwner(CODEX_HOME);
|
|
204
|
+
const inspection = readCodexHomeJournal(JOURNAL_PATH);
|
|
205
|
+
if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown");
|
|
206
|
+
const journal = inspection.kind === "read" ? inspection.journal as unknown as Journal : null;
|
|
191
207
|
if (!journal) return;
|
|
192
208
|
// The first exact injected config is the only safe whole-snapshot restore boundary for
|
|
193
209
|
// the first native snapshot. A later reinjection may preserve user edits made while routed;
|
|
@@ -204,6 +220,7 @@ export function markJournalInjectedState(
|
|
|
204
220
|
journal.injectedRootWebSearch = ownership.injectedRootWebSearch ?? null;
|
|
205
221
|
journal.replacedRootWebSearch = ownership.replacedRootWebSearch ?? null;
|
|
206
222
|
journal.injectedCatalogPath = ownership.injectedCatalogPath;
|
|
223
|
+
journal.opencodexHome = opencodexHomeForInjection(journal.opencodexHome);
|
|
207
224
|
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
208
225
|
}
|
|
209
226
|
|
|
@@ -240,19 +257,24 @@ export function journaledInjectedCatalogPath(): string | null {
|
|
|
240
257
|
}
|
|
241
258
|
|
|
242
259
|
export function removeJournal(): void {
|
|
260
|
+
assertCodexHomeOwner(CODEX_HOME);
|
|
243
261
|
try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ }
|
|
244
262
|
}
|
|
245
263
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
264
|
+
/** Successful field-level native restore releases ownership while retaining recovery evidence. */
|
|
265
|
+
export function releaseJournalHomeBinding(): void {
|
|
266
|
+
assertCodexHomeOwner(CODEX_HOME);
|
|
267
|
+
const inspection = readCodexHomeJournal(JOURNAL_PATH);
|
|
268
|
+
if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown");
|
|
269
|
+
if (inspection.kind === "missing" || inspection.journal.opencodexHome === undefined) return;
|
|
270
|
+
delete inspection.journal.opencodexHome;
|
|
271
|
+
atomicWriteFile(JOURNAL_PATH, JSON.stringify(inspection.journal));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Kept compatible with existing read-only callers; invalid evidence is never cleanup authority.
|
|
275
|
+
function readJournal(_cleanInvalid = true): Journal | null {
|
|
276
|
+
const inspected = readCodexHomeJournal(JOURNAL_PATH);
|
|
277
|
+
return inspected.kind === "read" ? inspected.journal as unknown as Journal : null;
|
|
256
278
|
}
|
|
257
279
|
|
|
258
280
|
/**
|
|
@@ -274,6 +296,12 @@ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner
|
|
|
274
296
|
}
|
|
275
297
|
|
|
276
298
|
export function restoreJournalState(): RestoreJournalResult {
|
|
299
|
+
try { assertCodexHomeOwner(CODEX_HOME); }
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
|
|
302
|
+
return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false,
|
|
303
|
+
complete: false, unverified: true, ownershipRefusal: error.reason };
|
|
304
|
+
}
|
|
277
305
|
const journal = readJournal();
|
|
278
306
|
if (!journal) {
|
|
279
307
|
return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false };
|
|
@@ -295,6 +323,7 @@ export function restoreJournalState(): RestoreJournalResult {
|
|
|
295
323
|
};
|
|
296
324
|
}
|
|
297
325
|
|
|
326
|
+
assertCodexHomeOwner(CODEX_HOME);
|
|
298
327
|
let configRestored = comparison.configAlreadyOriginal;
|
|
299
328
|
let profileRestored = comparison.profileAlreadyOriginal;
|
|
300
329
|
if (configUnchanged && !configRestored) {
|
|
@@ -330,6 +359,7 @@ export function restoreJournalState(): RestoreJournalResult {
|
|
|
330
359
|
profileChanged: !profileUnchanged,
|
|
331
360
|
complete,
|
|
332
361
|
unverified: false,
|
|
362
|
+
...(profileUnchanged && !profileRestored ? { profileRestoreFailed: true as const } : {}),
|
|
333
363
|
};
|
|
334
364
|
}
|
|
335
365
|
|
|
@@ -342,6 +372,12 @@ export interface ReconcileJournalOptions {
|
|
|
342
372
|
}
|
|
343
373
|
|
|
344
374
|
export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean {
|
|
375
|
+
try { assertCodexHomeOwner(CODEX_HOME); }
|
|
376
|
+
catch (error) {
|
|
377
|
+
if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
|
|
378
|
+
console.error(error.message);
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
345
381
|
const journal = readJournal();
|
|
346
382
|
if (!journal) return false;
|
|
347
383
|
const owner = journalOwner();
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure";
|
|
2
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
1
3
|
import { createHash } from "node:crypto";
|
|
2
4
|
import { readFileSync, statSync } from "node:fs";
|
|
3
5
|
import { join } from "node:path";
|
|
4
6
|
import { readCodexTokens } from "./auth-collision";
|
|
5
7
|
import {
|
|
8
|
+
CHATGPT_CLIENT_ID,
|
|
9
|
+
CHATGPT_TOKEN_URL,
|
|
10
|
+
credsFromToken,
|
|
6
11
|
decodeJwtPayload,
|
|
7
12
|
extractAccountId,
|
|
8
|
-
refreshChatGPTToken,
|
|
9
13
|
} from "../oauth/chatgpt";
|
|
10
14
|
import type { OAuthCredentials } from "../oauth/types";
|
|
11
15
|
import { extractChatgptPlanType } from "./plan";
|
|
@@ -17,7 +21,12 @@ import {
|
|
|
17
21
|
import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write";
|
|
18
22
|
import { resolveCodexHomeDir } from "./home";
|
|
19
23
|
import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
|
|
20
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
clearAccountNeedsReauth,
|
|
26
|
+
isMainRefreshGrantRejected,
|
|
27
|
+
markMainRefreshGrantRejected,
|
|
28
|
+
clearMainRefreshGrantRejection,
|
|
29
|
+
} from "./account-runtime-state";
|
|
21
30
|
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
22
31
|
import { withNativeMainExclusiveClaim } from "./native-main-claim";
|
|
23
32
|
import { resolveNativeProfileContext } from "./native-profile-store";
|
|
@@ -78,6 +87,68 @@ export class MainAccountTokenRefreshError extends Error {
|
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The native-main refresh, posted here rather than through `refreshChatGPTToken` so the token
|
|
92
|
+
* endpoint's structured `error` code survives to {@link classifyChatgptRefreshFailure}.
|
|
93
|
+
*
|
|
94
|
+
* `refreshChatGPTToken` collapses the refusal into one display string, and classifying a grant
|
|
95
|
+
* from that string is what let `refresh_token_reused` pass as transient -- the grant never
|
|
96
|
+
* retired, every request re-earning its own 401 -- while a 5xx whose description merely read
|
|
97
|
+
* "session expired" passed as proof and would have retired a live grant.
|
|
98
|
+
*
|
|
99
|
+
* Same endpoint, client id and grant type as every other ChatGPT refresh, and the success path
|
|
100
|
+
* is `credsFromToken` itself, so only the failure branch differs: it classifies by code, exactly
|
|
101
|
+
* as the stored-pool refresh does.
|
|
102
|
+
*/
|
|
103
|
+
async function refreshNativeMainGrant(
|
|
104
|
+
refreshToken: string,
|
|
105
|
+
options: { signal: AbortSignal },
|
|
106
|
+
): Promise<OAuthCredentials> {
|
|
107
|
+
const resp = await fetch(CHATGPT_TOKEN_URL, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
110
|
+
body: new URLSearchParams({
|
|
111
|
+
grant_type: "refresh_token",
|
|
112
|
+
client_id: CHATGPT_CLIENT_ID,
|
|
113
|
+
refresh_token: refreshToken,
|
|
114
|
+
}).toString(),
|
|
115
|
+
signal: options.signal,
|
|
116
|
+
});
|
|
117
|
+
if (!resp.ok) {
|
|
118
|
+
const body = await readBoundedResponseBody(resp, { signal: options.signal, maxBytes: 16_384, fatalUtf8: true })
|
|
119
|
+
.catch(() => undefined);
|
|
120
|
+
const failure = classifyChatgptRefreshFailure(resp.status, body?.displaySafe ? body.text : "");
|
|
121
|
+
noteChatgptRefreshFailure("native main", resp.status, failure);
|
|
122
|
+
throw new MainAccountTokenRefreshError(failure.reason === "unknown" ? "transient" : "reauth");
|
|
123
|
+
}
|
|
124
|
+
let credentials: OAuthCredentials;
|
|
125
|
+
try {
|
|
126
|
+
credentials = credsFromToken((await resp.json()) as Record<string, unknown>);
|
|
127
|
+
} catch {
|
|
128
|
+
if (options.signal.aborted) throw options.signal.reason;
|
|
129
|
+
noteNativeMainRefresh("transient", resp.status);
|
|
130
|
+
// Parsing errors may include provider body text; retain only the fixed verdict.
|
|
131
|
+
throw new MainAccountTokenRefreshError("transient");
|
|
132
|
+
}
|
|
133
|
+
noteNativeMainRefresh("ok", resp.status);
|
|
134
|
+
return credentials;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One line per native-main refresh verdict, so a dead grant is diagnosable from service.log.
|
|
139
|
+
*
|
|
140
|
+
* A revoked session is invisible from the outside: the grant is refused, nothing is written, and
|
|
141
|
+
* the request fails with a status that looks like every other 401. Carries the token endpoint's
|
|
142
|
+
* HTTP status and its structured `error` code and nothing else -- never a token, never a body.
|
|
143
|
+
*/
|
|
144
|
+
function noteNativeMainRefresh(outcome: string, status?: number, code?: string): void {
|
|
145
|
+
console.warn(
|
|
146
|
+
`[codex] native main refresh: ${outcome}`
|
|
147
|
+
+ (status === undefined ? "" : ` status=${status}`)
|
|
148
|
+
+ ` code=${code && /^[A-Za-z0-9._-]{1,64}$/.test(code) ? code : "none"}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
81
152
|
function nonEmptyString(value: unknown): string | undefined {
|
|
82
153
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
83
154
|
}
|
|
@@ -147,13 +218,45 @@ function mainAccessTokenFresh(accessToken: string | undefined, now: number, skew
|
|
|
147
218
|
}
|
|
148
219
|
|
|
149
220
|
/** A refresh grant makes native main routeable even when the current access token is expired. */
|
|
150
|
-
|
|
221
|
+
function mainRefreshGrantKey(current: MainAuthJsonCredential): string | undefined {
|
|
222
|
+
return current.refreshToken
|
|
223
|
+
? `${current.path}:${refreshGrantFingerprintForToken(current.refreshToken)}` : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function credentialRefreshGrantRejected(current: MainAuthJsonCredential | null): boolean {
|
|
227
|
+
const key = current && mainRefreshGrantKey(current);
|
|
228
|
+
return !!key && isMainRefreshGrantRejected(key);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface MainAccountCredentialStatus {
|
|
232
|
+
refreshGrantRejected: boolean;
|
|
233
|
+
hasRefreshGrant: boolean;
|
|
234
|
+
usable: boolean;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One owned physical observation; no credential material escapes this view. */
|
|
238
|
+
export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCredentialStatus {
|
|
151
239
|
const current = readMainAuthJsonCredential();
|
|
152
|
-
|
|
240
|
+
const refreshGrantRejected = credentialRefreshGrantRejected(current);
|
|
241
|
+
const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected;
|
|
242
|
+
return {
|
|
243
|
+
refreshGrantRejected,
|
|
244
|
+
hasRefreshGrant,
|
|
245
|
+
usable: !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0)),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Current owned profile only; callers must hold the native read/selection permission. */
|
|
250
|
+
export function isMainAccountRefreshGrantRejected(): boolean {
|
|
251
|
+
return getMainAccountCredentialStatus().refreshGrantRejected;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function isMainAccountCredentialUsable(now = Date.now()): boolean {
|
|
255
|
+
return getMainAccountCredentialStatus(now).usable;
|
|
153
256
|
}
|
|
154
257
|
|
|
155
258
|
export function hasMainAccountRefreshGrant(): boolean {
|
|
156
|
-
return
|
|
259
|
+
return getMainAccountCredentialStatus().hasRefreshGrant;
|
|
157
260
|
}
|
|
158
261
|
|
|
159
262
|
function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential): void {
|
|
@@ -342,14 +445,26 @@ async function resolveMainAccountToken(
|
|
|
342
445
|
dependencies: NativeMainRefreshDependencies = {},
|
|
343
446
|
rejectedAccessToken?: string,
|
|
344
447
|
): Promise<{ accessToken: string; chatgptAccountId: string } | null> {
|
|
448
|
+
if (dependencies.signal?.aborted) throw dependencies.signal.reason;
|
|
345
449
|
const initial = readMainAuthJsonCredential();
|
|
346
|
-
|
|
450
|
+
// Every skip below ends a forced refresh WITHOUT asking the token endpoint anything, which from
|
|
451
|
+
// the outside is indistinguishable from a refusal: same failed request, no write, no verdict.
|
|
452
|
+
// A forced refresh is the only caller that passes `rejectedAccessToken`, so only it reports.
|
|
453
|
+
if (!initial) {
|
|
454
|
+
if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential");
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
if (credentialRefreshGrantRejected(initial)) throw new MainAccountTokenRefreshError("reauth");
|
|
347
458
|
const now = Date.now();
|
|
348
459
|
if (initial.accessToken !== rejectedAccessToken
|
|
349
460
|
&& mainAccessTokenFresh(initial.accessToken, now, MAIN_TOKEN_REFRESH_SKEW_MS)) {
|
|
461
|
+
// The stored credential is no longer the one upstream rejected: somebody else replaced it
|
|
462
|
+
// while this request was in flight, so there is nothing here to refresh or to retire.
|
|
463
|
+
if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-credential-replaced");
|
|
350
464
|
return { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId };
|
|
351
465
|
}
|
|
352
466
|
if (!initial.refreshToken) {
|
|
467
|
+
if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-refresh-grant");
|
|
353
468
|
return initial.accessToken !== rejectedAccessToken
|
|
354
469
|
&& mainAccessTokenFresh(initial.accessToken, now, 0)
|
|
355
470
|
? { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId }
|
|
@@ -380,6 +495,7 @@ async function resolveMainAccountToken(
|
|
|
380
495
|
() => withCodexRefreshFileLock(lockKey, signal, async () => {
|
|
381
496
|
const locked = readMainAuthJsonCredential();
|
|
382
497
|
if (!locked) throw new MainAuthJsonChangedDuringRefreshError();
|
|
498
|
+
if (credentialRefreshGrantRejected(locked)) throw new MainAccountTokenRefreshError("reauth");
|
|
383
499
|
if (!locked.refreshToken
|
|
384
500
|
|| refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) {
|
|
385
501
|
if (locked.accessToken !== rejectedAccessToken
|
|
@@ -392,16 +508,31 @@ async function resolveMainAccountToken(
|
|
|
392
508
|
&& mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
|
|
393
509
|
return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
|
|
394
510
|
}
|
|
395
|
-
const refresh = dependencies.refreshToken
|
|
396
|
-
?? ((refreshToken: string, options: { signal: AbortSignal }) => refreshChatGPTToken(refreshToken, options));
|
|
511
|
+
const refresh = dependencies.refreshToken ?? refreshNativeMainGrant;
|
|
397
512
|
let refreshed: OAuthCredentials;
|
|
398
513
|
try {
|
|
399
514
|
refreshed = await refresh(locked.refreshToken, { signal });
|
|
400
515
|
} catch (cause) {
|
|
516
|
+
// A refusal already classified at the endpoint keeps that verdict. An injected refresh
|
|
517
|
+
// threw an ordinary Error and has only its message, so prose still decides there --
|
|
518
|
+
// and only there, where there is no structured code to read.
|
|
401
519
|
const message = cause instanceof Error ? cause.message.toLowerCase() : "";
|
|
402
|
-
const reason =
|
|
403
|
-
?
|
|
404
|
-
:
|
|
520
|
+
const reason = cause instanceof MainAccountTokenRefreshError
|
|
521
|
+
? cause.reason
|
|
522
|
+
: dependencies.refreshToken && /invalid_grant|invalidated|revoked|expired/.test(message)
|
|
523
|
+
? "reauth" as const
|
|
524
|
+
: "transient" as const;
|
|
525
|
+
// The shared point every request path reaches a terminal native-main verdict through:
|
|
526
|
+
// the forced refresh after an upstream 401, the pre-send refresh of an expired bearer,
|
|
527
|
+
// and the compact twin all land here. Recording the quarantine at the callers instead
|
|
528
|
+
// left the native main slot alone unrecorded -- `refreshNativeMainForwardAuth` has no
|
|
529
|
+
// `quarantine` flag for its caller to act on the way the stored-pool twin does -- so a
|
|
530
|
+
// revoked session stayed `needsReauth: false` and every request re-earned its own 401.
|
|
531
|
+
if (signal.aborted) throw signal.reason;
|
|
532
|
+
if (reason === "reauth") {
|
|
533
|
+
assertMainAuthJsonSnapshotUnchanged(locked);
|
|
534
|
+
markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!);
|
|
535
|
+
}
|
|
405
536
|
throw new MainAccountTokenRefreshError(reason, { cause });
|
|
406
537
|
}
|
|
407
538
|
// The refresh may resolve after the caller went away (an implementation that does
|
|
@@ -411,6 +542,8 @@ async function resolveMainAccountToken(
|
|
|
411
542
|
// before its own commit above.
|
|
412
543
|
if (dependencies.signal?.aborted) throw dependencies.signal.reason;
|
|
413
544
|
const result = persistRefreshedMainAuthJson(locked, refreshed);
|
|
545
|
+
// A permitted in-flight success speaks only for its own profile and grant.
|
|
546
|
+
clearMainRefreshGrantRejection(mainRefreshGrantKey(locked)!);
|
|
414
547
|
if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
415
548
|
return result;
|
|
416
549
|
}),
|
|
@@ -143,6 +143,7 @@ export function projectCatalogOnlyOutcome({
|
|
|
143
143
|
*/
|
|
144
144
|
export function createManagementConvergeCodex(
|
|
145
145
|
config: Readonly<OcxConfig>,
|
|
146
|
+
options: Readonly<{ beforeCommit?: () => boolean; expectedCatalogPath?: string }> = {},
|
|
146
147
|
): ConvergeCodex {
|
|
147
148
|
const retainedConfig = config;
|
|
148
149
|
return async request => {
|
|
@@ -167,6 +168,8 @@ export function createManagementConvergeCodex(
|
|
|
167
168
|
const snapshot = captureCatalogAdmissionSnapshot(retainedConfig);
|
|
168
169
|
const result = await convergeCodexCatalog(snapshot, request, {
|
|
169
170
|
onCommitBegin: () => { commitBegan = true; },
|
|
171
|
+
beforeCommit: options.beforeCommit,
|
|
172
|
+
expectedCatalogPath: options.expectedCatalogPath,
|
|
170
173
|
});
|
|
171
174
|
return projectCatalogOnlyOutcome(result);
|
|
172
175
|
} catch (error) {
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/** Diagnostic only: never use this outcome as quota, entitlement, or admission evidence. */
|
|
2
2
|
export type CodexQuotaRefreshOutcome =
|
|
3
|
-
|
|
3
|
+
/** `code` is set only for a provider code that requires a new sign-in (fixed vocabulary). */
|
|
4
|
+
| { status: "http_error"; httpStatus: number; code?: CodexTerminalAuthCode }
|
|
4
5
|
| { status: "ok" | "not_reported" | "timeout" | "network_error" | "invalid_response" | "internal_error" };
|
|
5
6
|
|
|
7
|
+
/** Provider auth codes that only a new sign-in can recover. */
|
|
8
|
+
export const CODEX_TERMINAL_AUTH_CODES = [
|
|
9
|
+
"invalid_workspace_selected",
|
|
10
|
+
"invalid_refresh_token",
|
|
11
|
+
// ChatGPT revokes every session of an account whose plan changes (for example Pro to Free),
|
|
12
|
+
// while the access token's `exp` still lies in the future.
|
|
13
|
+
"token_invalidated",
|
|
14
|
+
] as const;
|
|
15
|
+
export type CodexTerminalAuthCode = typeof CODEX_TERMINAL_AUTH_CODES[number];
|
|
16
|
+
export function isCodexTerminalAuthCode(code: unknown): code is CodexTerminalAuthCode {
|
|
17
|
+
return (CODEX_TERMINAL_AUTH_CODES as readonly unknown[]).includes(code);
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
/** The management response is untrusted at the CLI boundary; copy only the fixed vocabulary. */
|
|
7
21
|
export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefreshOutcome | undefined {
|
|
8
22
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
@@ -10,7 +24,8 @@ export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefre
|
|
|
10
24
|
if (row.status === "http_error") {
|
|
11
25
|
return typeof row.httpStatus === "number" && Number.isInteger(row.httpStatus)
|
|
12
26
|
&& row.httpStatus >= 100 && row.httpStatus <= 599
|
|
13
|
-
? { status: "http_error", httpStatus: row.httpStatus
|
|
27
|
+
? { status: "http_error", httpStatus: row.httpStatus,
|
|
28
|
+
...(isCodexTerminalAuthCode(row.code) ? { code: row.code } : {}) }
|
|
14
29
|
: undefined;
|
|
15
30
|
}
|
|
16
31
|
switch (row.status) {
|
package/src/codex/quota-types.ts
CHANGED
|
@@ -22,6 +22,32 @@ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
|
|
|
22
22
|
*/
|
|
23
23
|
export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
|
|
24
24
|
|
|
25
|
+
/** Credits have a separate clock: ordinary usage headers do not re-observe the balance. */
|
|
26
|
+
export const CODEX_CREDITS_FRESHNESS_MS = 5 * 60_000;
|
|
27
|
+
|
|
28
|
+
export interface CodexSpendableCredits {
|
|
29
|
+
hasCredits?: boolean;
|
|
30
|
+
unlimited?: boolean;
|
|
31
|
+
balance?: number;
|
|
32
|
+
overageLimitReached?: boolean;
|
|
33
|
+
/** Credit spending control, never the included-plan rate_limit.allowed verdict. */
|
|
34
|
+
allowed?: boolean;
|
|
35
|
+
observedAt: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function hasSpendableCodexCredits(
|
|
39
|
+
quota: Pick<StoredAccountQuota, "credits"> | null,
|
|
40
|
+
now = Date.now(),
|
|
41
|
+
): boolean {
|
|
42
|
+
const credits = quota?.credits;
|
|
43
|
+
if (!credits || !Number.isFinite(credits.observedAt)) return false;
|
|
44
|
+
const age = now - credits.observedAt;
|
|
45
|
+
if (age < 0 || age > CODEX_CREDITS_FRESHNESS_MS
|
|
46
|
+
|| credits.allowed === false || credits.overageLimitReached === true || credits.hasCredits === false) return false;
|
|
47
|
+
return credits.unlimited === true || (credits.hasCredits === true
|
|
48
|
+
&& typeof credits.balance === "number" && Number.isFinite(credits.balance) && credits.balance > 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
/**
|
|
26
52
|
* Above this a value is already milliseconds; at or below it, it is Unix seconds.
|
|
27
53
|
*
|
|
@@ -80,6 +106,8 @@ export type StoredAccountQuota = {
|
|
|
80
106
|
shortWindowSeconds?: number;
|
|
81
107
|
customWindows?: Array<{ label: string; percent: number; resetAt?: number }>;
|
|
82
108
|
resetCredits?: number;
|
|
109
|
+
/** Spendable usage credits; resetCredits are separate, manually redeemed reset tickets. */
|
|
110
|
+
credits?: CodexSpendableCredits | null;
|
|
83
111
|
/** Monthly usage came from an explicitly monthly PRIMARY, not supplementary tertiary, window. */
|
|
84
112
|
monthlyIsPrimaryWindow?: boolean;
|
|
85
113
|
updatedAt: number;
|
|
@@ -114,7 +142,14 @@ export type WhamUsageResponse = {
|
|
|
114
142
|
secondary_window?: WhamUsageWindow | null;
|
|
115
143
|
tertiary_window?: WhamUsageWindow | null;
|
|
116
144
|
};
|
|
117
|
-
credits?:
|
|
145
|
+
credits?: {
|
|
146
|
+
has_credits?: unknown;
|
|
147
|
+
unlimited?: unknown;
|
|
148
|
+
balance?: unknown;
|
|
149
|
+
overage_limit_reached?: unknown;
|
|
150
|
+
} | null;
|
|
151
|
+
/** Absent/null imposes no veto; present raw controls require an object with reached:false. */
|
|
152
|
+
spend_control?: unknown;
|
|
118
153
|
rate_limit_reset_credits?: { available_count: number } | null;
|
|
119
154
|
additional_rate_limits?: WhamAdditionalRateLimit[] | null;
|
|
120
155
|
};
|