@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -0,0 +1,56 @@
1
+ import {
2
+ filterCatalogVisibleModels,
3
+ nativeContextLimits,
4
+ nativeDefaultReasoningEffort,
5
+ nativeOpenAiContextWindow,
6
+ nativeReasoningEfforts,
7
+ visibleNativeSlugs,
8
+ type CatalogModel,
9
+ } from "../codex/catalog";
10
+ import type { OcxConfig } from "../types";
11
+ import type { GrokInjectModel } from "./inject";
12
+
13
+ /**
14
+ * Catalog → inject payload used by both `syncGrokConfig` and the dashboard enable
15
+ * path. Native rows carry the pinned ladder; routed rows carry
16
+ * `reasoningEfforts` / `defaultReasoningEffort`. The writer sanitizes Grok-invalid
17
+ * rungs — this function forwards the same lists `/v1/models` already advertises.
18
+ */
19
+ export function buildGrokInjectModels(
20
+ config: Pick<OcxConfig, "disabledModels" | "combos" | "providers" | "providerContextCaps">,
21
+ routed: CatalogModel[],
22
+ ): GrokInjectModel[] {
23
+ const contextLimits = nativeContextLimits(config);
24
+ return [
25
+ ...visibleNativeSlugs(config).map(id => {
26
+ const contextWindow = nativeOpenAiContextWindow(id, contextLimits);
27
+ const reasoningEfforts = nativeReasoningEfforts(id);
28
+ const defaultReasoningEffort = nativeDefaultReasoningEffort(id);
29
+ return {
30
+ id,
31
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
32
+ ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}),
33
+ ...(defaultReasoningEffort !== undefined ? { defaultReasoningEffort } : {}),
34
+ };
35
+ }),
36
+ ...routed.map(model => {
37
+ const efforts = model.reasoningEfforts ?? [];
38
+ return {
39
+ id: model.alias ?? `${model.provider}/${model.id}`,
40
+ ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
41
+ ...(efforts.length > 0 ? { reasoningEfforts: efforts } : {}),
42
+ ...(model.defaultReasoningEffort !== undefined
43
+ ? { defaultReasoningEffort: model.defaultReasoningEffort }
44
+ : {}),
45
+ };
46
+ }),
47
+ ];
48
+ }
49
+
50
+ /** Visible routed catalog, then the shared inject list. */
51
+ export function grokInjectModelsFromCatalog(
52
+ config: OcxConfig,
53
+ catalog: CatalogModel[],
54
+ ): GrokInjectModel[] {
55
+ return buildGrokInjectModels(config, filterCatalogVisibleModels(catalog, config));
56
+ }
@@ -65,22 +65,35 @@ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus {
65
65
  let baseUrl: string | null = null;
66
66
  let current: GrokStatusModel | null = null;
67
67
 
68
+ // The provider block carries base_url in the current shape; per-model base_url is the
69
+ // legacy fallback for fences written before the model_providers migration.
70
+ let inProviderBlock = false;
71
+
68
72
  for (const rawLine of region.split("\n")) {
69
73
  const line = rawLine.trim();
74
+ const providerHeader = /^\[model_providers\.([^\]]+)\]$/.exec(line);
75
+ if (providerHeader) {
76
+ inProviderBlock = true;
77
+ continue;
78
+ }
70
79
  const header = /^\[model\.([^\]]+)\]$/.exec(line);
71
80
  if (header) {
81
+ inProviderBlock = false;
72
82
  current = { alias: header[1]!, id: "" };
73
83
  models.push(current);
74
84
  continue;
75
85
  }
76
- if (!current) continue;
77
- if (line.startsWith("model =")) {
78
- current.id = tomlStringValue(line) ?? "";
79
- } else if (line.startsWith("base_url =")) {
80
- baseUrl ??= tomlStringValue(line) ?? null;
81
- } else if (line.startsWith("context_window =")) {
82
- const value = Number(line.slice(line.indexOf("=") + 1).trim());
83
- if (Number.isFinite(value) && value > 0) current.contextWindow = value;
86
+ if (line.startsWith("base_url =")) {
87
+ // Prefer the provider block's base_url; fall back to per-model (legacy shape).
88
+ if (inProviderBlock) baseUrl ??= tomlStringValue(line) ?? null;
89
+ else if (current && baseUrl === null) baseUrl = tomlStringValue(line) ?? null;
90
+ } else if (!inProviderBlock && current) {
91
+ if (line.startsWith("model =")) {
92
+ current.id = tomlStringValue(line) ?? "";
93
+ } else if (line.startsWith("context_window =")) {
94
+ const value = Number(line.slice(line.indexOf("=") + 1).trim());
95
+ if (Number.isFinite(value) && value > 0) current.contextWindow = value;
96
+ }
84
97
  }
85
98
  }
86
99
 
package/src/grok/sync.ts CHANGED
@@ -6,9 +6,10 @@
6
6
  *
7
7
  * Deps are injectable (mirrors src/codex/sync.ts) so tests can run without a live proxy.
8
8
  */
9
- import { visibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, type CatalogModel } from "../codex/catalog";
9
+ import type { CatalogModel } from "../codex/catalog";
10
10
  import type { OcxConfig } from "../types";
11
- import { injectGrokConfig, type GrokInjectModel, type GrokInjectResult } from "./inject";
11
+ import { projectGrokCatalog } from "./catalog";
12
+ import { injectGrokConfig, type GrokInjectResult } from "./inject";
12
13
 
13
14
  export interface GrokSyncDeps {
14
15
  fetchAllModels: (config: OcxConfig) => Promise<CatalogModel[]>;
@@ -32,22 +33,10 @@ export async function syncGrokConfig(
32
33
  opts: { hostname?: string; grokHome?: string } = {},
33
34
  deps: GrokSyncDeps = { fetchAllModels: defaultFetchAllModels, injectGrokConfig },
34
35
  ): Promise<GrokInjectResult> {
35
- let models: GrokInjectModel[];
36
+ let projection: ReturnType<typeof projectGrokCatalog>;
36
37
  try {
37
- const routed = filterCatalogVisibleModels(await deps.fetchAllModels(config), config);
38
- models = [
39
- // Native slugs carry their context window too. Without it Grok falls back to its own
40
- // default (200k) and understates models like gpt-5.6-sol, which is 372k. This is the same
41
- // accessor the dashboard's native rows use, so the two cannot disagree.
42
- ...visibleNativeSlugs(config).map(id => {
43
- const contextWindow = nativeOpenAiContextWindow(id, nativeContextLimits(config));
44
- return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) };
45
- }),
46
- ...routed.map(m => ({
47
- id: m.alias ?? `${m.provider}/${m.id}`,
48
- ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}),
49
- })),
50
- ];
38
+ const allRouted = await deps.fetchAllModels(config);
39
+ projection = projectGrokCatalog(allRouted, config);
51
40
  } catch (err) {
52
41
  return {
53
42
  ok: false,
@@ -58,9 +47,12 @@ export async function syncGrokConfig(
58
47
  // Pass the FULL list plus the exclusion set: the writer allocates aliases over
59
48
  // everything and emits only what is switched on, so a model's alias never depends on
60
49
  // its neighbours' switches. Absent/empty selection keeps today's behaviour exactly.
61
- return deps.injectGrokConfig(port, models, {
50
+ return deps.injectGrokConfig(port, projection.models, {
62
51
  ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
63
52
  ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}),
64
53
  excluded: new Set(config.grokExcludedModels ?? []),
54
+ catalogModelIds: projection.catalogModelIds,
55
+ disabledProviderNamespaces: projection.disabledProviderNamespaces,
56
+ comboPublicModelIds: projection.comboPublicModelIds,
65
57
  });
66
58
  }
@@ -21,7 +21,7 @@ import type { AttemptRecoveryKind } from "../usage/log";
21
21
  import { bridgeToResponsesSSE } from "../bridge";
22
22
  import { clearableDeadline, idleDeadline } from "../lib/abort";
23
23
  import { readBoundedResponseBody } from "../lib/bounded-body";
24
- import { fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
24
+ import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
25
25
  import { rateLimitRetryDelayMs } from "../providers/key-failover";
26
26
  import {
27
27
  isTranslatorBudgetExceededError,
@@ -521,12 +521,15 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
521
521
  deps.onAttemptSend?.(retryRecovery ?? recovery);
522
522
  const h = new Headers(request.headers);
523
523
  if (!h.has("accept-encoding")) h.set("accept-encoding", "identity");
524
- return fetchImpl(request.url, {
524
+ // Same reset-recovery parity as the web-search loop: the replay needs
525
+ // `keepalive: false` to abandon the pooled socket, because Bun has ignored the
526
+ // hop-by-hop header alone (oven-sh/bun#20492).
527
+ return fetchImpl(request.url, applyUpstreamRecoveryInit({
525
528
  method: request.method,
526
529
  headers: h,
527
530
  body: request.body,
528
531
  signal: headerDeadline.signal,
529
- });
532
+ }, retryRecovery));
530
533
  },
531
534
  { abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
532
535
  );
@@ -22,11 +22,14 @@ import {
22
22
  type ServiceStateEvidence,
23
23
  } from "../../service";
24
24
  import {
25
+ createWindowsTaskListingCache,
25
26
  inspectServiceManagerInstallation,
26
27
  type ProbeDeps,
27
28
  type ServiceManagerClaim,
28
29
  } from "../../service-manager-probe";
29
30
 
31
+ export { createWindowsTaskListingCache };
32
+
30
33
  export type NativeTeardownOwnership = { ok: true } | { ok: false; message: string };
31
34
 
32
35
  export function assertNativeTeardownOwned(): NativeTeardownOwnership {
@@ -199,4 +202,4 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
199
202
  return valid.length === 0
200
203
  ? { ownership: "owned", reason: "no service state and no service manager claim" }
201
204
  : { ownership: "owned", reason: "the recorded service state names these homes" };
202
- }
205
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { ensureRestrictedDir } from "../paths";
4
5
  import { FABRIC_LIMITS } from "./constants";
5
6
  import {
6
7
  FABRIC_PRODUCER_PROTOCOL_MAX_BYTES,
@@ -31,12 +32,44 @@ interface IsolateRequest {
31
32
  now?: () => number;
32
33
  }
33
34
 
34
- function minimalChildEnv(scratchRoot: string): Record<string, string> {
35
- return {
35
+ /**
36
+ * The environment an isolated producer child runs with.
37
+ *
38
+ * Exported so a test that spawns `producer-child.ts` directly cannot drift from
39
+ * the environment production actually uses. The Windows loader state and the
40
+ * scratch-owned temp paths below are load-bearing, and a test carrying its own
41
+ * literal copy of this object silently loses them.
42
+ */
43
+ export function minimalFabricChildEnv(scratchRoot: string): Record<string, string> {
44
+ const childTempDir = join(scratchRoot, ".tmp");
45
+ ensureRestrictedDir(childTempDir, scratchRoot);
46
+ const env: Record<string, string> = {
36
47
  TZ: "UTC",
37
48
  NO_COLOR: "1",
38
49
  OCX_FABRIC_SCRATCH_ROOT: scratchRoot,
50
+ // Executors commonly use os.tmpdir() through libraries they import. Keep
51
+ // those writes inside the same scratch boundary instead of forwarding the
52
+ // user's ambient temp directory (Windows) or falling back to /tmp (POSIX).
53
+ TEMP: childTempDir,
54
+ TMP: childTempDir,
55
+ TMPDIR: childTempDir,
39
56
  };
57
+ if (process.platform !== "win32") return env;
58
+ // Windows has no equivalent of "run with an (almost) empty environment". A
59
+ // CreateProcess child inherits nothing here, and the loader itself reads the
60
+ // environment: without SystemRoot it cannot resolve the system DLLs the Bun
61
+ // executable links against, so the child dies before its entry module runs.
62
+ // The parent then sees an immediate non-zero close with no protocol line and
63
+ // reports harness_failure -- which is what turned every CL-07 producer case
64
+ // into "inconclusive" on the Windows leg while POSIX stayed green.
65
+ //
66
+ // These are OS-owned loader state, not caller-supplied configuration. Temp
67
+ // state is deliberately not forwarded; it is rooted in scratch above.
68
+ for (const name of ["SystemRoot", "windir"] as const) {
69
+ const value = process.env[name];
70
+ if (value) env[name] = value;
71
+ }
72
+ return env;
40
73
  }
41
74
 
42
75
  function killChild(child: ChildProcess): void {
@@ -56,7 +89,7 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis
56
89
  let child: ChildProcess;
57
90
  try {
58
91
  child = spawn(process.execPath, ["run", CHILD_ENTRY], {
59
- env: minimalChildEnv(request.scratchRoot),
92
+ env: minimalFabricChildEnv(request.scratchRoot),
60
93
  stdio: ["pipe", "pipe", "pipe"],
61
94
  });
62
95
  } catch (error) {
@@ -68,6 +68,83 @@ function classifyIpv4(hostname: string): DestinationAssessment {
68
68
  return { kind: "public", detail: "public IP" };
69
69
  }
70
70
 
71
+ /**
72
+ * Expand an IPv6 literal into its eight hextets, or null when it is not one this can parse.
73
+ * `firstIpv6Hextet` below only needs the leading group; prefix matching needs the whole address,
74
+ * and `::` compression plus the RFC 4291 trailing dotted-quad form both have to be handled.
75
+ */
76
+ function ipv6Hextets(hostname: string): number[] | null {
77
+ let text = hostname;
78
+ const dotted = text.match(/(\d{1,3}(?:\.\d{1,3}){3})$/);
79
+ if (dotted?.index !== undefined) {
80
+ const octets = dotted[1].split(".").map(Number);
81
+ if (octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
82
+ text = text.slice(0, dotted.index)
83
+ + ((octets[0]! << 8) | octets[1]!).toString(16)
84
+ + ":"
85
+ + ((octets[2]! << 8) | octets[3]!).toString(16);
86
+ }
87
+ const halves = text.split("::");
88
+ if (halves.length > 2) return null;
89
+ const parseGroups = (part: string): number[] | null => {
90
+ if (!part) return [];
91
+ const out: number[] = [];
92
+ for (const piece of part.split(":")) {
93
+ if (!/^[0-9a-f]{1,4}$/i.test(piece)) return null;
94
+ out.push(Number.parseInt(piece, 16));
95
+ }
96
+ return out;
97
+ };
98
+ const head = parseGroups(halves[0] ?? "");
99
+ const tail = halves.length === 2 ? parseGroups(halves[1] ?? "") : [];
100
+ if (!head || !tail) return null;
101
+ if (halves.length === 1) return head.length === 8 ? head : null;
102
+ const fill = 8 - head.length - tail.length;
103
+ if (fill < 1) return null;
104
+ return [...head, ...Array<number>(fill).fill(0), ...tail];
105
+ }
106
+
107
+ /** RFC 6052 §2.1 well-known NAT64 prefix, 64:ff9b::/96, as its six leading hextets. */
108
+ const NAT64_WELL_KNOWN_PREFIX = [0x64, 0xff9b, 0, 0, 0, 0] as const;
109
+
110
+ /**
111
+ * `0:0:0:0:ffff:0::/96` — the explicit-zero spelling of a mapped IPv4 that some DNS resolvers
112
+ * return, e.g. `::ffff:0:c612:1b` for `198.18.0.27`.
113
+ *
114
+ * This is deliberately NOT taught to `classifyIpv6`. Under RFC 4291 the mapped prefix is
115
+ * `::ffff:0:0/96`, so `::ffff:0:c612:1b` is a reserved address whose tail merely LOOKS like an
116
+ * IPv4 — it is not equivalent to `198.18.0.27`. Treating the two as equal in the general
117
+ * classifier would admit `::ffff:0:5db8:d822` (tail `93.184.216.34`) as a public destination,
118
+ * which is the merge blocker a maintainer raised on #2812.
119
+ *
120
+ * The reported symptom is narrower than that equivalence: on a fake-IP resolver the answer
121
+ * assesses as `non-global address`, so the `allowBenchmarkAddresses` exception — which exists
122
+ * precisely for Clash/Surge/Mihomo fake-IP — could never be reached for this spelling. The fix
123
+ * therefore lives inside that opt-in, and only for a tail that is itself in `198.18.0.0/15`.
124
+ */
125
+ const EXPLICIT_ZERO_MAPPED_PREFIX = [0, 0, 0, 0, 0xffff, 0] as const;
126
+
127
+ /**
128
+ * True when this DNS answer may pass the `allowBenchmarkAddresses` opt-in.
129
+ *
130
+ * Ordinary benchmark answers (IPv4 `198.18/19`, canonical `::ffff:198.18.0.27`, and the NAT64
131
+ * form) already carry `detail: "benchmark address"` and pass through the first branch. The
132
+ * second branch adds ONLY the explicit-zero spelling, and only when its embedded quad is itself
133
+ * a benchmark address — so a public, loopback, private, or metadata-looking tail is refused.
134
+ */
135
+ function isBenchmarkDnsAnswer(address: string, assessment: DestinationAssessment | null): boolean {
136
+ if (assessment?.kind === "private" && assessment.detail === "benchmark address") return true;
137
+ if (isIP(address) !== 6) return false;
138
+ if (assessment?.kind !== "private" || assessment.detail !== "non-global address") return false;
139
+ const hextets = ipv6Hextets(normalizeHostname(address));
140
+ if (!hextets) return false;
141
+ if (!EXPLICIT_ZERO_MAPPED_PREFIX.every((group, index) => hextets[index] === group)) return false;
142
+ const hi = hextets[6]!;
143
+ const lo = hextets[7]!;
144
+ const embedded = classifyIpv4(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`);
145
+ return embedded.kind === "private" && embedded.detail === "benchmark address";
146
+ }
147
+
71
148
  function firstIpv6Hextet(hostname: string): number | null {
72
149
  const head = hostname.split(":")[0];
73
150
  if (!head) return 0;
@@ -89,6 +166,20 @@ function classifyIpv6(hostname: string): DestinationAssessment {
89
166
  const ipv4 = `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
90
167
  return classifyIpv4(ipv4);
91
168
  }
169
+ // NAT64 (RFC 6052): on an IPv6-only/DNS64 network every IPv4-only peer is synthesized into
170
+ // 64:ff9b::<ipv4>, whose leading hextet (0x64) is below the 2000::/3 global-unicast window and
171
+ // so fell through to "non-global address". That rejected ordinary public destinations for any
172
+ // user behind NAT64 — two tests already worked around it with `allowPrivateNetwork: true`.
173
+ // Classify the EMBEDDED IPv4 instead, exactly as the ::ffff: forms above do, so a wrapped
174
+ // 127.0.0.1 or 10/8 stays blocked rather than becoming an SSRF bypass. Only the well-known
175
+ // prefix is decoded; RFC 8215's 64:ff9b:1::/48 is reserved for local-use translation and keeps
176
+ // its non-global treatment.
177
+ const hextets = ipv6Hextets(hostname);
178
+ if (hextets && NAT64_WELL_KNOWN_PREFIX.every((group, index) => hextets[index] === group)) {
179
+ const hi = hextets[6]!;
180
+ const lo = hextets[7]!;
181
+ return classifyIpv4(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`);
182
+ }
92
183
  if (hostname === "::1") return { kind: "loopback", detail: "loopback address" };
93
184
  if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" };
94
185
  const hextet = firstIpv6Hextet(hostname);
@@ -250,11 +341,7 @@ export async function providerDestinationResolvedError(
250
341
  const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
251
342
  if (!assessment || assessment.kind === "public") continue;
252
343
  // Clash fake-IP only: 198.18/19 benchmark detail. Mixed dangerous sets still reject.
253
- if (
254
- options?.allowBenchmarkAddresses
255
- && assessment.kind === "private"
256
- && assessment.detail === "benchmark address"
257
- ) {
344
+ if (options?.allowBenchmarkAddresses && isBenchmarkDnsAnswer(address, assessment)) {
258
345
  continue;
259
346
  }
260
347
  if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`;
@@ -353,7 +440,7 @@ export async function resolvePublicAddresses(
353
440
  // fake-IP DNS, not a LAN provider. Accept it without allowPrivateNetwork and
354
441
  // do not mark the destination private, so the caller's HTTP(S)_PROXY path
355
442
  // still applies (credit #1748).
356
- if (benchmarkAllowed && assessment?.kind === "private" && assessment.detail === "benchmark address") {
443
+ if (benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) {
357
444
  validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) });
358
445
  continue;
359
446
  }
@@ -377,4 +464,3 @@ export async function resolvePublicAddresses(
377
464
  export async function assertUrlResolvesPublic(url: string): Promise<void> {
378
465
  await resolvePublicAddresses(url);
379
466
  }
380
-
package/src/lib/redact.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  export const REDACTED_SECRET = "[REDACTED]";
2
2
 
3
- const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
3
+ /**
4
+ * Credential-bearing header/field names. Exported for transports that must refuse to send
5
+ * credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than
6
+ * re-deriving a narrower local list.
7
+ */
8
+ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
4
9
 
5
10
  /**
6
11
  * Colon-labelled credential headers echoed back inside an error body
@@ -9,6 +9,23 @@
9
9
  */
10
10
  export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.6-luna"] as const;
11
11
 
12
+ /**
13
+ * Optional blocked model redirects at the shared routing layer.
14
+ * When `blockedModelRedirects` is configured (e.g. `{ "gpt-5.6-terra": "gpt-5.6-luna" }`),
15
+ * requests targeting those models are rewritten to the substitute model with
16
+ * routeReason "blocked-model-redirect".
17
+ * Returns undefined when not configured or the model is not in the redirect map.
18
+ */
19
+ export function resolveBlockedModelRedirect(
20
+ config: { blockedModelRedirects?: Record<string, string> } | undefined,
21
+ modelId: string,
22
+ ): string | undefined {
23
+ if (!config?.blockedModelRedirects || typeof config.blockedModelRedirects !== "object") {
24
+ return undefined;
25
+ }
26
+ return config.blockedModelRedirects[modelId];
27
+ }
28
+
12
29
  /** Normalize a persisted `sourceModels` override; falls back to the defaults. */
13
30
  export function shadowSourceModels(configured?: unknown): string[] {
14
31
  const configuredStrings = Array.isArray(configured)
@@ -44,18 +61,36 @@ export function shadowSourceModelPrefix(modelId: string, configured?: unknown):
44
61
  return shadowSourceModels(configured).find(prefix => modelId.startsWith(prefix));
45
62
  }
46
63
 
64
+ export interface ShadowCallModelIdentity {
65
+ providerName: string;
66
+ modelId: string;
67
+ }
68
+
69
+ /** Match a source prefix and replacement as a provider+model pair, never by slug alone. */
70
+ export function shadowCallTargetsIntersect(
71
+ source: ShadowCallModelIdentity,
72
+ target: ShadowCallModelIdentity,
73
+ ): boolean {
74
+ return source.providerName === target.providerName
75
+ && target.modelId.startsWith(source.modelId);
76
+ }
77
+
47
78
  /**
48
79
  * Decide whether a matching source model should use the opt-in intercept.
49
80
  *
50
81
  * Before Codex 0.147.0 this checked x-codex-turn-metadata and exempted
51
82
  * request_kind "turn". Codex 0.147.0 can label background helper calls as
52
83
  * "turn", causing them to bypass the intercept (#1684). The fix is to
53
- * intercept every configured shadow source model unconditionally the model
54
- * slug alone is a sufficient signal.
84
+ * intercept every configured shadow source model regardless of request kind.
85
+ * A replacement intersecting the same provider+model source set remains a
86
+ * no-op because rewriting it would only create self-interception (#2706).
55
87
  */
56
88
  export function shouldInterceptShadowCall(
57
89
  modelId: string,
58
90
  configured: unknown,
91
+ source: ShadowCallModelIdentity,
92
+ target: ShadowCallModelIdentity,
59
93
  ): boolean {
60
- return isShadowSourceModel(modelId, configured);
94
+ return isShadowSourceModel(modelId, configured)
95
+ && !shadowCallTargetsIntersect(source, target);
61
96
  }
@@ -58,15 +58,20 @@ function canonicalize(path: string): string {
58
58
  * `homedir()` later would return the sandbox and leave the real home unprotected — the
59
59
  * guard would be perfectly inverted while its tests still looked green.
60
60
  */
61
- const PROTECTED_HOME = canonicalize(
62
- join(process.env[REAL_HOME_ENV]?.trim() || homedir(), ".opencodex"),
63
- );
61
+ const REAL_HOME = process.env[REAL_HOME_ENV]?.trim() || homedir();
62
+ const PROTECTED_HOME = canonicalize(join(REAL_HOME, ".opencodex"));
63
+ const PROTECTED_CODEX_HOME = canonicalize(join(REAL_HOME, ".codex"));
64
64
 
65
65
  /** The production home this process protects. Exported for the guard's own tests. */
66
66
  export function protectedHomeForTests(): string {
67
67
  return PROTECTED_HOME;
68
68
  }
69
69
 
70
+ /** The production Codex home this process protects when tests write native credentials. */
71
+ export function protectedCodexHomeForTests(): string {
72
+ return PROTECTED_CODEX_HOME;
73
+ }
74
+
70
75
  export function isTestHomeGuardArmed(): boolean {
71
76
  return process.env[GUARD_ENV] === "1";
72
77
  }
@@ -88,3 +93,13 @@ export function assertNotRealHomeUnderTest(dir: string): void {
88
93
  + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).",
89
94
  );
90
95
  }
96
+
97
+ /** Throw when an armed test process is about to write the real native Codex home. */
98
+ export function assertNotRealCodexHomeUnderTest(dir: string): void {
99
+ if (!isTestHomeGuardArmed()) return;
100
+ if (canonicalize(dir) !== PROTECTED_CODEX_HOME) return;
101
+ throw new Error(
102
+ `refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. `
103
+ + "Point CODEX_HOME at a temp directory for this test before writing native auth.json.",
104
+ );
105
+ }
@@ -239,12 +239,19 @@ export interface ResetRetryOptions {
239
239
  abortSignal?: AbortSignal;
240
240
  /** Short host/path label for the retry warn log (no secrets/query strings). */
241
241
  label?: string;
242
+ /** Total upstream sends allowed, including the first one. Not a per-layer retry count. */
242
243
  attempts?: number;
243
244
  }
244
245
 
245
246
  export interface TransientRetryOptions extends ResetRetryOptions {
246
247
  /** Test seam: per-attempt slow budget override (defaults to TRANSIENT_RETRY_SLOW_ATTEMPT_MS). */
247
248
  slowAttemptMs?: number;
249
+ /**
250
+ * Reports how many upstream sends this call actually consumed, so a caller that spans
251
+ * several legs of one request (initial send, then a 429/account-recovery refetch) can
252
+ * keep them on ONE budget instead of handing each leg a fresh one.
253
+ */
254
+ onSendsConsumed?: (sends: number) => void;
248
255
  }
249
256
 
250
257
  export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";
@@ -353,23 +360,47 @@ export async function fetchWithResetRetry(
353
360
  * its body intact. Honors Retry-After via retryBackoffDelayMs.
354
361
  *
355
362
  * A failed attempt slower than the slow budget is returned as-is (slow-502 shape);
356
- * note `opts.attempts` is shared with the inner reset layer (no caller passes it today).
363
+ * `opts.attempts` is ONE total-send budget covering this layer and the inner reset layer
364
+ * together, so it bounds the real number of upstream requests rather than multiplying.
357
365
  */
358
366
  export async function fetchWithTransientRetry(
359
367
  doFetch: ReplayableFetch,
360
368
  opts: TransientRetryOptions = {},
361
369
  ): Promise<Response> {
362
- const attempts = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS);
370
+ const budget = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS);
363
371
  const slowAttemptMs = opts.slowAttemptMs ?? TRANSIENT_RETRY_SLOW_ATTEMPT_MS;
364
372
  const transientStatuses: number[] = [];
373
+ // `attempts` is ONE total-send budget shared with the inner reset layer, not a per-layer
374
+ // count. Forwarding it into every `fetchWithResetRetry` made the two multiply: with
375
+ // `attempts: 3` the outer loop ran 3 transient rounds and each round independently retried
376
+ // 3 connection resets, so a single call could emit 9 upstream sends — and 10 could emit 100.
377
+ // That was harmless only because no caller passed `attempts`; the provider-level
378
+ // `transientRetryOn5xx` policy is the first one that does, and multiplying load against an
379
+ // already-failing provider is worse than not retrying at all.
380
+ let sent = 0;
381
+ const countedFetch: ReplayableFetch = (recovery) => {
382
+ // Incremented BEFORE the await so a rejected send still consumes budget; counting only
383
+ // successes would let a reset storm loop without bound.
384
+ sent += 1;
385
+ return doFetch(recovery);
386
+ };
387
+ // Floor of 1 keeps the inner call legal once the budget is spent; the loop condition, not a
388
+ // zero-attempt inner call, is what actually stops the retries.
389
+ const remaining = () => Math.max(1, budget - sent);
390
+ // Reported in `finally` rather than at each exit: this function returns from five places
391
+ // and throws from one, and a caller sharing the budget across request legs must be told the
392
+ // real count on every one of them.
393
+ try {
365
394
  let attemptStart = Date.now();
366
- let res = await fetchWithResetRetry(doFetch, opts);
367
- for (let attempt = 0; attempt < attempts - 1; attempt++) {
395
+ let res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() });
396
+ for (let attempt = 0; sent < budget; attempt++) {
368
397
  if (res.ok || !isTransientUpstreamStatus(res.status)) return res;
398
+ // Checked before cancelResponseBodyBestEffort so an already-aborted caller never receives
399
+ // a response whose body we just cancelled.
369
400
  if (opts.abortSignal?.aborted) return res;
370
401
  if (Date.now() - attemptStart > slowAttemptMs) return res;
371
402
  console.warn(
372
- `[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
403
+ `[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${sent + 1}/${budget})`,
373
404
  );
374
405
  const delay = retryBackoffDelayMs(attempt, {
375
406
  baseDelayMs: TRANSIENT_RETRY_BASE_DELAY_MS,
@@ -377,16 +408,22 @@ export async function fetchWithTransientRetry(
377
408
  headers: res.headers,
378
409
  });
379
410
  cancelResponseBodyBestEffort(res);
411
+ // Throws on abort (see sleepWithAbort): the rejection propagates, and the body we just
412
+ // cancelled belonged to a response we were discarding anyway.
380
413
  await sleepWithAbort(delay, opts.abortSignal);
381
414
  attemptStart = Date.now();
382
415
  transientStatuses.push(res.status);
383
416
  try {
384
- res = await fetchWithResetRetry(doFetch, opts, "transient-5xx");
417
+ res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() }, "transient-5xx");
385
418
  } catch (err) {
386
419
  // Keep the prior 5xx evidence attached: the origin already responded, so
387
420
  // this rejection is not pre-connection and must not classify as neutral.
388
421
  throw new UpstreamRetryEvidenceError(transientStatuses, err);
389
422
  }
390
423
  }
424
+ // Budget exhausted: the last response is returned with its body intact.
391
425
  return res;
426
+ } finally {
427
+ opts.onSendsConsumed?.(sent);
428
+ }
392
429
  }