@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -0,0 +1,139 @@
1
+ import { isPlainObject } from "./internal";
2
+ import { normalizeResponsesToolResultAdjacency } from "./tool-output-recovery";
3
+
4
+ export class OpenGatewayInputCompatibilityError extends Error {
5
+ constructor() {
6
+ super("OpenGateway cannot preserve this tool result; use text or valid input_image parts with a call_id.");
7
+ this.name = "OpenGatewayInputCompatibilityError";
8
+ }
9
+ }
10
+
11
+ const TOOL_ROUND_TYPES = new Set([
12
+ "function_call",
13
+ "function_call_output",
14
+ "custom_tool_call",
15
+ "custom_tool_call_output",
16
+ "local_shell_call",
17
+ "local_shell_call_output",
18
+ ]);
19
+
20
+ function normalizeImageDetail(part: unknown): unknown {
21
+ return isPlainObject(part) && part.type === "input_image" && part.detail === "original"
22
+ ? { ...part, detail: "high" }
23
+ : part;
24
+ }
25
+
26
+ function isToolRound(item: unknown): boolean {
27
+ return isPlainObject(item) && typeof item.type === "string" && TOOL_ROUND_TYPES.has(item.type);
28
+ }
29
+
30
+ function pairedCallId(item: Record<string, unknown>): string | undefined {
31
+ return typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : undefined;
32
+ }
33
+
34
+ type ProjectedInput = { readonly changed: boolean; readonly input: unknown[] };
35
+
36
+ function projectOpenGatewayInput(input: readonly unknown[]): ProjectedInput {
37
+ let changed = false;
38
+ const projected: unknown[] = [];
39
+ const pendingImages: unknown[] = [];
40
+ const flushImages = (): void => {
41
+ if (pendingImages.length === 0) return;
42
+ projected.push(...pendingImages);
43
+ pendingImages.length = 0;
44
+ };
45
+ for (const item of input) {
46
+ if (!isToolRound(item)) flushImages();
47
+ if (!isPlainObject(item)) {
48
+ projected.push(item);
49
+ continue;
50
+ }
51
+ if (item.type === "function_call_output" && Array.isArray(item.output)) {
52
+ const callId = pairedCallId(item);
53
+ const content: Record<string, unknown>[] = [];
54
+ const text: string[] = [];
55
+ let supported = callId !== undefined;
56
+ let hasImage = false;
57
+ if (supported) {
58
+ for (const part of item.output) {
59
+ if (!isPlainObject(part)) {
60
+ supported = false;
61
+ break;
62
+ }
63
+ switch (part.type) {
64
+ case "text":
65
+ case "input_text":
66
+ case "output_text":
67
+ if (typeof part.text !== "string") {
68
+ supported = false;
69
+ break;
70
+ }
71
+ text.push(part.text);
72
+ content.push({ type: "input_text", text: part.text });
73
+ break;
74
+ case "input_image":
75
+ if (!((typeof part.image_url === "string" && part.image_url.length > 0)
76
+ || (typeof part.file_id === "string" && part.file_id.length > 0))
77
+ || (part.image_url !== undefined && typeof part.image_url !== "string")
78
+ || (part.file_id !== undefined && typeof part.file_id !== "string")
79
+ || (part.detail !== undefined && (typeof part.detail !== "string"
80
+ || !["auto", "low", "high", "original"].includes(part.detail)))) {
81
+ supported = false;
82
+ break;
83
+ }
84
+ content.push({ ...part, ...(part.detail === "original" ? { detail: "high" } : {}) });
85
+ hasImage = true;
86
+ break;
87
+ default:
88
+ supported = false;
89
+ }
90
+ if (!supported) break;
91
+ }
92
+ }
93
+ if (supported && callId !== undefined) {
94
+ changed = true;
95
+ const marker = `[tool output for ${callId}]`;
96
+ projected.push({
97
+ ...item,
98
+ output: text.length > 0 ? text.join("\n") : hasImage ? marker : "",
99
+ });
100
+ if (hasImage) {
101
+ pendingImages.push({
102
+ type: "message",
103
+ role: "user",
104
+ content: [{ type: "input_text", text: marker }, ...content],
105
+ });
106
+ }
107
+ continue;
108
+ }
109
+ throw new OpenGatewayInputCompatibilityError();
110
+ }
111
+ if (Array.isArray(item.content)) {
112
+ const originalContent = item.content;
113
+ const content = originalContent.map(normalizeImageDetail);
114
+ if (content.some((part, index) => part !== originalContent[index])) {
115
+ changed = true;
116
+ projected.push({ ...item, content });
117
+ continue;
118
+ }
119
+ }
120
+ projected.push(item);
121
+ }
122
+ flushImages();
123
+ return { changed, input: projected };
124
+ }
125
+
126
+ /** OpenGateway requires string tool results and auto/low/high image detail. */
127
+ export function normalizeOpenGatewayResponsesInput(body: unknown, url: string): unknown {
128
+ // Same exact-URL boundary as completion recovery; a query-routed variant is a different destination.
129
+ if (url !== "https://apis.opengateway.ai/v1/responses" || !isPlainObject(body) || !Array.isArray(body.input)) return body;
130
+
131
+ const direct = projectOpenGatewayInput(body.input);
132
+ if (!direct.changed) return body;
133
+ const aligned = normalizeResponsesToolResultAdjacency(body);
134
+ if (!isPlainObject(aligned) || !Array.isArray(aligned.input) || aligned === body) {
135
+ return { ...body, input: direct.input };
136
+ }
137
+ const paired = projectOpenGatewayInput(aligned.input);
138
+ return { ...aligned, input: paired.changed ? paired.input : direct.input };
139
+ }
@@ -40,7 +40,7 @@ import {
40
40
  emittedFastWire,
41
41
  } from "../../providers/fastwire";
42
42
  import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning";
43
- import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips";
43
+ import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips";
44
44
  import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice";
45
45
  import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache";
46
46
  import { isPlainObject } from "./internal";
@@ -55,6 +55,7 @@ import { observeOutbound } from "../../usage/cache-diagnostic";
55
55
  import { normalizeMuseToolChoice } from "./muse-tool-choice";
56
56
  import { declaresHostedWebSearch, projectHostedSearchHistory, renderCompactionSearchHistoryItems } from "./compaction-search-history";
57
57
  import { stripKimiEmptyAssistantMessages } from "./empty-assistant";
58
+ import { normalizeOpenGatewayResponsesInput } from "./opengateway-input";
58
59
 
59
60
  /**
60
61
  * Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need
@@ -334,6 +335,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
334
335
  if (!isOpenAiOperatedResponsesDestination(provider)) {
335
336
  if (parsed._compactionRequest !== true && isPlainObject(outBody)) outBody = frameCompactionHistory(outBody);
336
337
  outBody = stripCanonicalOnlyTopLevelFields(outBody);
338
+ outBody = stripCanonicalOnlyInputItems(outBody);
337
339
  }
338
340
  outBody = promoteClientLoadedTools(outBody);
339
341
  }
@@ -508,11 +510,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
508
510
  || !declaresHostedWebSearch(finalBody))) {
509
511
  finalBody = projectHostedSearchHistory(finalBody);
510
512
  }
513
+ finalBody = normalizeOpenGatewayResponsesInput(finalBody, url);
511
514
  observeOutbound(parsed._rawBody, finalBody, headers);
512
515
  if (!isCanonicalOpenAiForwardProvider(provider)) {
513
516
  validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools);
514
517
  }
515
- const body = JSON.stringify(applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName));
518
+ const wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName);
519
+ const body = JSON.stringify(wireBody);
516
520
  const releaseBodyObservation = translatorBudget.observeExternallyCapped(
517
521
  "passthrough_serialization",
518
522
  Buffer.byteLength(body, "utf8"),
@@ -522,6 +526,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
522
526
  method: "POST",
523
527
  headers,
524
528
  body,
529
+ ...(isPlainObject(wireBody) && typeof wireBody.model === "string" ? { outboundModelId: wireBody.model } : {}),
525
530
  releaseBodyObservation,
526
531
  ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
527
532
  ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}),
@@ -187,6 +187,38 @@ export function stripCanonicalOnlyTopLevelFields(body: unknown): unknown {
187
187
  return next;
188
188
  }
189
189
 
190
+ /**
191
+ * OpenAI-private input item TYPES, the item-level sibling of the two tables above.
192
+ *
193
+ * Each one is a positional control the OpenAI Responses backend interprets where it sits in
194
+ * history, never a turn of the conversation, so dropping it leaves every message, call and output
195
+ * in place. Codex gates sending one on its own `is_openai` provider check, which loopback
196
+ * injection always satisfies, so a gateway this proxy does not operate can still receive one and
197
+ * reject the whole body as an unknown item type.
198
+ *
199
+ * Same rule as the top-level table: only types a client is OBSERVED to send.
200
+ */
201
+ const CANONICAL_ONLY_INPUT_ITEM_TYPES: ReadonlySet<string> = new Set([
202
+ // Codex 0.164 `ResponseItem::ConfigurationUpdate` ({type, reasoning: {effort}}): a durable
203
+ // reasoning-effort change recorded at its position in history. codex-rs sends it only for a
204
+ // catalog row with `supports_reasoning_effort_updates`, and keeps it in saved history after a
205
+ // model switch. Mapping the effort onto another provider is out of scope (#6895).
206
+ "configuration_update",
207
+ ]);
208
+
209
+ /**
210
+ * Remove OpenAI-private input items. The caller decides the boundary, exactly as for
211
+ * {@link stripCanonicalOnlyTopLevelFields}; the canonical and OpenAI-operated destinations keep
212
+ * the items verbatim and in order. Returns the input unchanged when none is present.
213
+ */
214
+ export function stripCanonicalOnlyInputItems(body: unknown): unknown {
215
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
216
+ const isPrivate = (item: unknown): boolean => isPlainObject(item)
217
+ && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type);
218
+ if (!body.input.some(isPrivate)) return body;
219
+ return { ...body, input: body.input.filter(item => !isPrivate(item)) };
220
+ }
221
+
190
222
  /**
191
223
  * Sampling fields a model on the provider's `noStopModels` / `noPenaltyModels` list rejects on
192
224
  * every wire. Claude inbound translates `stop_sequences` into a Responses `stop`
@@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../../types";
3
3
  import { CODEX_RESPONSES_LITE_HEADER } from "../../codex/forward-transport-headers";
4
4
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers-destination";
5
5
  import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers";
6
+ import { AGENT_ASSERTION_HEADER } from "../../lib/authorization-scheme";
6
7
  import { openaiResponsesUrl } from "../openai-responses-url";
7
8
 
8
9
  // Caller credentials are forwarded only to the canonical Codex destination, never by capability.
@@ -23,6 +24,18 @@ export function copyResponsesClientMetadata(target: Headers, incoming: Headers,
23
24
  }
24
25
  }
25
26
 
27
+ /**
28
+ * Strip trailing slashes with a linear scan rather than a regex.
29
+ *
30
+ * `/\/+$/` retries candidate end positions when a long slash run is not at the end of the
31
+ * string; the scan walks each character once and produces the same result on every input.
32
+ */
33
+ function stripTrailingSlashes(value: string): string {
34
+ let end = value.length;
35
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
36
+ return end === value.length ? value : value.slice(0, end);
37
+ }
38
+
26
39
  /** Shared Responses URL/auth/header construction; capabilities never grant auth authority. */
27
40
  export function buildResponsesTransport(provider: OcxProviderConfig, incoming: Pick<IncomingMeta, "headers">): {
28
41
  url: string; headers: Record<string, string>;
@@ -31,7 +44,7 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P
31
44
  let url: string;
32
45
  if (provider.authMode === "forward") {
33
46
  const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider);
34
- const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : provider.baseUrl.replace(/\/+$/, "");
47
+ const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : stripTrailingSlashes(provider.baseUrl);
35
48
  url = `${baseUrl}/responses`;
36
49
  if (provider.headers) Object.assign(headers, provider.headers);
37
50
  const runtimeProvider = provider as {
@@ -57,6 +70,11 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P
57
70
  if (override && mayForwardCallerCredentials) {
58
71
  headers.authorization = `Bearer ${override.accessToken}`;
59
72
  headers["chatgpt-account-id"] = override.chatgptAccountId;
73
+ } else if (mayForwardCallerCredentials) {
74
+ // dual_header runtime assertion: first-party Direct only, beside the caller's own Bearer.
75
+ // A pool override above never carries it, and non-canonical transports never see it.
76
+ const assertion = incoming.headers.get(AGENT_ASSERTION_HEADER);
77
+ if (assertion) headers[AGENT_ASSERTION_HEADER] = assertion;
60
78
  }
61
79
  } else {
62
80
  url = provider.responsesPath === undefined
@@ -6,6 +6,7 @@
6
6
  * responsesRequestSchema so routing/OAuth/pool/sidecars are inherited unchanged.
7
7
  */
8
8
  import { chatImageUrlFromPart } from "./image-parts";
9
+ import { signedReasoningInputItems } from "./reasoning-details";
9
10
  import { untranslatedChatInputMedia, untranslatedInputMediaMessage } from "../responses/input-media";
10
11
 
11
12
  export class ChatCompletionsRequestError extends Error {}
@@ -439,7 +440,16 @@ function resolveReasoningSummary(raw: Rec): string | undefined {
439
440
  * Translate an OpenAI Chat Completions request body into a /v1/responses request body.
440
441
  * Throws ChatCompletionsRequestError (-> 400) on malformed input.
441
442
  */
442
- export function chatCompletionsToResponsesBody(raw: unknown): Rec {
443
+ export interface ChatResponsesProjectionOptions {
444
+ /**
445
+ * The route is one first-party Anthropic destination that cannot change credential domain, so
446
+ * signed thinking a client returns in `reasoning_details` may be replayed to it (#7429).
447
+ * Every other route keeps the plaintext projection.
448
+ */
449
+ acceptAnthropicReasoningSignatures?: boolean;
450
+ }
451
+
452
+ export function chatCompletionsToResponsesBody(raw: unknown, options: ChatResponsesProjectionOptions = {}): Rec {
443
453
  assertChatCompletionsRoutingBody(raw);
444
454
  // Only the translated path reaches this function. Native Chat can retain its
445
455
  // provider-specific file/audio blocks; projecting them here would discard them.
@@ -510,7 +520,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
510
520
  // is no attachment point on the message itself, and the parser buffers a
511
521
  // reasoning item and prepends it to the NEXT assistant message. Emitting it
512
522
  // here keeps that adjacency intact.
513
- const reasoningText = assistantReasoningText(msg);
523
+ const signedReasoning = options.acceptAnthropicReasoningSignatures === true
524
+ ? signedReasoningInputItems(msg.reasoning_details)
525
+ : undefined;
526
+ const reasoningText = signedReasoning ? undefined : assistantReasoningText(msg);
527
+ // Signed entries carry their own text, so `reasoning_content` (the same text) is not
528
+ // projected a second time beside them.
529
+ if (signedReasoning) input.push(...signedReasoning);
514
530
  if (reasoningText !== undefined) {
515
531
  // `summary` is required on a reasoning input item by the OpenAI Responses API, and our
516
532
  // own responsesRequestSchema marks it optional, so a summary-less item validated locally
@@ -525,6 +541,8 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
525
541
  content: [{ type: "reasoning_text", text: reasoningText }],
526
542
  });
527
543
  }
544
+ // An Opus progress update also sits in `content`. The wire has no marker that tells it
545
+ // from answer text, so the content is replayed as sent, as it was before #7429.
528
546
  const blocks = assistantContentToBlocks(msg.content);
529
547
  if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks });
530
548
  if (msg.tool_calls !== undefined) {
@@ -23,6 +23,7 @@ import {
23
23
  } from "../lib/errors";
24
24
  import { redactSecretString } from "../lib/redact";
25
25
  import { createSseBlockBuffer, sseDataPayload } from "../server/sse-payload-rewrite";
26
+ import { reasoningDetailEntriesForItem } from "./reasoning-details";
26
27
 
27
28
  function isRec(v: unknown): v is Rec {
28
29
  return !!v && typeof v === "object" && !Array.isArray(v);
@@ -149,7 +150,7 @@ export function chunkBase(id: string, model: string, created: number): Rec {
149
150
  };
150
151
  }
151
152
 
152
- function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number {
153
+ export function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number {
153
154
  let nextBytes = previousBytes + Buffer.byteLength(fragment);
154
155
  const previousLast = previous.charCodeAt(previous.length - 1);
155
156
  const fragmentFirst = fragment.charCodeAt(0);
@@ -511,6 +512,17 @@ export function responsesSseToChatCompletionsSse(
511
512
  frame.choices = [{ index: 0, delta: { reasoning_content: text }, finish_reason: null }];
512
513
  emit(frame);
513
514
  };
515
+ // #7429: provider-issued thinking state, one complete frame per finished reasoning item.
516
+ let nextReasoningDetailIndex = 0;
517
+ const emitReasoningDetails = (item: Rec) => {
518
+ const entries = reasoningDetailEntriesForItem(item, nextReasoningDetailIndex, translatorBudget);
519
+ if (entries.length === 0) return;
520
+ nextReasoningDetailIndex += entries.length;
521
+ ensureRole();
522
+ const frame = chunkBase(id, model, created);
523
+ frame.choices = [{ index: 0, delta: { reasoning_details: entries }, finish_reason: null }];
524
+ emit(frame);
525
+ };
514
526
  const resolveFinalArguments = (candidate: unknown, streamed: string) => {
515
527
  if (typeof candidate !== "string") return streamed;
516
528
  // Some compatible providers send an empty final snapshot. Do not let that erase
@@ -717,6 +729,7 @@ export function responsesSseToChatCompletionsSse(
717
729
  && refusalItems.has(data.output_index)) {
718
730
  refusalItem(data.output_index, data, "item_id");
719
731
  }
732
+ if (item.type === "reasoning") emitReasoningDetails(item);
720
733
  if (item.type === "function_call") {
721
734
  sawToolUse = true;
722
735
  const callId = typeof item.call_id === "string" ? item.call_id : "";
@@ -872,6 +885,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
872
885
  let contentBytes = 0;
873
886
  let reasoningBytes = 0;
874
887
  const toolCalls: Rec[] = [];
888
+ const reasoningDetails: Rec[] = [];
875
889
  const append = (previous: string, previousBytes: number, fragment: string): { text: string; bytes: number } => {
876
890
  if (!fragment) return { text: previous, bytes: previousBytes };
877
891
  const scope = { kind: "retained_collectors" as const };
@@ -902,6 +916,11 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
902
916
  }
903
917
  }
904
918
  } else if (raw.type === "reasoning") {
919
+ const entries = reasoningDetailEntriesForItem(raw, reasoningDetails.length, translatorBudget);
920
+ if (entries.length > 0) {
921
+ translatorBudget?.chargeRetained(Buffer.byteLength(JSON.stringify(entries)), { kind: "retained_collectors" });
922
+ reasoningDetails.push(...entries);
923
+ }
905
924
  if (Array.isArray(raw.summary)) {
906
925
  for (const part of raw.summary) {
907
926
  if (isRec(part) && part.type === "summary_text" && typeof part.text === "string") {
@@ -948,6 +967,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
948
967
  refusal,
949
968
  };
950
969
  if (reasoning) message.reasoning_content = reasoning;
970
+ if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails;
951
971
  if (toolCalls.length > 0) message.tool_calls = toolCalls;
952
972
 
953
973
  return {
@@ -981,7 +1001,8 @@ export async function collectChatCompletion(
981
1001
  let content = "";
982
1002
  let refusal: string | null = null;
983
1003
  let reasoning = "";
984
- const retainedBytes = { content: 0, refusal: 0, reasoning: 0 };
1004
+ const reasoningDetails: Rec[] = [];
1005
+ const retainedBytes = { content: 0, refusal: 0, reasoning: 0, reasoningDetails: 0 };
985
1006
  const toolCalls = new Map<number, { id: string; name: string; arguments: string; argumentBytes: number }>();
986
1007
  // Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the
987
1008
  // wire index, which is stable across deltas and present before the call id.
@@ -1005,8 +1026,8 @@ export async function collectChatCompletion(
1005
1026
  }
1006
1027
  };
1007
1028
  const releaseCollectors = () => {
1008
- translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning,
1009
- { kind: "retained_collectors" });
1029
+ translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning
1030
+ + retainedBytes.reasoningDetails, { kind: "retained_collectors" });
1010
1031
  };
1011
1032
  try {
1012
1033
  // Share native relay framing, while retaining the collector's existing admission
@@ -1055,6 +1076,16 @@ export async function collectChatCompletion(
1055
1076
  refusal = appendRetained("refusal", refusal ?? "", delta.refusal);
1056
1077
  }
1057
1078
  if (typeof delta.reasoning_content === "string") reasoning = appendRetained("reasoning", reasoning, delta.reasoning_content);
1079
+ // Each entry arrives complete in one frame (#7429), so the fold is a concatenation.
1080
+ if (Array.isArray(delta.reasoning_details)) {
1081
+ for (const entry of delta.reasoning_details) {
1082
+ if (!isRec(entry)) continue;
1083
+ const entryBytes = Buffer.byteLength(JSON.stringify(entry));
1084
+ translatorBudget.chargeRetained(entryBytes, { kind: "retained_collectors" });
1085
+ retainedBytes.reasoningDetails += entryBytes;
1086
+ reasoningDetails.push(entry);
1087
+ }
1088
+ }
1058
1089
  if (Array.isArray(delta.tool_calls)) {
1059
1090
  for (const tc of delta.tool_calls) {
1060
1091
  if (!isRec(tc)) continue;
@@ -1121,6 +1152,7 @@ export async function collectChatCompletion(
1121
1152
  refusal,
1122
1153
  };
1123
1154
  if (reasoning) message.reasoning_content = reasoning;
1155
+ if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails;
1124
1156
  if (toolCalls.size > 0) {
1125
1157
  // Final owner transfer is itself a charging operation: if it overflows,
1126
1158
  // release every copy already charged in this loop and close every scope
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Anthropic thinking state on the Chat Completions wire, as OpenRouter-style `reasoning_details`
3
+ * (#7429).
4
+ *
5
+ * Chat has no slot for a thinking block's signature, so a Chat client echoing `reasoning_content`
6
+ * gave Claude back unsigned text, which the Anthropic adapter must drop: every tool step lost its
7
+ * reasoning. The carrier is the one OpenRouter documents for Claude and Hermes and pi already round
8
+ * trip: an ordered array on the assistant message, `{type: "reasoning.text", text, signature}`
9
+ * for a signed thinking block and `{type: "reasoning.encrypted", data}` for a redacted one.
10
+ *
11
+ * Outbound, the entries are built from the signature and redacted payloads the provider issued,
12
+ * never from anything this proxy computes. A signature that signs text the client never saw
13
+ * (hidden thinking) is withheld, because pairing it with the visible text would attest to content
14
+ * Anthropic did not sign. Inbound, the entries are read only for a single-destination first-party
15
+ * Anthropic route (the caller decides that), only when every entry is well formed, and become the
16
+ * same `ocxr1` envelope the Responses wire already uses; anything else falls back to plaintext.
17
+ */
18
+ import {
19
+ decodeReasoningEnvelope,
20
+ encodeReasoningEnvelope,
21
+ isLikelyRealAnthropicThinkingSignature,
22
+ type ReasoningEnvelope,
23
+ } from "../responses/reasoning-envelope";
24
+ import type { TranslatorBudget } from "../lib/translator-budget";
25
+
26
+ type Rec = Record<string, unknown>;
27
+
28
+ function isRec(value: unknown): value is Rec {
29
+ return value !== null && typeof value === "object" && !Array.isArray(value);
30
+ }
31
+
32
+ /** OpenRouter's `format` for Anthropic thinking entries. */
33
+ export const ANTHROPIC_REASONING_DETAIL_FORMAT = "anthropic-claude-v1";
34
+
35
+ /** One closed reasoning item, as either outbound path sees it. */
36
+ export interface ReasoningDetailSource {
37
+ /** The thinking text the client was shown for this item. */
38
+ text: string;
39
+ signature?: string;
40
+ redacted?: readonly string[];
41
+ /** The signature covers thinking text the client was not shown. */
42
+ hidden?: boolean;
43
+ }
44
+
45
+ /**
46
+ * The `reasoning_details` entries for one reasoning item, numbered from `firstIndex`. Redacted
47
+ * blocks come first: that is the order the adapter replays them in. Empty when the item carries no
48
+ * provider-issued state a Chat client can return.
49
+ */
50
+ export function reasoningDetailEntries(source: ReasoningDetailSource, firstIndex: number): Rec[] {
51
+ const entries: Rec[] = [];
52
+ for (const data of source.redacted ?? []) {
53
+ if (typeof data !== "string" || data.length === 0) continue;
54
+ entries.push({
55
+ type: "reasoning.encrypted",
56
+ data,
57
+ format: ANTHROPIC_REASONING_DETAIL_FORMAT,
58
+ index: firstIndex + entries.length,
59
+ });
60
+ }
61
+ if (source.hidden !== true && isLikelyRealAnthropicThinkingSignature(source.signature)) {
62
+ entries.push({
63
+ type: "reasoning.text",
64
+ text: source.text,
65
+ signature: source.signature,
66
+ format: ANTHROPIC_REASONING_DETAIL_FORMAT,
67
+ index: firstIndex + entries.length,
68
+ });
69
+ }
70
+ return entries;
71
+ }
72
+
73
+ /** The visible text of a Responses reasoning item, read the way the parser reads it. */
74
+ export function responsesReasoningItemText(item: Rec): string {
75
+ const pick = (parts: unknown, type: string): string => Array.isArray(parts)
76
+ ? parts.filter((part): part is Rec => isRec(part) && part.type === type && typeof part.text === "string")
77
+ .map(part => part.text as string).join("")
78
+ : "";
79
+ return pick(item.summary, "summary_text") || pick(item.content, "reasoning_text");
80
+ }
81
+
82
+ /**
83
+ * The entries for a finished Responses reasoning item from the internal bridge. Its `ocxr1`
84
+ * envelope holds what the provider issued. `txt` alone means the signed text was hidden; `txt`
85
+ * with `upd` is an Opus progress update, whose signed text the client received as `content`.
86
+ */
87
+ export function reasoningDetailEntriesForItem(item: Rec, firstIndex: number, budget?: TranslatorBudget): Rec[] {
88
+ if (typeof item.encrypted_content !== "string") return [];
89
+ const envelope = decodeReasoningEnvelope(item.encrypted_content, budget);
90
+ if (!envelope || (!envelope.sig && !envelope.red)) return [];
91
+ const progress = envelope.upd !== undefined && typeof envelope.txt === "string";
92
+ return reasoningDetailEntries({
93
+ text: progress ? envelope.txt! : responsesReasoningItemText(item),
94
+ ...(envelope.sig ? { signature: envelope.sig } : {}),
95
+ ...(envelope.red ? { redacted: envelope.red } : {}),
96
+ hidden: !progress && envelope.txt !== undefined,
97
+ }, firstIndex);
98
+ }
99
+
100
+ function reasoningInputItem(text: string, envelope: ReasoningEnvelope): Rec {
101
+ return {
102
+ type: "reasoning",
103
+ summary: text ? [{ type: "summary_text", text }] : [],
104
+ ...(text ? { content: [{ type: "reasoning_text", text }] } : {}),
105
+ encrypted_content: encodeReasoningEnvelope(envelope),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Responses reasoning input items carrying the signatures a client returned in
111
+ * `reasoning_details`, or `undefined` when the array is absent, empty or holds anything this
112
+ * carrier did not issue — an unsigned or malformed signature, a summary entry, another provider's
113
+ * encrypted format. Undefined means the caller keeps its plaintext projection unchanged.
114
+ */
115
+ export function signedReasoningInputItems(details: unknown): Rec[] | undefined {
116
+ if (!Array.isArray(details) || details.length === 0) return undefined;
117
+ const items: Rec[] = [];
118
+ let redacted: string[] = [];
119
+ for (const raw of details) {
120
+ if (!isRec(raw)) return undefined;
121
+ if (raw.type === "reasoning.encrypted") {
122
+ if (raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT || typeof raw.data !== "string" || raw.data.length === 0) {
123
+ return undefined;
124
+ }
125
+ redacted.push(raw.data);
126
+ continue;
127
+ }
128
+ if (raw.type !== "reasoning.text") return undefined;
129
+ if (raw.format !== undefined && raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT) return undefined;
130
+ const signature = typeof raw.signature === "string" ? raw.signature : undefined;
131
+ if (typeof raw.text !== "string" || !isLikelyRealAnthropicThinkingSignature(signature)) return undefined;
132
+ items.push(reasoningInputItem(raw.text, { sig: signature, ...(redacted.length > 0 ? { red: redacted } : {}) }));
133
+ redacted = [];
134
+ }
135
+ if (redacted.length > 0) items.push(reasoningInputItem("", { red: redacted }));
136
+ return items;
137
+ }
@@ -1,4 +1,4 @@
1
- import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs";
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths";
@@ -44,6 +44,20 @@ export interface Desktop3pMetadata {
44
44
 
45
45
  export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker";
46
46
 
47
+ export const MAX_DESKTOP_PROFILE_FILE_BYTES = 4 * 1024 * 1024;
48
+
49
+ export function assertDesktopLibraryTextSize(text: string): void {
50
+ if (Buffer.byteLength(text, "utf8") > MAX_DESKTOP_PROFILE_FILE_BYTES) {
51
+ throw new Error("desktop_config_too_large");
52
+ }
53
+ }
54
+
55
+ export function serializeDesktopLibraryJson(value: unknown): string {
56
+ const text = JSON.stringify(value, null, 2) + "\n";
57
+ assertDesktopLibraryTextSize(text);
58
+ return text;
59
+ }
60
+
47
61
  /** A path that no longer names the observed file was swapped; a same-path failure keeps its own errno. */
48
62
  function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolean {
49
63
  try {
@@ -54,7 +68,13 @@ function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolea
54
68
  }
55
69
 
56
70
  /** Read regular config files only; a path swap must not turn an observation into a FIFO wait. */
57
- export function readDesktopLibraryBytes(path: string): Buffer {
71
+ export function readDesktopLibraryBytes(
72
+ path: string,
73
+ maxBytes: number = MAX_DESKTOP_PROFILE_FILE_BYTES,
74
+ ): Buffer {
75
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_DESKTOP_PROFILE_FILE_BYTES) {
76
+ throw new RangeError("invalid Desktop library read limit");
77
+ }
58
78
  const expected = lstatSync(path, { bigint: true });
59
79
  if (!expected.isFile()) throw new Error("desktop_config_not_regular");
60
80
  // POSIX open of a FIFO can itself block, before fstat is possible. Windows has no POSIX
@@ -73,7 +93,27 @@ export function readDesktopLibraryBytes(path: string): Buffer {
73
93
  const opened = fstatSync(fd, { bigint: true });
74
94
  if (!opened.isFile() || opened.dev !== expected.dev || opened.ino !== expected.ino
75
95
  || opened.ctimeNs !== expected.ctimeNs) throw new Error("desktop_config_not_regular");
76
- return readFileSync(fd);
96
+ if (opened.size > BigInt(maxBytes)) throw new Error("desktop_config_too_large");
97
+ // Allocate for the observed size plus one sentinel byte, not the full cap: callers scan many
98
+ // small profiles, and the cap would zero-fill megabytes per read. A file that fills the
99
+ // buffer grew after validation — keep reading within the bound so it classifies as
100
+ // desktop_config_too_large rather than silently truncating.
101
+ let bytes = Buffer.alloc(Number(opened.size) + 1);
102
+ let length = 0;
103
+ for (;;) {
104
+ const count = readSync(fd, bytes, length, Math.min(64 * 1024, bytes.length - length), null);
105
+ if (count === 0) break;
106
+ length += count;
107
+ if (length > maxBytes) throw new Error("desktop_config_too_large");
108
+ if (length === bytes.length && bytes.length <= maxBytes) {
109
+ const grown = Buffer.alloc(Math.min(maxBytes + 1, bytes.length * 2));
110
+ bytes.copy(grown, 0, 0, length);
111
+ bytes = grown;
112
+ }
113
+ }
114
+ if (length > maxBytes) throw new Error("desktop_config_too_large");
115
+ if (!pathStillNamesExpectedFile(path, opened)) throw new Error("desktop_config_not_regular");
116
+ return bytes.subarray(0, length);
77
117
  } finally {
78
118
  closeSync(fd);
79
119
  }