@bitkyc08/opencodex 2.65.0 → 2.66.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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-DyHlFeNB.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-CI8cVKWu.js} +1 -1
  5. package/gui/dist/assets/index-BiwnVAUm.js +86 -0
  6. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-_WyYzbMt.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -31,7 +31,7 @@ function uuid(): string {
31
31
  return crypto.randomUUID().replace(/-/g, "");
32
32
  }
33
33
 
34
- function completionId(): string {
34
+ export function completionId(): string {
35
35
  return `chatcmpl-${uuid().slice(0, 24)}`;
36
36
  }
37
37
 
@@ -133,12 +133,12 @@ function streamErrorStatus(message: string): number {
133
133
  return 502;
134
134
  }
135
135
 
136
- function dataFrame(payload: Rec | "[DONE]"): string {
136
+ export function dataFrame(payload: Rec | "[DONE]"): string {
137
137
  if (payload === "[DONE]") return "data: [DONE]\n\n";
138
138
  return `data: ${JSON.stringify(payload)}\n\n`;
139
139
  }
140
140
 
141
- function chunkBase(id: string, model: string, created: number): Rec {
141
+ export function chunkBase(id: string, model: string, created: number): Rec {
142
142
  return {
143
143
  id,
144
144
  object: "chat.completion.chunk",
@@ -161,6 +161,90 @@ function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: st
161
161
  return nextBytes;
162
162
  }
163
163
 
164
+ /** Details a streamed Chat failure carries into its error frame. */
165
+ export interface ChatCompletionsStreamFailure {
166
+ code?: string | null;
167
+ type?: string;
168
+ status?: number;
169
+ }
170
+
171
+ /**
172
+ * The `data: {error}` payload a streamed Chat failure ends with, and whether it is one of the
173
+ * fixed, bounded failures that must be delivered even when the translation budget is exhausted.
174
+ * Provider text is redacted; the two fixed failures carry no provider text at all.
175
+ */
176
+ export function chatCompletionsStreamErrorPayload(
177
+ message: string,
178
+ details?: ChatCompletionsStreamFailure,
179
+ ): { payload: Rec; bounded: boolean } {
180
+ const translatorOverflow = details?.code === "translation_buffer_limit";
181
+ const safeMessage = translatorOverflow ? "upstream translation buffer exceeded the safe limit"
182
+ : details?.code === "invalid_refusal" ? "upstream refusal representations are inconsistent"
183
+ : redactSecretString(message);
184
+ const statusHint = details?.status ?? streamErrorStatus(safeMessage);
185
+ const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage);
186
+ if (translatorOverflow) {
187
+ classified.code = "translation_buffer_limit";
188
+ // Provider-controlled overflow is an upstream failure on every path:
189
+ // streaming frame, collector, and defensive JSON agree on 502.
190
+ classified.type = "upstream_error";
191
+ } else if (details?.code === "invalid_refusal") {
192
+ classified.code = details.code;
193
+ classified.type = "upstream_error";
194
+ } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
195
+ classified.code = CYBER_POLICY_ERROR_CODE;
196
+ classified.type = cyberPolicyErrorType(details?.type);
197
+ } else if (details?.code !== undefined && details.code !== null && !classified.code) {
198
+ classified.code = details.code;
199
+ }
200
+ return {
201
+ payload: {
202
+ error: {
203
+ message: classified.message,
204
+ type: classified.type,
205
+ param: null,
206
+ code: classified.code,
207
+ },
208
+ },
209
+ bounded: translatorOverflow || details?.code === "invalid_refusal",
210
+ };
211
+ }
212
+
213
+ /** How a Responses `response.failed` error object becomes a streamed Chat failure. */
214
+ export function chatCompletionsFailedResponse(error: Rec): { message: string; details: ChatCompletionsStreamFailure } {
215
+ const message = typeof error.message === "string" ? error.message : "upstream request failed";
216
+ const code = typeof error.code === "string" ? error.code : null;
217
+ const type = typeof error.type === "string" ? error.type : undefined;
218
+ return {
219
+ message,
220
+ details: {
221
+ code,
222
+ ...(code === "translation_buffer_limit"
223
+ ? { status: 502, type: "upstream_error" }
224
+ : { type, ...(code === CYBER_POLICY_ERROR_CODE ? { status: 400 } : {}) }),
225
+ },
226
+ };
227
+ }
228
+
229
+ /**
230
+ * A Responses incomplete reason as the Chat client sees it: a truthful early finish for an
231
+ * output cap or a content filter, and a failure for every other reason (stall, adapter EOF,
232
+ * proxy-synthesized incompletes), which must never look like a clean stop.
233
+ */
234
+ export function chatCompletionsIncompleteOutcome(
235
+ reason: unknown,
236
+ message: unknown,
237
+ ): { finishReason: "length" | "content_filter" } | { failMessage: string } {
238
+ if (reason === "max_output_tokens") return { finishReason: "length" };
239
+ if (reason === "content_filter") return { finishReason: "content_filter" };
240
+ const why = typeof reason === "string" ? reason : "unknown";
241
+ return {
242
+ failMessage: typeof message === "string" && message.length > 0
243
+ ? message
244
+ : `upstream stream ended early (${why})`,
245
+ };
246
+ }
247
+
164
248
  function refusalTranslationError(): ChatCompletionsStreamError {
165
249
  // Never include provider-controlled refusal text or correlation IDs in diagnostics.
166
250
  return new ChatCompletionsStreamError("upstream refusal representations are inconsistent", {
@@ -506,38 +590,12 @@ export function responsesSseToChatCompletionsSse(
506
590
  // Deliver the error frame then close the stream abnormally (no [DONE]).
507
591
  // Do not controller.error() — that can drop already-enqueued bytes from consumers
508
592
  // like response.text().
509
- const translatorOverflow = details?.code === "translation_buffer_limit";
510
- const safeMessage = translatorOverflow ? "upstream translation buffer exceeded the safe limit"
511
- : details?.code === "invalid_refusal" ? "upstream refusal representations are inconsistent"
512
- : redactSecretString(message);
513
- const statusHint = details?.status ?? streamErrorStatus(safeMessage);
514
- const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage);
515
- if (translatorOverflow) {
516
- classified.code = "translation_buffer_limit";
517
- // Provider-controlled overflow is an upstream failure on every path:
518
- // streaming frame, collector, and defensive JSON agree on 502.
519
- classified.type = "upstream_error";
520
- } else if (details?.code === "invalid_refusal") {
521
- classified.code = details.code;
522
- classified.type = "upstream_error";
523
- } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
524
- classified.code = CYBER_POLICY_ERROR_CODE;
525
- classified.type = cyberPolicyErrorType(details?.type);
526
- } else if (details?.code !== undefined && details.code !== null && !classified.code) {
527
- classified.code = details.code;
528
- }
593
+ const { payload, bounded } = chatCompletionsStreamErrorPayload(message, details);
529
594
  try {
530
- const frame = encoder.encode(dataFrame({
531
- error: {
532
- message: classified.message,
533
- type: classified.type,
534
- param: null,
535
- code: classified.code,
536
- },
537
- }));
595
+ const frame = encoder.encode(dataFrame(payload));
538
596
  // These fixed, bounded failures must survive even when decoder-owned input
539
597
  // still fills the budget. They contain no provider text or IDs.
540
- if (translatorOverflow || details?.code === "invalid_refusal") controller.enqueue(frame);
598
+ if (bounded) controller.enqueue(frame);
541
599
  else enqueueLiveFrame(frame);
542
600
  emittedFrames++;
543
601
  } catch {
@@ -552,8 +610,16 @@ export function responsesSseToChatCompletionsSse(
552
610
  }
553
611
  switch (eventName) {
554
612
  case "response.created":
613
+ ensureRole();
614
+ break;
555
615
  case "response.heartbeat":
556
616
  ensureRole();
617
+ // A typed Responses heartbeat carries transport liveness, not Chat content. Preserve
618
+ // that signal as an SSE comment so idle-sensitive Chat clients receive bytes without
619
+ // inventing a semantic chunk that parsers, usage counters, or progress watchdogs could
620
+ // mistake for model output.
621
+ enqueueLiveFrame(encoder.encode(": opencodex heartbeat\n\n"));
622
+ emittedFrames++;
557
623
  break;
558
624
  case "response.output_text.delta": {
559
625
  if (typeof data.delta === "string") emitContent(data.delta);
@@ -680,37 +746,23 @@ export function responsesSseToChatCompletionsSse(
680
746
  case "response.incomplete": {
681
747
  const response = isRec(data.response) ? data.response : {};
682
748
  const details = isRec(response.incomplete_details) ? response.incomplete_details : {};
683
- const reason = details.reason === "max_output_tokens" ? "length"
684
- : details.reason === "content_filter" ? "content_filter"
685
- : undefined;
686
- if (reason !== undefined) {
749
+ const outcome = chatCompletionsIncompleteOutcome(details.reason, details.message);
750
+ if ("finishReason" in outcome) {
687
751
  // Truthful OpenAI-compatible finish reasons: the turn ended, just early.
688
752
  snapshotRefusals(response);
689
- finish(reason, response.usage);
753
+ finish(outcome.finishReason, response.usage);
690
754
  } else {
691
755
  // upstream_stall_timeout / adapter_eof / proxy-synthesized incompletes are
692
756
  // failures, not early finishes: emit an error frame and close WITHOUT
693
757
  // [DONE] instead of a success-looking stop/tool_calls + [DONE].
694
- const why = typeof details.reason === "string" ? details.reason : "unknown";
695
- const message = typeof details.message === "string" && details.message.length > 0
696
- ? details.message
697
- : `upstream stream ended early (${why})`;
698
- fail(message);
758
+ fail(outcome.failMessage);
699
759
  }
700
760
  break;
701
761
  }
702
762
  case "response.failed": {
703
763
  const response = isRec(data.response) ? data.response : {};
704
- const error = isRec(response.error) ? response.error : {};
705
- const message = typeof error.message === "string" ? error.message : "upstream request failed";
706
- const code = typeof error.code === "string" ? error.code : null;
707
- const type = typeof error.type === "string" ? error.type : undefined;
708
- fail(message, {
709
- code,
710
- ...(code === "translation_buffer_limit"
711
- ? { status: 502, type: "upstream_error" }
712
- : { type, ...(code === CYBER_POLICY_ERROR_CODE ? { status: 400 } : {}) }),
713
- });
764
+ const failure = chatCompletionsFailedResponse(isRec(response.error) ? response.error : {});
765
+ fail(failure.message, failure.details);
714
766
  break;
715
767
  }
716
768
  default:
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The one way a management route installs a new `claudeCode` block on the live config.
3
+ *
4
+ * Every writer stamps the auth-mode migration sentinel. `runClaudeAuthModeMigration` reads a
5
+ * block with no `authMode` and no sentinel as a pre-upgrade subscriber and pins it to literal
6
+ * subscription, so a route that CREATES the block (a bare `{ enabled }` toggle) without the
7
+ * sentinel silently converts an Auto user on the next start. Persisting stays with the caller,
8
+ * through `saveConfigPreservingClaudeCode`, whose baseline guard keeps a concurrent hand edit
9
+ * of the block unless this process changed it too.
10
+ */
11
+ import type { OcxClaudeCodeConfig, OcxConfig } from "../types";
12
+
13
+ export function commitClaudeCodeBlock(config: OcxConfig, next: OcxClaudeCodeConfig, now = new Date()): void {
14
+ if (!next.authModeMigratedAt) next.authModeMigratedAt = now.toISOString();
15
+ config.claudeCode = next;
16
+ }
@@ -7,14 +7,26 @@
7
7
  * the legacy claude-ocx-* spelling a saved selector may still carry —
8
8
  * with first-wins dedupe (mirrors the desktop3p registry collision policy).
9
9
  * Values are authoritative context windows only (native override table /
10
- * adapter-reported CatalogModel.contextWindow); nothing is guessed.
10
+ * adapter-reported CatalogModel.contextWindow / the Anthropic registry for passthrough ids);
11
+ * nothing is guessed.
11
12
  */
12
13
  import { aliasForNative, aliasForRoute, currentClaudeAliasSpelling, legacyAliasForNative, legacyAliasForRoute } from "./alias";
13
14
  import { desktop3pAlias } from "./desktop-3p";
14
15
  import { nativeOpenAiContextWindow, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog";
16
+ import { ANTHROPIC_MODEL_CONTEXT_WINDOWS } from "../providers/registry/model-seeds";
17
+ import type { OcxClaudeCodeConfig } from "../types";
15
18
 
16
19
  const ONE_MILLION = 1_000_000;
17
20
 
21
+ /**
22
+ * The native id each Claude Code tier alias resolves to (2.1.282: `--model opus` sends
23
+ * `claude-opus-5-5`, `sonnet` sends `claude-sonnet-5`, `fable` sends `claude-fable-5-1`).
24
+ * Behind a gateway Claude Code accounts an unmarked id at 200k, so an unset tier slot is filled
25
+ * with this id when the map marks it (#5755). A marked alias cannot stand in: Claude Code sends
26
+ * `sonnet[1m]` upstream unresolved. Haiku has no entry; its 200k window never marks.
27
+ */
28
+ const CLAUDE_CODE_NATIVE_TIERS = { opus: "claude-opus-5-5", sonnet: "claude-sonnet-5", fable: "claude-fable-5-1" } as const;
29
+
18
30
  /**
19
31
  * Auto-context defaults (devlog 260712 020, user-approved).
20
32
  *
@@ -124,6 +136,27 @@ export function shouldMarkOneMillion(window: number | undefined, auto: AutoConte
124
136
  return auto.enabled && window > AUTO_CONTEXT_FLOOR && window >= auto.compactWindow;
125
137
  }
126
138
 
139
+ /** Present when bare Claude ids take the native passthrough on this launch (#5755). */
140
+ export interface NativeClaudePassthrough {
141
+ /** `claudeCode.modelMap`: a mapped id is routed, not passed through. */
142
+ modelMap?: Readonly<Record<string, string>>;
143
+ }
144
+
145
+ /**
146
+ * On a local subscription launch with `nativePassthrough` on, a bare `claude-*` id goes straight
147
+ * to Anthropic under Claude Code's own login and never reaches the router
148
+ * (server/claude-messages.ts `wantsNativePassthrough`). Undefined when the router decides:
149
+ * proxy auth, passthrough off, or a connected client, which authenticates with an admission token.
150
+ */
151
+ export function nativeClaudePassthroughFor(
152
+ claudeCode: OcxClaudeCodeConfig | undefined,
153
+ markerMode: "proxy" | "subscription",
154
+ ): NativeClaudePassthrough | undefined {
155
+ return markerMode === "subscription" && claudeCode?.nativePassthrough !== false
156
+ ? { modelMap: claudeCode?.modelMap }
157
+ : undefined;
158
+ }
159
+
127
160
  export function buildClaudeContextWindows(
128
161
  nativeSlugs: readonly string[],
129
162
  routedModels: readonly CatalogModel[],
@@ -131,6 +164,7 @@ export function buildClaudeContextWindows(
131
164
  // it the Claude surface keeps advertising the uncapped authoritative window while the
132
165
  // Codex catalog advertises the capped one, and the two disagree about the same model.
133
166
  nativeContextCap?: NativeContextLimitsInput,
167
+ nativeClaude?: NativeClaudePassthrough,
134
168
  ): Record<string, number> {
135
169
  const out: Record<string, number> = {};
136
170
  const put = (key: string | null, value: number) => {
@@ -145,6 +179,16 @@ export function buildClaudeContextWindows(
145
179
  put(aliasForNative(slug), window);
146
180
  put(legacyAliasForNative(slug), window);
147
181
  }
182
+ // Passthrough ids never reach the router, so the registry speaks for them ahead of any routed
183
+ // row that shares the bare id (#5755). An anthropic row capped under 1M still keeps its id
184
+ // unmarked, and a modelMap entry routes its id elsewhere.
185
+ if (nativeClaude) {
186
+ const capped = new Set(routedModels.flatMap(m =>
187
+ m.provider === "anthropic" && typeof m.contextWindow === "number" && m.contextWindow < ONE_MILLION ? [m.id] : []));
188
+ for (const [id, window] of Object.entries(ANTHROPIC_MODEL_CONTEXT_WINDOWS)) {
189
+ if (window >= ONE_MILLION && !capped.has(id) && !nativeClaude.modelMap?.[id]) put(id, window);
190
+ }
191
+ }
148
192
  // Anthropic passthrough guard (audit 021 #3): canonical claude ids ride the
149
193
  // subscription passthrough — marking a sub-1M one would strap [1m]/1M-beta onto
150
194
  // a model that cannot host it. Register anthropic rows only at >=1M.
@@ -217,10 +261,14 @@ export function effectiveModelEnv(
217
261
  const marked = withOneMillionMarker(value === undefined ? undefined : currentClaudeAliasSpelling(value), windows, auto);
218
262
  if (marked) out[name] = marked;
219
263
  };
264
+ // An unset tier slot is filled only when its native id marks; otherwise Claude Code keeps
265
+ // choosing, as before.
266
+ const tier = (configured: string | undefined, native: string) =>
267
+ configured ?? (shouldMarkOneMillion(windows[native], auto) ? native : undefined);
220
268
  set("ANTHROPIC_MODEL", claudeCode?.model);
221
- set("ANTHROPIC_DEFAULT_OPUS_MODEL", claudeCode?.tierModels?.opus);
222
- set("ANTHROPIC_DEFAULT_SONNET_MODEL", claudeCode?.tierModels?.sonnet);
223
- set("ANTHROPIC_DEFAULT_FABLE_MODEL", claudeCode?.tierModels?.fable);
269
+ set("ANTHROPIC_DEFAULT_OPUS_MODEL", tier(claudeCode?.tierModels?.opus, CLAUDE_CODE_NATIVE_TIERS.opus));
270
+ set("ANTHROPIC_DEFAULT_SONNET_MODEL", tier(claudeCode?.tierModels?.sonnet, CLAUDE_CODE_NATIVE_TIERS.sonnet));
271
+ set("ANTHROPIC_DEFAULT_FABLE_MODEL", tier(claudeCode?.tierModels?.fable, CLAUDE_CODE_NATIVE_TIERS.fable));
224
272
  const effectiveHaiku = claudeCode?.tierModels?.haiku ?? claudeCode?.smallFastModel;
225
273
  set("ANTHROPIC_DEFAULT_HAIKU_MODEL", effectiveHaiku);
226
274
  set("ANTHROPIC_SMALL_FAST_MODEL", effectiveHaiku);
@@ -24,6 +24,7 @@ import type { OcxConfig } from "../types";
24
24
  import { inspectDesktop3pConfigLibrary } from "./desktop-3p";
25
25
  import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca";
26
26
  import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime";
27
+ import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./intercept/proxy-auth";
27
28
  import {
28
29
  applyClaudeInterceptSettings,
29
30
  buildClaudeInterceptEnv,
@@ -115,14 +116,22 @@ export interface DesktopFirstPartyTarget {
115
116
  env: ClaudeInterceptEnv;
116
117
  }
117
118
 
118
- /** The settings env a first-party apply on this machine writes (CA is created on demand). */
119
- export function desktopFirstPartyTarget(
119
+ function firstPartyTarget(
120
120
  config: Pick<OcxConfig, "claudeCode" | "port">,
121
- opencodexConfigDir = getConfigDir(),
121
+ opencodexConfigDir: string,
122
+ authToken: string,
122
123
  ): DesktopFirstPartyTarget {
123
124
  const proxyPort = claudeInterceptProxyPort(config, config.port ?? 10100);
124
125
  const caCertPath = claudeInterceptCaCertPath(opencodexConfigDir);
125
- return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath) };
126
+ return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath, authToken) };
127
+ }
128
+
129
+ /** The settings env a first-party apply on this machine writes (CA and token are created on demand). */
130
+ export function desktopFirstPartyTarget(
131
+ config: Pick<OcxConfig, "claudeCode" | "port">,
132
+ opencodexConfigDir = getConfigDir(),
133
+ ): DesktopFirstPartyTarget {
134
+ return firstPartyTarget(config, opencodexConfigDir, ensureClaudeInterceptProxyToken(opencodexConfigDir));
126
135
  }
127
136
 
128
137
  export interface DesktopFirstPartyInspection {
@@ -147,8 +156,11 @@ export function captureDesktopFirstPartyRollback(
147
156
  config: Pick<OcxConfig, "claudeCode" | "port">,
148
157
  options: DesktopFirstPartyOptions = {},
149
158
  ): () => boolean {
159
+ // The expected env resolves at rollback time: the apply mints the proxy token, so capturing it
160
+ // here would both mint on a path that may never apply and throw when the intercept directory is
161
+ // unavailable — a failure the apply already reports as ca_unavailable.
150
162
  return captureClaudeInterceptSettingsRollback(
151
- desktopFirstPartyTarget(config, options.opencodexConfigDir).env, options.claudeConfigDir,
163
+ () => desktopFirstPartyTarget(config, options.opencodexConfigDir).env, options.claudeConfigDir,
152
164
  );
153
165
  }
154
166
 
@@ -156,7 +168,11 @@ export function inspectDesktopFirstParty(
156
168
  config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole">,
157
169
  options: DesktopFirstPartyOptions = {},
158
170
  ): DesktopFirstPartyInspection {
159
- const target = desktopFirstPartyTarget(config, options.opencodexConfigDir);
171
+ const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir();
172
+ // Inspection is read-only: a missing token means no apply or runtime start produced one,
173
+ // so an owned env can never match the empty credential — it classifies stale, and a real
174
+ // apply is what refreshes it.
175
+ const target = firstPartyTarget(config, opencodexConfigDir, readClaudeInterceptProxyToken(opencodexConfigDir) ?? "");
160
176
  const settings = inspectClaudeInterceptSettings(target.env, options.claudeConfigDir);
161
177
  return {
162
178
  interceptEnabled: claudeInterceptEnabled(config),
@@ -205,12 +221,13 @@ export function applyDesktopFirstParty(
205
221
  options: DesktopFirstPartyOptions = {},
206
222
  ): DesktopFirstPartyApplyResult {
207
223
  const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir();
208
- const target = desktopFirstPartyTarget(config, opencodexConfigDir);
209
224
  if (!claudeInterceptEnabled(config)) return { ok: false, reason: "intercept_disabled", path: "" };
225
+ let target: DesktopFirstPartyTarget;
210
226
  try {
211
227
  ensureLocalInterceptCa(opencodexConfigDir);
228
+ target = desktopFirstPartyTarget(config, opencodexConfigDir);
212
229
  } catch {
213
- return { ok: false, reason: "ca_unavailable", path: target.caCertPath };
230
+ return { ok: false, reason: "ca_unavailable", path: claudeInterceptCaCertPath(opencodexConfigDir) };
214
231
  }
215
232
  const written = applyClaudeInterceptSettings(target.env, options.claudeConfigDir);
216
233
  if (!written.ok) return { ok: false, reason: written.reason, path: written.path };
@@ -177,6 +177,28 @@ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly str
177
177
  return ids;
178
178
  }
179
179
 
180
+ /**
181
+ * Whether translating this Messages body would elide a blocked skill bundle: a user text block
182
+ * `maybeElideSkillText` would stub, or a tool_result answering a blocked Skill call. Pure; the
183
+ * managed native Messages lane asks it so a request whose bundle the operator blocked keeps the
184
+ * translated path that applies the block.
185
+ */
186
+ export function anthropicBodyElidesBlockedSkill(body: unknown, cc?: Pick<OcxClaudeCodeConfig, "blockedSkills">): boolean {
187
+ if (!isRec(body) || !Array.isArray(body.messages)) return false;
188
+ const names = effectiveBlockedSkillNames(cc);
189
+ if (names.length === 0) return false;
190
+ const callIds = blockedSkillCallIds(body.messages, names);
191
+ for (const msg of body.messages) {
192
+ if (!isRec(msg) || msg.role !== "user" || !Array.isArray(msg.content)) continue;
193
+ for (const block of msg.content) {
194
+ if (!isRec(block)) continue;
195
+ if (block.type === "text" && typeof block.text === "string" && maybeElideSkillText(block.text, names) !== block.text) return true;
196
+ if (block.type === "tool_result" && typeof block.tool_use_id === "string" && callIds.has(block.tool_use_id)) return true;
197
+ }
198
+ }
199
+ return false;
200
+ }
201
+
180
202
  /**
181
203
  * Claude Code (observed 2026-07-11, real CLI smoke) sends `role:"system"` entries in
182
204
  * `messages` despite the published API having no system role. They are emitted as
@@ -1,3 +1,4 @@
1
+ import { timingSafeEqual } from "node:crypto";
1
2
  import { BlockList, createServer, connect, isIP, type Server, type Socket } from "node:net";
2
3
 
3
4
  /**
@@ -23,6 +24,12 @@ const UPSTREAM_CONNECT_TIMEOUT_MS = 15_000;
23
24
  export interface ConnectProxyOptions {
24
25
  /** Loopback port of the TLS listener that terminates intercepted tunnels. */
25
26
  interceptPort: number;
27
+ /**
28
+ * Per-install bearer carried as HTTP Basic proxy credentials. Omit only for listeners whose
29
+ * clients cannot present proxy credentials at all; an unauthenticated CONNECT proxy stays an
30
+ * open loopback relay, so every consumer that can carry the credential must set this.
31
+ */
32
+ authToken?: string | (() => string | null);
26
33
  /** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */
27
34
  interceptHosts?: readonly string[];
28
35
  /** Per-connection override, consulted before interceptHosts; null keeps the default. */
@@ -60,7 +67,7 @@ function connectRequestInfo(head: string): ConnectRequestInfo {
60
67
  }
61
68
 
62
69
  type ResolvedConnectProxyOptions = Required<Pick<ConnectProxyOptions, "interceptPort" | "interceptHosts" | "dialUpstream">>
63
- & Pick<ConnectProxyOptions, "selectTunnel">;
70
+ & Pick<ConnectProxyOptions, "selectTunnel" | "authToken">;
64
71
 
65
72
  export interface ConnectProxyHandle {
66
73
  port: number;
@@ -103,9 +110,10 @@ export function isLoopbackTarget(host: string): boolean {
103
110
  return /^(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+))*$/.test(host);
104
111
  }
105
112
 
106
- function respond(socket: Socket, status: number, reason: string): void {
113
+ function respond(socket: Socket, status: number, reason: string, headers: Readonly<Record<string, string>> = {}): void {
107
114
  if (socket.destroyed) return;
108
- socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
115
+ const extra = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join("");
116
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
109
117
  }
110
118
 
111
119
  function splice(client: Socket, upstream: Socket, pending: Uint8Array): void {
@@ -125,6 +133,20 @@ function splice(client: Socket, upstream: Socket, pending: Uint8Array): void {
125
133
  upstream.pipe(client);
126
134
  }
127
135
 
136
+ function proxyAuthorized(head: string, token: string): boolean {
137
+ const header = head.split("\r\n").find(line => /^proxy-authorization:/i.test(line));
138
+ const supplied = header?.slice(header.indexOf(":") + 1).trim();
139
+ const expected = `Basic ${Buffer.from(`opencodex:${token}`).toString("base64")}`;
140
+ if (!supplied) return false;
141
+ // Compare byte lengths, not string lengths: the head decodes latin1 and Buffer.from
142
+ // re-encodes utf-8, so a non-ASCII header can match in characters while differing in
143
+ // bytes — and timingSafeEqual throws on a length mismatch instead of returning false.
144
+ const suppliedBytes = Buffer.from(supplied);
145
+ const expectedBytes = Buffer.from(expected);
146
+ if (suppliedBytes.length !== expectedBytes.length) return false;
147
+ return timingSafeEqual(suppliedBytes, expectedBytes);
148
+ }
149
+
128
150
  function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions): void {
129
151
  let head: Buffer = Buffer.alloc(0);
130
152
  socket.on("error", () => socket.destroy());
@@ -143,13 +165,23 @@ function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions):
143
165
  }
144
166
  socket.off("data", onData);
145
167
  socket.pause();
146
- const target = parseConnectRequestLine(head.subarray(0, end).toString("latin1"));
168
+ const requestHead = head.subarray(0, end).toString("latin1");
169
+ const target = parseConnectRequestLine(requestHead);
147
170
  // Bytes after the head belong to the tunnel (a client may pipeline its TLS ClientHello).
148
171
  let pending = head.subarray(end + 4);
149
172
  if (!target) {
150
173
  respond(socket, 405, "Method Not Allowed");
151
174
  return;
152
175
  }
176
+ if (options.authToken !== undefined) {
177
+ let token: string | null = null;
178
+ try { token = typeof options.authToken === "function" ? options.authToken() : options.authToken; }
179
+ catch { /* unavailable credential must never fall back to an unauthenticated proxy */ }
180
+ if (!token || !proxyAuthorized(requestHead, token)) {
181
+ respond(socket, 407, "Proxy Authentication Required", { "Proxy-Authenticate": 'Basic realm="OpenCodex"' });
182
+ return;
183
+ }
184
+ }
153
185
  if (isLoopbackTarget(target.host)) {
154
186
  respond(socket, 403, "Forbidden");
155
187
  return;
@@ -228,6 +260,7 @@ function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions):
228
260
  export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise<ConnectProxyHandle> {
229
261
  const resolved: ResolvedConnectProxyOptions = {
230
262
  interceptPort: options.interceptPort,
263
+ authToken: options.authToken,
231
264
  interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS,
232
265
  selectTunnel: options.selectTunnel,
233
266
  dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })),