@bitkyc08/opencodex 2.58.0 → 2.59.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 (188) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +1 -1
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/cursor/catalog.ts +11 -0
  10. package/src/adapters/cursor/effort-map.ts +16 -2
  11. package/src/adapters/cursor/envelope-echo.ts +55 -2
  12. package/src/adapters/cursor/message-mapper.ts +3 -2
  13. package/src/adapters/cursor/protobuf-request.ts +8 -5
  14. package/src/adapters/cursor/request-builder.ts +14 -3
  15. package/src/adapters/cursor/thread-continuity.ts +105 -31
  16. package/src/adapters/cursor/tool-guidance.ts +5 -4
  17. package/src/adapters/cursor.ts +42 -1
  18. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  19. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  20. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  21. package/src/adapters/devin.ts +75 -13
  22. package/src/adapters/google-antigravity-wire.ts +29 -2
  23. package/src/adapters/google-http.ts +8 -1
  24. package/src/adapters/google.ts +23 -4
  25. package/src/adapters/openai-chat/response-events.ts +61 -0
  26. package/src/adapters/openai-chat.ts +5 -10
  27. package/src/adapters/openai-responses/passthrough.ts +10 -1
  28. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  29. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  30. package/src/adapters/responses-tool-schema.ts +76 -46
  31. package/src/adapters/run-turn-queue.ts +17 -4
  32. package/src/bridge/response-json.ts +1 -1
  33. package/src/bridge/sse.ts +165 -24
  34. package/src/claude/context-windows.ts +22 -0
  35. package/src/claude/outbound.ts +35 -4
  36. package/src/cli/account-api.ts +4 -3
  37. package/src/cli/account-extended.ts +22 -2
  38. package/src/cli/account-orca-import.ts +63 -0
  39. package/src/cli/account.ts +32 -4
  40. package/src/cli/capabilities.ts +40 -0
  41. package/src/cli/claude.ts +29 -1
  42. package/src/cli/codex-cli-update.ts +97 -2
  43. package/src/cli/dispatch.ts +54 -0
  44. package/src/cli/doctor.ts +197 -2
  45. package/src/cli/help.ts +4 -1
  46. package/src/cli/index.ts +88 -20
  47. package/src/cli/models-runtime.ts +33 -4
  48. package/src/cli/registry.ts +11 -1
  49. package/src/cli/runtime-api.ts +44 -0
  50. package/src/cli/start-args.ts +94 -0
  51. package/src/cli/system-command.ts +2 -0
  52. package/src/client/machine-api.ts +4 -3
  53. package/src/client/machine-listener.ts +14 -1
  54. package/src/clients/config-export/constants.ts +2 -3
  55. package/src/clients/config-export.ts +5 -5
  56. package/src/codex/account-store.ts +81 -5
  57. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  58. package/src/codex/auth-api/routes.ts +17 -2
  59. package/src/codex/auth-context.ts +16 -12
  60. package/src/codex/catalog/build-entries.ts +25 -4
  61. package/src/codex/catalog/derive-entry.ts +8 -1
  62. package/src/codex/catalog/effort.ts +10 -6
  63. package/src/codex/catalog/gather-capture.ts +1 -0
  64. package/src/codex/catalog/model-hints.ts +37 -5
  65. package/src/codex/catalog/parsing.ts +83 -5
  66. package/src/codex/catalog/reserve-warn.ts +96 -0
  67. package/src/codex/catalog/retained-sync.ts +19 -0
  68. package/src/codex/catalog/routed-gather.ts +42 -3
  69. package/src/codex/cli-installation-identity.ts +210 -0
  70. package/src/codex/cli-installation-targets.ts +158 -0
  71. package/src/codex/convergence.ts +5 -0
  72. package/src/codex/history-provider.ts +4 -1
  73. package/src/codex/history-state-open.ts +105 -0
  74. package/src/codex/inject/config-toml.ts +44 -2
  75. package/src/codex/inject.ts +3 -2
  76. package/src/codex/lineage.ts +83 -32
  77. package/src/codex/loopback-target.ts +31 -0
  78. package/src/codex/main-account-hard-lock.ts +2 -1
  79. package/src/codex/main-account.ts +10 -3
  80. package/src/codex/main-device-reauth.ts +17 -9
  81. package/src/codex/model-entitlements.ts +60 -1
  82. package/src/codex/observed-model-denials.ts +137 -0
  83. package/src/codex/orca-auth-source.ts +94 -0
  84. package/src/codex/orca-import.ts +219 -0
  85. package/src/codex/prompt-text-probe.ts +282 -12
  86. package/src/codex/quota-401-recovery.ts +12 -0
  87. package/src/codex/quota-types.ts +65 -0
  88. package/src/codex/quota.ts +24 -19
  89. package/src/codex/routing/cooldown-math.ts +8 -47
  90. package/src/codex/routing/pin-drain.ts +57 -0
  91. package/src/codex/routing.ts +13 -15
  92. package/src/codex/subagent-model-fallback.ts +94 -0
  93. package/src/codex/windows-installation-files.ts +224 -0
  94. package/src/combos/failover.ts +122 -5
  95. package/src/config/diagnostics.ts +21 -0
  96. package/src/config/load-degrade.ts +15 -0
  97. package/src/config/pending-teardown.ts +8 -0
  98. package/src/config/process-state.ts +36 -3
  99. package/src/config/provider-relative-send-path.ts +16 -0
  100. package/src/config/proxy-env.ts +23 -5
  101. package/src/config/schema/config-schema.ts +21 -0
  102. package/src/config/schema/leaf-validators.ts +64 -17
  103. package/src/generated/compatibility-version.json +235 -163
  104. package/src/generated/model-metadata.ts +1 -1
  105. package/src/lib/bounded-body.ts +4 -2
  106. package/src/lib/destination-policy.ts +48 -6
  107. package/src/lib/errors.ts +3 -15
  108. package/src/lib/local-destinations.ts +32 -5
  109. package/src/lib/provider-outbound.ts +3 -3
  110. package/src/lib/proxy-env.ts +70 -3
  111. package/src/lib/request-execution-budget.ts +11 -3
  112. package/src/lib/response-body-inactivity.ts +193 -0
  113. package/src/lib/retry-delay.ts +69 -0
  114. package/src/lib/socks5-fetch.ts +631 -0
  115. package/src/lib/spend-reservation-ledger.ts +115 -9
  116. package/src/lib/workflow-budget.ts +145 -8
  117. package/src/oauth/account-quota-rank.ts +72 -15
  118. package/src/oauth/generic-account-failover.ts +40 -27
  119. package/src/oauth/orcarouter.ts +15 -2
  120. package/src/oauth/store.ts +8 -0
  121. package/src/providers/codex-capacity.ts +9 -0
  122. package/src/providers/devin-provider-merge-migration.ts +33 -12
  123. package/src/providers/free-directory.ts +20 -2
  124. package/src/providers/key-failover.ts +261 -7
  125. package/src/providers/model-rename-migration.ts +1 -0
  126. package/src/providers/openai-sidecar.ts +4 -0
  127. package/src/providers/opencode-go-transport.ts +14 -5
  128. package/src/providers/quota/report-cache.ts +3 -0
  129. package/src/providers/registry/entries-extended.ts +96 -0
  130. package/src/providers/registry/model-seeds.ts +78 -21
  131. package/src/responses/apply-patch-envelope.ts +44 -11
  132. package/src/responses/bridge-search-replay-cache.ts +152 -0
  133. package/src/responses/code-mode-helper-compat.ts +26 -16
  134. package/src/responses/custom-tool-compat.ts +1 -1
  135. package/src/responses/hosted-tool-policy.ts +85 -2
  136. package/src/responses/schema.ts +9 -2
  137. package/src/server/auth-cors.ts +26 -0
  138. package/src/server/chat-completions.ts +9 -4
  139. package/src/server/chat-native-sse.ts +26 -9
  140. package/src/server/chat-native.ts +10 -4
  141. package/src/server/claude-messages.ts +24 -2
  142. package/src/server/gui-static.ts +36 -2
  143. package/src/server/inbound-body-admission.ts +187 -0
  144. package/src/server/index.ts +15 -19
  145. package/src/server/management/api-access.ts +3 -4
  146. package/src/server/management/config-routes.ts +31 -6
  147. package/src/server/management/provider-capability-config.ts +35 -7
  148. package/src/server/management/provider-routes.ts +70 -18
  149. package/src/server/proxy-liveness.ts +97 -2
  150. package/src/server/relay.ts +17 -24
  151. package/src/server/request-log.ts +25 -1
  152. package/src/server/responses/adapter-continuation.ts +71 -27
  153. package/src/server/responses/adapter-delivery.ts +39 -8
  154. package/src/server/responses/adapter-dispatch.ts +52 -24
  155. package/src/server/responses/compact.ts +60 -11
  156. package/src/server/responses/core-codex-account.ts +83 -22
  157. package/src/server/responses/core-normalize.ts +12 -5
  158. package/src/server/responses/fetch-helpers.ts +68 -2
  159. package/src/server/responses/passthrough-delivery.ts +10 -1
  160. package/src/server/responses/passthrough-dispatch.ts +113 -48
  161. package/src/server/responses/passthrough-execution.ts +11 -1
  162. package/src/server/responses/request-prepare.ts +29 -0
  163. package/src/server/responses/request-send-budget.ts +84 -7
  164. package/src/server/responses/request-sidecar-auth.ts +16 -8
  165. package/src/server/responses/request-spend.ts +38 -9
  166. package/src/server/responses/request-transport.ts +13 -10
  167. package/src/server/responses/run-turn-execution.ts +20 -5
  168. package/src/server/responses/sidecar-execution.ts +2 -0
  169. package/src/server/responses/ws-upstream.ts +2 -1
  170. package/src/server/responses-custom-tool-repair.ts +2 -2
  171. package/src/server/sse-frame-buffer.ts +12 -10
  172. package/src/server/sse-payload-rewrite.ts +36 -9
  173. package/src/server/system-env-shell.ts +5 -1
  174. package/src/server/system-env.ts +7 -1
  175. package/src/server/workflow-refusal.ts +56 -2
  176. package/src/service/cli.ts +16 -6
  177. package/src/service/guards.ts +10 -0
  178. package/src/service/health.ts +43 -0
  179. package/src/service/state.ts +7 -2
  180. package/src/types/accounts.ts +4 -0
  181. package/src/types/config.ts +100 -3
  182. package/src/types/provider.ts +19 -0
  183. package/src/types/request.ts +7 -1
  184. package/src/types/wire.ts +9 -1
  185. package/src/usage/expected-prices.ts +28 -0
  186. package/src/usage/log.ts +87 -4
  187. package/src/web-search/passthrough-bridge.ts +39 -5
  188. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
@@ -0,0 +1,210 @@
1
+ import { createHash } from "node:crypto";
2
+ import { win32 } from "node:path";
3
+ import { parseStrictSemver } from "../lib/strict-semver";
4
+
5
+ /** Explicit observation, not runtime selection or authority to run an installer. */
6
+ export interface CodexCliInstallationIdentityInput {
7
+ readonly candidate: string;
8
+ readonly npmPrefix: string;
9
+ readonly npmCli: string;
10
+ readonly node: string;
11
+ /** Where the candidate came from; defaults to an explicit CLI argument. */
12
+ readonly candidateSource?: "explicit-cli" | "selected";
13
+ }
14
+
15
+ export interface CodexCliInstallationIdentityReport {
16
+ readonly schemaVersion: 1;
17
+ readonly candidateSource: "explicit-cli" | "selected";
18
+ readonly status: "observed" | "refused";
19
+ readonly reason: string;
20
+ readonly installationIdentityObserved: boolean;
21
+ readonly selectionAttested: false;
22
+ readonly managed: false;
23
+ readonly applyAllowed: false;
24
+ readonly packageVersion: string | null;
25
+ readonly npmVersion: string | null;
26
+ readonly identityDigest: string | null;
27
+ readonly proof: "windows-handle-bound" | null;
28
+ readonly toolchain: "observed-only";
29
+ }
30
+
31
+ export interface InstallationFileRequest {
32
+ readonly path: string;
33
+ readonly maxBytes: number;
34
+ readonly hashOnly?: boolean;
35
+ }
36
+
37
+ export interface ObservedInstallationFile {
38
+ readonly path: string;
39
+ readonly identity: {
40
+ readonly volumeSerial: string;
41
+ readonly fileId: string;
42
+ readonly size: number;
43
+ readonly lastWriteTime: string;
44
+ readonly changeTime: string;
45
+ };
46
+ readonly bytes: Uint8Array;
47
+ readonly digest?: string;
48
+ }
49
+
50
+ export type InstallationFilesResult =
51
+ | { readonly kind: "observed"; readonly files: readonly ObservedInstallationFile[] }
52
+ | { readonly kind: "refused"; readonly reason: string };
53
+
54
+ export interface CodexCliInstallationIdentityDeps {
55
+ readonly platform?: NodeJS.Platform;
56
+ readonly inspectFiles?: (requests: readonly InstallationFileRequest[]) => Promise<InstallationFilesResult>;
57
+ }
58
+
59
+ const TEXT_LIMIT = 256 * 1024;
60
+ const NODE_LIMIT = 256 * 1024 * 1024;
61
+ const hash = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex");
62
+ // NTFS can enable case-sensitive directory entries. Do not collapse different
63
+ // paths merely because ordinary Windows directories usually ignore case.
64
+ const key = (path: string): string => path[0]!.toUpperCase() + path.slice(1);
65
+
66
+ function localPath(value: string): string | null {
67
+ if (typeof value !== "string" || value.length > 30_000 || !/^[a-z]:[\\/]/i.test(value)) return null;
68
+ const path = value.replaceAll("/", "\\");
69
+ const parts = path.slice(3).split("\\");
70
+ if (!parts.length || parts.some(part => !part || part === "." || part === ".."
71
+ || /[\x00-\x1f<>:"|?*]/.test(part) || /[. ]$/.test(part)
72
+ || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) return null;
73
+ return path;
74
+ }
75
+
76
+ function supportedLayoutPath(path: string): boolean {
77
+ // This slice does not adopt package managers or packaged desktop applications.
78
+ return !/(?:^|\\)(?:windowsapps|scoop|\.volta|\.nvm|\.asdf|\.mise|\.fnm|\.nvs|\.nodenv)(?:\\|$)|\.app(?:\\|$)/i.test(path);
79
+ }
80
+
81
+ function report(
82
+ reason: string,
83
+ versions?: { codex: string; npm: string; digest: string },
84
+ candidateSource: CodexCliInstallationIdentityReport["candidateSource"] = "explicit-cli",
85
+ ): CodexCliInstallationIdentityReport {
86
+ return Object.freeze({
87
+ schemaVersion: 1, candidateSource, status: versions ? "observed" : "refused",
88
+ reason, installationIdentityObserved: !!versions, selectionAttested: false, managed: false,
89
+ applyAllowed: false, packageVersion: versions?.codex ?? null, npmVersion: versions?.npm ?? null,
90
+ identityDigest: versions?.digest ?? null, proof: versions ? "windows-handle-bound" : null,
91
+ toolchain: "observed-only",
92
+ });
93
+ }
94
+
95
+ /** A refusal before any observation ran, e.g. when no selected candidate can be identified. */
96
+ export function codexCliInstallationRefusal(
97
+ reason: string,
98
+ candidateSource: CodexCliInstallationIdentityReport["candidateSource"] = "explicit-cli",
99
+ ): CodexCliInstallationIdentityReport {
100
+ return report(reason, undefined, candidateSource);
101
+ }
102
+
103
+ function manifest(file: ObservedInstallationFile, name: string, bin: string, expected: string): string | null {
104
+ try {
105
+ const value: unknown = JSON.parse(Buffer.from(file.bytes).toString("utf8"));
106
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
107
+ const item = value as Record<string, unknown>;
108
+ const entry = typeof item.bin === "string" ? item.bin
109
+ : item.bin && typeof item.bin === "object" ? (item.bin as Record<string, unknown>)[bin] : null;
110
+ return item.name === name && typeof item.version === "string" && parseStrictSemver(item.version)
111
+ && entry === expected ? item.version : null;
112
+ } catch { return null; }
113
+ }
114
+
115
+ /** Only the standard npm cmd-shim is linked here; arbitrary wrappers remain unverified. */
116
+ function npmCodexCommandShim(bytes: Uint8Array): boolean {
117
+ const expected = `@ECHO off
118
+ GOTO start
119
+ :find_dp0
120
+ SET dp0=%~dp0
121
+ EXIT /b
122
+ :start
123
+ SETLOCAL
124
+ CALL :find_dp0
125
+
126
+ IF EXIST "%dp0%\\node.exe" (
127
+ SET "_prog=%dp0%\\node.exe"
128
+ ) ELSE (
129
+ SET "_prog=node"
130
+ SET PATHEXT=%PATHEXT:;.JS;=;%
131
+ )
132
+
133
+ endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js" %*`;
134
+ const normalize = (text: string) => text.replaceAll("\r\n", "\n").trimEnd();
135
+ return normalize(Buffer.from(bytes).toString("utf8")) === normalize(expected);
136
+ }
137
+
138
+ function fileDigest(file: ObservedInstallationFile): string {
139
+ return file.digest ?? hash(file.bytes);
140
+ }
141
+
142
+ function fileEvidence(file: ObservedInstallationFile): readonly (string | number)[] {
143
+ const id = file.identity;
144
+ return [key(file.path), id.volumeSerial, id.fileId, id.size, id.lastWriteTime, id.changeTime, fileDigest(file)];
145
+ }
146
+
147
+ /** No filesystem or native-library work occurs until this explicit operation is called. */
148
+ export async function inspectCodexCliInstallationIdentity(
149
+ input: CodexCliInstallationIdentityInput,
150
+ deps: CodexCliInstallationIdentityDeps = {},
151
+ ): Promise<CodexCliInstallationIdentityReport> {
152
+ const source = input.candidateSource ?? "explicit-cli";
153
+ const refused = (reason: string) => report(reason, undefined, source);
154
+ if ((deps.platform ?? process.platform) !== "win32") return refused("unsupported_platform");
155
+ const candidate = localPath(input.candidate);
156
+ const prefix = localPath(input.npmPrefix);
157
+ const npmCli = localPath(input.npmCli);
158
+ const node = localPath(input.node);
159
+ if (!candidate || !prefix || !npmCli || !node) return refused("unsafe_path");
160
+ if (![candidate, prefix, npmCli, node].every(supportedLayoutPath)) return refused("unsupported_layout");
161
+ const packageRoot = win32.join(prefix, "node_modules", "@openai", "codex");
162
+ const codexManifest = win32.join(packageRoot, "package.json");
163
+ const codexBin = win32.join(packageRoot, "bin", "codex.js");
164
+ const shim = win32.join(prefix, "codex.cmd");
165
+ // A codex.opencodex-real.cmd backup is npm's renamed launcher: same grammar, same dir.
166
+ const backingShim = win32.join(prefix, "codex.opencodex-real.cmd");
167
+ const acceptedCandidates = source === "selected"
168
+ ? [key(codexBin), key(shim), key(backingShim)]
169
+ : [key(codexBin), key(shim)];
170
+ if (!acceptedCandidates.includes(key(candidate))
171
+ || !/\\node_modules\\npm\\bin\\npm-cli\.js$/i.test(npmCli)
172
+ || win32.basename(node).toLowerCase() !== "node.exe") return refused("unsupported_layout");
173
+ const npmManifest = win32.join(win32.dirname(win32.dirname(npmCli)), "package.json");
174
+ try {
175
+ const inspect = deps.inspectFiles ?? (await import("./windows-installation-files")).inspectWindowsInstallationFiles;
176
+ // Read both manifests again with every linked file held open: first-pass bytes cannot
177
+ // authorize links after a manifest replacement between the two observations.
178
+ const first = await inspect([{ path: codexManifest, maxBytes: TEXT_LIMIT }, { path: npmManifest, maxBytes: TEXT_LIMIT }]);
179
+ if (first.kind !== "observed") return refused("native_proof_unavailable");
180
+ const initial = new Map(first.files.map(file => [key(file.path), file]));
181
+ const codex = initial.get(key(codexManifest));
182
+ const npm = initial.get(key(npmManifest));
183
+ if (!codex || !npm) return refused("read_failed");
184
+ const codexVersion = manifest(codex, "@openai/codex", "codex", "bin/codex.js");
185
+ const npmVersion = manifest(npm, "npm", "npm", "bin/npm-cli.js");
186
+ if (!codexVersion || !npmVersion) return refused("package_mismatch");
187
+ const requests: InstallationFileRequest[] = [codexManifest, npmManifest, candidate, codexBin, npmCli]
188
+ .filter((path, index, all) => all.findIndex(other => key(other) === key(path)) === index)
189
+ .map(path => ({ path, maxBytes: TEXT_LIMIT }));
190
+ requests.push({ path: node, maxBytes: NODE_LIMIT, hashOnly: true });
191
+ const second = await inspect(requests);
192
+ if (second.kind !== "observed") return refused("native_proof_unavailable");
193
+ const files = new Map(second.files.map(file => [key(file.path), file]));
194
+ if (requests.some(request => !files.has(key(request.path)))) return refused("read_failed");
195
+ for (const before of [codex, npm]) {
196
+ if (JSON.stringify(fileEvidence(before)) !== JSON.stringify(fileEvidence(files.get(key(before.path))!))) {
197
+ return refused("identity_changed");
198
+ }
199
+ }
200
+ if ([key(shim), key(backingShim)].includes(key(candidate))
201
+ && !npmCodexCommandShim(files.get(key(candidate))!.bytes)) {
202
+ return refused("launcher_mismatch");
203
+ }
204
+ const observed = [...files.values()].sort((a, b) => key(a.path) < key(b.path) ? -1 : key(a.path) > key(b.path) ? 1 : 0);
205
+ if (observed.some(file => !/^[a-f0-9]{64}$/i.test(fileDigest(file)))) return refused("read_failed");
206
+ const digest = hash(JSON.stringify(["opencodex-explicit-installation-observation-v1", key(prefix),
207
+ key(candidate), key(npmCli), key(node), ...observed.map(fileEvidence)]));
208
+ return report("identity_observed", { codex: codexVersion, npm: npmVersion, digest }, source);
209
+ } catch { return refused("read_failed"); }
210
+ }
@@ -0,0 +1,158 @@
1
+ import { closeSync, existsSync, openSync, readSync } from "node:fs";
2
+ import { win32 } from "node:path";
3
+ import { SHIM_MARKER } from "./shim-templates";
4
+ import type { CodexCliInstallationIdentityInput } from "./cli-installation-identity";
5
+
6
+ /**
7
+ * The proof-captured launcher snapshot used to identify the selected candidate.
8
+ * Every field is optional because older launchers and direct Bun/source runs
9
+ * supply no snapshot at all; derivation fails closed rather than reading the
10
+ * ambient environment.
11
+ */
12
+ export interface CodexCliInstallationSnapshot {
13
+ readonly codexCliPath?: string | null;
14
+ readonly path?: string | null;
15
+ readonly pathExt?: string | null;
16
+ }
17
+
18
+ export type CodexCliInstallationTargetRefusal =
19
+ | "unsupported_platform"
20
+ | "candidate_unavailable"
21
+ | "unsupported_layout"
22
+ | "toolchain_unresolved";
23
+
24
+ export type CodexCliInstallationTargetDerivation =
25
+ | { readonly kind: "derived"; readonly input: CodexCliInstallationIdentityInput }
26
+ | { readonly kind: "unavailable"; readonly reason: CodexCliInstallationTargetRefusal };
27
+
28
+ export interface CodexCliInstallationTargetDeps {
29
+ readonly platform?: NodeJS.Platform;
30
+ readonly exists?: (path: string) => boolean;
31
+ /** Bounded prefix read used only to recognize an OpenCodex-owned wrapper. */
32
+ readonly fileContains?: (path: string, marker: string) => boolean;
33
+ }
34
+
35
+ const DEFAULT_PATH_EXT = ".COM;.EXE;.BAT;.CMD;.PS1";
36
+ const SHIM_PROBE_BYTES = 8 * 1024;
37
+ const CODEX_PACKAGE_SUFFIX = "\\node_modules\\@openai\\codex\\bin\\codex.js";
38
+
39
+ function defaultFileContains(path: string, marker: string): boolean {
40
+ let descriptor: number | undefined;
41
+ let contains = false;
42
+ try {
43
+ descriptor = openSync(path, "r");
44
+ const bytes = Buffer.allocUnsafe(SHIM_PROBE_BYTES);
45
+ const count = readSync(descriptor, bytes, 0, bytes.length, 0);
46
+ contains = bytes.subarray(0, count).toString("utf8").includes(marker);
47
+ } catch {
48
+ contains = false;
49
+ } finally {
50
+ if (descriptor !== undefined) {
51
+ try { closeSync(descriptor); } catch { contains = false; }
52
+ }
53
+ }
54
+ return contains;
55
+ }
56
+
57
+ /**
58
+ * First match wins, mirroring PATH resolution: directories in order, and within
59
+ * each directory every PATHEXT suffix in order (or the exact name when it
60
+ * already carries an extension). Skipping a hit to keep scanning would attest
61
+ * something other than the launcher that actually resolves.
62
+ */
63
+ function scanPath(
64
+ name: string,
65
+ pathValue: string | null | undefined,
66
+ pathExt: string | null | undefined,
67
+ exists: (path: string) => boolean,
68
+ ): string | null {
69
+ const extensions = (pathExt ?? DEFAULT_PATH_EXT).split(";").map(value => value.trim()).filter(Boolean);
70
+ const names = /\.[a-z0-9]+$/i.test(name) ? [name] : extensions.map(ext => name + ext.toLowerCase());
71
+ for (const entry of (pathValue ?? "").split(";")) {
72
+ const dir = entry.trim().replace(/^"+|"+$/g, "");
73
+ if (!dir) continue;
74
+ for (const candidateName of names) {
75
+ const candidate = win32.join(dir, candidateName);
76
+ if (exists(candidate)) return candidate;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Derive the four attestation inputs from the proof-captured launcher snapshot.
84
+ * Discovery only proposes candidate paths; the held-handle observation remains
85
+ * the authority, so a stale or racing probe can only produce a refusal, never a
86
+ * false identity. No ambient environment is read: without a snapshot the result
87
+ * is unavailable rather than silently trusting the child's environment.
88
+ */
89
+ export function deriveCodexCliInstallationInput(
90
+ snapshot: CodexCliInstallationSnapshot,
91
+ deps: CodexCliInstallationTargetDeps = {},
92
+ ): CodexCliInstallationTargetDerivation {
93
+ if ((deps.platform ?? process.platform) !== "win32") {
94
+ return { kind: "unavailable", reason: "unsupported_platform" };
95
+ }
96
+ const exists = deps.exists ?? existsSync;
97
+ const fileContains = deps.fileContains ?? defaultFileContains;
98
+ const configured = snapshot.codexCliPath;
99
+
100
+ let candidate: string | null;
101
+ if (configured) {
102
+ if (/^[a-z]:[\\/]/i.test(configured)) {
103
+ if (!exists(configured)) return { kind: "unavailable", reason: "candidate_unavailable" };
104
+ candidate = win32.normalize(configured);
105
+ } else {
106
+ if (configured.includes("/") || configured.includes("\\")) {
107
+ return { kind: "unavailable", reason: "candidate_unavailable" };
108
+ }
109
+ candidate = scanPath(configured, snapshot.path, snapshot.pathExt, exists);
110
+ if (!candidate) return { kind: "unavailable", reason: "candidate_unavailable" };
111
+ }
112
+ } else {
113
+ candidate = scanPath("codex", snapshot.path, snapshot.pathExt, exists);
114
+ if (!candidate) return { kind: "unavailable", reason: "candidate_unavailable" };
115
+ }
116
+
117
+ // An OpenCodex wrapper at the npm prefix is our own launcher, not the npm
118
+ // artifact. The renamed original beside it is the file npm wrote.
119
+ if (/\.cmd$/i.test(candidate) && fileContains(candidate, SHIM_MARKER)) {
120
+ const backing = candidate.slice(0, -".cmd".length) + ".opencodex-real.cmd";
121
+ if (!exists(backing)) return { kind: "unavailable", reason: "unsupported_layout" };
122
+ candidate = backing;
123
+ }
124
+
125
+ const base = win32.basename(candidate).toLowerCase();
126
+ let prefix: string;
127
+ if (base === "codex.cmd" || base === "codex.opencodex-real.cmd") {
128
+ prefix = win32.dirname(candidate);
129
+ } else if (candidate.toLowerCase().endsWith(CODEX_PACKAGE_SUFFIX)) {
130
+ prefix = candidate.slice(0, candidate.length - CODEX_PACKAGE_SUFFIX.length);
131
+ } else {
132
+ return { kind: "unavailable", reason: "unsupported_layout" };
133
+ }
134
+ if (!exists(win32.join(prefix, "node_modules", "@openai", "codex", "package.json"))) {
135
+ return { kind: "unavailable", reason: "unsupported_layout" };
136
+ }
137
+
138
+ // The npm cmd-shim itself prefers %dp0%\node.exe before falling back to PATH.
139
+ let node = win32.join(prefix, "node.exe");
140
+ if (!exists(node)) {
141
+ const resolved = scanPath("node.exe", snapshot.path, null, exists);
142
+ if (!resolved) return { kind: "unavailable", reason: "toolchain_unresolved" };
143
+ node = resolved;
144
+ }
145
+ const npmCli = win32.join(win32.dirname(node), "node_modules", "npm", "bin", "npm-cli.js");
146
+ if (!exists(npmCli)) return { kind: "unavailable", reason: "toolchain_unresolved" };
147
+
148
+ return {
149
+ kind: "derived",
150
+ input: Object.freeze({
151
+ candidate,
152
+ npmPrefix: prefix,
153
+ npmCli,
154
+ node,
155
+ candidateSource: "selected",
156
+ }),
157
+ };
158
+ }
@@ -36,6 +36,7 @@ import {
36
36
  catalogHasRoutedEntries,
37
37
  findSupportedNativeTemplate,
38
38
  legacyCatalogBackupPath,
39
+ nativeMultiAgentDefaults,
39
40
  parseCatalogJson,
40
41
  type RawCatalog,
41
42
  type RawEntry,
@@ -69,6 +70,7 @@ import {
69
70
  clampCatalogModelsToObservedCodexSupport,
70
71
  supportedCodexReasoningEffortsFromObservedCatalog,
71
72
  } from "./catalog/effort";
73
+ import { suppressedSyntheticMaxCatalogSlugs } from "./catalog/model-hints";
72
74
  import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime";
73
75
  import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./account-namespaces";
74
76
  import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
@@ -305,6 +307,7 @@ function prepareCatalog(
305
307
  [catalog.models ?? [], ...nativeRecoverySources],
306
308
  );
307
309
  const catalogModels = nativeCatalogModels;
310
+ const suppressedSyntheticMaxSlugs = suppressedSyntheticMaxCatalogSlugs(config, ordered, catalogModels);
308
311
  const routedEntries = buildCatalogEntriesFromObservedState({
309
312
  template: template ? JSON.parse(JSON.stringify(template)) : null,
310
313
  gptSlugs: [],
@@ -371,8 +374,10 @@ function prepareCatalog(
371
374
  includeNativeOpenAi,
372
375
  accountBoundEntries,
373
376
  suppressedBareNativeSlugs,
377
+ suppressedSyntheticMaxSlugs,
374
378
  openaiContextCap,
375
379
  nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames,
380
+ nativeMultiAgentDefaults: nativeMultiAgentDefaults(baselineCatalogModels),
376
381
  policy: {
377
382
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
378
383
  nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
4
4
  import { zstdDecompressSync } from "node:zlib";
5
5
  import { Database } from "bun:sqlite";
6
6
  import { resolveCodexStateDbPath } from "./paths";
7
+ import { openCodexStateForPreflight } from "./history-state-open";
7
8
  import { atomicWriteFile, getConfigDir } from "../config";
8
9
  import {
9
10
  CODEX_HISTORY_RESUMABLE_SOURCES,
@@ -433,7 +434,9 @@ export function preflightCodexHistoryInjection(
433
434
  if (!existsSync(resolvedPath)) {
434
435
  return restoreEntries.length > 0 ? "history_state_database_missing" : null;
435
436
  }
436
- db = new Database(resolvedPath, { readonly: true });
437
+ // Read-only, and narrowed so a cleanly-closed WAL store is inspected rather than refused
438
+ // (#4943). The open order is the safety property; see history-state-open.ts.
439
+ db = openCodexStateForPreflight(resolvedPath);
437
440
  const columns = db.query<{ name: string }, []>("PRAGMA table_info(threads)").all();
438
441
  const paginatedColumn = columns.some(column => column.name === "history_mode");
439
442
  if (paginatedColumn && restoreEntries.length > 0) return HISTORY_RELABEL_STANDS_DOWN;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * How the read-only Codex history preflight opens the state store.
3
+ *
4
+ * Split out of `history-provider.ts` deliberately. That file is the single largest module in
5
+ * `src/codex/` and sits just under the repository's 2000-line ratchet; the reasoning below is
6
+ * load-bearing and long, and appending it there would have pushed the file over. Keeping the
7
+ * open policy in one small module also puts the decision somewhere it can be read on its own,
8
+ * which matters because getting it wrong is silent in both directions.
9
+ */
10
+ import { existsSync } from "node:fs";
11
+ import { pathToFileURL } from "node:url";
12
+ import { Database, constants as sqliteConstants } from "bun:sqlite";
13
+
14
+ /**
15
+ * Read-only open flags that skip WAL shared memory entirely.
16
+ *
17
+ * `immutable=1` has to arrive as a `file:` URI, and a URI filename needs SQLITE_OPEN_URI.
18
+ * Same idiom as the storage scanner, the log-guard inspector and the coordinator doctor, for
19
+ * the same reason: never touch a foreign store's WAL protocol.
20
+ */
21
+ const IMMUTABLE_READONLY_FLAGS = sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI;
22
+
23
+ /** Is this the "there is no shared memory and I may not create it" open failure? */
24
+ export function isStateDbCantOpenError(error: unknown): boolean {
25
+ const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
26
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
27
+ // Matched on the code with a message fallback, exactly like classifyRecoverableHistoryError:
28
+ // the same SQLite condition reaches us as a code on some platforms and as bare text on others.
29
+ return code === "SQLITE_CANTOPEN" || message.includes("unable to open database file");
30
+ }
31
+
32
+ /** Which step of the primary attempt a test wants to fail. */
33
+ export type StateDbPreflightOpenPhase = "open" | "first-read";
34
+
35
+ /**
36
+ * Test-only knob: force the primary attempt to fail with a supplied error.
37
+ *
38
+ * The fallback below turns on a condition this repository cannot reproduce deterministically
39
+ * from a test: whether a plain read-only open of a cleanly-closed WAL store fails or quietly
40
+ * creates the `-shm` depends on the platform VFS and on the directory the store sits in.
41
+ * Pinning the NARROWING — sidecars absent admits the immutable read, either sidecar present
42
+ * still refuses — therefore needs the failure supplied rather than provoked, or the test would
43
+ * assert the host's SQLite build instead of this decision (#4943).
44
+ *
45
+ * The phase exists because the platforms disagree about WHEN the condition is raised, not only
46
+ * about whether it is: see the first-read note on `openCodexStateForPreflight`.
47
+ */
48
+ let openFailureForTests: ((path: string, phase: StateDbPreflightOpenPhase) => unknown) | undefined;
49
+ export function setStateDbPreflightOpenFailureForTests(hook: typeof openFailureForTests): void {
50
+ openFailureForTests = hook;
51
+ }
52
+
53
+ /**
54
+ * Open the Codex state store for the read-only preflight.
55
+ *
56
+ * `{ readonly: true }` is tried FIRST and stays the primary path, because it is the only mode
57
+ * that can see a live WAL: it joins the writer's shared memory, so a thread another process
58
+ * just migrated to paginated history is visible here and the preflight refuses on it. An
59
+ * immutable open reads the last checkpointed main database instead. Making that the primary
60
+ * path would trade a refusal for a stale snapshot, and a refusal this preflight fails to
61
+ * observe is a config transition that proceeds over history Codex owns — the exact outcome the
62
+ * whole guard exists to prevent.
63
+ *
64
+ * A WAL store closed cleanly is the case that has no shared memory to join. SQLite cannot
65
+ * create the `-shm` from a read-only connection, so the open fails SQLITE_CANTOPEN on a store
66
+ * that is perfectly healthy, the catch-all in the preflight folds that into
67
+ * `history_injection_preflight_unavailable`, and `ocx sync` refuses on every attempt with no
68
+ * way forward (#4943).
69
+ *
70
+ * So the fallback is admitted only in the state where the absent sidecars are what make an
71
+ * immutable read exact rather than stale: no `-wal` and no `-shm` on disk means no writer is
72
+ * attached and no committed content sits outside the main database, so the main file IS the
73
+ * whole store and the two modes cannot disagree. Either sidecar present keeps the original
74
+ * error and the refusal that follows from it — a `-wal` holds content this connection would
75
+ * not read, and a `-shm` means a writer is attached, and neither is a store this preflight
76
+ * may inspect from a snapshot.
77
+ *
78
+ * The first read belongs INSIDE this attempt. `sqlite3_open_v2` does not touch page 1, so a
79
+ * store whose header says WAL is not inspected until the first prepare — which is where the
80
+ * missing shared memory is discovered on macOS, one caller frame above this function. Opening
81
+ * here and reading there put the classification and the failure in different scopes: the
82
+ * fallback was never reached, and the operator got the catch-all refusal the fix was supposed
83
+ * to remove. Linux hides this because its SQLite materializes the sidecars on that first read
84
+ * and never fails at all (#4943, macOS CI).
85
+ */
86
+ export function openCodexStateForPreflight(resolvedPath: string): Database {
87
+ let db: Database | undefined;
88
+ try {
89
+ const forced = openFailureForTests?.(resolvedPath, "open");
90
+ if (forced) throw forced;
91
+ db = new Database(resolvedPath, { readonly: true });
92
+ const forcedRead = openFailureForTests?.(resolvedPath, "first-read");
93
+ if (forcedRead) throw forcedRead;
94
+ // Page 1, read while the failure is still this function's to classify.
95
+ db.query<{ tables: number }, []>("SELECT count(*) AS tables FROM sqlite_master").get();
96
+ return db;
97
+ } catch (error) {
98
+ db?.close();
99
+ if (!isStateDbCantOpenError(error)) throw error;
100
+ if (existsSync(`${resolvedPath}-wal`) || existsSync(`${resolvedPath}-shm`)) throw error;
101
+ // pathToFileURL percent-encodes the reserved characters a naive `file:${path}` would
102
+ // misparse as a query or fragment.
103
+ return new Database(`${pathToFileURL(resolvedPath).href}?immutable=1`, IMMUTABLE_READONLY_FLAGS);
104
+ }
105
+ }
@@ -55,6 +55,46 @@ export function applyEol(content: string, eol: "\r\n" | "\n"): string {
55
55
  return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
56
56
  }
57
57
 
58
+ /** Label Codex shows for the injected provider when the operator has not chosen one. */
59
+ export const DEFAULT_CODEX_PROVIDER_DISPLAY_NAME = "OpenCodex Proxy";
60
+
61
+ /** Longest label accepted, matching the display-label policy used for provider names. */
62
+ const MAX_CODEX_PROVIDER_DISPLAY_NAME_LENGTH = 128;
63
+
64
+ /** Would this label put a control character into config.toml? */
65
+ function hasControlCharacter(value: string): boolean {
66
+ // Checked by code point rather than by a control-character regex, which needs a lint
67
+ // suppression this repository's hygiene gate rejects — and which reads no more clearly.
68
+ for (const character of value) {
69
+ const code = character.codePointAt(0) ?? 0;
70
+ if (code < 0x20 || code === 0x7f) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Which label to write, given whatever the config holds.
77
+ *
78
+ * Presentation only, and deliberately separate from identity: routing resolves through the
79
+ * provider id `opencodex` in the root `model_provider` line and the `[model_providers.opencodex]`
80
+ * header, neither of which is derived from this value. So a rename cannot reroute a thread or
81
+ * orphan a row that already names that id (#4810).
82
+ *
83
+ * Every rejected value falls back to the default rather than being emitted or omitted. Codex
84
+ * refuses to load a provider with no name, so writing a blank one would break the whole config
85
+ * file rather than one thread — strictly worse than the branding it was meant to remove. That is
86
+ * also why there is no way to suppress the field: suppression here means choosing a neutral
87
+ * label. A control character or an over-long value is rejected for the same reason, because
88
+ * `tomlString` would faithfully encode something Codex may still reject.
89
+ */
90
+ export function resolveCodexProviderDisplayName(configured?: string): string {
91
+ const trimmed = (configured ?? "").trim();
92
+ if (!trimmed) return DEFAULT_CODEX_PROVIDER_DISPLAY_NAME;
93
+ if (trimmed.length > MAX_CODEX_PROVIDER_DISPLAY_NAME_LENGTH) return DEFAULT_CODEX_PROVIDER_DISPLAY_NAME;
94
+ if (hasControlCharacter(trimmed)) return DEFAULT_CODEX_PROVIDER_DISPLAY_NAME;
95
+ return trimmed;
96
+ }
97
+
58
98
  export function buildProviderTableBlock(
59
99
  port: number,
60
100
  supportsWebsockets?: boolean,
@@ -84,12 +124,13 @@ export function buildProviderTableBlock(
84
124
  export function buildProviderTableBlockForTarget(
85
125
  target: CodexRoutingTarget,
86
126
  supportsWebsockets = false,
127
+ displayName?: string,
87
128
  ): string {
88
129
  const lines = [
89
130
  "",
90
131
  OCX_SECTION_MARKER,
91
132
  "[model_providers.opencodex]",
92
- 'name = "OpenCodex Proxy"',
133
+ `name = ${tomlString(resolveCodexProviderDisplayName(displayName))}`,
93
134
  `base_url = ${tomlString(target.baseUrl)}`,
94
135
  'wire_api = "responses"',
95
136
  // false only in the authless Desktop opt-in (#1107); true keeps the App/TUI account gate.
@@ -518,6 +559,7 @@ export function buildProfileFileForTarget(
518
559
  catalogPath?: string | null,
519
560
  supportsWebsockets = false,
520
561
  fastMode?: boolean,
562
+ displayName?: string,
521
563
  ): string {
522
564
  const origin = routingTargetOrigin(target);
523
565
  const host = new URL(origin).host;
@@ -542,7 +584,7 @@ export function buildProfileFileForTarget(
542
584
  ];
543
585
  if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
544
586
  if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`);
545
- lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets).trimEnd(), "");
587
+ lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets, displayName).trimEnd(), "");
546
588
  return lines.join("\n");
547
589
  }
548
590
 
@@ -369,7 +369,7 @@ async function injectCodexConfigImpl(
369
369
  content =
370
370
  content.trimEnd() +
371
371
  "\n" +
372
- buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}));
372
+ buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName);
373
373
  // 3) Keep existing `openai`-tagged threads reaching the proxy (see above). Ownership rules
374
374
  // are the Design B ones: a user's own root line is never replaced.
375
375
  if (keepRootOverrideAlongsideTable) {
@@ -430,6 +430,7 @@ async function injectCodexConfigImpl(
430
430
  catalogPath,
431
431
  websocketsEnabled(config ?? {}),
432
432
  config?.fastMode,
433
+ config?.codexProviderDisplayName,
433
434
  );
434
435
  content = applyEol(content, eol);
435
436
 
@@ -492,7 +493,7 @@ async function injectCodexConfigImpl(
492
493
  */
493
494
  if (hadOcxProviderTableOnDisk && !providerTableMode) {
494
495
  content = applyEol(
495
- content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {})),
496
+ content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName),
496
497
  eol,
497
498
  );
498
499
  }