@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -0,0 +1,109 @@
1
+ import type { OcxConfig } from "../types/config";
2
+
3
+ export const REMOTE_HUB_PROTOCOL = 1;
4
+ export const MINIMUM_REMOTE_CLIENT_PROTOCOL = 1;
5
+
6
+ export interface RemoteReadyMetadata {
7
+ protocol: number;
8
+ minimumClientProtocol: number;
9
+ managementUrl: string;
10
+ features?: string[];
11
+ }
12
+
13
+ export type RemoteProtocolCompatibility =
14
+ | { ok: true; metadata: RemoteReadyMetadata; features: Set<string> }
15
+ | { ok: false; reason: "invalid" | "hub-too-new" | "hub-too-old"; message: string };
16
+
17
+ const INVALID_REMOTE_PROTOCOL_MESSAGE =
18
+ "OpenCodex hub returned invalid remote protocol metadata; upgrade or repair ocx on the hub.";
19
+
20
+ function positiveSafeInteger(value: unknown): value is number {
21
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
22
+ }
23
+
24
+ function managementOrigin(value: unknown): string | null {
25
+ if (typeof value !== "string") return null;
26
+ try {
27
+ const parsed = new URL(value);
28
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
29
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
30
+ return parsed.origin;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function observedManagementOrigin(req: Request): string | null {
37
+ try {
38
+ const requestUrl = new URL(req.url);
39
+ const host = req.headers.get("Host") ?? requestUrl.host;
40
+ return managementOrigin(`${requestUrl.protocol}//${host}`);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function readyProtocolMetadata(config: OcxConfig, req: Request): RemoteReadyMetadata {
47
+ const configured = config.runtimeRole === "hub"
48
+ ? managementOrigin(config.hub?.managementPublicOrigin)
49
+ : null;
50
+ const managementUrl = configured ?? observedManagementOrigin(req);
51
+ if (!managementUrl) throw new Error("Readiness request does not have an HTTP(S) management origin");
52
+ return {
53
+ protocol: REMOTE_HUB_PROTOCOL,
54
+ minimumClientProtocol: MINIMUM_REMOTE_CLIENT_PROTOCOL,
55
+ managementUrl,
56
+ };
57
+ }
58
+
59
+ export function parseRemoteReadyMetadata(value: unknown): RemoteReadyMetadata | null {
60
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
61
+ const raw = value as Record<string, unknown>;
62
+ if (!positiveSafeInteger(raw.protocol) || !positiveSafeInteger(raw.minimumClientProtocol)) return null;
63
+ if (raw.minimumClientProtocol > raw.protocol) return null;
64
+ const parsedManagementOrigin = managementOrigin(raw.managementUrl);
65
+ if (!parsedManagementOrigin) return null;
66
+ const features = raw.features;
67
+ if (features !== undefined && (
68
+ !Array.isArray(features)
69
+ || features.length > 64
70
+ || features.some(feature => typeof feature !== "string" || !feature || feature.length > 80 || /[\x00-\x1f\x7f]/.test(feature))
71
+ || new Set(features).size !== features.length
72
+ )) return null;
73
+ return {
74
+ protocol: raw.protocol,
75
+ minimumClientProtocol: raw.minimumClientProtocol,
76
+ managementUrl: parsedManagementOrigin,
77
+ ...(Array.isArray(features) ? { features: [...features] as string[] } : {}),
78
+ };
79
+ }
80
+
81
+ export function checkRemoteProtocolCompatibility(
82
+ value: unknown,
83
+ client: { protocol: number; minimumHubProtocol: number; features?: readonly string[] } = {
84
+ protocol: REMOTE_HUB_PROTOCOL,
85
+ minimumHubProtocol: MINIMUM_REMOTE_CLIENT_PROTOCOL,
86
+ },
87
+ ): RemoteProtocolCompatibility {
88
+ const metadata = parseRemoteReadyMetadata(value);
89
+ if (!metadata || !positiveSafeInteger(client.protocol) || !positiveSafeInteger(client.minimumHubProtocol)) {
90
+ return { ok: false, reason: "invalid", message: INVALID_REMOTE_PROTOCOL_MESSAGE };
91
+ }
92
+ if (client.protocol < metadata.minimumClientProtocol) {
93
+ return {
94
+ ok: false,
95
+ reason: "hub-too-new",
96
+ message: `OpenCodex hub requires remote protocol ${metadata.minimumClientProtocol}; this client supports protocol ${client.protocol}. Upgrade ocx on this client.`,
97
+ };
98
+ }
99
+ if (metadata.protocol < client.minimumHubProtocol) {
100
+ return {
101
+ ok: false,
102
+ reason: "hub-too-old",
103
+ message: `OpenCodex hub provides remote protocol ${metadata.protocol}; this client requires at least ${client.minimumHubProtocol}. Upgrade ocx on the hub.`,
104
+ };
105
+ }
106
+ const supported = new Set(client.features ?? []);
107
+ const features = new Set((metadata.features ?? []).filter(feature => supported.has(feature)));
108
+ return { ok: true, metadata, features };
109
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ChatGPT-backend citation markers.
3
+ *
4
+ * The ChatGPT backend delimits inline citations with Unicode private-use characters:
5
+ *
6
+ * \uE200 cite \uE202 turn1view0 \uE202 turn1view1 \uE201
7
+ *
8
+ * The desktop client renders that as source chips. The Codex TUI does not: it prints the
9
+ * codepoints literally, so the user sees "citeturn1view0turn1view1" in the answer and in
10
+ * the saved transcript (#3150).
11
+ *
12
+ * OpenCodex neither produces nor understands this grammar — it arrives as ordinary
13
+ * assistant text from a ChatGPT-derived backend (GitHub Copilot in the report). The proxy
14
+ * is the last place that can remove it before a client that cannot render it.
15
+ *
16
+ * Strip, do not translate. The `turnNviewN` ids are turn-scoped and opaque, and the
17
+ * response carries no mapping from them to a URL, so there is nothing to convert them
18
+ * into. Structured `url_citation` annotations are a separate path and are untouched.
19
+ */
20
+
21
+ /** Opens a citation span. */
22
+ export const CITATION_MARKER_START = "\uE200";
23
+ /** Separates the `cite` keyword and each source reference inside a span. */
24
+ export const CITATION_MARKER_SEPARATOR = "\uE202";
25
+ /** Closes a citation span. */
26
+ export const CITATION_MARKER_END = "\uE201";
27
+
28
+ /** True when the text contains any of the three delimiters. Cheap pre-check. */
29
+ export function hasCitationMarker(text: string): boolean {
30
+ return text.includes(CITATION_MARKER_START)
31
+ || text.includes(CITATION_MARKER_SEPARATOR)
32
+ || text.includes(CITATION_MARKER_END);
33
+ }
34
+
35
+ /**
36
+ * Remove every complete `START … END` span from a whole string.
37
+ *
38
+ * A START with no END is left alone rather than truncating the remainder: an unterminated
39
+ * marker is malformed input, and dropping everything after it would delete real answer
40
+ * text. A stray SEPARATOR or END outside a span is also left alone for the same reason —
41
+ * this function only removes what it can prove is a citation span.
42
+ */
43
+ export function stripCitationMarkers(text: string): string {
44
+ if (!text.includes(CITATION_MARKER_START)) return text;
45
+ let out = "";
46
+ let index = 0;
47
+ for (;;) {
48
+ const start = text.indexOf(CITATION_MARKER_START, index);
49
+ if (start === -1) {
50
+ out += text.slice(index);
51
+ return out;
52
+ }
53
+ const end = text.indexOf(CITATION_MARKER_END, start + 1);
54
+ if (end === -1) {
55
+ // Unterminated: keep the rest verbatim.
56
+ out += text.slice(index);
57
+ return out;
58
+ }
59
+ out += text.slice(index, start);
60
+ index = end + 1;
61
+ }
62
+ }
63
+
64
+ export interface CitationMarkerFilter {
65
+ /** Feed one streaming delta; returns the portion safe to emit now. */
66
+ push(delta: string): string;
67
+ /** Release anything still held when the message closes. */
68
+ flush(): string;
69
+ }
70
+
71
+ /**
72
+ * Streaming filter.
73
+ *
74
+ * A marker can straddle a delta boundary — `\uE200cite` in one chunk and the rest in the
75
+ * next — so a stateless per-delta strip would emit the tail of a span it never recognized.
76
+ * This holds back the text from an unterminated START and releases it once the END arrives
77
+ * (removed) or the stream ends (verbatim, so nothing the model actually said is lost).
78
+ */
79
+ export function createCitationMarkerFilter(): CitationMarkerFilter {
80
+ // Text from an open START that has not been terminated yet.
81
+ let held = "";
82
+ return {
83
+ push(delta: string): string {
84
+ const combined = held + delta;
85
+ held = "";
86
+ const start = combined.lastIndexOf(CITATION_MARKER_START);
87
+ if (start === -1) return stripCitationMarkers(combined);
88
+ const endAfterStart = combined.indexOf(CITATION_MARKER_END, start + 1);
89
+ if (endAfterStart !== -1) return stripCitationMarkers(combined);
90
+ // The trailing span is still open: emit everything before it, hold the rest.
91
+ held = combined.slice(start);
92
+ return stripCitationMarkers(combined.slice(0, start));
93
+ },
94
+ flush(): string {
95
+ const rest = held;
96
+ held = "";
97
+ return rest;
98
+ },
99
+ };
100
+ }
101
+
@@ -19,7 +19,7 @@ import { compactionItemToText, isCompactionItemType } from "./compaction";
19
19
  import { previousResponseReplayPrefixLength } from "./state";
20
20
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
21
21
  import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
22
- import { extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
22
+ import { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
23
23
  import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat";
24
24
 
25
25
  function isObj(v: unknown): v is Record<string, unknown> {
@@ -165,6 +165,15 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
165
165
  return { ...(isObj(raw) ? raw : {}), type: "object" };
166
166
  };
167
167
  const pushFn = (t: Record<string, unknown>, namespace?: string) => {
168
+ // Hosted image_generation already installed the synthetic root tool. A later
169
+ // ordinary root `image_gen` must not create a second un-namespaced identity.
170
+ if (
171
+ !namespace
172
+ && t.name === IMAGE_GEN_TOOL_NAME
173
+ && out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration)
174
+ ) {
175
+ return;
176
+ }
168
177
  const tool: OcxTool = {
169
178
  name: t.name as string,
170
179
  description: (t.description as string) ?? "",
@@ -175,6 +184,16 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
175
184
  out.push(tool);
176
185
  };
177
186
  const pushCustom = (t: Record<string, unknown>, namespace?: string) => {
187
+ // Hosted image_generation already installed the synthetic root tool. A later
188
+ // root custom `image_gen` would collide on the same wire name with a different
189
+ // `freeform` flag and throw `ambiguous tool catalog`.
190
+ if (
191
+ !namespace
192
+ && t.name === IMAGE_GEN_TOOL_NAME
193
+ && out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration)
194
+ ) {
195
+ return;
196
+ }
178
197
  // Freeform custom tools are lowered to a single string `input` because chat models cannot
179
198
  // emit Responses grammar payloads directly. Keep tool-specific input guidance scoped to the
180
199
  // tool that owns it: leaking apply_patch syntax into `exec` or another freeform tool teaches
@@ -224,6 +243,28 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
224
243
  toolSearch: true,
225
244
  });
226
245
  }
246
+ else if (t.type === "image_generation" || t.type === "image_gen") {
247
+ // Keep Codex's image_gen visible to routed chat models. The hosted OpenAI tool
248
+ // cannot execute on Grok; the model still has to see a callable image_gen so
249
+ // Codex's client-side /v1/images request can fire and be relayed to xAI.
250
+ // Identity is the un-namespaced synthetic root (`imageGeneration: true`), not
251
+ // the bare name: a namespaced ordinary `image_gen` must not suppress it.
252
+ const synthetic = buildImageTool();
253
+ // Every un-namespaced `image_gen` collides on one wire name, so removing only
254
+ // the first leaves a second root behind and the catalog stays ambiguous.
255
+ // Drop all root collisions, keep namespaced entries, then insert exactly one
256
+ // synthetic root — at the earliest colliding position so declaration order is
257
+ // preserved for models that read the catalog positionally.
258
+ let insertAt = -1;
259
+ for (let i = out.length - 1; i >= 0; i -= 1) {
260
+ const tool = out[i]!;
261
+ if (tool.name !== IMAGE_GEN_TOOL_NAME || tool.namespace) continue;
262
+ out.splice(i, 1);
263
+ insertAt = i;
264
+ }
265
+ if (insertAt >= 0) out.splice(insertAt, 0, synthetic);
266
+ else out.push(synthetic);
267
+ }
227
268
  else if (typeof t.name === "string" && t.type !== "web_search" && t.type !== "image_generation") {
228
269
  // Any OTHER named tool (e.g. a native/computer-use tool type opencodex doesn't explicitly
229
270
  // model) is client-executed — pass it through as a function so the routed model can read and
@@ -231,8 +272,7 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
231
272
  // silently dropped, so the model never saw them.
232
273
  pushFn(t);
233
274
  }
234
- // Only the OpenAI-hosted server-side tools (web_search, image_generation) are intentionally
235
- // dropped — they're executed by OpenAI and can't be relayed to a routed chat model.
275
+ // Hosted web_search is still dropped here the web-search sidecar re-injects it.
236
276
  }
237
277
  return out.length > 0 ? out : undefined;
238
278
  }
@@ -215,7 +215,9 @@ function isErrno(error: unknown, code: string): boolean {
215
215
  }
216
216
 
217
217
  function canUseExclusiveCopyFallback(error: unknown): boolean {
218
- return process.platform === "win32" || ["EPERM", "EACCES", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV"]
218
+ // Same platform seam as harden(): a fixture pinned to the POSIX lane on a Windows host must
219
+ // see a link failure as a failure, not as a cue to copy.
220
+ return windowsSecretAclApplies() || ["EPERM", "EACCES", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV"]
219
221
  .some(code => isErrno(error, code));
220
222
  }
221
223
 
@@ -235,7 +237,9 @@ function nextSpillHardenDeadlineMs(budget: SpillAclBudget | undefined): number |
235
237
  }
236
238
 
237
239
  function harden(path: string, mode: number, budget?: SpillAclBudget): void {
238
- const aclApplies = budget ? windowsSecretAclApplies() : process.platform === "win32";
240
+ // One predicate for both lanes: the test seam that forces a platform must reach the
241
+ // sync harden too, or a fixture pinned to "linux" on a Windows host still spawns icacls.
242
+ const aclApplies = windowsSecretAclApplies();
239
243
  try {
240
244
  chmodSync(path, mode);
241
245
  } catch {
@@ -384,7 +388,8 @@ function publishNoReplace(
384
388
  else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL);
385
389
  copied = true;
386
390
  harden(destinationPath, 0o600, budget);
387
- const copyFd = openSync(destinationPath, "r");
391
+ // "r+": a read-only handle cannot be fsynced on Windows (EPERM).
392
+ const copyFd = openSync(destinationPath, "r+");
388
393
  try {
389
394
  if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
390
395
  else fsyncSync(copyFd);
@@ -422,7 +427,7 @@ async function publishNoReplaceAsync(
422
427
  copied = true;
423
428
  await hardenAsync(destinationPath, 0o600, budget, retryTimedOutOnce);
424
429
  throwIfPublicationSuperseded(publicationControl);
425
- const copyFd = openSync(destinationPath, "r");
430
+ const copyFd = openSync(destinationPath, "r+");
426
431
  try {
427
432
  if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
428
433
  else fsyncSync(copyFd);
package/src/router.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "./combos";
10
10
  import type { NormalizedComboConfig } from "./combos/types";
11
11
  import { hasOwnProvider } from "./config/provider-name";
12
- import { resolveEnvValue } from "./config";
12
+ import { resolveProviderApiKey } from "./providers/key-store";
13
13
  import { assertProviderDestinationAllowed } from "./lib/destination-policy";
14
14
  import { redactSecretString, redactUrlForLog } from "./lib/redact";
15
15
  import {
@@ -265,8 +265,27 @@ function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effec
265
265
  );
266
266
  }
267
267
 
268
+ /**
269
+ * One notice per provider id: the destination is an operator-configured key
270
+ * (never a caller-supplied string), so the log carries no request content.
271
+ */
272
+ const compactionFallbackWarnings = new Set<string>();
273
+ function warnCompactionDefaultProviderFallbackOnce(providerName: string): void {
274
+ if (compactionFallbackWarnings.has(providerName)) return;
275
+ compactionFallbackWarnings.add(providerName);
276
+ console.warn(
277
+ `compaction: no enabled canonical "openai" provider for the native compaction model;`
278
+ + ` summarizing through default provider "${providerName}" instead (#2901).`,
279
+ );
280
+ }
281
+
282
+ /** Test seam: forget which compaction fallbacks have been announced. */
283
+ export function resetCompactionFallbackWarningsForTests(): void {
284
+ compactionFallbackWarnings.clear();
285
+ }
286
+
268
287
  function usableResolvedApiKey(apiKey: string | undefined): string | undefined {
269
- const resolved = resolveEnvValue(apiKey);
288
+ const resolved = resolveProviderApiKey(apiKey);
270
289
  return typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined;
271
290
  }
272
291
 
@@ -328,6 +347,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
328
347
  const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
329
348
  const requiresReasoningPlaceholderModels = mergeStringArray(registryEntry.requiresReasoningPlaceholderModels, provider.requiresReasoningPlaceholderModels);
330
349
  const reasoningSplitModels = mergeStringArray(registryEntry.reasoningSplitModels, provider.reasoningSplitModels);
350
+ const reasoningDetailsModels = mergeStringArray(registryEntry.reasoningDetailsModels, provider.reasoningDetailsModels);
331
351
  const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
332
352
  const thinkingBudgetModels = mergeStringArray(registryEntry.thinkingBudgetModels, provider.thinkingBudgetModels);
333
353
  const registryBaseUrlIsTemplate = /\{[^}]*\}/.test(registryEntry.baseUrl);
@@ -451,6 +471,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
451
471
  ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
452
472
  ...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}),
453
473
  ...(reasoningSplitModels ? { reasoningSplitModels } : {}),
474
+ ...(reasoningDetailsModels ? { reasoningDetailsModels } : {}),
454
475
  ...(thinkingToggleModels ? { thinkingToggleModels } : {}),
455
476
  ...(thinkingBudgetModels ? { thinkingBudgetModels } : {}),
456
477
  };
@@ -576,6 +597,7 @@ function routeModelInternal(
576
597
  modelId: string,
577
598
  bypassCombos: boolean,
578
599
  policyEvidence?: PolicyRequestEvidence,
600
+ allowCompactionNativeFallback = false,
579
601
  ): RouteResult {
580
602
  const slash = modelId.indexOf("/");
581
603
  // Policy namespace is system-reserved: an explicit `policy/<id>` or a
@@ -701,6 +723,28 @@ function routeModelInternal(
701
723
  if (provider && provider.disabled !== true) {
702
724
  return routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
703
725
  }
726
+ // Codex chooses a bare native model for compaction even when the operator's
727
+ // ordinary route is a third-party provider. Keep the native reservation
728
+ // unchanged for ordinary turns; only the explicit compaction surface may
729
+ // use the configured default as its summarizer destination.
730
+ if (allowCompactionNativeFallback
731
+ && config.defaultProvider !== OPENAI_CODEX_PROVIDER_ID
732
+ && config.defaultProvider !== LEGACY_CHATGPT_PROVIDER_ID
733
+ && config.defaultProvider !== LEGACY_OPENAI_MULTI_PROVIDER_ID
734
+ && hasOwnProvider(config.providers, config.defaultProvider)) {
735
+ const defaultProvider = config.providers[config.defaultProvider];
736
+ if (defaultProvider.disabled !== true) {
737
+ warnCompactionDefaultProviderFallbackOnce(config.defaultProvider);
738
+ return routeResult(
739
+ config,
740
+ config.defaultProvider,
741
+ defaultProvider,
742
+ modelId,
743
+ "default-provider",
744
+ "compaction-default-provider",
745
+ );
746
+ }
747
+ }
704
748
  throw new NoEnabledOpenAiProviderError(modelId);
705
749
  }
706
750
 
@@ -753,12 +797,7 @@ function routeModelInternal(
753
797
  throw new Error(`No provider configured for model: ${modelId}`);
754
798
  }
755
799
 
756
- export function routeModel(
757
- config: OcxConfig,
758
- modelId: string,
759
- policyEvidence?: PolicyRequestEvidence,
760
- ): RouteResult {
761
- const route = routeModelInternal(config, modelId, false, policyEvidence);
800
+ function routeWithDecisionTrace(config: OcxConfig, modelId: string, route: RouteResult): RouteResult {
762
801
  // Policy routes carry a full evaluation trace already; never rebuild it.
763
802
  if (route.routeDecision) return route;
764
803
  const accountRef = route.codexAccountNamespace;
@@ -783,6 +822,31 @@ export function routeModel(
783
822
  return route;
784
823
  }
785
824
 
825
+ export function routeModel(
826
+ config: OcxConfig,
827
+ modelId: string,
828
+ policyEvidence?: PolicyRequestEvidence,
829
+ ): RouteResult {
830
+ const route = routeModelInternal(config, modelId, false, policyEvidence);
831
+ return routeWithDecisionTrace(config, modelId, route);
832
+ }
833
+
834
+ /**
835
+ * Route a client-selected compaction model. Codex may send a bare native model
836
+ * even when its ordinary turns are configured for another provider; in that
837
+ * one case the configured default provider is a safe summarizer destination.
838
+ * This helper is intentionally separate so ordinary requests retain the
839
+ * canonical OpenAI reservation and exact account selectors remain fail-closed.
840
+ */
841
+ export function routeCompactionModel(
842
+ config: OcxConfig,
843
+ modelId: string,
844
+ policyEvidence?: PolicyRequestEvidence,
845
+ ): RouteResult {
846
+ const route = routeModelInternal(config, modelId, false, policyEvidence, true);
847
+ return routeWithDecisionTrace(config, modelId, route);
848
+ }
849
+
786
850
  /** Resolve a combo-selected provider/model target without consulting public combo aliases again. */
787
851
  export function routeConcreteModel(config: OcxConfig, modelId: string): RouteResult {
788
852
  return routeModelInternal(config, modelId, true, undefined);
@@ -121,7 +121,29 @@ export function managementRequestOrigin(req: Request, config: OcxConfig): string
121
121
  const host = req.headers.get("Host");
122
122
  const parsedHost = parseHttpHost(host);
123
123
  if (!host || !parsedHost) return null;
124
- if (!isApiAuthRequired(config) && !isLoopbackHostname(parsedHost.hostname)) return null;
124
+ if (isLoopbackHostname(parsedHost.hostname)) {
125
+ try {
126
+ const protocol = new URL(req.url).protocol;
127
+ if (protocol !== "http:" && protocol !== "https:") return null;
128
+ return new URL(`${protocol}//${host}`).origin;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+ if (!isApiAuthRequired(config)) return null;
134
+ if (config.runtimeRole === "hub" && config.hub?.managementPublicOrigin) {
135
+ try {
136
+ const configured = new URL(config.hub.managementPublicOrigin);
137
+ if (
138
+ (configured.protocol === "http:" || configured.protocol === "https:")
139
+ && !configured.username
140
+ && !configured.password
141
+ && configured.pathname === "/"
142
+ && !configured.search
143
+ && !configured.hash
144
+ ) return configured.origin;
145
+ } catch { /* malformed direct fixture: fall through to observed origin */ }
146
+ }
125
147
  try {
126
148
  const protocol = new URL(req.url).protocol;
127
149
  if (protocol !== "http:" && protocol !== "https:") return null;
@@ -200,6 +222,7 @@ export function corsHeaders(req?: Request, config?: RequestPolicyView): Record<s
200
222
 
201
223
  export function managementCorsHeaders(req?: Request, config?: OcxConfig): Record<string, string> {
202
224
  const headers = corsHeaders();
225
+ headers["Access-Control-Allow-Headers"] = `${STATIC_ALLOWED_REQUEST_HEADERS}, X-OpenCodex-GUI-Origin, X-OpenCodex-CSRF-Token`;
203
226
  const origin = req?.headers.get("Origin");
204
227
  if (origin && req && config && isAllowedManagementOrigin(req, config)) {
205
228
  headers["Access-Control-Allow-Origin"] = origin;
@@ -351,6 +374,10 @@ export function resolveDataPlaneAdmissionSecret(
351
374
  if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment", source };
352
375
  for (const k of config.apiKeys ?? []) {
353
376
  if (secretEquals(actual, k.key)) return { kind: "configured", keyId: k.id, source };
377
+ const pending = k.pendingRotation;
378
+ if (pending && Date.parse(pending.expiresAt) > Date.now() && secretEquals(actual, pending.key)) {
379
+ return { kind: "configured", keyId: k.id, source };
380
+ }
354
381
  }
355
382
  return null;
356
383
  }
@@ -667,6 +694,13 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
667
694
  "noStructuredOutputModels",
668
695
  );
669
696
  if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`;
697
+ const retainModelsError = nonBlankStringArrayConfigError(raw.retainModels, "retainModels");
698
+ if (retainModelsError) return `provider ${name} ${retainModelsError}`;
699
+ const toolReasoningOptOutError = nonBlankStringArrayConfigError(
700
+ raw.omitReasoningEffortWithToolsModels,
701
+ "omitReasoningEffortWithToolsModels",
702
+ );
703
+ if (toolReasoningOptOutError) return `provider ${name} ${toolReasoningOptOutError}`;
670
704
  const openRouterError = openRouterRoutingConfigError(typed);
671
705
  if (openRouterError) return `provider ${name} ${openRouterError}`;
672
706
  const vercelError = vercelGatewayRoutingConfigError(typed);
@@ -760,6 +794,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
760
794
  "noTopPModels",
761
795
  "noPenaltyModels",
762
796
  "noStructuredOutputModels",
797
+ "retainModels",
798
+ "omitReasoningEffortWithToolsModels",
763
799
  "upstreamHttpVersion",
764
800
  "autoToolChoiceOnlyModels",
765
801
  "preserveReasoningContentModels",