@bitkyc08/opencodex 2.55.0 → 2.57.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 +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getConfigDir } from "./paths";
|
|
5
|
+
import { hardenSecretDir, windowsSecretAclApplies } from "../lib/windows-secret-acl";
|
|
6
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
7
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
8
|
+
import {
|
|
9
|
+
bumpConfigGenerationAtPath,
|
|
10
|
+
bumpCurrentConfigGeneration,
|
|
11
|
+
initializeConfigGeneration,
|
|
12
|
+
observeConfigGenerationAtPath,
|
|
13
|
+
readConfigGenerationAtPath,
|
|
14
|
+
readConfigGenerationInTransaction,
|
|
15
|
+
type ConfigGenerationObservation,
|
|
16
|
+
} from "../codex/generation";
|
|
17
|
+
import type {
|
|
18
|
+
BumpConfigGeneration,
|
|
19
|
+
ConfigGeneration,
|
|
20
|
+
ReadConfigGeneration,
|
|
21
|
+
WithExpectedConfigGenerationSync,
|
|
22
|
+
} from "../codex/convergence-types";
|
|
23
|
+
|
|
24
|
+
const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite";
|
|
25
|
+
const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const;
|
|
26
|
+
let warnedConfigMutationDirectoryAcl = false;
|
|
27
|
+
|
|
28
|
+
export class ConfigMutationLockError extends Error {
|
|
29
|
+
readonly code = "CONFIG_MUTATION_LOCK_UNAVAILABLE";
|
|
30
|
+
|
|
31
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
32
|
+
super(message, options);
|
|
33
|
+
this.name = "ConfigMutationLockError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function configMutationDatabasePath(): string {
|
|
38
|
+
const dir = getConfigDir();
|
|
39
|
+
// First statement on purpose: a rejected mutation must leave nothing behind, not a
|
|
40
|
+
// freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts.
|
|
41
|
+
assertNotRealHomeUnderTest(dir);
|
|
42
|
+
if (!existsSync(dir)) {
|
|
43
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
44
|
+
} else {
|
|
45
|
+
try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
|
|
46
|
+
}
|
|
47
|
+
if (windowsSecretAclApplies()) {
|
|
48
|
+
try {
|
|
49
|
+
// Distinct timeout memo from management-token directory harden: a required
|
|
50
|
+
// management-dir timeout must not poison config mutation on the same home
|
|
51
|
+
// (windows-latest server-management-auth cases).
|
|
52
|
+
hardenSecretDir(dir, { required: true, timeoutMemoKey: `${dir}::config-mutation` });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!warnedConfigMutationDirectoryAcl) {
|
|
55
|
+
warnedConfigMutationDirectoryAcl = true;
|
|
56
|
+
const diagnostics = error instanceof Error ? error.message : "ACL hardening failed";
|
|
57
|
+
console.warn(
|
|
58
|
+
`[opencodex] Config mutation coordination directory ACL hardening did not complete; continuing without it. ${diagnostics}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const path = join(dir, CONFIG_MUTATION_DB_FILENAME);
|
|
64
|
+
recordOwnedConfigPath(dir, path);
|
|
65
|
+
for (const suffix of CONFIG_MUTATION_DB_SIDECARS) {
|
|
66
|
+
recordOwnedConfigPath(dir, `${path}${suffix}`);
|
|
67
|
+
}
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Raised when an independent config-mutation transaction is requested recursively. */
|
|
72
|
+
export class NestedConfigMutationError extends Error {
|
|
73
|
+
constructor() {
|
|
74
|
+
super("prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync");
|
|
75
|
+
this.name = "NestedConfigMutationError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Prepare the shared config-mutation database path for an independent top-level
|
|
81
|
+
* SQLite transaction. Callers must not invoke this while holding
|
|
82
|
+
* {@link withConfigMutationLockSync}; a second `BEGIN IMMEDIATE` deliberately
|
|
83
|
+
* fails busy instead of joining an uncommitted transaction.
|
|
84
|
+
*
|
|
85
|
+
* @throws {NestedConfigMutationError} If a config mutation lock is already held.
|
|
86
|
+
*/
|
|
87
|
+
export function prepareConfigMutationDatabasePathForWrite(): string {
|
|
88
|
+
if (configMutationLockDepth > 0) {
|
|
89
|
+
throw new NestedConfigMutationError();
|
|
90
|
+
}
|
|
91
|
+
return configMutationDatabasePath();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let configMutationLockDepth = 0;
|
|
95
|
+
let configMutationDatabase: Database | null = null;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Serialize synchronous config and Codex credential-generation commits across processes with an
|
|
99
|
+
* OS-backed SQLite write transaction. `busy_timeout=0` is deliberate: runtime request paths must
|
|
100
|
+
* fail immediately under contention rather than freeze the Bun event loop. Process exit releases
|
|
101
|
+
* SQLite locks without stale-owner deletion or lease recovery races.
|
|
102
|
+
*
|
|
103
|
+
* Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`.
|
|
104
|
+
*/
|
|
105
|
+
export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
106
|
+
if (configMutationLockDepth > 0) {
|
|
107
|
+
configMutationLockDepth += 1;
|
|
108
|
+
try {
|
|
109
|
+
return fn();
|
|
110
|
+
} finally {
|
|
111
|
+
configMutationLockDepth -= 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const path = configMutationDatabasePath();
|
|
115
|
+
let database: Database | undefined;
|
|
116
|
+
let transactionOpen = false;
|
|
117
|
+
try {
|
|
118
|
+
database = new Database(path, { create: true });
|
|
119
|
+
try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ }
|
|
120
|
+
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
121
|
+
transactionOpen = true;
|
|
122
|
+
initializeConfigGeneration(database);
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
if (transactionOpen) {
|
|
125
|
+
try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
|
|
126
|
+
}
|
|
127
|
+
try { database?.close(); } catch { /* acquisition already failed */ }
|
|
128
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
129
|
+
? String((cause as { code?: unknown }).code)
|
|
130
|
+
: "";
|
|
131
|
+
throw new ConfigMutationLockError(
|
|
132
|
+
code === "SQLITE_BUSY" ? "Config mutation already in progress" : "Could not acquire config mutation transaction",
|
|
133
|
+
{ cause },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
configMutationLockDepth = 1;
|
|
138
|
+
configMutationDatabase = database;
|
|
139
|
+
try {
|
|
140
|
+
const value = fn();
|
|
141
|
+
database.exec("COMMIT");
|
|
142
|
+
transactionOpen = false;
|
|
143
|
+
return value;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (transactionOpen) {
|
|
146
|
+
try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
|
|
147
|
+
transactionOpen = false;
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
} finally {
|
|
151
|
+
configMutationLockDepth = 0;
|
|
152
|
+
configMutationDatabase = null;
|
|
153
|
+
try { database.close(); } catch { /* the OS lock is released with the handle */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function bumpGenerationForCooperatingConfigWrite(): void {
|
|
158
|
+
if (!configMutationDatabase) {
|
|
159
|
+
throw new Error("A cooperating config write requires the config mutation transaction.");
|
|
160
|
+
}
|
|
161
|
+
bumpCurrentConfigGeneration(configMutationDatabase);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const readConfigGeneration: ReadConfigGeneration = () => {
|
|
165
|
+
try {
|
|
166
|
+
return readConfigGenerationAtPath(configMutationDatabasePath());
|
|
167
|
+
} catch {
|
|
168
|
+
return { kind: "unavailable", reason: "database" };
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export function observeConfigGeneration(): ConfigGenerationObservation {
|
|
173
|
+
return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read the generation from the transaction that is open RIGHT NOW.
|
|
178
|
+
*
|
|
179
|
+
* The observer cannot do this job. On the very first acquisition the
|
|
180
|
+
* `BEGIN IMMEDIATE` that creates the table has not committed yet, so a separate
|
|
181
|
+
* read-only connection cannot read a generation from it — measured, not
|
|
182
|
+
* assumed. A caller that compared a pre-lock observation against an observer
|
|
183
|
+
* re-read would therefore refuse every first write as stale.
|
|
184
|
+
*
|
|
185
|
+
* Throwing when no transaction is open is deliberate. Being called outside the
|
|
186
|
+
* lock is broken plumbing, and returning a typed "unavailable" would let that
|
|
187
|
+
* bug arrive disguised as an environmental failure — retried forever, on a
|
|
188
|
+
* machine where nothing is wrong.
|
|
189
|
+
*/
|
|
190
|
+
export function readConfigGenerationInCurrentMutationTransaction(): ConfigGeneration {
|
|
191
|
+
if (configMutationLockDepth < 1 || !configMutationDatabase) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
"readConfigGenerationInCurrentMutationTransaction requires an open config mutation transaction.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return readConfigGenerationInTransaction(configMutationDatabase);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const bumpConfigGeneration: BumpConfigGeneration = expected => {
|
|
200
|
+
try {
|
|
201
|
+
return bumpConfigGenerationAtPath(configMutationDatabasePath(), expected);
|
|
202
|
+
} catch {
|
|
203
|
+
return { kind: "unavailable", reason: "database" };
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
function configGenerationFailureReason(error: unknown): "busy" | "database" {
|
|
208
|
+
const cause = error instanceof ConfigMutationLockError ? error.cause : error;
|
|
209
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
210
|
+
? String((cause as { code?: unknown }).code)
|
|
211
|
+
: "";
|
|
212
|
+
const message = cause instanceof Error ? cause.message : "";
|
|
213
|
+
return code === "SQLITE_BUSY"
|
|
214
|
+
|| code === "SQLITE_LOCKED"
|
|
215
|
+
|| /database (?:is|table is) locked/i.test(message)
|
|
216
|
+
? "busy"
|
|
217
|
+
: "database";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync = (
|
|
221
|
+
expected,
|
|
222
|
+
commit,
|
|
223
|
+
) => {
|
|
224
|
+
let callbackThrew = false;
|
|
225
|
+
let callbackError: unknown;
|
|
226
|
+
try {
|
|
227
|
+
return withConfigMutationLockSync(() => {
|
|
228
|
+
const database = configMutationDatabase;
|
|
229
|
+
if (!database) throw new Error("Config mutation transaction database is unavailable.");
|
|
230
|
+
const current = readConfigGenerationInTransaction(database);
|
|
231
|
+
if (current.value !== expected.value) return { kind: "conflict", current };
|
|
232
|
+
try {
|
|
233
|
+
return { kind: "matched", generation: current, value: commit() };
|
|
234
|
+
} catch (error) {
|
|
235
|
+
callbackThrew = true;
|
|
236
|
+
callbackError = error;
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (callbackThrew && error === callbackError) throw error;
|
|
242
|
+
return { kind: "unavailable", reason: configGenerationFailureReason(error) };
|
|
243
|
+
}
|
|
244
|
+
};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { getConfigPath } from "./paths";
|
|
3
|
+
import { isMissingPathError, nextAtomicTempSequence } from "./atomic-write";
|
|
4
|
+
import { forgetEphemeralSecretPath, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
5
|
+
|
|
6
|
+
export class OpenAiTierBackupCleanupError extends Error {
|
|
7
|
+
constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class OpenAiTierBackupRollbackError extends Error {
|
|
11
|
+
constructor() { super("OpenAI tier backup rollback failed"); this.name = "OpenAiTierBackupRollbackError"; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class OpenAiTierBackupCollisionError extends Error {
|
|
15
|
+
readonly configPath?: string;
|
|
16
|
+
constructor(configPath?: string) {
|
|
17
|
+
super("Existing OpenAI tier backup differs from the current config");
|
|
18
|
+
this.name = "OpenAiTierBackupCollisionError";
|
|
19
|
+
this.configPath = configPath;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class OpenAiTierRollbackPreserveError extends Error {
|
|
24
|
+
readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted";
|
|
25
|
+
constructor(message: string, options?: ErrorOptions & { code?: OpenAiTierRollbackPreserveError["code"] }) {
|
|
26
|
+
super(message, options);
|
|
27
|
+
this.name = "OpenAiTierRollbackPreserveError";
|
|
28
|
+
this.code = options?.code;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class OpenAiTierBackupSecretResidualError extends Error {
|
|
33
|
+
constructor(readonly tempPath: string, options?: ErrorOptions) {
|
|
34
|
+
super("OpenAI tier backup could not scrub or remove a secret-bearing temporary file", options);
|
|
35
|
+
this.name = "OpenAiTierBackupSecretResidualError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OpenAiTierBackupIO {
|
|
40
|
+
exists(path: string): boolean;
|
|
41
|
+
read(path: string): Uint8Array;
|
|
42
|
+
createExclusive(path: string): void;
|
|
43
|
+
write(path: string, bytes: Uint8Array): void;
|
|
44
|
+
harden(path: string): void;
|
|
45
|
+
publishNoReplace(temp: string, backup: string): void;
|
|
46
|
+
truncate(path: string): void;
|
|
47
|
+
unlink(path: string): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
51
|
+
return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isAlreadyExistsError(error: unknown): boolean {
|
|
55
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Classify an existing `.pre-openai-tiers-v2.bak` snapshot.
|
|
60
|
+
*
|
|
61
|
+
* - `"stale"`: unparseable JSON (not written by us / truncated) or already a
|
|
62
|
+
* post-migration (tier v2) snapshot — safe to delete or replace.
|
|
63
|
+
* - `"rollback"`: parses as a valid pre-migration (v1) config — a
|
|
64
|
+
* user-intentional rollback point that must never be silently destroyed.
|
|
65
|
+
*
|
|
66
|
+
* Shared by the startup migration backup path and `ocx init` cleanup so both
|
|
67
|
+
* apply the same preservation policy (issue #257 / sol review 260722).
|
|
68
|
+
*/
|
|
69
|
+
export function classifyOpenAiTierBackup(backupBytes: Uint8Array): "stale" | "rollback" {
|
|
70
|
+
try {
|
|
71
|
+
// Use Buffer.from to ensure proper UTF-8 decoding from Uint8Array/Buffer.
|
|
72
|
+
const parsed = JSON.parse(Buffer.from(backupBytes).toString("utf8")) as Record<string, unknown>;
|
|
73
|
+
return parsed.openaiProviderTierVersion === 2 ? "stale" : "rollback";
|
|
74
|
+
} catch {
|
|
75
|
+
// Unparseable: not a config file we created, treat as stale.
|
|
76
|
+
return "stale";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function backupConfigBeforeOpenAiTierMigration(
|
|
81
|
+
configPath = getConfigPath(),
|
|
82
|
+
io: OpenAiTierBackupIO = {
|
|
83
|
+
exists: existsSync,
|
|
84
|
+
read: target => readFileSync(target),
|
|
85
|
+
createExclusive: target => { writeFileSync(target, new Uint8Array(), { flag: "wx", mode: 0o600 }); },
|
|
86
|
+
write: (target, bytes) => writeFileSync(target, bytes),
|
|
87
|
+
harden: target => {
|
|
88
|
+
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
89
|
+
// Soft-fail: a wedged/failed icacls on CI temp volumes must not abort
|
|
90
|
+
// startServer mid-suite (timeout + EBUSY cascade on shared TEST_DIR).
|
|
91
|
+
// chmod above still applies; live credential writes keep required:true.
|
|
92
|
+
if (process.platform === "win32") hardenSecretPath(target, { required: false });
|
|
93
|
+
},
|
|
94
|
+
publishNoReplace: (temp, backup) => linkSync(temp, backup),
|
|
95
|
+
truncate: target => truncateSync(target, 0),
|
|
96
|
+
unlink: unlinkSync,
|
|
97
|
+
},
|
|
98
|
+
): "absent" | "created" | "reused" {
|
|
99
|
+
const source = configPath;
|
|
100
|
+
if (!io.exists(source)) return "absent";
|
|
101
|
+
const original = io.read(source);
|
|
102
|
+
// v2 snapshot path. The historical `.pre-openai-tiers-v1.bak` is read only by restore
|
|
103
|
+
// docs/fixtures and is never reused or overwritten as the v2 snapshot.
|
|
104
|
+
const backup = `${source}.pre-openai-tiers-v2.bak`;
|
|
105
|
+
if (io.exists(backup)) {
|
|
106
|
+
if (!sameBytes(original, io.read(backup))) {
|
|
107
|
+
// The backup differs from the current config. Only treat it as stale when it is
|
|
108
|
+
// clearly not a user-intentional rollback point:
|
|
109
|
+
// - unparseable JSON: written by a different tool or truncated
|
|
110
|
+
// - already at tier version 2: the backup is from a post-migration config (e.g.
|
|
111
|
+
// ocx init wrote a fresh v2 config, making the old backup obsolete)
|
|
112
|
+
// A backup that parses as a valid pre-migration (v1) config is kept as-is and
|
|
113
|
+
// we throw a collision error, because silently replacing a user-created rollback
|
|
114
|
+
// point would be surprising and potentially destructive.
|
|
115
|
+
const backupBytes = io.read(backup);
|
|
116
|
+
if (classifyOpenAiTierBackup(backupBytes) === "rollback") {
|
|
117
|
+
throw new OpenAiTierBackupCollisionError(source);
|
|
118
|
+
}
|
|
119
|
+
console.warn("[openai-provider-migration] Replacing stale pre-migration backup (post-migration config was rewritten since last migration).");
|
|
120
|
+
io.unlink(backup);
|
|
121
|
+
} else {
|
|
122
|
+
return "reused";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const temp = `${backup}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
|
|
126
|
+
let published = false;
|
|
127
|
+
let cleanupAttempted = false;
|
|
128
|
+
|
|
129
|
+
const scrubUnpublishedTemp = (): void => {
|
|
130
|
+
cleanupAttempted = true;
|
|
131
|
+
let scrubbed = false;
|
|
132
|
+
try {
|
|
133
|
+
io.truncate(temp);
|
|
134
|
+
scrubbed = true;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (isMissingPathError(error)) scrubbed = true;
|
|
137
|
+
else {
|
|
138
|
+
try { io.write(temp, new Uint8Array()); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
let removed = false;
|
|
142
|
+
try {
|
|
143
|
+
io.unlink(temp);
|
|
144
|
+
removed = true;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (isMissingPathError(error)) {
|
|
147
|
+
removed = true;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
try { io.unlink(temp); removed = true; }
|
|
151
|
+
catch (retryError) {
|
|
152
|
+
if (isMissingPathError(retryError)) {
|
|
153
|
+
removed = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (removed) forgetEphemeralSecretPath(temp);
|
|
159
|
+
if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp);
|
|
160
|
+
if (!removed) throw new OpenAiTierBackupCleanupError();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
io.createExclusive(temp);
|
|
165
|
+
io.write(temp, original);
|
|
166
|
+
io.harden(temp);
|
|
167
|
+
try {
|
|
168
|
+
io.publishNoReplace(temp, backup);
|
|
169
|
+
} catch (cause) {
|
|
170
|
+
if (!isAlreadyExistsError(cause)) throw cause;
|
|
171
|
+
const winner = io.read(backup);
|
|
172
|
+
if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError(source);
|
|
173
|
+
scrubUnpublishedTemp();
|
|
174
|
+
return "reused";
|
|
175
|
+
}
|
|
176
|
+
published = true;
|
|
177
|
+
try {
|
|
178
|
+
io.unlink(temp);
|
|
179
|
+
forgetEphemeralSecretPath(temp);
|
|
180
|
+
} catch (firstError) {
|
|
181
|
+
if (isMissingPathError(firstError)) {
|
|
182
|
+
forgetEphemeralSecretPath(temp);
|
|
183
|
+
} else try {
|
|
184
|
+
io.unlink(temp);
|
|
185
|
+
forgetEphemeralSecretPath(temp);
|
|
186
|
+
} catch (secondError) {
|
|
187
|
+
if (isMissingPathError(secondError)) {
|
|
188
|
+
forgetEphemeralSecretPath(temp);
|
|
189
|
+
return "created";
|
|
190
|
+
}
|
|
191
|
+
// temp and backup are hard links to the same inode. Roll back the backup
|
|
192
|
+
// link before any truncation so the downgrade snapshot is never zeroed.
|
|
193
|
+
try { io.unlink(backup); } catch { throw new OpenAiTierBackupRollbackError(); }
|
|
194
|
+
published = false;
|
|
195
|
+
scrubUnpublishedTemp();
|
|
196
|
+
throw new OpenAiTierBackupCleanupError();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return "created";
|
|
200
|
+
} catch (cause) {
|
|
201
|
+
if (!published && !cleanupAttempted) {
|
|
202
|
+
scrubUnpublishedTemp();
|
|
203
|
+
}
|
|
204
|
+
throw cause;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface OpenAiTierRollbackPreserveIO {
|
|
209
|
+
exists(path: string): boolean;
|
|
210
|
+
read(path: string): Uint8Array;
|
|
211
|
+
copyExclusive(source: string, destination: string): void;
|
|
212
|
+
unlink(path: string): void;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = {
|
|
216
|
+
exists: existsSync,
|
|
217
|
+
read: target => readFileSync(target),
|
|
218
|
+
copyExclusive: (source, destination) => {
|
|
219
|
+
copyFileSync(source, destination, fsConstants.COPYFILE_EXCL);
|
|
220
|
+
},
|
|
221
|
+
unlink: unlinkSync,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Copy a rollback-classified `.pre-openai-tiers-v2.bak` to a unique
|
|
228
|
+
* `.pre-openai-tiers-v1-rollback.<timestamp>[suffix].bak` path, then unlink the
|
|
229
|
+
* blocking v2 name. The original bytes are copied with no-replace publication;
|
|
230
|
+
* the v2 path is removed only after the copy is verified. Shared by startup
|
|
231
|
+
* migration recovery and `ocx init` cleanup so the two paths cannot drift.
|
|
232
|
+
*/
|
|
233
|
+
export function preserveOpenAiTierRollbackSnapshot(
|
|
234
|
+
configPath = getConfigPath(),
|
|
235
|
+
io: OpenAiTierRollbackPreserveIO = DEFAULT_ROLLBACK_PRESERVE_IO,
|
|
236
|
+
): string {
|
|
237
|
+
const backup = `${configPath}.pre-openai-tiers-v2.bak`;
|
|
238
|
+
if (!io.exists(backup)) {
|
|
239
|
+
throw new OpenAiTierRollbackPreserveError("OpenAI tier rollback backup is missing", { code: "missing" });
|
|
240
|
+
}
|
|
241
|
+
const original = io.read(backup);
|
|
242
|
+
if (classifyOpenAiTierBackup(original) !== "rollback") {
|
|
243
|
+
throw new OpenAiTierRollbackPreserveError("OpenAI tier backup is not a rollback snapshot", { code: "not-rollback" });
|
|
244
|
+
}
|
|
245
|
+
for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) {
|
|
246
|
+
const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`;
|
|
247
|
+
try {
|
|
248
|
+
io.copyExclusive(backup, preserved);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (isAlreadyExistsError(error)) continue;
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
let copied: Uint8Array;
|
|
254
|
+
try {
|
|
255
|
+
copied = io.read(preserved);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
throw new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" });
|
|
258
|
+
}
|
|
259
|
+
if (!sameBytes(original, copied)) {
|
|
260
|
+
try { io.unlink(preserved); } catch { /* keep the original backup; incomplete copy is best-effort */ }
|
|
261
|
+
throw new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" });
|
|
262
|
+
}
|
|
263
|
+
io.unlink(backup);
|
|
264
|
+
return preserved;
|
|
265
|
+
}
|
|
266
|
+
throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" });
|
|
267
|
+
}
|
|
268
|
+
|
|
@@ -202,6 +202,37 @@ export function pendingTeardownOutstanding(): boolean {
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Are the outstanding obligations EXACTLY the ones this stop chose to keep?
|
|
207
|
+
*
|
|
208
|
+
* `ocx stop` can preserve its own obligations deliberately — the Codex history preflight
|
|
209
|
+
* refuses before anything is restored, so the receipt has to survive for a later stop
|
|
210
|
+
* (#4718). That is safe for an update to continue past, because the stop knows those
|
|
211
|
+
* receipts describe a proxy it just proved down.
|
|
212
|
+
*
|
|
213
|
+
* Nothing else is. A quarantined receipt is waiting on a human, and a receipt belonging
|
|
214
|
+
* to a live owner means another stop is in flight; letting either ride along would turn
|
|
215
|
+
* "we deliberately kept ours" into "we ignored everyone's". So membership is the test,
|
|
216
|
+
* not a count of ours: an unrecognized obligation of any kind answers false and the
|
|
217
|
+
* caller falls back to the ordinary failure code.
|
|
218
|
+
*
|
|
219
|
+
* Quarantined names are included in the scan on purpose. They do not correspond to any
|
|
220
|
+
* nonce this run preserved, so their presence always answers false.
|
|
221
|
+
*/
|
|
222
|
+
export function pendingTeardownsAreExactly(nonces: readonly string[]): boolean {
|
|
223
|
+
const expected = new Set(nonces.map(nonce => `${PREFIX}${nonce}${SUFFIX}`));
|
|
224
|
+
let names: string[];
|
|
225
|
+
try {
|
|
226
|
+
names = readdirSync(getConfigDir());
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// A home that does not exist holds nothing, which matches only an empty expectation.
|
|
229
|
+
// Any other scan failure may be hiding an obligation and must not answer "exactly".
|
|
230
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" && expected.size === 0;
|
|
231
|
+
}
|
|
232
|
+
const found = names.filter(isAnyTeardownObligationFileName);
|
|
233
|
+
return found.length === expected.size && found.every(name => expected.has(name));
|
|
234
|
+
}
|
|
235
|
+
|
|
205
236
|
/** Paths of quarantined obligations awaiting a human. */
|
|
206
237
|
export function listQuarantinedTeardowns(): string[] {
|
|
207
238
|
try {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { configReasoningPinsConfigError } from "./provider-validation";
|
|
3
|
+
import type { OcxConfig } from "../types";
|
|
4
|
+
import { refreshUserCostOverlays, withPreservedDiskOnlyProviders } from "../usage/user-cost-overlays";
|
|
5
|
+
import { atomicWriteFile, isMissingPathError } from "./atomic-write";
|
|
6
|
+
import { getConfigPath } from "./paths";
|
|
7
|
+
import { configRebaseDeletionKeys, projectConfigRebaseProvenance } from "./rebase-provenance";
|
|
8
|
+
import { clientConnectionSchema } from "./schema/leaf-validators";
|
|
9
|
+
|
|
10
|
+
/** The literal file, with no schema merge or default injection. */
|
|
11
|
+
export function readRawConfigJson(): Record<string, unknown> | undefined {
|
|
12
|
+
try {
|
|
13
|
+
const configPath = getConfigPath();
|
|
14
|
+
if (!existsSync(configPath)) return undefined;
|
|
15
|
+
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
16
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
17
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
18
|
+
return parsed as Record<string, unknown>;
|
|
19
|
+
} catch {
|
|
20
|
+
// Unreadable or corrupt: behave exactly as before. Never fail a save over protection.
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function failClosedClientPersistenceError(
|
|
26
|
+
raw: Record<string, unknown> | undefined,
|
|
27
|
+
candidate: OcxConfig,
|
|
28
|
+
): string | null {
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
const rawHasClient = Object.hasOwn(raw, "client") && raw.client !== undefined;
|
|
31
|
+
const rawRole = raw.runtimeRole;
|
|
32
|
+
const rawRoleValid = rawRole === undefined
|
|
33
|
+
|| rawRole === "standalone"
|
|
34
|
+
|| rawRole === "hub"
|
|
35
|
+
|| rawRole === "client";
|
|
36
|
+
const rawClientValid = !rawHasClient || clientConnectionSchema.safeParse(raw.client).success;
|
|
37
|
+
const rawPairValid = rawRoleValid
|
|
38
|
+
&& ((rawRole === "client" && rawHasClient && rawClientValid)
|
|
39
|
+
|| (rawRole !== "client" && !rawHasClient));
|
|
40
|
+
if (rawPairValid) return null;
|
|
41
|
+
|
|
42
|
+
const candidateValid = candidate.runtimeRole === "client"
|
|
43
|
+
&& clientConnectionSchema.safeParse(candidate.client).success;
|
|
44
|
+
const deletions = configRebaseDeletionKeys(candidate);
|
|
45
|
+
const explicitClear = deletions.has("client") && deletions.has("runtimeRole");
|
|
46
|
+
if (candidateValid || explicitClear) return null;
|
|
47
|
+
return "config write refused: malformed or mismatched remote client state must be repaired or explicitly cleared";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Atomic config.json write WITHOUT the mutation lock; callers must hold
|
|
52
|
+
* `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the
|
|
53
|
+
* cost-overlay registry from the persisted config so runtime estimates follow
|
|
54
|
+
* every save path.
|
|
55
|
+
*/
|
|
56
|
+
export function persistConfigUnlocked(config: OcxConfig): boolean {
|
|
57
|
+
const pinError = configReasoningPinsConfigError(config);
|
|
58
|
+
if (pinError) throw new Error(pinError);
|
|
59
|
+
const configPath = getConfigPath();
|
|
60
|
+
const rawBeforeWrite = readRawConfigJson();
|
|
61
|
+
const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config);
|
|
62
|
+
if (clientPersistenceError) throw new Error(clientPersistenceError);
|
|
63
|
+
// External editors can add provider rows the live config deliberately does
|
|
64
|
+
// not route with yet; merge them at the serialization boundary so an
|
|
65
|
+
// unrelated in-process save cannot erase the provider or its overlay.
|
|
66
|
+
// Provider preservation reads symbol-keyed live-owner state, which structuredClone
|
|
67
|
+
// intentionally drops. Resolve that ownership before projecting JSON provenance.
|
|
68
|
+
const provenanceProjection = projectConfigRebaseProvenance(config);
|
|
69
|
+
const persisted = withPreservedDiskOnlyProviders(config);
|
|
70
|
+
if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance;
|
|
71
|
+
else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
|
|
72
|
+
const bytes = JSON.stringify(persisted, null, 2) + "\n";
|
|
73
|
+
let unchanged = false;
|
|
74
|
+
try {
|
|
75
|
+
unchanged = readFileSync(configPath, "utf8") === bytes;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!isMissingPathError(error)) throw error;
|
|
78
|
+
}
|
|
79
|
+
// Keep the runtime overlay registry in sync with EVERY persist path,
|
|
80
|
+
// including byte-identical saves: a cooperating CLI process may have written
|
|
81
|
+
// the same bytes (e.g. before a proxy notification), and Logs/Usage must
|
|
82
|
+
// adopt the overlay without waiting for a changed save or restart.
|
|
83
|
+
if (unchanged) {
|
|
84
|
+
refreshUserCostOverlays(persisted);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
atomicWriteFile(configPath, bytes);
|
|
88
|
+
// For changed saves, refresh only AFTER the write succeeded so a failed
|
|
89
|
+
// write cannot leave estimates reflecting configuration never persisted.
|
|
90
|
+
refreshUserCostOverlays(persisted);
|
|
91
|
+
return true;
|
|
92
|
+
}
|