@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
|
@@ -2,7 +2,7 @@ import { projectAntigravitySelectedModels } from "../../providers/antigravity-ef
|
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { initializeConfigOwnership } from "../../lib/config-ownership";
|
|
5
|
-
import { getConfigDir, loadConfig, websocketsEnabled } from "../../config";
|
|
5
|
+
import { getConfigDir, loadConfig, websocketsEnabled, withConfigMutationLockSync } from "../../config";
|
|
6
6
|
import { shouldSyncCodexOnStart } from "../desired-state";
|
|
7
7
|
import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration";
|
|
8
8
|
import { getCodexHome } from "../paths";
|
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
nativeMultiAgentDefaults,
|
|
41
41
|
} from "./parsing";
|
|
42
42
|
import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
|
|
43
|
+
import { ocxRoutedNamespaceCounts, routedRemovalBackedByConfigFile, unconfiguredRoutedRemoval } from "./routed-removal";
|
|
44
|
+
import { ConfigMutationLockError } from "../../config/mutation-lock";
|
|
43
45
|
import {
|
|
44
46
|
accountBoundNativeOpenAiSlugsBySelector,
|
|
45
47
|
desktopAllowlistSuppressedNativeSlugs,
|
|
@@ -64,11 +66,13 @@ import {
|
|
|
64
66
|
} from "../catalog-write-serialization";
|
|
65
67
|
import {
|
|
66
68
|
preparedBytesDifferFromDisk,
|
|
69
|
+
auditRefusedCatalogReplacement,
|
|
67
70
|
publishHashedCodexCatalogBackup,
|
|
68
71
|
publishLegacyCodexCatalogBackup,
|
|
69
72
|
replaceActiveCodexCatalog,
|
|
70
73
|
replaceCodexModelsCache,
|
|
71
74
|
type PreparedCatalogFileWrite,
|
|
75
|
+
type CatalogFileReplacement,
|
|
72
76
|
} from "../internal/catalog-writer";
|
|
73
77
|
import { visibleCodexAccountSelectors } from "./account-models";
|
|
74
78
|
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
|
|
@@ -105,8 +109,15 @@ interface RetainedCatalogSyncResult {
|
|
|
105
109
|
comboOmissions: ComboCatalogOmission[];
|
|
106
110
|
/** Validated catalog commit (including identical bytes), or a refused refresh. */
|
|
107
111
|
refreshOutcome?: "committed" | "refused";
|
|
108
|
-
/**
|
|
109
|
-
|
|
112
|
+
/**
|
|
113
|
+
* `desired_disabled`: observed under K after the provider await. `unbacked_routed_removal`: the
|
|
114
|
+
* catalog would lose routed namespaces config.json still enables, or config.json is missing or
|
|
115
|
+
* unreadable (#6529). `foreign_owner`: this Codex home is bound to another OPENCODEX_HOME. Nothing
|
|
116
|
+
* was written in any of these cases.
|
|
117
|
+
*/
|
|
118
|
+
skippedReason?: "desired_disabled" | "unbacked_routed_removal" | "foreign_owner" | "owner_unknown";
|
|
119
|
+
/** With `unbacked_routed_removal`: how many routed namespaces the refused write would have emptied. */
|
|
120
|
+
protectedRoutedNamespaces?: number;
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
/**
|
|
@@ -554,12 +565,46 @@ function writeRetainedCatalogSync({
|
|
|
554
565
|
if (!preparedBytesDifferFromDisk(preparedCatalog)) {
|
|
555
566
|
return { added, path: catalogPath, catalogWritten: false, comboOmissions };
|
|
556
567
|
}
|
|
557
|
-
|
|
558
|
-
|
|
568
|
+
// A refresh may drop a provider's rows only when config.json on disk agrees the provider is gone.
|
|
569
|
+
// Without that, a config that is not the user's (missing or unreadable file read as defaults,
|
|
570
|
+
// another OPENCODEX_HOME) would publish a native-only catalog and exit cleanly (#6529).
|
|
571
|
+
const refused = (protectedRoutedNamespaces: number): RetainedCatalogSyncResult => ({
|
|
572
|
+
added: 0,
|
|
573
|
+
path: catalogPath,
|
|
574
|
+
catalogWritten: false,
|
|
575
|
+
comboOmissions,
|
|
576
|
+
skippedReason: "unbacked_routed_removal",
|
|
577
|
+
protectedRoutedNamespaces,
|
|
578
|
+
});
|
|
579
|
+
let replacement: CatalogFileReplacement | undefined;
|
|
580
|
+
const removal = unconfiguredRoutedRemoval(onDiskCatalog, catalog, config);
|
|
581
|
+
if (removal !== null) {
|
|
582
|
+
// Hold C (K -> C) from the config read through the replacement, so a save that enables one of
|
|
583
|
+
// these providers cannot land between the check and the write. A held C is a refusal too.
|
|
584
|
+
let backed = false;
|
|
585
|
+
try {
|
|
586
|
+
backed = withConfigMutationLockSync(() => {
|
|
587
|
+
if (!routedRemovalBackedByConfigFile(removal)) return false;
|
|
588
|
+
replacement = replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
|
|
589
|
+
return replacement.kind !== "refused";
|
|
590
|
+
});
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (!(error instanceof ConfigMutationLockError)) throw error;
|
|
593
|
+
}
|
|
594
|
+
if (!backed) {
|
|
595
|
+
if (replacement?.kind !== "refused") {
|
|
596
|
+
auditRefusedCatalogReplacement(permit, owningCodexHome, preparedCatalog, "unbacked-routed-removal");
|
|
597
|
+
}
|
|
598
|
+
return refused(removal.namespaces.length);
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
replacement = replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
|
|
602
|
+
if (replacement.kind === "refused") return refused(ocxRoutedNamespaceCounts(onDiskCatalog).size);
|
|
603
|
+
}
|
|
559
604
|
return {
|
|
560
605
|
added,
|
|
561
606
|
path: catalogPath,
|
|
562
|
-
catalogWritten:
|
|
607
|
+
catalogWritten: replacement?.kind === "written",
|
|
563
608
|
comboOmissions,
|
|
564
609
|
};
|
|
565
610
|
}
|
|
@@ -649,7 +694,7 @@ export async function syncCatalogModels(
|
|
|
649
694
|
owningCodexHome,
|
|
650
695
|
modelEntitlements,
|
|
651
696
|
});
|
|
652
|
-
});
|
|
697
|
+
}, { intent: "refresh", writer: "retained-sync" });
|
|
653
698
|
if (committed.kind === "completed" && committed.value !== null) {
|
|
654
699
|
return {
|
|
655
700
|
...committed.value,
|
|
@@ -662,6 +707,10 @@ export async function syncCatalogModels(
|
|
|
662
707
|
catalogWritten: false,
|
|
663
708
|
comboOmissions,
|
|
664
709
|
refreshOutcome: "refused",
|
|
710
|
+
...(committed.kind === "unavailable" && committed.reason === "foreign-owner"
|
|
711
|
+
? { skippedReason: "foreign_owner" as const } : {}),
|
|
712
|
+
...(committed.kind === "unavailable" && committed.reason === "owner-unknown"
|
|
713
|
+
? { skippedReason: "owner_unknown" as const } : {}),
|
|
665
714
|
};
|
|
666
715
|
}
|
|
667
716
|
|
|
@@ -729,9 +778,7 @@ export function invalidateCodexModelsCacheWithPermitOutcome(
|
|
|
729
778
|
// `cacheSynced` mean what its name and its consumers already assume, and what
|
|
730
779
|
// `pullRemoteCatalog` and the early returns in `refreshCodexModelCatalog`
|
|
731
780
|
// already assert: a write happened.
|
|
732
|
-
|
|
733
|
-
replaceCodexModelsCache(permit, owningCodexHome, preparedCache);
|
|
734
|
-
return "written";
|
|
781
|
+
return replaceCodexModelsCache(permit, owningCodexHome, preparedCache).kind;
|
|
735
782
|
} catch {
|
|
736
783
|
return "failed";
|
|
737
784
|
}
|
|
@@ -750,6 +797,7 @@ export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): b
|
|
|
750
797
|
const outcome = withCatalogWriteSerialization(
|
|
751
798
|
owningCodexHome,
|
|
752
799
|
permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options),
|
|
800
|
+
{ intent: "cache", writer: "cache-invalidate" },
|
|
753
801
|
);
|
|
754
802
|
return outcome.kind === "completed" && outcome.value;
|
|
755
803
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { COMBO_NAMESPACE } from "../../combos";
|
|
2
|
+
import { readConfigAdmissionSnapshot, type ConfigAdmissionSnapshot } from "../../config/diagnostics";
|
|
3
|
+
import type { OcxConfig } from "../../types";
|
|
4
|
+
import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
|
|
5
|
+
import { isOcxAuthoredRoutedEntry } from "./build-entries";
|
|
6
|
+
import { isNativeAliasCatalogEntry } from "./metadata";
|
|
7
|
+
import type { RawCatalog, RawEntry } from "./parsing";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Routed namespaces a candidate catalog empties of OpenCodex-authored rows only because the config
|
|
11
|
+
* that produced it does not enable them: the "deleted provider" rule in build-entries. That rule is
|
|
12
|
+
* the one way a refresh removes a provider's rows wholesale, so it is the one a config that is not
|
|
13
|
+
* the user's (a missing or unreadable config.json read as defaults, another OPENCODEX_HOME) turns
|
|
14
|
+
* into a silent native-only catalog (#6529).
|
|
15
|
+
*/
|
|
16
|
+
export interface UnconfiguredRoutedRemoval {
|
|
17
|
+
readonly namespaces: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RoutedNamespaceConfig = Pick<OcxConfig, "providers" | "combos">;
|
|
21
|
+
|
|
22
|
+
function routedNamespace(entry: RawEntry): string | null {
|
|
23
|
+
if (!entry || typeof entry !== "object" || typeof entry.slug !== "string") return null;
|
|
24
|
+
// Account-bound native rows follow the account pool, not provider config.
|
|
25
|
+
if (trustedAccountBoundNativeCatalogSlug(entry) !== undefined) return null;
|
|
26
|
+
if (isNativeAliasCatalogEntry(entry)) return COMBO_NAMESPACE;
|
|
27
|
+
// Combo aliases may be bare or use an unrelated provider namespace. Ownership selects the
|
|
28
|
+
// combo namespace only with the same generated-description authorship signal as routed rows.
|
|
29
|
+
if (entry.owned_by === COMBO_NAMESPACE
|
|
30
|
+
&& typeof entry.description === "string"
|
|
31
|
+
&& entry.description.startsWith("Routed via opencodex → ")) return COMBO_NAMESPACE;
|
|
32
|
+
// Foreign rows (Cursor, user tooling) are never removed by the provider rule.
|
|
33
|
+
if (!isOcxAuthoredRoutedEntry(entry)) return null;
|
|
34
|
+
return entry.slug.slice(0, entry.slug.indexOf("/"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** OpenCodex-authored routed rows per namespace; foreign and account-bound rows never count. */
|
|
38
|
+
export function ocxRoutedNamespaceCounts(catalog: RawCatalog | null): Map<string, number> {
|
|
39
|
+
const counts = new Map<string, number>();
|
|
40
|
+
for (const entry of catalog?.models ?? []) {
|
|
41
|
+
const namespace = routedNamespace(entry);
|
|
42
|
+
if (namespace !== null) counts.set(namespace, (counts.get(namespace) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
return counts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Total OpenCodex-authored routed rows, or null for a value that is not a catalog. */
|
|
48
|
+
export function ocxRoutedRowCount(catalog: unknown): number | null {
|
|
49
|
+
if (catalog === null || typeof catalog !== "object" || !Array.isArray((catalog as RawCatalog).models)) return null;
|
|
50
|
+
let total = 0;
|
|
51
|
+
for (const count of ocxRoutedNamespaceCounts(catalog as RawCatalog).values()) total += count;
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a config still produces routed rows for a namespace: an enabled provider, or any combo. */
|
|
56
|
+
export function configEnablesRoutedNamespace(config: RoutedNamespaceConfig, namespace: string): boolean {
|
|
57
|
+
if (namespace === COMBO_NAMESPACE && Object.keys(config.combos ?? {}).length > 0) return true;
|
|
58
|
+
const provider = config.providers?.[namespace];
|
|
59
|
+
return provider !== undefined && provider.disabled !== true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The namespaces whose OpenCodex-authored routed rows are on disk now, absent from the candidate,
|
|
64
|
+
* and not enabled by the driving config; null when the candidate removes no such namespace.
|
|
65
|
+
* Removals inside a namespace the driving config still enables (discovery, selection, a deleted
|
|
66
|
+
* custom model) are the provider's own business and are not reported here.
|
|
67
|
+
*/
|
|
68
|
+
export function unconfiguredRoutedRemoval(
|
|
69
|
+
active: RawCatalog | null,
|
|
70
|
+
candidate: RawCatalog,
|
|
71
|
+
driving: RoutedNamespaceConfig,
|
|
72
|
+
): UnconfiguredRoutedRemoval | null {
|
|
73
|
+
const activeModels = active?.models ?? [];
|
|
74
|
+
if (activeModels.length === 0) return null;
|
|
75
|
+
const kept = new Set((candidate.models ?? []).flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []));
|
|
76
|
+
const namespaces = new Set<string>();
|
|
77
|
+
for (const entry of activeModels) {
|
|
78
|
+
if (typeof entry.slug === "string" && kept.has(entry.slug)) continue;
|
|
79
|
+
const namespace = routedNamespace(entry);
|
|
80
|
+
if (namespace !== null && !configEnablesRoutedNamespace(driving, namespace)) namespaces.add(namespace);
|
|
81
|
+
}
|
|
82
|
+
return namespaces.size > 0 ? { namespaces: [...namespaces].sort() } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* True when config.json on disk backs every removal: it is a readable, parseable file, and it enables
|
|
87
|
+
* none of the removed namespaces. A missing, unreadable or salvaged file backs nothing, and a file
|
|
88
|
+
* that still enables a namespace shows the driving config was not the one on disk. Read once, at
|
|
89
|
+
* write time, so a config that fell back to defaults while the file was briefly unreadable cannot
|
|
90
|
+
* pass on a later successful read.
|
|
91
|
+
*/
|
|
92
|
+
export function routedRemovalBackedByConfigFile(
|
|
93
|
+
removal: UnconfiguredRoutedRemoval,
|
|
94
|
+
read: () => ConfigAdmissionSnapshot = readConfigAdmissionSnapshot,
|
|
95
|
+
): boolean {
|
|
96
|
+
const snapshot = read();
|
|
97
|
+
if (snapshot.kind !== "read" || snapshot.diagnostics.source !== "file") return false;
|
|
98
|
+
return removal.namespaces.every(namespace => !configEnablesRoutedNamespace(snapshot.diagnostics.config, namespace));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One line for logs when K refused a writer from another OPENCODEX_HOME; names no path. */
|
|
102
|
+
export const FOREIGN_CODEX_HOME_OWNER_MESSAGE = "Codex catalog left unchanged: this Codex home was set up by "
|
|
103
|
+
+ "another OpenCodex home (OPENCODEX_HOME), recorded in its opencodex-journal.json. Run opencodex "
|
|
104
|
+
+ "from that home, or run `ocx restore` there first.";
|
|
105
|
+
|
|
106
|
+
/** Unavailable evidence is not proof of a foreign owner. No paths or journal content are exposed. */
|
|
107
|
+
export const UNKNOWN_CODEX_HOME_OWNER_MESSAGE = "Codex catalog left unchanged: ownership of this Codex home "
|
|
108
|
+
+ "could not be checked. Restore readable ownership evidence and retry.";
|
|
109
|
+
|
|
110
|
+
/** One line for logs: counts only, never provider names, model ids or paths. */
|
|
111
|
+
export function unbackedRoutedRemovalMessage(count: number): string {
|
|
112
|
+
return `Codex catalog left unchanged: this refresh would remove the routed models of ${count} `
|
|
113
|
+
+ `provider namespace${count === 1 ? "" : "s"} that config.json still enables, or config.json `
|
|
114
|
+
+ "is missing or unreadable. Run `ocx sync` again once config.json is readable; use `ocx restore` "
|
|
115
|
+
+ "to remove routed models on purpose.";
|
|
116
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Bottom-layer metadata contract: neither K nor the filesystem adapter owns intent. */
|
|
2
|
+
export type CatalogWriteIntent = "refresh" | "cache" | "pull" | "restore";
|
|
3
|
+
export type CatalogAuditConfigSource = "file" | "default" | "fallback" | "unreadable";
|
|
4
|
+
export type CatalogAuditRefusalReason = "foreign-owner" | "owner-unknown"
|
|
5
|
+
| "unbacked-routed-removal" | "unbacked-routed-clear";
|
|
6
|
+
|
|
7
|
+
export interface CatalogWriteAuditDetails {
|
|
8
|
+
readonly target: "catalog" | "cache";
|
|
9
|
+
readonly outcome: "written" | "refused";
|
|
10
|
+
readonly reason?: CatalogAuditRefusalReason;
|
|
11
|
+
readonly routedBefore?: number | null;
|
|
12
|
+
readonly routedAfter?: number | null;
|
|
13
|
+
readonly configSource?: CatalogAuditConfigSource;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CatalogWriteAuditEvent extends CatalogWriteAuditDetails {
|
|
17
|
+
readonly intent: CatalogWriteIntent;
|
|
18
|
+
readonly writer: string;
|
|
19
|
+
/** Supplied by K, never discovered by the adapter. */
|
|
20
|
+
readonly opencodexHome: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync, constants, fstatSync, ftruncateSync, lstatSync, openSync, readSync, writeSync,
|
|
3
|
+
type Stats,
|
|
4
|
+
} from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { redactUserPath } from "../../lib/redact";
|
|
7
|
+
import { hardenSecretPath, windowsSecretAclApplies } from "../../lib/windows-secret-acl";
|
|
8
|
+
import type { CatalogWriteAuditEvent } from "./write-audit-contract";
|
|
9
|
+
|
|
10
|
+
export const CODEX_CATALOG_AUDIT_FILE = "opencodex-catalog-audit.jsonl";
|
|
11
|
+
export const CATALOG_AUDIT_MAX_BYTES = 256 * 1024;
|
|
12
|
+
export const CATALOG_AUDIT_KEEP_RECORDS = 400;
|
|
13
|
+
export const CATALOG_AUDIT_MAX_EVENT_BYTES = 2 * 1024;
|
|
14
|
+
const MAX_HOME_CHARACTERS = 256;
|
|
15
|
+
const AUDIT_ACL_DEADLINE_MS = 1000;
|
|
16
|
+
const WRITERS = new Set([
|
|
17
|
+
"convergence", "retained-sync", "cache-from-catalog", "cache-invalidate",
|
|
18
|
+
"catalog-pull", "catalog-restore", "codex-restore", "sync-cache", "startup-cache",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function codexCatalogAuditPath(codexHome: string): string {
|
|
22
|
+
return join(codexHome, CODEX_CATALOG_AUDIT_FILE);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeCount(value: number | null | undefined): number | null {
|
|
26
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function auditLine(event: CatalogWriteAuditEvent): Buffer {
|
|
30
|
+
const writer = WRITERS.has(event.writer) ? event.writer : "other";
|
|
31
|
+
// Explicit projection: no argv, arbitrary event properties, model IDs or provider names.
|
|
32
|
+
return Buffer.from(`${JSON.stringify({
|
|
33
|
+
at: new Date().toISOString(), pid: process.pid, ppid: process.ppid,
|
|
34
|
+
command: writer,
|
|
35
|
+
opencodexHome: redactUserPath(event.opencodexHome).slice(0, MAX_HOME_CHARACTERS),
|
|
36
|
+
target: event.target, outcome: event.outcome, reason: event.reason,
|
|
37
|
+
intent: event.intent, writer,
|
|
38
|
+
configSource: event.configSource,
|
|
39
|
+
routedBefore: safeCount(event.routedBefore), routedAfter: safeCount(event.routedAfter),
|
|
40
|
+
})}\n`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function regularPrivateFile(stat: Stats): boolean {
|
|
44
|
+
// POSIX bits cannot prove NTFS privacy. Windows requires fresh-file ACL hardening.
|
|
45
|
+
return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1
|
|
46
|
+
&& stat.uid === process.getuid?.() && (stat.mode & 0o777) === 0o600;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sameFile(left: Stats, right: Stats): boolean {
|
|
50
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
51
|
+
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read at most the tail budget plus one byte to locate a complete first record. */
|
|
55
|
+
function readTail(fd: number, size: number): Buffer {
|
|
56
|
+
const start = Math.max(0, size - CATALOG_AUDIT_MAX_BYTES - 1);
|
|
57
|
+
const bytes = Buffer.alloc(size - start);
|
|
58
|
+
let length = 0;
|
|
59
|
+
while (length < bytes.length) {
|
|
60
|
+
const count = readSync(fd, bytes, length, bytes.length - length, start + length);
|
|
61
|
+
if (count === 0) break;
|
|
62
|
+
length += count;
|
|
63
|
+
}
|
|
64
|
+
if (length !== bytes.length) throw new Error("audit file changed during read");
|
|
65
|
+
if (start === 0) return bytes;
|
|
66
|
+
const boundary = bytes.indexOf(10);
|
|
67
|
+
return boundary === -1 ? Buffer.alloc(0) : bytes.subarray(boundary + 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function completeRecords(bytes: Buffer): { records: Buffer[]; count: number; clean: boolean } {
|
|
71
|
+
const records: Buffer[] = [];
|
|
72
|
+
let start = 0;
|
|
73
|
+
let clean = true;
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (let end = bytes.indexOf(10); end !== -1; end = bytes.indexOf(10, start)) {
|
|
76
|
+
const line = bytes.subarray(start, end);
|
|
77
|
+
try {
|
|
78
|
+
const text = line.toString("utf8");
|
|
79
|
+
if (!line.equals(Buffer.from(text))) throw new Error("invalid UTF-8");
|
|
80
|
+
JSON.parse(text);
|
|
81
|
+
records[count % CATALOG_AUDIT_KEEP_RECORDS] = bytes.subarray(start, end + 1);
|
|
82
|
+
count++;
|
|
83
|
+
} catch { clean = false; }
|
|
84
|
+
start = end + 1;
|
|
85
|
+
}
|
|
86
|
+
const pivot = count > CATALOG_AUDIT_KEEP_RECORDS ? count % CATALOG_AUDIT_KEEP_RECORDS : 0;
|
|
87
|
+
return { records: records.slice(pivot).concat(records.slice(0, pivot)), count,
|
|
88
|
+
clean: clean && start === bytes.length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeAll(fd: number, bytes: Buffer, position: number | null): void {
|
|
92
|
+
let offset = 0;
|
|
93
|
+
while (offset < bytes.length) {
|
|
94
|
+
const count = writeSync(fd, bytes, offset, bytes.length - offset,
|
|
95
|
+
position === null ? null : position + offset);
|
|
96
|
+
if (count === 0) throw new Error("audit write made no progress");
|
|
97
|
+
offset += count;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Best-effort adapter. Caller MUST hold K, even for a refusal that has no permit.
|
|
103
|
+
* POSIX owners compact the descriptor; foreign callers only append within both caps.
|
|
104
|
+
* Windows only publishes into fresh owner files after required ACL hardening.
|
|
105
|
+
* This diagnostic has no replay or authorization role. Process death may leave a partial line.
|
|
106
|
+
*/
|
|
107
|
+
export function appendCatalogWriteAudit(
|
|
108
|
+
codexHome: string,
|
|
109
|
+
event: CatalogWriteAuditEvent,
|
|
110
|
+
options: { readonly create: boolean },
|
|
111
|
+
): "appended" | "created" | "skipped" {
|
|
112
|
+
let fd: number | undefined;
|
|
113
|
+
try {
|
|
114
|
+
const line = auditLine(event);
|
|
115
|
+
if (line.length > CATALOG_AUDIT_MAX_EVENT_BYTES) return "skipped";
|
|
116
|
+
const path = codexCatalogAuditPath(codexHome);
|
|
117
|
+
const windows = windowsSecretAclApplies();
|
|
118
|
+
let before: Stats | undefined;
|
|
119
|
+
try { before = lstatSync(path); }
|
|
120
|
+
catch (error) {
|
|
121
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !options.create) return "skipped";
|
|
122
|
+
}
|
|
123
|
+
if (before && !regularPrivateFile(before)) return "skipped";
|
|
124
|
+
// No mkdir, pathname truncation or chmod. O_EXCL prevents creation over a raced path.
|
|
125
|
+
const flags = constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
|
|
126
|
+
| (before ? (options.create ? 0 : constants.O_APPEND) : constants.O_CREAT | constants.O_EXCL);
|
|
127
|
+
fd = openSync(path, flags, 0o600);
|
|
128
|
+
let opened = fstatSync(fd);
|
|
129
|
+
if (windows) {
|
|
130
|
+
// Existing files have already been refused: no read-only NTFS privacy verifier exists.
|
|
131
|
+
if (!opened.isFile() || opened.nlink !== 1 || opened.size !== 0
|
|
132
|
+
|| !sameFile(opened, lstatSync(path))) return "skipped";
|
|
133
|
+
if (!hardenSecretPath(path, { required: true, deadlineMs: AUDIT_ACL_DEADLINE_MS }).ok) return "skipped";
|
|
134
|
+
const hardened = fstatSync(fd);
|
|
135
|
+
// ACL changes legitimately move ctime. Retain object identity, then take a fresh baseline.
|
|
136
|
+
if (!hardened.isFile() || hardened.nlink !== 1 || hardened.size !== 0
|
|
137
|
+
|| opened.dev !== hardened.dev || opened.ino !== hardened.ino
|
|
138
|
+
|| !sameFile(hardened, lstatSync(path))) return "skipped";
|
|
139
|
+
opened = hardened;
|
|
140
|
+
} else if (!regularPrivateFile(opened) || (before && !sameFile(before, opened))) return "skipped";
|
|
141
|
+
if (!options.create && opened.size + line.length > CATALOG_AUDIT_MAX_BYTES) return "skipped";
|
|
142
|
+
const tail = readTail(fd, opened.size);
|
|
143
|
+
const parsed = completeRecords(tail);
|
|
144
|
+
if (!sameFile(opened, fstatSync(fd)) || !sameFile(opened, lstatSync(path))) return "skipped";
|
|
145
|
+
if (!options.create) {
|
|
146
|
+
if (!parsed.clean || parsed.count + 1 > CATALOG_AUDIT_KEEP_RECORDS) return "skipped";
|
|
147
|
+
writeAll(fd, line, null);
|
|
148
|
+
} else if (opened.size <= CATALOG_AUDIT_MAX_BYTES - line.length
|
|
149
|
+
&& parsed.clean && parsed.count < CATALOG_AUDIT_KEEP_RECORDS) {
|
|
150
|
+
writeAll(fd, line, opened.size);
|
|
151
|
+
} else {
|
|
152
|
+
const retained: Buffer[] = [line];
|
|
153
|
+
let bytes = line.length;
|
|
154
|
+
for (let index = parsed.records.length - 1; index >= 0 && retained.length < CATALOG_AUDIT_KEEP_RECORDS; index--) {
|
|
155
|
+
const record = parsed.records[index]!;
|
|
156
|
+
if (bytes + record.length > CATALOG_AUDIT_MAX_BYTES) break;
|
|
157
|
+
retained.unshift(record);
|
|
158
|
+
bytes += record.length;
|
|
159
|
+
}
|
|
160
|
+
const output = Buffer.concat(retained, bytes);
|
|
161
|
+
writeAll(fd, output, 0);
|
|
162
|
+
ftruncateSync(fd, output.length);
|
|
163
|
+
}
|
|
164
|
+
return before ? "appended" : "created";
|
|
165
|
+
} catch {
|
|
166
|
+
// Deliberately best-effort: an audit IO failure never changes the catalog result.
|
|
167
|
+
return "skipped";
|
|
168
|
+
} finally {
|
|
169
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* diagnostic only */ } }
|
|
170
|
+
}
|
|
171
|
+
}
|