@bitkyc08/opencodex 2.41.0 → 2.43.0-preview.20260906
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/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-VGeQEZ_v.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +54 -24
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/identity.ts +8 -2
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +259 -29
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +47 -13
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +49 -9
- package/src/cli/account-extended.ts +7 -1
- package/src/cli/capabilities.ts +15 -4
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/observe.ts +3 -1
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +272 -32
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +44 -5
- package/src/codex/catalog/metadata.ts +149 -14
- package/src/codex/catalog/native-models.ts +116 -4
- package/src/codex/catalog/parsing.ts +122 -8
- package/src/codex/catalog/provider-fetch.ts +154 -23
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +169 -0
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +99 -34
- package/src/codex/injected-marker.ts +30 -4
- package/src/codex/journal.ts +14 -0
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +474 -178
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/account-quota-rank.ts +40 -1
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/chatgpt-device.ts +187 -0
- package/src/oauth/chatgpt.ts +31 -4
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +140 -29
- package/src/oauth/log.ts +3 -0
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/muse-subscription-usage.ts +95 -0
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +625 -98
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +122 -28
- package/src/server/live.ts +18 -4
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +45 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +510 -57
- package/src/server/responses/empty-completion-guard.ts +4 -0
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/request.ts +8 -0
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-B2YjLA-i.css +0 -1
- package/gui/dist/assets/index-aPup8CKb.js +0 -112
|
@@ -15,6 +15,20 @@ export function isRootOpenaiBaseUrlLine(line: string): boolean {
|
|
|
15
15
|
return /^\s*openai_base_url\s*=/.test(line);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* codex-rs root key that redirects the realtime sideband WebSocket (WebRTC voice
|
|
20
|
+
* join + standalone realtime WS) without touching ordinary provider HTTP. Since
|
|
21
|
+
* openai/codex 438c9e98d (#35830) the sideband ignores the provider base URL and
|
|
22
|
+
* dials `https://api.openai.com/v1` unless this key is set, so a Pool-routed
|
|
23
|
+
* call-create and a directly-joined sideband end up on different accounts (404).
|
|
24
|
+
* Injected next to `openai_base_url` with the same value.
|
|
25
|
+
*/
|
|
26
|
+
export const REALTIME_WS_BASE_URL_KEY = "experimental_realtime_ws_base_url";
|
|
27
|
+
|
|
28
|
+
export function isRootRealtimeWsBaseUrlLine(line: string): boolean {
|
|
29
|
+
return /^\s*experimental_realtime_ws_base_url\s*=/.test(line);
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export function tomlStringPattern(key: string): RegExp {
|
|
19
33
|
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
20
34
|
const keyToken = `(?:${escaped}|"${escaped}"|'${escaped}')`;
|
|
@@ -65,16 +79,28 @@ export function providerTableString(content: string, provider: string, key: stri
|
|
|
65
79
|
* EXACT value match against what we recorded writing: a user gateway we never wrote
|
|
66
80
|
* cannot match, so restore can never delete a URL that was not ours.
|
|
67
81
|
*/
|
|
68
|
-
export function stripJournaledOpenaiBaseUrl(
|
|
69
|
-
|
|
82
|
+
export function stripJournaledOpenaiBaseUrl(
|
|
83
|
+
content: string,
|
|
84
|
+
injectedUrl: string | null,
|
|
85
|
+
injectedRealtimeWsUrl: string | null = null,
|
|
86
|
+
): string {
|
|
87
|
+
if (!injectedUrl && !injectedRealtimeWsUrl) return content;
|
|
70
88
|
const lines = content.split(String.fromCharCode(10));
|
|
71
89
|
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
72
90
|
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
73
91
|
const drop = new Set<number>();
|
|
74
92
|
for (let i = 0; i < rootEnd; i++) {
|
|
75
93
|
const line = lines[i]!;
|
|
76
|
-
|
|
77
|
-
|
|
94
|
+
// Each key is matched against ITS OWN recorded value. The realtime override is
|
|
95
|
+
// journaled separately so a user-owned override that happens to equal the proxy
|
|
96
|
+
// URL is never mistaken for ours.
|
|
97
|
+
if (isRootOpenaiBaseUrlLine(line)) {
|
|
98
|
+
if (!injectedUrl || rootTomlString(line, "openai_base_url") !== injectedUrl) continue;
|
|
99
|
+
} else if (isRootRealtimeWsBaseUrlLine(line)) {
|
|
100
|
+
if (!injectedRealtimeWsUrl || rootTomlString(line, REALTIME_WS_BASE_URL_KEY) !== injectedRealtimeWsUrl) continue;
|
|
101
|
+
} else {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
78
104
|
drop.add(i);
|
|
79
105
|
// Take an ownership marker directly above it too, so repeated cycles cannot
|
|
80
106
|
// accumulate orphaned comments.
|
package/src/codex/journal.ts
CHANGED
|
@@ -35,6 +35,13 @@ interface Journal {
|
|
|
35
35
|
* and it is what lets restore tell OUR loopback URL apart from a gateway the user set.
|
|
36
36
|
*/
|
|
37
37
|
injectedOpenaiBaseUrl?: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* The root `experimental_realtime_ws_base_url` this injection wrote, when it wrote one.
|
|
40
|
+
* Recorded on its own rather than inferred from `injectedOpenaiBaseUrl`: a user can own a
|
|
41
|
+
* realtime override whose value happens to equal the proxy URL, and restore must not treat
|
|
42
|
+
* that as ours. Null when the key was preserved or not injected.
|
|
43
|
+
*/
|
|
44
|
+
injectedRealtimeWsBaseUrl?: string | null;
|
|
38
45
|
/**
|
|
39
46
|
* The catalog path this injection actually wrote to.
|
|
40
47
|
*
|
|
@@ -119,6 +126,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
|
|
|
119
126
|
|
|
120
127
|
export interface InjectedJournalOwnership {
|
|
121
128
|
injectedOpenaiBaseUrl: string | null;
|
|
129
|
+
injectedRealtimeWsBaseUrl: string | null;
|
|
122
130
|
injectedCatalogPath: string | null;
|
|
123
131
|
}
|
|
124
132
|
|
|
@@ -140,6 +148,7 @@ export function markJournalInjectedState(
|
|
|
140
148
|
// Only the caller knows which values it actually owns. Deriving these from the final TOML
|
|
141
149
|
// would mistake a preserved user override for injected routing.
|
|
142
150
|
journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl;
|
|
151
|
+
journal.injectedRealtimeWsBaseUrl = ownership.injectedRealtimeWsBaseUrl;
|
|
143
152
|
journal.injectedCatalogPath = ownership.injectedCatalogPath;
|
|
144
153
|
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
145
154
|
}
|
|
@@ -156,6 +165,11 @@ export function journaledInjectedOpenaiBaseUrl(): string | null {
|
|
|
156
165
|
return readJournal()?.injectedOpenaiBaseUrl ?? null;
|
|
157
166
|
}
|
|
158
167
|
|
|
168
|
+
/** The root `experimental_realtime_ws_base_url` the last injection wrote, or null. */
|
|
169
|
+
export function journaledInjectedRealtimeWsBaseUrl(): string | null {
|
|
170
|
+
return readJournal()?.injectedRealtimeWsBaseUrl ?? null;
|
|
171
|
+
}
|
|
172
|
+
|
|
159
173
|
/** The catalog path the last injection wrote to, or null when none was recorded. */
|
|
160
174
|
export function journaledInjectedCatalogPath(): string | null {
|
|
161
175
|
return readJournal()?.injectedCatalogPath ?? null;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { resolveCodexHomeDir } from "./home";
|
|
4
|
+
import { parseTomlDocument } from "./project-config-warnings";
|
|
5
|
+
import { redactUserPath } from "../lib/redact";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keys that are valid in model catalog data but not at the top level of the
|
|
9
|
+
* user config. Codex rejects them when strict config parsing is enabled.
|
|
10
|
+
*/
|
|
11
|
+
const LEGACY_KEYS = ["persistent_instructions"] as const;
|
|
12
|
+
|
|
13
|
+
export type LegacyCodexConfigKey = (typeof LEGACY_KEYS)[number];
|
|
14
|
+
|
|
15
|
+
export interface LegacyCodexConfigKeyDiagnostic {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly code: LegacyCodexConfigKey;
|
|
18
|
+
readonly detail: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type LegacyCodexConfigKeyDiagnosticsResult =
|
|
22
|
+
| { readonly status: "available"; readonly path: string; readonly diagnostics: readonly LegacyCodexConfigKeyDiagnostic[] }
|
|
23
|
+
| { readonly status: "unavailable"; readonly path: string; readonly reason: "read_failed" | "not_a_file" };
|
|
24
|
+
|
|
25
|
+
function resolveCodexConfigPath(codexConfigPath?: string): string {
|
|
26
|
+
if (codexConfigPath) return codexConfigPath;
|
|
27
|
+
return join(resolveCodexHomeDir(), "config.toml");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function collectLegacyCodexConfigKeyDiagnostics(
|
|
31
|
+
options: { codexConfigPath?: string } = {},
|
|
32
|
+
): LegacyCodexConfigKeyDiagnosticsResult {
|
|
33
|
+
const path = resolveCodexConfigPath(options.codexConfigPath);
|
|
34
|
+
if (!existsSync(path)) return { status: "available", path, diagnostics: [] };
|
|
35
|
+
let content: string;
|
|
36
|
+
try {
|
|
37
|
+
if (!statSync(path).isFile()) return { status: "unavailable", path, reason: "not_a_file" };
|
|
38
|
+
content = readFileSync(path, "utf-8");
|
|
39
|
+
} catch { // no-excuse-ok: catch -- optional doctor diagnostics must not fail on an unreadable file.
|
|
40
|
+
return { status: "unavailable", path, reason: "read_failed" };
|
|
41
|
+
}
|
|
42
|
+
const { root } = parseTomlDocument(content);
|
|
43
|
+
const found: LegacyCodexConfigKeyDiagnostic[] = [];
|
|
44
|
+
for (const key of LEGACY_KEYS) {
|
|
45
|
+
if (key in root) {
|
|
46
|
+
found.push({
|
|
47
|
+
path,
|
|
48
|
+
code: key,
|
|
49
|
+
detail: `top-level '${key}' is not a valid Codex config key. `
|
|
50
|
+
+ "codex --strict-config rejects the whole file. Remove the key and put durable guidance in AGENTS.md.",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { status: "available", path, diagnostics: found };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatLegacyCodexConfigKeyDiagnosticsForDoctor(
|
|
58
|
+
result: LegacyCodexConfigKeyDiagnosticsResult,
|
|
59
|
+
): string[] {
|
|
60
|
+
const displayPath = redactUserPath(result.path);
|
|
61
|
+
if (result.status === "unavailable") {
|
|
62
|
+
return [` -- Codex config at ${displayPath} could not be read (${result.reason}); legacy-key check skipped`];
|
|
63
|
+
}
|
|
64
|
+
if (result.diagnostics.length === 0) {
|
|
65
|
+
return [" ok no unsupported legacy top-level keys in the Codex config"];
|
|
66
|
+
}
|
|
67
|
+
return result.diagnostics.map(diagnostic => `[WARN] ${redactUserPath(diagnostic.path)}: ${diagnostic.detail}`);
|
|
68
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
interface CurrentLogColumn {
|
|
4
|
+
name: string;
|
|
5
|
+
type: string;
|
|
6
|
+
notnull: number;
|
|
7
|
+
defaultValue: string | null;
|
|
8
|
+
pk: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Pinned to Codex logs migration 0002. Keep this schema private: inspection reports
|
|
12
|
+
// compatibility, not column names, so sensitive payload-bearing fields never leak through
|
|
13
|
+
// the management API. Any additive/rebuilt future schema is monitor-only until reviewed.
|
|
14
|
+
const CURRENT_LOG_SCHEMA: readonly CurrentLogColumn[] = [
|
|
15
|
+
{ name: "id", type: "INTEGER", notnull: 0, defaultValue: null, pk: 1 },
|
|
16
|
+
{ name: "ts", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
|
|
17
|
+
{ name: "ts_nanos", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
|
|
18
|
+
{ name: "level", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
19
|
+
{ name: "target", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
20
|
+
{ name: "feedback_log_body", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
21
|
+
{ name: "module_path", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
22
|
+
{ name: "file", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
23
|
+
{ name: "line", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 },
|
|
24
|
+
{ name: "thread_id", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
25
|
+
{ name: "process_uuid", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
26
|
+
{ name: "estimated_bytes", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 },
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
const CURRENT_LOG_TABLE_SQL = `CREATE TABLE logs (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
ts INTEGER NOT NULL,
|
|
32
|
+
ts_nanos INTEGER NOT NULL,
|
|
33
|
+
level TEXT NOT NULL,
|
|
34
|
+
target TEXT NOT NULL,
|
|
35
|
+
feedback_log_body TEXT,
|
|
36
|
+
module_path TEXT,
|
|
37
|
+
file TEXT,
|
|
38
|
+
line INTEGER,
|
|
39
|
+
thread_id TEXT,
|
|
40
|
+
process_uuid TEXT,
|
|
41
|
+
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
|
42
|
+
)`;
|
|
43
|
+
|
|
44
|
+
const CURRENT_LOG_INDEX_SQL = {
|
|
45
|
+
idx_logs_ts: "CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC)",
|
|
46
|
+
idx_logs_thread_id: "CREATE INDEX idx_logs_thread_id ON logs(thread_id)",
|
|
47
|
+
idx_logs_thread_id_ts: "CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC)",
|
|
48
|
+
idx_logs_process_uuid_threadless_ts: `CREATE INDEX idx_logs_process_uuid_threadless_ts
|
|
49
|
+
ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC)
|
|
50
|
+
WHERE thread_id IS NULL`,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
export interface ColumnRow {
|
|
55
|
+
cid: number;
|
|
56
|
+
name: string;
|
|
57
|
+
type: string;
|
|
58
|
+
notnull: number;
|
|
59
|
+
dflt_value: string | null;
|
|
60
|
+
pk: number;
|
|
61
|
+
}
|
|
62
|
+
interface SchemaObjectRow { name: string; type: string; sql: string | null }
|
|
63
|
+
|
|
64
|
+
function normalizeDeclaredType(type: string): string {
|
|
65
|
+
return String(type ?? "").trim().toUpperCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeDefault(value: string | null): string | null {
|
|
69
|
+
return value === null ? null : String(value).trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeSchemaSql(sql: string | null | undefined): string {
|
|
73
|
+
return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " ");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sameColumns(columns: ColumnRow[]): boolean {
|
|
77
|
+
if (columns.length !== CURRENT_LOG_SCHEMA.length) return false;
|
|
78
|
+
return columns.every((column, index) => {
|
|
79
|
+
const expected = CURRENT_LOG_SCHEMA[index];
|
|
80
|
+
return column.cid === index
|
|
81
|
+
&& column.name === expected.name
|
|
82
|
+
&& normalizeDeclaredType(column.type) === expected.type
|
|
83
|
+
&& Number(column.notnull) === expected.notnull
|
|
84
|
+
&& normalizeDefault(column.dflt_value) === expected.defaultValue
|
|
85
|
+
&& Number(column.pk) === expected.pk;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The authoritative compatibility predicate: exact table SQL, exact column
|
|
91
|
+
* metadata, and every canonical index.
|
|
92
|
+
*
|
|
93
|
+
* Exported because the mutation paths must apply the SAME test inside their
|
|
94
|
+
* write transaction. They used to check column NAMES only, which is strictly
|
|
95
|
+
* weaker than what the inspector reports, so a schema change landing between
|
|
96
|
+
* the outer inspection and the locked write let Protect install a row-dropping
|
|
97
|
+
* trigger and let Reclaim vacuum pages on a database the inspector classifies
|
|
98
|
+
* as monitor-only. The lock serializes OpenCodex against itself; it does not
|
|
99
|
+
* stop Codex or another SQLite writer, so that TOCTOU window is real.
|
|
100
|
+
*/
|
|
101
|
+
export function hasCurrentLogsSchema(db: Database): boolean {
|
|
102
|
+
const columns = db.query<ColumnRow, []>("PRAGMA table_info(logs)").all();
|
|
103
|
+
return hasCurrentLogsTable(db, columns);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function hasCurrentLogsTable(db: Database, columns: ColumnRow[]): boolean {
|
|
107
|
+
const table = db.query<SchemaObjectRow, []>(
|
|
108
|
+
"SELECT name, type, sql FROM sqlite_schema WHERE name = 'logs' LIMIT 1",
|
|
109
|
+
).get();
|
|
110
|
+
if (table?.type !== "table"
|
|
111
|
+
|| !sameColumns(columns)
|
|
112
|
+
|| normalizeSchemaSql(table.sql) !== normalizeSchemaSql(CURRENT_LOG_TABLE_SQL)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const indexes = db.query<SchemaObjectRow, []>(`
|
|
117
|
+
SELECT name, type, sql FROM sqlite_schema
|
|
118
|
+
WHERE name IN (
|
|
119
|
+
'idx_logs_ts',
|
|
120
|
+
'idx_logs_thread_id',
|
|
121
|
+
'idx_logs_thread_id_ts',
|
|
122
|
+
'idx_logs_process_uuid_threadless_ts'
|
|
123
|
+
)
|
|
124
|
+
`).all();
|
|
125
|
+
const byName = new Map(indexes.map(row => [row.name, row]));
|
|
126
|
+
for (const [name, expectedSql] of Object.entries(CURRENT_LOG_INDEX_SQL)) {
|
|
127
|
+
const row = byName.get(name);
|
|
128
|
+
if (row?.type !== "index" || normalizeSchemaSql(row.sql) !== normalizeSchemaSql(expectedSql)) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Extra indexes and triggers do not redefine the table contract. In particular,
|
|
134
|
+
// Protect intentionally installs OpenCodex-owned triggers and unrelated user triggers
|
|
135
|
+
// are supported, so compatibility is based on the canonical table plus required indexes.
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
resolveCodexSqliteHome,
|
|
9
9
|
type CodexSqliteHomeDeps,
|
|
10
10
|
} from "../paths";
|
|
11
|
+
export { hasCurrentLogsSchema } from "./inspect-schema";
|
|
12
|
+
import { hasCurrentLogsTable, type ColumnRow } from "./inspect-schema";
|
|
11
13
|
|
|
12
14
|
const IMMUTABLE_READONLY_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
|
|
13
15
|
const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]);
|
|
@@ -15,56 +17,6 @@ const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]);
|
|
|
15
17
|
// alone; skipping all row aggregates above 64 MiB reduced /api/storage to 628ms.
|
|
16
18
|
const MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES = 64 * 1024 * 1024;
|
|
17
19
|
|
|
18
|
-
interface CurrentLogColumn {
|
|
19
|
-
name: string;
|
|
20
|
-
type: string;
|
|
21
|
-
notnull: number;
|
|
22
|
-
defaultValue: string | null;
|
|
23
|
-
pk: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Pinned to Codex logs migration 0002. Keep this schema private: inspection reports
|
|
27
|
-
// compatibility, not column names, so sensitive payload-bearing fields never leak through
|
|
28
|
-
// the management API. Any additive/rebuilt future schema is monitor-only until reviewed.
|
|
29
|
-
const CURRENT_LOG_SCHEMA: readonly CurrentLogColumn[] = [
|
|
30
|
-
{ name: "id", type: "INTEGER", notnull: 0, defaultValue: null, pk: 1 },
|
|
31
|
-
{ name: "ts", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
|
|
32
|
-
{ name: "ts_nanos", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
|
|
33
|
-
{ name: "level", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
34
|
-
{ name: "target", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
35
|
-
{ name: "feedback_log_body", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
36
|
-
{ name: "module_path", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
37
|
-
{ name: "file", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
38
|
-
{ name: "line", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 },
|
|
39
|
-
{ name: "thread_id", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
40
|
-
{ name: "process_uuid", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
41
|
-
{ name: "estimated_bytes", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 },
|
|
42
|
-
] as const;
|
|
43
|
-
|
|
44
|
-
const CURRENT_LOG_TABLE_SQL = `CREATE TABLE logs (
|
|
45
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
-
ts INTEGER NOT NULL,
|
|
47
|
-
ts_nanos INTEGER NOT NULL,
|
|
48
|
-
level TEXT NOT NULL,
|
|
49
|
-
target TEXT NOT NULL,
|
|
50
|
-
feedback_log_body TEXT,
|
|
51
|
-
module_path TEXT,
|
|
52
|
-
file TEXT,
|
|
53
|
-
line INTEGER,
|
|
54
|
-
thread_id TEXT,
|
|
55
|
-
process_uuid TEXT,
|
|
56
|
-
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
|
57
|
-
)`;
|
|
58
|
-
|
|
59
|
-
const CURRENT_LOG_INDEX_SQL = {
|
|
60
|
-
idx_logs_ts: "CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC)",
|
|
61
|
-
idx_logs_thread_id: "CREATE INDEX idx_logs_thread_id ON logs(thread_id)",
|
|
62
|
-
idx_logs_thread_id_ts: "CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC)",
|
|
63
|
-
idx_logs_process_uuid_threadless_ts: `CREATE INDEX idx_logs_process_uuid_threadless_ts
|
|
64
|
-
ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC)
|
|
65
|
-
WHERE thread_id IS NULL`,
|
|
66
|
-
} as const;
|
|
67
|
-
|
|
68
20
|
export type CodexLogGuardCapabilityReason =
|
|
69
21
|
| "database_missing"
|
|
70
22
|
| "database_unreadable"
|
|
@@ -124,15 +76,6 @@ export interface CodexLogGuardInspection {
|
|
|
124
76
|
};
|
|
125
77
|
}
|
|
126
78
|
|
|
127
|
-
interface ColumnRow {
|
|
128
|
-
cid: number;
|
|
129
|
-
name: string;
|
|
130
|
-
type: string;
|
|
131
|
-
notnull: number;
|
|
132
|
-
dflt_value: string | null;
|
|
133
|
-
pk: number;
|
|
134
|
-
}
|
|
135
|
-
interface SchemaObjectRow { name: string; type: string; sql: string | null }
|
|
136
79
|
interface CountRow { n: number }
|
|
137
80
|
interface LevelRow { level: string; rows: number }
|
|
138
81
|
interface TargetCountRow { rows: number }
|
|
@@ -243,81 +186,6 @@ function unavailableInspection(): CodexLogGuardInspection {
|
|
|
243
186
|
};
|
|
244
187
|
}
|
|
245
188
|
|
|
246
|
-
function normalizeDeclaredType(type: string): string {
|
|
247
|
-
return String(type ?? "").trim().toUpperCase();
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function normalizeDefault(value: string | null): string | null {
|
|
251
|
-
return value === null ? null : String(value).trim();
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function normalizeSchemaSql(sql: string | null | undefined): string {
|
|
255
|
-
return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " ");
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function sameColumns(columns: ColumnRow[]): boolean {
|
|
259
|
-
if (columns.length !== CURRENT_LOG_SCHEMA.length) return false;
|
|
260
|
-
return columns.every((column, index) => {
|
|
261
|
-
const expected = CURRENT_LOG_SCHEMA[index];
|
|
262
|
-
return column.cid === index
|
|
263
|
-
&& column.name === expected.name
|
|
264
|
-
&& normalizeDeclaredType(column.type) === expected.type
|
|
265
|
-
&& Number(column.notnull) === expected.notnull
|
|
266
|
-
&& normalizeDefault(column.dflt_value) === expected.defaultValue
|
|
267
|
-
&& Number(column.pk) === expected.pk;
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* The authoritative compatibility predicate: exact table SQL, exact column
|
|
273
|
-
* metadata, and every canonical index.
|
|
274
|
-
*
|
|
275
|
-
* Exported because the mutation paths must apply the SAME test inside their
|
|
276
|
-
* write transaction. They used to check column NAMES only, which is strictly
|
|
277
|
-
* weaker than what the inspector reports, so a schema change landing between
|
|
278
|
-
* the outer inspection and the locked write let Protect install a row-dropping
|
|
279
|
-
* trigger and let Reclaim vacuum pages on a database the inspector classifies
|
|
280
|
-
* as monitor-only. The lock serializes OpenCodex against itself; it does not
|
|
281
|
-
* stop Codex or another SQLite writer, so that TOCTOU window is real.
|
|
282
|
-
*/
|
|
283
|
-
export function hasCurrentLogsSchema(db: Database): boolean {
|
|
284
|
-
const columns = db.query<ColumnRow, []>("PRAGMA table_info(logs)").all();
|
|
285
|
-
return hasCurrentLogsTable(db, columns);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function hasCurrentLogsTable(db: Database, columns: ColumnRow[]): boolean {
|
|
289
|
-
const table = db.query<SchemaObjectRow, []>(
|
|
290
|
-
"SELECT name, type, sql FROM sqlite_schema WHERE name = 'logs' LIMIT 1",
|
|
291
|
-
).get();
|
|
292
|
-
if (table?.type !== "table"
|
|
293
|
-
|| !sameColumns(columns)
|
|
294
|
-
|| normalizeSchemaSql(table.sql) !== normalizeSchemaSql(CURRENT_LOG_TABLE_SQL)) {
|
|
295
|
-
return false;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const indexes = db.query<SchemaObjectRow, []>(`
|
|
299
|
-
SELECT name, type, sql FROM sqlite_schema
|
|
300
|
-
WHERE name IN (
|
|
301
|
-
'idx_logs_ts',
|
|
302
|
-
'idx_logs_thread_id',
|
|
303
|
-
'idx_logs_thread_id_ts',
|
|
304
|
-
'idx_logs_process_uuid_threadless_ts'
|
|
305
|
-
)
|
|
306
|
-
`).all();
|
|
307
|
-
const byName = new Map(indexes.map(row => [row.name, row]));
|
|
308
|
-
for (const [name, expectedSql] of Object.entries(CURRENT_LOG_INDEX_SQL)) {
|
|
309
|
-
const row = byName.get(name);
|
|
310
|
-
if (row?.type !== "index" || normalizeSchemaSql(row.sql) !== normalizeSchemaSql(expectedSql)) {
|
|
311
|
-
return false;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Extra indexes and triggers do not redefine the table contract. In particular,
|
|
316
|
-
// Protect intentionally installs OpenCodex-owned triggers and unrelated user triggers
|
|
317
|
-
// are supported, so compatibility is based on the canonical table plus required indexes.
|
|
318
|
-
return true;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
189
|
function pragmaNumber(db: Database, pragma: "page_size" | "page_count" | "freelist_count"): number {
|
|
322
190
|
const row = db.query<Record<string, number>, []>(`PRAGMA ${pragma}`).get();
|
|
323
191
|
return Number(row?.[pragma] ?? 0);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
import type { DataPlaneAdmission } from "../server/auth-cors";
|
|
3
|
+
import { NATIVE_RESERVE_MODEL } from "./catalog/native-models";
|
|
4
|
+
|
|
5
|
+
export const CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE =
|
|
6
|
+
"Luna Reserve compatibility is only available as a conversation model, not a vision helper. Choose another vision model.";
|
|
7
|
+
|
|
8
|
+
/** Callers classify the concrete destination as canonical forward before using this predicate. */
|
|
9
|
+
export function isCodexReserveHelperUnsupported(
|
|
10
|
+
config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
|
|
11
|
+
modelId: string,
|
|
12
|
+
admission: Pick<DataPlaneAdmission, "source"> | undefined,
|
|
13
|
+
terminalHelper: boolean,
|
|
14
|
+
): boolean {
|
|
15
|
+
return terminalHelper && modelId === NATIVE_RESERVE_MODEL && isCodexReserveRequestEligible(config, admission);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Runtime authority comes from the receiving listener, not the catalog's injection target. */
|
|
19
|
+
export function isCodexReserveRequestEligible(
|
|
20
|
+
config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
|
|
21
|
+
admission: Pick<DataPlaneAdmission, "source"> | undefined,
|
|
22
|
+
): boolean {
|
|
23
|
+
return config.codexDesktopAuthless === true && config.runtimeRole !== "client"
|
|
24
|
+
&& admission?.source === "loopback";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Bind scope, not the dial address: wildcard listeners are never loopback-only. */
|
|
28
|
+
export function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
29
|
+
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase().replace(/\.$/, "");
|
|
30
|
+
return (
|
|
31
|
+
normalized === "" ||
|
|
32
|
+
normalized === "localhost" ||
|
|
33
|
+
normalized === "127.0.0.1" ||
|
|
34
|
+
normalized === "::1" ||
|
|
35
|
+
normalized === "[::1]"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function shouldInjectApiAuthHeader(
|
|
40
|
+
config: Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener"> | undefined,
|
|
41
|
+
): boolean {
|
|
42
|
+
// The dedicated listener binds loopback and does not require an admission credential.
|
|
43
|
+
if (config?.unauthenticatedLoopbackListener?.enabled) return false;
|
|
44
|
+
return !isLoopbackHostname(config?.hostname);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Match standalone injection, never a remote client's independently supplied routing target. */
|
|
48
|
+
export function isEffectiveCodexDesktopAuthless(
|
|
49
|
+
config: Pick<OcxConfig, "runtimeRole" | "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless"> | undefined,
|
|
50
|
+
): boolean {
|
|
51
|
+
return config?.codexDesktopAuthless === true
|
|
52
|
+
&& config.runtimeRole !== "client"
|
|
53
|
+
&& !shouldInjectApiAuthHeader(config);
|
|
54
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { StoredAccountQuota } from "./quota-types";
|
|
2
3
|
import { truncateRetainedUtf8 } from "../lib/admission";
|
|
3
4
|
|
|
4
5
|
const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
|
|
@@ -16,6 +17,65 @@ export interface CachedMainAccountInfo extends MainAccountInfo {
|
|
|
16
17
|
let cachedMainAccountInfo: CachedMainAccountInfo | null = null;
|
|
17
18
|
let cachedMainCredentialPresence: boolean | null = null;
|
|
18
19
|
let mainAccountIdentityGeneration = 0;
|
|
20
|
+
let observedMainQuotaIdentityKey: string | undefined;
|
|
21
|
+
const mainQuotaCredentialKey = randomBytes(32);
|
|
22
|
+
let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter } | undefined;
|
|
23
|
+
let mainQuotaCredentialGeneration = 0;
|
|
24
|
+
|
|
25
|
+
/** Process-local transition fence; no credential material or persisted identity. */
|
|
26
|
+
export function getMainQuotaCredentialGeneration(): number { return mainQuotaCredentialGeneration; }
|
|
27
|
+
|
|
28
|
+
export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>;
|
|
29
|
+
|
|
30
|
+
function mainQuotaIdentityKey(accountId: string): string {
|
|
31
|
+
return createHash("sha256").update("opencodex-main-quota-v1\0").update(accountId).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Only an existing owned physical-identity read may publish this observation. */
|
|
35
|
+
export function observeMainQuotaIdentity(accountId: string): void {
|
|
36
|
+
if (!accountId) return;
|
|
37
|
+
const identityKey = mainQuotaIdentityKey(accountId);
|
|
38
|
+
if (identityKey === observedMainQuotaIdentityKey) return;
|
|
39
|
+
observedMainQuotaIdentityKey = identityKey;
|
|
40
|
+
mainAccountIdentityGeneration += 1;
|
|
41
|
+
mainQuotaCredential = undefined;
|
|
42
|
+
mainQuotaCredentialGeneration += 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | undefined {
|
|
46
|
+
if (!accountId) return undefined;
|
|
47
|
+
const identityKey = mainQuotaIdentityKey(accountId);
|
|
48
|
+
if (identityKey !== observedMainQuotaIdentityKey) return undefined;
|
|
49
|
+
return { identityKey, identityGeneration: mainAccountIdentityGeneration };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Credential material must come from an already-owned read, never an incoming request. */
|
|
53
|
+
export function observeMainQuotaCredential(accessToken: string, accountId: string): MainQuotaWriter | undefined {
|
|
54
|
+
const writer = captureMainQuotaWriter(accountId);
|
|
55
|
+
if (!accessToken || !writer) return undefined;
|
|
56
|
+
const bearerHmac = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest();
|
|
57
|
+
if (!mainQuotaCredential || !isMainQuotaWriterLive(mainQuotaCredential.writer)
|
|
58
|
+
|| !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) mainQuotaCredentialGeneration += 1;
|
|
59
|
+
mainQuotaCredential = { bearerHmac, writer };
|
|
60
|
+
return { ...writer };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function matchesMainQuotaCredential(accessToken: string, effectiveAccountId: string | undefined): boolean {
|
|
64
|
+
const observed = mainQuotaCredential;
|
|
65
|
+
if (!accessToken || !effectiveAccountId || !observed || !isMainQuotaWriterLive(observed.writer)) return false;
|
|
66
|
+
if (mainQuotaIdentityKey(effectiveAccountId) !== observed.writer.identityKey) return false;
|
|
67
|
+
const candidate = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest();
|
|
68
|
+
return timingSafeEqual(candidate, observed.bearerHmac);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isMainQuotaWriterLive(writer: MainQuotaWriter): boolean {
|
|
72
|
+
return writer.identityKey === observedMainQuotaIdentityKey
|
|
73
|
+
&& writer.identityGeneration === mainAccountIdentityGeneration;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getObservedMainQuotaIdentityKey(): string | undefined {
|
|
77
|
+
return observedMainQuotaIdentityKey;
|
|
78
|
+
}
|
|
19
79
|
|
|
20
80
|
export function captureMainAccountIdentityGeneration(): number {
|
|
21
81
|
return mainAccountIdentityGeneration;
|
|
@@ -40,6 +100,8 @@ export function setMainAccountInfoCache(value: CachedMainAccountInfo): void {
|
|
|
40
100
|
export function clearMainAccountInfoCache(): void {
|
|
41
101
|
cachedMainAccountInfo = null;
|
|
42
102
|
mainAccountIdentityGeneration += 1;
|
|
103
|
+
mainQuotaCredential = undefined;
|
|
104
|
+
mainQuotaCredentialGeneration += 1;
|
|
43
105
|
}
|
|
44
106
|
|
|
45
107
|
/** Last physical credential presence observed while native-main ownership was held. */
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
import { getMainPolicyQuota } from "./quota";
|
|
3
|
+
|
|
4
|
+
export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99;
|
|
5
|
+
|
|
6
|
+
export interface MainAccountHardLockStatus {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
state: "off" | "unknown" | "ready" | "blocked";
|
|
9
|
+
/** Unix milliseconds; absent when a blocking observation has no future reset. */
|
|
10
|
+
resetAt?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock">;
|
|
14
|
+
|
|
15
|
+
function resetTimestamp(value: number | undefined): number | undefined {
|
|
16
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
|
|
17
|
+
return value < 10_000_000_000 ? value * 1000 : value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Observed admission policy, not a reservation of the account's remaining quota. */
|
|
21
|
+
export function getMainAccountHardLockStatus(
|
|
22
|
+
config: PolicyConfig,
|
|
23
|
+
now = Date.now(),
|
|
24
|
+
): MainAccountHardLockStatus {
|
|
25
|
+
if (config.codexMainAccountHardLock !== true) return { enabled: false, state: "off" };
|
|
26
|
+
const quota = getMainPolicyQuota();
|
|
27
|
+
if (!quota) return { enabled: true, state: "unknown" };
|
|
28
|
+
// Account window priority is deliberate: a 5h account uses that window, even if
|
|
29
|
+
// its weekly bar is higher. An unknown/expired selected window does not change the choice.
|
|
30
|
+
const hasShort = quota.shortPercent !== undefined || quota.shortResetAt !== undefined
|
|
31
|
+
|| quota.shortWindowSeconds !== undefined;
|
|
32
|
+
const hasWeekly = quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined;
|
|
33
|
+
const [percent, rawReset] = hasShort
|
|
34
|
+
? [quota.shortPercent, quota.shortResetAt]
|
|
35
|
+
: hasWeekly ? [quota.weeklyPercent, quota.weeklyResetAt] : [quota.monthlyPercent, quota.monthlyResetAt];
|
|
36
|
+
const resetAt = resetTimestamp(rawReset);
|
|
37
|
+
// The routing score's unknown sentinel is 101. It is never a raw quota observation.
|
|
38
|
+
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
|
|
39
|
+
return { enabled: true, state: "unknown" };
|
|
40
|
+
}
|
|
41
|
+
if (percent < MAIN_ACCOUNT_HARD_LOCK_PERCENT) return { enabled: true, state: "ready" };
|
|
42
|
+
return {
|
|
43
|
+
enabled: true,
|
|
44
|
+
state: "blocked",
|
|
45
|
+
// A predicted reset is not evidence of recovery. Only a fresh lower reading releases.
|
|
46
|
+
...(resetAt !== undefined && resetAt > now ? { resetAt } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean {
|
|
51
|
+
return getMainAccountHardLockStatus(config, now).state === "blocked";
|
|
52
|
+
}
|
|
@@ -57,6 +57,8 @@ type MainAuthJsonCredential = {
|
|
|
57
57
|
export interface NativeMainRefreshDependencies {
|
|
58
58
|
refreshToken?: (refreshToken: string, options: { signal: AbortSignal }) => Promise<OAuthCredentials>;
|
|
59
59
|
signal?: AbortSignal;
|
|
60
|
+
/** Metadata-only refresh must not retract a concurrent traffic reauth quarantine. */
|
|
61
|
+
preserveReauth?: boolean;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
export class MainAuthJsonChangedDuringRefreshError extends Error {
|
|
@@ -283,7 +285,7 @@ async function resolveMainAccountToken(
|
|
|
283
285
|
throw new MainAccountTokenRefreshError(reason, { cause });
|
|
284
286
|
}
|
|
285
287
|
const result = persistRefreshedMainAuthJson(locked, refreshed);
|
|
286
|
-
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
288
|
+
if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
287
289
|
return result;
|
|
288
290
|
}),
|
|
289
291
|
{ waitMs: 30_000, signal },
|