@bitkyc08/opencodex 2.7.9-preview.20260712.1 → 2.7.9

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 (39) hide show
  1. package/README.md +3 -1
  2. package/gui/dist/assets/index-BAAFKwsh.js +40 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +2 -2
  5. package/src/adapters/cursor/transport-retry.ts +5 -3
  6. package/src/adapters/google-errors.ts +9 -19
  7. package/src/adapters/google-http.ts +29 -66
  8. package/src/adapters/kiro-errors.ts +10 -23
  9. package/src/adapters/kiro-retry.ts +26 -58
  10. package/src/adapters/upstream-http-error.ts +48 -0
  11. package/src/bridge.ts +6 -2
  12. package/src/claude/gateway-cache.ts +3 -3
  13. package/src/claude/outbound.ts +117 -40
  14. package/src/cli/claude.ts +36 -4
  15. package/src/config.ts +54 -3
  16. package/src/lib/destination-policy.ts +167 -0
  17. package/src/lib/injection-debug-log.ts +34 -0
  18. package/src/lib/upstream-retry.ts +53 -3
  19. package/src/lib/windows-secret-acl.ts +173 -0
  20. package/src/oauth/index.ts +9 -7
  21. package/src/oauth/store.ts +1 -0
  22. package/src/providers/registry.ts +10 -3
  23. package/src/providers/xai-transport.ts +89 -0
  24. package/src/router.ts +6 -1
  25. package/src/server/auth-cors.ts +4 -0
  26. package/src/server/claude-messages.ts +32 -2
  27. package/src/server/management-api.ts +159 -33
  28. package/src/server/request-decompress.ts +45 -12
  29. package/src/server/responses.ts +21 -12
  30. package/src/server/system-env.ts +110 -68
  31. package/src/service.ts +4 -0
  32. package/src/types.ts +25 -5
  33. package/src/vision/anthropic-describe.ts +185 -0
  34. package/src/vision/index.ts +219 -10
  35. package/src/web-search/anthropic-executor.ts +187 -0
  36. package/src/web-search/executor.ts +4 -2
  37. package/src/web-search/index.ts +80 -18
  38. package/src/web-search/loop.ts +14 -2
  39. package/gui/dist/assets/index-Csp2AZYr.js +0 -40
@@ -14,12 +14,19 @@
14
14
  * MUST stay a leaf module: imports nothing from server.ts or adapters (kiro-retry imports
15
15
  * the shared abort helpers from here).
16
16
  */
17
+ import { clearableDeadline } from "./abort";
17
18
 
18
19
  // 1 initial + 2 retries: the pool may hold more than one stale socket.
19
20
  const RESET_RETRY_MAX_ATTEMPTS = 3;
20
21
  const RESET_RETRY_BASE_DELAY_MS = 150;
21
22
  const RESET_RETRY_MAX_DELAY_MS = 1_000;
22
23
 
24
+ export interface RetryBackoffOptions {
25
+ baseDelayMs: number;
26
+ maxDelayMs: number;
27
+ headers?: Headers;
28
+ }
29
+
23
30
  export function abortError(signal?: AbortSignal): unknown {
24
31
  return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
25
32
  }
@@ -56,11 +63,51 @@ export function isConnectionResetError(err: unknown): boolean {
56
63
  || msg.includes("connection reset by peer");
57
64
  }
58
65
 
59
- function retryDelayMs(attempt: number): number {
60
- const exp = Math.min(RESET_RETRY_BASE_DELAY_MS * (2 ** attempt), RESET_RETRY_MAX_DELAY_MS);
66
+ function retryAfterDelayMs(headers: Headers): number | undefined {
67
+ const raw = headers.get("retry-after")?.trim();
68
+ if (!raw) return undefined;
69
+ const seconds = Number(raw);
70
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
71
+ const dateMs = Date.parse(raw);
72
+ if (!Number.isFinite(dateMs)) return undefined;
73
+ return Math.max(0, dateMs - Date.now());
74
+ }
75
+
76
+ export function retryBackoffDelayMs(attempt: number, opts: RetryBackoffOptions): number {
77
+ const retryAfter = opts.headers ? retryAfterDelayMs(opts.headers) : undefined;
78
+ if (retryAfter !== undefined) return Math.min(retryAfter, opts.maxDelayMs);
79
+ const exp = Math.min(opts.baseDelayMs * (2 ** attempt), opts.maxDelayMs);
61
80
  return Math.floor(exp * (0.8 + Math.random() * 0.4));
62
81
  }
63
82
 
83
+ export function cancelResponseBodyBestEffort(res: Response): void {
84
+ try {
85
+ const cancellation = res.body?.cancel();
86
+ if (cancellation) void cancellation.catch(() => {});
87
+ } catch {
88
+ // Cancellation is cleanup only; retries must not wait for or fail because of it.
89
+ }
90
+ }
91
+
92
+ export async function fetchWithAttemptDeadline(
93
+ url: string,
94
+ init: RequestInit,
95
+ timeoutMs: number,
96
+ abortSignal?: AbortSignal,
97
+ ): Promise<Response> {
98
+ const attemptTimeout = clearableDeadline(timeoutMs, abortSignal);
99
+ try {
100
+ return await fetch(url, {
101
+ ...init,
102
+ signal: attemptTimeout.signal,
103
+ });
104
+ } finally {
105
+ // Only the header timer is cleared. The composed signal still contains the parent, so a
106
+ // caller abort after headers continue to cancel consumption of the returned response body.
107
+ attemptTimeout.clear();
108
+ }
109
+ }
110
+
64
111
  export interface ResetRetryOptions {
65
112
  abortSignal?: AbortSignal;
66
113
  /** Short host/path label for the retry warn log (no secrets/query strings). */
@@ -89,7 +136,10 @@ export async function fetchWithResetRetry(
89
136
  console.warn(
90
137
  `[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
91
138
  );
92
- await sleepWithAbort(retryDelayMs(attempt), opts.abortSignal);
139
+ await sleepWithAbort(retryBackoffDelayMs(attempt, {
140
+ baseDelayMs: RESET_RETRY_BASE_DELAY_MS,
141
+ maxDelayMs: RESET_RETRY_MAX_DELAY_MS,
142
+ }), opts.abortSignal);
93
143
  }
94
144
  }
95
145
  throw lastError ?? new Error("upstream fetch failed");
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Windows per-user NTFS ACL hardening for secret files and directories.
3
+ *
4
+ * On Windows, `chmod` only controls POSIX-style bits in the ACE list and does NOT remove
5
+ * inherited permissions from other users. Real per-user isolation requires icacls to:
6
+ * 1. Disable inheritance (icacls path /inheritance:r)
7
+ * 2. Strip broad explicit grants by SID (Everyone, Users, Authenticated Users)
8
+ * 3. Grant the current user full control (icacls path /grant:r "CURRENTUSER:(F)")
9
+ *
10
+ * On non-Windows platforms the helpers fall through to the caller's existing chmod-based
11
+ * behaviour: they return ok:true without invoking any external process.
12
+ *
13
+ * Design:
14
+ * hardenSecretPath(path, { required: false }) — non-fatal read-path mode.
15
+ * Never throws. Returns { ok, diagnostics? }.
16
+ * hardenSecretPath(path, { required: true }) — write-path mode.
17
+ * Throws a sanitized error (no raw path) on Windows ACL failure.
18
+ * hardenSecretDir — same contract for directories.
19
+ */
20
+
21
+ import { execFileSync } from "node:child_process";
22
+ import { existsSync } from "node:fs";
23
+ import { env, platform } from "node:process";
24
+
25
+ const hardenedDirectories = new Set<string>();
26
+ const hardenedPaths = new Set<string>();
27
+
28
+ export interface HardenResult {
29
+ ok: boolean;
30
+ diagnostics?: string;
31
+ }
32
+
33
+ export interface HardenOptions {
34
+ required: boolean;
35
+ }
36
+
37
+ /**
38
+ * Return the current Windows username from the environment.
39
+ * Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous.
40
+ * The value is used directly in icacls arguments, so it must be present.
41
+ */
42
+ function currentWindowsUser(): string | undefined {
43
+ const username = env["USERNAME"];
44
+ const domain = env["USERDOMAIN"];
45
+ if (!username) return undefined;
46
+ // USERDOMAIN is the machine/domain name; USERNAME is the account name.
47
+ // icacls accepts "DOMAIN\User" or just "User" for local accounts.
48
+ return domain ? `${domain}\\${username}` : username;
49
+ }
50
+
51
+ /**
52
+ * Run icacls to harden a single file system entry.
53
+ * - Disables inheritance (keeps nothing: /inheritance:r)
54
+ * - Grants the current user Full Control
55
+ *
56
+ * We do NOT use a shell string; all arguments are passed as an array so no
57
+ * shell injection is possible even for paths with unusual characters.
58
+ *
59
+ * Throws the raw child_process error on failure (caller sanitizes).
60
+ */
61
+ function runIcacls(targetPath: string, directory: boolean): void {
62
+ const user = currentWindowsUser();
63
+ if (!user) {
64
+ throw new Error("Cannot determine current Windows user for ACL hardening");
65
+ }
66
+
67
+ // Step 1: disable inheritance and remove inherited ACEs
68
+ execFileSync("icacls.exe", [targetPath, "/inheritance:r"], {
69
+ stdio: ["ignore", "pipe", "ignore"],
70
+ timeout: 5000,
71
+ shell: false,
72
+ });
73
+
74
+ // Step 2: remove broad explicit grants using stable SIDs (not localized names).
75
+ execFileSync("icacls.exe", [
76
+ targetPath,
77
+ "/remove:g",
78
+ "*S-1-1-0",
79
+ "*S-1-5-11",
80
+ "*S-1-5-32-545",
81
+ ], {
82
+ stdio: ["ignore", "pipe", "ignore"],
83
+ timeout: 5000,
84
+ shell: false,
85
+ });
86
+
87
+ // Step 3: grant current user full control.
88
+ const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
89
+ execFileSync("icacls.exe", [targetPath, "/grant:r", grant], {
90
+ stdio: ["ignore", "pipe", "ignore"],
91
+ timeout: 5000,
92
+ shell: false,
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Sanitize an error from a failed ACL operation into a safe diagnostic string.
98
+ * The raw path must not appear in the returned string (it may contain
99
+ * sensitive username components or PII from the home directory path).
100
+ */
101
+ function sanitizeDiagnostics(error: unknown): string {
102
+ // We do not expose the raw error message or any path-like fragments.
103
+ // Just describe what failed generically.
104
+ const code = error instanceof Error && "code" in error ? String((error as NodeJS.ErrnoException).code) : "";
105
+ const codePart = code ? ` (${code})` : "";
106
+ return `ACL hardening failed${codePart} — filesystem may not support per-user NTFS ACLs`;
107
+ }
108
+
109
+ /**
110
+ * Harden a single file path with per-user NTFS ACLs on Windows.
111
+ * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
112
+ *
113
+ * @param targetPath Absolute path to the file to harden.
114
+ * @param opts { required: boolean } — required:true throws on failure.
115
+ */
116
+ export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult {
117
+ // Skip for missing files — we cannot harden what does not exist yet.
118
+ if (!existsSync(targetPath)) {
119
+ return { ok: true };
120
+ }
121
+
122
+ // Non-Windows: no NTFS ACLs; caller handles chmod.
123
+ if (platform !== "win32") {
124
+ return { ok: true };
125
+ }
126
+
127
+ if (hardenedPaths.has(targetPath)) return { ok: true };
128
+
129
+ try {
130
+ runIcacls(targetPath, false);
131
+ hardenedPaths.add(targetPath);
132
+ return { ok: true };
133
+ } catch (err) {
134
+ const diagnostics = sanitizeDiagnostics(err);
135
+ if (opts.required) {
136
+ throw new Error(diagnostics);
137
+ }
138
+ return { ok: false, diagnostics };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Harden a directory path with per-user NTFS ACLs on Windows.
144
+ * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
145
+ *
146
+ * @param targetPath Absolute path to the directory to harden.
147
+ * @param opts { required: boolean } — required:true throws on failure.
148
+ */
149
+ export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult {
150
+ // Skip for missing directories — we cannot harden what does not exist yet.
151
+ if (!existsSync(targetPath)) {
152
+ return { ok: true };
153
+ }
154
+
155
+ // Non-Windows: no NTFS ACLs; caller handles chmod.
156
+ if (platform !== "win32") {
157
+ return { ok: true };
158
+ }
159
+
160
+ if (hardenedDirectories.has(targetPath)) return { ok: true };
161
+
162
+ try {
163
+ runIcacls(targetPath, true);
164
+ hardenedDirectories.add(targetPath);
165
+ return { ok: true };
166
+ } catch (err) {
167
+ const diagnostics = sanitizeDiagnostics(err);
168
+ if (opts.required) {
169
+ throw new Error(diagnostics);
170
+ }
171
+ return { ok: false, diagnostics };
172
+ }
173
+ }
@@ -12,6 +12,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"
12
12
  import { loginCursor, refreshCursorToken } from "./cursor";
13
13
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
14
14
  import { effectiveGoogleMode } from "../providers/registry";
15
+ import { resolveProviderTransport } from "../providers/xai-transport";
15
16
 
16
17
  const REFRESH_SKEW_MS = 60_000;
17
18
  const tokenRefreshes = new Map<string, Promise<string>>();
@@ -254,27 +255,28 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
254
255
  * response.
255
256
  */
256
257
  export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
257
- const headers: Record<string, string> = { ...(prov.headers ?? {}) };
258
- if (effectiveGoogleMode(providerName, prov) === "ai-studio") {
258
+ const effectiveProvider = resolveProviderTransport(providerName, prov);
259
+ const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
260
+ if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
259
261
  // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
260
262
  // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
261
263
  // enough to list everything without a pageToken loop. Vertex/antigravity keep the
262
264
  // generic branch (they fall back to their static model lists).
263
265
  if (apiKey) headers["x-goog-api-key"] = apiKey;
264
- return { url: `${prov.baseUrl}/v1beta/models?pageSize=1000`, headers };
266
+ return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers };
265
267
  }
266
- if (prov.adapter === "anthropic") {
268
+ if (effectiveProvider.adapter === "anthropic") {
267
269
  headers["anthropic-version"] = "2023-06-01";
268
- if (prov.authMode === "oauth") {
270
+ if (effectiveProvider.authMode === "oauth") {
269
271
  headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
270
272
  if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
271
273
  } else if (apiKey) {
272
274
  headers["x-api-key"] = apiKey;
273
275
  }
274
- return { url: `${prov.baseUrl}/v1/models?limit=1000`, headers };
276
+ return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers };
275
277
  }
276
278
  if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
277
- return { url: `${prov.baseUrl}/models`, headers };
279
+ return { url: `${effectiveProvider.baseUrl}/models`, headers };
278
280
  }
279
281
 
280
282
  /**
@@ -54,6 +54,7 @@ function persist(store: AuthStore): void {
54
54
  } else {
55
55
  try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
56
56
  }
57
+ hardenConfigDir();
57
58
  atomicWriteFile(authPath(), JSON.stringify(store, null, 2) + "\n");
58
59
  }
59
60
 
@@ -18,6 +18,7 @@ export interface ProviderRegistryEntry {
18
18
  adapter: string;
19
19
  baseUrl: string;
20
20
  authKind: ProviderAuthKind;
21
+ allowPrivateNetworkByDefault?: boolean;
21
22
  keyOptional?: boolean;
22
23
  allowBaseUrlOverride?: boolean;
23
24
  modelSuffixBracketStrip?: boolean;
@@ -235,6 +236,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
235
236
  models: ["grok-4.5", "grok-4.3", "grok-4.20-multi-agent-0309", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
236
237
  defaultModel: "grok-4.5",
237
238
  noReasoningModels: ["grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
239
+ // Replay assistant reasoning_content for grok reasoning models: xAI documents dropped
240
+ // reasoning_content as the top cause of prompt-cache misses on multi-turn conversations
241
+ // (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching).
242
+ // Models that never emit reasoning simply have no thinking parts to replay (no-op).
243
+ preserveReasoningContentModels: ["grok-4.5", "grok-4.3", "grok-4.20-multi-agent-0309", "grok-4.20-0309-reasoning"],
238
244
  // grok-4.5 reasoning is always-on with low/medium/high control (no off tier upstream).
239
245
  modelReasoningEfforts: { "grok-4.5": ["low", "medium", "high"] },
240
246
  modelContextWindows: {
@@ -436,9 +442,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
436
442
  { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
437
443
  { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.5-flash-low", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
438
444
  { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
439
- { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
440
- { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
441
- { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" },
445
+ { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
446
+ { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
447
+ { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" },
442
448
  {
443
449
  id: "deepseek",
444
450
  label: "DeepSeek",
@@ -521,6 +527,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
521
527
  {
522
528
  id: "litellm", label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", authKind: "key",
523
529
  dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start",
530
+ allowPrivateNetworkByDefault: true,
524
531
  allowBaseUrlOverride: true,
525
532
  // A self-hosted proxy may legitimately run without a master key.
526
533
  keyOptional: true,
@@ -0,0 +1,89 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxProviderConfig } from "../types";
3
+
4
+ /**
5
+ * xAI account OAuth and xAI API keys share a bearer shape but not a billing
6
+ * transport. OAuth represents the Grok CLI subscription entitlement, while a
7
+ * key represents the API team. Keep the saved provider preset compatible with
8
+ * the dashboard's "Use an API key instead" switch and resolve the transport at
9
+ * request time.
10
+ */
11
+ export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
12
+
13
+ /** Minimum-compatible official Grok CLI wire version verified with the proxy. */
14
+ export const XAI_GROK_CLIENT_VERSION = "0.2.93";
15
+
16
+ const XAI_GROK_CLI_HEADERS: Readonly<Record<string, string>> = {
17
+ "x-grok-client-identifier": "opencodex",
18
+ "x-grok-client-version": XAI_GROK_CLIENT_VERSION,
19
+ "x-xai-token-auth": "xai-grok-cli",
20
+ };
21
+
22
+ /**
23
+ * Sticky-routing hint for xAI's automatic prefix cache. xAI routes requests
24
+ * carrying the same `x-grok-conv-id` to the same server, which is where the
25
+ * prompt cache lives (docs.x.ai prompt-caching best-practices; verified
26
+ * 2026-07-13, devlog/_plan/260713_grok_caching). Codex clients send a stable
27
+ * per-conversation `prompt_cache_key`; hash it so the raw session id never
28
+ * leaves the proxy.
29
+ */
30
+ export const XAI_CONV_ID_HEADER = "x-grok-conv-id";
31
+
32
+ function hasHeaderCaseInsensitive(headers: Record<string, string> | undefined, name: string): boolean {
33
+ if (!headers) return false;
34
+ const target = name.toLowerCase();
35
+ return Object.keys(headers).some(key => key.toLowerCase() === target);
36
+ }
37
+
38
+ /** Drop default entries the user already overrides under any header-name casing. */
39
+ function withoutUserOverridden(defaults: Readonly<Record<string, string>>, userHeaders: Record<string, string> | undefined): Record<string, string> {
40
+ if (!userHeaders) return { ...defaults };
41
+ const out: Record<string, string> = {};
42
+ for (const [key, value] of Object.entries(defaults)) {
43
+ if (!hasHeaderCaseInsensitive(userHeaders, key)) out[key] = value;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ export function deriveXaiConvId(promptCacheKey: string): string {
49
+ return createHash("sha256").update(promptCacheKey).digest("hex").slice(0, 32);
50
+ }
51
+
52
+ /**
53
+ * Resolve the effective xAI transport without mutating persisted config.
54
+ * User-provided headers are preserved and may advance the compatibility
55
+ * version without waiting for an opencodex release.
56
+ *
57
+ * `promptCacheKey` (the client's stable conversation key) additionally pins
58
+ * cache-affinity routing via `x-grok-conv-id` in BOTH auth modes. Blank or
59
+ * whitespace-only keys are ignored so unrelated requests can never collapse
60
+ * onto one shared conv id, and any user-configured header (any case) wins.
61
+ */
62
+ export function resolveProviderTransport(
63
+ providerName: string,
64
+ provider: OcxProviderConfig,
65
+ promptCacheKey?: string,
66
+ ): OcxProviderConfig {
67
+ if (providerName !== "xai") return provider;
68
+ const cacheKey = promptCacheKey?.trim();
69
+ const convIdHeaders: Record<string, string> =
70
+ cacheKey && !hasHeaderCaseInsensitive(provider.headers, XAI_CONV_ID_HEADER)
71
+ ? { [XAI_CONV_ID_HEADER]: deriveXaiConvId(cacheKey) }
72
+ : {};
73
+ if (provider.authMode !== "oauth") {
74
+ if (Object.keys(convIdHeaders).length === 0) return provider;
75
+ return {
76
+ ...provider,
77
+ headers: { ...convIdHeaders, ...(provider.headers ?? {}) },
78
+ };
79
+ }
80
+ return {
81
+ ...provider,
82
+ baseUrl: XAI_GROK_CLI_BASE_URL,
83
+ headers: {
84
+ ...withoutUserOverridden(XAI_GROK_CLI_HEADERS, provider.headers),
85
+ ...convIdHeaders,
86
+ ...(provider.headers ?? {}),
87
+ },
88
+ };
89
+ }
package/src/router.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OcxConfig, OcxProviderConfig } from "./types";
2
2
  import { hasOwnProvider, resolveEnvValue } from "./config";
3
+ import { assertProviderDestinationAllowed } from "./lib/destination-policy";
3
4
  import { PROVIDER_REGISTRY } from "./providers/registry";
4
5
 
5
6
  interface RouteResult {
@@ -79,7 +80,10 @@ function mergeStringArrayRecord(
79
80
 
80
81
  function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
81
82
  const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
82
- if (!registryEntry) return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
83
+ if (!registryEntry) {
84
+ assertProviderDestinationAllowed(providerName, provider);
85
+ return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
86
+ }
83
87
  const canonicalAuthMode = registryEntry.authKind === "forward" || registryEntry.authKind === "oauth"
84
88
  ? registryEntry.authKind
85
89
  : provider.authMode === "forward" ? undefined : provider.authMode;
@@ -107,6 +111,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
107
111
  const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved
108
112
  ? userBaseUrl
109
113
  : registryEntry.baseUrl;
114
+ assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork });
110
115
 
111
116
  return {
112
117
  ...provider,
@@ -5,6 +5,7 @@ import {
5
5
  providerBaseUrlConfigError,
6
6
  providerHeadersConfigError,
7
7
  } from "../config";
8
+ import { providerDestinationConfigError } from "../lib/destination-policy";
8
9
  import type { OcxConfig, OcxProviderConfig } from "../types";
9
10
 
10
11
  let _corsOrigin = "http://localhost:10100";
@@ -156,6 +157,8 @@ export function requireApiAuth(req: Request, config: OcxConfig, kind: "managemen
156
157
  export function providerManagementConfigError(name: string, provider: OcxProviderConfig): string | null {
157
158
  const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
158
159
  if (baseUrlError) return `provider ${name} ${baseUrlError}`;
160
+ const destinationError = providerDestinationConfigError(name, provider);
161
+ if (destinationError) return `provider ${name} ${destinationError}`;
159
162
  const headersError = providerHeadersConfigError(provider.headers);
160
163
  if (headersError) return `provider ${name} ${headersError}`;
161
164
  if (provider.authMode === "forward") {
@@ -205,6 +208,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
205
208
  for (const key of [
206
209
  "defaultModel",
207
210
  "disabled",
211
+ "allowPrivateNetwork",
208
212
  "authMode",
209
213
  "liveModels",
210
214
  "models",
@@ -31,6 +31,21 @@ function isRec(v: unknown): v is Rec {
31
31
  return !!v && typeof v === "object" && !Array.isArray(v);
32
32
  }
33
33
 
34
+ /** Resolve Claude-only sidecar overrides without mutating the shared server config. */
35
+ export function buildClaudeReplayConfig(config: OcxConfig): OcxConfig {
36
+ return {
37
+ ...config,
38
+ webSearchSidecar: {
39
+ ...config.webSearchSidecar,
40
+ ...config.claudeCode?.webSearchSidecar,
41
+ },
42
+ visionSidecar: {
43
+ ...config.visionSidecar,
44
+ ...config.claudeCode?.visionSidecar,
45
+ },
46
+ };
47
+ }
48
+
34
49
  function claudeInboundDisabled(config: OcxConfig): Response | null {
35
50
  if (config.claudeCode?.enabled === false) {
36
51
  return anthropicErrorResponse(403, "Claude inbound is disabled (GUI: Claude ON toggle / config.claudeCode.enabled)", "permission_error");
@@ -178,15 +193,21 @@ async function anthropicNativePassthrough(
178
193
  });
179
194
  headers.set("content-type", "application/json");
180
195
 
196
+ const timeoutSignal = AbortSignal.timeout(config.connectTimeoutMs ?? 120_000);
197
+ const upstreamSignal = AbortSignal.any([req.signal, timeoutSignal]);
181
198
  let upstream: Response;
182
199
  try {
183
200
  upstream = await fetch(`${base}${pathname}${search}`, {
184
201
  method: "POST",
185
202
  headers,
186
203
  body: JSON.stringify(body),
187
- signal: req.signal,
204
+ signal: upstreamSignal,
188
205
  });
189
206
  } catch (err) {
207
+ if (timeoutSignal.aborted && upstreamSignal.reason === timeoutSignal.reason) {
208
+ finalize(504, { closeReason: "non_stream" });
209
+ return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error");
210
+ }
190
211
  finalize(502, { closeReason: "non_stream" });
191
212
  return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error");
192
213
  }
@@ -326,6 +347,15 @@ export async function handleClaudeMessages(
326
347
  const value = req.headers.get(name);
327
348
  if (value) headers.set(name, value);
328
349
  }
350
+ if (!nativeRoute) {
351
+ // Routed replays need main ChatGPT auth so OpenAI-backed sidecars remain reachable.
352
+ const { getMainAccountToken } = await import("../codex/main-account");
353
+ const token = getMainAccountToken();
354
+ if (token) {
355
+ headers.set("authorization", `Bearer ${token.accessToken}`);
356
+ headers.set("chatgpt-account-id", token.chatgptAccountId);
357
+ }
358
+ }
329
359
  if (nativeRoute) {
330
360
  // No forwarded ChatGPT auth exists on this surface. Attach the main codex login
331
361
  // (read-only auth.json token); account-pool rotation still overrides downstream.
@@ -362,7 +392,7 @@ export async function handleClaudeMessages(
362
392
  nativeLogged = true;
363
393
  addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
364
394
  };
365
- const upstream = await handleResponses(internalReq, config, logCtx, {
395
+ const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, {
366
396
  abortSignal: req.signal,
367
397
  onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
368
398
  onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),