@bitkyc08/opencodex 2.77.0 → 2.78.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/bin/ocx.mjs +11 -10
- package/gui/dist/assets/App-BtfG5id-.js +54 -0
- package/gui/dist/assets/App-lCIslZ1L.css +1 -0
- package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
- package/gui/dist/assets/index-DmQB8D_R.css +1 -0
- package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
- package/gui/dist/index.html +2 -2
- 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/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 +35 -19
- package/src/claude/intercept/client-hello.ts +103 -0
- package/src/claude/intercept/model-bindings.ts +3 -2
- package/src/claude/intercept/picker-listener.ts +279 -66
- 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 +30 -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 +34 -8
- package/src/cli/agent-runtime-settings.ts +130 -0
- package/src/cli/agent-settings.ts +101 -0
- package/src/cli/agent.ts +19 -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 -1199
- package/src/cli/capability-types.ts +54 -0
- package/src/cli/catalog-command-result.ts +57 -0
- package/src/cli/chatgpt-command.ts +25 -2
- 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/codex-shim-autorestore.ts +2 -1
- package/src/cli/codex-shim-readiness.ts +23 -3
- 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/dispatch.ts +52 -79
- package/src/cli/doctor.ts +22 -4
- package/src/cli/help-catalog.ts +31 -14
- package/src/cli/help-recovery.ts +2 -2
- package/src/cli/help.ts +25 -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 +7 -0
- 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/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 +112 -34
- package/src/cli/registry.ts +62 -20
- 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 +12 -0
- package/src/cli/route-policy-write.ts +137 -0
- package/src/cli/route-policy.ts +8 -0
- package/src/cli/runtime-api.ts +56 -79
- 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/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/dsh.ts +21 -0
- package/src/clients/config-export.ts +7 -1
- 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/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 +58 -10
- 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/codex-home-owner.ts +124 -0
- package/src/codex/convergence.ts +55 -15
- 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/management-convergence.ts +3 -0
- package/src/codex/quota-types.ts +3 -0
- package/src/codex/quota.ts +13 -3
- package/src/codex/refresh.ts +20 -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 +14 -2
- package/src/codex/shim-probe.ts +4 -3
- package/src/codex/shim-state-file.ts +93 -7
- package/src/codex/shim.ts +15 -68
- package/src/codex/sync.ts +24 -4
- package/src/combos/failover.ts +19 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/atomic-write.ts +13 -8
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +3 -0
- package/src/config/schema/chatgpt-desktop.ts +22 -0
- package/src/config/schema/config-schema.ts +32 -14
- package/src/config/schema/leaf-validators.ts +3 -2
- package/src/generated/compatibility-version.json +445 -129
- package/src/integrations/merge.ts +13 -5
- package/src/integrations/mutation-plan.ts +46 -3
- 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 +46 -21
- package/src/integrations/target.ts +25 -2
- package/src/integrations/writer.ts +89 -31
- package/src/lib/package-tree-integrity.ts +42 -1
- 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-routing.ts +17 -12
- package/src/oauth/anthropic.ts +27 -2
- package/src/oauth/index.ts +6 -8
- package/src/oauth/pool-settings-capability.ts +9 -2
- package/src/protocols/plan-snapshot.ts +1 -1
- package/src/protocols/settings.ts +28 -4
- package/src/server/caller-session-identity.ts +32 -0
- package/src/server/claude-messages.ts +11 -4
- package/src/server/index/serve-options.ts +12 -7
- package/src/server/index.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +6 -5
- 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 +5 -5
- package/src/server/management/model-routes.ts +3 -3
- 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 +25 -24
- 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/responses/adapter-dispatch.ts +53 -28
- package/src/server/responses/core-auth.ts +10 -4
- package/src/server/responses/core-combo.ts +46 -6
- 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/types/config.ts +2 -0
- package/src/types/provider.ts +10 -10
- package/src/update/index.ts +2 -1
- package/src/update/update-failure-guidance.d.mts +6 -0
- package/src/update/update-failure-guidance.mjs +30 -5
- package/gui/dist/assets/App-COdEeqSj.css +0 -1
- package/gui/dist/assets/App-DEpCJEP_.js +0 -54
- package/gui/dist/assets/index-Cd7ittMv.css +0 -1
- package/gui/dist/assets/index-DwJKFJm5.js +0 -94
- package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
package/src/codex/convergence.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { projectAntigravitySelectedModels } from "../providers/antigravity-effort-families";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { sameCatalogHealPath } from "./catalog/heal-observation";
|
|
3
4
|
|
|
4
5
|
import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
|
|
5
6
|
import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy";
|
|
@@ -83,11 +84,20 @@ import {
|
|
|
83
84
|
type CodexModelEntitlementSnapshot,
|
|
84
85
|
} from "./model-entitlements";
|
|
85
86
|
import { resolveAdmittedCodexModelEntitlements } from "./model-entitlement-admission";
|
|
87
|
+
import {
|
|
88
|
+
FOREIGN_CODEX_HOME_OWNER_MESSAGE,
|
|
89
|
+
UNKNOWN_CODEX_HOME_OWNER_MESSAGE,
|
|
90
|
+
routedRemovalBackedByConfigFile,
|
|
91
|
+
unbackedRoutedRemovalMessage,
|
|
92
|
+
unconfiguredRoutedRemoval,
|
|
93
|
+
type UnconfiguredRoutedRemoval,
|
|
94
|
+
} from "./catalog/routed-removal";
|
|
86
95
|
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
|
|
87
96
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
88
97
|
import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
89
98
|
import { withCatalogWriteSerialization } from "./catalog-write-serialization";
|
|
90
99
|
import {
|
|
100
|
+
auditRefusedCatalogReplacement,
|
|
91
101
|
publishHashedCodexCatalogBackup,
|
|
92
102
|
publishLegacyCodexCatalogBackup,
|
|
93
103
|
replaceActiveCodexCatalog,
|
|
@@ -109,14 +119,20 @@ import type {
|
|
|
109
119
|
export interface CatalogWriteReceipt {
|
|
110
120
|
readonly keyedBackup: "written" | "preserved" | "not-requested";
|
|
111
121
|
readonly legacyBackup: "written" | "preserved" | "not-requested";
|
|
112
|
-
|
|
113
|
-
readonly
|
|
122
|
+
/** `unchanged`: the bytes on disk already matched, so nothing was rewritten. */
|
|
123
|
+
readonly catalog: "written" | "unchanged" | "not-written";
|
|
124
|
+
readonly cache: "written" | "unchanged" | "not-written";
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
export type CodexCatalogCommitResult =
|
|
117
128
|
| { readonly kind: "committed"; readonly changed: boolean; readonly writes: CatalogWriteReceipt }
|
|
118
129
|
| { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" | "account-entitlement" }
|
|
119
|
-
|
|
130
|
+
/**
|
|
131
|
+
* `unbacked-routed-removal`: the candidate drops routed namespaces config.json does not back, or
|
|
132
|
+
* clears every routed row while config.json is unreadable. `foreign-owner`: this Codex home is
|
|
133
|
+
* bound to another OPENCODEX_HOME (#6529).
|
|
134
|
+
*/
|
|
135
|
+
| { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" | "unbacked-routed-removal" | "foreign-owner" | "owner-unknown" }
|
|
120
136
|
| { readonly kind: "failed"; readonly surface: "disk"; readonly writes: CatalogWriteReceipt };
|
|
121
137
|
|
|
122
138
|
declare const catalogCandidateBrand: unique symbol;
|
|
@@ -140,10 +156,11 @@ interface CandidateState {
|
|
|
140
156
|
readonly cache: PreparedCatalogFileWrite;
|
|
141
157
|
readonly keyedBackup?: PreparedCatalogFileWrite;
|
|
142
158
|
readonly legacyBackup?: PreparedCatalogFileWrite;
|
|
143
|
-
readonly changed: boolean;
|
|
144
159
|
readonly notices: readonly CatalogNotice[];
|
|
145
160
|
readonly modelEntitlements: CodexModelEntitlementSnapshot;
|
|
146
161
|
readonly discoveryConfig?: OcxConfig;
|
|
162
|
+
/** Routed namespaces this candidate empties only because the driving config lacks them. */
|
|
163
|
+
readonly routedRemoval: UnconfiguredRoutedRemoval | null;
|
|
147
164
|
}
|
|
148
165
|
|
|
149
166
|
const candidateStates = new WeakMap<object, CandidateState>();
|
|
@@ -537,11 +554,10 @@ export async function gatherCodexCatalogCandidate(
|
|
|
537
554
|
...(pristineBytes ? { keyedBackup: { path: paths.keyedBackup, content: pristineBytes } } : {}),
|
|
538
555
|
...(pristineBytes && paths.legacyBackup
|
|
539
556
|
? { legacyBackup: { path: paths.legacyBackup, content: pristineBytes } } : {}),
|
|
540
|
-
changed: Buffer.from(activeBytes ?? []).toString("utf8") !== preparedCatalogBytes
|
|
541
|
-
|| Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
|
|
542
557
|
notices: Object.freeze([...notices]),
|
|
543
558
|
modelEntitlements,
|
|
544
559
|
...(discoveryChanged ? { discoveryConfig } : {}),
|
|
560
|
+
routedRemoval: unconfiguredRoutedRemoval(active, preparedCatalog, snapshot.config),
|
|
545
561
|
});
|
|
546
562
|
return { kind: "candidate", candidate };
|
|
547
563
|
} catch (error) {
|
|
@@ -626,11 +642,14 @@ function fixedCommit(state: CandidateState, permit: Parameters<typeof replaceAct
|
|
|
626
642
|
if (state.legacyBackup) {
|
|
627
643
|
writes = { ...writes, legacyBackup: publishLegacyCodexCatalogBackup(permit, state.home, state.legacyBackup) };
|
|
628
644
|
}
|
|
629
|
-
replaceActiveCodexCatalog(permit, state.home, state.catalog);
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
return { kind: "
|
|
645
|
+
const catalog = replaceActiveCodexCatalog(permit, state.home, state.catalog);
|
|
646
|
+
// The funnel's backstop: every routed row would go while config.json is not a readable file.
|
|
647
|
+
// Nothing past the pristine backups was written, and the cache must not describe a catalog
|
|
648
|
+
// that was never published.
|
|
649
|
+
if (catalog.kind === "refused") return { kind: "refused", reason: "unbacked-routed-removal" };
|
|
650
|
+
writes = { ...writes, catalog: catalog.kind };
|
|
651
|
+
writes = { ...writes, cache: replaceCodexModelsCache(permit, state.home, state.cache).kind };
|
|
652
|
+
return { kind: "committed", changed: writes.catalog === "written" || writes.cache === "written", writes };
|
|
634
653
|
} catch {
|
|
635
654
|
return { kind: "failed", surface: "disk", writes };
|
|
636
655
|
}
|
|
@@ -639,6 +658,7 @@ function fixedCommit(state: CandidateState, permit: Parameters<typeof replaceAct
|
|
|
639
658
|
export async function commitCodexCatalogCandidate(
|
|
640
659
|
candidate: CodexCatalogCandidate,
|
|
641
660
|
deadlineMs: number,
|
|
661
|
+
lifecycle: Readonly<{ beforeCommit?: () => boolean; expectedCatalogPath?: string }> = {},
|
|
642
662
|
): Promise<CommitAttempt> {
|
|
643
663
|
const state = candidateStates.get(candidate as object);
|
|
644
664
|
if (!state) return { kind: "refused", reason: "source-ambiguous" };
|
|
@@ -649,13 +669,33 @@ export async function commitCodexCatalogCandidate(
|
|
|
649
669
|
state.consumed = true;
|
|
650
670
|
const guarded = withExpectedConfigGenerationSync(state.generation, () => {
|
|
651
671
|
const invalid = revalidateCandidate(state);
|
|
652
|
-
|
|
672
|
+
if (invalid) return invalid;
|
|
673
|
+
if (lifecycle.expectedCatalogPath !== undefined && !sameCatalogHealPath(state.catalog.path, lifecycle.expectedCatalogPath)) {
|
|
674
|
+
return { kind: "stale", reason: "target-identity" } as const;
|
|
675
|
+
}
|
|
676
|
+
if (lifecycle.beforeCommit && lifecycle.beforeCommit() !== true) return { kind: "stale", reason: "process-local" } as const;
|
|
677
|
+
// A refresh may empty a routed namespace only when config.json on disk agrees it is gone.
|
|
678
|
+
// Read under K, so a config that fell back to defaults during a transient read failure,
|
|
679
|
+
// or that belongs to another OPENCODEX_HOME, cannot publish a native-only catalog (#6529).
|
|
680
|
+
if (state.routedRemoval !== null && !routedRemovalBackedByConfigFile(state.routedRemoval)) {
|
|
681
|
+
auditRefusedCatalogReplacement(permit, state.home, state.catalog, "unbacked-routed-removal");
|
|
682
|
+
console.warn(`[opencodex] ${unbackedRoutedRemovalMessage(state.routedRemoval.namespaces.length)}`);
|
|
683
|
+
return { kind: "refused", reason: "unbacked-routed-removal" } as const;
|
|
684
|
+
}
|
|
685
|
+
return fixedCommit(state, permit);
|
|
653
686
|
});
|
|
654
687
|
if (guarded.kind === "conflict") return { kind: "stale", reason: "generation" } as const;
|
|
655
688
|
if (guarded.kind === "unavailable") return { kind: "busy" } as const;
|
|
656
689
|
return guarded.value;
|
|
657
|
-
});
|
|
690
|
+
}, { intent: "refresh", writer: "convergence" });
|
|
658
691
|
if (acquired.kind === "completed") return acquired.value;
|
|
692
|
+
if (acquired.reason === "foreign-owner" || acquired.reason === "owner-unknown") {
|
|
693
|
+
// Not retryable: another OPENCODEX_HOME owns this Codex home until it restores (#6529).
|
|
694
|
+
state.consumed = true;
|
|
695
|
+
console.warn(`[opencodex] ${acquired.reason === "foreign-owner"
|
|
696
|
+
? FOREIGN_CODEX_HOME_OWNER_MESSAGE : UNKNOWN_CODEX_HOME_OWNER_MESSAGE}`);
|
|
697
|
+
return { kind: "refused", reason: acquired.reason };
|
|
698
|
+
}
|
|
659
699
|
if (acquired.reason !== "busy" || Date.now() >= deadline) return { kind: "busy" };
|
|
660
700
|
await Bun.sleep(Math.min(10, Math.max(1, deadline - Date.now())));
|
|
661
701
|
}
|
|
@@ -676,7 +716,7 @@ function projectCommit(result: CommitAttempt, notices: readonly CatalogNotice[])
|
|
|
676
716
|
export async function convergeCodexCatalog(
|
|
677
717
|
snapshot: CatalogAdmissionSnapshot,
|
|
678
718
|
request: ConvergeRequest,
|
|
679
|
-
lifecycle: Readonly<{ onCommitBegin?: () => void }> = {},
|
|
719
|
+
lifecycle: Readonly<{ onCommitBegin?: () => void; beforeCommit?: () => boolean; expectedCatalogPath?: string }> = {},
|
|
680
720
|
): Promise<Readonly<{ changed: boolean; catalogRefresh: CatalogDisposition }>> {
|
|
681
721
|
if (request.scope !== "catalog" || request.action !== "converge") {
|
|
682
722
|
return {
|
|
@@ -688,7 +728,7 @@ export async function convergeCodexCatalog(
|
|
|
688
728
|
if (gathered.kind === "disposition") return { changed: false, catalogRefresh: gathered.disposition };
|
|
689
729
|
const state = candidateStates.get(gathered.candidate as object)!;
|
|
690
730
|
lifecycle.onCommitBegin?.();
|
|
691
|
-
const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs);
|
|
731
|
+
const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs, lifecycle);
|
|
692
732
|
if (committed.kind === "committed" && state.discoveryConfig) {
|
|
693
733
|
const mutable = snapshot.config as OcxConfig;
|
|
694
734
|
mutable.modelDiscovery = state.discoveryConfig.modelDiscovery;
|
package/src/codex/features.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWri
|
|
|
38
38
|
import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl";
|
|
39
39
|
import { CODEX_CONFIG_PATH } from "./paths";
|
|
40
40
|
import { resolveAndPersistCodexRuntime } from "./runtime";
|
|
41
|
+
import { decodeOverlayState } from "./shim-state-file";
|
|
41
42
|
import { canonicalizeOpenCodexModeHint } from "./multi-agent-mode-policy";
|
|
42
43
|
|
|
43
44
|
/** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */
|
|
@@ -1232,13 +1233,19 @@ function codexNativeBinaryCandidates(command: string): string[] {
|
|
|
1232
1233
|
/** Backing paths tied to this exact OCX shim entry in codex-shim.json. */
|
|
1233
1234
|
function selectedShimBackingPaths(commandPath: string): string[] {
|
|
1234
1235
|
try {
|
|
1235
|
-
const
|
|
1236
|
+
const configDir = getConfigDir();
|
|
1237
|
+
const state = JSON.parse(readFileSync(join(configDir, "codex-shim.json"), "utf8")) as {
|
|
1238
|
+
schema?: unknown;
|
|
1239
|
+
mode?: unknown;
|
|
1236
1240
|
wrapperPath?: unknown;
|
|
1237
1241
|
originalPath?: unknown;
|
|
1238
1242
|
backupPath?: unknown;
|
|
1239
1243
|
realPath?: unknown;
|
|
1240
1244
|
wrappers?: Array<Record<string, unknown>>;
|
|
1241
1245
|
};
|
|
1246
|
+
const overlay = decodeOverlayState(state, configDir);
|
|
1247
|
+
if (overlay) return resolve(overlay.wrapperPath) === resolve(commandPath) ? [overlay.launcherPath] : [];
|
|
1248
|
+
if (state.schema !== undefined || state.mode !== undefined) return [];
|
|
1242
1249
|
const entries = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state];
|
|
1243
1250
|
const selected = resolve(commandPath);
|
|
1244
1251
|
for (const entry of entries) {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { assertCodexHomeOwner, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "../codex-home-owner";
|
|
1
2
|
import { loadConfig } from "../../config";
|
|
2
3
|
import { shouldSyncCodexOnStart } from "../desired-state";
|
|
3
4
|
import { siblingOfLivePort, siblingSkipMessage } from "../sibling-start";
|
|
4
5
|
import { withCatalogWriteSerialization } from "../catalog-write-serialization";
|
|
5
6
|
import { restoreCodexCatalogWithPermit } from "../catalog/sync";
|
|
7
|
+
import { readCodexCatalogPath } from "../catalog/parsing";
|
|
8
|
+
import { notifyCatalogPublication } from "../catalog/publication-observer";
|
|
6
9
|
import { withCodexWriteLock, CodexWriteLockSkipped } from "../codex-write-lock";
|
|
7
10
|
import { inspectNativeCodexOwnership } from "../../integrations/native/ownership-preflight";
|
|
8
11
|
import { resolveCodexHistoryTransition } from "../history-transition";
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
import {
|
|
25
28
|
journaledInjectedCatalogPath,
|
|
26
29
|
removeJournal,
|
|
30
|
+
releaseJournalHomeBinding,
|
|
27
31
|
restoreJournalState,
|
|
28
32
|
} from "../journal";
|
|
29
33
|
import {
|
|
@@ -128,6 +132,7 @@ export interface CodexRestoreHistoryResult {
|
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
export interface CodexNativeRestoreResult {
|
|
135
|
+
ownershipRefusal?: CodexHomeOwnerRefusalReason;
|
|
131
136
|
success: boolean;
|
|
132
137
|
message: string;
|
|
133
138
|
externalProvider?: string;
|
|
@@ -339,6 +344,9 @@ export interface RestoreConfigOptions {
|
|
|
339
344
|
|
|
340
345
|
/** The config/profile half of a native restore, reported as one artifact. */
|
|
341
346
|
function restoreCodexConfigInline(kind = "sync", options: RestoreConfigOptions = {}): CodexRestoreConfigResult {
|
|
347
|
+
assertCodexHomeOwner(getCodexHome());
|
|
348
|
+
beforeRestoreConfigForTests?.(kind);
|
|
349
|
+
assertCodexHomeOwner(getCodexHome());
|
|
342
350
|
const preImages = captureCodexPreImages();
|
|
343
351
|
const result = restoreCodexConfigInlineImpl(kind, options);
|
|
344
352
|
if (result.state === "failed") {
|
|
@@ -350,7 +358,6 @@ function restoreCodexConfigInline(kind = "sync", options: RestoreConfigOptions =
|
|
|
350
358
|
|
|
351
359
|
function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOptions): CodexRestoreConfigResult {
|
|
352
360
|
try {
|
|
353
|
-
beforeRestoreConfigForTests?.(kind);
|
|
354
361
|
const disposition = resolveRestoreHistoryDisposition(options.removeProviderTable);
|
|
355
362
|
if (disposition.kind === "refuse") {
|
|
356
363
|
return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${disposition.reason}.` };
|
|
@@ -368,12 +375,20 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
|
|
|
368
375
|
// accepting that tagged conversations stop opening.
|
|
369
376
|
const capturedBlock = options.removeProviderTable === true ? null : readOcxProviderTableBlock();
|
|
370
377
|
const journal = restoreJournalState();
|
|
378
|
+
if (journal.ownershipRefusal) throw new CodexHomeOwnerRefusal(journal.ownershipRefusal);
|
|
371
379
|
if (journal.unverified) {
|
|
372
380
|
return {
|
|
373
381
|
state: "failed", changed: false, action: "failed",
|
|
374
382
|
message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.",
|
|
375
383
|
};
|
|
376
384
|
}
|
|
385
|
+
if (journal.profileRestoreFailed) {
|
|
386
|
+
return {
|
|
387
|
+
state: "failed", changed: false, action: "failed",
|
|
388
|
+
message: "Codex profile could not be restored; native files and the journal binding were preserved.",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
assertCodexHomeOwner(getCodexHome());
|
|
377
392
|
const restored = journal.configRestored
|
|
378
393
|
? { success: true, message: "Codex config restored from opencodex journal.", retainedProviderTable: undefined as string[] | undefined }
|
|
379
394
|
: removeCodexConfig({
|
|
@@ -403,6 +418,9 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
|
|
|
403
418
|
if (settled.kind === "stand-down" && settled.retainProviderTable && capturedBlock !== null) {
|
|
404
419
|
retainedLines = retainOcxProviderTableOnDisk(capturedBlock) ?? retainedLines;
|
|
405
420
|
}
|
|
421
|
+
// All native work and the final migration check succeeded. A field-level
|
|
422
|
+
// fallback retains its snapshot, but no longer owns this Codex home.
|
|
423
|
+
releaseJournalHomeBinding();
|
|
406
424
|
}
|
|
407
425
|
if (restored.success && retainedLines !== null) {
|
|
408
426
|
return {
|
|
@@ -430,6 +448,7 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
|
|
|
430
448
|
}
|
|
431
449
|
: { state: "failed", changed: false, action: "failed", message: restored.message };
|
|
432
450
|
} catch (error) {
|
|
451
|
+
if (error instanceof CodexHomeOwnerRefusal) throw error;
|
|
433
452
|
return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
434
453
|
}
|
|
435
454
|
}
|
|
@@ -451,7 +470,8 @@ function restoreCodexCatalogArtifact(
|
|
|
451
470
|
const restored = withCatalogWriteSerialization(owningCodexHome, permit =>
|
|
452
471
|
revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())
|
|
453
472
|
? null
|
|
454
|
-
: restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath)
|
|
473
|
+
: restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath),
|
|
474
|
+
{ intent: "restore", writer: "codex-restore" });
|
|
455
475
|
return restored.kind === "completed" && restored.value !== null
|
|
456
476
|
? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." }
|
|
457
477
|
: restored.kind === "completed"
|
|
@@ -487,8 +507,10 @@ export async function restoreNativeCodexAsync(
|
|
|
487
507
|
const sibling = siblingRestoreSkip();
|
|
488
508
|
if (sibling) return sibling;
|
|
489
509
|
try {
|
|
510
|
+
assertCodexHomeOwner(getCodexHome());
|
|
490
511
|
return await restoreNativeCodexAsyncImpl(options);
|
|
491
512
|
} catch (error) {
|
|
513
|
+
if (error instanceof CodexHomeOwnerRefusal) return homeOwnerRestoreRefusal(error);
|
|
492
514
|
if (!(error instanceof CodexRestoreRefusal)) throw error;
|
|
493
515
|
return failedConfigRestoreEnvelope(error.config);
|
|
494
516
|
}
|
|
@@ -533,7 +555,7 @@ async function restoreNativeCodexAsyncImpl(
|
|
|
533
555
|
// Captured before the config half: a successful journal restore DELETES the journal, and
|
|
534
556
|
// restoring the config can drop `model_catalog_json`. Either one would hide the routed
|
|
535
557
|
// catalog we actually wrote (#1798).
|
|
536
|
-
const journaledCatalogPath = journaledInjectedCatalogPath();
|
|
558
|
+
const journaledCatalogPath = journaledInjectedCatalogPath() ?? readCodexCatalogPath();
|
|
537
559
|
let config: CodexRestoreConfigResult;
|
|
538
560
|
let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
|
|
539
561
|
|
|
@@ -549,6 +571,7 @@ async function restoreNativeCodexAsyncImpl(
|
|
|
549
571
|
readAdmissionUnderLock: () => witness,
|
|
550
572
|
},
|
|
551
573
|
(ctx) => {
|
|
574
|
+
assertCodexHomeOwner(getCodexHome());
|
|
552
575
|
if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
|
|
553
576
|
throw new CodexWriteLockSkipped("desired_enabled");
|
|
554
577
|
}
|
|
@@ -576,6 +599,7 @@ async function restoreNativeCodexAsyncImpl(
|
|
|
576
599
|
// Throw inside N so the published remove transition rolls back too.
|
|
577
600
|
if (restored.state === "failed") throw new CodexRestoreRefusal(restored);
|
|
578
601
|
} catch (error) {
|
|
602
|
+
if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
|
|
579
603
|
const compensated = restoreCodexPreImages(preImages);
|
|
580
604
|
if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
|
|
581
605
|
throw error;
|
|
@@ -619,6 +643,7 @@ async function restoreNativeCodexAsyncImpl(
|
|
|
619
643
|
}
|
|
620
644
|
|
|
621
645
|
if (config.state === "failed") return failedConfigRestoreEnvelope(config);
|
|
646
|
+
notifyCatalogPublication({ kind: "native-released", path: journaledCatalogPath });
|
|
622
647
|
const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
|
|
623
648
|
// Re-asked after the config half, because the store can paginate mid-transaction. Deciding
|
|
624
649
|
// the history job from the pre-write answer alone would spawn a Worker whose own preflight
|
|
@@ -681,11 +706,27 @@ async function restoreNativeCodexAsyncImpl(
|
|
|
681
706
|
};
|
|
682
707
|
}
|
|
683
708
|
|
|
709
|
+
function homeOwnerRestoreRefusal(error: CodexHomeOwnerRefusal): CodexNativeRestoreResult {
|
|
710
|
+
return { ...skippedRestoreEnvelope(false, error.message), ownershipRefusal: error.reason };
|
|
711
|
+
}
|
|
712
|
+
|
|
684
713
|
export function restoreNativeCodex(
|
|
685
714
|
options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
|
|
686
715
|
): CodexNativeRestoreResult {
|
|
687
716
|
const sibling = siblingRestoreSkip();
|
|
688
717
|
if (sibling) return sibling;
|
|
718
|
+
try {
|
|
719
|
+
assertCodexHomeOwner(getCodexHome());
|
|
720
|
+
return restoreNativeCodexImpl(options);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
if (error instanceof CodexHomeOwnerRefusal) return homeOwnerRestoreRefusal(error);
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function restoreNativeCodexImpl(
|
|
728
|
+
options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
|
|
729
|
+
): CodexNativeRestoreResult {
|
|
689
730
|
const activeProvider = currentExternalCodexModelProvider();
|
|
690
731
|
if (activeProvider) {
|
|
691
732
|
removeJournal();
|
|
@@ -700,9 +741,10 @@ export function restoreNativeCodex(
|
|
|
700
741
|
// Captured before the config half: a successful journal restore DELETES the journal, and
|
|
701
742
|
// restoring the config can drop `model_catalog_json`. Either one would hide the routed
|
|
702
743
|
// catalog we actually wrote (#1798).
|
|
703
|
-
const journaledCatalogPath = journaledInjectedCatalogPath();
|
|
744
|
+
const journaledCatalogPath = journaledInjectedCatalogPath() ?? readCodexCatalogPath();
|
|
704
745
|
const config = restoreCodexConfigInline("sync", options);
|
|
705
746
|
if (config.state === "failed") return failedConfigRestoreEnvelope(config);
|
|
747
|
+
notifyCatalogPublication({ kind: "native-released", path: journaledCatalogPath });
|
|
706
748
|
// Same mid-transaction pagination re-check as the async path.
|
|
707
749
|
const historyStoodDown = historyStandsDown
|
|
708
750
|
|| resolveRestoreHistoryDisposition(options.removeProviderTable).kind === "stand-down";
|
package/src/codex/inject.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
readConfigAdmissionSnapshot,
|
|
8
8
|
withConfigMutationLockSync,
|
|
9
9
|
} from "../config";
|
|
10
|
+
import { assertCodexHomeOwner, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
|
|
10
11
|
import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock";
|
|
11
12
|
import {
|
|
12
13
|
localClientSkipMessage,
|
|
@@ -134,6 +135,7 @@ function runClientWriteGuard(guard: InjectCodexOptions["beforeClientWrite"]): vo
|
|
|
134
135
|
|
|
135
136
|
|
|
136
137
|
export interface CodexInjectResult {
|
|
138
|
+
ownershipRefusal?: CodexHomeOwnerRefusalReason;
|
|
137
139
|
success: boolean;
|
|
138
140
|
message: string;
|
|
139
141
|
/** False when injection intentionally preserves configuration owned by another provider. */
|
|
@@ -192,8 +194,9 @@ export async function injectCodexConfig(
|
|
|
192
194
|
if (siblingOfLivePort() !== null) {
|
|
193
195
|
return { success: true, status: "skipped", skippedReason: "sibling", message: siblingSkipMessage() };
|
|
194
196
|
}
|
|
195
|
-
try { return await injectCodexConfigImpl(port, config, options); }
|
|
197
|
+
try { assertCodexHomeOwner(getCodexHome()); return await injectCodexConfigImpl(port, config, options); }
|
|
196
198
|
catch (error) {
|
|
199
|
+
if (error instanceof CodexHomeOwnerRefusal) return { success: false, ownershipRefusal: error.reason, message: error.message };
|
|
197
200
|
if (error instanceof CodexHistoryPreflightRefusal) return { success: false, historyPreflightFailureReason: error.message, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` };
|
|
198
201
|
if (error instanceof CodexInjectRefusal) return error.result;
|
|
199
202
|
throw error;
|
|
@@ -415,6 +418,7 @@ async function injectCodexConfigImpl(
|
|
|
415
418
|
* flip included — before the result is reported.
|
|
416
419
|
*/
|
|
417
420
|
const reconcileAndDerivePlan = (): { plan: CodexInjectionPlanOk; nativeInput: string } => {
|
|
421
|
+
assertCodexHomeOwner(getCodexHome());
|
|
418
422
|
if (missingConfig) createEmptyCodexConfigInBoundary();
|
|
419
423
|
let nativeInput = rawContent;
|
|
420
424
|
let plan = admittedPlan;
|
|
@@ -453,6 +457,7 @@ async function injectCodexConfigImpl(
|
|
|
453
457
|
beforeHistoryArtifactCommitForTests?.(eligibility.kind);
|
|
454
458
|
plan.historyRelabelRefusal = observeHistoryRefusalOrThrow(plan);
|
|
455
459
|
historyArtifactStageForTests?.("after-preflight");
|
|
460
|
+
assertCodexHomeOwner(getCodexHome());
|
|
456
461
|
writeJournal({
|
|
457
462
|
currentStateIsNative: journalBaselineIsNative(nativeInput),
|
|
458
463
|
configContent: plan.baselineContent,
|
|
@@ -463,6 +468,7 @@ async function injectCodexConfigImpl(
|
|
|
463
468
|
if (hasUnverifiedJournalBaseline(plan.baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage);
|
|
464
469
|
atomicWriteFile(CODEX_CONFIG_PATH, plan.content);
|
|
465
470
|
historyArtifactStageForTests?.("after-config");
|
|
471
|
+
assertCodexHomeOwner(getCodexHome());
|
|
466
472
|
atomicWriteFile(CODEX_PROFILE_PATH, plan.profileContent);
|
|
467
473
|
markJournalInjectedState(plan.content, plan.profileContent, {
|
|
468
474
|
// A root override is ours whenever we wrote one and no user-owned value won. That is
|
|
@@ -526,6 +532,7 @@ async function injectCodexConfigImpl(
|
|
|
526
532
|
};
|
|
527
533
|
}
|
|
528
534
|
runClientWriteGuard(options.beforeClientWrite);
|
|
535
|
+
assertCodexHomeOwner(getCodexHome());
|
|
529
536
|
/*
|
|
530
537
|
* One preimage covers the reconcile and the artifact commit together: a
|
|
531
538
|
* refusal after the feature transition hands back the exact bytes the
|
|
@@ -537,6 +544,7 @@ async function injectCodexConfigImpl(
|
|
|
537
544
|
applyNativeArtifacts(resolved.plan, resolved.nativeInput);
|
|
538
545
|
effectivePlan = resolved.plan;
|
|
539
546
|
} catch (error) {
|
|
547
|
+
if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
|
|
540
548
|
const restored = restoreCodexPreImages(preImages);
|
|
541
549
|
if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored);
|
|
542
550
|
throw error;
|
|
@@ -582,6 +590,7 @@ async function injectCodexConfigImpl(
|
|
|
582
590
|
// transition or capturing preimages; rejection must not compensate over
|
|
583
591
|
// a disconnect's restored files.
|
|
584
592
|
runClientWriteGuard(options.beforeClientWrite);
|
|
593
|
+
assertCodexHomeOwner(getCodexHome());
|
|
585
594
|
/*
|
|
586
595
|
* Exact pre-images, captured under the lock and used for compensation.
|
|
587
596
|
*
|
|
@@ -630,6 +639,7 @@ async function injectCodexConfigImpl(
|
|
|
630
639
|
}
|
|
631
640
|
applyNativeArtifacts(resolved.plan, resolved.nativeInput);
|
|
632
641
|
} catch (error) {
|
|
642
|
+
if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
|
|
633
643
|
// Compensate, then ALWAYS throw. Returning a partial result would let the
|
|
634
644
|
// lock commit a row describing an apply that did not finish.
|
|
635
645
|
const restored = restoreCodexPreImages(preImages);
|
|
@@ -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
|
}
|