@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
package/src/lib/errors.ts CHANGED
@@ -18,7 +18,7 @@ export const ENCRYPTED_FUNCTION_OUTPUT_REJECTION =
18
18
  */
19
19
  export const SEND_BUDGET_EXHAUSTED_CODE = "request_send_budget_exhausted";
20
20
 
21
- /** Canonical human-readable message paths used by Responses upstream failures. */
21
+ /** First nonblank string across the canonical upstream error paths, in priority order. */
22
22
  export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined {
23
23
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
24
24
  const json = payload as {
@@ -31,14 +31,14 @@ export function upstreamErrorMessageFromPayload(payload: unknown): string | unde
31
31
  incomplete_details?: { message?: unknown };
32
32
  };
33
33
  };
34
- const message = json.error?.message
35
- ?? json.last_error?.message
36
- ?? json.response?.error?.message
37
- ?? json.response?.incomplete_details?.message
34
+ const messages = [json.error?.message,
35
+ json.last_error?.message,
36
+ json.response?.error?.message,
37
+ json.response?.incomplete_details?.message,
38
38
  // The Responses stream error event carries a flat message (type/code/message),
39
39
  // unlike the response.failed envelope the branches above already cover.
40
- ?? (json.type === "error" ? json.message : undefined);
41
- return typeof message === "string" ? message : undefined;
40
+ json.type === "error" ? json.message : undefined];
41
+ return messages.find((message): message is string => typeof message === "string" && message.trim().length > 0);
42
42
  }
43
43
 
44
44
  /** OpenAI / Codex hard block for high-risk cybersecurity activity (HTTP 400 or mid-stream). */
package/src/lib/jsonc.ts CHANGED
@@ -108,3 +108,16 @@ export function canonicalizeJsonc(text: string): string {
108
108
  export function parseJsonc(text: string): unknown {
109
109
  return JSON.parse(canonicalizeJsonc(text));
110
110
  }
111
+
112
+ /**
113
+ * True when the text carries a `//` or block comment outside a string. A writer that
114
+ * re-serializes the document would drop those, so it checks this before writing.
115
+ * An unterminated block comment counts as a comment.
116
+ */
117
+ export function hasJsoncComments(text: string): boolean {
118
+ try {
119
+ return stripJsonComments(text) !== text;
120
+ } catch {
121
+ return true;
122
+ }
123
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * One non-streaming chat completion through this proxy's own /v1/chat/completions.
3
+ *
4
+ * The way opencodex makes a model call of its own: the request goes back through the router like
5
+ * any client's, so every provider the user has configured is reachable and none needs its own
6
+ * client here. The routed vision describer and the Codex role sizing both send through it.
7
+ *
8
+ * Destination: the unauthenticated loopback listener when one is enabled, otherwise the bind
9
+ * address (#4236), resolved by localInferenceDestination rather than composing 127.0.0.1 by hand.
10
+ * Admission: localAdmissionToken (env token, hardened service token file, first apiKeys entry),
11
+ * sent as x-opencodex-api-key and never Authorization, because an admission secret in a
12
+ * forwardable header is a forwarding hazard. Never the admin token.
13
+ */
14
+ import type { OcxConfig } from "../types";
15
+ import { localAdmissionToken, localInferenceDestination } from "./local-destinations";
16
+ import { signalWithTimeout, cancelBodyOnAbort } from "./abort";
17
+ import { readBoundedResponseBytes } from "./bounded-body";
18
+ import { redactSecretString } from "./redact";
19
+ import { sidecarEnter } from "./sidecar-tracker";
20
+ import { configuredPort } from "../server/auth-cors";
21
+
22
+ export type LocalChatConfig = Pick<OcxConfig, "port" | "hostname" | "apiKeys" | "unauthenticatedLoopbackListener">;
23
+
24
+ export interface LocalChatCompletionRequest {
25
+ readonly config: LocalChatConfig;
26
+ readonly body: Record<string, unknown>;
27
+ /** Names the caller in error text and logs, e.g. "routed describe". */
28
+ readonly label: string;
29
+ /** Log prefix and sidecar breadcrumb, e.g. "vision". */
30
+ readonly logTag: string;
31
+ readonly timeoutMs: number;
32
+ readonly maxResponseBytes: number;
33
+ /**
34
+ * Count raw bytes while reading and cancel the body at the bound. Unset keeps the routed
35
+ * describer's bound: the whole body is read, then its UTF-16 length is compared.
36
+ */
37
+ readonly boundWhileStreaming?: boolean;
38
+ readonly headers?: Record<string, string>;
39
+ readonly abortSignal?: AbortSignal;
40
+ /** Test seam; production always self-fetches the resolved local destination. */
41
+ readonly baseUrlOverride?: string;
42
+ }
43
+
44
+ export type LocalChatCompletionOutcome = { text: string; error?: string };
45
+
46
+ function destination(config: Pick<OcxConfig, "port" | "hostname" | "unauthenticatedLoopbackListener">) {
47
+ // config.port can be 0 (ephemeral bind, tests) or stale after a live port override; the
48
+ // server records its ACTUAL bound port via setCorsOrigin at startup, so prefer that when
49
+ // config carries no positive port. configuredPort() is itself 0 when _corsOrigin has no
50
+ // explicit port (a default-port origin), so the literal default has to backstop it or the
51
+ // composed URL names port 0 and the self-fetch cannot connect.
52
+ const port = config.port && config.port > 0
53
+ ? config.port
54
+ : Number(configuredPort()) || 10_100;
55
+ return localInferenceDestination(config, port);
56
+ }
57
+
58
+ export function localChatCompletionBaseUrl(
59
+ config: Pick<OcxConfig, "port" | "hostname" | "unauthenticatedLoopbackListener">,
60
+ ): string {
61
+ return destination(config).origin;
62
+ }
63
+
64
+ export async function postLocalChatCompletion(request: LocalChatCompletionRequest): Promise<LocalChatCompletionOutcome> {
65
+ const { config, label, logTag } = request;
66
+ const headers: Record<string, string> = { "Content-Type": "application/json", ...request.headers };
67
+ const admission = localAdmissionToken(config);
68
+ if (admission) headers["x-opencodex-api-key"] = admission;
69
+ // A bind that demands admission with no resolvable credential would return 401 with a body
70
+ // the caller reports as a failure; naming the cause once is the difference between "it is
71
+ // broken" and a fixable configuration note.
72
+ if (!admission && !request.baseUrlOverride && destination(config).requiresAdmissionToken) {
73
+ console.warn(
74
+ `[${logTag}] ${label} has no opencodex data-plane credential for `
75
+ + `${localChatCompletionBaseUrl(config)} — the self-fetch will be refused.`,
76
+ );
77
+ }
78
+
79
+ const linkedSignal = signalWithTimeout(request.timeoutMs, request.abortSignal);
80
+ const sidecarExit = sidecarEnter(logTag);
81
+ const t0 = Date.now();
82
+ try {
83
+ const res = await fetch(`${request.baseUrlOverride ?? localChatCompletionBaseUrl(config)}/v1/chat/completions`, {
84
+ method: "POST",
85
+ headers,
86
+ body: JSON.stringify({ ...request.body, stream: false }),
87
+ signal: linkedSignal.signal,
88
+ redirect: "manual",
89
+ });
90
+ const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
91
+ try {
92
+ let raw: string;
93
+ if (request.boundWhileStreaming) {
94
+ const read = await readBoundedResponseBytes(res, { maxBytes: request.maxResponseBytes, signal: linkedSignal.signal });
95
+ if (read.oversized) return { text: "", error: `${label} response exceeded byte bound` };
96
+ raw = new TextDecoder().decode(read.bytes);
97
+ } else {
98
+ raw = await res.text();
99
+ if (raw.length > request.maxResponseBytes) {
100
+ return { text: "", error: `${label} response exceeded byte bound` };
101
+ }
102
+ }
103
+ if (!res.ok) {
104
+ return { text: "", error: `${label} HTTP ${res.status}: ${redactSecretString(raw.slice(0, 200))}` };
105
+ }
106
+ let payload: unknown;
107
+ try { payload = JSON.parse(raw); } catch {
108
+ return { text: "", error: `${label} returned non-JSON` };
109
+ }
110
+ const content = extractChatContent(payload);
111
+ if (!content) return { text: "", error: `${label} returned no text` };
112
+ return { text: content };
113
+ } finally {
114
+ detachBodyGuard();
115
+ }
116
+ } catch (e) {
117
+ const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
118
+ console.warn(`[${logTag}] ${label} ${kind} (${Date.now() - t0}ms)`);
119
+ return { text: "", error: redactSecretString(e instanceof Error ? e.message : String(e)) };
120
+ } finally {
121
+ sidecarExit();
122
+ linkedSignal.cleanup();
123
+ }
124
+ }
125
+
126
+ function extractChatContent(payload: unknown): string | undefined {
127
+ if (!payload || typeof payload !== "object") return undefined;
128
+ const choices = (payload as { choices?: unknown }).choices;
129
+ if (!Array.isArray(choices) || choices.length === 0) return undefined;
130
+ const message = (choices[0] as { message?: unknown })?.message;
131
+ if (!message || typeof message !== "object") return undefined;
132
+ const content = (message as { content?: unknown }).content;
133
+ if (typeof content === "string" && content.trim().length > 0) return content;
134
+ // Some adapters emit content parts; join text parts.
135
+ if (Array.isArray(content)) {
136
+ const joined = content
137
+ .map(part => (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
138
+ ? (part as { text: string }).text
139
+ : ""))
140
+ .join("");
141
+ if (joined.trim().length > 0) return joined;
142
+ }
143
+ return undefined;
144
+ }
@@ -1,3 +1,4 @@
1
+ import { isIP } from "node:net";
1
2
  import type { OcxProviderConfig } from "../types";
2
3
  import {
3
4
  assessUrlDestination,
@@ -30,6 +31,73 @@ export interface ProviderOutboundDependencies {
30
31
  isCanonicalUrl?: (name: string, url: string) => boolean;
31
32
  /** Recheck caller-owned credential authority after DNS and immediately before transport. */
32
33
  beforeSend?: () => boolean;
34
+ /**
35
+ * Caller opt-in for a cleartext `http:` POST to a self-hosted service. Honoured only when the
36
+ * URL host is exactly `localhost` or an address literal in the narrow local allowlist
37
+ * (`localCleartextAddressAllowed`) AND the provider row itself sets `allowPrivateNetwork: true`
38
+ * (a registry default does not count). Every resolved answer must stay inside the same
39
+ * allowlist, and a proxied route is refused, so the cleartext body never leaves the host or
40
+ * LAN. Every other destination keeps the HTTPS-only POST gate. Defaults to refusing.
41
+ */
42
+ allowLocalCleartextPost?: boolean;
43
+ }
44
+
45
+ function ipv4Octets(address: string): number[] | null {
46
+ return isIP(address) === 4 ? address.split(".").map(Number) : null;
47
+ }
48
+
49
+ function ipv6Hextets(address: string): number[] | null {
50
+ if (isIP(address) !== 6 || address.includes("%")) return null;
51
+ let text = address.toLowerCase();
52
+ const lastColon = text.lastIndexOf(":");
53
+ const embedded = ipv4Octets(text.slice(lastColon + 1));
54
+ if (text.slice(lastColon + 1).includes(".")) {
55
+ if (!embedded) return null;
56
+ const [a, b, c, d] = embedded as [number, number, number, number];
57
+ text = `${text.slice(0, lastColon + 1)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
58
+ }
59
+ const [head = "", tail] = text.split("::");
60
+ const headParts = head ? head.split(":") : [];
61
+ const tailParts = tail ? tail.split(":") : [];
62
+ const fill = tail === undefined ? 0 : 8 - headParts.length - tailParts.length;
63
+ const parts = [...headParts, ...Array<string>(Math.max(0, fill)).fill("0"), ...tailParts];
64
+ return parts.length === 8 ? parts.map(part => Number.parseInt(part, 16)) : null;
65
+ }
66
+
67
+ /**
68
+ * The only addresses a cleartext provider POST may reach: IPv4 loopback 127/8, IPv4-mapped
69
+ * loopback ::ffff:127.0.0.0/104, ::1, RFC 1918 (10/8, 172.16/12, 192.168/16), and IPv6 ULA
70
+ * fc00::/7. Deliberately narrower than the private-network classification: CGNAT 100.64/10,
71
+ * NAT64 64:ff9b:1::/48, benchmark, documentation, multicast/broadcast, link-local, metadata and
72
+ * unspecified addresses never qualify.
73
+ */
74
+ export function localCleartextAddressAllowed(address: string): boolean {
75
+ const v4 = ipv4Octets(address);
76
+ if (v4) {
77
+ const [a, b] = v4 as [number, number];
78
+ return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
79
+ }
80
+ const v6 = ipv6Hextets(address);
81
+ if (!v6) return false;
82
+ if (v6.slice(0, 7).every(part => part === 0) && v6[7] === 1) return true;
83
+ if (v6.slice(0, 5).every(part => part === 0) && v6[5] === 0xffff && (v6[6]! >> 8) === 127) return true;
84
+ return (v6[0]! & 0xfe00) === 0xfc00;
85
+ }
86
+
87
+ function unbracketedHostname(url: URL): string {
88
+ const host = url.hostname;
89
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
90
+ }
91
+
92
+ function localCleartextPostAllowed(
93
+ provider: ProviderOutboundConfig,
94
+ url: URL,
95
+ dependencies: ProviderOutboundDependencies,
96
+ ): boolean {
97
+ if (dependencies.allowLocalCleartextPost !== true || url.protocol !== "http:") return false;
98
+ if (provider.allowPrivateNetwork !== true) return false;
99
+ const host = unbracketedHostname(url);
100
+ return host === "localhost" || localCleartextAddressAllowed(host);
33
101
  }
34
102
 
35
103
  export class ProviderOutboundPolicyError extends Error {
@@ -167,14 +235,21 @@ async function providerOutboundRequest(
167
235
  if (dependencies.beforeSend?.() === false) throw new ProviderOutboundSendCancelledError("provider credential changed before send");
168
236
  };
169
237
  const postUrl = method === "POST" ? new URL(url) : undefined;
170
- if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") {
238
+ if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:"
239
+ && !localCleartextPostAllowed(provider, postUrl, dependencies)) {
171
240
  throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS");
172
241
  }
242
+ // Only reachable after the narrow opt-in above admitted it.
243
+ const localCleartextPost = postUrl?.protocol === "http:";
173
244
  // A provider entry keeps unknown configuration keys, so `fetch` can arrive as a value the
174
245
  // operator wrote into the file rather than an executor a caller attached. Calling that would
175
246
  // throw inside discovery and fail the provider for a reason nothing in its configuration
176
247
  // explains; the built-in transport is what a configured value means.
177
248
  if (typeof provider.fetch === "function") {
249
+ // An executor resolves names itself, so a cleartext POST through one must name an address.
250
+ if (localCleartextPost && !isIP(unbracketedHostname(postUrl!))) {
251
+ throw new ProviderOutboundPolicyError("a cleartext provider POST through a caller-supplied executor must use an address literal");
252
+ }
178
253
  // A caller-owned executor decides its own transport, so a provider egress route cannot be
179
254
  // applied to it. Refusing is the only honest answer: running the executor anyway would send
180
255
  // the request by whatever route that executor picked while the configuration says otherwise.
@@ -231,6 +306,13 @@ async function providerOutboundRequest(
231
306
  const proxyApplies = egress.kind === "inherit"
232
307
  ? globalProxy !== null && !noProxyMatches(parsed)
233
308
  : providerProxy !== null;
309
+ // A cleartext body must go straight to the local peer: a proxy (global or provider route, and
310
+ // the DNS-failure proxy degradation below, which needs one) would carry it off-host unencrypted.
311
+ if (localCleartextPost && proxyApplies) {
312
+ throw new ProviderOutboundPolicyError(
313
+ `a cleartext provider POST cannot use an outbound proxy; add ${normalizeProxyHostname(parsed.hostname)} to NO_PROXY`,
314
+ );
315
+ }
234
316
  const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
235
317
  // The IPv6 fake-IP gate keeps its stricter documented condition — a
236
318
  // scheme-matched variable or a SOCKS5 ALL_PROXY, never a non-SOCKS
@@ -306,6 +388,10 @@ async function providerOutboundRequest(
306
388
  `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`,
307
389
  );
308
390
  }
391
+ if (localCleartextPost
392
+ && !resolved.addresses.every(answer => localCleartextAddressAllowed(answer.address))) {
393
+ throw new ProviderOutboundPolicyError("a cleartext provider POST resolved outside the local address allowlist");
394
+ }
309
395
  const requestOptions = {
310
396
  headers: init.headers,
311
397
  rejectUnauthorized: true,
@@ -0,0 +1,23 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+
3
+ export const RUNTIME_PROVIDER_FETCH = Symbol("opencodex.provider.runtime-fetch");
4
+
5
+ export interface RuntimeProviderFetch {
6
+ providerName: string;
7
+ origins: readonly string[];
8
+ fetch: typeof globalThis.fetch;
9
+ }
10
+
11
+ /** Return only an explicitly injected executor matching the provider and exact destination origin. */
12
+ export function runtimeProviderFetch(
13
+ provider: OcxProviderConfig,
14
+ providerName: string | undefined,
15
+ ): typeof globalThis.fetch | undefined {
16
+ const runtime = (provider as OcxProviderConfig & { [RUNTIME_PROVIDER_FETCH]?: RuntimeProviderFetch })[RUNTIME_PROVIDER_FETCH];
17
+ if (!runtime || runtime.providerName !== providerName) return undefined;
18
+ try {
19
+ return runtime.origins.includes(new URL(provider.baseUrl).origin) ? runtime.fetch : undefined;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
@@ -0,0 +1,221 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import { redactSecretString } from "./redact";
3
+ import { runtimeProviderFetch } from "./provider-runtime-fetch";
4
+ import { markEgressTransparentExecutor } from "./provider-egress";
5
+ import {
6
+ outboundProxyConfigured,
7
+ proxyEnvPresent,
8
+ resolveProxyRoute,
9
+ socks5ProxyFromEnv,
10
+ type ProxyEnvMap,
11
+ } from "./proxy-env";
12
+
13
+ export type ProviderTlsProfile = "antigravity-browser";
14
+ /**
15
+ * `pending` means the profile is configured and valid but no request has used it yet; the
16
+ * dashboard must not report a configured profile as `disabled` before its first send.
17
+ */
18
+ export type ProviderTlsProfileStatus = "disabled" | "pending" | "active" | "failed";
19
+ export const ANTIGRAVITY_TLS_HOSTS = new Set([
20
+ "daily-cloudcode-pa.googleapis.com",
21
+ "cloudcode-pa.googleapis.com",
22
+ ]);
23
+ type TlsRuntime = {
24
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
25
+ resolveProxyRoute?: typeof resolveProxyRoute;
26
+ env?: ProxyEnvMap;
27
+ };
28
+ let status = new Map<string, ProviderTlsProfileStatus>();
29
+ let runtime: TlsRuntime | undefined;
30
+
31
+ export function isCanonicalAntigravityUrl(input: string | URL): boolean {
32
+ try {
33
+ const url = new URL(input);
34
+ return (
35
+ url.protocol === "https:" &&
36
+ (url.port === "" || url.port === "443") &&
37
+ !url.username &&
38
+ !url.password &&
39
+ ANTIGRAVITY_TLS_HOSTS.has(url.hostname.toLowerCase())
40
+ );
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ export function providerTlsProfileConfigError(
47
+ providerName: string,
48
+ provider: Pick<
49
+ OcxProviderConfig,
50
+ "adapter" | "authMode" | "googleMode" | "baseUrl" | "tlsProfile"
51
+ >,
52
+ ): string | null {
53
+ if (provider.tlsProfile === undefined) return null;
54
+ if (provider.tlsProfile !== "antigravity-browser")
55
+ return "tlsProfile must be antigravity-browser";
56
+ if (
57
+ providerName !== "google-antigravity" ||
58
+ provider.adapter !== "google" ||
59
+ provider.authMode !== "oauth" ||
60
+ provider.googleMode !== "cloud-code-assist" ||
61
+ !isCanonicalAntigravityUrl(provider.baseUrl)
62
+ ) {
63
+ return "tlsProfile antigravity-browser requires the canonical Google Antigravity OAuth destination";
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export function getProviderTlsProfileStatus(
69
+ name: string,
70
+ configured?: boolean,
71
+ ): ProviderTlsProfileStatus {
72
+ const recorded = status.get(name);
73
+ if (configured === undefined) return recorded ?? "disabled";
74
+ if (!configured) return "disabled";
75
+ return recorded === undefined || recorded === "disabled" ? "pending" : recorded;
76
+ }
77
+
78
+ /** The `/api/providers` fragment for a configured profile; empty when the provider has none. */
79
+ export function providerTlsProfileDiagnostic(
80
+ name: string,
81
+ provider: Pick<OcxProviderConfig, "tlsProfile">,
82
+ ): { tlsProfile?: { profile: ProviderTlsProfile; status: ProviderTlsProfileStatus } } {
83
+ if (provider.tlsProfile === undefined) return {};
84
+ return { tlsProfile: { profile: provider.tlsProfile, status: getProviderTlsProfileStatus(name, true) } };
85
+ }
86
+
87
+ export function resetProviderTlsProfileForTests(): void {
88
+ status = new Map();
89
+ runtime = undefined;
90
+ }
91
+
92
+ export function setProviderTlsRuntimeForTest(
93
+ next: TlsRuntime | undefined,
94
+ ): void {
95
+ runtime = next;
96
+ }
97
+
98
+ function preserveTransportError(error: unknown): Error {
99
+ // A configured proxy URL can carry user:pass@, and the native transport may echo it. The
100
+ // shared redactor does not mask URL userinfo, so strip it here before the message travels.
101
+ const message = redactSecretString(
102
+ error instanceof Error ? error.message : "provider TLS transport failed",
103
+ ).replace(/\/\/[^/@\s]+@/g, "//<redacted>@");
104
+ const name = error instanceof Error ? error.name : "Error";
105
+ if (name === "AbortError" || name === "TimeoutError")
106
+ return new DOMException(message, name);
107
+ const wrapped = new Error(message);
108
+ wrapped.name = name;
109
+ return wrapped;
110
+ }
111
+
112
+ /** Proxy schemes the native TLS transport can carry for a route decided elsewhere. */
113
+ const TLS_PROXY_PROTOCOLS = new Set(["http:", "https:", "socks5:", "socks5h:"]);
114
+
115
+ function requireDirect(env: ProxyEnvMap): Record<string, never> {
116
+ // The native transport reads HTTP_PROXY/HTTPS_PROXY/ALL_PROXY itself whenever no proxy option
117
+ // is given, and it has no per-request "direct" switch. A direct route is therefore only
118
+ // honoured when no proxy variable exists at all; otherwise omitting the option would send the
119
+ // credential through the environment proxy the operator routed this request away from.
120
+ if (outboundProxyConfigured(env)) {
121
+ throw new Error("provider TLS profile cannot force a direct connection while a proxy environment variable is set");
122
+ }
123
+ return {};
124
+ }
125
+
126
+ /**
127
+ * The proxy option expressing this send's route on the native transport, or a refusal.
128
+ *
129
+ * `sendWithConnectionPolicy` resolves the per-provider egress (`providers.<name>.proxy` /
130
+ * `noProxy`) and passes it as `init.proxy`: a URL, `false` for direct, or absent when the
131
+ * provider inherits the global environment route.
132
+ */
133
+ function tlsProxyOption(init: RequestInit | undefined, destination: string | URL): { proxy?: string } {
134
+ const env = runtime?.env ?? process.env;
135
+ const decided = init !== undefined && Object.hasOwn(init, "proxy")
136
+ ? (init as RequestInit & { proxy?: unknown }).proxy
137
+ : undefined;
138
+ if (typeof decided === "string") {
139
+ let protocol: string;
140
+ try {
141
+ protocol = new URL(decided).protocol;
142
+ } catch {
143
+ throw new Error("provider TLS profile cannot preserve configured proxy semantics");
144
+ }
145
+ if (!TLS_PROXY_PROTOCOLS.has(protocol)) {
146
+ throw new Error("provider TLS profile cannot preserve configured proxy semantics");
147
+ }
148
+ return { proxy: decided };
149
+ }
150
+ if (decided === false) return requireDirect(env);
151
+ const route = (runtime?.resolveProxyRoute ?? resolveProxyRoute)(new URL(destination), env);
152
+ if (route.kind === "fallback") {
153
+ // `resolveProxyRoute` only classifies HTTP(S) proxies; an inherited ALL_PROXY of socks5://
154
+ // or socks5h:// is the route the ordinary outbound path takes through socks5ProxyFromEnv().
155
+ // Carry that same route when no HTTPS-specific variable outranks it, instead of refusing
156
+ // every Antigravity send for an operator whose only global proxy is SOCKS.
157
+ const socks = proxyEnvPresent("HTTPS_PROXY", env) ? undefined : socks5ProxyFromEnv(env);
158
+ if (socks) return { proxy: socks.trim() };
159
+ throw new Error("provider TLS profile cannot preserve configured proxy semantics");
160
+ }
161
+ if (route.kind === "proxy") return { proxy: route.proxy };
162
+ return requireDirect(env);
163
+ }
164
+
165
+ export function providerTlsFetch(
166
+ name: string,
167
+ provider: Pick<
168
+ OcxProviderConfig,
169
+ "adapter" | "authMode" | "googleMode" | "baseUrl" | "tlsProfile"
170
+ >,
171
+ fallback: typeof globalThis.fetch,
172
+ ): typeof globalThis.fetch {
173
+ if (provider.tlsProfile === undefined) {
174
+ status.set(name, "disabled");
175
+ return fallback;
176
+ }
177
+ if (providerTlsProfileConfigError(name, provider)) {
178
+ status.set(name, "failed");
179
+ return (async () => {
180
+ throw new Error("invalid provider TLS profile");
181
+ }) as unknown as typeof globalThis.fetch;
182
+ }
183
+ // Transparent to provider egress: the route decided at the physical send arrives as
184
+ // `init.proxy` and is either carried by the native transport or refused below.
185
+ return markEgressTransparentExecutor((async (input, init) => {
186
+ const destination =
187
+ typeof input === "string" || input instanceof URL ? input : input.url;
188
+ if (!isCanonicalAntigravityUrl(destination)) {
189
+ status.set(name, "failed");
190
+ throw new Error("provider TLS profile refused noncanonical destination");
191
+ }
192
+ try {
193
+ const configured = runtimeProviderFetch(
194
+ provider as OcxProviderConfig,
195
+ name,
196
+ );
197
+ const proxyOption = tlsProxyOption(init, destination);
198
+ const mod =
199
+ configured === undefined
200
+ ? runtime ?? ((await import("wreq-js")) as unknown as TlsRuntime)
201
+ : undefined;
202
+ const { proxy: _decidedRoute, ...rest } = (init ?? {}) as RequestInit & { proxy?: unknown };
203
+ const response = await (configured ?? mod!.fetch)(input, {
204
+ ...rest,
205
+ redirect: "manual",
206
+ browser: "chrome_142",
207
+ os: "windows",
208
+ ...proxyOption,
209
+ } as RequestInit & { browser: string; os: string });
210
+ status.set(name, "active");
211
+ return response;
212
+ } catch (error) {
213
+ if (init?.signal?.aborted && error === init.signal.reason) {
214
+ // A caller cancellation says nothing about the profile's health.
215
+ throw error;
216
+ }
217
+ status.set(name, "failed");
218
+ throw preserveTransportError(error);
219
+ }
220
+ }) as typeof globalThis.fetch);
221
+ }
@@ -79,6 +79,14 @@ export interface DispatchIntent {
79
79
  * second charge. Charging both is how a four-send cap silently becomes a two-send cap.
80
80
  */
81
81
  readonly countedExternally?: boolean;
82
+ /**
83
+ * True when the caller's request was rebuilt onto a different destination by an
84
+ * authorized mid-flight change (e.g. Kiro's reset-triggered account rebuild).
85
+ * The physical destination is still recorded, but the move is not a failover
86
+ * decision: it must not consume the request's single target transition or
87
+ * alternate-target allowance, which belong to the actual endpoint fallback.
88
+ */
89
+ readonly rebasedTarget?: boolean;
82
90
  }
83
91
 
84
92
  export interface SingleUseDispatchPermit {
@@ -316,10 +324,12 @@ function createRequestExecutionBudgetWithLedger(
316
324
  const changesTarget = lastTargetKey !== undefined && lastTargetKey !== intent.targetKey;
317
325
  const isAlternateTarget = changesTarget || intent.sendClass === "account-failover"
318
326
  || intent.sendClass === "combo-failover";
319
- if (isAlternateTarget && changesTarget && targetTransitions >= policy.maxTargetTransitions) {
327
+ const chargesTransition = changesTarget && intent.rebasedTarget !== true;
328
+ const chargesAlternateTarget = isAlternateTarget && intent.rebasedTarget !== true;
329
+ if (isAlternateTarget && chargesTransition && targetTransitions >= policy.maxTargetTransitions) {
320
330
  return { allowed: false, reason: "target-transition-exhausted" };
321
331
  }
322
- if (isAlternateTarget && alternateTargetSends >= policy.maxAlternateTargetSends) {
332
+ if (chargesAlternateTarget && alternateTargetSends >= policy.maxAlternateTargetSends) {
323
333
  return { allowed: false, reason: "alternate-target-exhausted" };
324
334
  }
325
335
 
@@ -349,8 +359,8 @@ function createRequestExecutionBudgetWithLedger(
349
359
  counter.spent += 1;
350
360
  if (intent.countedExternally === true) counter.pendingExternalSends += 1;
351
361
  if (drawsReserve) reserveSpent = true;
352
- if (isAlternateTarget) alternateTargetSends += 1;
353
- if (changesTarget) targetTransitions += 1;
362
+ if (chargesAlternateTarget) alternateTargetSends += 1;
363
+ if (chargesTransition) targetTransitions += 1;
354
364
  lastTargetKey = intent.targetKey;
355
365
 
356
366
  let settled: "open" | "used" | "released" = "open";
@@ -386,8 +396,8 @@ function createRequestExecutionBudgetWithLedger(
386
396
  counter.spent -= 1;
387
397
  observer?.refund();
388
398
  if (drawsReserve) reserveSpent = false;
389
- if (isAlternateTarget) alternateTargetSends -= 1;
390
- if (changesTarget) targetTransitions -= 1;
399
+ if (chargesAlternateTarget) alternateTargetSends -= 1;
400
+ if (chargesTransition) targetTransitions -= 1;
391
401
  lastTargetKey = previousTargetKey;
392
402
  },
393
403
  },