@bitkyc08/opencodex 2.13.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-Co12XTT-.js +76 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +5 -1
- package/src/adapters/cursor/request-builder.ts +3 -3
- package/src/adapters/google.ts +25 -5
- package/src/adapters/openai-chat.ts +182 -6
- package/src/adapters/openai-responses.ts +17 -7
- package/src/codex/catalog/bundled.ts +16 -0
- package/src/codex/catalog/metadata.ts +180 -5
- package/src/codex/catalog/parsing.ts +7 -6
- package/src/codex/catalog/sync.ts +73 -6
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence.ts +20 -0
- package/src/codex/prompt-journal.ts +50 -13
- package/src/codex/prompt-layers.ts +1 -1
- package/src/config.ts +57 -0
- package/src/generated/compatibility-version.json +64 -48
- package/src/generated/model-metadata.ts +3 -3
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/oauth/login-cli.ts +52 -26
- package/src/providers/derive.ts +2 -0
- package/src/providers/openai-sidecar.ts +9 -2
- package/src/providers/quota.ts +57 -0
- package/src/providers/registry.ts +53 -25
- package/src/responses/state.ts +22 -0
- package/src/router.ts +1 -0
- package/src/server/claude-messages.ts +57 -11
- package/src/server/direct-local-http.ts +7 -3
- package/src/server/images.ts +6 -0
- package/src/server/index.ts +51 -11
- package/src/server/live.ts +117 -13
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/config-routes.ts +20 -3
- package/src/server/management/logs-usage-routes.ts +28 -0
- package/src/server/management/model-routes.ts +11 -3
- package/src/server/management/model-rows.ts +18 -3
- package/src/server/management/provider-routes.ts +107 -3
- package/src/server/management-auth.ts +65 -1
- package/src/server/proxy-liveness.ts +1 -0
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +460 -0
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core.ts +142 -6
- package/src/server/responses/encrypted-payload.ts +4 -1
- package/src/server/search.ts +4 -0
- package/src/types.ts +27 -0
- package/src/usage/expected-prices.ts +11 -0
- package/src/vision/describe.ts +4 -0
- package/src/web-search/anthropic-executor.ts +5 -1
- package/src/web-search/executor.ts +9 -1
- package/src/web-search/index.ts +5 -0
- package/src/web-search/loop.ts +42 -5
- package/gui/dist/assets/index-BHldBl6_.js +0 -76
|
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from
|
|
|
4
4
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
|
|
6
6
|
import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
|
|
7
|
+
import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces";
|
|
7
8
|
import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
|
|
8
9
|
import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
|
|
9
10
|
import type { OcxConfig, OcxProviderConfig } from "../../types";
|
|
@@ -33,8 +34,8 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
|
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
import type { RawEntry } from "./parsing";
|
|
36
|
-
import { readCurrentCatalogOrCache, unique } from "./bundled";
|
|
37
|
-
import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
|
|
37
|
+
import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexModelsCache, unique } from "./bundled";
|
|
38
|
+
import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";
|
|
38
39
|
import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
|
|
39
40
|
import { NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./native-models";
|
|
40
41
|
export { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
|
|
@@ -194,10 +195,35 @@ export function shouldIncludeNativeOpenAi(config: Pick<OcxConfig, "providers">):
|
|
|
194
195
|
return !hasEnabledProvider || shouldIncludeAccountBoundNativeOpenAi(config);
|
|
195
196
|
}
|
|
196
197
|
|
|
198
|
+
type AccountSelectorConfig = Pick<
|
|
199
|
+
OcxConfig,
|
|
200
|
+
"codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled"
|
|
201
|
+
>;
|
|
202
|
+
|
|
203
|
+
function mainAccountSelectors(config: AccountSelectorConfig): string[] {
|
|
204
|
+
const targets = new Map(codexAccountNamespaceEntries(config));
|
|
205
|
+
return visibleCodexAccountSelectors(config).filter(selector =>
|
|
206
|
+
isMainCodexAccountTarget(targets.get(selector) ?? ""));
|
|
207
|
+
}
|
|
208
|
+
|
|
197
209
|
/** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */
|
|
198
|
-
export function desktopVisibleNativeSlugs(
|
|
210
|
+
export function desktopVisibleNativeSlugs(
|
|
211
|
+
config: Pick<OcxConfig, "claudeCode" | "disabledModels" | "combos" | "providers"
|
|
212
|
+
| "codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled">,
|
|
213
|
+
): string[] {
|
|
199
214
|
if (config.claudeCode?.desktopNativeModels === false) return [];
|
|
200
|
-
|
|
215
|
+
const visible = visibleNativeSlugs(config);
|
|
216
|
+
if (!shouldIncludeAccountBoundNativeOpenAi(config)) return visible;
|
|
217
|
+
const qualified = [...accountBoundNativeOpenAiSlugsBySelector(config).entries()].flatMap(([selector, slugs]) =>
|
|
218
|
+
slugs
|
|
219
|
+
.filter(slug => !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug))
|
|
220
|
+
.map(slug => `${selector}/${slug}`),
|
|
221
|
+
);
|
|
222
|
+
const disabled = new Set(config.disabledModels ?? []);
|
|
223
|
+
return unique([
|
|
224
|
+
...visible,
|
|
225
|
+
...qualified.filter(slug => !disabled.has(slug) && !disabled.has(slug.slice(slug.indexOf("/") + 1))),
|
|
226
|
+
]);
|
|
201
227
|
}
|
|
202
228
|
|
|
203
229
|
export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
|
|
@@ -214,6 +240,7 @@ export function applyNativeVisibility(
|
|
|
214
240
|
entries: RawEntry[],
|
|
215
241
|
disabledModels: ReadonlySet<string>,
|
|
216
242
|
hideBareNative = false,
|
|
243
|
+
observedNativeSlugs: ReadonlySet<string> = new Set(),
|
|
217
244
|
): RawEntry[] {
|
|
218
245
|
for (const entry of entries) {
|
|
219
246
|
if (isNativeAliasCatalogEntry(entry)) continue;
|
|
@@ -222,7 +249,7 @@ export function applyNativeVisibility(
|
|
|
222
249
|
const nativeSlug = accountBoundSlug ?? slug;
|
|
223
250
|
if (!nativeSlug
|
|
224
251
|
|| (!accountBoundSlug && slug.includes("/"))
|
|
225
|
-
|| !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue;
|
|
252
|
+
|| (!SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug) && !observedNativeSlugs.has(nativeSlug))) continue;
|
|
226
253
|
const disabled = disabledModels.has(nativeSlug)
|
|
227
254
|
|| (accountBoundSlug !== undefined && disabledModels.has(slug));
|
|
228
255
|
entry.visibility = disabled || (!accountBoundSlug && hideBareNative)
|
|
@@ -259,6 +286,154 @@ export function nativeOpenAiSlugs(): string[] {
|
|
|
259
286
|
return live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
|
|
260
287
|
}
|
|
261
288
|
|
|
289
|
+
const ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX = /^(?:gpt-|o1-|o3-|o4-)/;
|
|
290
|
+
const ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER = "opencodex_account_observed_native";
|
|
291
|
+
const ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER = "opencodex_account_observed_selectors";
|
|
292
|
+
|
|
293
|
+
function isAccountBoundOpenAiNativeSlug(slug: string): boolean {
|
|
294
|
+
return !slug.includes("/") && ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX.test(slug);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Shape/plausibility filter for a candidate account-native row. **This is not a trust control.**
|
|
299
|
+
*
|
|
300
|
+
* It checks that a row carries the field shape a real Codex catalog row has, which rejects
|
|
301
|
+
* malformed and minimal hand-written rows. It cannot distinguish a genuine upstream observation
|
|
302
|
+
* from a complete row typed by hand into `$CODEX_HOME/models_cache.json`: there is no signature,
|
|
303
|
+
* source identity, or server attestation to check. A full-shape forged row is accepted, and
|
|
304
|
+
* `observedFullShapeRowIsAccepted` in tests/native-model-toggle.test.ts pins that so nobody
|
|
305
|
+
* later mistakes this predicate for a security boundary.
|
|
306
|
+
*
|
|
307
|
+
* That is acceptable here because the file is user-owned and written by Codex itself: anyone
|
|
308
|
+
* able to rewrite it can already edit `config.json` or run `ocx` directly, and `router.ts`
|
|
309
|
+
* accepts any bare `gpt-*` id under an account namespace regardless of this catalog. What the
|
|
310
|
+
* filter buys is that garbage rows do not get advertised through discovery — not that an
|
|
311
|
+
* advertised row is proven genuine.
|
|
312
|
+
*/
|
|
313
|
+
function hasNativeCatalogRowShape(entry: RawEntry): boolean {
|
|
314
|
+
const levels = entry.supported_reasoning_levels;
|
|
315
|
+
const messages = entry.model_messages;
|
|
316
|
+
return typeof entry.base_instructions === "string"
|
|
317
|
+
&& entry.base_instructions.length > 0
|
|
318
|
+
&& (typeof entry.comp_hash === "string" || entry.comp_hash === null)
|
|
319
|
+
&& entry.shell_type === "shell_command"
|
|
320
|
+
&& Array.isArray(levels)
|
|
321
|
+
&& levels.length > 0
|
|
322
|
+
&& levels.every(level => typeof level === "object" && level !== null
|
|
323
|
+
&& typeof (level as { effort?: unknown }).effort === "string")
|
|
324
|
+
&& typeof messages === "object"
|
|
325
|
+
&& messages !== null
|
|
326
|
+
&& !Array.isArray(messages);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function observedAccountBoundNativeSlug(entry: RawEntry): string | undefined {
|
|
330
|
+
const accountBound = trustedAccountBoundNativeCatalogSlug(entry);
|
|
331
|
+
const slug = accountBound ?? (typeof entry.slug === "string" ? entry.slug : "");
|
|
332
|
+
if (!isAccountBoundOpenAiNativeSlug(slug)
|
|
333
|
+
|| entry.supported_in_api !== true
|
|
334
|
+
|| !hasNativeCatalogRowShape(entry)
|
|
335
|
+
|| (entry.visibility !== "list" && entry[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER] !== true)) {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
return slug;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Return exact, previously observed account-native rows that are not in the static release set.
|
|
343
|
+
* The result is used only to carry a hidden observation across startup cache invalidation.
|
|
344
|
+
*/
|
|
345
|
+
export function observedAccountBoundNativeEntries(
|
|
346
|
+
observedEntries: readonly RawEntry[],
|
|
347
|
+
): RawEntry[] {
|
|
348
|
+
const seen = new Set<string>();
|
|
349
|
+
return observedEntries.flatMap(entry => {
|
|
350
|
+
const slug = observedAccountBoundNativeSlug(entry);
|
|
351
|
+
// Only carry bare upstream observations across cache replacement. Account-qualified rows are
|
|
352
|
+
// already a projection of the current selector map and must not preserve private/stale labels.
|
|
353
|
+
if (!slug
|
|
354
|
+
|| typeof entry.slug !== "string"
|
|
355
|
+
|| entry.slug.includes("/")
|
|
356
|
+
|| SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)
|
|
357
|
+
|| seen.has(slug)) return [];
|
|
358
|
+
seen.add(slug);
|
|
359
|
+
return [structuredClone(entry)];
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Native ids observed in the user's Codex catalog/cache for account-qualified discovery.
|
|
365
|
+
*
|
|
366
|
+
* Unknown ids are deliberately returned only to callers that build selector-qualified rows. The
|
|
367
|
+
* static bare set remains the source of truth for global/API-key discovery, while this preserves
|
|
368
|
+
* exact account-scoped ids such as `gpt-daybreak-blue-latest` until the static set catches up.
|
|
369
|
+
*/
|
|
370
|
+
export function accountBoundNativeOpenAiSlugs(
|
|
371
|
+
observedEntries: readonly RawEntry[] = [
|
|
372
|
+
...(readCurrentCodexModelsCache()?.models ?? []),
|
|
373
|
+
// Existing generated rows are also safe to reuse after a process starts without a cache
|
|
374
|
+
// invalidation pass; bare user-authored catalog rows are intentionally not trusted here.
|
|
375
|
+
...(readCurrentCodexCatalog()?.models ?? []).filter(entry =>
|
|
376
|
+
trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
|
|
377
|
+
],
|
|
378
|
+
): string[] {
|
|
379
|
+
const observed = observedEntries.flatMap(entry => {
|
|
380
|
+
const slug = observedAccountBoundNativeSlug(entry);
|
|
381
|
+
return slug === undefined ? [] : [slug];
|
|
382
|
+
});
|
|
383
|
+
return unique([...NATIVE_OPENAI_MODELS, ...observed]);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Resolve account-native ids per public selector. Bare observations come from Codex's main
|
|
388
|
+
* catalog/cache, so they are eligible only for selectors that target the main account. A
|
|
389
|
+
* generated qualified row carries its own selector and never gets copied to an unrelated pool
|
|
390
|
+
* account. An explicit observation marker is public selector metadata only; private account ids
|
|
391
|
+
* never enter the catalog or cache.
|
|
392
|
+
*/
|
|
393
|
+
export function accountBoundNativeOpenAiSlugsBySelector(
|
|
394
|
+
config: AccountSelectorConfig,
|
|
395
|
+
observedEntries: readonly RawEntry[] = [
|
|
396
|
+
...(readCurrentCodexModelsCache()?.models ?? []),
|
|
397
|
+
...(readCurrentCodexCatalog()?.models ?? []).filter(entry =>
|
|
398
|
+
trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
|
|
399
|
+
],
|
|
400
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
401
|
+
const selectors = visibleCodexAccountSelectors(config);
|
|
402
|
+
const mainSelectors = new Set(mainAccountSelectors(config));
|
|
403
|
+
const result = new Map<string, Set<string>>(
|
|
404
|
+
selectors.map(selector => [selector, new Set(NATIVE_OPENAI_MODELS)]),
|
|
405
|
+
);
|
|
406
|
+
for (const entry of observedEntries) {
|
|
407
|
+
const slug = observedAccountBoundNativeSlug(entry);
|
|
408
|
+
if (slug === undefined || SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) continue;
|
|
409
|
+
const generated = trustedAccountBoundNativeCatalogSlug(entry);
|
|
410
|
+
const generatedSelector = generated === undefined || typeof entry.slug !== "string"
|
|
411
|
+
? undefined
|
|
412
|
+
: entry.slug.slice(0, entry.slug.indexOf("/"));
|
|
413
|
+
const markedSelectors = Array.isArray(entry[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER])
|
|
414
|
+
? entry[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER].filter((value): value is string => typeof value === "string")
|
|
415
|
+
: [];
|
|
416
|
+
const eligible = generatedSelector !== undefined
|
|
417
|
+
? (mainSelectors.has(generatedSelector) ? [generatedSelector] : [])
|
|
418
|
+
: markedSelectors.length > 0
|
|
419
|
+
? markedSelectors.filter(selector => mainSelectors.has(selector))
|
|
420
|
+
: [...mainSelectors];
|
|
421
|
+
for (const selector of eligible) {
|
|
422
|
+
const rows = result.get(selector);
|
|
423
|
+
if (rows) rows.add(slug);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return new Map([...result.entries()].map(([selector, slugs]) => [selector, [...slugs]]));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Unknown native ids observed from Codex, excluding the static release set. */
|
|
430
|
+
export function observedAccountBoundNativeOpenAiSlugs(
|
|
431
|
+
observedEntries?: readonly RawEntry[],
|
|
432
|
+
): string[] {
|
|
433
|
+
const all = accountBoundNativeOpenAiSlugs(observedEntries);
|
|
434
|
+
return all.filter(slug => !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug));
|
|
435
|
+
}
|
|
436
|
+
|
|
262
437
|
function catalogNativeSlugs(): string[] {
|
|
263
438
|
const cat = readCurrentCatalogOrCache();
|
|
264
439
|
const models = cat?.models ?? [];
|
|
@@ -393,17 +393,18 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls =
|
|
|
393
393
|
// Per-model routed opt-ins can be added once provider metadata exposes this capability.
|
|
394
394
|
delete entry.supports_reasoning_summaries;
|
|
395
395
|
const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
396
|
+
// `supports_search_tool` selects Codex's deferred tool-discovery surface; it is not the hosted
|
|
397
|
+
// web-search capability. OpenCodex can round-trip tool_search when a client sends it, but routed
|
|
398
|
+
// providers have no provider/model proof that Codex App plugins work through that deferred
|
|
399
|
+
// surface. Advertising it unconditionally hides the App's compatible direct MCP tools (#1522),
|
|
400
|
+
// so routed rows fail closed to direct discovery. The sidecar-backed hosted web-search metadata
|
|
401
|
+
// remains advertised independently for non-Cursor routes.
|
|
400
402
|
if (isCursorEntry) {
|
|
401
403
|
delete entry.web_search_tool_type;
|
|
402
|
-
entry.supports_search_tool = false;
|
|
403
404
|
} else {
|
|
404
405
|
entry.web_search_tool_type = "text_and_image";
|
|
405
|
-
entry.supports_search_tool = true;
|
|
406
406
|
}
|
|
407
|
+
entry.supports_search_tool = false;
|
|
407
408
|
// Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
|
|
408
409
|
// Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
|
|
409
410
|
// Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the
|
|
@@ -30,11 +30,12 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
|
30
30
|
import { redactSecretString } from "../../lib/redact";
|
|
31
31
|
import upstreamModelsSnapshot from "../data/upstream-models.json";
|
|
32
32
|
import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
33
|
+
import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces";
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
|
|
36
37
|
import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
|
|
37
|
-
import { applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata";
|
|
38
|
+
import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata";
|
|
38
39
|
import {
|
|
39
40
|
bundledCatalogCacheState,
|
|
40
41
|
loadBundledCodexCatalog,
|
|
@@ -313,7 +314,7 @@ export function deriveEntry(
|
|
|
313
314
|
slug, display_name: routedDisplayName(slug), description: desc,
|
|
314
315
|
shell_type: "shell_command", visibility: "list", supported_in_api: true,
|
|
315
316
|
priority, base_instructions: "You are a helpful coding assistant.",
|
|
316
|
-
...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool:
|
|
317
|
+
...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: false } : {}),
|
|
317
318
|
};
|
|
318
319
|
if (isRouted) {
|
|
319
320
|
applyRoutedCodexToolMode(entry);
|
|
@@ -346,6 +347,10 @@ export interface ObservedCatalogEntryBuildInput {
|
|
|
346
347
|
readonly disabledNativeAccountSlugs: ReadonlySet<string>;
|
|
347
348
|
readonly multiAgentV2Enabled: boolean;
|
|
348
349
|
readonly openaiContextCap?: number;
|
|
350
|
+
/** Additional native ids to clone under account selectors, without creating bare rows. */
|
|
351
|
+
readonly accountNativeSlugs?: readonly string[];
|
|
352
|
+
/** Per-selector account ids; unknown observations must not be copied to unrelated accounts. */
|
|
353
|
+
readonly accountNativeSlugsBySelector?: ReadonlyMap<string, readonly string[]>;
|
|
349
354
|
}
|
|
350
355
|
|
|
351
356
|
/** Build entries with the process-observed Codex feature state. */
|
|
@@ -361,6 +366,8 @@ export function buildCatalogEntries(
|
|
|
361
366
|
suppressedBareNativeSlugs: ReadonlySet<string> = new Set(),
|
|
362
367
|
disabledNativeAccountSlugs: ReadonlySet<string> = new Set(),
|
|
363
368
|
contextCap?: number,
|
|
369
|
+
accountNativeSlugs?: readonly string[],
|
|
370
|
+
accountNativeSlugsBySelector?: ReadonlyMap<string, readonly string[]>,
|
|
364
371
|
): RawEntry[] {
|
|
365
372
|
return buildCatalogEntriesFromObservedState({
|
|
366
373
|
template,
|
|
@@ -375,6 +382,8 @@ export function buildCatalogEntries(
|
|
|
375
382
|
disabledNativeAccountSlugs,
|
|
376
383
|
multiAgentV2Enabled: isMultiAgentV2Enabled(),
|
|
377
384
|
openaiContextCap: contextCap,
|
|
385
|
+
accountNativeSlugs,
|
|
386
|
+
accountNativeSlugsBySelector,
|
|
378
387
|
});
|
|
379
388
|
}
|
|
380
389
|
|
|
@@ -392,6 +401,8 @@ export function buildCatalogEntriesFromObservedState({
|
|
|
392
401
|
disabledNativeAccountSlugs,
|
|
393
402
|
multiAgentV2Enabled,
|
|
394
403
|
openaiContextCap,
|
|
404
|
+
accountNativeSlugs,
|
|
405
|
+
accountNativeSlugsBySelector,
|
|
395
406
|
}: ObservedCatalogEntryBuildInput): RawEntry[] {
|
|
396
407
|
// Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
|
|
397
408
|
// models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
|
|
@@ -450,8 +461,16 @@ export function buildCatalogEntriesFromObservedState({
|
|
|
450
461
|
emittedNativeAliases.add(nativeAlias);
|
|
451
462
|
emittedNativeAliasSlugs.add(slug);
|
|
452
463
|
}
|
|
464
|
+
const nativeEntriesBySlug = new Map(nativeEntries.map(entry => [String(entry.slug), entry] as const));
|
|
453
465
|
for (const [selectorIndex, selector] of accountSelectors.entries()) {
|
|
454
|
-
|
|
466
|
+
const selectorNativeSlugs = accountNativeSlugsBySelector?.get(selector)
|
|
467
|
+
?? accountNativeSlugs
|
|
468
|
+
?? gptSlugs;
|
|
469
|
+
const accountNativeEntries = selectorNativeSlugs.map(slug => (
|
|
470
|
+
nativeEntriesBySlug.get(slug)
|
|
471
|
+
?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap)
|
|
472
|
+
));
|
|
473
|
+
for (const [nativeIndex, native] of accountNativeEntries.entries()) {
|
|
455
474
|
const nativeSlug = String(native.slug);
|
|
456
475
|
if (disabledNativeAccountSlugs.has(nativeSlug)) continue;
|
|
457
476
|
const e = JSON.parse(JSON.stringify(native)) as RawEntry;
|
|
@@ -785,6 +804,7 @@ export function mergeCatalogEntriesFromObservedState({
|
|
|
785
804
|
&& !(m.slug as string).includes("/")
|
|
786
805
|
&& m.owned_by !== COMBO_NAMESPACE
|
|
787
806
|
&& (policy.unsupportedNativeEntries === "preserve"
|
|
807
|
+
|| policy.nativeBackfillSlugs.includes(m.slug as string)
|
|
788
808
|
|| !isUnsupportedOpenAiNativeSlug(m.slug as string)))
|
|
789
809
|
.map(m => {
|
|
790
810
|
const slug = m.slug as string;
|
|
@@ -936,6 +956,11 @@ export function mergeCatalogEntriesFromObservedState({
|
|
|
936
956
|
}
|
|
937
957
|
|
|
938
958
|
const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries];
|
|
959
|
+
const observedNativeSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => {
|
|
960
|
+
const slug = trustedAccountBoundNativeCatalogSlug(entry);
|
|
961
|
+
return slug === undefined ? [] : [slug];
|
|
962
|
+
}));
|
|
963
|
+
for (const slug of policy.nativeBackfillSlugs) observedNativeSlugs.add(slug);
|
|
939
964
|
const mergedEntries = [...native, ...managedEntries].map(m => {
|
|
940
965
|
const normalized = normalizeServiceTiers(m);
|
|
941
966
|
if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap);
|
|
@@ -969,7 +994,7 @@ export function mergeCatalogEntriesFromObservedState({
|
|
|
969
994
|
// clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable
|
|
970
995
|
// only their generated account row.
|
|
971
996
|
const versionedEntries = applyMultiAgentMode(
|
|
972
|
-
applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0),
|
|
997
|
+
applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs),
|
|
973
998
|
multiAgentMode,
|
|
974
999
|
multiAgentV2Enabled,
|
|
975
1000
|
);
|
|
@@ -1055,6 +1080,7 @@ interface RetainedCatalogSyncRead {
|
|
|
1055
1080
|
readonly catalogPath: string;
|
|
1056
1081
|
readonly catalog: RawCatalog;
|
|
1057
1082
|
readonly onDiskCatalog: RawCatalog | null;
|
|
1083
|
+
readonly modelsCache: RawCatalog | null;
|
|
1058
1084
|
readonly evidence: string;
|
|
1059
1085
|
/**
|
|
1060
1086
|
* Process-local epochs, baselined AFTER our own gather rather than with the
|
|
@@ -1163,9 +1189,10 @@ function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | n
|
|
|
1163
1189
|
// merge source. Preservation must inspect the file that this sync is about to overwrite;
|
|
1164
1190
|
// otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
|
|
1165
1191
|
const onDiskCatalog = readCatalog(catalogPath);
|
|
1192
|
+
const modelsCache = readCatalog(activeCodexModelsCachePath());
|
|
1166
1193
|
const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog);
|
|
1167
1194
|
// `processEvidence` is filled in after the provider await, not here.
|
|
1168
|
-
return { catalogPath, catalog, onDiskCatalog, evidence, processEvidence: "" };
|
|
1195
|
+
return { catalogPath, catalog, onDiskCatalog, modelsCache, evidence, processEvidence: "" };
|
|
1169
1196
|
}
|
|
1170
1197
|
|
|
1171
1198
|
function revalidateRetainedCatalogSync(
|
|
@@ -1181,6 +1208,7 @@ function revalidateRetainedCatalogSync(
|
|
|
1181
1208
|
catalogPath,
|
|
1182
1209
|
catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog,
|
|
1183
1210
|
onDiskCatalog: readCatalog(catalogPath),
|
|
1211
|
+
modelsCache: readCatalog(activeCodexModelsCachePath()),
|
|
1184
1212
|
evidence,
|
|
1185
1213
|
processEvidence: prepared.processEvidence,
|
|
1186
1214
|
};
|
|
@@ -1283,6 +1311,20 @@ function writeRetainedCatalogSync({
|
|
|
1283
1311
|
const accountSelectors = includeAccountBoundNativeOpenAi
|
|
1284
1312
|
? visibleCodexAccountSelectors(config)
|
|
1285
1313
|
: [];
|
|
1314
|
+
const observedAccountNativeEntries = [
|
|
1315
|
+
...(read.modelsCache?.models ?? []),
|
|
1316
|
+
...(onDiskCatalog?.models ?? []).filter(entry =>
|
|
1317
|
+
trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
|
|
1318
|
+
];
|
|
1319
|
+
const accountNativeSlugs = accountSelectors.length > 0
|
|
1320
|
+
? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries)
|
|
1321
|
+
: [];
|
|
1322
|
+
const accountNativeSlugsBySelector = accountSelectors.length > 0
|
|
1323
|
+
? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)
|
|
1324
|
+
: new Map<string, readonly string[]>();
|
|
1325
|
+
// Unknown account-native ids have no safe bare/global identity. They are only projected through
|
|
1326
|
+
// the selector map above; the no-selector catalog remains the static native/API-key surface.
|
|
1327
|
+
const observedNativeSlugs: string[] = [];
|
|
1286
1328
|
const wsEnabled = websocketsEnabled(config);
|
|
1287
1329
|
const multiAgentV2Enabled = isMultiAgentV2Enabled();
|
|
1288
1330
|
const goEntries = buildCatalogEntriesFromObservedState({
|
|
@@ -1343,6 +1385,8 @@ function writeRetainedCatalogSync({
|
|
|
1343
1385
|
disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))),
|
|
1344
1386
|
multiAgentV2Enabled,
|
|
1345
1387
|
openaiContextCap,
|
|
1388
|
+
accountNativeSlugs,
|
|
1389
|
+
accountNativeSlugsBySelector,
|
|
1346
1390
|
}).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined)
|
|
1347
1391
|
: [];
|
|
1348
1392
|
catalog.models = mergeCatalogEntriesFromObservedState({
|
|
@@ -1368,6 +1412,7 @@ function writeRetainedCatalogSync({
|
|
|
1368
1412
|
openaiContextCap,
|
|
1369
1413
|
policy: {
|
|
1370
1414
|
...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
|
|
1415
|
+
nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs],
|
|
1371
1416
|
warningPolicy: "emit",
|
|
1372
1417
|
},
|
|
1373
1418
|
});
|
|
@@ -1591,10 +1636,32 @@ export function invalidateCodexModelsCacheWithPermit(
|
|
|
1591
1636
|
if (!existsSync(catalogPath)) return false;
|
|
1592
1637
|
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
1593
1638
|
const models = catalog.models ?? catalog;
|
|
1639
|
+
const currentCache = readCatalog(activeCodexModelsCachePath());
|
|
1640
|
+
const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
|
|
1641
|
+
typeof entry.slug === "string" ? [entry.slug] : []));
|
|
1642
|
+
const currentConfig = loadConfig();
|
|
1643
|
+
const mainSelectors = visibleCodexAccountSelectors(currentConfig).filter(selector => {
|
|
1644
|
+
const target = new Map(codexAccountNamespaceEntries(currentConfig)).get(selector);
|
|
1645
|
+
return isMainCodexAccountTarget(target ?? "");
|
|
1646
|
+
});
|
|
1647
|
+
const observedAccountModels = observedAccountBoundNativeEntries(currentCache?.models ?? [])
|
|
1648
|
+
.filter(entry => {
|
|
1649
|
+
const slug = typeof entry.slug === "string" ? entry.slug : "";
|
|
1650
|
+
return !existingSlugs.has(slug);
|
|
1651
|
+
})
|
|
1652
|
+
.map(entry => ({
|
|
1653
|
+
...entry,
|
|
1654
|
+
// Keep the observation in Codex's cache without advertising a new bare picker row. The
|
|
1655
|
+
// next OpenCodex catalog sync consumes this marker and creates only selector-qualified
|
|
1656
|
+
// rows for the currently configured public account selectors.
|
|
1657
|
+
visibility: "hide",
|
|
1658
|
+
opencodex_account_observed_native: true,
|
|
1659
|
+
opencodex_account_observed_selectors: mainSelectors,
|
|
1660
|
+
}));
|
|
1594
1661
|
const wrapper = {
|
|
1595
1662
|
fetched_at: "2000-01-01T00:00:00Z",
|
|
1596
1663
|
client_version: "0.0.0",
|
|
1597
|
-
models,
|
|
1664
|
+
models: [...models, ...observedAccountModels],
|
|
1598
1665
|
};
|
|
1599
1666
|
replaceCodexModelsCache(permit, owningCodexHome, {
|
|
1600
1667
|
path: activeCodexModelsCachePath(),
|
package/src/codex/catalog.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Public surface preserved exactly; importers keep using "src/codex/catalog".
|
|
3
3
|
export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
|
|
4
4
|
export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
|
|
5
|
-
export { CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
|
|
5
|
+
export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
|
|
6
6
|
export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
|
|
7
7
|
export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
|
|
8
8
|
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
|
package/src/codex/convergence.ts
CHANGED
|
@@ -49,6 +49,8 @@ import {
|
|
|
49
49
|
import { exactComboCatalogSlugs } from "./catalog/aggregation";
|
|
50
50
|
import {
|
|
51
51
|
isNativeAliasCatalogEntry,
|
|
52
|
+
accountBoundNativeOpenAiSlugs,
|
|
53
|
+
accountBoundNativeOpenAiSlugsBySelector,
|
|
52
54
|
disabledNativeSlugs,
|
|
53
55
|
desktopAllowlistSuppressedNativeSlugs,
|
|
54
56
|
NATIVE_OPENAI_MODELS,
|
|
@@ -216,6 +218,7 @@ function prepareCatalog(
|
|
|
216
218
|
baselineCatalogModels: readonly Readonly<Record<string, unknown>>[],
|
|
217
219
|
degradedProviderNames: ReadonlySet<string>,
|
|
218
220
|
nativeRecoverySources: readonly (readonly RawEntry[])[] = [],
|
|
221
|
+
observedAccountNativeEntries: readonly RawEntry[] = [],
|
|
219
222
|
): RawCatalog {
|
|
220
223
|
const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog;
|
|
221
224
|
const template = findNativeTemplate(catalog);
|
|
@@ -232,6 +235,15 @@ function prepareCatalog(
|
|
|
232
235
|
const accountSelectors = shouldIncludeAccountBoundNativeOpenAi(config)
|
|
233
236
|
? visibleCodexAccountSelectors(config)
|
|
234
237
|
: [];
|
|
238
|
+
const accountNativeSlugs = accountSelectors.length > 0
|
|
239
|
+
? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries)
|
|
240
|
+
: [];
|
|
241
|
+
const accountNativeSlugsBySelector = accountSelectors.length > 0
|
|
242
|
+
? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)
|
|
243
|
+
: new Map<string, readonly string[]>();
|
|
244
|
+
// Unknown account-native ids have no safe bare/global identity. They are only projected through
|
|
245
|
+
// selector-qualified rows when a live selector is configured.
|
|
246
|
+
const observedNativeSlugs: string[] = [];
|
|
235
247
|
const disabledNative = disabledNativeSlugs(config);
|
|
236
248
|
const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery(
|
|
237
249
|
active?.models ?? catalog.models ?? [],
|
|
@@ -265,6 +277,8 @@ function prepareCatalog(
|
|
|
265
277
|
suppressedBareNativeSlugs,
|
|
266
278
|
disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))),
|
|
267
279
|
multiAgentV2Enabled,
|
|
280
|
+
accountNativeSlugs,
|
|
281
|
+
accountNativeSlugsBySelector,
|
|
268
282
|
}).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined);
|
|
269
283
|
const gatheredProviderNames = new Set(enabledProviders.map(([name]) => name));
|
|
270
284
|
const selectedModelsByProvider = new Map<string, ReadonlySet<string>>(
|
|
@@ -296,6 +310,7 @@ function prepareCatalog(
|
|
|
296
310
|
suppressedBareNativeSlugs,
|
|
297
311
|
policy: {
|
|
298
312
|
...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
|
|
313
|
+
nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs],
|
|
299
314
|
warningPolicy: "suppress",
|
|
300
315
|
},
|
|
301
316
|
});
|
|
@@ -384,6 +399,11 @@ export async function gatherCodexCatalogCandidate(
|
|
|
384
399
|
catalogFrom(keyedBackupBytes)?.models ?? [],
|
|
385
400
|
catalogFrom(legacyBackupBytes)?.models ?? [],
|
|
386
401
|
],
|
|
402
|
+
[
|
|
403
|
+
...(catalogFrom(cacheBytes)?.models ?? []),
|
|
404
|
+
...(catalogFrom(activeBytes)?.models ?? []).filter(entry =>
|
|
405
|
+
trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
|
|
406
|
+
],
|
|
387
407
|
);
|
|
388
408
|
const preparedCatalogBytes = catalogBytes(preparedCatalog);
|
|
389
409
|
const preparedCacheBytes = `${JSON.stringify({
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* recovery sees a mismatch and overwrites their work with a stale image.
|
|
21
21
|
*/
|
|
22
22
|
import { existsSync, mkdirSync, openSync, closeSync, fsyncSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
|
-
import { dirname } from "node:path";
|
|
23
|
+
import { dirname, resolve } from "node:path";
|
|
24
24
|
import { createHash, randomBytes } from "node:crypto";
|
|
25
25
|
import { forgetEphemeralSecretPath, hardenSecretPath, windowsSecretAclApplies } from "../lib/windows-secret-acl";
|
|
26
26
|
|
|
@@ -210,14 +210,40 @@ export type RecoveryOutcome =
|
|
|
210
210
|
| { ok: true; action: "none" | "committed" | "rolled-back" }
|
|
211
211
|
| { ok: false; error: "recovery_required"; detail: string };
|
|
212
212
|
|
|
213
|
+
export interface RecoveryTargets {
|
|
214
|
+
configPath: string;
|
|
215
|
+
storePath: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Lexical path identity only: never resolve an untrusted journal path through the filesystem. */
|
|
219
|
+
export function sameRecoveryPath(
|
|
220
|
+
recordedPath: string,
|
|
221
|
+
expectedPath: string,
|
|
222
|
+
platform: NodeJS.Platform = process.platform,
|
|
223
|
+
): boolean {
|
|
224
|
+
const recorded = resolve(recordedPath);
|
|
225
|
+
const expected = resolve(expectedPath);
|
|
226
|
+
return platform === "win32"
|
|
227
|
+
? recorded.toLowerCase() === expected.toLowerCase()
|
|
228
|
+
: recorded === expected;
|
|
229
|
+
}
|
|
230
|
+
|
|
213
231
|
/**
|
|
214
232
|
* Run at service start and at every lock acquisition. Never from a read.
|
|
215
233
|
*
|
|
216
234
|
* A journal on disk means commit never happened, so the default is rollback.
|
|
217
235
|
* The single exception is not a roll-forward: when BOTH targets already hold the
|
|
218
236
|
* post-image the writes had finished and only the journal deletion was missing.
|
|
237
|
+
*
|
|
238
|
+
* The expected targets come from the active prompt-layer configuration, not
|
|
239
|
+
* from the journal. The envelope checksum detects accidental corruption, not a
|
|
240
|
+
* same-user forgery, so recovery binds the decoded record to those trusted
|
|
241
|
+
* paths before reading either target.
|
|
219
242
|
*/
|
|
220
|
-
export function recoverIfNeeded(
|
|
243
|
+
export function recoverIfNeeded(
|
|
244
|
+
journalPath: string,
|
|
245
|
+
expectedTargets: RecoveryTargets,
|
|
246
|
+
): RecoveryOutcome {
|
|
221
247
|
const raw = readOrNull(journalPath);
|
|
222
248
|
if (raw === null) return { ok: true, action: "none" };
|
|
223
249
|
|
|
@@ -230,11 +256,22 @@ export function recoverIfNeeded(journalPath: string): RecoveryOutcome {
|
|
|
230
256
|
};
|
|
231
257
|
}
|
|
232
258
|
|
|
233
|
-
|
|
234
|
-
|
|
259
|
+
if (
|
|
260
|
+
!sameRecoveryPath(record.configPath, expectedTargets.configPath)
|
|
261
|
+
|| !sameRecoveryPath(record.storePath, expectedTargets.storePath)
|
|
262
|
+
) {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
error: "recovery_required",
|
|
266
|
+
detail: `journal at ${journalPath} does not match the active prompt-layer paths; nothing was read from its targets`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const config = classify(readOrNull(expectedTargets.configPath), record.preConfig, record.postConfig);
|
|
271
|
+
const store = classify(readOrNull(expectedTargets.storePath), record.preStore, record.postStore);
|
|
235
272
|
|
|
236
273
|
if (config === "unknown" || store === "unknown") {
|
|
237
|
-
const which = config === "unknown" ?
|
|
274
|
+
const which = config === "unknown" ? expectedTargets.configPath : expectedTargets.storePath;
|
|
238
275
|
return {
|
|
239
276
|
ok: false,
|
|
240
277
|
error: "recovery_required",
|
|
@@ -258,38 +295,38 @@ export function recoverIfNeeded(journalPath: string): RecoveryOutcome {
|
|
|
258
295
|
// Revalidate each target immediately before its own restore: a target that
|
|
259
296
|
// changed since the initial classification must not be overwritten.
|
|
260
297
|
if (config === "post") {
|
|
261
|
-
if (classify(readOrNull(
|
|
298
|
+
if (classify(readOrNull(expectedTargets.configPath), record.preConfig, record.postConfig) !== "post") {
|
|
262
299
|
return {
|
|
263
300
|
ok: false,
|
|
264
301
|
error: "recovery_required",
|
|
265
|
-
detail: `${
|
|
302
|
+
detail: `${expectedTargets.configPath} changed after its first classification; refusing to overwrite it`,
|
|
266
303
|
};
|
|
267
304
|
}
|
|
268
305
|
try {
|
|
269
|
-
restore(
|
|
306
|
+
restore(expectedTargets.configPath, record.preConfigBytes);
|
|
270
307
|
} catch (error) {
|
|
271
308
|
return {
|
|
272
309
|
ok: false,
|
|
273
310
|
error: "recovery_required",
|
|
274
|
-
detail: `rollback of ${
|
|
311
|
+
detail: `rollback of ${expectedTargets.configPath} failed mid-write: ${error instanceof Error ? error.message : String(error)}`,
|
|
275
312
|
};
|
|
276
313
|
}
|
|
277
314
|
}
|
|
278
315
|
if (store === "post") {
|
|
279
|
-
if (classify(readOrNull(
|
|
316
|
+
if (classify(readOrNull(expectedTargets.storePath), record.preStore, record.postStore) !== "post") {
|
|
280
317
|
return {
|
|
281
318
|
ok: false,
|
|
282
319
|
error: "recovery_required",
|
|
283
|
-
detail: `${
|
|
320
|
+
detail: `${expectedTargets.storePath} changed after its first classification; refusing to overwrite it`,
|
|
284
321
|
};
|
|
285
322
|
}
|
|
286
323
|
try {
|
|
287
|
-
restore(
|
|
324
|
+
restore(expectedTargets.storePath, record.preStoreBytes);
|
|
288
325
|
} catch (error) {
|
|
289
326
|
return {
|
|
290
327
|
ok: false,
|
|
291
328
|
error: "recovery_required",
|
|
292
|
-
detail: `rollback of ${
|
|
329
|
+
detail: `rollback of ${expectedTargets.storePath} failed mid-write: ${error instanceof Error ? error.message : String(error)}`,
|
|
293
330
|
};
|
|
294
331
|
}
|
|
295
332
|
}
|