@bitkyc08/opencodex 2.10.2 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/server/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { reconcileOAuthProviders } from "../oauth";
|
|
|
22
22
|
import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization";
|
|
23
23
|
import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync";
|
|
24
24
|
import { getCodexHome } from "../codex/paths";
|
|
25
|
+
import { shouldSyncCodexOnStart } from "../codex/desired-state";
|
|
26
|
+
import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight";
|
|
25
27
|
import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api";
|
|
26
28
|
import { startMemoryWatchdog } from "./memory-watchdog";
|
|
27
29
|
import {
|
|
@@ -69,6 +71,7 @@ import {
|
|
|
69
71
|
getActiveTurnCount,
|
|
70
72
|
isDraining,
|
|
71
73
|
registerTurn,
|
|
74
|
+
runListenerShutdown,
|
|
72
75
|
setServerRef,
|
|
73
76
|
trackStreamLifetime,
|
|
74
77
|
tryAdmitTurn,
|
|
@@ -136,6 +139,8 @@ import {
|
|
|
136
139
|
admissionFields,
|
|
137
140
|
resolveApiAuth,
|
|
138
141
|
resolveResponsesApiAuth,
|
|
142
|
+
requestPolicyView,
|
|
143
|
+
type RequestPolicyView,
|
|
139
144
|
safeConfigDTO,
|
|
140
145
|
setCorsOrigin,
|
|
141
146
|
withCors,
|
|
@@ -162,6 +167,7 @@ import {
|
|
|
162
167
|
releaseNativeMainStartupLifecycle,
|
|
163
168
|
startNativeMainStartupLifecycle,
|
|
164
169
|
type NativeMainStartupGateDeps,
|
|
170
|
+
type NativeMainStartupLifecycle,
|
|
165
171
|
} from "../codex/native-profile-startup";
|
|
166
172
|
import { handleImages } from "./images";
|
|
167
173
|
import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
|
|
@@ -180,6 +186,8 @@ import {
|
|
|
180
186
|
createLocalAttestationProof,
|
|
181
187
|
createLocalAttestationSecret,
|
|
182
188
|
} from "../lib/local-management-attestation";
|
|
189
|
+
import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
|
|
190
|
+
import { createReadinessGate, type ReadinessGate } from "./readiness";
|
|
183
191
|
|
|
184
192
|
const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
|
|
185
193
|
const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
|
|
@@ -365,6 +373,8 @@ export interface StartServerDeps {
|
|
|
365
373
|
liveSidebandWebSocketFactory?: LiveSidebandWebSocketFactory;
|
|
366
374
|
/** Test-only seam; production derives a fresh local-attestation secret per process. */
|
|
367
375
|
localAttestationSecret?: string;
|
|
376
|
+
/** Optional readiness gate; a fresh pending gate is created when omitted. */
|
|
377
|
+
readinessGate?: ReadinessGate;
|
|
368
378
|
}
|
|
369
379
|
|
|
370
380
|
/*
|
|
@@ -388,7 +398,7 @@ export function consumeStartupCacheInvalidationWrite(): boolean {
|
|
|
388
398
|
return wrote;
|
|
389
399
|
}
|
|
390
400
|
|
|
391
|
-
export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
401
|
+
export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
|
|
392
402
|
const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret();
|
|
393
403
|
const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()));
|
|
394
404
|
setLiveStateStoreConfig(config);
|
|
@@ -486,30 +496,82 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
486
496
|
const configuredHost = config.hostname?.trim();
|
|
487
497
|
const bindHost = !configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
|
|
488
498
|
|
|
499
|
+
// Unauthenticated loopback listener (#1102). Off unless explicitly enabled.
|
|
500
|
+
const loopbackListener = config.unauthenticatedLoopbackListener;
|
|
501
|
+
const loopbackListenerPort = loopbackListener?.enabled ? loopbackListener.port : null;
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Which listener a request arrived on, expressed as the only thing that differs: the bind
|
|
505
|
+
* address the auth and CORS decisions should see.
|
|
506
|
+
*
|
|
507
|
+
* The public listener passes the shared config through untouched, so its behaviour is
|
|
508
|
+
* byte-identical to before. The loopback listener substitutes 127.0.0.1, which is what makes
|
|
509
|
+
* `isApiAuthRequired` return false for it — the same code path a plain loopback bind has
|
|
510
|
+
* always taken, including the Host-header check inside `isAllowedRequestOrigin`.
|
|
511
|
+
*
|
|
512
|
+
* Built per request rather than once per listener so a management-API config change is
|
|
513
|
+
* picked up immediately instead of being frozen at listen time.
|
|
514
|
+
*/
|
|
515
|
+
const publicPolicy = (): RequestPolicyView => config;
|
|
516
|
+
const loopbackPolicy = (): RequestPolicyView => requestPolicyView(config, "127.0.0.1");
|
|
517
|
+
void publicPolicy;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Routes the unauthenticated loopback listener will serve. Everything else 404s.
|
|
521
|
+
*
|
|
522
|
+
* This is an allowlist rather than a filter applied to the public handler, because a filter
|
|
523
|
+
* inverts the failure mode: a route added later would be reachable here by default. The four
|
|
524
|
+
* entries are exactly what a directly-spawned `codex app-server` needs.
|
|
525
|
+
*
|
|
526
|
+
* `GET /v1/models` is on the list for a reason that is easy to miss. When catalog
|
|
527
|
+
* materialization fails or finds no source, `syncCodex` warns and injects with
|
|
528
|
+
* `catalogPath: null`; Codex then builds an ONLINE model manager and `model/list` refreshes
|
|
529
|
+
* through `GET {base_url}/models`. Returning 404 there would leave the picker on its bundled
|
|
530
|
+
* fallback — fixing the direct-spawn host while breaking its model list.
|
|
531
|
+
*/
|
|
532
|
+
function loopbackRouteAllowed(url: URL, req: Request): boolean {
|
|
533
|
+
const path = url.pathname;
|
|
534
|
+
if (path === "/v1/responses") {
|
|
535
|
+
return req.method === "POST" || req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
536
|
+
}
|
|
537
|
+
if (path === "/v1/responses/compact") return req.method === "POST";
|
|
538
|
+
if (path === "/v1/models") return req.method === "GET";
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
|
|
489
542
|
// Codex treats empty / non-JSON 503 bodies as "Unknown error" (#452). Keep Retry-After and
|
|
490
543
|
// the server_is_overloaded code so clients can back off, but always return a JSON envelope.
|
|
491
|
-
|
|
544
|
+
// These two run BEFORE the auth/origin checks, so they need the receiving listener's policy
|
|
545
|
+
// explicitly (#1102). Reaching for the shared `config` here would attach public-policy CORS
|
|
546
|
+
// headers to a 503 on the loopback listener — no model runs and no credential is spent, but
|
|
547
|
+
// it is the one error path that would answer a rebinding origin with its own origin echoed
|
|
548
|
+
// back.
|
|
549
|
+
function drainingResponse(req: Request, policy: RequestPolicyView): Response {
|
|
492
550
|
const response = formatErrorResponse(503, "server_error", "Service shutting down");
|
|
493
551
|
const headers = new Headers(response.headers);
|
|
494
|
-
for (const [name, value] of Object.entries(corsHeaders(req,
|
|
552
|
+
for (const [name, value] of Object.entries(corsHeaders(req, policy))) {
|
|
495
553
|
headers.set(name, value);
|
|
496
554
|
}
|
|
497
555
|
headers.set("Retry-After", "5");
|
|
498
556
|
return new Response(response.body, { status: 503, headers });
|
|
499
557
|
}
|
|
500
558
|
|
|
501
|
-
function serverBusyResponse(req: Request, resource: string): Response {
|
|
559
|
+
function serverBusyResponse(req: Request, resource: string, policy: RequestPolicyView): Response {
|
|
502
560
|
return withCors(new Response(JSON.stringify({
|
|
503
561
|
error: { type: "server_error", code: "server_busy", message: `${resource} capacity reached` },
|
|
504
562
|
}), {
|
|
505
563
|
status: 503,
|
|
506
564
|
headers: { "Content-Type": "application/json", "Retry-After": "1" },
|
|
507
|
-
}), req,
|
|
565
|
+
}), req, policy);
|
|
508
566
|
}
|
|
509
567
|
|
|
510
|
-
async function runAdmittedHttpTurn(
|
|
568
|
+
async function runAdmittedHttpTurn(
|
|
569
|
+
req: Request,
|
|
570
|
+
policy: RequestPolicyView,
|
|
571
|
+
work: (lease: ActiveTurnLease) => Promise<Response>,
|
|
572
|
+
): Promise<Response> {
|
|
511
573
|
const lease = tryAdmitTurn();
|
|
512
|
-
if (!lease) return serverBusyResponse(req, "active turns");
|
|
574
|
+
if (!lease) return serverBusyResponse(req, "active turns", policy);
|
|
513
575
|
let response: Response;
|
|
514
576
|
try {
|
|
515
577
|
response = await work(lease);
|
|
@@ -523,28 +585,84 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
523
585
|
return response;
|
|
524
586
|
}
|
|
525
587
|
|
|
526
|
-
|
|
588
|
+
// Readiness gate: one PRIVATE controller per startServer invocation, captured
|
|
589
|
+
// by this listener's closure. Starting/failing a second server in the same
|
|
590
|
+
// process can never reset or mutate this gate. handleStart creates the gate,
|
|
591
|
+
// passes it in, and transitions it after the post-startup sync settles. When
|
|
592
|
+
// no gate is supplied (tests, ad-hoc starts) a fresh pending gate is created.
|
|
593
|
+
const readinessGate = deps.readinessGate ?? createReadinessGate();
|
|
594
|
+
// Actual bound port, filled in after Bun.serve binds so /readyz reports the
|
|
595
|
+
// real ephemeral port for startServer(0). /healthz keeps its existing port
|
|
596
|
+
// field (the requested listenPort) byte-for-byte.
|
|
597
|
+
let boundPort: number | null = null;
|
|
598
|
+
|
|
599
|
+
// Native-main startup ownership creates several SQLite coordination files in
|
|
600
|
+
// CODEX_HOME. When the user has disabled the Codex integration, starting the
|
|
601
|
+
// proxy must not manufacture those Codex artifacts merely to serve other
|
|
602
|
+
// clients; no Codex request can use this lifecycle in that state.
|
|
603
|
+
const nativeOwnership = inspectNativeCodexOwnership();
|
|
604
|
+
const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config)
|
|
605
|
+
&& nativeOwnership.ownership !== "foreign"
|
|
606
|
+
? startNativeMainStartupLifecycle(deps.nativeMainStartup)
|
|
607
|
+
: {
|
|
608
|
+
homeId: null,
|
|
609
|
+
settled: Promise.resolve({ status: "ready", homeId: null }),
|
|
610
|
+
release: async () => {},
|
|
611
|
+
};
|
|
527
612
|
let server: Server<WsData>;
|
|
613
|
+
let loopbackServer: Server<WsData> | null = null;
|
|
528
614
|
try {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
615
|
+
const serveOptions = {
|
|
616
|
+
idleTimeout: 255,
|
|
617
|
+
async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
|
|
618
|
+
// The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
|
|
619
|
+
// else. Rejecting here, before any handler runs, is what keeps the surface from growing
|
|
620
|
+
// silently when a route is added below.
|
|
621
|
+
if (requestServer === loopbackServer && !loopbackRouteAllowed(new URL(req.url), req)) {
|
|
622
|
+
return withCors(
|
|
623
|
+
formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
|
|
624
|
+
req,
|
|
625
|
+
loopbackPolicy(),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
// Auth and CORS decisions below read `policy`, not `config`. For the public listener the
|
|
629
|
+
// two are the same object, so its behaviour is unchanged; for the loopback listener the
|
|
630
|
+
// view substitutes 127.0.0.1 as the bind address, which is what routes it through the
|
|
631
|
+
// same code path a plain loopback bind has always taken — Host-header check included.
|
|
632
|
+
// Routing, provider selection and response bodies keep using `config`.
|
|
633
|
+
const policy: RequestPolicyView = requestServer === loopbackServer ? loopbackPolicy() : config;
|
|
534
634
|
const url = new URL(req.url);
|
|
535
635
|
markActivity(`${req.method} ${url.pathname}`);
|
|
536
636
|
|
|
637
|
+
// Readiness is exact-GET on the literal /readyz path. Compare the DECODED
|
|
638
|
+
// pathname so an encoded variant like /readyz%2F (which decodes to
|
|
639
|
+
// /readyz/) cannot bypass the exact-path rejection and reach the GUI
|
|
640
|
+
// fallback (serveGuiFile decodes the pathname and would serve index.html
|
|
641
|
+
// with 200). Malformed percent-sequences fall back to the raw pathname,
|
|
642
|
+
// which still cannot match the exact literal below.
|
|
643
|
+
let readyzPath: string | undefined;
|
|
644
|
+
try {
|
|
645
|
+
const decoded = decodeURIComponent(url.pathname);
|
|
646
|
+
if (decoded === "/readyz" || decoded === "/readyz/") readyzPath = decoded;
|
|
647
|
+
} catch { /* malformed encoding — not a readiness path */ }
|
|
648
|
+
|
|
537
649
|
if (req.method === "OPTIONS") {
|
|
650
|
+
// /readyz is exact-GET only; OPTIONS (like POST and the trailing-slash
|
|
651
|
+
// path) must answer the deterministic JSON 404, never the generic 204
|
|
652
|
+
// preflight response that the SPA fallback would otherwise allow.
|
|
653
|
+
if (readyzPath !== undefined) {
|
|
654
|
+
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
|
|
655
|
+
}
|
|
538
656
|
const managementPreflight = url.pathname.startsWith("/api/");
|
|
539
657
|
const allowed = managementPreflight
|
|
540
658
|
? isAllowedManagementOrigin(req, config)
|
|
541
|
-
: isAllowedRequestOrigin(req,
|
|
659
|
+
: isAllowedRequestOrigin(req, policy);
|
|
542
660
|
if (!allowed) {
|
|
543
661
|
return new Response(null, { status: 403, headers: corsHeaders() });
|
|
544
662
|
}
|
|
545
663
|
return new Response(null, {
|
|
546
664
|
status: 204,
|
|
547
|
-
headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req,
|
|
665
|
+
headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, policy),
|
|
548
666
|
});
|
|
549
667
|
}
|
|
550
668
|
|
|
@@ -552,14 +670,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
552
670
|
// handshake-time only, so capture inbound headers and thread them into the pipeline.
|
|
553
671
|
if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
554
672
|
if (isDraining()) {
|
|
555
|
-
return drainingResponse(req);
|
|
673
|
+
return drainingResponse(req, policy);
|
|
556
674
|
}
|
|
557
|
-
const admission = resolveResponsesApiAuth(req,
|
|
675
|
+
const admission = resolveResponsesApiAuth(req, policy);
|
|
558
676
|
if (!admission) {
|
|
559
|
-
return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
677
|
+
return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
560
678
|
}
|
|
561
|
-
if (!isAllowedRequestOrigin(req,
|
|
562
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req,
|
|
679
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
680
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy);
|
|
563
681
|
}
|
|
564
682
|
// WS transport gate: Codex's built-in `openai` provider hardcodes supports_websockets=true,
|
|
565
683
|
// so under Design B it always tries the WS transport first. When the feature is off, reject
|
|
@@ -567,21 +685,33 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
567
685
|
// session-scoped HTTP fallback (client.rs WebsocketStreamOutcome::FallbackToHttp) instead of
|
|
568
686
|
// surfacing broken-pipe errors from sockets a "disabled" feature would otherwise accept.
|
|
569
687
|
if (!websocketsEnabled(config)) {
|
|
570
|
-
return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req,
|
|
688
|
+
return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req, policy);
|
|
571
689
|
}
|
|
572
690
|
const websocketLease = tryReserveCodexWebSocket();
|
|
573
|
-
if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets");
|
|
574
|
-
|
|
691
|
+
if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets", policy);
|
|
692
|
+
// Upgrade on the server that RECEIVED this request, not the captured `server`
|
|
693
|
+
// binding. They are the same object for the public listener, but the
|
|
694
|
+
// unauthenticated loopback listener (#1102) is a second Bun.serve, and handing its
|
|
695
|
+
// request to the public server's upgrade would fail or cross sockets.
|
|
696
|
+
if (requestServer.upgrade(req, {
|
|
575
697
|
data: buildResponsesWsData(selectForwardHeaders(req.headers), admission, websocketLease),
|
|
576
698
|
})) return undefined as unknown as Response;
|
|
577
699
|
websocketLease.release();
|
|
578
|
-
return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req,
|
|
700
|
+
return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
|
|
579
701
|
}
|
|
580
702
|
|
|
581
703
|
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
582
704
|
// service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
|
|
583
705
|
const healthPort = server.port ?? listenPort;
|
|
584
|
-
const response = jsonResponse({
|
|
706
|
+
const response = jsonResponse({
|
|
707
|
+
status: "ok",
|
|
708
|
+
service: "opencodex",
|
|
709
|
+
version: VERSION,
|
|
710
|
+
uptime: process.uptime(),
|
|
711
|
+
pid: process.pid,
|
|
712
|
+
port: healthPort,
|
|
713
|
+
restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
|
|
714
|
+
}, 200, req, policy);
|
|
585
715
|
const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
|
|
586
716
|
if (challenge) {
|
|
587
717
|
const proof = createLocalAttestationProof(localAttestationSecret, challenge, process.pid, healthPort);
|
|
@@ -590,13 +720,54 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
590
720
|
return response;
|
|
591
721
|
}
|
|
592
722
|
|
|
723
|
+
// Readiness: like /healthz this is exact GET and unauthenticated (so a client can
|
|
724
|
+
// back off BEFORE knowing the admission token), but stricter than liveness. The
|
|
725
|
+
// body carries only sanitized identity + the fixed status enum; the sync message,
|
|
726
|
+
// warning text, catalog path, provider output, and account data are never exposed.
|
|
727
|
+
// POST or "/readyz/" must NOT match (exact pathname + GET method): answer them
|
|
728
|
+
// with a JSON 404 here so they can never be silently accepted by the GUI SPA
|
|
729
|
+
// fallback (which would serve index.html with HTTP 200 once gui/dist exists).
|
|
730
|
+
if (readyzPath !== undefined) {
|
|
731
|
+
if (readyzPath !== "/readyz" || req.method !== "GET") {
|
|
732
|
+
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
|
|
733
|
+
}
|
|
734
|
+
// A draining proxy must never advertise ready: every data-plane branch
|
|
735
|
+
// answers drainingResponse while isDraining() is set, but the one-shot
|
|
736
|
+
// readiness gate is not mutated on shutdown (it is owned by the startup
|
|
737
|
+
// sync). Report pending so `ocx ready --wait` and external supervisors
|
|
738
|
+
// keep polling instead of promoting a proxy that is draining.
|
|
739
|
+
const status = isDraining() ? "pending" : readinessGate.getStatus();
|
|
740
|
+
const body = {
|
|
741
|
+
service: "opencodex",
|
|
742
|
+
version: VERSION,
|
|
743
|
+
uptime: process.uptime(),
|
|
744
|
+
pid: process.pid,
|
|
745
|
+
port: boundPort ?? listenPort,
|
|
746
|
+
status,
|
|
747
|
+
};
|
|
748
|
+
if (status === "ready") {
|
|
749
|
+
return jsonResponse(body, 200, req, policy);
|
|
750
|
+
}
|
|
751
|
+
// Pending/failed: 503 with a conservative Retry-After so well-behaved clients
|
|
752
|
+
// (and `ocx ready --wait`) back off instead of hot-looping.
|
|
753
|
+
const resp = jsonResponse(body, 503, req, policy);
|
|
754
|
+
const headers = new Headers(resp.headers);
|
|
755
|
+
headers.set("Retry-After", "1");
|
|
756
|
+
return new Response(resp.body, { status: 503, headers });
|
|
757
|
+
}
|
|
758
|
+
|
|
593
759
|
if (url.pathname.startsWith("/api/")) {
|
|
594
|
-
const
|
|
760
|
+
const localManagementAuth = {
|
|
761
|
+
attestationSecret: localAttestationSecret,
|
|
762
|
+
pid: process.pid,
|
|
763
|
+
port: boundPort ?? requestServer.port ?? listenPort,
|
|
764
|
+
};
|
|
765
|
+
const apiAuthError = requireManagementAuth(req, managementAuth, config, localManagementAuth);
|
|
595
766
|
if (apiAuthError) return withManagementCors(apiAuthError, req, config);
|
|
596
767
|
// Which credential passed the gate, resolved from the same session table the
|
|
597
768
|
// gate used. Consent-bearing routes need this: request headers are forgeable
|
|
598
769
|
// by anything holding the admin token, the credential is not.
|
|
599
|
-
const principal = managementPrincipal(req, managementAuth, config) ?? undefined;
|
|
770
|
+
const principal = managementPrincipal(req, managementAuth, config, localManagementAuth) ?? undefined;
|
|
600
771
|
const mgmtResponse = await handleManagementAPI(req, url, config, deps.managementApi, principal);
|
|
601
772
|
if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
|
|
602
773
|
return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
|
|
@@ -606,10 +777,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
606
777
|
// Model discovery never forwards Authorization upstream, so the broader admission
|
|
607
778
|
// set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by
|
|
608
779
|
// remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version).
|
|
609
|
-
const admission = resolveApiAuth(req,
|
|
610
|
-
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
611
|
-
if (!isAllowedRequestOrigin(req,
|
|
612
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req,
|
|
780
|
+
const admission = resolveApiAuth(req, policy);
|
|
781
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
782
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
783
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
613
784
|
}
|
|
614
785
|
let goModels;
|
|
615
786
|
try {
|
|
@@ -619,7 +790,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
619
790
|
return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), {
|
|
620
791
|
status: 503,
|
|
621
792
|
headers: { "content-type": "application/json", "Retry-After": "1" },
|
|
622
|
-
}), req,
|
|
793
|
+
}), req, policy);
|
|
623
794
|
}
|
|
624
795
|
throw error;
|
|
625
796
|
}
|
|
@@ -645,7 +816,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
645
816
|
const wantsAnthropicList = req.headers.get("anthropic-version") !== null
|
|
646
817
|
|| url.searchParams.get("flavor") === "anthropic";
|
|
647
818
|
if (wantsAnthropicList && !url.searchParams.has("client_version")) {
|
|
648
|
-
if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req,
|
|
819
|
+
if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, policy);
|
|
649
820
|
// Build Desktop 3P registry so inbound alias resolution works for subsequent requests.
|
|
650
821
|
buildDesktop3pRegistry(
|
|
651
822
|
[...desktopVisibleNativeSlugs(config)],
|
|
@@ -666,7 +837,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
666
837
|
? "desktop3p" as const
|
|
667
838
|
: (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
|
|
668
839
|
const data = buildAnthropicModelInfos([...desktopVisibleNativeSlugs(config)], goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias);
|
|
669
|
-
return jsonResponse({ data }, 200, req,
|
|
840
|
+
return jsonResponse({ data }, 200, req, policy);
|
|
670
841
|
}
|
|
671
842
|
if (url.searchParams.has("client_version")) {
|
|
672
843
|
// Codex client → Codex catalog shape: native gpt + namespaced routed models,
|
|
@@ -688,7 +859,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
688
859
|
disabledModels,
|
|
689
860
|
accountSelectors.length > 0,
|
|
690
861
|
),
|
|
691
|
-
}, 200, req,
|
|
862
|
+
}, 200, req, policy);
|
|
692
863
|
}
|
|
693
864
|
// OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
|
|
694
865
|
// (pure availability list — disabled natives are omitted entirely).
|
|
@@ -752,7 +923,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
752
923
|
...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
|
|
753
924
|
})),
|
|
754
925
|
];
|
|
755
|
-
return jsonResponse({ object: "list", data }, 200, req,
|
|
926
|
+
return jsonResponse({ object: "list", data }, 200, req, policy);
|
|
756
927
|
}
|
|
757
928
|
|
|
758
929
|
// Remote compaction v1 (codex-rs with Feature::RemoteCompactionV2 off — the default).
|
|
@@ -760,12 +931,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
760
931
|
// before the /v1/* 404 guard below.
|
|
761
932
|
if (url.pathname === "/v1/responses/compact" && req.method === "POST") {
|
|
762
933
|
if (isDraining()) {
|
|
763
|
-
return drainingResponse(req);
|
|
934
|
+
return drainingResponse(req, policy);
|
|
764
935
|
}
|
|
765
|
-
const admission = resolveResponsesApiAuth(req,
|
|
766
|
-
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
767
|
-
if (!isAllowedRequestOrigin(req,
|
|
768
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req,
|
|
936
|
+
const admission = resolveResponsesApiAuth(req, policy);
|
|
937
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
938
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
939
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
769
940
|
}
|
|
770
941
|
const start = Date.now();
|
|
771
942
|
const requestId = nextRequestLogId(start);
|
|
@@ -775,7 +946,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
775
946
|
...admissionFields(admission),
|
|
776
947
|
inboundProtocol: "responses",
|
|
777
948
|
};
|
|
778
|
-
return runAdmittedHttpTurn(req, async turnAdmissionLease => {
|
|
949
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
779
950
|
let response: Response;
|
|
780
951
|
try {
|
|
781
952
|
response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease);
|
|
@@ -784,7 +955,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
784
955
|
}
|
|
785
956
|
addFinalRequestLog(requestId, start, logCtx, response.status,
|
|
786
957
|
response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
787
|
-
return withCors(response, req,
|
|
958
|
+
return withCors(response, req, policy);
|
|
788
959
|
});
|
|
789
960
|
}
|
|
790
961
|
|
|
@@ -794,12 +965,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
794
965
|
) {
|
|
795
966
|
disableResponsesRequestTimeout(req, requestServer);
|
|
796
967
|
if (isDraining()) {
|
|
797
|
-
return drainingResponse(req);
|
|
968
|
+
return drainingResponse(req, policy);
|
|
798
969
|
}
|
|
799
|
-
const admission = resolveApiAuth(req,
|
|
800
|
-
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
801
|
-
if (!isAllowedRequestOrigin(req,
|
|
802
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req,
|
|
970
|
+
const admission = resolveApiAuth(req, policy);
|
|
971
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
972
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
973
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
803
974
|
}
|
|
804
975
|
const start = Date.now();
|
|
805
976
|
const requestId = nextRequestLogId(start);
|
|
@@ -809,24 +980,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
809
980
|
...admissionFields(admission),
|
|
810
981
|
};
|
|
811
982
|
const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const;
|
|
812
|
-
return runAdmittedHttpTurn(req, async turnAdmissionLease => {
|
|
983
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
813
984
|
const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease);
|
|
814
985
|
addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
815
|
-
return withCors(response, req,
|
|
986
|
+
return withCors(response, req, policy);
|
|
816
987
|
});
|
|
817
988
|
}
|
|
818
989
|
|
|
819
990
|
if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) {
|
|
820
|
-
const admission = resolveApiAuth(req,
|
|
821
|
-
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
822
|
-
if (!isAllowedRequestOrigin(req,
|
|
823
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req,
|
|
991
|
+
const admission = resolveApiAuth(req, policy);
|
|
992
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
993
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
994
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
824
995
|
}
|
|
825
996
|
const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length));
|
|
826
997
|
const { resolveArtifactPath } = await import("../images/artifacts");
|
|
827
998
|
const artifactPath = resolveArtifactPath(id);
|
|
828
999
|
if (!artifactPath) {
|
|
829
|
-
return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req,
|
|
1000
|
+
return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, policy);
|
|
830
1001
|
}
|
|
831
1002
|
const file = Bun.file(artifactPath);
|
|
832
1003
|
const ext = artifactPath.split(".").pop()?.toLowerCase();
|
|
@@ -843,18 +1014,18 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
843
1014
|
"cache-control": "private, max-age=3600",
|
|
844
1015
|
"x-content-type-options": "nosniff",
|
|
845
1016
|
},
|
|
846
|
-
}), req,
|
|
1017
|
+
}), req, policy);
|
|
847
1018
|
}
|
|
848
1019
|
|
|
849
1020
|
if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
|
|
850
1021
|
disableResponsesRequestTimeout(req, requestServer);
|
|
851
1022
|
if (isDraining()) {
|
|
852
|
-
return drainingResponse(req);
|
|
1023
|
+
return drainingResponse(req, policy);
|
|
853
1024
|
}
|
|
854
|
-
const admission = resolveApiAuth(req,
|
|
855
|
-
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
856
|
-
if (!isAllowedRequestOrigin(req,
|
|
857
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req,
|
|
1025
|
+
const admission = resolveApiAuth(req, policy);
|
|
1026
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1027
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1028
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
858
1029
|
}
|
|
859
1030
|
const start = Date.now();
|
|
860
1031
|
const requestId = nextRequestLogId(start);
|
|
@@ -863,23 +1034,23 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
863
1034
|
provider: "unknown",
|
|
864
1035
|
...admissionFields(admission),
|
|
865
1036
|
};
|
|
866
|
-
return runAdmittedHttpTurn(req, async turnAdmissionLease => {
|
|
1037
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
867
1038
|
const response = await handleSearch(req, config, logCtx, turnAdmissionLease);
|
|
868
1039
|
addFinalRequestLog(requestId, start, logCtx, response.status,
|
|
869
1040
|
response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
870
|
-
return withCors(response, req,
|
|
1041
|
+
return withCors(response, req, policy);
|
|
871
1042
|
});
|
|
872
1043
|
}
|
|
873
1044
|
|
|
874
1045
|
if (url.pathname === "/v1/responses" && req.method === "POST") {
|
|
875
1046
|
disableResponsesRequestTimeout(req, requestServer);
|
|
876
1047
|
if (isDraining()) {
|
|
877
|
-
return drainingResponse(req);
|
|
1048
|
+
return drainingResponse(req, policy);
|
|
878
1049
|
}
|
|
879
|
-
const admission = resolveResponsesApiAuth(req,
|
|
880
|
-
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
881
|
-
if (!isAllowedRequestOrigin(req,
|
|
882
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req,
|
|
1050
|
+
const admission = resolveResponsesApiAuth(req, policy);
|
|
1051
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1052
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1053
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
883
1054
|
}
|
|
884
1055
|
const start = Date.now();
|
|
885
1056
|
const requestId = nextRequestLogId(start);
|
|
@@ -898,7 +1069,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
898
1069
|
logged = true;
|
|
899
1070
|
addFinalRequestLog(requestId, start, logCtx, status, meta);
|
|
900
1071
|
};
|
|
901
|
-
return runAdmittedHttpTurn(req, async turnAdmissionLease => {
|
|
1072
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
902
1073
|
const response = await handleResponses(req, config, logCtx, {
|
|
903
1074
|
turnAdmissionLease,
|
|
904
1075
|
abortSignal: req.signal,
|
|
@@ -913,7 +1084,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
913
1084
|
finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
|
|
914
1085
|
},
|
|
915
1086
|
});
|
|
916
|
-
return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req,
|
|
1087
|
+
return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy);
|
|
917
1088
|
});
|
|
918
1089
|
}
|
|
919
1090
|
|
|
@@ -921,29 +1092,29 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
921
1092
|
// Claude Code posts `/v1/messages?beta=true` — pathname match ignores the query (003 G9).
|
|
922
1093
|
if (url.pathname === "/v1/messages/count_tokens" && req.method === "POST") {
|
|
923
1094
|
if (isDraining()) {
|
|
924
|
-
return drainingResponse(req);
|
|
1095
|
+
return drainingResponse(req, policy);
|
|
925
1096
|
}
|
|
926
|
-
const admission = resolveApiAuth(req,
|
|
1097
|
+
const admission = resolveApiAuth(req, policy);
|
|
927
1098
|
if (!admission) {
|
|
928
|
-
return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req,
|
|
1099
|
+
return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy);
|
|
929
1100
|
}
|
|
930
|
-
if (!isAllowedRequestOrigin(req,
|
|
931
|
-
return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req,
|
|
1101
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1102
|
+
return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy);
|
|
932
1103
|
}
|
|
933
|
-
return runAdmittedHttpTurn(req, async () => withCors(await handleClaudeCountTokens(req, config), req,
|
|
1104
|
+
return runAdmittedHttpTurn(req, policy, async () => withCors(await handleClaudeCountTokens(req, config), req, policy));
|
|
934
1105
|
}
|
|
935
1106
|
|
|
936
1107
|
if (url.pathname === "/v1/messages" && req.method === "POST") {
|
|
937
1108
|
disableResponsesRequestTimeout(req, requestServer);
|
|
938
1109
|
if (isDraining()) {
|
|
939
|
-
return drainingResponse(req);
|
|
1110
|
+
return drainingResponse(req, policy);
|
|
940
1111
|
}
|
|
941
|
-
const admission = resolveApiAuth(req,
|
|
1112
|
+
const admission = resolveApiAuth(req, policy);
|
|
942
1113
|
if (!admission) {
|
|
943
|
-
return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req,
|
|
1114
|
+
return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy);
|
|
944
1115
|
}
|
|
945
|
-
if (!isAllowedRequestOrigin(req,
|
|
946
|
-
return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req,
|
|
1116
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1117
|
+
return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy);
|
|
947
1118
|
}
|
|
948
1119
|
const start = Date.now();
|
|
949
1120
|
const requestId = nextRequestLogId(start);
|
|
@@ -956,7 +1127,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
956
1127
|
// Logging is finalized inside handleClaudeMessages (Responses-vocab tap on the
|
|
957
1128
|
// pre-translation stream + native passthrough callbacks) — do not re-wrap the
|
|
958
1129
|
// translated Anthropic stream here.
|
|
959
|
-
return runAdmittedHttpTurn(req, async turnAdmissionLease => withCors(
|
|
1130
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
|
|
960
1131
|
await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }),
|
|
961
1132
|
req,
|
|
962
1133
|
config,
|
|
@@ -968,12 +1139,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
968
1139
|
if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
|
|
969
1140
|
disableResponsesRequestTimeout(req, requestServer);
|
|
970
1141
|
if (isDraining()) {
|
|
971
|
-
return drainingResponse(req);
|
|
1142
|
+
return drainingResponse(req, policy);
|
|
972
1143
|
}
|
|
973
|
-
const admission = resolveResponsesApiAuth(req,
|
|
974
|
-
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
975
|
-
if (!isAllowedRequestOrigin(req,
|
|
976
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req,
|
|
1144
|
+
const admission = resolveResponsesApiAuth(req, policy);
|
|
1145
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1146
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1147
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
977
1148
|
}
|
|
978
1149
|
const start = Date.now();
|
|
979
1150
|
const requestId = nextRequestLogId(start);
|
|
@@ -983,7 +1154,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
983
1154
|
...admissionFields(admission),
|
|
984
1155
|
inboundProtocol: "chat",
|
|
985
1156
|
};
|
|
986
|
-
return runAdmittedHttpTurn(req, async turnAdmissionLease => withCors(
|
|
1157
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
|
|
987
1158
|
await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease }),
|
|
988
1159
|
req,
|
|
989
1160
|
config,
|
|
@@ -999,12 +1170,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
999
1170
|
) {
|
|
1000
1171
|
disableResponsesRequestTimeout(req, requestServer);
|
|
1001
1172
|
if (isDraining()) {
|
|
1002
|
-
return drainingResponse(req);
|
|
1173
|
+
return drainingResponse(req, policy);
|
|
1003
1174
|
}
|
|
1004
|
-
const admission = resolveApiAuth(req,
|
|
1005
|
-
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
1006
|
-
if (!isAllowedRequestOrigin(req,
|
|
1007
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req,
|
|
1175
|
+
const admission = resolveApiAuth(req, policy);
|
|
1176
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1177
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1178
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1008
1179
|
}
|
|
1009
1180
|
const start = Date.now();
|
|
1010
1181
|
const requestId = nextRequestLogId(start);
|
|
@@ -1013,7 +1184,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1013
1184
|
provider: "unknown",
|
|
1014
1185
|
...admissionFields(admission),
|
|
1015
1186
|
};
|
|
1016
|
-
return runAdmittedHttpTurn(req, async turnAdmissionLease => {
|
|
1187
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
1017
1188
|
const response = await handleLive(req, config, logCtx, turnAdmissionLease);
|
|
1018
1189
|
addFinalRequestLog(
|
|
1019
1190
|
requestId,
|
|
@@ -1022,7 +1193,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1022
1193
|
response.status,
|
|
1023
1194
|
response.status === 499 ? { closeReason: "client_cancel" } : undefined,
|
|
1024
1195
|
);
|
|
1025
|
-
return withCors(response, req,
|
|
1196
|
+
return withCors(response, req, policy);
|
|
1026
1197
|
});
|
|
1027
1198
|
}
|
|
1028
1199
|
|
|
@@ -1033,12 +1204,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1033
1204
|
: null;
|
|
1034
1205
|
if (liveSidebandTarget) {
|
|
1035
1206
|
if (isDraining()) {
|
|
1036
|
-
return drainingResponse(req);
|
|
1207
|
+
return drainingResponse(req, policy);
|
|
1037
1208
|
}
|
|
1038
|
-
const admission = resolveApiAuth(req,
|
|
1039
|
-
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req,
|
|
1040
|
-
if (!isAllowedRequestOrigin(req,
|
|
1041
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req,
|
|
1209
|
+
const admission = resolveApiAuth(req, policy);
|
|
1210
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1211
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1212
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy);
|
|
1042
1213
|
}
|
|
1043
1214
|
const start = Date.now();
|
|
1044
1215
|
const requestId = nextRequestLogId(start);
|
|
@@ -1048,7 +1219,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1048
1219
|
...admissionFields(admission),
|
|
1049
1220
|
};
|
|
1050
1221
|
const turnAdmissionLease = tryAdmitTurn();
|
|
1051
|
-
if (!turnAdmissionLease) return serverBusyResponse(req, "active turns");
|
|
1222
|
+
if (!turnAdmissionLease) return serverBusyResponse(req, "active turns", policy);
|
|
1052
1223
|
let resolved;
|
|
1053
1224
|
try {
|
|
1054
1225
|
resolved = await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease);
|
|
@@ -1059,10 +1230,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1059
1230
|
if (resolved instanceof Response) {
|
|
1060
1231
|
turnAdmissionLease.release();
|
|
1061
1232
|
addFinalRequestLog(requestId, start, logCtx, resolved.status);
|
|
1062
|
-
return withCors(resolved, req,
|
|
1233
|
+
return withCors(resolved, req, policy);
|
|
1063
1234
|
}
|
|
1064
1235
|
addFinalRequestLog(requestId, start, logCtx, 101);
|
|
1065
|
-
if (
|
|
1236
|
+
if (requestServer.upgrade(req, {
|
|
1066
1237
|
data: {
|
|
1067
1238
|
kind: "live-sideband",
|
|
1068
1239
|
liveUpstreamUrl: resolved.upstreamWsUrl,
|
|
@@ -1073,7 +1244,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1073
1244
|
} satisfies WsData,
|
|
1074
1245
|
})) return undefined as unknown as Response;
|
|
1075
1246
|
turnAdmissionLease.release();
|
|
1076
|
-
return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req,
|
|
1247
|
+
return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
|
|
1077
1248
|
}
|
|
1078
1249
|
|
|
1079
1250
|
// Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
|
|
@@ -1081,7 +1252,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1081
1252
|
// endpoint clients — memories/*, realtime/* — would surface confusing
|
|
1082
1253
|
// serde decode errors instead of a clean not-found).
|
|
1083
1254
|
if (url.pathname.startsWith("/v1/")) {
|
|
1084
|
-
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req,
|
|
1255
|
+
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
|
|
1085
1256
|
}
|
|
1086
1257
|
|
|
1087
1258
|
const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes("."))
|
|
@@ -1302,7 +1473,34 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1302
1473
|
ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
|
|
1303
1474
|
},
|
|
1304
1475
|
},
|
|
1305
|
-
}
|
|
1476
|
+
} as const;
|
|
1477
|
+
|
|
1478
|
+
server = Bun.serve<WsData>({ ...serveOptions, port: listenPort, hostname: bindHost });
|
|
1479
|
+
|
|
1480
|
+
// Both binds are one startup transaction (#1102). If the loopback bind fails after the
|
|
1481
|
+
// public one succeeded, leaving the public listener up would strand it: the CLI's port
|
|
1482
|
+
// retry would read the failure as a public-port conflict and pick a different port,
|
|
1483
|
+
// accumulating listeners. Roll back and rethrow the original error instead.
|
|
1484
|
+
if (loopbackListenerPort !== null) {
|
|
1485
|
+
try {
|
|
1486
|
+
loopbackServer = Bun.serve<WsData>({
|
|
1487
|
+
...serveOptions,
|
|
1488
|
+
port: loopbackListenerPort,
|
|
1489
|
+
hostname: "127.0.0.1",
|
|
1490
|
+
});
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
try {
|
|
1493
|
+
// startServer is synchronous, so this rollback cannot await. Bun begins closing the
|
|
1494
|
+
// listen socket on the call itself; the caller sees the original bind error either
|
|
1495
|
+
// way, and the alternative — leaving the public listener up — is the failure this
|
|
1496
|
+
// rollback exists to prevent.
|
|
1497
|
+
void server.stop(true);
|
|
1498
|
+
} catch {
|
|
1499
|
+
/* the original bind error is the one worth reporting */
|
|
1500
|
+
}
|
|
1501
|
+
throw error;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1306
1504
|
} catch (error) {
|
|
1307
1505
|
void nativeMainLifecycle.release();
|
|
1308
1506
|
throw error;
|
|
@@ -1310,18 +1508,26 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1310
1508
|
|
|
1311
1509
|
bindNativeMainStartupLifecycle(server, nativeMainLifecycle);
|
|
1312
1510
|
const nativeStop = server.stop.bind(server);
|
|
1511
|
+
const loopbackListenerRef = loopbackServer;
|
|
1313
1512
|
Object.defineProperty(server, "stop", {
|
|
1314
1513
|
configurable: true,
|
|
1315
1514
|
value: async (closeActiveConnections?: boolean): Promise<void> => {
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1515
|
+
// The orchestration lives in `runListenerShutdown` so its two competing properties —
|
|
1516
|
+
// cleanup completes, failure propagates — are testable without a live socket.
|
|
1517
|
+
await runListenerShutdown(
|
|
1518
|
+
[
|
|
1519
|
+
() => nativeStop(closeActiveConnections),
|
|
1520
|
+
...(loopbackListenerRef
|
|
1521
|
+
? [() => loopbackListenerRef.stop(closeActiveConnections)]
|
|
1522
|
+
: []),
|
|
1523
|
+
],
|
|
1524
|
+
() => releaseNativeMainStartupLifecycle(server),
|
|
1525
|
+
);
|
|
1321
1526
|
},
|
|
1322
1527
|
});
|
|
1323
1528
|
setServerRef(server);
|
|
1324
1529
|
const actualPort = server.port ?? listenPort;
|
|
1530
|
+
boundPort = actualPort;
|
|
1325
1531
|
setCorsOrigin(actualPort);
|
|
1326
1532
|
|
|
1327
1533
|
console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`);
|
|
@@ -1331,6 +1537,17 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
|
|
|
1331
1537
|
console.log(` GET /api/* → management API`);
|
|
1332
1538
|
console.log(` GET / → GUI dashboard`);
|
|
1333
1539
|
|
|
1540
|
+
if (loopbackServer) {
|
|
1541
|
+
// Loud on every start, not once at enable time. An operator who inherits a config, or
|
|
1542
|
+
// who forgot, has to be able to see that an unauthenticated surface is live without
|
|
1543
|
+
// reading the file.
|
|
1544
|
+
const loopbackPort = loopbackServer.port ?? loopbackListenerPort;
|
|
1545
|
+
console.warn(`⚠️ Unauthenticated loopback listener active on http://127.0.0.1:${loopbackPort}`);
|
|
1546
|
+
console.warn(` Any local process can use it without a credential — it spends account`);
|
|
1547
|
+
console.warn(` quota and paid provider credentials, and can starve authenticated`);
|
|
1548
|
+
console.warn(` remote clients. Not for shared or multi-tenant hosts.`);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1334
1551
|
// Prime pool-account quota in the background so the rotation engine has real
|
|
1335
1552
|
// usage scores from the first routing decision, even when the dashboard is
|
|
1336
1553
|
// never opened (the common CLI/WSL case). Fire-and-forget: never blocks the
|