@bitkyc08/opencodex 2.77.0 → 2.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
@@ -1,21 +1,31 @@
1
1
  import {
2
2
  closeSync,
3
+ constants,
3
4
  existsSync,
4
5
  fstatSync,
5
6
  lstatSync,
6
7
  mkdirSync,
7
8
  openSync,
8
9
  readSync,
10
+ renameSync,
11
+ unlinkSync,
9
12
  writeFileSync,
10
13
  type Stats,
11
14
  } from "node:fs";
12
- import { join } from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+ import { dirname, isAbsolute, join, resolve } from "node:path";
13
17
  import { getConfigDir } from "../config";
14
18
  import { recordOwnedConfigPath } from "../lib/config-ownership";
15
19
 
16
20
  export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024;
17
21
 
18
22
  interface ShimState {
23
+ schema?: 2;
24
+ mode?: "path-overlay";
25
+ launcherPath?: string;
26
+ wrapperIdentity?: OverlayIdentity;
27
+ envIdentity?: OverlayIdentity;
28
+ transactionId?: string;
19
29
  platform: NodeJS.Platform;
20
30
  wrapperPath: string;
21
31
  originalPath: string;
@@ -23,6 +33,51 @@ interface ShimState {
23
33
  wrappers?: ShimFileState[];
24
34
  }
25
35
 
36
+ export interface OverlayIdentity { dev: number; ino: number }
37
+ export interface OverlayState {
38
+ schema: 2;
39
+ mode: "path-overlay";
40
+ platform: NodeJS.Platform;
41
+ wrapperPath: string;
42
+ launcherPath: string;
43
+ // Older schema-2 records remain readable, but absent identities confer no mutation authority.
44
+ wrapperIdentity?: OverlayIdentity;
45
+ envIdentity?: OverlayIdentity;
46
+ transactionId?: string;
47
+ }
48
+
49
+ export function decodeOverlayState(value: unknown, configDir: string): OverlayState | null {
50
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
51
+ const state = value as Record<string, unknown>;
52
+ const platforms = ["aix", "darwin", "freebsd", "linux", "openbsd", "sunos", "android", "netbsd", "haiku", "cygwin"];
53
+ const validPath = (path: unknown): path is string => typeof path === "string" && isAbsolute(path) && !/[\x00-\x1f\x7f]/.test(path);
54
+ if (state.schema !== 2 || state.mode !== "path-overlay" || !platforms.includes(String(state.platform))
55
+ || !validPath(configDir) || !validPath(state.wrapperPath) || !validPath(state.launcherPath)
56
+ || state.wrapperPath !== join(resolve(configDir), "bin", "codex")
57
+ || dirname(state.wrapperPath).includes(":") || resolve(state.launcherPath) === state.wrapperPath) return null;
58
+ for (const field of ["originalPath", "backupPath", "realPath"]) {
59
+ if (state[field] !== undefined && state[field] !== state.launcherPath) return null;
60
+ }
61
+ if (state.preserveOnly !== undefined && state.preserveOnly !== false) return null;
62
+ if (state.wrappers !== undefined) {
63
+ if (!Array.isArray(state.wrappers) || state.wrappers.length !== 1) return null;
64
+ const file = state.wrappers[0];
65
+ if (!file || typeof file !== "object" || file.wrapperPath !== state.wrapperPath
66
+ || file.originalPath !== state.launcherPath || file.backupPath !== state.launcherPath
67
+ || (file.realPath !== undefined && file.realPath !== state.launcherPath) || file.preserveOnly) return null;
68
+ }
69
+ for (const key of ["wrapperIdentity", "envIdentity"]) {
70
+ const id = state[key] as Partial<OverlayIdentity> | undefined;
71
+ if (id !== undefined && (!id || !Number.isSafeInteger(id.dev) || !Number.isSafeInteger(id.ino))) return null;
72
+ }
73
+ if (state.transactionId !== undefined && (typeof state.transactionId !== "string" || !/^[a-f0-9-]{36}$/.test(state.transactionId))) return null;
74
+ return state as unknown as OverlayState;
75
+ }
76
+
77
+ export function readOverlayState(configDir = getConfigDir()): OverlayState | null {
78
+ return decodeOverlayState(readStateResult(join(configDir, "codex-shim.json")).state, configDir);
79
+ }
80
+
26
81
  interface ShimFileState {
27
82
  wrapperPath: string;
28
83
  originalPath: string;
@@ -43,7 +98,7 @@ function fileErrorCode(error: unknown): string | undefined {
43
98
  : undefined;
44
99
  }
45
100
 
46
- function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer; content: string } | { warning: string } | null {
101
+ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer; content: string; stat: Stats } | { warning: string } | null {
47
102
  let lexicalBefore: Stats;
48
103
  try {
49
104
  lexicalBefore = lstatSync(path);
@@ -56,7 +111,7 @@ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer
56
111
  }
57
112
  let fd: number;
58
113
  try {
59
- fd = openSync(path, "r");
114
+ fd = openSync(path, process.platform === "win32" ? "r" : constants.O_RDONLY | constants.O_NONBLOCK);
60
115
  } catch (error) {
61
116
  if (fileErrorCode(error) === "ENOENT") return null;
62
117
  return { warning: `Codex shim state could not be opened as a regular file at ${path}.` };
@@ -87,11 +142,16 @@ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer
87
142
  }
88
143
  if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
89
144
  || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs
145
+ || before.mode !== after.mode || before.uid !== after.uid
90
146
  || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino
91
- || lexicalAfter.isSymbolicLink() || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino) {
147
+ || lexicalBefore.mode !== before.mode || lexicalBefore.uid !== before.uid
148
+ || lexicalBefore.size !== before.size || lexicalBefore.mtimeMs !== before.mtimeMs || lexicalBefore.ctimeMs !== before.ctimeMs
149
+ || !lexicalAfter.isFile() || lexicalAfter.isSymbolicLink() || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino
150
+ || lexicalAfter.mode !== after.mode || lexicalAfter.uid !== after.uid
151
+ || lexicalAfter.size !== after.size || lexicalAfter.mtimeMs !== after.mtimeMs || lexicalAfter.ctimeMs !== after.ctimeMs) {
92
152
  return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` };
93
153
  }
94
- return { bytes: buffer, content: buffer.toString("utf8") };
154
+ return { bytes: buffer, content: buffer.toString("utf8"), stat: after };
95
155
  } finally {
96
156
  closeSync(fd);
97
157
  }
@@ -105,6 +165,15 @@ function readStateResult(path = statePath()): ShimStateReadResult {
105
165
  const value = JSON.parse(bounded.content) as unknown;
106
166
  if (!value || typeof value !== "object") return { state: null, present: true };
107
167
  const state = value as Record<string, unknown>;
168
+ if (state.mode !== undefined || state.schema !== undefined) {
169
+ // Legacy records stay readable; private overlay authority requires safe ownership.
170
+ // Non-writable mode drift is readable for opt-in repair, never a healthy installation.
171
+ if (process.platform !== "win32" && (bounded.stat.uid !== process.getuid?.() || (bounded.stat.mode & 0o022))) {
172
+ return { state: null, present: true, warning: "Private Codex shim state is not owned by the current user or is group/world writable; preserving artifacts. Remove the private files manually before reinstalling." };
173
+ }
174
+ const overlay = decodeOverlayState(value, dirname(path));
175
+ return { state: overlay ? { ...overlay, originalPath: overlay.launcherPath, backupPath: overlay.launcherPath } : null, present: true };
176
+ }
108
177
  if (typeof state.platform !== "string") return { state: null, present: true };
109
178
  const validFile = (item: unknown): item is ShimFileState => {
110
179
  if (!item || typeof item !== "object") return false;
@@ -134,18 +203,35 @@ function statePath(): string {
134
203
  return join(getConfigDir(), "codex-shim.json");
135
204
  }
136
205
 
137
- function writeState(state: ShimState): void {
206
+ function writeState(state: ShimState | OverlayState): void {
138
207
  const path = statePath();
139
208
  recordOwnedConfigPath(getConfigDir(), path);
140
209
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
210
+ if (state.mode === "path-overlay") {
211
+ if (!decodeOverlayState(state, getConfigDir()) || !state.wrapperIdentity || !state.envIdentity) {
212
+ throw new Error("Invalid Codex overlay state: recorded ownership identities are required");
213
+ }
214
+ const staged = `${path}.${randomUUID()}.tmp`;
215
+ try {
216
+ writeFileSync(staged, JSON.stringify(state, null, 2) + "\n", { flag: "wx", mode: 0o600 });
217
+ renameSync(staged, path);
218
+ } finally {
219
+ try { unlinkSync(staged); } catch { /* committed or absent */ }
220
+ }
221
+ return;
222
+ }
141
223
  writeFileSync(path, JSON.stringify(state, null, 2) + "\n", "utf8");
142
224
  }
143
225
 
144
226
  function stateFiles(state: ShimState): ShimFileState[] {
227
+ if (state.mode === "path-overlay" && state.launcherPath) return [{
228
+ wrapperPath: state.wrapperPath, originalPath: state.launcherPath,
229
+ backupPath: state.launcherPath, realPath: state.launcherPath,
230
+ }];
145
231
  return state.wrappers?.length
146
232
  ? state.wrappers
147
233
  : [{ wrapperPath: state.wrapperPath, originalPath: state.originalPath, backupPath: state.backupPath }];
148
234
  }
149
235
 
150
236
  export type { ShimState, ShimFileState };
151
- export { fileErrorCode, readStateResult, readState, statePath, writeState, stateFiles };
237
+ export { readBoundedRegularFile, fileErrorCode, readStateResult, readState, statePath, writeState, stateFiles };
package/src/codex/shim.ts CHANGED
@@ -55,6 +55,8 @@ import {
55
55
  import { tryAcquireShimRestoreLock } from "./shim-restore-lock";
56
56
  import {
57
57
  isWindowsInteropDir,
58
+ isExecutableCodexCandidate,
59
+ inspectShimFile,
58
60
  realIsDirectory,
59
61
  resolveStableFnmCodexPath,
60
62
  type CodexPathScanDeps,
@@ -69,6 +71,10 @@ export type { CodexPathScanDeps } from "./shim-path-resolution";
69
71
  export { isWindowsInteropDir } from "./shim-path-resolution";
70
72
  export { isLocalAbsoluteInspectionPath, inspectCodexShimBackingForCommand } from "./shim-inspect";
71
73
 
74
+ import { isShim, diagnoseCodexShim } from "./shim-diagnostics";
75
+ import { installUnixOverlay, autoRestoreUnixOverlay, uninstallUnixCodexShim, overlayPaths } from "./shim-overlay";
76
+ export { diagnoseCodexShim, codexShimStatus, type CodexShimDiagnostic } from "./shim-diagnostics";
77
+
72
78
  export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100;
73
79
 
74
80
  let lastShimDiscoveryError: string | null = null;
@@ -114,31 +120,12 @@ function commandNames(name: string): string[] {
114
120
  return [name, ...exts.flatMap(ext => [`${name}${ext.toLowerCase()}`, `${name}${ext.toUpperCase()}`])];
115
121
  }
116
122
 
117
- function isShim(path: string): boolean {
118
- try {
119
- return readFileSync(path, "utf8").includes(SHIM_MARKER);
120
- } catch {
121
- return false;
122
- }
123
- }
124
-
125
- function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
126
- try {
127
- const content = readFileSync(path, "utf8");
128
- if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false;
129
- if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false;
130
- if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false;
131
- return true;
132
- } catch {
133
- return false;
134
- }
135
- }
136
-
137
123
  export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
138
124
  lastShimDiscoveryError = null;
139
125
  const exists = deps.exists ?? existsSync;
140
- const shimFile = deps.isShimFile ?? isShim;
126
+ const shimFile = deps.isShimFile ?? (process.platform === "win32" ? isShim : (path: string) => inspectShimFile(path) !== false);
141
127
  const isDir = deps.isDirectory ?? realIsDirectory;
128
+ const eligible = deps.executableEligible ?? (deps.exists ? () => true : isExecutableCodexCandidate);
142
129
  const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime());
143
130
  const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32");
144
131
  // The injected path flavour governs join and separator alike; host-native `join` and
@@ -166,7 +153,9 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
166
153
  const names = isWindowsInteropDir(dir, automountRoot) ? interopNames : commandNames("codex");
167
154
  for (const name of names) {
168
155
  const path = joinPath(dir, name);
169
- if (!exists(path) || shimFile(path)) continue;
156
+ if (!exists(path)) continue;
157
+ if (usePosix && (path === overlayPaths().wrapper || !eligible(path))) continue;
158
+ if (shimFile(path)) continue;
170
159
  if (!isDir(path)) {
171
160
  const resolved = discoveredCodexPath(path, usePosix, deps.realpath);
172
161
  if (resolved !== null) return resolved;
@@ -1072,7 +1061,8 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
1072
1061
  };
1073
1062
  }
1074
1063
 
1075
- export function installCodexShim(): { installed: boolean; message: string } {
1064
+ export function installCodexShim(): { installed: boolean; message: string; refused?: boolean; runnable?: boolean } {
1065
+ if (process.platform !== "win32") return installUnixOverlay(findCodexOnPath);
1076
1066
  return installCodexShimInternal({ allowFreshInstall: true });
1077
1067
  }
1078
1068
 
@@ -1086,6 +1076,7 @@ export function autoRestoreCodexShim(options: {
1086
1076
  /** Narrow deterministic race seam for the guarded transaction tests. */
1087
1077
  beforeGuardedRefresh?: (wrapperPath: string, index: number) => void;
1088
1078
  }): CodexShimAutoRestoreResult {
1079
+ if (process.platform !== "win32") return autoRestoreUnixOverlay(options);
1089
1080
  const stateRead = readStateResult();
1090
1081
  const state = stateRead.state;
1091
1082
  if (!state) {
@@ -1181,6 +1172,7 @@ export function autoRestoreCodexShim(options: {
1181
1172
  }
1182
1173
 
1183
1174
  export function uninstallCodexShim(): { removed: boolean; message: string } {
1175
+ if (process.platform !== "win32") return uninstallUnixCodexShim();
1184
1176
  const state = readState();
1185
1177
  if (!state) return { removed: false, message: "Codex autostart shim is not installed." };
1186
1178
  const files = stateFiles(state);
@@ -1199,48 +1191,3 @@ export function uninstallCodexShim(): { removed: boolean; message: string } {
1199
1191
  export function isCodexShimInstalled(): boolean {
1200
1192
  return diagnoseCodexShim().installed;
1201
1193
  }
1202
-
1203
- export interface CodexShimDiagnostic {
1204
- installed: boolean;
1205
- healthy: boolean;
1206
- summary: string;
1207
- }
1208
-
1209
- /** Structured, secret-free shim state for CLI/GUI lifecycle diagnostics. */
1210
- export function diagnoseCodexShim(): CodexShimDiagnostic {
1211
- const state = readState();
1212
- if (!state) {
1213
- if (existsSync(statePath())) {
1214
- return {
1215
- installed: true,
1216
- healthy: false,
1217
- summary: `Codex autostart shim state is invalid or corrupt at ${statePath()}. Reinstall or remove the shim.`,
1218
- };
1219
- }
1220
- return {
1221
- installed: false,
1222
- healthy: false,
1223
- summary: "Codex autostart shim is not installed.",
1224
- };
1225
- }
1226
- const files = stateFiles(state);
1227
- const healthy = files.length > 0 && files.every(file => file.preserveOnly
1228
- ? existsSync(file.backupPath) && !existsSync(file.originalPath)
1229
- : existsSync(file.wrapperPath)
1230
- && (existsSync(file.backupPath) || (file.realPath ? existsSync(file.realPath) : false))
1231
- && isHealthyShim(file.wrapperPath, state.platform));
1232
- const summary = files.map(file => {
1233
- const wrapper = existsSync(file.wrapperPath)
1234
- ? isShim(file.wrapperPath)
1235
- ? "shim present"
1236
- : "present but not an opencodex shim"
1237
- : "missing";
1238
- const backup = existsSync(file.backupPath) ? "present" : "missing";
1239
- return `Codex autostart shim: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`;
1240
- }).join("\n");
1241
- return { installed: true, healthy, summary };
1242
- }
1243
-
1244
- export function codexShimStatus(): string {
1245
- return diagnoseCodexShim().summary;
1246
- }
package/src/codex/sync.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { currentExternalCodexModelProvider, injectCodexConfig } from "./inject";
2
+ import { FOREIGN_CODEX_HOME_OWNER_MESSAGE, UNKNOWN_CODEX_HOME_OWNER_MESSAGE, unbackedRoutedRemovalMessage } from "./catalog/routed-removal";
2
3
  import { printProjectCodexConfigWarnings, groupProjectCodexConfigWarningsByPath, type ProjectCodexConfigWarning } from "./project-config-warnings";
3
4
  import { refreshCodexModelCatalog } from "./refresh";
4
5
  import { applyProxyEnv, loadConfig } from "../config";
@@ -35,8 +36,8 @@ export interface CodexSyncResult {
35
36
  cacheSynced: boolean;
36
37
  /**
37
38
  * Whether the catalog owner committed a validated catalog or refused the
38
- * refresh. Only a `catalog-only` result carries it; `ok` already answers the
39
- * question for callers that do not care which half refused.
39
+ * refresh. Available on applied and catalog-only results; `ok` on applied
40
+ * describes config injection, so callers must also inspect catalog evidence.
40
41
  */
41
42
  refreshOutcome?: "committed" | "refused";
42
43
  message: string;
@@ -262,17 +263,27 @@ export async function syncModelsToCodex(
262
263
  let cacheSynced = false;
263
264
  let warning: string | undefined;
264
265
  let comboOmissions: ComboCatalogOmission[] = [];
266
+ let refreshOutcome: "committed" | "refused" | undefined;
265
267
 
266
268
  try {
267
269
  const cat = await deps.refreshCodexModelCatalog(config);
268
270
  added = cat.added;
271
+ refreshOutcome = cat.refreshOutcome;
269
272
  catalogExists = cat.catalogExists;
270
273
  catalogWritten = cat.catalogWritten;
271
274
  cacheSynced = cat.cacheSynced;
272
275
  catalogPathForInjection = cat.catalogExists ? cat.path : null;
273
276
  catalogPath = catalogPathForInjection;
274
277
  comboOmissions = cat.comboOmissions ?? [];
275
- if (cat.added > 0) {
278
+ if (cat.skippedReason === "unbacked_routed_removal" || cat.skippedReason === "foreign_owner" || cat.skippedReason === "owner_unknown") {
279
+ // The catalog on disk is kept as it was; say so, or a native-only refresh refused for safety
280
+ // reads exactly like a successful sync (#6529).
281
+ warning = cat.skippedReason === "foreign_owner"
282
+ ? FOREIGN_CODEX_HOME_OWNER_MESSAGE
283
+ : cat.skippedReason === "owner_unknown" ? UNKNOWN_CODEX_HOME_OWNER_MESSAGE
284
+ : unbackedRoutedRemovalMessage(cat.protectedRoutedNamespaces ?? 1);
285
+ log?.error(`[opencodex] ${warning}`);
286
+ } else if (cat.added > 0) {
276
287
  log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
277
288
  } else if (!cat.catalogExists) {
278
289
  warning = "catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.";
@@ -322,6 +333,7 @@ export async function syncModelsToCodex(
322
333
  status: "applied",
323
334
  ok: result.success,
324
335
  added,
336
+ ...(refreshOutcome ? { refreshOutcome } : {}),
325
337
  catalogPath,
326
338
  catalogExists,
327
339
  catalogWritten,
@@ -370,7 +382,15 @@ async function refreshCatalogForSync(
370
382
  cacheSynced = cat.cacheSynced;
371
383
  catalogPath = cat.catalogExists ? cat.path : null;
372
384
  comboOmissions = cat.comboOmissions ?? [];
373
- if (cat.added > 0) {
385
+ if (cat.skippedReason === "unbacked_routed_removal" || cat.skippedReason === "foreign_owner" || cat.skippedReason === "owner_unknown") {
386
+ // The catalog on disk is kept as it was; say so, or a native-only refresh refused for safety
387
+ // reads exactly like a successful sync (#6529).
388
+ warning = cat.skippedReason === "foreign_owner"
389
+ ? FOREIGN_CODEX_HOME_OWNER_MESSAGE
390
+ : cat.skippedReason === "owner_unknown" ? UNKNOWN_CODEX_HOME_OWNER_MESSAGE
391
+ : unbackedRoutedRemovalMessage(cat.protectedRoutedNamespaces ?? 1);
392
+ log?.error(`[opencodex] ${warning}`);
393
+ } else if (cat.added > 0) {
374
394
  log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
375
395
  } else if (!cat.catalogExists) {
376
396
  warning = "catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.";
@@ -11,6 +11,7 @@ import {
11
11
 
12
12
  interface TargetCooldown {
13
13
  cooldownUntil: number;
14
+ status?: number;
14
15
  }
15
16
 
16
17
  const DEFAULT_COOLDOWN_MS = 60_000;
@@ -195,6 +196,23 @@ export function remainingComboCooldownMs(comboId: string, now = Date.now()): num
195
196
  return soonest;
196
197
  }
197
198
 
199
+ /** Snapshot active quota cooldowns without inspecting request eligibility or changing state. */
200
+ export function snapshotComboQuotaCooldowns<T extends Pick<OcxComboTarget, "provider" | "model">>(
201
+ comboId: string,
202
+ targets: Iterable<T>,
203
+ now = Date.now(),
204
+ ): Array<{ target: T; cooldownUntil: number }> {
205
+ const snapshot: Array<{ target: T; cooldownUntil: number }> = [];
206
+ for (const target of targets) {
207
+ const key = cooldownMapKey(comboId, target);
208
+ const cooldown = targetCooldowns.get(key);
209
+ if (!cooldown || cooldown.cooldownUntil <= now) continue;
210
+ if (cooldown.status !== 429 && cooldown.status !== 402) continue;
211
+ snapshot.push({ target, cooldownUntil: cooldown.cooldownUntil });
212
+ }
213
+ return snapshot;
214
+ }
215
+
198
216
  export function comboCooldownRetryAfterSeconds(comboId: string, now = Date.now()): string | undefined {
199
217
  const remainingMs = remainingComboCooldownMs(comboId, now);
200
218
  if (remainingMs === undefined) return undefined;
@@ -244,6 +262,7 @@ export function coolComboTarget(
244
262
  targetCooldowns.set(cooldownMapKey(comboId, target), {
245
263
  // Local fallbacks are capped at ten minutes; explicit server delays at one day.
246
264
  cooldownUntil: now + (serverDelayMs ?? Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS)),
265
+ status: options?.status,
247
266
  });
248
267
  sweepExpiredOnWrite(now);
249
268
  return true;
@@ -28,6 +28,7 @@ export {
28
28
  pickComboTarget,
29
29
  pickComboTargetWithWait,
30
30
  quotaInactiveReason,
31
+ snapshotComboQuotaCooldowns,
31
32
  tryPickComboModel,
32
33
  UnknownComboError,
33
34
  type ComboPick,
@@ -6,6 +6,7 @@ import {
6
6
  coolComboTarget,
7
7
  earliestComboCooldown,
8
8
  isComboTargetInCooldown,
9
+ snapshotComboQuotaCooldowns as snapshotTargetQuotaCooldowns,
9
10
  type ComboFailureCooldownScope,
10
11
  } from "./failover";
11
12
  import { quotaResetRemainingMs } from "./reset-window";
@@ -67,6 +68,17 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now:
67
68
  return !cachedProviderQuotaIsExhausted(getCachedProviderRoutingQuota(target.provider, provider, now), now, target.model);
68
69
  }
69
70
 
71
+ /** Capture usable providers' quota cooldowns; request eligibility is deferred to exhaustion. */
72
+ export function snapshotComboQuotaCooldowns(
73
+ config: OcxConfig,
74
+ comboId: string,
75
+ now = Date.now(),
76
+ ): Array<{ target: NormalizedComboTarget; cooldownUntil: number }> {
77
+ const targets = getCombo(config, comboId)?.targets.filter(target =>
78
+ targetProviderIsUsable(config, target, now)) ?? [];
79
+ return snapshotTargetQuotaCooldowns(comboId, targets, now);
80
+ }
81
+
70
82
  function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean {
71
83
  if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 100) return false;
72
84
  return typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt > now;
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs";
14
14
  import { basename, dirname, join } from "node:path";
15
15
  import { recordOwnedConfigPath } from "../lib/config-ownership";
16
- import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
16
+ import { assertNotRealCodexHomeUnderTest, assertNotRealHomeUnderTest } from "../lib/test-home-guard";
17
17
  import {
18
18
  forgetEphemeralSecretPath,
19
19
  hardenSecretPath,
@@ -114,14 +114,19 @@ export function resolveWriteTarget(path: string): string {
114
114
  }
115
115
 
116
116
  function assertResolvedTargetAllowed(path: string, target: string): void {
117
+ // Codex-home files (catalog, models cache, journal, config.toml, profile) are addressed through
118
+ // CODEX_HOME, which falls back to os.homedir() when a test's teardown deletes it, and os.homedir()
119
+ // ignores HOME. That is how test runs rewrote the real catalog (#6529), so every atomic write
120
+ // whose directory is the real Codex home is refused under an armed test process.
121
+ let realParent: string | null = null;
122
+ try {
123
+ realParent = realpathSync(dirname(target));
124
+ } catch {
125
+ realParent = null;
126
+ }
127
+ assertNotRealCodexHomeUnderTest(realParent ?? dirname(target));
117
128
  if (target === path) {
118
- let realParent: string;
119
- try {
120
- realParent = realpathSync(dirname(target));
121
- } catch {
122
- return;
123
- }
124
- if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
129
+ if (realParent !== null && realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
125
130
  return;
126
131
  }
127
132
  assertNotRealHomeUnderTest(dirname(target));
@@ -1,4 +1,5 @@
1
1
  import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
2
+ import { protocolConfigSchema } from "./schema/config-schema";
2
3
  import { createHash } from "node:crypto";
3
4
  import { lstatSync, readFileSync } from "node:fs";
4
5
  import { join } from "node:path";
@@ -66,6 +67,7 @@ import {
66
67
  runtimeRoleSchema,
67
68
  spendSchema,
68
69
  chatgptDesktopSchema,
70
+ chatgptDesktopConfigIssue,
69
71
  compactionRoutingSchema,
70
72
  skillsConfigSchema,
71
73
  memoryModelsSchema,
@@ -109,6 +111,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
109
111
  if (normalized.chatgptDesktop?.appServerShim === true && process.platform !== "darwin") {
110
112
  warnings.push("chatgptDesktop.appServerShim is experimental and macOS only; ignored on this platform");
111
113
  }
114
+ const chatgptDesktopIssue = chatgptDesktopConfigIssue(rawParsed);
115
+ if (chatgptDesktopIssue) warnings.push(`${chatgptDesktopIssue}; the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
112
116
  warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning));
113
117
  warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
114
118
  warnings.push(...degradedListenerWarnings(rawParsed, normalized));
@@ -647,7 +651,19 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
647
651
  if (memoryModels !== undefined && !memoryModelsSchema.safeParse(memoryModels).success) {
648
652
  return { ok: false, error: "schema_invalid: memoryModels: requires a nonblank model and an optional declared reasoningEffort per configured phase, and no other fields" };
649
653
  }
650
- const routeValue = (rawConfigRecord(value)?.anthropicAccountPool as Record<string, unknown> | undefined)?.routes;
654
+ const protocols = rawConfigRecord(value)?.protocols;
655
+ if (protocols !== undefined && !protocolConfigSchema.safeParse(protocols).success) {
656
+ return { ok: false, error: "schema_invalid: protocols: expected valid protocol policy and boolean rollout flags" };
657
+ }
658
+ const rawAnthropicPool = rawConfigRecord(value)?.anthropicAccountPool;
659
+ const anthropicPool = rawConfigRecord(rawAnthropicPool);
660
+ if (rawAnthropicPool !== undefined && !anthropicPool) {
661
+ return { ok: false, error: "schema_invalid: anthropicAccountPool: must be an object" };
662
+ }
663
+ if (anthropicPool && Object.hasOwn(anthropicPool, "nativeMessages") && typeof anthropicPool.nativeMessages !== "boolean") {
664
+ return { ok: false, error: "schema_invalid: anthropicAccountPool.nativeMessages: must be a boolean" };
665
+ }
666
+ const routeValue = anthropicPool?.routes;
651
667
  if (routeValue !== undefined) {
652
668
  const parsed = parseAnthropicModelRoutes(routeValue);
653
669
  if (!parsed.ok) return { ok: false, error: `schema_invalid: anthropicAccountPool.routes: ${parsed.error}` };
@@ -730,6 +746,8 @@ export function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
730
746
  if (salvaged) {
731
747
  const config = normalizeApiKeyIds(salvaged.parsed);
732
748
  const warnings = degradedListenerWarnings(parsed, config);
749
+ const chatgptDesktopIssue = chatgptDesktopConfigIssue(parsed);
750
+ if (chatgptDesktopIssue) warnings.push(`${chatgptDesktopIssue}; the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
733
751
  return {
734
752
  config,
735
753
  source: "fallback",
@@ -41,6 +41,7 @@ import {
41
41
  transientRetryOn5xxPolicySchema,
42
42
  runtimeRoleSchema,
43
43
  spendSchema,
44
+ chatgptDesktopConfigIssue,
44
45
  } from "./schema/leaf-validators";
45
46
  import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo";
46
47
 
@@ -124,6 +125,8 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx
124
125
  export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void {
125
126
  if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved");
126
127
  if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available");
128
+ const chatgptDesktop = chatgptDesktopConfigIssue(rawParsed);
129
+ if (chatgptDesktop) console.warn(`⚠️ config.json ${chatgptDesktop} — the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
127
130
  warnDegradedStreamMode(rawParsed, validated);
128
131
  warnDegradedCompactionRouting(rawParsed, validated);
129
132
  warnDegradedMemoryModels(rawParsed, validated);
@@ -0,0 +1,22 @@
1
+ import * as z from "zod/v4";
2
+ import { redactSecretString } from "../../lib/redact";
3
+
4
+ /** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */
5
+ export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict();
6
+
7
+ /**
8
+ * Why a present `chatgptDesktop` block fails the schema, or null when it is valid or absent. The
9
+ * read path degrades a failing block to absent, so the integration reads as off while the file
10
+ * still says `appServerShim: true`; this names the field so that is never silent. A leftover key
11
+ * from an older or ported config (for example `unblockSend`) is the usual cause.
12
+ */
13
+ export function chatgptDesktopConfigIssue(rawParsed: unknown): string | null {
14
+ if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null;
15
+ const block = (rawParsed as Record<string, unknown>).chatgptDesktop;
16
+ if (block === undefined) return null;
17
+ const result = chatgptDesktopSchema.safeParse(block);
18
+ if (result.success) return null;
19
+ const issue = result.error.issues[0];
20
+ const field = issue?.path.join(".");
21
+ return redactSecretString(`chatgptDesktop${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`);
22
+ }
@@ -74,6 +74,18 @@ import { parseDesktopProfile } from "../../claude/desktop-profile";
74
74
  import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/intercept/model-bindings";
75
75
  import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory";
76
76
 
77
+ /** Strict write contract; file-load recovery is applied only by the enclosing schema. */
78
+ export const protocolConfigSchema = z.object({
79
+ unrepresentable: z.enum(["legacy", "reject"]).optional(),
80
+ rollout: z.object({
81
+ nativeChatCombos: z.boolean().optional(),
82
+ managedMessagesNative: z.boolean().optional(),
83
+ managedMessagesNativeOAuth: z.boolean().optional(),
84
+ directEncoders: z.boolean().optional(),
85
+ shadowPlan: z.boolean().optional(),
86
+ }).strict().optional(),
87
+ }).strict();
88
+
77
89
  export const configSchema = z.object({
78
90
  chatgptDesktop: chatgptDesktopSchema.optional().catch(undefined),
79
91
  codexNativeSteering: z.boolean().optional().catch(false),
@@ -96,18 +108,23 @@ export const configSchema = z.object({
96
108
  // which can reopen a surface the operator meant to close. src/protocols/settings.ts parses it
97
109
  // and fails closed instead.
98
110
  apiSurfaces: z.unknown().optional(),
99
- // Every protocol default is the conservative one (legacy policy, rollout off), so a malformed
100
- // block dropping to undefined cannot widen behavior.
101
- protocols: z.object({
102
- unrepresentable: z.enum(["legacy", "reject"]).optional(),
103
- rollout: z.object({
104
- nativeChatCombos: z.boolean().optional(),
105
- managedMessagesNative: z.boolean().optional(),
106
- managedMessagesNativeOAuth: z.boolean().optional(),
107
- directEncoders: z.boolean().optional(),
108
- shadowPlan: z.boolean().optional(),
109
- }).strict().optional(),
110
- }).strict().optional().catch(undefined),
111
+ // Keep malformed native policy disabled even when an enabled pool supplies defaults.
112
+ protocols: protocolConfigSchema.optional().catch(ctx => {
113
+ const raw = ctx.input;
114
+ const protocols = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
115
+ const rollout = protocols.rollout;
116
+ const fields = rollout && typeof rollout === "object" && !Array.isArray(rollout) ? rollout as Record<string, unknown> : {};
117
+ return {
118
+ unrepresentable: protocols.unrepresentable === "reject" ? "reject" as const : "legacy" as const,
119
+ rollout: {
120
+ nativeChatCombos: fields.nativeChatCombos === true,
121
+ managedMessagesNative: false,
122
+ managedMessagesNativeOAuth: false,
123
+ directEncoders: fields.directEncoders === true,
124
+ shadowPlan: fields.shadowPlan === true,
125
+ },
126
+ };
127
+ }),
111
128
  // A malformed present client block must remain diagnosable from raw config and
112
129
  // fail closed through src/client/state.ts; unrelated provider state still loads.
113
130
  client: clientConnectionSchema.optional().catch(undefined),
@@ -308,8 +325,9 @@ export const configSchema = z.object({
308
325
  // path below and wipe providers/pool accounts. Warning emitted in loadConfig.
309
326
  streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
310
327
  blockedModelRedirects: blockedModelRedirectsSchema.optional().catch(undefined),
311
- // Preserve malformed hand edits for a local routing error; candidate writes use the shared parser.
312
- anthropicAccountPool: z.unknown().optional(),
328
+ // Degrade malformed hand edits locally; candidate writes reject them before parsing.
329
+ // An invalid native preference retains the legacy route instead of enabling native by default.
330
+ anthropicAccountPool: z.object({ nativeMessages: z.boolean().optional().catch(false) }).passthrough().optional().catch(undefined),
313
331
  // Same degrade-don't-reject rationale as the fields above: a hand-edited
314
332
  // non-string must not trip the backup-and-defaults repair path. Unset then
315
333
  // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).