@bitkyc08/opencodex 2.81.0 → 2.83.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 +166 -73
- package/gui/dist/assets/App-Ba8uG696.js +54 -0
- package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
- package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
- package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
- package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/cursor/catalog.ts +2 -2
- package/src/adapters/cursor/effort-map.ts +1 -1
- package/src/adapters/devin/live-models.ts +2 -2
- package/src/adapters/google-truncation.ts +22 -0
- package/src/adapters/google.ts +7 -3
- package/src/adapters/ollama-native.ts +18 -14
- package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
- package/src/adapters/openai-responses/passthrough.ts +11 -4
- package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
- package/src/adapters/openai-responses/reasoning.ts +11 -1
- package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
- package/src/bridge/errors.ts +7 -1
- package/src/claude/agents-inject.ts +14 -3
- package/src/claude/context-windows.ts +22 -20
- package/src/claude/desktop-3p.ts +88 -12
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/desktop-profile.ts +2 -1
- package/src/claude/gateway-cache.ts +18 -1
- package/src/claude/inbound.ts +77 -3
- package/src/claude/intercept/cli-catalog.ts +3 -0
- package/src/claude/intercept/picker-ca-persistence.ts +7 -6
- package/src/claude/intercept/picker-models.ts +14 -6
- package/src/claude/intercept/settings.ts +3 -0
- package/src/claude/long-context.ts +66 -0
- package/src/claude/model-info.ts +19 -16
- package/src/claude/outbound.ts +94 -9
- package/src/claude/subagent-model.ts +40 -11
- package/src/cli/account-auth.ts +20 -12
- package/src/cli/agent.ts +9 -5
- package/src/cli/aside-profile-recovery.ts +33 -0
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-integrations.ts +2 -2
- package/src/cli/cli-path-diagnostics.ts +132 -0
- package/src/cli/desktop-runtime-guidance.ts +65 -0
- package/src/cli/dispatch.ts +5 -0
- package/src/cli/doctor.ts +16 -3
- package/src/cli/index.ts +14 -9
- package/src/cli/integration-aside-sync.ts +2 -1
- package/src/cli/integrations.ts +16 -3
- package/src/cli/resolve.ts +26 -3
- package/src/cli/root.ts +4 -2
- package/src/cli/status.ts +64 -12
- package/src/cli/update-restart-child.ts +4 -2
- package/src/cli/update-restart-home.ts +28 -10
- package/src/cli/update-restart-service-record.ts +116 -0
- package/src/cli/update-restart-supervision.ts +96 -0
- package/src/cli/update-restart.ts +59 -13
- package/src/cli/v2.ts +27 -20
- package/src/cli/version-skew-notice.ts +77 -0
- package/src/clients/config-export/model-metadata.ts +16 -0
- package/src/clients/config-export/zcode-store.ts +46 -11
- package/src/clients/config-export/zcode.ts +3 -5
- package/src/clients/config-export.ts +1 -1
- package/src/clients/lazycodex.ts +1 -1
- package/src/clients/omo-role-models.ts +32 -7
- package/src/codex/auth-api/account-selection.ts +107 -0
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +10 -49
- package/src/codex/auth-context.ts +28 -4
- package/src/codex/autostart-health.ts +12 -3
- package/src/codex/catalog/build-entries.ts +18 -0
- package/src/codex/catalog/bundled.ts +155 -26
- package/src/codex/catalog/combo-member.ts +3 -2
- package/src/codex/catalog/effort.ts +26 -5
- package/src/codex/catalog/gather-capture.ts +5 -0
- package/src/codex/catalog/model-hints.ts +15 -8
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/native-models.ts +1 -1
- package/src/codex/catalog/operator-tiers.ts +38 -0
- package/src/codex/catalog/provider-models.ts +68 -35
- package/src/codex/catalog/retained-sync.ts +7 -5
- package/src/codex/catalog/routed-gather.ts +15 -1
- package/src/codex/catalog-auto-refresh-sources.ts +3 -4
- package/src/codex/catalog-auto-refresh.ts +19 -0
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-home-owner.ts +3 -0
- package/src/codex/config-write-lock.ts +333 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/desktop-app/darwin.ts +1 -1
- package/src/codex/desktop-app/handoff.ts +1 -1
- package/src/codex/desktop-app/linux.ts +1 -1
- package/src/codex/desktop-app/lock.ts +1 -1
- package/src/codex/desktop-app/types.ts +1 -1
- package/src/codex/desktop-app/windows.ts +1 -1
- package/src/codex/desktop-app-restart.ts +1 -1
- package/src/codex/features.ts +257 -200
- package/src/codex/inject/config-write-section.ts +34 -0
- package/src/codex/inject/multi-agent-v2.ts +14 -6
- package/src/codex/inject/remove.ts +36 -11
- package/src/codex/inject/restore.ts +85 -20
- package/src/codex/inject-coordination.ts +15 -6
- package/src/codex/inject.ts +62 -20
- package/src/codex/journal.ts +62 -17
- package/src/codex/main-account-cache.ts +43 -0
- package/src/codex/main-account.ts +6 -7
- package/src/codex/paths.ts +2 -0
- package/src/codex/prepared-home.ts +18 -0
- package/src/codex/prompt-journal.ts +25 -12
- package/src/codex/prompt-layers.ts +49 -26
- package/src/codex/prompt-lock-claim.ts +134 -0
- package/src/codex/prompt-lock-evidence.ts +26 -0
- package/src/codex/prompt-lock-io.ts +16 -0
- package/src/codex/prompt-lock-owner.ts +130 -0
- package/src/codex/prompt-lock.ts +84 -53
- package/src/codex/routing/failure-window.ts +146 -0
- package/src/codex/routing/selection.ts +4 -1
- package/src/codex/routing.ts +13 -17
- package/src/codex/runtime.ts +106 -18
- package/src/codex/shim.ts +45 -19
- package/src/combos/jev-service-exchange.ts +281 -0
- package/src/combos/jev.ts +34 -220
- package/src/config/atomic-write.ts +1 -1
- package/src/config/codex-ws-reuse-setting.ts +10 -0
- package/src/config/live-reconcile.ts +17 -0
- package/src/config/load-degrade.ts +8 -0
- package/src/config/rebase-provenance.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/generated/compatibility-version.json +375 -227
- package/src/generated/model-metadata.ts +2 -2
- package/src/grok/inject.ts +276 -206
- package/src/grok/reset-coupon-ledger.ts +223 -57
- package/src/grok/reset-coupons.ts +13 -6
- package/src/grok/sync.ts +2 -1
- package/src/images/loop.ts +29 -11
- package/src/integrations/aside-profiles.ts +10 -3
- package/src/integrations/catalog-refresh.ts +18 -2
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/writer.ts +9 -3
- package/src/lib/bun-path-runtime.d.mts +32 -0
- package/src/lib/bun-path-runtime.mjs +95 -0
- package/src/lib/bun-runtime-preflight.ts +140 -0
- package/src/lib/desktop-cli-handoff.d.mts +20 -0
- package/src/lib/desktop-cli-handoff.mjs +92 -0
- package/src/lib/desktop-cli-record.d.mts +29 -0
- package/src/lib/desktop-cli-record.mjs +102 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/request-execution-budget.ts +8 -0
- package/src/lib/synced-state-location.ts +46 -6
- package/src/lib/test-home-guard.ts +87 -1
- package/src/lib/translator-budget.ts +17 -4
- package/src/lib/windows-atomic-replace.ts +38 -16
- package/src/lib/windows-elevation.ts +6 -0
- package/src/lib/windows-secret-acl.ts +92 -25
- package/src/lib/winsw.ts +2 -3
- package/src/oauth/anthropic-account-refusal.ts +39 -1
- package/src/oauth/anthropic-routing.ts +1 -1
- package/src/oauth/anthropic-send-ownership.ts +3 -3
- package/src/oauth/chatgpt.ts +113 -41
- package/src/oauth/index.ts +7 -3
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/store.ts +1 -1
- package/src/oauth/types.ts +1 -1
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
- package/src/providers/azure-model-metadata.ts +115 -0
- package/src/providers/derive.ts +21 -0
- package/src/providers/devin-cli-authmode-migration.ts +1 -1
- package/src/providers/devin-provider-merge-migration.ts +1 -1
- package/src/providers/github-copilot-auto.ts +169 -0
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/vendor-probes-key.ts +127 -17
- package/src/providers/quota.ts +1 -1
- package/src/providers/registry/entries-core.ts +6 -6
- package/src/providers/registry/entries-extended.ts +4 -0
- package/src/providers/registry/model-seeds.ts +3 -3
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/reasoning-envelope.ts +29 -1
- package/src/responses/reasoning-replay-cache.ts +17 -1
- package/src/responses/spill-store.ts +15 -2
- package/src/responses/state/metrics.ts +8 -0
- package/src/responses/state/spill-failure.ts +12 -0
- package/src/responses/state/spill-queue.ts +12 -5
- package/src/responses/state.ts +18 -10
- package/src/responses/task-input.ts +17 -0
- package/src/server/background-lifecycle.ts +27 -0
- package/src/server/chat-completions.ts +1 -0
- package/src/server/chat-native.ts +28 -12
- package/src/server/claude-messages.ts +43 -6
- package/src/server/inbound-body-admission.ts +25 -7
- package/src/server/index/claude-intercept-lifecycle.ts +3 -1
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/serve-options.ts +18 -4
- package/src/server/index/spend-ledger-lifecycle.ts +2 -1
- package/src/server/index/startup-warnings.ts +19 -0
- package/src/server/index.ts +2 -1
- package/src/server/lifecycle.ts +31 -4
- package/src/server/management/agent-settings-routes.ts +93 -32
- package/src/server/management/codex-agent-role-routes.ts +4 -2
- package/src/server/management/codex-prompt-routes.ts +1 -0
- package/src/server/management/codex-role-auto-assign.ts +13 -2
- package/src/server/management/config-routes.ts +59 -8
- package/src/server/management/context.ts +2 -2
- package/src/server/management/grok-coupon-routes.ts +277 -52
- package/src/server/management/link-routes.ts +42 -20
- package/src/server/management/route-registry.ts +2 -2
- package/src/server/management/shared.ts +3 -3
- package/src/server/messages-native-oauth.ts +9 -8
- package/src/server/messages-native.ts +49 -13
- package/src/server/messages-response-headers.ts +14 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-conversation.ts +48 -5
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-terminal-status.ts +186 -0
- package/src/server/request-log.ts +15 -89
- package/src/server/responses/adapter-continuation.ts +11 -3
- package/src/server/responses/adapter-dispatch.ts +99 -19
- package/src/server/responses/codex-ws-exchange.ts +17 -2
- package/src/server/responses/codex-ws-pool.ts +100 -13
- package/src/server/responses/codex-ws-wire.ts +9 -0
- package/src/server/responses/combo-requested-effort.ts +3 -1
- package/src/server/responses/compact.ts +3 -2
- package/src/server/responses/core-codex-account.ts +22 -1
- package/src/server/responses/core-combo-native.ts +20 -3
- package/src/server/responses/core-combo.ts +22 -14
- package/src/server/responses/core-lifetime.ts +3 -36
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +73 -0
- package/src/server/responses/core.ts +9 -9
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +26 -3
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +24 -2
- package/src/server/responses/passthrough-dispatch.ts +134 -62
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +4 -0
- package/src/server/responses/request-send-budget.ts +112 -49
- package/src/server/responses/request-transport.ts +125 -5
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +15 -5
- package/src/server/responses/sidecar-execution.ts +30 -16
- package/src/server/responses/sidecar-send-budget.ts +58 -0
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +19 -6
- package/src/server/startup-health-cache.ts +2 -1
- package/src/server/workflow-refusal.ts +3 -0
- package/src/service/cli.ts +20 -4
- package/src/service/desktop-command-guard.ts +32 -0
- package/src/service/desktop-startup.ts +66 -40
- package/src/service/desktop-supervision.d.mts +26 -0
- package/src/service/desktop-supervision.mjs +140 -0
- package/src/service/managing-cli.ts +1 -0
- package/src/service/orchestration.ts +46 -20
- package/src/service/repair.ts +25 -9
- package/src/service/state.ts +4 -3
- package/src/service/systemd.ts +17 -8
- package/src/service/windows-ops.ts +61 -22
- package/src/storage/policy-job.ts +5 -1
- package/src/storage/policy-worker.ts +3 -1
- package/src/storage/policy.ts +9 -0
- package/src/types/config.ts +21 -0
- package/src/types/request.ts +10 -0
- package/src/types/wire.ts +1 -0
- package/src/update/index.ts +57 -9
- package/src/update/restart-ownership.ts +10 -3
- package/src/update/runtime-ownership.d.mts +6 -0
- package/src/update/runtime-ownership.mjs +23 -7
- package/src/usage/expected-prices.ts +1 -1
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-7yhoys8a.js +0 -54
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AtomicWriteHooks } from "../../config/atomic-write";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { withConfigMutationLockSync } from "../../config";
|
|
4
|
+
import { ConfigWriteLockRefusal, assertConfigWriteDestination, publishConfigWriteTarget, watchConfigWriteTargets, withConfigWriteLockHeld, type LockHandle } from "../config-write-lock";
|
|
5
|
+
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "../paths";
|
|
6
|
+
import { getCodexHome } from "../paths";
|
|
7
|
+
import { codexHomeIsAbsent } from "../codex-home-owner";
|
|
8
|
+
import { JOURNAL_PATH, removeJournal } from "../journal";
|
|
9
|
+
import { externalCodexModelProvider } from "./config-toml";
|
|
10
|
+
|
|
11
|
+
export function beginCodexWriteSection(held: LockHandle): void {
|
|
12
|
+
assertConfigWriteDestination(CODEX_CONFIG_PATH, held);
|
|
13
|
+
watchConfigWriteTargets(held, [CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, JOURNAL_PATH]);
|
|
14
|
+
}
|
|
15
|
+
export function publishCodexArtifact<T>(path: string, held: LockHandle | undefined, run: (destination: string, hooks: AtomicWriteHooks) => T): T {
|
|
16
|
+
if (!held) throw new Error("Codex artifact publication requires a held configuration lock");
|
|
17
|
+
return publishConfigWriteTarget(CODEX_CONFIG_PATH, held, path, run);
|
|
18
|
+
}
|
|
19
|
+
/** Courtesy cleanup re-reads provider ownership inside the same config section. */
|
|
20
|
+
export function cleanExternalProviderJournal(beforeClientWrite?: () => void): void {
|
|
21
|
+
if (codexHomeIsAbsent(getCodexHome())) return;
|
|
22
|
+
const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, held => {
|
|
23
|
+
beginCodexWriteSection(held);
|
|
24
|
+
const cleanup = () => {
|
|
25
|
+
beforeClientWrite?.();
|
|
26
|
+
if (existsSync(CODEX_CONFIG_PATH) && externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8"))) {
|
|
27
|
+
publishCodexArtifact(JOURNAL_PATH, held, (_path, hooks) => removeJournal(hooks));
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
if (beforeClientWrite) withConfigMutationLockSync(cleanup);
|
|
31
|
+
else cleanup();
|
|
32
|
+
});
|
|
33
|
+
if (!locked.ok) throw new ConfigWriteLockRefusal(locked);
|
|
34
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import type { OcxConfig } from "../../types";
|
|
3
|
+
import { ConfigWriteDestinationChanged, type LockHandle } from "../config-write-lock";
|
|
3
4
|
import { CODEX_CONFIG_PATH } from "../paths";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -18,11 +19,11 @@ export type InjectedV1SurfaceReconcile =
|
|
|
18
19
|
| { ok: true; content: string; changed: boolean }
|
|
19
20
|
| { ok: false; message: string };
|
|
20
21
|
|
|
21
|
-
let toggleForTests: ((enabled: boolean) => void) | undefined;
|
|
22
|
+
let toggleForTests: ((enabled: boolean, env: NodeJS.ProcessEnv, validateBeforeSpawn: () => void) => void) | undefined;
|
|
22
23
|
|
|
23
24
|
/** Test seam: substitute the native `codex features` toggle so no Codex runtime is required. */
|
|
24
25
|
export function setCodexMultiAgentV2ToggleForTests(
|
|
25
|
-
toggle: ((enabled: boolean) => void) | undefined,
|
|
26
|
+
toggle: ((enabled: boolean, env: NodeJS.ProcessEnv, validateBeforeSpawn: () => void) => void) | undefined,
|
|
26
27
|
): void {
|
|
27
28
|
toggleForTests = toggle;
|
|
28
29
|
}
|
|
@@ -43,7 +44,11 @@ export interface PreparedV1SurfaceReconcile {
|
|
|
43
44
|
* only — `run()` re-checks the flag on the bytes present under the lock.
|
|
44
45
|
*/
|
|
45
46
|
readonly enabledAtPrepare: boolean;
|
|
46
|
-
|
|
47
|
+
/**
|
|
48
|
+
* `heldConfigWriteLock` is the injector's own held write-lock handle: the
|
|
49
|
+
* transition would otherwise re-acquire the same file and refuse itself.
|
|
50
|
+
*/
|
|
51
|
+
run(heldConfigWriteLock?: LockHandle): InjectedV1SurfaceReconcile;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -66,19 +71,22 @@ export async function prepareInjectedV1SurfaceReconcile(
|
|
|
66
71
|
let toggle = toggleForTests;
|
|
67
72
|
if (!toggle) {
|
|
68
73
|
const { runCodexFeaturesCommand } = await import("../../cli/v2");
|
|
69
|
-
toggle = enabled => runCodexFeaturesCommand(enabled ? "enable" : "disable");
|
|
74
|
+
toggle = (enabled, env, validate) => runCodexFeaturesCommand(enabled ? "enable" : "disable", "multi_agent_v2", env, validate);
|
|
70
75
|
}
|
|
71
76
|
const resolvedToggle = toggle;
|
|
72
77
|
return {
|
|
73
78
|
enabledAtPrepare,
|
|
74
|
-
run() {
|
|
79
|
+
run(heldConfigWriteLock) {
|
|
75
80
|
// Decide on the bytes present NOW, under the lock — the prepare-time
|
|
76
81
|
// answer is stale the moment another writer could have touched the file.
|
|
77
82
|
if (!isMultiAgentV2Enabled()) {
|
|
78
83
|
return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8"), changed: false };
|
|
79
84
|
}
|
|
80
|
-
const transition = transitionMultiAgentV2(false, resolvedToggle
|
|
85
|
+
const transition = transitionMultiAgentV2(false, resolvedToggle, {
|
|
86
|
+
...(heldConfigWriteLock !== undefined ? { heldConfigWriteLock } : {}),
|
|
87
|
+
});
|
|
81
88
|
if (!transition.ok) {
|
|
89
|
+
if (transition.retryable === false) throw new ConfigWriteDestinationChanged(transition.error);
|
|
82
90
|
return {
|
|
83
91
|
ok: false,
|
|
84
92
|
message: `Codex config injection refused: could not reconcile the v1 surface with the global multi_agent_v2 feature: ${transition.error}.`,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { beginCodexWriteSection, publishCodexArtifact } from "./config-write-section";
|
|
1
2
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
3
|
import { normalizeStructuralWhitespace, rootAssignmentKey, rootSourceLines, sourceAssignment, sourceText } from "../toml-source-lines";
|
|
3
4
|
import { atomicWriteFile } from "../../config";
|
|
5
|
+
import { ConfigWriteLockRefusal, configWriteLockFailureMessage, withConfigWriteLockHeld } from "../config-write-lock";
|
|
6
|
+
import type { LockHandle } from "../config-write-lock";
|
|
4
7
|
import {
|
|
5
8
|
REALTIME_WS_BASE_URL_KEY,
|
|
6
9
|
hasInjectedOpenaiBaseUrl,
|
|
@@ -15,6 +18,8 @@ import {
|
|
|
15
18
|
journaledReplacedRootWebSearch,
|
|
16
19
|
} from "../journal";
|
|
17
20
|
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "../paths";
|
|
21
|
+
import { getCodexHome } from "../paths";
|
|
22
|
+
import { codexHomeIsAbsent } from "../codex-home-owner";
|
|
18
23
|
import { transformManagedSubagentDefaults } from "../subagent-defaults";
|
|
19
24
|
import {
|
|
20
25
|
applyEol,
|
|
@@ -45,14 +50,22 @@ export function readOcxProviderTableBlock(): string | null {
|
|
|
45
50
|
* writes leaves a fully native config, which is the direction this whole change is trying
|
|
46
51
|
* to reach anyway.
|
|
47
52
|
*/
|
|
48
|
-
export function retainOcxProviderTableOnDisk(block: string): string[] | null {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
export function retainOcxProviderTableOnDisk(block: string, heldConfigWriteLock?: LockHandle): string[] | null {
|
|
54
|
+
// The read, the append, and the rename are one section under the shared
|
|
55
|
+
// write lock; an unlocked read+rename here could discard a concurrent
|
|
56
|
+
// scalar or projection write.
|
|
57
|
+
const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, heldConfigWriteLock, held => {
|
|
58
|
+
beginCodexWriteSection(held);
|
|
59
|
+
if (!existsSync(CODEX_CONFIG_PATH)) return null;
|
|
60
|
+
const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
61
|
+
const eol = dominantEol(rawContent);
|
|
62
|
+
const content = applyEol(rawContent, "\n");
|
|
63
|
+
const next = appendOcxProviderTableBlock(content, block);
|
|
64
|
+
if (next !== content) publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, applyEol(next, eol), undefined, hooks));
|
|
65
|
+
return block.replace(/\n+$/, "").split("\n");
|
|
66
|
+
});
|
|
67
|
+
if (!locked.ok) throw new ConfigWriteLockRefusal(locked);
|
|
68
|
+
return locked.value;
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
interface StripOpencodexConfigResult {
|
|
@@ -162,9 +175,12 @@ export type RemoveCodexConfigHistoryDisposition =
|
|
|
162
175
|
export interface RemoveCodexConfigOptions {
|
|
163
176
|
preserveProfile?: boolean;
|
|
164
177
|
historyDisposition?: RemoveCodexConfigHistoryDisposition;
|
|
178
|
+
/** Run under a config write lock this process already holds (restore's single section). */
|
|
179
|
+
heldConfigWriteLock?: LockHandle;
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
export interface RemoveCodexConfigResult {
|
|
183
|
+
retryable?: boolean;
|
|
168
184
|
success: boolean;
|
|
169
185
|
message: string;
|
|
170
186
|
/** The exact lines left on disk when the disposition was `stand-down-retain`. */
|
|
@@ -174,6 +190,10 @@ export interface RemoveCodexConfigResult {
|
|
|
174
190
|
export function removeCodexConfig(
|
|
175
191
|
options: RemoveCodexConfigOptions = {},
|
|
176
192
|
): RemoveCodexConfigResult {
|
|
193
|
+
if (codexHomeIsAbsent(getCodexHome())) return {
|
|
194
|
+
success: true,
|
|
195
|
+
message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`,
|
|
196
|
+
};
|
|
177
197
|
const historyDisposition = options.historyDisposition ?? "refuse-on-any";
|
|
178
198
|
const historyError = preflightCodexHistoryInjection(false, false);
|
|
179
199
|
// The preflight answers "may I rewrite conversation history?". Routing removal is a
|
|
@@ -184,9 +204,11 @@ export function removeCodexConfig(
|
|
|
184
204
|
if (historyError && !(historyDisposition !== "refuse-on-any" && historyError === HISTORY_RELABEL_STANDS_DOWN)) {
|
|
185
205
|
return { success: false, message: `Codex configuration preserved: ${historyError}. Native writer coordination is required.` };
|
|
186
206
|
}
|
|
207
|
+
const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, options.heldConfigWriteLock, held => {
|
|
208
|
+
beginCodexWriteSection(held);
|
|
187
209
|
if (!existsSync(CODEX_CONFIG_PATH)) {
|
|
188
210
|
if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
|
|
189
|
-
|
|
211
|
+
publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => { hooks.validateBeforeRename?.(path); unlinkSync(path); hooks.afterRename?.(path); });
|
|
190
212
|
return {
|
|
191
213
|
success: true,
|
|
192
214
|
message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`,
|
|
@@ -223,10 +245,10 @@ export function removeCodexConfig(
|
|
|
223
245
|
? stripped.content
|
|
224
246
|
: appendOcxProviderTableBlock(stripped.content, retainedBlock);
|
|
225
247
|
if (had || finalContent !== content) {
|
|
226
|
-
|
|
248
|
+
publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, applyEol(finalContent, eol), undefined, hooks));
|
|
227
249
|
}
|
|
228
250
|
if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
|
|
229
|
-
|
|
251
|
+
publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => { hooks.validateBeforeRename?.(path); unlinkSync(path); hooks.afterRename?.(path); });
|
|
230
252
|
const retainedNote = retainedBlock === null
|
|
231
253
|
? ""
|
|
232
254
|
: " Kept [model_providers.opencodex] so conversations already tagged opencodex still open;"
|
|
@@ -250,4 +272,7 @@ export function removeCodexConfig(
|
|
|
250
272
|
message: removedMessage,
|
|
251
273
|
...(retainedBlock === null ? {} : { retainedProviderTable: retainedBlock.replace(/\n+$/, "").split("\n") }),
|
|
252
274
|
};
|
|
275
|
+
});
|
|
276
|
+
if (!locked.ok) return { success: false, retryable: locked.error === "locked", message: configWriteLockFailureMessage(locked) };
|
|
277
|
+
return locked.value;
|
|
253
278
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { beginCodexWriteSection, cleanExternalProviderJournal, publishCodexArtifact } from "./config-write-section";
|
|
2
|
+
import { assertCodexHomeOwner, codexHomeIsAbsent, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "../codex-home-owner";
|
|
2
3
|
import { loadConfig } from "../../config";
|
|
3
4
|
import { shouldSyncCodexOnStart } from "../desired-state";
|
|
4
5
|
import { siblingOfLivePort, siblingSkipMessage } from "../sibling-start";
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
} from "../user-identity";
|
|
27
28
|
import {
|
|
28
29
|
journaledInjectedCatalogPath,
|
|
29
|
-
|
|
30
|
+
JOURNAL_PATH,
|
|
30
31
|
releaseJournalHomeBinding,
|
|
31
32
|
restoreJournalState,
|
|
32
33
|
} from "../journal";
|
|
@@ -44,10 +45,17 @@ import {
|
|
|
44
45
|
type CodexHistoryJobOutcome,
|
|
45
46
|
} from "../history-job";
|
|
46
47
|
import {
|
|
48
|
+
CODEX_CONFIG_PATH,
|
|
47
49
|
DEFAULT_CATALOG_PATH,
|
|
48
50
|
getCodexHome,
|
|
49
51
|
tomlString,
|
|
50
52
|
} from "../paths";
|
|
53
|
+
import {
|
|
54
|
+
acquireConfigWriteLock,
|
|
55
|
+
releaseConfigWriteLock,
|
|
56
|
+
configWriteLockFailureMessage, withConfigWriteLockHeld,
|
|
57
|
+
} from "../config-write-lock";
|
|
58
|
+
import type { LockHandle } from "../config-write-lock";
|
|
51
59
|
import { shouldInjectApiAuthHeader } from "../loopback-target";
|
|
52
60
|
import { currentExternalCodexModelProvider } from "./config-toml";
|
|
53
61
|
import {
|
|
@@ -110,6 +118,7 @@ export interface CodexRestoreConfigResult {
|
|
|
110
118
|
| "failed";
|
|
111
119
|
message: string;
|
|
112
120
|
retained?: RetainedCodexProviderTable;
|
|
121
|
+
retryable?: boolean;
|
|
113
122
|
}
|
|
114
123
|
|
|
115
124
|
export interface CodexRestoreCatalogResult {
|
|
@@ -343,20 +352,45 @@ export interface RestoreConfigOptions {
|
|
|
343
352
|
}
|
|
344
353
|
|
|
345
354
|
/** The config/profile half of a native restore, reported as one artifact. */
|
|
346
|
-
function restoreCodexConfigInline(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
+
function restoreCodexConfigInline(
|
|
356
|
+
kind = "sync",
|
|
357
|
+
options: RestoreConfigOptions = {},
|
|
358
|
+
heldConfigWriteLock?: LockHandle,
|
|
359
|
+
): CodexRestoreConfigResult {
|
|
360
|
+
/*
|
|
361
|
+
* The whole config half — preimage capture, journal replay, removal, the
|
|
362
|
+
* provider-table re-attach, and failure compensation — is ONE section of the
|
|
363
|
+
* shared write lock. Splitting it let a foreign write land between the pieces
|
|
364
|
+
* and left preimage compensation replaying over bytes that writer committed.
|
|
365
|
+
*/
|
|
366
|
+
const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, heldConfigWriteLock, (held) => {
|
|
367
|
+
beginCodexWriteSection(held);
|
|
368
|
+
assertCodexHomeOwner(getCodexHome());
|
|
369
|
+
beforeRestoreConfigForTests?.(kind);
|
|
370
|
+
assertCodexHomeOwner(getCodexHome());
|
|
371
|
+
const preImages = captureCodexPreImages();
|
|
372
|
+
const result = restoreCodexConfigInlineImpl(kind, options, held);
|
|
373
|
+
if (result.state === "failed") {
|
|
374
|
+
const compensated = restoreCodexPreImages(preImages, held);
|
|
375
|
+
if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
});
|
|
379
|
+
if (!locked.ok) {
|
|
380
|
+
// Nothing was captured or written — there is nothing to compensate.
|
|
381
|
+
return {
|
|
382
|
+
state: "failed", changed: false, action: "failed",
|
|
383
|
+
retryable: locked.error === "locked", message: configWriteLockFailureMessage(locked),
|
|
384
|
+
};
|
|
355
385
|
}
|
|
356
|
-
return
|
|
386
|
+
return locked.value;
|
|
357
387
|
}
|
|
358
388
|
|
|
359
|
-
function restoreCodexConfigInlineImpl(
|
|
389
|
+
function restoreCodexConfigInlineImpl(
|
|
390
|
+
kind: string,
|
|
391
|
+
options: RestoreConfigOptions,
|
|
392
|
+
heldConfigWriteLock: LockHandle,
|
|
393
|
+
): CodexRestoreConfigResult {
|
|
360
394
|
try {
|
|
361
395
|
const disposition = resolveRestoreHistoryDisposition(options.removeProviderTable);
|
|
362
396
|
if (disposition.kind === "refuse") {
|
|
@@ -374,8 +408,17 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
|
|
|
374
408
|
// The one caller that must not capture is the explicit removal flag: it is the user
|
|
375
409
|
// accepting that tagged conversations stop opening.
|
|
376
410
|
const capturedBlock = options.removeProviderTable === true ? null : readOcxProviderTableBlock();
|
|
377
|
-
const journal = restoreJournalState();
|
|
411
|
+
const journal = restoreJournalState({ heldConfigWriteLock });
|
|
378
412
|
if (journal.ownershipRefusal) throw new CodexHomeOwnerRefusal(journal.ownershipRefusal);
|
|
413
|
+
if (journal.lockUnsafe) return { state: "failed", changed: false, action: "failed", retryable: false, message: journal.lockUnsafe };
|
|
414
|
+
if (journal.lockBusy) {
|
|
415
|
+
// Only reachable when this impl ran without a live held handle — the
|
|
416
|
+
// outer section takes the file lock before anything else runs.
|
|
417
|
+
return {
|
|
418
|
+
state: "failed", changed: false, action: "failed",
|
|
419
|
+
retryable: true, message: "Another process is writing Codex configuration right now. Retry shortly.",
|
|
420
|
+
};
|
|
421
|
+
}
|
|
379
422
|
if (journal.unverified) {
|
|
380
423
|
return {
|
|
381
424
|
state: "failed", changed: false, action: "failed",
|
|
@@ -398,6 +441,7 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
|
|
|
398
441
|
historyDisposition: disposition.kind === "stand-down"
|
|
399
442
|
? disposition.retainProviderTable ? "stand-down-retain" : "stand-down-remove"
|
|
400
443
|
: "refuse-on-any",
|
|
444
|
+
heldConfigWriteLock,
|
|
401
445
|
});
|
|
402
446
|
let retainedLines = restored.retainedProviderTable ?? null;
|
|
403
447
|
if (restored.success) {
|
|
@@ -416,11 +460,11 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
|
|
|
416
460
|
// idempotent — it checks for the table before appending — so the three do not have to
|
|
417
461
|
// be told apart here.
|
|
418
462
|
if (settled.kind === "stand-down" && settled.retainProviderTable && capturedBlock !== null) {
|
|
419
|
-
retainedLines = retainOcxProviderTableOnDisk(capturedBlock) ?? retainedLines;
|
|
463
|
+
retainedLines = retainOcxProviderTableOnDisk(capturedBlock, heldConfigWriteLock) ?? retainedLines;
|
|
420
464
|
}
|
|
421
465
|
// All native work and the final migration check succeeded. A field-level
|
|
422
466
|
// fallback retains its snapshot, but no longer owns this Codex home.
|
|
423
|
-
releaseJournalHomeBinding();
|
|
467
|
+
publishCodexArtifact(JOURNAL_PATH, heldConfigWriteLock, (_path, hooks) => releaseJournalHomeBinding(hooks));
|
|
424
468
|
}
|
|
425
469
|
if (restored.success && retainedLines !== null) {
|
|
426
470
|
return {
|
|
@@ -504,6 +548,7 @@ function restoreCodexCatalogArtifact(
|
|
|
504
548
|
export async function restoreNativeCodexAsync(
|
|
505
549
|
options: { revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
|
|
506
550
|
): Promise<CodexNativeRestoreResult> {
|
|
551
|
+
if (codexHomeIsAbsent(getCodexHome())) return skippedRestoreEnvelope(true, "Codex config not found; no native restore was needed.");
|
|
507
552
|
const sibling = siblingRestoreSkip();
|
|
508
553
|
if (sibling) return sibling;
|
|
509
554
|
try {
|
|
@@ -524,7 +569,7 @@ async function restoreNativeCodexAsyncImpl(
|
|
|
524
569
|
// External-provider courtesy: only the stale journal is removed. The
|
|
525
570
|
// history worker must not launch — it would turn a read-mostly courtesy
|
|
526
571
|
// result into a history mutation on a home we do not own.
|
|
527
|
-
|
|
572
|
+
cleanExternalProviderJournal();
|
|
528
573
|
return externalProviderRestoreResult(activeProvider);
|
|
529
574
|
}
|
|
530
575
|
|
|
@@ -560,6 +605,22 @@ async function restoreNativeCodexAsyncImpl(
|
|
|
560
605
|
let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
|
|
561
606
|
|
|
562
607
|
if (eligibility.kind === "coordinated" || eligibility.kind === "adopt") {
|
|
608
|
+
/*
|
|
609
|
+
* File lock BEFORE N — the same order injection takes. With the config
|
|
610
|
+
* writes living inside the journal replay under the file lock, a restore
|
|
611
|
+
* that took N first while a sibling held the file lock was the one
|
|
612
|
+
* deliberate inversion; acquiring here first removes it.
|
|
613
|
+
*/
|
|
614
|
+
const configLock = await acquireConfigWriteLock(CODEX_CONFIG_PATH);
|
|
615
|
+
if (!configLock.ok) {
|
|
616
|
+
config = {
|
|
617
|
+
state: "failed",
|
|
618
|
+
changed: false,
|
|
619
|
+
action: "failed",
|
|
620
|
+
retryable: configLock.error === "locked", message: configWriteLockFailureMessage(configLock),
|
|
621
|
+
};
|
|
622
|
+
} else try {
|
|
623
|
+
beginCodexWriteSection(configLock.handle);
|
|
563
624
|
// The restore has no candidate bytes to witness; freshness comes from the
|
|
564
625
|
// filesystem reads and the desired-state re-read performed under the lock.
|
|
565
626
|
const witness = { authoritySnapshotId: "codex-native-restore" };
|
|
@@ -595,12 +656,12 @@ async function restoreNativeCodexAsyncImpl(
|
|
|
595
656
|
const preImages = captureCodexPreImages();
|
|
596
657
|
let restored: CodexRestoreConfigResult;
|
|
597
658
|
try {
|
|
598
|
-
restored = restoreCodexConfigInline(eligibility.kind, options);
|
|
659
|
+
restored = restoreCodexConfigInline(eligibility.kind, options, configLock.handle);
|
|
599
660
|
// Throw inside N so the published remove transition rolls back too.
|
|
600
661
|
if (restored.state === "failed") throw new CodexRestoreRefusal(restored);
|
|
601
662
|
} catch (error) {
|
|
602
663
|
if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
|
|
603
|
-
const compensated = restoreCodexPreImages(preImages);
|
|
664
|
+
const compensated = restoreCodexPreImages(preImages, configLock.handle);
|
|
604
665
|
if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
|
|
605
666
|
throw error;
|
|
606
667
|
}
|
|
@@ -632,6 +693,9 @@ async function restoreNativeCodexAsyncImpl(
|
|
|
632
693
|
config = coordinated.value.config;
|
|
633
694
|
transitionReceipt = coordinated.value.receipt;
|
|
634
695
|
}
|
|
696
|
+
} finally {
|
|
697
|
+
releaseConfigWriteLock(configLock.handle);
|
|
698
|
+
}
|
|
635
699
|
} else {
|
|
636
700
|
// Legacy-uncoordinated (or unresolvable) homes keep the unserialized path
|
|
637
701
|
// they have always had; restore is the escape hatch and must not strand
|
|
@@ -713,6 +777,7 @@ function homeOwnerRestoreRefusal(error: CodexHomeOwnerRefusal): CodexNativeResto
|
|
|
713
777
|
export function restoreNativeCodex(
|
|
714
778
|
options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
|
|
715
779
|
): CodexNativeRestoreResult {
|
|
780
|
+
if (codexHomeIsAbsent(getCodexHome())) return skippedRestoreEnvelope(true, "Codex config not found; no native restore was needed.");
|
|
716
781
|
const sibling = siblingRestoreSkip();
|
|
717
782
|
if (sibling) return sibling;
|
|
718
783
|
try {
|
|
@@ -729,7 +794,7 @@ function restoreNativeCodexImpl(
|
|
|
729
794
|
): CodexNativeRestoreResult {
|
|
730
795
|
const activeProvider = currentExternalCodexModelProvider();
|
|
731
796
|
if (activeProvider) {
|
|
732
|
-
|
|
797
|
+
cleanExternalProviderJournal();
|
|
733
798
|
return externalProviderRestoreResult(activeProvider);
|
|
734
799
|
}
|
|
735
800
|
if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
9
9
|
|
|
10
|
+
import { publishCodexArtifact } from "./inject/config-write-section";
|
|
11
|
+
import { withConfigWriteLockHeld, type LockHandle } from "./config-write-lock";
|
|
10
12
|
import { atomicWriteFile } from "../config";
|
|
11
13
|
import type { CodexWriteLockResult, CodexWriteLockSkipReason } from "./codex-write-lock";
|
|
12
14
|
import { HUB_GATED_SKIP_MESSAGE } from "./desired-state";
|
|
@@ -367,7 +369,9 @@ export function recordCodexNativeTransactionProvenance(
|
|
|
367
369
|
*/
|
|
368
370
|
export function restoreCodexPreImages(
|
|
369
371
|
pre: CodexPreImages,
|
|
372
|
+
heldConfigWriteLock?: LockHandle,
|
|
370
373
|
): { complete: boolean; unrestored: readonly string[] } {
|
|
374
|
+
const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, heldConfigWriteLock, held => {
|
|
371
375
|
const unrestored: string[] = [];
|
|
372
376
|
const surfaces: readonly [string, string, string | null][] = [
|
|
373
377
|
["config", CODEX_CONFIG_PATH, pre.config],
|
|
@@ -375,18 +379,23 @@ export function restoreCodexPreImages(
|
|
|
375
379
|
["journal", JOURNAL_PATH, pre.journal],
|
|
376
380
|
];
|
|
377
381
|
for (const [name, path, bytes] of surfaces) {
|
|
382
|
+
// The current journal remains recovery authority until both artifacts are restored.
|
|
383
|
+
if (name === "journal" && unrestored.length > 0) { unrestored.push(name); continue; }
|
|
378
384
|
try {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
+
publishCodexArtifact(path, held, (destination, hooks) => {
|
|
386
|
+
if (bytes === null) {
|
|
387
|
+
hooks.validateBeforeRename?.(destination);
|
|
388
|
+
if (existsSync(destination)) require("node:fs").unlinkSync(destination);
|
|
389
|
+
hooks.afterRename?.(destination);
|
|
390
|
+
} else if (readOrNull(destination) !== bytes) atomicWriteFile(destination, bytes, undefined, hooks);
|
|
391
|
+
});
|
|
385
392
|
} catch {
|
|
386
393
|
unrestored.push(name);
|
|
387
394
|
}
|
|
388
395
|
}
|
|
389
396
|
return { complete: unrestored.length === 0, unrestored };
|
|
397
|
+
});
|
|
398
|
+
return locked.ok ? locked.value : { complete: false, unrestored: ["config", "profile", "journal"] };
|
|
390
399
|
}
|
|
391
400
|
|
|
392
401
|
export function buildInjectWitness(
|
package/src/codex/inject.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { beginCodexWriteSection, cleanExternalProviderJournal, publishCodexArtifact } from "./inject/config-write-section";
|
|
1
2
|
import { closeSync, existsSync, openSync, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
import {
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
} from "../config";
|
|
10
11
|
import { assertCodexHomeOwner, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
|
|
11
12
|
import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock";
|
|
13
|
+
import { ConfigWriteDestinationChanged, ConfigWriteLockRefusal, configWriteLockFailureMessage, acquireConfigWriteLock, CONFIG_WRITE_LOCK_WAIT_MS, releaseConfigWriteLock, type LockHandle } from "./config-write-lock";
|
|
12
14
|
import {
|
|
13
15
|
localClientSkipMessage,
|
|
14
16
|
localClientSkipReason,
|
|
@@ -36,7 +38,7 @@ import {
|
|
|
36
38
|
resolveEffectiveUserIdentity,
|
|
37
39
|
} from "./user-identity";
|
|
38
40
|
import {
|
|
39
|
-
hasUnverifiedJournalBaseline,
|
|
41
|
+
JOURNAL_PATH, hasUnverifiedJournalBaseline,
|
|
40
42
|
markJournalInjectedState,
|
|
41
43
|
journaledInjectedOpenaiBaseUrl,
|
|
42
44
|
journaledInjectedRealtimeWsBaseUrl,
|
|
@@ -196,6 +198,8 @@ export async function injectCodexConfig(
|
|
|
196
198
|
}
|
|
197
199
|
try { assertCodexHomeOwner(getCodexHome()); return await injectCodexConfigImpl(port, config, options); }
|
|
198
200
|
catch (error) {
|
|
201
|
+
if (error instanceof ConfigWriteDestinationChanged) return { success: false, retryable: false, message: error.message };
|
|
202
|
+
if (error instanceof ConfigWriteLockRefusal) return { success: false, retryable: error.retryable, message: error.message };
|
|
199
203
|
if (error instanceof CodexHomeOwnerRefusal) return { success: false, ownershipRefusal: error.reason, message: error.message };
|
|
200
204
|
if (error instanceof CodexHistoryPreflightRefusal) return { success: false, historyPreflightFailureReason: error.message, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` };
|
|
201
205
|
if (error instanceof CodexInjectRefusal) return error.result;
|
|
@@ -239,12 +243,7 @@ async function injectCodexConfigImpl(
|
|
|
239
243
|
// A launcher may have journaled before the provider manager took ownership. Never let shutdown
|
|
240
244
|
// replay that stale snapshot over externally managed config.
|
|
241
245
|
if (!options.validateOnly) {
|
|
242
|
-
|
|
243
|
-
withConfigMutationLockSync(() => {
|
|
244
|
-
runClientWriteGuard(options.beforeClientWrite);
|
|
245
|
-
removeJournal();
|
|
246
|
-
});
|
|
247
|
-
} else removeJournal();
|
|
246
|
+
cleanExternalProviderJournal(options.beforeClientWrite ? () => runClientWriteGuard(options.beforeClientWrite) : undefined);
|
|
248
247
|
}
|
|
249
248
|
const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(
|
|
250
249
|
config,
|
|
@@ -419,11 +418,26 @@ async function injectCodexConfigImpl(
|
|
|
419
418
|
*/
|
|
420
419
|
const reconcileAndDerivePlan = (): { plan: CodexInjectionPlanOk; nativeInput: string } => {
|
|
421
420
|
assertCodexHomeOwner(getCodexHome());
|
|
422
|
-
|
|
421
|
+
/*
|
|
422
|
+
* Re-admission under the file lock: `rawContent` and `admittedPlan` were
|
|
423
|
+
* read before this section acquired it, so the file may have moved while
|
|
424
|
+
* the lock was being waited out — committing the stale plan would rename
|
|
425
|
+
* over bytes a competing writer landed. Refuse retryably; the next pass
|
|
426
|
+
* re-reads fresh bytes (the same witness check the coordinated path does).
|
|
427
|
+
*/
|
|
428
|
+
if ((existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : null)
|
|
429
|
+
!== (missingConfig ? null : rawContent)) {
|
|
430
|
+
throw new CodexInjectRefusal({
|
|
431
|
+
success: false,
|
|
432
|
+
retryable: true,
|
|
433
|
+
message: "Codex config injection refused: config.toml changed while the write lock was being acquired. Retry to inject on the latest bytes.",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (missingConfig) publishCodexArtifact(CODEX_CONFIG_PATH, heldConfigWriteLock, (_path, hooks) => { hooks.validateBeforeRename?.(CODEX_CONFIG_PATH); createEmptyCodexConfigInBoundary(); hooks.afterRename?.(CODEX_CONFIG_PATH); });
|
|
423
437
|
let nativeInput = rawContent;
|
|
424
438
|
let plan = admittedPlan;
|
|
425
439
|
if (v1Reconcile) {
|
|
426
|
-
const reconciled = v1Reconcile.run();
|
|
440
|
+
const reconciled = v1Reconcile.run(heldConfigWriteLock);
|
|
427
441
|
if (!reconciled.ok) {
|
|
428
442
|
throw new CodexInjectRefusal({ success: false, message: reconciled.message });
|
|
429
443
|
}
|
|
@@ -458,19 +472,19 @@ async function injectCodexConfigImpl(
|
|
|
458
472
|
plan.historyRelabelRefusal = observeHistoryRefusalOrThrow(plan);
|
|
459
473
|
historyArtifactStageForTests?.("after-preflight");
|
|
460
474
|
assertCodexHomeOwner(getCodexHome());
|
|
461
|
-
writeJournal({
|
|
475
|
+
publishCodexArtifact(JOURNAL_PATH, heldConfigWriteLock, (_path, hooks) => writeJournal({
|
|
462
476
|
currentStateIsNative: journalBaselineIsNative(nativeInput),
|
|
463
477
|
configContent: plan.baselineContent,
|
|
464
478
|
owner: options.journalOwner,
|
|
465
|
-
});
|
|
479
|
+
}, hooks));
|
|
466
480
|
// A native snapshot may have been refreshed above. An older hashless routed snapshot
|
|
467
481
|
// must not gain the new injection's hash and later overwrite preserved user edits.
|
|
468
482
|
if (hasUnverifiedJournalBaseline(plan.baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage);
|
|
469
|
-
|
|
483
|
+
publishCodexArtifact(CODEX_CONFIG_PATH, heldConfigWriteLock, (path, hooks) => atomicWriteFile(path, plan.content, undefined, hooks));
|
|
470
484
|
historyArtifactStageForTests?.("after-config");
|
|
471
485
|
assertCodexHomeOwner(getCodexHome());
|
|
472
|
-
|
|
473
|
-
markJournalInjectedState(plan.content, plan.profileContent, {
|
|
486
|
+
publishCodexArtifact(CODEX_PROFILE_PATH, heldConfigWriteLock, (path, hooks) => atomicWriteFile(path, plan.profileContent, undefined, hooks));
|
|
487
|
+
publishCodexArtifact(JOURNAL_PATH, heldConfigWriteLock, (_path, hooks) => markJournalInjectedState(plan.content, plan.profileContent, {
|
|
474
488
|
// A root override is ours whenever we wrote one and no user-owned value won. That is
|
|
475
489
|
// loopback Design B, the client-compaction form, and a table form that retained the
|
|
476
490
|
// root line for paginated history. Journaling it matters because the marker comment
|
|
@@ -494,7 +508,7 @@ async function injectCodexConfigImpl(
|
|
|
494
508
|
// This is the catalog artifact selected for this injection, even when config.toml
|
|
495
509
|
// already points at that path and therefore needs no textual rewrite.
|
|
496
510
|
injectedCatalogPath: plan.catalogPath,
|
|
497
|
-
});
|
|
511
|
+
}, hooks));
|
|
498
512
|
historyArtifactStageForTests?.("after-artifacts");
|
|
499
513
|
// Detect migration throughout the artifact transaction, not just at entry.
|
|
500
514
|
plan.historyRelabelRefusal = observeHistoryRefusalOrThrow(plan);
|
|
@@ -508,6 +522,16 @@ async function injectCodexConfigImpl(
|
|
|
508
522
|
*/
|
|
509
523
|
let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
|
|
510
524
|
|
|
525
|
+
/*
|
|
526
|
+
* The file-level write lock every config.toml writer shares (see
|
|
527
|
+
* config-write-lock.ts). The injector takes it FIRST — before the SQLite N
|
|
528
|
+
* lock below — so the feature transition and the artifact commit can never
|
|
529
|
+
* be interleaved with a scalar edit, a remove/restore transform, or a
|
|
530
|
+
* prompt-layers commit. The reconcile consumes the same handle rather than
|
|
531
|
+
* re-acquiring the file it is already holding.
|
|
532
|
+
*/
|
|
533
|
+
let heldConfigWriteLock: LockHandle | undefined;
|
|
534
|
+
|
|
511
535
|
/*
|
|
512
536
|
* The plan the committed write actually used: the admitted plan, or the
|
|
513
537
|
* re-derivation from the post-reconcile bytes when the feature transition
|
|
@@ -516,6 +540,19 @@ async function injectCodexConfigImpl(
|
|
|
516
540
|
*/
|
|
517
541
|
let effectivePlan: CodexInjectionPlanOk = admittedPlan;
|
|
518
542
|
|
|
543
|
+
const configLock = await acquireConfigWriteLock(CODEX_CONFIG_PATH, {
|
|
544
|
+
timeoutMs: Math.min(options.lockTimeoutMs ?? CONFIG_WRITE_LOCK_WAIT_MS, CONFIG_WRITE_LOCK_WAIT_MS),
|
|
545
|
+
});
|
|
546
|
+
if (!configLock.ok) {
|
|
547
|
+
return {
|
|
548
|
+
success: false,
|
|
549
|
+
retryable: configLock.error === "locked",
|
|
550
|
+
message: configWriteLockFailureMessage(configLock),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
heldConfigWriteLock = configLock.handle;
|
|
554
|
+
try {
|
|
555
|
+
beginCodexWriteSection(configLock.handle);
|
|
519
556
|
if (eligibility.kind === "legacy-uncoordinated") {
|
|
520
557
|
const applyLegacy = (): CodexInjectResult | undefined => {
|
|
521
558
|
const legacyGateSnapshot = loadConfig();
|
|
@@ -544,8 +581,8 @@ async function injectCodexConfigImpl(
|
|
|
544
581
|
applyNativeArtifacts(resolved.plan, resolved.nativeInput);
|
|
545
582
|
effectivePlan = resolved.plan;
|
|
546
583
|
} catch (error) {
|
|
547
|
-
if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
|
|
548
|
-
const restored = restoreCodexPreImages(preImages);
|
|
584
|
+
if (error instanceof ConfigWriteDestinationChanged || codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
|
|
585
|
+
const restored = restoreCodexPreImages(preImages, heldConfigWriteLock);
|
|
549
586
|
if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored);
|
|
550
587
|
throw error;
|
|
551
588
|
}
|
|
@@ -565,7 +602,7 @@ async function injectCodexConfigImpl(
|
|
|
565
602
|
...(eligibility.kind === "adopt" ? { adoption: { direction: "apply" as const } } : {}),
|
|
566
603
|
onPostCallbackFailure: () => {
|
|
567
604
|
if (!coordinatedPreImages) throw new Error("Codex injection preimages were not captured.");
|
|
568
|
-
const restored = restoreCodexPreImages(coordinatedPreImages);
|
|
605
|
+
const restored = restoreCodexPreImages(coordinatedPreImages, heldConfigWriteLock);
|
|
569
606
|
if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored);
|
|
570
607
|
},
|
|
571
608
|
admitted: { authoritySnapshotId: witness.comparisonId },
|
|
@@ -639,10 +676,10 @@ async function injectCodexConfigImpl(
|
|
|
639
676
|
}
|
|
640
677
|
applyNativeArtifacts(resolved.plan, resolved.nativeInput);
|
|
641
678
|
} catch (error) {
|
|
642
|
-
if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
|
|
679
|
+
if (error instanceof ConfigWriteDestinationChanged || codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
|
|
643
680
|
// Compensate, then ALWAYS throw. Returning a partial result would let the
|
|
644
681
|
// lock commit a row describing an apply that did not finish.
|
|
645
|
-
const restored = restoreCodexPreImages(preImages);
|
|
682
|
+
const restored = restoreCodexPreImages(preImages, heldConfigWriteLock);
|
|
646
683
|
if (!restored.complete) {
|
|
647
684
|
throw new CodexPartialWriteError(restored.unrestored);
|
|
648
685
|
}
|
|
@@ -676,6 +713,10 @@ async function injectCodexConfigImpl(
|
|
|
676
713
|
);
|
|
677
714
|
transitionReceipt = coordinated.value.receipt;
|
|
678
715
|
}
|
|
716
|
+
} finally {
|
|
717
|
+
releaseConfigWriteLock(configLock.handle);
|
|
718
|
+
heldConfigWriteLock = undefined;
|
|
719
|
+
}
|
|
679
720
|
// Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs
|
|
680
721
|
// the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore
|
|
681
722
|
// machinery; cheap no-op when there is nothing to migrate). The client-compaction opt-in keeps
|
|
@@ -926,6 +967,7 @@ function missingCodexConfigAdmission(): MissingCodexConfig {
|
|
|
926
967
|
* exclusive: a file that appeared since admission belongs to another writer, and this plan,
|
|
927
968
|
* derived from an absent file, must not replace it.
|
|
928
969
|
*/
|
|
970
|
+
|
|
929
971
|
function createEmptyCodexConfigInBoundary(): void {
|
|
930
972
|
try {
|
|
931
973
|
closeSync(openSync(CODEX_CONFIG_PATH, "wx", 0o600));
|