@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -22,6 +22,7 @@ export interface ProviderRegistryEntry {
22
22
  label: string;
23
23
  adapter: string;
24
24
  baseUrl: string;
25
+ apiKeyTransport?: OcxProviderConfig["apiKeyTransport"];
25
26
  authKind: ProviderAuthKind;
26
27
  codexAccountMode?: CodexAccountMode;
27
28
  /** OAuth preset may explicitly honor a persisted API-key billing mode. */
@@ -88,7 +89,7 @@ export interface ProviderRegistryEntry {
88
89
 
89
90
  export type ProviderConfigSeed = Pick<
90
91
  OcxProviderConfig,
91
- "adapter" | "baseUrl" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models"
92
+ "adapter" | "baseUrl" | "apiKeyTransport" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models"
92
93
  | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities"
93
94
  | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens"
94
95
  | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
@@ -480,6 +481,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
480
481
  baseUrl: "https://api.kimi.com/coding/v1",
481
482
  authKind: "oauth",
482
483
  modelSuffixBracketStrip: true,
484
+ // Kimi Code Plan documents a stable session/task prompt_cache_key as required to improve
485
+ // cache hit rates.
486
+ // The chat adapter only forwards a key already on the internal request (Codex's session key,
487
+ // or the one the Claude /v1/messages inbound derives); the adapter itself never invents one.
488
+ // Evidence: https://platform.kimi.com/docs/api/chat
489
+ promptCacheKey: true,
483
490
  featured: true,
484
491
  oauthId: "kimi",
485
492
  jawcodeBundle: "moonshot",
@@ -506,7 +513,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
506
513
  baseUrl: "https://runtime.us-east-1.kiro.dev",
507
514
  authKind: "oauth",
508
515
  oauthId: "kiro",
509
- note: "Import-first: reuses your installed Kiro CLI login requires kiro-cli installed and signed in (`kiro-cli login`). Experimental third-party harness — see Kiro ToS.",
516
+ note: "Import-first: reuses your installed and signed-in Kiro CLI session (requires `kiro-cli login`). Add account logs `kiro-cli` out, switches it through a fresh browser login, stores the account by profile ARN, and restores the previous CLI session on cancellation or failure. Experimental third-party harness — see Kiro ToS.",
510
517
  models: KIRO_MODELS,
511
518
  defaultModel: "kiro-auto",
512
519
  // Kiro speaks CodeWhisperer wire, not OpenAI-style GET /models. Keep the static
@@ -978,7 +985,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
978
985
  authKind: "key",
979
986
  dashboardUrl: "https://ollama.com/settings/keys",
980
987
  // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15.
981
- // Evidence: .codexclaw/evidence/260710_wp9_ollama_cloud_model_ids.md.
982
988
  models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"],
983
989
  defaultModel: "glm-5.2",
984
990
  noVisionModels: [
@@ -1019,6 +1025,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1019
1025
  id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key",
1020
1026
  dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code",
1021
1027
  modelSuffixBracketStrip: true,
1028
+ // API-key form of the same Kimi Code Plan transport; keep cache affinity identical to OAuth.
1029
+ promptCacheKey: true,
1022
1030
  models: KIMI_CODING_MODELS,
1023
1031
  modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS,
1024
1032
  modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES,
@@ -16,6 +16,7 @@ import { compactionItemToText } from "./compaction";
16
16
  import { previousResponseReplayPrefixLength } from "./state";
17
17
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
18
18
  import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
19
+ import { extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
19
20
 
20
21
  function isObj(v: unknown): v is Record<string, unknown> {
21
22
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -107,6 +108,10 @@ function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] {
107
108
  if ((t === "function" || t === "custom") && "name" in value) {
108
109
  return { name: (value as { name: string }).name };
109
110
  }
111
+ // Hosted image tool types (with or without a name) map to the synthetic image_gen wire name.
112
+ if (t === "image_generation" || t === "image_gen") {
113
+ return { name: IMAGE_GEN_TOOL_NAME };
114
+ }
110
115
  if (t === "allowed_tools" && Array.isArray(value.tools)) {
111
116
  const names = value.tools
112
117
  .map(allowedToolName)
@@ -124,6 +129,7 @@ function allowedToolName(tool: unknown): string | undefined {
124
129
  if (!isObj(tool)) return undefined;
125
130
  if (typeof tool.name === "string" && tool.name.length > 0) return tool.name;
126
131
  if (tool.type === "web_search" || tool.type === "web_search_preview") return WEB_SEARCH_TOOL_NAME;
132
+ if (tool.type === "image_generation" || tool.type === "image_gen") return IMAGE_GEN_TOOL_NAME;
127
133
  if (tool.type === "tool_search") return "tool_search";
128
134
  return undefined;
129
135
  }
@@ -612,6 +618,10 @@ export function parseRequest(body: unknown): OcxParsedRequest {
612
618
  // gpt-mini sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path
613
619
  // re-injects a synthetic function tool only when it will actually handle the call.
614
620
  const webSearch = extractHostedWebSearch(data.tools as unknown[] | undefined);
621
+ const imageGen = extractHostedImageGeneration([
622
+ ...(data.tools as unknown[] ?? []),
623
+ ...loadedToolSpecs,
624
+ ]);
615
625
  // Detect structured-output mode (Responses `text.format`) so the web-search sidecar can render its
616
626
  // tool_result as JSON rather than prose that could corrupt the model's schema-constrained answer.
617
627
  const structuredOutput = detectStructuredOutput(data.text);
@@ -625,6 +635,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
625
635
  _rawBody: body,
626
636
  ...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}),
627
637
  ...(webSearch ? { _webSearch: webSearch } : {}),
638
+ ...(imageGen ? { _imageGeneration: imageGen } : {}),
628
639
  ...(structuredOutput ? { _structuredOutput: true } : {}),
629
640
  ...(compactionRequest ? { _compactionRequest: true } : {}),
630
641
  ...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}),
@@ -1,6 +1,6 @@
1
1
  import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { atomicWriteFile, getConfigDir } from "../config";
3
+ import { atomicWriteFileAsync, getConfigDir } from "../config";
4
4
  import type { OcxProviderContinuationState } from "../types";
5
5
 
6
6
  const MAX_STORED_RESPONSES = 1_000;
@@ -83,6 +83,8 @@ const replayedInputPrefixLengths = new WeakMap<object, number>();
83
83
  let loaded = false;
84
84
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
85
85
  let pendingPersistPath: string | null = null;
86
+ /** Single-flight gate: overlapping response-state writes serialize (#612). */
87
+ let persistGate: Promise<void> = Promise.resolve();
86
88
 
87
89
  function now(): number {
88
90
  return Date.now();
@@ -247,12 +249,18 @@ function ensureLoaded(): void {
247
249
  }
248
250
  }
249
251
 
250
- function persistNow(path: string): void {
252
+ async function persistNow(path: string): Promise<void> {
251
253
  if (persistTimer) {
252
254
  clearTimeout(persistTimer);
253
255
  persistTimer = null;
254
256
  }
255
257
  pendingPersistPath = null;
258
+
259
+ // Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612).
260
+ const previous = persistGate;
261
+ let release!: () => void;
262
+ persistGate = new Promise<void>(resolve => { release = resolve; });
263
+ await previous;
256
264
  try {
257
265
  const entries: [string, StoredResponseState][] = [];
258
266
  let total = 0;
@@ -273,9 +281,11 @@ function persistNow(path: string): void {
273
281
  // mkdirSync's mode only applies on creation — re-harden an existing config dir so the
274
282
  // conversation-content snapshot never lands in a group/world-readable directory.
275
283
  try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
276
- atomicWriteFile(path, JSON.stringify({ version: 2, states: entries }));
284
+ await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
277
285
  } catch {
278
286
  /* best-effort: disk trouble must never affect request handling */
287
+ } finally {
288
+ release();
279
289
  }
280
290
  }
281
291
 
@@ -285,15 +295,18 @@ function schedulePersist(): void {
285
295
  // debounce fires, and a late write must land in the home that owned the recorded state.
286
296
  pendingPersistPath = snapshotPath();
287
297
  const path = pendingPersistPath;
288
- persistTimer = setTimeout(() => persistNow(path), SNAPSHOT_DEBOUNCE_MS);
298
+ persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
289
299
  (persistTimer as { unref?: () => void }).unref?.();
290
300
  }
291
301
 
292
302
  /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
293
- export function flushResponseState(): void {
294
- if (!persistTimer) return;
295
- // Use the path captured when the write was scheduled — OPENCODEX_HOME may have moved since.
296
- persistNow(pendingPersistPath ?? snapshotPath());
303
+ export async function flushResponseState(): Promise<void> {
304
+ if (persistTimer) {
305
+ await persistNow(pendingPersistPath ?? snapshotPath());
306
+ return;
307
+ }
308
+ // No pending timer: still await any in-flight write so shutdown does not race (#612).
309
+ await persistGate;
297
310
  }
298
311
 
299
312
  function inputItems(input: unknown): unknown[] {
@@ -448,6 +461,7 @@ export function clearResponseStateMemoryForTests(): void {
448
461
  clearTimeout(persistTimer);
449
462
  persistTimer = null;
450
463
  }
464
+ pendingPersistPath = null;
451
465
  states.clear();
452
466
  storedResponseBytes = 0;
453
467
  loaded = false;
@@ -0,0 +1,19 @@
1
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
2
+ return !!value && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+
5
+ /** Collect top-level and Responses Lite tool containers without altering their order. */
6
+ export function collectResponsesToolGroups(body: unknown): unknown[][] {
7
+ if (!isPlainObject(body)) return [];
8
+
9
+ const groups: unknown[][] = [];
10
+ if (Array.isArray(body.tools)) groups.push(body.tools);
11
+ if (!Array.isArray(body.input)) return groups;
12
+
13
+ for (const item of body.input) {
14
+ if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
15
+ groups.push(item.tools);
16
+ }
17
+ }
18
+ return groups;
19
+ }
package/src/router.ts CHANGED
@@ -185,15 +185,22 @@ function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effec
185
185
  );
186
186
  }
187
187
 
188
+ function usableResolvedApiKey(apiKey: string | undefined): string | undefined {
189
+ const resolved = resolveEnvValue(apiKey);
190
+ return typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined;
191
+ }
192
+
188
193
  function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
189
194
  const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
190
195
  if (!registryEntry) {
191
196
  assertProviderDestinationAllowed(providerName, provider);
192
- return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
197
+ return { ...provider, apiKey: usableResolvedApiKey(provider.apiKey) };
193
198
  }
199
+ const resolvedApiKey = usableResolvedApiKey(provider.apiKey);
194
200
  const explicitKeyOverride = registryEntry.authKind === "oauth"
195
201
  && registryEntry.allowKeyAuthOverride === true
196
- && provider.authMode === "key";
202
+ && provider.authMode === "key"
203
+ && resolvedApiKey !== undefined;
197
204
  const canonicalAuthMode = explicitKeyOverride
198
205
  ? "key"
199
206
  : registryEntry.authKind === "forward" || registryEntry.authKind === "oauth"
@@ -239,7 +246,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
239
246
  adapter: registryEntry.adapter,
240
247
  baseUrl,
241
248
  authMode: canonicalAuthMode,
242
- apiKey: resolveEnvValue(provider.apiKey),
249
+ apiKey: resolvedApiKey,
243
250
  // Backfill the Google wire mode + Vertex project/location from the registry when the user
244
251
  // config omits them, so a minimal `google-vertex`/`google-antigravity` entry still routes
245
252
  // through the correct branch (CCA/Vertex) instead of falling back to AI Studio.
@@ -286,7 +293,10 @@ function activeProviderEntries(config: OcxConfig): [string, OcxProviderConfig][]
286
293
 
287
294
  export class NoEnabledOpenAiProviderError extends Error {
288
295
  constructor(modelId: string) {
289
- super(`No enabled OpenAI provider for model: ${modelId}. Run 'ocx init' to configure a provider, or check that your config has an enabled 'openai' provider.`);
296
+ super(
297
+ `Model ${modelId} requires the canonical openai provider. `
298
+ + `Run: ocx provider add openai && ocx sync && ocx restart`,
299
+ );
290
300
  this.name = "NoEnabledOpenAiProviderError";
291
301
  }
292
302
  }
@@ -1,6 +1,7 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { formatErrorResponse } from "../bridge";
3
3
  import {
4
+ apiKeyTransportConfigError,
4
5
  booleanRecordConfigError,
5
6
  modelAdapterRecordConfigError,
6
7
  codexAutoStartEnabled,
@@ -18,6 +19,7 @@ import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
18
19
 
19
20
  let _corsOrigin = "http://localhost:10100";
20
21
  export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
22
+ /** The proxy's own listening port. No admission check uses it: both loopback predicates key on hostname alone. */
21
23
  export function configuredPort(): string {
22
24
  try { return new URL(_corsOrigin).port; } catch { return "10100"; }
23
25
  }
@@ -35,8 +37,18 @@ export function parseHttpHost(value: string | null): { hostname: string; port: s
35
37
  export function isLoopbackRequestHost(value: string | null): boolean {
36
38
  const parsed = parseHttpHost(value);
37
39
  if (!parsed) return true;
38
- if (!isLoopbackHostname(parsed.hostname)) return false;
39
- return parsed.port === "" || parsed.port === configuredPort();
40
+ // Loopback is a trust boundary by hostname, not by port. `ssh -L 20100:localhost:10100`
41
+ // legitimately arrives as `Host: localhost:20100`, and refusing it took the whole /v1/*
42
+ // data plane down with it, not just CORS. The sibling isLoopbackOriginValue() dropped its
43
+ // own port check for the same reason in e4e06125b ("same-trust-boundary"). Port equality
44
+ // was never the rebinding defense: a rebinding browser connects to the real port and sends
45
+ // it verbatim, so the hostname check below is what rejected it then and now.
46
+ //
47
+ // Scope of that guarantee: it holds for Hosts `parseHttpHost` can parse. An unparseable
48
+ // Host still returns true above — pre-existing behavior, not browser-reachable (a browser
49
+ // composes Host from its own connection), and pinned by a characterization test in
50
+ // tests/server-loopback-host-gate.test.ts. Tightening it is separate work.
51
+ return isLoopbackHostname(parsed.hostname);
40
52
  }
41
53
 
42
54
  export function isLoopbackOriginValue(value: string): boolean {
@@ -76,6 +88,34 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean
76
88
  return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config);
77
89
  }
78
90
 
91
+ export function managementRequestOrigin(req: Request, config: OcxConfig): string | null {
92
+ const host = req.headers.get("Host");
93
+ const parsedHost = parseHttpHost(host);
94
+ if (!host || !parsedHost) return null;
95
+ if (!isApiAuthRequired(config) && !isLoopbackHostname(parsedHost.hostname)) return null;
96
+ try {
97
+ const protocol = new URL(req.url).protocol;
98
+ if (protocol !== "http:" && protocol !== "https:") return null;
99
+ return new URL(`${protocol}//${host}`).origin;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ export function isAllowedManagementOrigin(req: Request, config: OcxConfig): boolean {
106
+ const requestOrigin = managementRequestOrigin(req, config);
107
+ if (!requestOrigin) return false;
108
+ const origin = req.headers.get("Origin");
109
+ return !origin || origin === requestOrigin;
110
+ }
111
+
112
+ export function browserSecurityHeaders(): Record<string, string> {
113
+ return {
114
+ "X-Frame-Options": "DENY",
115
+ "Content-Security-Policy": "frame-ancestors 'none'",
116
+ };
117
+ }
118
+
79
119
  export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, string> {
80
120
  const origin = req?.headers.get("Origin");
81
121
  const allowOrigin = origin && req && config && isAllowedRequestOrigin(req, config) ? origin : _corsOrigin;
@@ -87,9 +127,19 @@ export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, s
87
127
  // block covers GPT-Live voice protocol headers relayed by the /v1/live call-create path.
88
128
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta, ChatGPT-Account-Id, OpenAI-Alpha, X-Session-Id, Session-Id, Thread-Id, Originator, X-OAI-Attestation",
89
129
  "Vary": "Origin",
130
+ ...browserSecurityHeaders(),
90
131
  };
91
132
  }
92
133
 
134
+ export function managementCorsHeaders(req?: Request, config?: OcxConfig): Record<string, string> {
135
+ const headers = corsHeaders();
136
+ const origin = req?.headers.get("Origin");
137
+ if (origin && req && config && isAllowedManagementOrigin(req, config)) {
138
+ headers["Access-Control-Allow-Origin"] = origin;
139
+ }
140
+ return headers;
141
+ }
142
+
93
143
  export function withCors(response: Response, req: Request, config: OcxConfig): Response {
94
144
  const headers = new Headers(response.headers);
95
145
  for (const [name, value] of Object.entries(corsHeaders(req, config))) {
@@ -102,6 +152,18 @@ export function withCors(response: Response, req: Request, config: OcxConfig): R
102
152
  });
103
153
  }
104
154
 
155
+ export function withManagementCors(response: Response, req: Request, config: OcxConfig): Response {
156
+ const headers = new Headers(response.headers);
157
+ for (const [name, value] of Object.entries(managementCorsHeaders(req, config))) {
158
+ headers.set(name, value);
159
+ }
160
+ return new Response(response.body, {
161
+ status: response.status,
162
+ statusText: response.statusText,
163
+ headers,
164
+ });
165
+ }
166
+
105
167
  export function jsonResponse(data: unknown, status = 200, req?: Request, config?: OcxConfig): Response {
106
168
  return new Response(JSON.stringify(data), {
107
169
  status,
@@ -114,8 +176,15 @@ export function configuredApiAuthToken(_config: OcxConfig): string | undefined {
114
176
  return token || undefined;
115
177
  }
116
178
 
179
+ export function configuredAdminAuthToken(): string | undefined {
180
+ const token = process.env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim();
181
+ return token || undefined;
182
+ }
183
+
117
184
  export function isLoopbackHostname(hostname: string | undefined): boolean {
118
- const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
185
+ // A fully-qualified "localhost." is the same host as "localhost": curl and some clients
186
+ // send the trailing dot verbatim, and refusing it 403s a legitimate loopback caller.
187
+ const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase().replace(/\.$/, "");
119
188
  return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
120
189
  }
121
190
 
@@ -124,31 +193,48 @@ export function isApiAuthRequired(config: OcxConfig): boolean {
124
193
  }
125
194
 
126
195
  export function assertServerAuthConfig(config: OcxConfig): void {
127
- if (isApiAuthRequired(config) && !configuredApiAuthToken(config)) {
128
- throw new Error("OPENCODEX_API_AUTH_TOKEN is required when binding opencodex to a non-loopback hostname");
196
+ const hasConfiguredDataCredential = !!configuredApiAuthToken(config)
197
+ || (config.apiKeys ?? []).some(entry => !!entry.key.trim());
198
+ if (isApiAuthRequired(config) && !hasConfiguredDataCredential) {
199
+ throw new Error(
200
+ "A data-plane credential (OPENCODEX_API_AUTH_TOKEN or config.apiKeys) is required when binding opencodex to a non-loopback hostname",
201
+ );
129
202
  }
130
203
  }
131
204
 
132
- /** Whether `token` is one of the proxy's own admission secrets (env token or config API keys). */
133
- export function isProxyAdmissionSecret(token: string, config: OcxConfig): boolean {
134
- const actual = token.trim();
135
- if (!actual) return false;
205
+ function secretEquals(actual: string, expected: string | undefined): boolean {
206
+ if (!expected) return false;
136
207
  const enc = new TextEncoder();
137
208
  const actualBytes = enc.encode(actual);
138
- // Check env-based token
139
- const expected = configuredApiAuthToken(config);
140
- if (expected) {
141
- const expectedBytes = enc.encode(expected);
142
- if (expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes)) return true;
143
- }
144
- // Check config-based API keys
209
+ const expectedBytes = enc.encode(expected);
210
+ return expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes);
211
+ }
212
+
213
+ /** Whether `token` is a data-plane admission secret. */
214
+ export function isDataPlaneAdmissionSecret(token: string, config: OcxConfig): boolean {
215
+ const actual = token.trim();
216
+ if (!actual) return false;
217
+ if (secretEquals(actual, configuredApiAuthToken(config))) return true;
145
218
  for (const k of config.apiKeys ?? []) {
146
- const keyBytes = enc.encode(k.key);
147
- if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
219
+ if (secretEquals(actual, k.key)) return true;
148
220
  }
149
221
  return false;
150
222
  }
151
223
 
224
+ /** Whether `token` is the environment-provided management secret. */
225
+ export function isManagementAdmissionSecret(token: string): boolean {
226
+ const actual = token.trim();
227
+ return !!actual && secretEquals(actual, configuredAdminAuthToken());
228
+ }
229
+
230
+ /** Whether `token` is one of the proxy's own admission secrets and must never reach an upstream. */
231
+ export function isProxyAdmissionSecret(token: string, config: OcxConfig): boolean {
232
+ const actual = token.trim();
233
+ if (!actual) return false;
234
+ if (/^ocx_(?:data|admin|session)_/.test(actual) || /^ocx_[0-9a-f]{40}$/.test(actual)) return true;
235
+ return isDataPlaneAdmissionSecret(actual, config) || isManagementAdmissionSecret(actual);
236
+ }
237
+
152
238
  export class ForwardAdmissionCredentialError extends Error {
153
239
  constructor() {
154
240
  super("OpenCodex admission credentials cannot be forwarded upstream");
@@ -168,12 +254,11 @@ export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
168
254
  // Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
169
255
  || req.headers.get("x-api-key")?.trim();
170
256
  if (!actual) return false;
171
- return isProxyAdmissionSecret(actual, config);
257
+ return isDataPlaneAdmissionSecret(actual, config);
172
258
  }
173
259
 
174
- export function requireApiAuth(req: Request, config: OcxConfig, kind: "management" | "data-plane"): Response | null {
260
+ export function requireApiAuth(req: Request, config: OcxConfig, _kind: "data-plane"): Response | null {
175
261
  if (hasValidApiAuth(req, config)) return null;
176
- if (kind === "management") return jsonResponse({ error: "opencodex API key required" }, 401);
177
262
  return formatErrorResponse(401, "authentication_error", "opencodex API key required");
178
263
  }
179
264
 
@@ -185,7 +270,7 @@ export function requireApiAuth(req: Request, config: OcxConfig, kind: "managemen
185
270
  export function requireResponsesApiAuth(req: Request, config: OcxConfig): Response | null {
186
271
  if (!isApiAuthRequired(config)) return null;
187
272
  const actual = req.headers.get("x-opencodex-api-key")?.trim();
188
- if (actual && isProxyAdmissionSecret(actual, config)) return null;
273
+ if (actual && isDataPlaneAdmissionSecret(actual, config)) return null;
189
274
  return formatErrorResponse(401, "authentication_error", "opencodex API key required");
190
275
  }
191
276
 
@@ -232,6 +317,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
232
317
  if (destinationError) return `provider ${name} ${destinationError}`;
233
318
  const headersError = providerHeadersConfigError(typed.headers);
234
319
  if (headersError) return `provider ${name} ${headersError}`;
320
+ const apiKeyTransportError = apiKeyTransportConfigError(typed);
321
+ if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`;
235
322
  const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens");
236
323
  if (maxInputError) return `provider ${name} ${maxInputError}`;
237
324
  const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries");
@@ -307,6 +394,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
307
394
  "disabled",
308
395
  "allowPrivateNetwork",
309
396
  "authMode",
397
+ "apiKeyTransport",
310
398
  "keyOptional",
311
399
  "freeTier",
312
400
  "liveModels",
@@ -9,7 +9,7 @@
9
9
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
10
10
  import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard";
11
11
  import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
12
- import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
12
+ import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
13
13
  import { resolveDesktop3pAlias } from "../claude/desktop-3p";
14
14
  import { recordDesktopRequest } from "../claude/desktop-health";
15
15
  import { stripOneMillionMarker } from "../claude/context-windows";
@@ -523,6 +523,7 @@ export async function handleClaudeMessages(
523
523
  let anthropicBody: unknown;
524
524
  let internalBody: Rec;
525
525
  let cacheKeySource: ClaudeCacheKeySource = null;
526
+ let effortOverride: ReturnType<typeof extractOcxEffortDirective> = null;
526
527
  try {
527
528
  anthropicBody = await readAnthropicBody(req);
528
529
  // Defensive [1m] strip (devlog 138): clients normally remove the context-variant
@@ -539,6 +540,7 @@ export async function handleClaudeMessages(
539
540
  const routeOverride = extractOcxRouteDirective(anthropicBody);
540
541
  if (routeOverride && typeof anthropicBody.model === "string") {
541
542
  anthropicBody.model = stripOneMillionMarker(routeOverride);
543
+ effortOverride = extractOcxEffortDirective(anthropicBody);
542
544
  }
543
545
  }
544
546
  // Debug capture (opt-in allowlist scalars) BEFORE the passthrough branch so
@@ -567,6 +569,10 @@ export async function handleClaudeMessages(
567
569
  if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) {
568
570
  return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
569
571
  }
572
+ if (isRec(anthropicBody) && effortOverride) {
573
+ anthropicBody.output_config = { effort: effortOverride };
574
+ delete anthropicBody.thinking;
575
+ }
570
576
  const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode);
571
577
  internalBody = translation.body;
572
578
  cacheKeySource = translation.cacheKeySource;
@@ -678,6 +684,7 @@ export async function handleClaudeMessages(
678
684
  };
679
685
  const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, {
680
686
  abortSignal: req.signal,
687
+ promptCacheKeyIsSharedCohort: cacheKeySource === "system",
681
688
  ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
682
689
  onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
683
690
  onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { extname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { browserSecurityHeaders } from "./auth-cors";
4
+ import type { GuiSessionBootstrap } from "./management-auth";
3
5
 
4
6
  /** opencodex version, read from the packaged package.json (same source as the server bootstrap). */
5
7
  const VERSION = (() => {
@@ -54,8 +56,31 @@ function isFile(path: string): boolean {
54
56
  }
55
57
  }
56
58
 
57
- export function serveGuiFile(pathname: string): Response | null {
58
- const guiDist = findGuiDist();
59
+ function htmlResponse(path: string, session?: GuiSessionBootstrap): Response {
60
+ let html = readFileSync(path, "utf8");
61
+ if (session) {
62
+ const bootstrap = [
63
+ `<meta name="opencodex-session-token" content="${session.token}">`,
64
+ `<meta name="opencodex-session-csrf" content="${session.csrfToken}">`,
65
+ `<meta name="opencodex-session-origin" content="${session.origin}">`,
66
+ ].join("");
67
+ html = html.includes("</head>") ? html.replace("</head>", `${bootstrap}</head>`) : `${bootstrap}${html}`;
68
+ }
69
+ return new Response(html, {
70
+ headers: {
71
+ "Content-Type": "text/html",
72
+ "Cache-Control": "no-store",
73
+ Pragma: "no-cache",
74
+ ...browserSecurityHeaders(),
75
+ },
76
+ });
77
+ }
78
+
79
+ export function serveGuiFile(
80
+ pathname: string,
81
+ guiDist = findGuiDist(),
82
+ session?: GuiSessionBootstrap,
83
+ ): Response | null {
59
84
  if (!guiDist) return null;
60
85
  const filePath = resolveGuiFilePath(guiDist, pathname);
61
86
  if (!filePath) return null;
@@ -64,9 +89,7 @@ export function serveGuiFile(pathname: string): Response | null {
64
89
  if (!extname(pathname)) {
65
90
  const indexPath = join(guiDist, "index.html");
66
91
  if (isFile(indexPath)) {
67
- return new Response(Bun.file(indexPath), {
68
- headers: { "Content-Type": "text/html" },
69
- });
92
+ return htmlResponse(indexPath, session);
70
93
  }
71
94
  }
72
95
  return null;
@@ -74,8 +97,9 @@ export function serveGuiFile(pathname: string): Response | null {
74
97
 
75
98
  const ext = extname(filePath);
76
99
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
100
+ if (ext === ".html") return htmlResponse(filePath, session);
77
101
  return new Response(Bun.file(filePath), {
78
- headers: { "Content-Type": contentType },
102
+ headers: { "Content-Type": contentType, ...browserSecurityHeaders() },
79
103
  });
80
104
  }
81
105