@bitkyc08/opencodex 2.63.0 → 2.64.0-preview.20260923

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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-D5eN1TID.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-Bh_ErQDh.js} +1 -1
  10. package/gui/dist/assets/index-BEq4OCOz.js +86 -0
  11. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  12. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-BqrsSrIR.js +0 -50
  259. package/gui/dist/assets/index-C6SJrh0N.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -100,6 +100,7 @@ export interface CodexLogGuardMaintenanceDeps {
100
100
  ) => CodexLogGuardLockOutcome<T>;
101
101
  quickCheck?: (db: Database) => string[];
102
102
  openDatabase?: (databasePath: string, flags: number) => Database;
103
+ statDatabasePath?: (databasePath: string) => DatabasePathStat;
103
104
  batchPages?: number;
104
105
  maxPagesPerRun?: number;
105
106
  }
@@ -112,15 +113,30 @@ interface CheckpointRow {
112
113
  }
113
114
 
114
115
  interface DatabaseFileIdentity {
115
- dev: number;
116
- ino: number;
116
+ dev: bigint;
117
+ ino: bigint;
117
118
  realPath: string;
118
119
  }
119
120
 
120
- function databasePathIdentity(databasePath: string): DatabaseFileIdentity | null {
121
+ interface DatabasePathStat {
122
+ dev?: bigint | null;
123
+ ino?: bigint | null;
124
+ isFile(): boolean;
125
+ isSymbolicLink(): boolean;
126
+ }
127
+
128
+ function databasePathIdentity(
129
+ databasePath: string,
130
+ deps: CodexLogGuardMaintenanceDeps,
131
+ ): DatabaseFileIdentity | null {
121
132
  try {
122
- const stat = lstatSync(databasePath);
123
- if (!stat.isFile() || stat.isSymbolicLink()) return null;
133
+ const stat = deps.statDatabasePath
134
+ ? deps.statDatabasePath(databasePath)
135
+ : lstatSync(databasePath, { bigint: true });
136
+ // Windows file IDs are 64-bit; a number can round adjacent IDs equal.
137
+ // Missing or zero means the filesystem did not supply an identity, so fail closed.
138
+ if (!stat.isFile() || stat.isSymbolicLink()
139
+ || typeof stat.dev !== "bigint" || typeof stat.ino !== "bigint" || stat.ino === 0n) return null;
124
140
  const realPath = realpathSync.native(databasePath);
125
141
  if (!sameLogGuardPathIdentity(realPath, databasePath)) return null;
126
142
  return { dev: stat.dev, ino: stat.ino, realPath };
@@ -129,15 +145,16 @@ function databasePathIdentity(databasePath: string): DatabaseFileIdentity | null
129
145
  }
130
146
  }
131
147
 
132
- function databasePathIsSafe(databasePath: string): boolean {
133
- return databasePathIdentity(databasePath) !== null;
148
+ function databasePathIsSafe(databasePath: string, deps: CodexLogGuardMaintenanceDeps): boolean {
149
+ return databasePathIdentity(databasePath, deps) !== null;
134
150
  }
135
151
 
136
152
  function databasePathStillMatches(
137
153
  databasePath: string,
138
154
  before: DatabaseFileIdentity,
155
+ deps: CodexLogGuardMaintenanceDeps,
139
156
  ): boolean {
140
- const after = databasePathIdentity(databasePath);
157
+ const after = databasePathIdentity(databasePath, deps);
141
158
  return after !== null
142
159
  && after.dev === before.dev
143
160
  && after.ino === before.ino
@@ -228,15 +245,15 @@ function runCompaction(
228
245
  let probeOpen = false;
229
246
  let reportBusyPartial: (() => CodexLogGuardCompactionResult) | undefined;
230
247
  try {
231
- const beforeOpenIdentity = databasePathIdentity(databasePath);
248
+ const beforeOpenIdentity = databasePathIdentity(databasePath, deps);
232
249
  if (!beforeOpenIdentity) return { ok: false, error: "unsafe_path" };
233
250
  const openDatabase = deps.openDatabase
234
251
  ?? ((path: string, flags: number) => new Database(path, flags));
235
252
  db = openDatabase(databasePath, sqliteConstants.SQLITE_OPEN_READWRITE);
236
253
  // The path is user-writable foreign state. Re-check its regular-file,
237
- // canonical-path and st_dev/st_ino identity immediately after SQLite opens
238
- // it, before issuing any pragma or write-capable statement.
239
- if (!databasePathStillMatches(databasePath, beforeOpenIdentity)) {
254
+ // canonical-path and full-width st_dev/st_ino identity immediately after
255
+ // SQLite opens it, before issuing any pragma or write-capable statement.
256
+ if (!databasePathStillMatches(databasePath, beforeOpenIdentity, deps)) {
240
257
  return { ok: false, error: "unsafe_path" };
241
258
  }
242
259
  db.exec("PRAGMA busy_timeout = 0");
@@ -373,7 +390,7 @@ export function compactCodexLogs(
373
390
  if (inspection.capabilities.reclaim.state !== "supported") {
374
391
  return { ok: false, error: "unsupported_schema" };
375
392
  }
376
- if (!databasePathIsSafe(databasePath)) return { ok: false, error: "unsafe_path" };
393
+ if (!databasePathIsSafe(databasePath, deps)) return { ok: false, error: "unsafe_path" };
377
394
 
378
395
  const checkProcesses = deps.processCheck ?? listRunningCodexProcesses;
379
396
  const firstRefusal = processRefusal(checkProcesses());
@@ -1,4 +1,14 @@
1
- import { existsSync, readFileSync, realpathSync } from "node:fs";
1
+ import {
2
+ closeSync,
3
+ constants,
4
+ existsSync,
5
+ fstatSync,
6
+ lstatSync,
7
+ openSync,
8
+ readFileSync,
9
+ readSync,
10
+ realpathSync,
11
+ } from "node:fs";
2
12
  import path, { dirname, join, resolve } from "node:path";
3
13
  import { expandUserPath } from "../config";
4
14
  import { defaultCodexHome } from "./home";
@@ -8,6 +18,34 @@ import { truncateRetainedUtf8 } from "../lib/admission";
8
18
  const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
9
19
  const DIAGNOSTICS_CACHE_TTL_MS = 30_000;
10
20
  const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
21
+ const MAX_PROJECT_CONFIG_BYTES = 1024 * 1024;
22
+
23
+ export function readBoundedProjectConfig(filePath: string): string | null {
24
+ let fd: number | undefined;
25
+ try {
26
+ // A candidate can become a FIFO after discovery; opening must not wait for a writer.
27
+ fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
28
+ const stat = fstatSync(fd);
29
+ if (!stat.isFile() || stat.size > MAX_PROJECT_CONFIG_BYTES) return null;
30
+
31
+ const buffer = Buffer.allocUnsafe(stat.size + 1);
32
+ let bytesRead = 0;
33
+ while (bytesRead < buffer.length) {
34
+ const count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null);
35
+ if (count === 0) break;
36
+ bytesRead += count;
37
+ }
38
+ // Reject a file that changed while it was read, including a same-size rewrite.
39
+ const after = fstatSync(fd);
40
+ if (bytesRead !== stat.size || after.size !== stat.size
41
+ || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) return null;
42
+ return buffer.toString("utf-8", 0, bytesRead);
43
+ } catch {
44
+ return null;
45
+ } finally {
46
+ if (fd !== undefined) closeSync(fd);
47
+ }
48
+ }
11
49
 
12
50
  function resolveCodexConfigPath(): string {
13
51
  const raw = process.env.CODEX_HOME?.trim();
@@ -359,6 +397,12 @@ export function discoverProjectCodexConfigPaths(options: {
359
397
  const globalConfigIdentity = normalizeExistingPath(codexConfigPath);
360
398
  const addIfExists = (projectRoot: string) => {
361
399
  const candidate = join(resolve(projectRoot), ".codex", "config.toml");
400
+ try {
401
+ const stat = lstatSync(candidate);
402
+ if (!stat.isFile() || stat.size > MAX_PROJECT_CONFIG_BYTES) return;
403
+ } catch {
404
+ return;
405
+ }
362
406
  const candidateIdentity = normalizeExistingPath(candidate);
363
407
  if (candidateIdentity && candidateIdentity !== globalConfigIdentity) found.add(candidate);
364
408
  };
@@ -397,12 +441,8 @@ export function collectProjectCodexConfigWarnings(options: {
397
441
 
398
442
  const warnings: ProjectCodexConfigWarning[] = [];
399
443
  for (const path of discoverProjectCodexConfigPaths({ cwd: options.cwd, codexConfigPath })) {
400
- try {
401
- const content = readFileSync(path, "utf-8");
402
- warnings.push(...analyzeProjectCodexConfig(content, path));
403
- } catch {
404
- /* skip unreadable project config */
405
- }
444
+ const content = readBoundedProjectConfig(path);
445
+ if (content !== null) warnings.push(...analyzeProjectCodexConfig(content, path));
406
446
  }
407
447
  return warnings;
408
448
  }
@@ -78,3 +78,44 @@ export function decodeBasicString(literal: string): string | null {
78
78
  }
79
79
  return out;
80
80
  }
81
+
82
+ /** Decode externally authored TOML basic strings on the read-only fallback path. */
83
+ export function decodeTomlBasicString(literal: string): string | null {
84
+ if (literal.length < 2 || !literal.startsWith('"') || !literal.endsWith('"')) return null;
85
+ const inner = literal.slice(1, -1);
86
+ let out = "";
87
+ for (let i = 0; i < inner.length; i += 1) {
88
+ const ch = inner[i]!;
89
+ if (ch !== "\\") {
90
+ const code = inner.codePointAt(i)!;
91
+ if (ch === '"' || (code < 0x20 && code !== 0x09) || code === 0x7f
92
+ || (code >= 0xd800 && code <= 0xdfff)) return null;
93
+ out += String.fromCodePoint(code);
94
+ if (code > 0xffff) i += 1;
95
+ continue;
96
+ }
97
+ const escape = inner[++i];
98
+ switch (escape) {
99
+ case "b": out += "\b"; break;
100
+ case "t": out += "\t"; break;
101
+ case "n": out += "\n"; break;
102
+ case "f": out += "\f"; break;
103
+ case "r": out += "\r"; break;
104
+ case '"': out += '"'; break;
105
+ case "\\": out += "\\"; break;
106
+ case "u":
107
+ case "U": {
108
+ const digits = escape === "u" ? 4 : 8;
109
+ const hex = inner.slice(i + 1, i + 1 + digits);
110
+ if (hex.length !== digits || !/^[0-9a-fA-F]+$/.test(hex)) return null;
111
+ const code = Number.parseInt(hex, 16);
112
+ if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return null;
113
+ out += String.fromCodePoint(code);
114
+ i += digits;
115
+ break;
116
+ }
117
+ default: return null;
118
+ }
119
+ }
120
+ return out;
121
+ }
@@ -38,7 +38,7 @@ export function rootArrayEntries(configBytes: string | null, key: string): strin
38
38
  const PARSE_FAILED = Symbol("toml-parse-failed");
39
39
 
40
40
  /** A root-scope value, `undefined` when the key is absent, `PARSE_FAILED` when the file will not parse. */
41
- function rootValue(configBytes: string | null, key: string): unknown {
41
+ export function rootValue(configBytes: string | null, key: string): unknown {
42
42
  if (configBytes === null) return undefined;
43
43
  let parsed: unknown;
44
44
  try {
@@ -149,8 +149,8 @@ export type { Ownership } from "./prompt-layers/toml-read";
149
149
 
150
150
  import { activeConfigPath, activeStorePath, activeBaseVariantDir, journalPathFor, lockPathFor, type Paths } from "./prompt-layers/paths";
151
151
  import { readFileOrNull, computeRevision, updateFingerprintField } from "./prompt-layers/revision";
152
- import { normalizeBody, findInvalidCharacter, decodeBasicString } from "./prompt-layers/encoding";
153
- import { rootArrayEntries, hasRootKey, rootLines, tableLines, boolInLines, inspectOwnership } from "./prompt-layers/toml-read";
152
+ import { normalizeBody, findInvalidCharacter, decodeBasicString, decodeTomlBasicString } from "./prompt-layers/encoding";
153
+ import { rootArrayEntries, hasRootKey, rootLines, rootValue, tableLines, boolInLines, inspectOwnership } from "./prompt-layers/toml-read";
154
154
  import { setRootBool, setRootString, setTableBool, setProjection, removeUnownedProjection } from "./prompt-layers/toml-edit";
155
155
 
156
156
  /**
@@ -367,22 +367,23 @@ function readToggle(configBytes: string | null, id: ToggleId): ToggleState {
367
367
 
368
368
  function readModelInstructionsFile(configBytes: string | null): string | null {
369
369
  if (configBytes === null) return null;
370
+ const parsed = rootValue(configBytes, "model_instructions_file");
371
+ if (typeof parsed === "string") return parsed;
372
+ if (parsed === undefined) return null;
370
373
  for (const line of rootLines(configBytes)) {
371
374
  // Capture the whole literal INCLUDING its quotes and decode it, rather than
372
375
  // returning the raw inner text. `setRootString` writes this key through
373
376
  // `encodeBasicString`, which escapes backslashes, so on Windows the stored
374
- // literal is "C:\\Users\\..." while the path is "C:\Users\...". Reading the
375
- // inner text verbatim returned the doubled form: the round trip did not
376
- // survive, `baseSelection` compared a doubled path against the real variant
377
- // path and reported `external` for a variant this code had just selected.
377
+ // literal is "C:\\Users\\..." while the path is "C:\Users\...".
378
378
  //
379
- // `[^"]*` cannot span an escaped quote either. That is not a new limit -- it
380
- // is the same one the writer's restricted escape set is built around, and
381
- // `decodeBasicString` refuses anything outside it rather than guessing.
382
- const m = /^\s*model_instructions_file\s*=\s*("[^"]*")\s*(?:#.*)?$/.exec(line);
383
- if (m) return decodeBasicString(m[1]!);
379
+ // Bun may reject an unrelated safe-for-Codex integer. Decode the standard
380
+ // TOML escapes here, and never mistake an undecodable literal for a path.
381
+ const m = /^\s*model_instructions_file\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/.exec(line);
382
+ if (m) return decodeTomlBasicString(m[1]!) ?? "<unreadable model_instructions_file>";
384
383
  }
385
- return null;
384
+ // A present non-string value or unrecognised spelling fails closed. Only
385
+ // `undefined` above proves that the setting is absent.
386
+ return "<unreadable model_instructions_file>";
386
387
  }
387
388
 
388
389
  /** Variant ids are ours to generate, so they stay in one narrow shape. */
@@ -875,7 +876,10 @@ export function writeBaseVariant(
875
876
  ? input.id
876
877
  : input.id ?? newBaseVariantId(existing);
877
878
  if (!BASE_VARIANT_ID.test(targetId)) return { ok: false, error: "unknown_layer", detail: targetId };
878
- if (deleting && !existing.some(v => v.id === targetId)) {
879
+ // A caller-supplied id is an edit (or delete), never an alternate create path.
880
+ // Requiring it to exist keeps the generated-id path as the sole place where a
881
+ // new variant can enter, and therefore makes the cap impossible to bypass.
882
+ if (input.id !== null && !existing.some(v => v.id === targetId)) {
879
883
  return { ok: false, error: "unknown_layer", detail: targetId };
880
884
  }
881
885
  if (!deleting && input.id === null && existing.length >= MAX_BASE_VARIANTS) {
@@ -49,6 +49,9 @@ const LAYER_SECTION_TAGS: Record<string, string> = {
49
49
  skills: "skills_instructions",
50
50
  apps: "apps_instructions",
51
51
  plugins: "plugins_instructions",
52
+ // Context-dependent: it is absent when the active collaboration mode adds no
53
+ // instructions, but Codex wraps it in this tag when it does render.
54
+ collaboration: "collaboration_mode",
52
55
  environment: "environment_context",
53
56
  permissions: "permissions instructions",
54
57
  // Synthetic: the project doc carries no tag of its own (see extractSections).
@@ -71,7 +74,6 @@ const UNMAPPED_LAYER_IDS = [
71
74
  // truth is that this extractor has no verified tag for them.
72
75
  "personality",
73
76
  "realtime",
74
- "collaboration",
75
77
  // The Rust source names a <git_attribution> marker pair, but a world-state section is
76
78
  // DIFF-rendered: it emits nothing on a turn where its state has not changed. Live
77
79
  // `codex debug prompt-input` (codex-cli 0.145.0, 32978 bytes) showed no such block and
@@ -802,6 +804,22 @@ function extractSections(raw: string): Map<string, string> {
802
804
  /** Test seam: the extraction is the part worth pinning, not the spawn. */
803
805
  export const extractSectionsForTests = extractSections;
804
806
 
807
+ function mapSectionsToLayers(sections: Map<string, string>): Record<string, LayerText> {
808
+ const layers: Record<string, LayerText> = {};
809
+ for (const [layerId, tag] of Object.entries(LAYER_SECTION_TAGS)) {
810
+ const text = sections.get(tag) ?? null;
811
+ layers[layerId] = text === null
812
+ // Registered but not rendered on this turn, which is an ordinary state for
813
+ // a diff-rendered section rather than an error.
814
+ ? { text: null, reason: "not-rendered", bytes: 0 }
815
+ : { text, reason: "ok", bytes: Buffer.byteLength(text, "utf8") };
816
+ }
817
+ return layers;
818
+ }
819
+
820
+ /** Test seam: pin section-to-layer projection independently of the subprocess. */
821
+ export const mapSectionsToLayersForTests = mapSectionsToLayers;
822
+
805
823
  /**
806
824
  * Probe once and map every known layer to its rendered text.
807
825
  *
@@ -915,15 +933,7 @@ export async function probePromptText(
915
933
  detail: "prompt output could not be parsed",
916
934
  };
917
935
  }
918
- const layers: Record<string, LayerText> = {};
919
- for (const [layerId, tag] of Object.entries(LAYER_SECTION_TAGS)) {
920
- const text = sections.get(tag) ?? null;
921
- layers[layerId] = text === null
922
- // Registered but not rendered on this turn, which is an ordinary state for
923
- // a diff-rendered section rather than an error.
924
- ? { text: null, reason: "not-rendered", bytes: 0 }
925
- : { text, reason: "ok", bytes: Buffer.byteLength(text, "utf8") };
926
- }
936
+ const layers = mapSectionsToLayers(sections);
927
937
 
928
938
  // A file that exists and is empty is not the same as a layer that chose to send
929
939
  // nothing. Reporting "sent nothing" for an empty AGENTS.md tells the user their
@@ -0,0 +1,34 @@
1
+ import { isThirtyDayOnlyCodexPlan } from "./plan";
2
+ import type { StoredAccountQuota } from "./quota-types";
3
+
4
+ type Window = "weeklyPercent" | "monthlyPercent" | "shortPercent";
5
+ // Failback needs observations, not a recent cache write. Keep this request-policy evidence
6
+ // process-local: hydrated display bars do not prove a fresh observation in this process.
7
+ const observed = new WeakMap<StoredAccountQuota, Partial<Record<Window, number>>>();
8
+ const windows: Window[] = ["weeklyPercent", "monthlyPercent", "shortPercent"];
9
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
10
+
11
+ export function stampCodexQuotaUsageObservation(
12
+ next: StoredAccountQuota,
13
+ incoming: Pick<StoredAccountQuota, Window>,
14
+ existing?: StoredAccountQuota,
15
+ ): StoredAccountQuota {
16
+ const previous = existing && observed.get(existing);
17
+ const timestamps: Partial<Record<Window, number>> = {};
18
+ for (const window of windows) {
19
+ if (!finite(next[window])) continue;
20
+ if (finite(incoming[window])) timestamps[window] = next.updatedAt;
21
+ else if (previous?.[window] !== undefined) timestamps[window] = previous[window];
22
+ }
23
+ observed.set(next, timestamps);
24
+ return next;
25
+ }
26
+
27
+ export function codexQuotaHasFreshUsage(quota: StoredAccountQuota, plan: unknown, now: number, maxAgeMs: number): boolean {
28
+ const long: Window[] = isThirtyDayOnlyCodexPlan(plan) ? ["monthlyPercent"] : ["weeklyPercent", "monthlyPercent"];
29
+ const relevant = long.filter(window => finite(quota[window]));
30
+ if (relevant.length === 0) return false;
31
+ if (finite(quota.shortPercent)) relevant.push("shortPercent");
32
+ const timestamps = observed.get(quota);
33
+ return relevant.every(window => finite(timestamps?.[window]) && now - timestamps![window]! < maxAgeMs);
34
+ }
@@ -349,9 +349,10 @@ export async function codexScopedExhaustionCode(
349
349
  * Status alone and message text are intentionally insufficient. The broad
350
350
  * alternate-account retry remains eligible for 429/402 to preserve #584.
351
351
  *
352
- * The one carve-out from that breadth is an organization- or project-scoped exhaustion
353
- * ({@link SCOPED_EXHAUSTION_CODE_VALUES}), which reports `alternateRetryEligible: false`
354
- * because every credential inside the refusing limit would be refused by the same counter.
352
+ * Organization- or project-scoped exhaustion ({@link SCOPED_EXHAUSTION_CODE_VALUES}) remains
353
+ * alternate-retry eligible here because the response does not identify the refusing scope. The
354
+ * account-rotation path may suppress the send later when the resolved alternate carries binding
355
+ * evidence that it shares an organization-level counter.
355
356
  */
356
357
  export async function classifyCodexPreStreamRejection(
357
358
  response: Response,
@@ -377,7 +378,10 @@ export async function classifyCodexPreStreamRejection(
377
378
  });
378
379
  }
379
380
  if (scoped) {
380
- return rejection(status, "scoped-quota-exhaustion", { scopedExhaustionCode: scoped });
381
+ return rejection(status, "scoped-quota-exhaustion", {
382
+ alternateRetryEligible: true,
383
+ scopedExhaustionCode: scoped,
384
+ });
381
385
  }
382
386
  return rejection(
383
387
  status,
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { atomicWriteFile, getConfigDir } from "../config";
4
4
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
5
5
  import { isThirtyDayOnlyCodexPlan } from "./plan";
6
+ import { stampCodexQuotaUsageObservation } from "./quota-observation-freshness";
6
7
  import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
7
8
  import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache";
8
9
 
@@ -333,7 +334,7 @@ function mergeAccountQuota(
333
334
  assignCarriedShort(next, existing, updatedAt, policyEvidence);
334
335
  if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows;
335
336
  next.resetCredits = quota.resetCredits;
336
- return next;
337
+ return stampCodexQuotaUsageObservation(next, quota, existing);
337
338
  }
338
339
 
339
340
  if (snapshotHasWeekly(quota)) {
@@ -393,7 +394,7 @@ function mergeAccountQuota(
393
394
  if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
394
395
  else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
395
396
 
396
- return next;
397
+ return stampCodexQuotaUsageObservation(next, quota, existing);
397
398
  }
398
399
 
399
400
  /**
@@ -612,6 +613,7 @@ export function updateAccountQuota(
612
613
  }
613
614
  if (resetCredits !== undefined) quota.resetCredits = resetCredits;
614
615
 
616
+ stampCodexQuotaUsageObservation(quota, { weeklyPercent: nextWeekly, monthlyPercent: nextMonthly }, existing);
615
617
  accountQuota.set(accountId, quota);
616
618
  // This legacy writer has no physical credential provenance.
617
619
  if (accountId === MAIN_CODEX_ACCOUNT_ID) mainPolicyQuota = null;
@@ -1,3 +1,4 @@
1
+ import { getEffectiveCodexAutoSwitchThreshold } from "../account-auto-switch";
1
2
  import { isCodexAccountPaused } from "../account-pause";
2
3
  import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority";
3
4
  import { isSelectableCodexPoolAccount } from "../account-id";
@@ -250,7 +251,7 @@ export function hasCodexQuotaHeadroom(
250
251
  selectionOptions?: CodexAccountUsabilityOptions,
251
252
  now: number = Date.now(),
252
253
  ): boolean {
253
- const threshold = config.autoSwitchThreshold ?? 80;
254
+ const threshold = getEffectiveCodexAutoSwitchThreshold(config, accountId);
254
255
  if (threshold <= 0) return true;
255
256
  const usage = computeCodexUsageScore(
256
257
  getAccountQuota(accountId),
@@ -440,7 +441,7 @@ export function pickUnboundStrategyAccount(
440
441
  }
441
442
  picked = pickRoundRobinAccount(poolKey, eligible, limit);
442
443
  if (!picked) return null;
443
- if (commitSharedActive) {
444
+ if (commitSharedActive && sharesActiveSelection(picked, selectionOptions)) {
444
445
  if (!isIndependentCodexQuotaScope(quotaScope)
445
446
  && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
446
447
  rememberActiveCodexAccount(config, picked);
@@ -456,7 +457,7 @@ export function pickUnboundStrategyAccount(
456
457
  ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions)
457
458
  : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
458
459
  if (!picked) return null;
459
- if (commitSharedActive) {
460
+ if (commitSharedActive && sharesActiveSelection(picked, selectionOptions)) {
460
461
  if (!isIndependentCodexQuotaScope(quotaScope)
461
462
  && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
462
463
  rememberActiveCodexAccount(config, picked);
@@ -475,13 +476,21 @@ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string
475
476
  .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
476
477
  }
477
478
 
478
- /** Selection-only main routing must not lazily read the fenced native credential for its plan. */
479
+ /**
480
+ * Selection-only main routing must not lazily read the fenced native credential for its plan, and
481
+ * neither may a request whose main candidacy comes from its own bearer (#5019): that request is
482
+ * forbidden to read the physical main credential, so main is ranked without a plan.
483
+ */
479
484
  export function getPoolAccountPlanForSelection(
480
485
  config: OcxConfig,
481
486
  accountId: string,
482
487
  selectionOptions?: CodexAccountUsabilityOptions,
483
488
  ): string | undefined {
484
- if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
489
+ if (
490
+ accountId === MAIN_CODEX_ACCOUNT_ID
491
+ && (selectionOptions?.nativeMainSelectionOnly === true
492
+ || selectionOptions?.requestOwnedMainCredential === true)
493
+ ) {
485
494
  return undefined;
486
495
  }
487
496
  return getPoolAccountPlan(config, accountId);
@@ -505,6 +514,18 @@ export function sharedStateSelectionOptions(
505
514
  };
506
515
  }
507
516
 
517
+ /**
518
+ * A main that is live only through this request's own credential serves this request alone.
519
+ * Recording it as the shared active account would route later requests through a credential
520
+ * they do not carry (see CodexAccountUsabilityOptions.requestOwnedMainCredential).
521
+ */
522
+ export function sharesActiveSelection(
523
+ accountId: string,
524
+ selectionOptions?: CodexAccountUsabilityOptions,
525
+ ): boolean {
526
+ return !(accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.requestOwnedMainCredential === true);
527
+ }
528
+
508
529
  export function pickLowerUsageAccount(
509
530
  config: OcxConfig,
510
531
  active: string,
@@ -681,7 +702,7 @@ export function preferModelEntitledAccount(
681
702
  *
682
703
  * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only
683
704
  * fires when the tier filter has already excluded `active`, and only toward a
684
- * tier that strictly outranks it. Threads bound by affinity never reach here.
705
+ * tier that strictly outranks it. Bound threads reach it only through explicit priority failback.
685
706
  */
686
707
  export function pickPriorityPreemption(
687
708
  config: OcxConfig,
@@ -721,7 +742,7 @@ export function applyQuotaAutoSwitch(
721
742
  selectionOptions?: CodexAccountUsabilityOptions,
722
743
  commitSharedSelection = true,
723
744
  ): string {
724
- const threshold = config.autoSwitchThreshold ?? 80;
745
+ const threshold = getEffectiveCodexAutoSwitchThreshold(config, active);
725
746
  if (threshold <= 0) return active;
726
747
  const quota = getAccountQuota(active);
727
748
  const activeUsage = computeCodexUsageScore(
@@ -735,7 +756,8 @@ export function applyQuotaAutoSwitch(
735
756
  if (activeUsage < threshold) return active;
736
757
  const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
737
758
  if (best !== active) {
738
- if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
759
+ if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)
760
+ && sharesActiveSelection(best, selectionOptions)) {
739
761
  setActiveCodexAccount(config, best);
740
762
  }
741
763
  return best;
@@ -807,7 +829,8 @@ export function applyFailureFailover(
807
829
  // the moment of the failure; the streak outlives the soft avoid, so a later
808
830
  // scoped resolve reaches here with the streak still tripped and would otherwise
809
831
  // move the shared cursor after all.
810
- if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
832
+ if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)
833
+ && sharesActiveSelection(best, selectionOptions)) {
811
834
  promoteActiveCodexAccount(config, best);
812
835
  }
813
836
  return best;