@bitkyc08/opencodex 2.55.0-preview.20260914 → 2.56.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.
Files changed (167) hide show
  1. package/gui/dist/assets/{index-DH2PUHqr.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -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
+
@@ -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
+ }