@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
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { readConfigAdmissionSnapshot } from "../config/diagnostics";
|
|
2
|
+
import { readRuntimePort } from "../config/process-state";
|
|
3
|
+
import { readClientConnectionState } from "../client/state";
|
|
4
|
+
import { getActiveTurnCount, isDraining, isRecyclingForExit, isShutdownDraining } from "../server/lifecycle";
|
|
5
|
+
import type { OcxConfig } from "../types";
|
|
6
|
+
import { observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath, type CatalogObservation } from "./catalog/heal-observation";
|
|
7
|
+
import { subscribeCatalogPublication } from "./catalog/publication-observer";
|
|
8
|
+
import { configEnablesRoutedNamespace, ocxRoutedNamespaceCounts } from "./catalog/routed-removal";
|
|
9
|
+
import { inspectCodexHomeOwner } from "./codex-home-owner";
|
|
10
|
+
import { shouldSyncCodexOnStart } from "./desired-state";
|
|
11
|
+
import { JOURNAL_PATH, journalOwner } from "./journal";
|
|
12
|
+
import { CODEX_HOME, DEFAULT_CATALOG_PATH, resolveCodexConfigPath } from "./paths";
|
|
13
|
+
import { siblingOfLivePort } from "./sibling-start";
|
|
14
|
+
|
|
15
|
+
export type { CatalogObservation } from "./catalog/heal-observation";
|
|
16
|
+
export const CATALOG_HEAL_TICK_MS = 30_000;
|
|
17
|
+
export const CATALOG_HEAL_RECHECK_MS = 5 * 60_000;
|
|
18
|
+
export const CATALOG_HEAL_WINDOW_MS = 60 * 60_000;
|
|
19
|
+
export const CATALOG_HEAL_MAX_ATTEMPTS = 6;
|
|
20
|
+
export const CATALOG_HEAL_MAX_HEALS = 3;
|
|
21
|
+
|
|
22
|
+
export type CatalogSelfHealGate = "sibling" | "exiting" | "idle" | "runtime-record" | "config-unavailable" | "codex-off" | "not-owner" | "client";
|
|
23
|
+
export interface CatalogSelfHealGates {
|
|
24
|
+
siblingOfLivePort(): number | null;
|
|
25
|
+
exiting(): boolean;
|
|
26
|
+
idle(): boolean;
|
|
27
|
+
runtimeOwned(): boolean;
|
|
28
|
+
/** File-backed config authority; missing, malformed or salvaged config returns null. */
|
|
29
|
+
loadConfig(): OcxConfig | null;
|
|
30
|
+
ownsCodexHome(): boolean;
|
|
31
|
+
clientConnected(): boolean;
|
|
32
|
+
clientJournalOwner(): boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface CatalogHealOutcome { readonly committed: boolean }
|
|
35
|
+
export interface CatalogHealLifecycle {
|
|
36
|
+
readonly beforeCommit: () => boolean;
|
|
37
|
+
readonly expectedCatalogPath: string;
|
|
38
|
+
}
|
|
39
|
+
export interface CatalogSelfHealRecord {
|
|
40
|
+
readonly at: string;
|
|
41
|
+
readonly lostNamespaces: number;
|
|
42
|
+
readonly committed: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface CatalogSelfHealHandle {
|
|
45
|
+
stop(): void;
|
|
46
|
+
lastHeal(): CatalogSelfHealRecord | null;
|
|
47
|
+
tickForTests(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
export interface CatalogSelfHealDeps {
|
|
50
|
+
scheduleFn?: (fn: () => void, ms: number) => { cancel(): void };
|
|
51
|
+
now?: () => number;
|
|
52
|
+
catalogPath?: () => string | null;
|
|
53
|
+
observe?: (path: string, readContent: boolean) => CatalogObservation | null;
|
|
54
|
+
converge?: (config: OcxConfig, lifecycle: CatalogHealLifecycle) => Promise<CatalogHealOutcome>;
|
|
55
|
+
subscribe?: typeof subscribeCatalogPublication;
|
|
56
|
+
gates?: Partial<CatalogSelfHealGates>;
|
|
57
|
+
log?: Pick<Console, "warn">;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
|
|
61
|
+
const timer = setTimeout(fn, ms);
|
|
62
|
+
timer.unref?.();
|
|
63
|
+
return { cancel: () => clearTimeout(timer) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function convergeOwnCatalog(config: OcxConfig, lifecycle: CatalogHealLifecycle): Promise<CatalogHealOutcome> {
|
|
67
|
+
const [{ armDetachedConfigBaseline }, { createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([
|
|
68
|
+
import("../config"), import("./management-convergence"), import("./catalog-admission"),
|
|
69
|
+
]);
|
|
70
|
+
if (!lifecycle.beforeCommit()) return { committed: false };
|
|
71
|
+
// Discovery persistence rebases this detached snapshot against current disk config.
|
|
72
|
+
armDetachedConfigBaseline(config);
|
|
73
|
+
const outcome = await createManagementConvergeCodex(config, lifecycle)(createCatalogConvergeRequest({ deadlineMs: 1_000 }));
|
|
74
|
+
return { committed: outcome.kind === "catalog-only" && outcome.catalogRefresh.status === "committed" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function evaluateCatalogSelfHealGates(gates: CatalogSelfHealGates):
|
|
78
|
+
{ readonly open: true; readonly config: OcxConfig } | { readonly open: false; readonly gate: CatalogSelfHealGate } {
|
|
79
|
+
if (gates.siblingOfLivePort() !== null) return { open: false, gate: "sibling" };
|
|
80
|
+
if (gates.exiting()) return { open: false, gate: "exiting" };
|
|
81
|
+
if (!gates.idle()) return { open: false, gate: "idle" };
|
|
82
|
+
if (!gates.runtimeOwned()) return { open: false, gate: "runtime-record" };
|
|
83
|
+
const config = gates.loadConfig();
|
|
84
|
+
if (config === null) return { open: false, gate: "config-unavailable" };
|
|
85
|
+
if (!shouldSyncCodexOnStart(config)) return { open: false, gate: "codex-off" };
|
|
86
|
+
if (gates.clientConnected() || gates.clientJournalOwner()) return { open: false, gate: "client" };
|
|
87
|
+
if (!gates.ownsCodexHome()) return { open: false, gate: "not-owner" };
|
|
88
|
+
return { open: true, config };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type NamespaceObservation = { path: string; signature: string; namespaces: ReadonlySet<string> | null };
|
|
92
|
+
|
|
93
|
+
/** Owner lifecycle only: importing this module starts no timer or publication subscription. */
|
|
94
|
+
export function startCodexCatalogSelfHeal(options: { port?: number; deps?: CatalogSelfHealDeps } = {}): CatalogSelfHealHandle {
|
|
95
|
+
const deps = options.deps ?? {};
|
|
96
|
+
const scheduleFn = deps.scheduleFn ?? defaultSchedule;
|
|
97
|
+
const clock = deps.now ?? (() => performance.now());
|
|
98
|
+
const catalogPath = deps.catalogPath ?? (() => selectCatalogHealPath(JOURNAL_PATH, DEFAULT_CATALOG_PATH, resolveCodexConfigPath));
|
|
99
|
+
const observe = deps.observe ?? observeCatalogHealFile;
|
|
100
|
+
const converge = deps.converge ?? convergeOwnCatalog;
|
|
101
|
+
const gates: CatalogSelfHealGates = {
|
|
102
|
+
siblingOfLivePort,
|
|
103
|
+
exiting: () => isRecyclingForExit() || isShutdownDraining(),
|
|
104
|
+
idle: () => !isDraining() && getActiveTurnCount() === 0,
|
|
105
|
+
runtimeOwned: () => {
|
|
106
|
+
const runtime = readRuntimePort(process.pid);
|
|
107
|
+
return runtime !== null && runtime.siblingOfPort === undefined
|
|
108
|
+
&& (options.port === undefined || runtime.port === options.port);
|
|
109
|
+
},
|
|
110
|
+
loadConfig: () => {
|
|
111
|
+
const snapshot = readConfigAdmissionSnapshot();
|
|
112
|
+
return snapshot.kind === "read" && snapshot.diagnostics.source === "file"
|
|
113
|
+
? snapshot.diagnostics.config : null;
|
|
114
|
+
},
|
|
115
|
+
ownsCodexHome: () => {
|
|
116
|
+
const owner = inspectCodexHomeOwner(CODEX_HOME);
|
|
117
|
+
return owner.kind === "owned" || owner.kind === "unbound";
|
|
118
|
+
},
|
|
119
|
+
clientConnected: () => readClientConnectionState().kind !== "disconnected",
|
|
120
|
+
clientJournalOwner: () => journalOwner({ readOnly: true })?.kind === "client",
|
|
121
|
+
...deps.gates,
|
|
122
|
+
};
|
|
123
|
+
const log = deps.log ?? console;
|
|
124
|
+
let stopped = false;
|
|
125
|
+
let released = false;
|
|
126
|
+
let generation = 0;
|
|
127
|
+
let timer: { cancel(): void } | undefined;
|
|
128
|
+
let running = false;
|
|
129
|
+
let last: CatalogSelfHealRecord | null = null;
|
|
130
|
+
let target: string | null = null;
|
|
131
|
+
let baseline: NamespaceObservation | null = null;
|
|
132
|
+
let observed: NamespaceObservation | null = null;
|
|
133
|
+
let pendingRetry: { path: string; lostNamespaces: readonly string[] } | null = null;
|
|
134
|
+
let recheckAt = 0;
|
|
135
|
+
const attempts: number[] = [];
|
|
136
|
+
const heals: number[] = [];
|
|
137
|
+
|
|
138
|
+
const read = (path: string): NamespaceObservation | null => {
|
|
139
|
+
const quick = observe(path, false);
|
|
140
|
+
if (quick === null) return null;
|
|
141
|
+
if (observed !== null && sameCatalogHealPath(observed.path, path) && observed.signature === quick.signature) return observed;
|
|
142
|
+
const seen = observe(path, true);
|
|
143
|
+
if (seen === null) return null;
|
|
144
|
+
observed = { path, signature: seen.signature, namespaces: seen.catalog ? new Set(ocxRoutedNamespaceCounts(seen.catalog).keys()) : null };
|
|
145
|
+
return observed;
|
|
146
|
+
};
|
|
147
|
+
const accept = (path: string): void => {
|
|
148
|
+
const seen = read(path);
|
|
149
|
+
if (seen?.namespaces != null) baseline = seen;
|
|
150
|
+
};
|
|
151
|
+
const clear = (): void => {
|
|
152
|
+
generation += 1;
|
|
153
|
+
baseline = observed = null;
|
|
154
|
+
pendingRetry = null;
|
|
155
|
+
recheckAt = 0;
|
|
156
|
+
};
|
|
157
|
+
const updateTarget = (path: string | null): void => {
|
|
158
|
+
// Unavailable selection is not evidence of a different target or lost authority.
|
|
159
|
+
if (path === null) return;
|
|
160
|
+
if (target !== null && sameCatalogHealPath(target, path)) return;
|
|
161
|
+
clear();
|
|
162
|
+
target = path;
|
|
163
|
+
};
|
|
164
|
+
const beforeCommit = (path: string, entryGeneration: number): boolean => {
|
|
165
|
+
try {
|
|
166
|
+
const selected = catalogPath();
|
|
167
|
+
return !stopped && !released && generation === entryGeneration
|
|
168
|
+
&& selected !== null && sameCatalogHealPath(selected, path) && evaluateCatalogSelfHealGates(gates).open;
|
|
169
|
+
} catch { return false; }
|
|
170
|
+
};
|
|
171
|
+
const capRetryAt = (now: number): number => {
|
|
172
|
+
while (attempts.length && now - attempts[0]! >= CATALOG_HEAL_WINDOW_MS) attempts.shift();
|
|
173
|
+
while (heals.length && now - heals[0]! >= CATALOG_HEAL_WINDOW_MS) heals.shift();
|
|
174
|
+
return Math.max(
|
|
175
|
+
attempts.length >= CATALOG_HEAL_MAX_ATTEMPTS ? attempts[0]! + CATALOG_HEAL_WINDOW_MS : 0,
|
|
176
|
+
heals.length >= CATALOG_HEAL_MAX_HEALS ? heals[0]! + CATALOG_HEAL_WINDOW_MS : 0,
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const tick = async (): Promise<void> => {
|
|
181
|
+
if (stopped || running || released) return;
|
|
182
|
+
running = true;
|
|
183
|
+
try {
|
|
184
|
+
const path = catalogPath();
|
|
185
|
+
updateTarget(path);
|
|
186
|
+
if (path === null) return;
|
|
187
|
+
// Pending cache/funnel failure is independent of catalog stat or apparent repaired rows.
|
|
188
|
+
if (pendingRetry) {
|
|
189
|
+
const config = gates.loadConfig();
|
|
190
|
+
if (config === null) return;
|
|
191
|
+
const enabled = pendingRetry.lostNamespaces.filter(ns => configEnablesRoutedNamespace(config, ns));
|
|
192
|
+
if (enabled.length === 0) {
|
|
193
|
+
pendingRetry = null;
|
|
194
|
+
recheckAt = 0;
|
|
195
|
+
const seen = read(path);
|
|
196
|
+
if (seen?.namespaces != null) baseline = seen;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
pendingRetry = { path, lostNamespaces: enabled };
|
|
200
|
+
}
|
|
201
|
+
const now = clock();
|
|
202
|
+
if (now < recheckAt) return;
|
|
203
|
+
const seen = read(path);
|
|
204
|
+
// Missing/corrupt custom paths are unavailable, never reconstructed from the default.
|
|
205
|
+
if (seen?.namespaces == null) return;
|
|
206
|
+
if (baseline === null) { baseline = seen; return; }
|
|
207
|
+
if (!pendingRetry) {
|
|
208
|
+
if (baseline.signature === seen.signature) return;
|
|
209
|
+
const lost = [...baseline.namespaces!].filter(ns => !seen.namespaces!.has(ns));
|
|
210
|
+
if (lost.length === 0) { baseline = seen; return; }
|
|
211
|
+
pendingRetry = { path, lostNamespaces: lost };
|
|
212
|
+
}
|
|
213
|
+
const gate = evaluateCatalogSelfHealGates(gates);
|
|
214
|
+
if (!gate.open) { recheckAt = now + CATALOG_HEAL_RECHECK_MS; return; }
|
|
215
|
+
const lostEnabled = pendingRetry.lostNamespaces.filter(ns => configEnablesRoutedNamespace(gate.config, ns));
|
|
216
|
+
if (lostEnabled.length === 0) { pendingRetry = null; baseline = seen; return; }
|
|
217
|
+
pendingRetry = { path, lostNamespaces: lostEnabled };
|
|
218
|
+
const cappedUntil = capRetryAt(now);
|
|
219
|
+
if (cappedUntil > now) { recheckAt = cappedUntil; return; }
|
|
220
|
+
attempts.push(now);
|
|
221
|
+
const entryGeneration = generation;
|
|
222
|
+
log.warn(`[catalog-self-heal] republishing ${lostEnabled.length} lost routed provider namespace${lostEnabled.length === 1 ? "" : "s"}`);
|
|
223
|
+
let committed = false;
|
|
224
|
+
try { committed = (await converge(gate.config, {
|
|
225
|
+
beforeCommit: () => beforeCommit(path, entryGeneration), expectedCatalogPath: path,
|
|
226
|
+
})).committed; }
|
|
227
|
+
catch { /* Failed attempts retain pending retry and consume the attempt budget. */ }
|
|
228
|
+
const selected = catalogPath();
|
|
229
|
+
if (stopped || released || generation !== entryGeneration || selected === null || !sameCatalogHealPath(selected, path)) return;
|
|
230
|
+
last = { at: new Date().toISOString(), lostNamespaces: lostEnabled.length, committed };
|
|
231
|
+
if (committed) {
|
|
232
|
+
heals.push(clock());
|
|
233
|
+
pendingRetry = null;
|
|
234
|
+
recheckAt = 0;
|
|
235
|
+
accept(path); // Authoritative emptiness is a successful owner baseline too.
|
|
236
|
+
} else recheckAt = clock() + CATALOG_HEAL_RECHECK_MS;
|
|
237
|
+
} catch {
|
|
238
|
+
log.warn("[catalog-self-heal] observation unavailable; deferred");
|
|
239
|
+
} finally { running = false; }
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const unsubscribe = (deps.subscribe ?? subscribeCatalogPublication)(event => {
|
|
243
|
+
if (stopped) return;
|
|
244
|
+
// Native restoration can remove the journal before notification. Fence the known
|
|
245
|
+
// target (and target-less releases) without resolving possibly unavailable evidence.
|
|
246
|
+
const release = event.kind === "native-released" || event.intent === "restore";
|
|
247
|
+
if (release && (event.path === null || (target !== null && sameCatalogHealPath(event.path, target)))) {
|
|
248
|
+
clear();
|
|
249
|
+
released = true;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const path = catalogPath();
|
|
253
|
+
if (event.path !== null && (target === null || !sameCatalogHealPath(event.path, target))
|
|
254
|
+
&& (path === null || !sameCatalogHealPath(event.path, path))) return;
|
|
255
|
+
if (release) {
|
|
256
|
+
clear();
|
|
257
|
+
released = true; // Only a later accepted owner publication can re-arm this lifecycle.
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (running) return; // Catalog-success/cache-failure must not accept a baseline.
|
|
261
|
+
// A committed owner publication supplies a read-only baseline even while heal writes are gated.
|
|
262
|
+
if (event.path === null || path === null || !sameCatalogHealPath(event.path, path)) return;
|
|
263
|
+
updateTarget(path);
|
|
264
|
+
released = false;
|
|
265
|
+
pendingRetry = null;
|
|
266
|
+
recheckAt = 0;
|
|
267
|
+
accept(event.path);
|
|
268
|
+
});
|
|
269
|
+
// Capture immediately after startup sync, before a foreign write can become the first tick.
|
|
270
|
+
try { updateTarget(catalogPath()); if (target !== null) accept(target); }
|
|
271
|
+
catch { /* Unavailable observation can be retried by the scheduled tick. */ }
|
|
272
|
+
const schedule = (): void => {
|
|
273
|
+
if (stopped) return;
|
|
274
|
+
timer = scheduleFn(() => {
|
|
275
|
+
void tick().catch(() => log.warn("[catalog-self-heal] scheduled observation failed")).finally(schedule);
|
|
276
|
+
}, CATALOG_HEAL_TICK_MS);
|
|
277
|
+
};
|
|
278
|
+
schedule();
|
|
279
|
+
return {
|
|
280
|
+
stop() { stopped = true; clear(); timer?.cancel(); timer = undefined; unsubscribe(); },
|
|
281
|
+
lastHeal: () => last,
|
|
282
|
+
tickForTests: tick,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -28,12 +28,16 @@ import { chmodSync, lstatSync, realpathSync } from "node:fs";
|
|
|
28
28
|
|
|
29
29
|
import { Database } from "bun:sqlite";
|
|
30
30
|
|
|
31
|
+
import { currentOpencodexHome, inspectCodexHomeOwner } from "./codex-home-owner";
|
|
32
|
+
import { appendCatalogWriteAudit } from "./catalog/write-audit";
|
|
33
|
+
import type { CatalogWriteAuditDetails, CatalogWriteIntent } from "./catalog/write-audit-contract";
|
|
31
34
|
import {
|
|
32
35
|
CodexUserIdentityRefusal,
|
|
33
36
|
resolveCodexCatalogSerializationDatabasePath,
|
|
34
37
|
resolveEffectiveUserIdentity,
|
|
35
38
|
samePathIdentity,
|
|
36
39
|
} from "./user-identity";
|
|
40
|
+
import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
|
|
37
41
|
|
|
38
42
|
/**
|
|
39
43
|
* Authorization to perform the fixed sequence of catalog mutations inside ONE
|
|
@@ -51,9 +55,16 @@ export interface CatalogWritePermit {
|
|
|
51
55
|
|
|
52
56
|
declare const catalogWritePermitBrand: unique symbol;
|
|
53
57
|
|
|
58
|
+
export type { CatalogWriteIntent } from "./catalog/write-audit-contract";
|
|
59
|
+
|
|
60
|
+
export interface CatalogWriteOptions {
|
|
61
|
+
readonly intent: CatalogWriteIntent;
|
|
62
|
+
readonly writer: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
export type CatalogSerializationOutcome<T> =
|
|
55
66
|
| { kind: "completed"; value: T }
|
|
56
|
-
| { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" };
|
|
67
|
+
| { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" | "foreign-owner" | "owner-unknown" };
|
|
57
68
|
|
|
58
69
|
export class CatalogWritePermitRefusal extends Error {
|
|
59
70
|
readonly code = "CODEX_CATALOG_WRITE_PERMIT_REFUSED";
|
|
@@ -69,6 +80,8 @@ interface PermitRegistration {
|
|
|
69
80
|
readonly canonicalCodexHome: string;
|
|
70
81
|
/** Identifies the acquisition, so a permit cannot outlive its transaction. */
|
|
71
82
|
readonly transactionId: string;
|
|
83
|
+
readonly intent: CatalogWriteIntent;
|
|
84
|
+
readonly writer: string;
|
|
72
85
|
live: boolean;
|
|
73
86
|
}
|
|
74
87
|
|
|
@@ -121,6 +134,34 @@ export function assertCatalogWritePermit(
|
|
|
121
134
|
}
|
|
122
135
|
}
|
|
123
136
|
|
|
137
|
+
/** Only a live acquisition may expose its declared intent and writer. */
|
|
138
|
+
export function catalogWritePermitContext(permit: CatalogWritePermit): CatalogWriteOptions {
|
|
139
|
+
const registration = activePermits.get(permit as unknown as object);
|
|
140
|
+
if (!registration?.live) throw new CatalogWritePermitRefusal("The catalog write permit is not live.");
|
|
141
|
+
return { intent: registration.intent, writer: registration.writer };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Audit mutation is subject to the same live-home proof as catalog publication. */
|
|
145
|
+
export function auditCatalogWriteWithPermit(
|
|
146
|
+
permit: CatalogWritePermit,
|
|
147
|
+
canonicalCodexHome: string,
|
|
148
|
+
details: CatalogWriteAuditDetails,
|
|
149
|
+
): "appended" | "created" | "skipped" {
|
|
150
|
+
assertCatalogWritePermit(permit, canonicalCodexHome);
|
|
151
|
+
const context = catalogWritePermitContext(permit);
|
|
152
|
+
try { return appendCatalogWriteAudit(canonicalCodexHome, {
|
|
153
|
+
target: details.target, outcome: details.outcome, reason: details.reason,
|
|
154
|
+
routedBefore: details.routedBefore, routedAfter: details.routedAfter,
|
|
155
|
+
configSource: details.configSource, intent: context.intent, writer: context.writer,
|
|
156
|
+
opencodexHome: currentOpencodexHome(),
|
|
157
|
+
}, { create: true }); } catch { return "skipped"; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function ownerRefusal(canonicalCodexHome: string): "foreign-owner" | "owner-unknown" | null {
|
|
161
|
+
const owner = inspectCodexHomeOwner(canonicalCodexHome);
|
|
162
|
+
return owner.kind === "foreign" ? "foreign-owner" : owner.kind === "unknown" ? "owner-unknown" : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
124
165
|
/**
|
|
125
166
|
* Acquire K for one canonical CODEX_HOME and run `write` while it is held.
|
|
126
167
|
*
|
|
@@ -135,7 +176,14 @@ export function assertCatalogWritePermit(
|
|
|
135
176
|
export function withCatalogWriteSerialization<T>(
|
|
136
177
|
canonicalCodexHome: string,
|
|
137
178
|
write: (permit: CatalogWritePermit) => T,
|
|
179
|
+
options: CatalogWriteOptions,
|
|
138
180
|
): CatalogSerializationOutcome<T> {
|
|
181
|
+
// First, ahead of the owner precheck and its diagnostics: a test process never gets a permit for
|
|
182
|
+
// the real Codex home, whatever its CODEX_HOME resolved to (#6529).
|
|
183
|
+
assertNotRealCodexHomeUnderTest(canonicalCodexHome);
|
|
184
|
+
// Re-evaluate on every acquisition; unknown evidence cannot authorize a takeover.
|
|
185
|
+
const refusedOwner = ownerRefusal(canonicalCodexHome);
|
|
186
|
+
// A precheck refusal may still acquire K for append-only diagnostics; it never gets a permit.
|
|
139
187
|
let databasePath: string;
|
|
140
188
|
try {
|
|
141
189
|
databasePath = resolveCodexCatalogSerializationDatabasePath(
|
|
@@ -144,9 +192,9 @@ export function withCatalogWriteSerialization<T>(
|
|
|
144
192
|
);
|
|
145
193
|
} catch (error) {
|
|
146
194
|
if (error instanceof CodexUserIdentityRefusal) {
|
|
147
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
195
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
148
196
|
}
|
|
149
|
-
return { kind: "unavailable", reason: "database" };
|
|
197
|
+
return { kind: "unavailable", reason: refusedOwner ?? "database" };
|
|
150
198
|
}
|
|
151
199
|
|
|
152
200
|
let database: Database | undefined;
|
|
@@ -159,12 +207,12 @@ export function withCatalogWriteSerialization<T>(
|
|
|
159
207
|
try {
|
|
160
208
|
const before = lstatSync(databasePath);
|
|
161
209
|
if (before.isSymbolicLink() || !before.isFile()) {
|
|
162
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
210
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
163
211
|
}
|
|
164
212
|
if (process.platform !== "win32") {
|
|
165
213
|
const uid = process.getuid?.();
|
|
166
214
|
if (uid === undefined || before.uid !== uid || (before.mode & 0o777) !== 0o600) {
|
|
167
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
215
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
168
216
|
}
|
|
169
217
|
}
|
|
170
218
|
} catch (cause) {
|
|
@@ -182,16 +230,31 @@ export function withCatalogWriteSerialization<T>(
|
|
|
182
230
|
const opened = lstatSync(databasePath);
|
|
183
231
|
if (opened.isSymbolicLink() || !opened.isFile()
|
|
184
232
|
|| !samePathIdentity(realpathSync.native(databasePath), databasePath)) {
|
|
185
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
233
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
186
234
|
}
|
|
187
235
|
|
|
188
236
|
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
189
237
|
transactionOpen = true;
|
|
190
238
|
|
|
239
|
+
// Ownership may have changed while opening K. Refuse before minting a permit or invoking work.
|
|
240
|
+
const lockedOwnerRefusal = refusedOwner ?? ownerRefusal(canonicalCodexHome);
|
|
241
|
+
if (lockedOwnerRefusal) {
|
|
242
|
+
try { appendCatalogWriteAudit(canonicalCodexHome, {
|
|
243
|
+
target: options.intent === "cache" ? "cache" : "catalog",
|
|
244
|
+
outcome: "refused", reason: lockedOwnerRefusal,
|
|
245
|
+
intent: options.intent, writer: options.writer, opencodexHome: currentOpencodexHome(),
|
|
246
|
+
}, { create: false }); } catch { /* Diagnostic metadata cannot mask the owner refusal. */ }
|
|
247
|
+
database.exec("ROLLBACK");
|
|
248
|
+
transactionOpen = false;
|
|
249
|
+
return { kind: "unavailable", reason: lockedOwnerRefusal };
|
|
250
|
+
}
|
|
251
|
+
|
|
191
252
|
acquisitionCounter += 1;
|
|
192
253
|
registration = {
|
|
193
254
|
canonicalCodexHome,
|
|
194
255
|
transactionId: `${process.pid}:${acquisitionCounter}`,
|
|
256
|
+
intent: options.intent,
|
|
257
|
+
writer: options.writer,
|
|
195
258
|
live: true,
|
|
196
259
|
};
|
|
197
260
|
// A bare object: nothing about it is guessable or reconstructable, because
|
|
@@ -224,8 +287,9 @@ export function withCatalogWriteSerialization<T>(
|
|
|
224
287
|
if (permit) activePermits.delete(permit as unknown as object);
|
|
225
288
|
}
|
|
226
289
|
if (error instanceof CodexUserIdentityRefusal) {
|
|
227
|
-
return { kind: "unavailable", reason: "unsafe-path" };
|
|
290
|
+
return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
|
|
228
291
|
}
|
|
292
|
+
if (refusedOwner) return { kind: "unavailable", reason: refusedOwner };
|
|
229
293
|
if (isBusy(error)) return { kind: "unavailable", reason: "busy" };
|
|
230
294
|
// A callback failure is the caller's error, not a lock outcome: K acquired
|
|
231
295
|
// fine. Reporting it as `unavailable` would tell the caller to retry
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { getConfigDir } from "../config/paths";
|
|
4
|
+
import { samePathIdentity } from "./user-identity";
|
|
5
|
+
|
|
6
|
+
export const CODEX_HOME_JOURNAL_FILE = "opencodex-journal.json";
|
|
7
|
+
const MAX_JOURNAL_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/** K imports this module; keep it independent of the config barrel and journal writer. */
|
|
10
|
+
export function currentOpencodexHome(): string {
|
|
11
|
+
const configured = getConfigDir();
|
|
12
|
+
try { return realpathSync.native(configured); }
|
|
13
|
+
catch { return resolve(configured); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type CodexHomeOwnership =
|
|
17
|
+
| { readonly kind: "unbound" }
|
|
18
|
+
| { readonly kind: "owned" }
|
|
19
|
+
| { readonly kind: "stale"; readonly boundHome: string }
|
|
20
|
+
| { readonly kind: "foreign"; readonly boundHome: string }
|
|
21
|
+
| { readonly kind: "unknown" };
|
|
22
|
+
|
|
23
|
+
export type CodexHomeJournalInspection =
|
|
24
|
+
| { kind: "missing" }
|
|
25
|
+
| { kind: "read"; journal: Record<string, unknown> }
|
|
26
|
+
| { kind: "unknown" };
|
|
27
|
+
|
|
28
|
+
function absent(error: unknown): boolean {
|
|
29
|
+
return (error as NodeJS.ErrnoException)?.code === "ENOENT";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** ENOENT through a dangling link or an unresolvable ancestor is not proven absence. */
|
|
33
|
+
function provenAbsent(path: string): boolean {
|
|
34
|
+
try { lstatSync(path); return false; }
|
|
35
|
+
catch (error) { if (!absent(error)) return false; }
|
|
36
|
+
const parent = dirname(path);
|
|
37
|
+
if (parent === path) return false;
|
|
38
|
+
try { return statSync(realpathSync.native(parent)).isDirectory(); }
|
|
39
|
+
catch (error) { return absent(error) && provenAbsent(parent); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validJournal(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
44
|
+
const journal = value as Record<string, unknown>;
|
|
45
|
+
return journal.version === 1 && typeof journal.originalConfig === "string"
|
|
46
|
+
&& (journal.originalProfile === null || typeof journal.originalProfile === "string")
|
|
47
|
+
&& (journal.opencodexHome === undefined || (typeof journal.opencodexHome === "string"
|
|
48
|
+
&& isAbsolute(journal.opencodexHome) && !journal.opencodexHome.includes("\0")));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Bounded, regular-file-only observation. Failure never supplies cleanup authority. */
|
|
52
|
+
export function readCodexHomeJournal(journalPath: string): CodexHomeJournalInspection {
|
|
53
|
+
let fd: number | undefined;
|
|
54
|
+
try {
|
|
55
|
+
const before = lstatSync(journalPath);
|
|
56
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_JOURNAL_BYTES) return { kind: "unknown" };
|
|
57
|
+
fd = openSync(journalPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
58
|
+
const opened = fstatSync(fd);
|
|
59
|
+
if (!opened.isFile() || opened.size > MAX_JOURNAL_BYTES || before.dev !== opened.dev || before.ino !== opened.ino) return { kind: "unknown" };
|
|
60
|
+
const bytes = Buffer.alloc(opened.size + 1);
|
|
61
|
+
let length = 0;
|
|
62
|
+
while (length < bytes.length) {
|
|
63
|
+
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
64
|
+
if (!count) break;
|
|
65
|
+
length += count;
|
|
66
|
+
}
|
|
67
|
+
const after = lstatSync(journalPath);
|
|
68
|
+
if (length !== opened.size || !after.isFile() || after.dev !== opened.dev || after.ino !== opened.ino
|
|
69
|
+
|| after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return { kind: "unknown" };
|
|
70
|
+
const journal: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8"));
|
|
71
|
+
return validJournal(journal) ? { kind: "read", journal } : { kind: "unknown" };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return fd === undefined && absent(error) && provenAbsent(journalPath) ? { kind: "missing" } : { kind: "unknown" };
|
|
74
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function inspectBinding(boundHome: string, current: string): CodexHomeOwnership {
|
|
78
|
+
let physical: string;
|
|
79
|
+
try {
|
|
80
|
+
physical = realpathSync.native(boundHome);
|
|
81
|
+
if (!statSync(physical).isDirectory()) return { kind: "unknown" };
|
|
82
|
+
} catch { return provenAbsent(boundHome) ? { kind: "stale", boundHome } : { kind: "unknown" }; }
|
|
83
|
+
try {
|
|
84
|
+
const own = realpathSync.native(current);
|
|
85
|
+
if (!statSync(own).isDirectory()) return { kind: "unknown" };
|
|
86
|
+
return samePathIdentity(physical, own) ? { kind: "owned" } : { kind: "foreign", boundHome };
|
|
87
|
+
} catch { return { kind: "unknown" }; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function inspectCodexHomeOwner(canonicalHome: string, current = currentOpencodexHome()): CodexHomeOwnership {
|
|
91
|
+
const read = readCodexHomeJournal(join(canonicalHome, CODEX_HOME_JOURNAL_FILE));
|
|
92
|
+
if (read.kind === "unknown") return { kind: "unknown" };
|
|
93
|
+
if (read.kind === "missing" || read.journal.opencodexHome === undefined) return { kind: "unbound" };
|
|
94
|
+
return inspectBinding(read.journal.opencodexHome as string, current);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Preserve extant and uncertain bindings; only legacy or proven stale records are adopted. */
|
|
98
|
+
export function opencodexHomeForInjection(recorded: string | undefined, current = currentOpencodexHome()): string {
|
|
99
|
+
if (recorded === undefined) return current;
|
|
100
|
+
return inspectBinding(recorded, current).kind === "stale" ? current : recorded;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type CodexHomeOwnerRefusalReason = "foreign-owner" | "owner-unknown";
|
|
104
|
+
export class CodexHomeOwnerRefusal extends Error {
|
|
105
|
+
constructor(readonly reason: CodexHomeOwnerRefusalReason) {
|
|
106
|
+
super(reason === "foreign-owner"
|
|
107
|
+
? "Codex home is bound to another OpenCodex home; configuration and journal were preserved. Restore from the owning home first."
|
|
108
|
+
: "Codex home ownership could not be verified; configuration and journal were preserved.");
|
|
109
|
+
this.name = "CodexHomeOwnerRefusal";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function assertCodexHomeOwner(home: string): void {
|
|
113
|
+
const owner = inspectCodexHomeOwner(home);
|
|
114
|
+
if (owner.kind === "foreign" || owner.kind === "unknown") {
|
|
115
|
+
throw new CodexHomeOwnerRefusal(owner.kind === "foreign" ? "foreign-owner" : "owner-unknown");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Compensation must not replace evidence another home published during this operation. */
|
|
120
|
+
export function codexHomeOwnerBlocksCompensation(home: string, error: unknown): boolean {
|
|
121
|
+
if (!(error instanceof CodexHomeOwnerRefusal)) return false;
|
|
122
|
+
const owner = inspectCodexHomeOwner(home);
|
|
123
|
+
return owner.kind === "foreign" || owner.kind === "unknown";
|
|
124
|
+
}
|