@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
package/src/lib/debug.ts CHANGED
@@ -52,3 +52,43 @@ export function debugProviderDiagnosticLazy(
52
52
  /* diagnostics must never affect request handling */
53
53
  }
54
54
  }
55
+
56
+ /**
57
+ * One line per finalized attempt, formatted from what the recorder already counted.
58
+ *
59
+ * #3983 wanted this visibility and emitted a line per stream event to get it. Two things made
60
+ * that the wrong shape. It is a second record: `emitDebugLine` writes the ring AND stderr, and
61
+ * a service manager redirects stderr to a file, so an installed service accumulates a per-event
62
+ * history beside the ledger with its own retention and sequencing. And per-event lines needed a
63
+ * per-payload fingerprint to correlate, which under a process-global key makes every repeated
64
+ * prompt fragment and tool name correlatable for the life of the process.
65
+ *
66
+ * So this writes the ring ONLY -- `appendDebugLogLine` directly, never `emitDebugLine` -- and
67
+ * says nothing the ledger does not already hold. The ring becomes a live view of the durable
68
+ * record rather than a parallel source for it.
69
+ */
70
+ export function debugAttemptDeliverySummary(
71
+ requestId: string,
72
+ attempt: {
73
+ ordinal: number;
74
+ adapter: string;
75
+ deliverySummary?: {
76
+ adapterEvents: number;
77
+ relayedEvents: number;
78
+ semanticBytes: number;
79
+ sideEffectEvents: number;
80
+ terminalEvents: number;
81
+ };
82
+ },
83
+ ): void {
84
+ if (!isDebugEnabled() || !attempt.deliverySummary) return;
85
+ try {
86
+ appendDebugLogLine(`[ocx:${attempt.adapter}:delivery] ${JSON.stringify({
87
+ requestId,
88
+ ordinal: attempt.ordinal,
89
+ ...attempt.deliverySummary,
90
+ })}`);
91
+ } catch {
92
+ /* diagnostics must never affect request handling */
93
+ }
94
+ }
@@ -8,8 +8,33 @@ function windowsRundll32(): string {
8
8
  return existsSync(candidate) ? candidate : "rundll32";
9
9
  }
10
10
 
11
- export function openUrl(url: string): void {
12
- if (!/^https?:\/\//i.test(url)) return;
11
+ /**
12
+ * Whether the OS launcher actually started. `started` does not prove a browser rendered the
13
+ * page — nothing observable from here can — but it does separate "we handed the URL off" from
14
+ * "there was nothing to hand it to", which is the distinction a caller needs (#5261).
15
+ */
16
+ export type OpenUrlResult =
17
+ | { status: "started" }
18
+ | { status: "failed"; reason: "invalid-url" | "spawn-error" | "launcher-exit" };
19
+
20
+ /**
21
+ * How long to watch a launcher that did spawn before calling it started.
22
+ *
23
+ * `spawn` only proves a process began. `xdg-open` with no desktop handler, and rundll32 given a
24
+ * broken association, both spawn happily and exit nonzero a moment later without opening
25
+ * anything — so resolving on `spawn` alone would report a launch that did not happen. Those
26
+ * failures are immediate, and the only caller that awaits this is a login start, so a short
27
+ * window buys a true answer cheaply. A launcher still running when it elapses has started.
28
+ */
29
+ const LAUNCHER_SETTLE_MS = 400;
30
+
31
+ /**
32
+ * Never rejects. A browser that would not open is an inconvenience, not a login failure: the
33
+ * URL is still a valid thing to open by hand, so the caller decides what to say about it.
34
+ * Callers that genuinely do not care use `void openUrl(...)`.
35
+ */
36
+ export function openUrl(url: string): Promise<OpenUrlResult> {
37
+ if (!/^https?:\/\//i.test(url)) return Promise.resolve({ status: "failed", reason: "invalid-url" });
13
38
  const cmd =
14
39
  process.platform === "darwin" ? "open"
15
40
  : process.platform === "win32" ? windowsRundll32()
@@ -17,9 +42,28 @@ export function openUrl(url: string): void {
17
42
  const args = process.platform === "win32"
18
43
  ? ["url.dll,FileProtocolHandler", url]
19
44
  : [url];
20
- const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false });
21
- // Headless hosts (no xdg-open) emit ENOENT as an async 'error' event; without a
22
- // listener that is an uncaught exception that kills the whole proxy/login flow.
23
- child.on("error", () => {});
24
- child.unref();
45
+ return new Promise<OpenUrlResult>(resolve => {
46
+ let settled = false;
47
+ let timer: ReturnType<typeof setTimeout> | undefined;
48
+ const settle = (result: OpenUrlResult) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ if (timer) clearTimeout(timer);
52
+ resolve(result);
53
+ };
54
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false });
55
+ // Headless hosts (no xdg-open) emit ENOENT as an async 'error' event; without a
56
+ // listener that is an uncaught exception that kills the whole proxy/login flow.
57
+ // It is also the signal itself: on Windows this is how a missing rundll32 arrives.
58
+ child.on("error", () => settle({ status: "failed", reason: "spawn-error" }));
59
+ child.on("spawn", () => {
60
+ // Unref'd either way: this never keeps the process alive, it only decides what to report.
61
+ timer = setTimeout(() => settle({ status: "started" }), LAUNCHER_SETTLE_MS);
62
+ timer.unref?.();
63
+ });
64
+ child.on("exit", code => settle(code === 0 || code === null
65
+ ? { status: "started" }
66
+ : { status: "failed", reason: "launcher-exit" }));
67
+ child.unref();
68
+ });
25
69
  }
@@ -1,4 +1,5 @@
1
1
  import { statSync } from "node:fs";
2
+ import { isStandaloneBinary } from "./standalone";
2
3
 
3
4
  export interface PackageTreeObservation {
4
5
  readonly device: bigint;
@@ -96,6 +97,6 @@ export function createRuntimePackageTreeIntegrityGuard(
96
97
  observe: ObservePackageTree = observePackageManifest,
97
98
  now: () => number = Date.now,
98
99
  ): PackageTreeIntegrityGuard {
99
- if (installer === "source") return { status: () => ({ ok: true }) };
100
+ if (installer === "source" || isStandaloneBinary()) return { status: () => ({ ok: true }) };
100
101
  return createPackageTreeIntegrityGuard(observe, now);
101
102
  }
@@ -0,0 +1,8 @@
1
+ import pkg from "../../package.json" with { type: "json" };
2
+
3
+ type PackageManifest = { version?: unknown };
4
+
5
+ export function packageVersion(fallback = "unknown"): string {
6
+ const version = (pkg as PackageManifest).version;
7
+ return typeof version === "string" ? version : fallback;
8
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Per-provider egress: which transport a request for THIS provider actually leaves by.
3
+ *
4
+ * The global `proxy`/`noProxy` pair is process-wide (mirrored into HTTP_PROXY/HTTPS_PROXY/
5
+ * ALL_PROXY/NO_PROXY by `applyProxyEnv`), so it cannot express the split #2894 describes:
6
+ * one upstream must exit through a regional proxy, another must stay direct on the local
7
+ * network. This module is the single authority that answers that question for one request,
8
+ * and every transport owner that can carry the answer consumes it rather than re-deriving it.
9
+ *
10
+ * It deliberately mirrors the shape #5087 established for the global decision in
11
+ * `effectiveProxyFor`: the question is never "is a proxy configured" but "does a proxy apply
12
+ * to THIS request". A provider route is resolved against the request URL, so a per-provider
13
+ * bypass list is part of the decision rather than a second check somewhere downstream.
14
+ *
15
+ * The three states are exactly the ones the issue asks for, with one deliberate divergence:
16
+ *
17
+ * - field absent -> `inherit`: the global decision stands, byte-identical to today;
18
+ * - `null` or `"direct"` -> `direct`: this provider never uses the global proxy;
19
+ * - an http(s) URL -> `proxy`: this provider uses its own HTTP(S) proxy;
20
+ * - a socks5(h) URL -> `proxy`: this provider uses its own SOCKS5 proxy.
21
+ *
22
+ * The divergence is the empty string. #2894 sketches `""` as a third spelling of DIRECT.
23
+ * Treating it that way would make a dashboard field the operator merely cleared silently
24
+ * change a provider from "inherit the global proxy" to "never use the global proxy" — the
25
+ * quiet reinterpretation this batch exists to remove. An empty or whitespace-only value is
26
+ * therefore a configuration error naming both real alternatives.
27
+ */
28
+ import type { OcxProviderConfig } from "../types";
29
+ import { isSocks5ProxyUrl, noProxyMatches } from "./proxy-env";
30
+
31
+ export class InvalidProviderEgressError extends Error {
32
+ override readonly name = "InvalidProviderEgressError";
33
+ constructor(
34
+ /** The provider field that carries the offending value. */
35
+ readonly field: "proxy" | "noProxy",
36
+ /** The failure on its own, so configuration surfaces can phrase it their own way. */
37
+ readonly reason: string,
38
+ message: string,
39
+ ) {
40
+ super(message);
41
+ }
42
+ }
43
+
44
+ /** The literal an operator writes to pin one provider to direct egress. */
45
+ export const PROVIDER_EGRESS_DIRECT = "direct";
46
+
47
+ export type ProviderEgress =
48
+ | { kind: "inherit" }
49
+ | { kind: "direct"; reason: "configured" | "noProxy" }
50
+ | { kind: "proxy"; proxyUrl: string; transport: "http" | "socks5" };
51
+
52
+ export interface ProviderEgressContext {
53
+ providerName: string;
54
+ provider: Pick<OcxProviderConfig, "proxy" | "noProxy">;
55
+ url: string | URL;
56
+ }
57
+
58
+ function egressFailure(providerName: string, field: "proxy" | "noProxy", reason: string): never {
59
+ throw new InvalidProviderEgressError(field, reason, `providers.${providerName}.${field} is invalid: ${reason}`);
60
+ }
61
+
62
+ /**
63
+ * A proxy URL reduced to scheme, host and port for operator-facing output.
64
+ *
65
+ * A proxy URL routinely carries `user:password@`, and this value reaches startup banners,
66
+ * diagnostics and the dashboard DTO. `URL.origin` drops userinfo, query and path, so what is
67
+ * left identifies the route without reproducing the credential. Nothing derived from the
68
+ * credential is emitted either — not a hash, not a prefix — because a short digest over a
69
+ * known host is a guessable stand-in for the secret and a durable correlation key for the
70
+ * account behind it.
71
+ */
72
+ export function sanitizeProxyUrlForLog(proxyUrl: string): string {
73
+ try {
74
+ const parsed = new URL(proxyUrl);
75
+ return parsed.port ? `${parsed.protocol}//${parsed.hostname}:${parsed.port}` : parsed.origin;
76
+ } catch {
77
+ return "<unparseable-proxy-url>";
78
+ }
79
+ }
80
+
81
+ export function describeProviderEgressForLog(egress: ProviderEgress): string {
82
+ if (egress.kind === "inherit") return "inherit";
83
+ if (egress.kind === "direct") return `direct(${egress.reason})`;
84
+ return `${egress.transport}(${sanitizeProxyUrlForLog(egress.proxyUrl)})`;
85
+ }
86
+
87
+ function parseTargetUrl(providerName: string, url: string | URL): URL {
88
+ if (url instanceof URL) return url;
89
+ try {
90
+ return new URL(url);
91
+ } catch {
92
+ return egressFailure(providerName, "proxy", "the request URL is not parseable, so no provider route can be decided for it");
93
+ }
94
+ }
95
+
96
+ function normalizeNoProxy(providerName: string, raw: string | string[] | undefined): string | null {
97
+ if (raw === undefined) return null;
98
+ const entries = Array.isArray(raw) ? raw : [raw];
99
+ for (const entry of entries) {
100
+ if (typeof entry !== "string") {
101
+ return egressFailure(providerName, "noProxy", "every entry must be a string host pattern");
102
+ }
103
+ }
104
+ const joined = entries.join(",").trim();
105
+ return joined.length > 0 ? joined : null;
106
+ }
107
+
108
+ function parseProviderProxyRoute(providerName: string, raw: string): ProviderEgress {
109
+ const trimmed = raw.trim();
110
+ if (trimmed.length === 0) {
111
+ return egressFailure(
112
+ providerName,
113
+ "proxy",
114
+ `an empty value is ambiguous; write "${PROVIDER_EGRESS_DIRECT}" to force direct egress, or remove the field to inherit the global proxy`,
115
+ );
116
+ }
117
+ if (trimmed.toLowerCase() === PROVIDER_EGRESS_DIRECT) return { kind: "direct", reason: "configured" };
118
+ let parsed: URL;
119
+ try {
120
+ parsed = new URL(trimmed);
121
+ } catch {
122
+ return egressFailure(
123
+ providerName,
124
+ "proxy",
125
+ `"${PROVIDER_EGRESS_DIRECT}" or an absolute proxy URL is required; this value is neither`,
126
+ );
127
+ }
128
+ if (isSocks5ProxyUrl(trimmed)) {
129
+ if (!parsed.hostname) {
130
+ return egressFailure(providerName, "proxy", "the SOCKS5 proxy URL has no host");
131
+ }
132
+ return { kind: "proxy", proxyUrl: trimmed, transport: "socks5" };
133
+ }
134
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
135
+ return egressFailure(
136
+ providerName,
137
+ "proxy",
138
+ `unsupported proxy scheme "${parsed.protocol}"; supported schemes are http, https, socks5 and socks5h`,
139
+ );
140
+ }
141
+ if (!parsed.hostname) {
142
+ return egressFailure(providerName, "proxy", "the proxy URL has no host");
143
+ }
144
+ return { kind: "proxy", proxyUrl: parsed.toString(), transport: "http" };
145
+ }
146
+
147
+ /**
148
+ * The route this provider's request leaves by, or `inherit` when the global decision stands.
149
+ *
150
+ * Throws `InvalidProviderEgressError` rather than degrading to `inherit`: a malformed egress
151
+ * field is the one case where guessing is worst. Falling back to the global proxy would send a
152
+ * credential through a route the operator did not choose, and falling back to direct would
153
+ * leave a restricted network with no exit. Both read as success at the call site.
154
+ *
155
+ * A per-provider `noProxy` match outranks the provider's own proxy for the same reason it
156
+ * outranks the global one: it names destinations this provider must reach without a proxy.
157
+ * It is evaluated against the resolved route, so it also carves holes in an inherited global
158
+ * proxy — which is how a provider exempts one host without owning a proxy of its own.
159
+ */
160
+ export function resolveProviderEgress(context: ProviderEgressContext): ProviderEgress {
161
+ const { providerName, provider } = context;
162
+ const raw = provider.proxy;
163
+ let route: ProviderEgress;
164
+ if (raw === undefined) {
165
+ route = { kind: "inherit" };
166
+ } else if (raw === null) {
167
+ route = { kind: "direct", reason: "configured" };
168
+ } else if (typeof raw !== "string") {
169
+ return egressFailure(providerName, "proxy", "the value must be a proxy URL string, \"direct\", null, or absent");
170
+ } else {
171
+ route = parseProviderProxyRoute(providerName, raw);
172
+ }
173
+ const noProxy = normalizeNoProxy(providerName, provider.noProxy);
174
+ if (noProxy !== null) {
175
+ const target = parseTargetUrl(providerName, context.url);
176
+ if (noProxyMatches(target, { NO_PROXY: noProxy })) return { kind: "direct", reason: "noProxy" };
177
+ }
178
+ return route;
179
+ }
180
+
181
+ /**
182
+ * Whether this provider decided the route itself, as opposed to deferring to the global one.
183
+ *
184
+ * Transport owners use this to tell "the operator chose this" from "nothing was configured",
185
+ * which are the two cases that must not be collapsed when a transport cannot carry the choice.
186
+ */
187
+ export function providerEgressIsExplicit(egress: ProviderEgress): boolean {
188
+ return egress.kind !== "inherit";
189
+ }
190
+
191
+ /**
192
+ * The request-scoped fetch options that express `egress` to Bun's fetch.
193
+ *
194
+ * `proxy: false` is Bun's documented per-request direct connection: it ignores HTTP_PROXY,
195
+ * HTTPS_PROXY and ALL_PROXY, and it ignores NO_PROXY as well, which is what makes it a
196
+ * decision rather than a hint. `undefined`, `null` and `""` all mean "no option given" to
197
+ * Bun and fall through to the environment, so none of them can express direct egress — the
198
+ * reason this returns the literal `false` and never an empty string.
199
+ *
200
+ * A SOCKS5 route is returned as the same `proxy` string; `configuredOutboundFetch` recognises
201
+ * the scheme and hands the request to the SOCKS transport, because Bun's own fetch ignores a
202
+ * socks5 value.
203
+ */
204
+ export function providerEgressFetchInit(egress: ProviderEgress): { proxy?: string | false } {
205
+ if (egress.kind === "inherit") return {};
206
+ if (egress.kind === "direct") return { proxy: false };
207
+ return { proxy: egress.proxyUrl };
208
+ }
209
+
210
+ /**
211
+ * Marker for an executor that forwards its `RequestInit` to a transport which honours the
212
+ * request-scoped proxy option.
213
+ *
214
+ * A provider route is refused on an executor that owns its own transport, because applying it
215
+ * is impossible and ignoring it is worse. But not every `provider.fetch` owns a transport:
216
+ * some are internal wrappers that add a header and delegate, and `src/providers/xai-transport.ts`
217
+ * installs exactly such a wrapper on every xAI route. Refusing those would make the per-provider
218
+ * proxy unusable on one of the two providers the original issue names.
219
+ *
220
+ * The marker is opt-in and applied by the wrapper's author, so an executor that arrives from
221
+ * configuration or from a caller is opaque by default and still refused. `Symbol.for` keeps the
222
+ * mark readable across duplicated module instances.
223
+ */
224
+ const EGRESS_TRANSPARENT_EXECUTOR = Symbol.for("opencodex.provider-egress.transparent-executor");
225
+
226
+ export function markEgressTransparentExecutor<Fetch extends typeof globalThis.fetch>(executor: Fetch): Fetch {
227
+ (executor as unknown as Record<symbol, boolean>)[EGRESS_TRANSPARENT_EXECUTOR] = true;
228
+ return executor;
229
+ }
230
+
231
+ export function isEgressTransparentExecutor(executor: unknown): boolean {
232
+ return typeof executor === "function"
233
+ && (executor as unknown as Record<symbol, unknown>)[EGRESS_TRANSPARENT_EXECUTOR] === true;
234
+ }
235
+
236
+ /** The destination of a fetch input, or null when it cannot be read as a URL. */
237
+ export function egressTargetUrl(input: string | URL | Request): string | null {
238
+ if (typeof input === "string") return input;
239
+ if (input instanceof URL) return input.toString();
240
+ return typeof input?.url === "string" ? input.url : null;
241
+ }
242
+
243
+ /** Everything a physical send needs to decide the route for the request it is about to make. */
244
+ export interface ProviderEgressBinding {
245
+ providerName: string;
246
+ provider: Pick<OcxProviderConfig, "proxy" | "noProxy">;
247
+ }
248
+
249
+ /**
250
+ * The request options expressing `binding`'s route for the destination actually being sent to.
251
+ *
252
+ * Resolved at the physical send rather than when the executor was built, for the reason #4992
253
+ * already established for the connection policy: a queued request can be rebuilt against a
254
+ * different upstream host before it leaves, and a route decided against the original
255
+ * destination would then be applied to a different one. With a host-scoped `noProxy` that
256
+ * inverts the decision, and the credential leaves by a route the operator did not choose.
257
+ *
258
+ * Refuses rather than degrades when the selected executor owns its own transport.
259
+ */
260
+ export function providerEgressSendInit(
261
+ binding: ProviderEgressBinding,
262
+ physicalFetch: unknown,
263
+ input: string | URL | Request,
264
+ ): { proxy?: string | false } {
265
+ const url = egressTargetUrl(input);
266
+ if (url === null) return {};
267
+ const egress = resolveProviderEgress({ providerName: binding.providerName, provider: binding.provider, url });
268
+ if (providerEgressIsExplicit(egress) && !isEgressTransparentExecutor(physicalFetch)) {
269
+ // Name the field that actually made the route explicit. A bypass-list match with no
270
+ // `proxy` field at all would otherwise tell the operator to remove an override they
271
+ // never wrote.
272
+ const field = egress.kind === "direct" && egress.reason === "noProxy" ? "noProxy" : "proxy";
273
+ throw new InvalidProviderEgressError(
274
+ field,
275
+ "the selected transport owns its own routing, so this route cannot be applied",
276
+ `providers.${binding.providerName}.${field} cannot be applied to the selected provider transport; `
277
+ + "remove the provider egress override or the custom executor",
278
+ );
279
+ }
280
+ return providerEgressFetchInit(egress);
281
+ }
282
+
283
+ /**
284
+ * A destination used only to exercise the resolver at configuration time.
285
+ *
286
+ * Validation has no request URL, but `noProxy` is only meaningful against one. Resolving a
287
+ * reserved name checks the shape of both fields without asserting anything about which route a
288
+ * real request would take.
289
+ */
290
+ const EGRESS_VALIDATION_URL = "https://validation.invalid/";
291
+
292
+ /**
293
+ * The configuration error for a provider's egress fields, or null when they are usable.
294
+ *
295
+ * Delegates to `resolveProviderEgress` so configuration and request time cannot drift apart:
296
+ * a value accepted by `ocx config set` or the dashboard is one the transport will accept, and
297
+ * one rejected here is rejected there for the identical reason. Restating the rules would give
298
+ * this repository two definitions of a valid proxy value and no check that they agree.
299
+ */
300
+ export function providerEgressConfigError(
301
+ provider: Pick<OcxProviderConfig, "proxy" | "noProxy">,
302
+ ): string | null {
303
+ try {
304
+ resolveProviderEgress({ providerName: "<validation>", provider, url: EGRESS_VALIDATION_URL });
305
+ return null;
306
+ } catch (error) {
307
+ if (error instanceof InvalidProviderEgressError) return `${error.field} is invalid: ${error.reason}`;
308
+ throw error;
309
+ }
310
+ }
@@ -7,12 +7,13 @@ import {
7
7
  resolvePublicAddresses,
8
8
  } from "./destination-policy";
9
9
  import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
10
- import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env";
10
+ import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, schemeMatchedProxyFor } from "./proxy-env";
11
+ import { InvalidProviderEgressError, resolveProviderEgress } from "./provider-egress";
11
12
  import { publicProviderBaseUrl } from "./provider-url";
12
13
 
13
14
  type ProviderGetInit = Omit<RequestInit, "body" | "method" | "redirect">;
14
15
  type ProviderPostInit = ProviderGetInit & { body: string };
15
- type ProviderOutboundConfig = Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork"> & {
16
+ type ProviderOutboundConfig = Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork" | "proxy" | "noProxy"> & {
16
17
  fetch?: typeof globalThis.fetch;
17
18
  };
18
19
  export interface ProviderOutboundDependencies {
@@ -165,6 +166,17 @@ async function providerOutboundRequest(
165
166
  // throw inside discovery and fail the provider for a reason nothing in its configuration
166
167
  // explains; the built-in transport is what a configured value means.
167
168
  if (typeof provider.fetch === "function") {
169
+ // A caller-owned executor decides its own transport, so a provider egress route cannot be
170
+ // applied to it. Refusing is the only honest answer: running the executor anyway would send
171
+ // the request by whatever route that executor picked while the configuration says otherwise.
172
+ if (resolveProviderEgress({ providerName: name, provider, url }).kind !== "inherit") {
173
+ throw new InvalidProviderEgressError(
174
+ "proxy",
175
+ "a caller-supplied fetch executor owns its own routing, so this route cannot be applied",
176
+ `providers.${name}.proxy cannot be applied to a caller-supplied fetch executor; `
177
+ + "remove the provider egress override or the custom executor",
178
+ );
179
+ }
168
180
  // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config
169
181
  // checks and redirect blocking, but does not provide the resolved-address guarantees of
170
182
  // the built-in transport. Main-request migration must define that executor contract first.
@@ -186,13 +198,39 @@ async function providerOutboundRequest(
186
198
  return provider.fetch(url, { ...init, method, redirect: "manual" });
187
199
  }
188
200
  const parsed = postUrl ?? new URL(url);
189
- const proxyConfigured = outboundProxyConfigured();
190
- // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport
191
- // below reason about the same value. `null` here means "no proxy fetch would actually use",
192
- // even if some other proxy variable is set.
193
- const effectiveProxy = effectiveProxyFor(parsed);
201
+ // The provider's own route, decided against this request URL. `inherit` leaves every value
202
+ // below exactly as the global decision computed it.
203
+ const egress = resolveProviderEgress({ providerName: name, provider, url: parsed });
204
+ const providerProxy = egress.kind === "proxy" ? egress.proxyUrl : null;
205
+ // Snapshot the proxy fetch would actually use once, before the DNS await, so admission
206
+ // and transport below reason about the same value. `null` here means "no proxy fetch
207
+ // would actually use", even if some other proxy variable is set.
208
+ const globalProxy = effectiveProxyFor(parsed);
209
+ // The request leaves the DNS-pinned transport only when a proxy will actually carry it:
210
+ // a proxy variable fetch would use for this URL that NO_PROXY does not exempt.
211
+ // A scheme-mismatched or unusable variable, a NO_PROXY match, or an ALL_PROXY
212
+ // this target's scheme cannot use must not downgrade pinning or admit
213
+ // proxy-only DNS answers.
214
+ //
215
+ // A provider route replaces that decision outright rather than combining with it. An
216
+ // explicit provider proxy applies even where global NO_PROXY exempts the host, because the
217
+ // operator named this proxy for this provider; `providers.<name>.noProxy` is the exemption
218
+ // that belongs to that choice, and `resolveProviderEgress` has already applied it. A
219
+ // provider pinned to `direct` keeps the DNS-pinned transport, which reaches the peer
220
+ // through no proxy at all — the one route on this path that needs nothing from Bun.
221
+ const proxyApplies = egress.kind === "inherit"
222
+ ? globalProxy !== null && !noProxyMatches(parsed)
223
+ : providerProxy !== null;
194
224
  const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
195
- const allowMihomoIpv6FakeIp = (effectiveProxy !== null && !noProxyMatches(parsed))
225
+ // The IPv6 fake-IP gate keeps its stricter documented condition — a
226
+ // scheme-matched variable or a SOCKS5 ALL_PROXY, never a non-SOCKS
227
+ // ALL_PROXY — even when proxyApplies admits one for the transport
228
+ // decision, because admission binds the fetch to this value explicitly.
229
+ // An explicit provider proxy is exactly such a binding: the fetch below is pinned to it.
230
+ const bindingProxy = egress.kind === "inherit"
231
+ ? schemeMatchedProxyFor(parsed)
232
+ : providerProxy;
233
+ const allowMihomoIpv6FakeIp = (bindingProxy !== null && (providerProxy !== null || !noProxyMatches(parsed)))
196
234
  || transparentFakeIpException(url, parsed, isCanonicalUrl, name);
197
235
  const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
198
236
  const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
@@ -216,7 +254,7 @@ async function providerOutboundRequest(
216
254
  // proof is on the final request URL — not the provider name — because an
217
255
  // OAuth/forward name matches any baseUrl by design while the bearer is
218
256
  // pinned to the registry destination independently.
219
- allowBenchmarkAddresses: (proxyConfigured && !noProxyMatches(parsed))
257
+ allowBenchmarkAddresses: proxyApplies
220
258
  || transparentFakeIpException(url, parsed, isCanonicalUrl, name),
221
259
  // Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted either when bound
222
260
  // to a scheme-matched proxy (#3462) or under the TUN transparency exception for a
@@ -229,21 +267,28 @@ async function providerOutboundRequest(
229
267
  if (!dnsResolutionFailed) {
230
268
  throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked");
231
269
  }
232
- if (!proxyConfigured) throw error;
270
+ if (!proxyApplies) throw error;
233
271
  warnProxyBoundaryOnce();
234
272
  warnProxyDnsDegradationOnce();
235
- return configuredOutboundFetch(url, { ...init, method, redirect: "manual" });
273
+ // An explicit provider proxy stays pinned through the degradation too; re-inferring the
274
+ // route from the environment here would quietly move the request to a different exit.
275
+ return configuredOutboundFetch(url, {
276
+ ...init, method, redirect: "manual",
277
+ ...(providerProxy ? { proxy: providerProxy } : {}),
278
+ });
236
279
  }
237
280
  // A canonical TUN exception with no scheme-matched proxy must retain the
238
281
  // validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present.
239
- if (proxyConfigured && !resolved.privateNetwork && (effectiveProxy !== null || !allowMihomoIpv6FakeIp)) {
282
+ if (proxyApplies && !resolved.privateNetwork) {
240
283
  warnProxyBoundaryOnce();
241
284
  // When the Mihomo exception could have admitted an answer, pin the transport to the
242
285
  // proxy the admission assumed instead of letting fetch re-infer it from the environment.
243
- const proxy = (allowMihomoIpv6FakeIp && effectiveProxy) ? effectiveProxy : undefined;
286
+ // An explicit provider proxy is always pinned, for the same reason and unconditionally:
287
+ // the operator named the exit for this provider, so the environment must not re-decide it.
288
+ const proxy = providerProxy ?? ((allowMihomoIpv6FakeIp && bindingProxy) ? bindingProxy : undefined);
244
289
  return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) });
245
290
  }
246
- if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) {
291
+ if (proxyApplies && resolved.privateNetwork) {
247
292
  const hostname = normalizeProxyHostname(parsed.hostname);
248
293
  throw new Error(
249
294
  `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`,