@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.
- package/README.md +8 -1
- package/bin/ocx.mjs +13 -14
- package/gui/dist/assets/index-Czw-jpTU.css +1 -0
- package/gui/dist/assets/index-cmds12BG.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +49 -6
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/inbound.ts +26 -9
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/claude.ts +3 -3
- package/src/cli/doctor.ts +23 -18
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +88 -19
- package/src/cli/interactive-confirm.ts +129 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +55 -16
- package/src/cli/status.ts +47 -13
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/parsing.ts +16 -1
- package/src/codex/catalog/provider-fetch.ts +48 -12
- package/src/codex/catalog/sync.ts +18 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +15 -0
- package/src/codex/routing.ts +695 -106
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +566 -25
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +122 -4
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +164 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +283 -72
- package/src/oauth/key-providers.ts +17 -6
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +322 -10
- package/src/oauth/kiro.ts +423 -43
- package/src/oauth/login-cli.ts +32 -5
- package/src/oauth/store.ts +54 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +11 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +14 -4
- package/src/server/auth-cors.ts +110 -22
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/management/agent-settings-routes.ts +28 -4
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +5 -2
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +35 -7
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +2 -2
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +583 -152
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +49 -2
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +366 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +242 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/tray/windows.ts +2 -0
- package/src/types.ts +96 -1
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +103 -12
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/log.ts +4 -0
- package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
- 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
|
|
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,
|
package/src/responses/parser.ts
CHANGED
|
@@ -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 } : {}),
|
package/src/responses/state.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 (
|
|
295
|
-
|
|
296
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
257
|
+
return isDataPlaneAdmissionSecret(actual, config);
|
|
172
258
|
}
|
|
173
259
|
|
|
174
|
-
export function requireApiAuth(req: Request, config: OcxConfig,
|
|
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 &&
|
|
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" }),
|
package/src/server/gui-static.ts
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|