@bitkyc08/opencodex 2.39.0 → 2.40.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 +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- package/gui/dist/assets/index-uvENYLin.css +0 -1
package/src/server/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
websocketsEnabled,
|
|
22
22
|
} from "../config";
|
|
23
23
|
import { grokDefaultReasoningEffort } from "../grok/effort";
|
|
24
|
+
import { flushConfigDirHardening } from "../config/paths";
|
|
24
25
|
import { reconcileOAuthProviders } from "../oauth";
|
|
25
26
|
import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization";
|
|
26
27
|
import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync";
|
|
@@ -32,7 +33,8 @@ import {
|
|
|
32
33
|
type NativeCodexOwnership,
|
|
33
34
|
type OwnershipInspection,
|
|
34
35
|
} from "../integrations/native/ownership-preflight";
|
|
35
|
-
import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api";
|
|
36
|
+
import { createResetCreditWhamClient, registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api";
|
|
37
|
+
import { activateResetCreditAutoRedeem } from "../codex/reset-credit-auto-redeem";
|
|
36
38
|
import {
|
|
37
39
|
reconcileLiveStateStores,
|
|
38
40
|
setLiveStateStoreConfig,
|
|
@@ -155,6 +157,7 @@ import {
|
|
|
155
157
|
resolveApiAuth,
|
|
156
158
|
resolveResponsesApiAuth,
|
|
157
159
|
requestPolicyView,
|
|
160
|
+
type DataPlaneAdmission,
|
|
158
161
|
type RequestPolicyView,
|
|
159
162
|
safeConfigDTO,
|
|
160
163
|
setCorsOrigin,
|
|
@@ -190,6 +193,7 @@ import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveS
|
|
|
190
193
|
import { handleSearch } from "./search";
|
|
191
194
|
import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api";
|
|
192
195
|
import {
|
|
196
|
+
createManagementSessionControl,
|
|
193
197
|
initializeManagementAuthState,
|
|
194
198
|
issueGuiSession,
|
|
195
199
|
managementPrincipal,
|
|
@@ -204,15 +208,96 @@ import {
|
|
|
204
208
|
} from "../lib/local-management-attestation";
|
|
205
209
|
import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
|
|
206
210
|
import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract";
|
|
211
|
+
import {
|
|
212
|
+
GUI_PAIR_BROWSER_ORIGIN_HEADER,
|
|
213
|
+
GUI_PAIR_CAPABILITY_VERSION,
|
|
214
|
+
GUI_PAIR_PATH,
|
|
215
|
+
} from "../lib/gui-pair-capability";
|
|
216
|
+
import {
|
|
217
|
+
GuiPairingGrantRateLimitError,
|
|
218
|
+
consumeGuiPairingGrant,
|
|
219
|
+
createGuiPairingGrant,
|
|
220
|
+
} from "./gui-session";
|
|
207
221
|
import { createReadinessGate, type ReadinessGate } from "./readiness";
|
|
208
222
|
import {
|
|
209
223
|
createRuntimePackageTreeIntegrityGuard,
|
|
210
224
|
type PackageTreeIntegrityGuard,
|
|
211
225
|
} from "../lib/package-tree-integrity";
|
|
212
226
|
import { detectInstall } from "../update/index";
|
|
227
|
+
import { readyProtocolMetadata } from "../remote/protocol";
|
|
228
|
+
import { modelCapabilityFields } from "./models-capabilities";
|
|
229
|
+
import { recordCursorSeen } from "../integrations/cursor-seen";
|
|
213
230
|
|
|
214
231
|
export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
|
|
215
232
|
const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
|
|
233
|
+
|
|
234
|
+
// Header-safe by construction: a key id reaches a response header, so anything outside this
|
|
235
|
+
// class could inject a header break or a control character into a response we control.
|
|
236
|
+
const REMOTE_CATALOG_KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
237
|
+
const GUI_PAIRING_EXCHANGE_BODY_LIMIT = 4 * 1024;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read at most `limit` bytes of a request body, or refuse.
|
|
241
|
+
*
|
|
242
|
+
* Returns null the moment the body is known to exceed `limit`, without retaining the excess.
|
|
243
|
+
* `req.text()` cannot express that: it buffers to completion first, so a caller who omits
|
|
244
|
+
* Content-Length or uses chunked framing decides how much memory the process spends. That
|
|
245
|
+
* matters here because the one caller is an unauthenticated endpoint.
|
|
246
|
+
*
|
|
247
|
+
* limit+1 is the stopping point rather than limit, so a body exactly at the limit is still
|
|
248
|
+
* accepted and only a genuinely over-limit body is rejected.
|
|
249
|
+
*/
|
|
250
|
+
async function readBoundedRequestText(req: Request, limit: number): Promise<string | null> {
|
|
251
|
+
const body = req.body;
|
|
252
|
+
if (!body) return "";
|
|
253
|
+
const reader = body.getReader();
|
|
254
|
+
const chunks: Uint8Array[] = [];
|
|
255
|
+
let total = 0;
|
|
256
|
+
try {
|
|
257
|
+
for (;;) {
|
|
258
|
+
const { done, value } = await reader.read();
|
|
259
|
+
if (done) break;
|
|
260
|
+
if (!value || value.byteLength === 0) continue;
|
|
261
|
+
total += value.byteLength;
|
|
262
|
+
if (total > limit) return null;
|
|
263
|
+
chunks.push(value);
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
// Cancel rather than only releasing the lock: on the reject path the peer may still be
|
|
267
|
+
// sending, and an uncancelled body keeps that transfer alive.
|
|
268
|
+
await reader.cancel().catch(() => {});
|
|
269
|
+
}
|
|
270
|
+
const joined = new Uint8Array(total);
|
|
271
|
+
let offset = 0;
|
|
272
|
+
for (const chunk of chunks) {
|
|
273
|
+
joined.set(chunk, offset);
|
|
274
|
+
offset += chunk.byteLength;
|
|
275
|
+
}
|
|
276
|
+
return new TextDecoder().decode(joined);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Name WHICH configured credential was admitted, so a multi-key operator can attribute a
|
|
281
|
+
* catalog read.
|
|
282
|
+
*
|
|
283
|
+
* Scoped to configured keys on purpose: an environment token or a loopback bind has no key
|
|
284
|
+
* to name, and emitting one anyway would invent an attribution that does not exist. 200 only
|
|
285
|
+
* — this route emits no validator and therefore never answers 304.
|
|
286
|
+
*
|
|
287
|
+
* An id that fails the header-safe pattern is omitted rather than sanitized, with one warning
|
|
288
|
+
* that does NOT repeat the id: logging the offending value is how a malformed id becomes a
|
|
289
|
+
* log-injection vector instead of a dropped header.
|
|
290
|
+
*/
|
|
291
|
+
function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmission): Response {
|
|
292
|
+
if (response.status !== 200 || admission.kind !== "configured") return response;
|
|
293
|
+
if (!REMOTE_CATALOG_KEY_ID_PATTERN.test(admission.keyId)) {
|
|
294
|
+
console.warn("[remote-catalog] configured API key id is not header-safe; omitting x-opencodex-key-id");
|
|
295
|
+
return response;
|
|
296
|
+
}
|
|
297
|
+
response.headers.set("x-opencodex-key-id", admission.keyId);
|
|
298
|
+
return response;
|
|
299
|
+
}
|
|
300
|
+
|
|
216
301
|
const LIVE_SIDEBAND_PENDING_MAX = 32;
|
|
217
302
|
const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024;
|
|
218
303
|
const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000;
|
|
@@ -554,12 +639,16 @@ export function warnAgentTaskRecoveryStartup(config: {
|
|
|
554
639
|
|
|
555
640
|
export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
|
|
556
641
|
const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret();
|
|
642
|
+
// Captured before loadConfig() starts the optional ACL flight so stop() drains the same dir
|
|
643
|
+
// even if OPENCODEX_HOME changes underneath a long-lived process.
|
|
644
|
+
const startupConfigDir = getConfigDir();
|
|
557
645
|
const config = runModelRenameStartupMigration(runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig())));
|
|
558
646
|
warnAgentTaskRecoveryStartup(config);
|
|
559
647
|
setLiveStateStoreConfig(config);
|
|
560
648
|
applyProxyEnv(config);
|
|
561
649
|
assertServerAuthConfig(config);
|
|
562
650
|
const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
|
|
651
|
+
const managementSessionControl = createManagementSessionControl(managementAuth);
|
|
563
652
|
let userCostOverlayReconciler: { stop(): void } | null = null;
|
|
564
653
|
// Arm synchronously before listen. A pending journal therefore makes __main__ unusable
|
|
565
654
|
// before any request can resolve its physical credential, while health/management/Pool stay live.
|
|
@@ -673,6 +762,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
673
762
|
// Unauthenticated loopback listener (#1102). Off unless explicitly enabled.
|
|
674
763
|
const loopbackListener = config.unauthenticatedLoopbackListener;
|
|
675
764
|
const loopbackListenerPort = loopbackListener?.enabled ? loopbackListener.port : null;
|
|
765
|
+
// Hub management ingress is a third, management-only listener. Its address is intentionally
|
|
766
|
+
// fixed: the kernel loopback bind is the trust boundary that permits Tailscale identity headers.
|
|
767
|
+
const managementIngress = config.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
|
|
768
|
+
const managementIngressPort = managementIngress?.enabled ? managementIngress.port : null;
|
|
676
769
|
|
|
677
770
|
/**
|
|
678
771
|
* Which listener a request arrived on, expressed as the only thing that differs: the bind
|
|
@@ -694,8 +787,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
694
787
|
* Routes the unauthenticated loopback listener will serve. Everything else 404s.
|
|
695
788
|
*
|
|
696
789
|
* This is an allowlist rather than a filter applied to the public handler, because a filter
|
|
697
|
-
* inverts the failure mode: a route added later would be reachable here by default. The
|
|
698
|
-
* entries are exactly what a directly-spawned `codex app-server` needs.
|
|
790
|
+
* inverts the failure mode: a route added later would be reachable here by default. The
|
|
791
|
+
* entries below are exactly what a directly-spawned `codex app-server` needs.
|
|
792
|
+
*
|
|
793
|
+
* `POST /v1/alpha/search` is the native Codex web-search relay. Codex issues it against the
|
|
794
|
+
* same base URL as `/v1/responses`, so leaving it off the list turned every native web search
|
|
795
|
+
* on the direct-spawn host into a 404 (#3192). The handler still runs its own admission, so a
|
|
796
|
+
* loopback caller without a ChatGPT credential is refused inside it rather than by this gate.
|
|
699
797
|
*
|
|
700
798
|
* `GET /v1/models` is on the list for a reason that is easy to miss. When catalog
|
|
701
799
|
* materialization fails or finds no source, `syncCodex` warns and injects with
|
|
@@ -709,6 +807,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
709
807
|
return req.method === "POST" || req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
710
808
|
}
|
|
711
809
|
if (path === "/v1/responses/compact") return req.method === "POST";
|
|
810
|
+
if (path === "/v1/alpha/search") return req.method === "POST";
|
|
712
811
|
if (path === "/v1/models") return req.method === "GET";
|
|
713
812
|
// Standalone realtime voice sessions (codex-rs thread/realtime/start, WebSocket
|
|
714
813
|
// transport) — a directly-spawned `codex app-server` needs these for desktop
|
|
@@ -720,6 +819,33 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
720
819
|
return false;
|
|
721
820
|
}
|
|
722
821
|
|
|
822
|
+
/**
|
|
823
|
+
* Routes the loopback hub-management listener will serve. This is default-deny so adding a
|
|
824
|
+
* data-plane or health route to the public handler cannot silently expose it through Tailscale
|
|
825
|
+
* Serve. A dotted GUI path is admitted only when it resolves to a packaged file; extensionless
|
|
826
|
+
* GETs intentionally retain the existing SPA fallback.
|
|
827
|
+
*/
|
|
828
|
+
function managementIngressRouteAllowed(url: URL, req: Request): boolean {
|
|
829
|
+
const rawPath = url.pathname;
|
|
830
|
+
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") return false;
|
|
831
|
+
if (rawPath === "/opencodex-session") return req.method === "GET" || req.method === "POST";
|
|
832
|
+
if (rawPath.startsWith("/api/")) return true;
|
|
833
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
834
|
+
let decodedPath: string;
|
|
835
|
+
try {
|
|
836
|
+
decodedPath = decodeURIComponent(rawPath);
|
|
837
|
+
} catch {
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
if (
|
|
841
|
+
decodedPath.startsWith("/v1/")
|
|
842
|
+
|| decodedPath === "/healthz"
|
|
843
|
+
|| decodedPath === "/readyz"
|
|
844
|
+
) return false;
|
|
845
|
+
if (decodedPath === "/" || !decodedPath.includes(".")) return true;
|
|
846
|
+
return serveGuiFile(rawPath) !== null;
|
|
847
|
+
}
|
|
848
|
+
|
|
723
849
|
// Codex treats empty / non-JSON 503 bodies as "Unknown error" (#452). Keep Retry-After and
|
|
724
850
|
// the server_is_overloaded code so clients can back off, but always return a JSON envelope.
|
|
725
851
|
// These two run BEFORE the auth/origin checks, so they need the receiving listener's policy
|
|
@@ -871,6 +997,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
871
997
|
};
|
|
872
998
|
let server: Server<WsData>;
|
|
873
999
|
let loopbackServer: Server<WsData> | null = null;
|
|
1000
|
+
let managementIngressServer: Server<WsData> | null = null;
|
|
1001
|
+
|
|
1002
|
+
type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management";
|
|
1003
|
+
function ingressForServer(requestServer: Server<WsData>): ServerIngress {
|
|
1004
|
+
if (requestServer === loopbackServer) return "unauthenticated-loopback";
|
|
1005
|
+
if (requestServer === managementIngressServer) return "hub-management";
|
|
1006
|
+
return "public";
|
|
1007
|
+
}
|
|
874
1008
|
let backgroundLifecycle: ReturnType<typeof acquireServerBackgroundLifecycle> | null = null;
|
|
875
1009
|
try {
|
|
876
1010
|
backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy);
|
|
@@ -883,22 +1017,32 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
883
1017
|
idleTimeout: 255,
|
|
884
1018
|
maxRequestBodySize: MAX_DECOMPRESSED_BODY_BYTES,
|
|
885
1019
|
async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
|
|
1020
|
+
const ingress = ingressForServer(requestServer);
|
|
886
1021
|
// The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
|
|
887
1022
|
// else. Rejecting here, before any handler runs, is what keeps the surface from growing
|
|
888
1023
|
// silently when a route is added below.
|
|
889
|
-
if (
|
|
1024
|
+
if (ingress === "unauthenticated-loopback" && !loopbackRouteAllowed(new URL(req.url), req)) {
|
|
890
1025
|
return withCors(
|
|
891
1026
|
formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
|
|
892
1027
|
req,
|
|
893
1028
|
loopbackPolicy(),
|
|
894
1029
|
);
|
|
895
1030
|
}
|
|
1031
|
+
// Tailscale Serve terminates only on this separately bound loopback socket. Reject before
|
|
1032
|
+
// dispatch so no data, readiness, health, WebSocket, or unknown-static handler can run.
|
|
1033
|
+
if (ingress === "hub-management" && !managementIngressRouteAllowed(new URL(req.url), req)) {
|
|
1034
|
+
return withCors(
|
|
1035
|
+
formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
|
|
1036
|
+
req,
|
|
1037
|
+
config,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
896
1040
|
// Auth and CORS decisions below read `policy`, not `config`. For the public listener the
|
|
897
1041
|
// two are the same object, so its behaviour is unchanged; for the loopback listener the
|
|
898
1042
|
// view substitutes 127.0.0.1 as the bind address, which is what routes it through the
|
|
899
1043
|
// same code path a plain loopback bind has always taken — Host-header check included.
|
|
900
1044
|
// Routing, provider selection and response bodies keep using `config`.
|
|
901
|
-
const policy: RequestPolicyView =
|
|
1045
|
+
const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config;
|
|
902
1046
|
const url = new URL(req.url);
|
|
903
1047
|
markActivity(`${req.method} ${url.pathname}`);
|
|
904
1048
|
|
|
@@ -1008,6 +1152,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1008
1152
|
port: healthPort,
|
|
1009
1153
|
restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
|
|
1010
1154
|
providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION,
|
|
1155
|
+
guiPairCapability: GUI_PAIR_CAPABILITY_VERSION,
|
|
1011
1156
|
}, 200, req, policy);
|
|
1012
1157
|
const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
|
|
1013
1158
|
if (challenge) {
|
|
@@ -1041,6 +1186,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1041
1186
|
pid: process.pid,
|
|
1042
1187
|
port: boundPort ?? listenPort,
|
|
1043
1188
|
status,
|
|
1189
|
+
...readyProtocolMetadata(config, req),
|
|
1044
1190
|
};
|
|
1045
1191
|
if (status === "ready") {
|
|
1046
1192
|
return jsonResponse(body, 200, req, policy);
|
|
@@ -1065,7 +1211,29 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1065
1211
|
// gate used. Consent-bearing routes need this: request headers are forgeable
|
|
1066
1212
|
// by anything holding the admin token, the credential is not.
|
|
1067
1213
|
const principal = managementPrincipal(req, managementAuth, config, localManagementAuth) ?? undefined;
|
|
1068
|
-
|
|
1214
|
+
if (url.pathname === GUI_PAIR_PATH) {
|
|
1215
|
+
if (req.method !== "POST" || principal !== "gui-pair-capability" || !managementAuth.available) {
|
|
1216
|
+
return withManagementCors(Response.json({ error: "GUI pairing capability required" }, { status: 403 }), req, config);
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
const grant = createGuiPairingGrant(
|
|
1220
|
+
req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "",
|
|
1221
|
+
config,
|
|
1222
|
+
managementAuth,
|
|
1223
|
+
);
|
|
1224
|
+
return withManagementCors(Response.json(grant, {
|
|
1225
|
+
status: 201,
|
|
1226
|
+
headers: { "Cache-Control": "no-store" },
|
|
1227
|
+
}), req, config);
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
const status = error instanceof GuiPairingGrantRateLimitError ? 429 : 403;
|
|
1230
|
+
return withManagementCors(Response.json({ error: "GUI pairing grant refused" }, {
|
|
1231
|
+
status,
|
|
1232
|
+
...(status === 429 ? { headers: { "Retry-After": "60" } } : {}),
|
|
1233
|
+
}), req, config);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
const mgmtResponse = await handleManagementAPI(req, url, config, deps.managementApi, principal, managementSessionControl);
|
|
1069
1237
|
if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
|
|
1070
1238
|
return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
|
|
1071
1239
|
}
|
|
@@ -1121,23 +1289,34 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1121
1289
|
}
|
|
1122
1290
|
const headers: Record<string, string> = {
|
|
1123
1291
|
"content-type": "application/json",
|
|
1124
|
-
// Identity-varying content behind a credential: never let a shared cache keep it
|
|
1125
|
-
|
|
1292
|
+
// Identity-varying content behind a credential: never let a shared cache keep it,
|
|
1293
|
+
// and never hand out a validator it could revalidate with. `no-cache` alone does
|
|
1294
|
+
// not prevent storage — it forces revalidation, and the revalidation is exactly
|
|
1295
|
+
// what would cross identities here, because this body varies by key type and key
|
|
1296
|
+
// id while the ETag would be derived from bytes alone. A store keyed on URL plus
|
|
1297
|
+
// validator could then serve one credential's representation to another. Proving
|
|
1298
|
+
// an identity-partitioned cache key across every intermediary in the path is a
|
|
1299
|
+
// much larger commitment than the bandwidth a 304 saves on this payload, so this
|
|
1300
|
+
// route declines the trade: no-store, no ETag, no 304.
|
|
1301
|
+
//
|
|
1302
|
+
// GET /api/catalog keeps its validator. That route is management-authenticated
|
|
1303
|
+
// and loopback-scoped, and its representation does not vary by data-key identity.
|
|
1304
|
+
"cache-control": "no-store",
|
|
1126
1305
|
};
|
|
1127
|
-
if (serialized.etag) headers.ETag = serialized.etag;
|
|
1128
1306
|
const version = await persistedCodexVersion();
|
|
1129
1307
|
if (version) headers["x-opencodex-codex-version"] = version;
|
|
1130
|
-
//
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
return withCors(new Response(null, { status: 304, headers }), req, policy);
|
|
1134
|
-
}
|
|
1308
|
+
// No conditional handling: with no validator emitted, an If-None-Match on this route
|
|
1309
|
+
// can only have been guessed or copied from elsewhere, and honoring it would
|
|
1310
|
+
// reintroduce the cross-identity path above. Every request gets the full body.
|
|
1135
1311
|
if (serialized.bytes !== undefined) headers["content-length"] = String(serialized.bytes);
|
|
1136
1312
|
// HEAD returns identical status and headers with no body.
|
|
1137
|
-
return
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1313
|
+
return withRemoteCatalogKeyId(
|
|
1314
|
+
withCors(
|
|
1315
|
+
new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }),
|
|
1316
|
+
req,
|
|
1317
|
+
policy,
|
|
1318
|
+
),
|
|
1319
|
+
admission,
|
|
1141
1320
|
);
|
|
1142
1321
|
}
|
|
1143
1322
|
|
|
@@ -1153,6 +1332,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1153
1332
|
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1154
1333
|
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1155
1334
|
}
|
|
1335
|
+
// The Integrations page reports whether a Cursor client has reached this proxy; the
|
|
1336
|
+
// recorder keeps only a bounded User-Agent value and a timestamp, in memory.
|
|
1337
|
+
recordCursorSeen(req.headers);
|
|
1156
1338
|
let goModels;
|
|
1157
1339
|
let modelEntitlements;
|
|
1158
1340
|
try {
|
|
@@ -1172,7 +1354,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1172
1354
|
}
|
|
1173
1355
|
throw error;
|
|
1174
1356
|
}
|
|
1175
|
-
const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
|
|
1357
|
+
const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiContextTier, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
|
|
1176
1358
|
const { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } = await import("../codex/catalog/native-models");
|
|
1177
1359
|
const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
|
|
1178
1360
|
const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
|
|
@@ -1258,7 +1440,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1258
1440
|
: idsParam === "desktop"
|
|
1259
1441
|
? "desktop3p" as const
|
|
1260
1442
|
: (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
|
|
1261
|
-
const data = buildAnthropicModelInfos(desktopNativeSlugs, goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias, nativeContextLimits(config));
|
|
1443
|
+
const data = buildAnthropicModelInfos(desktopNativeSlugs, goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias, nativeContextLimits(config), config.fastMode);
|
|
1262
1444
|
return jsonResponse({ data }, 200, req, policy);
|
|
1263
1445
|
}
|
|
1264
1446
|
if (url.searchParams.has("client_version")) {
|
|
@@ -1326,6 +1508,16 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1326
1508
|
reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)),
|
|
1327
1509
|
};
|
|
1328
1510
|
};
|
|
1511
|
+
// Cursor's local-agent runtime (Private Inference build) reads api_types + capabilities
|
|
1512
|
+
// to enable its effort control; every other consumer ignores them. See
|
|
1513
|
+
// src/server/models-capabilities.ts.
|
|
1514
|
+
const nativeLimits = nativeContextLimits(config);
|
|
1515
|
+
const nativeContextInput = (metadataId: string) => {
|
|
1516
|
+
const tier = nativeOpenAiContextTier(metadataId, nativeLimits);
|
|
1517
|
+
return tier
|
|
1518
|
+
? { contextWindow: tier.defaultWindow, longContextWindow: tier.longWindow }
|
|
1519
|
+
: { contextWindow: nativeOpenAiContextWindow(metadataId, nativeLimits) };
|
|
1520
|
+
};
|
|
1329
1521
|
const nativeModelRow = (id: string, metadataId = id) => ({
|
|
1330
1522
|
id,
|
|
1331
1523
|
object: "model",
|
|
@@ -1335,7 +1527,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1335
1527
|
nativeReasoningEfforts(metadataId),
|
|
1336
1528
|
nativeDefaultReasoningEffort(metadataId),
|
|
1337
1529
|
),
|
|
1530
|
+
...modelCapabilityFields({
|
|
1531
|
+
reasoningEfforts: nativeReasoningEfforts(metadataId),
|
|
1532
|
+
// Cursor "Max Mode": advertise the family's default/long pair (272k/922k for
|
|
1533
|
+
// GPT-5.6) so the client can pick per request; without a tier, the effective
|
|
1534
|
+
// window is the only value.
|
|
1535
|
+
...nativeContextInput(metadataId),
|
|
1536
|
+
inputModalities: nativeInputModalities(metadataId),
|
|
1537
|
+
}),
|
|
1338
1538
|
});
|
|
1539
|
+
// Resolved once per request, not per model: the global fast switch offers the fast
|
|
1540
|
+
// identity to clients that have no Fast toggle of their own. Null when the switch is
|
|
1541
|
+
// off, so the row mapper does no work and loads no adapter module.
|
|
1542
|
+
const cursorFastIdForListing = config.fastMode === true
|
|
1543
|
+
? await (async () => {
|
|
1544
|
+
const { cursorFastIdFor } = await import("../adapters/cursor/catalog");
|
|
1545
|
+
return (modelId: string, provider = "cursor") => provider === "cursor" ? cursorFastIdFor(modelId) : undefined;
|
|
1546
|
+
})()
|
|
1547
|
+
: null;
|
|
1339
1548
|
// Selector-active discovery follows the same complete supported set as the Codex catalog
|
|
1340
1549
|
// for both bare and qualified rows. Without selectors, the live catalog continues to own
|
|
1341
1550
|
// bare availability.
|
|
@@ -1360,7 +1569,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1360
1569
|
...visibleNatives.map(id => nativeModelRow(id)),
|
|
1361
1570
|
...visibleAccountNatives.map(({ id, metadataId }) => nativeModelRow(id, metadataId)),
|
|
1362
1571
|
...await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => {
|
|
1363
|
-
|
|
1572
|
+
// Same rule as the anthropic branch: with the global fast switch on, a client
|
|
1573
|
+
// that has no Fast toggle is offered the fast identity directly. An operator
|
|
1574
|
+
// alias is an explicit decision and still wins.
|
|
1575
|
+
const fastModelId = cursorFastIdForListing?.(m.id, m.provider);
|
|
1576
|
+
const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`;
|
|
1364
1577
|
const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId);
|
|
1365
1578
|
const provider = config.providers[m.provider];
|
|
1366
1579
|
const effective = provider
|
|
@@ -1381,6 +1594,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1381
1594
|
...(isCombo ? { is_combo: true } : {}),
|
|
1382
1595
|
...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}),
|
|
1383
1596
|
...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
|
|
1597
|
+
...modelCapabilityFields({
|
|
1598
|
+
reasoningEfforts: m.reasoningEfforts,
|
|
1599
|
+
// contextWindow is already the post-cap effective value; contextCap is the raw
|
|
1600
|
+
// operator knob and over-reports models whose real window sits below it.
|
|
1601
|
+
contextWindow: m.contextWindow,
|
|
1602
|
+
inputModalities: m.inputModalities,
|
|
1603
|
+
}),
|
|
1384
1604
|
};
|
|
1385
1605
|
})),
|
|
1386
1606
|
];
|
|
@@ -1729,15 +1949,77 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1729
1949
|
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
|
|
1730
1950
|
}
|
|
1731
1951
|
|
|
1952
|
+
if (url.pathname === "/opencodex-session") {
|
|
1953
|
+
if (req.method === "GET") {
|
|
1954
|
+
const session = issueGuiSession(req, config, managementAuth, {
|
|
1955
|
+
trustedTailscaleIngress: ingress === "hub-management",
|
|
1956
|
+
});
|
|
1957
|
+
return session
|
|
1958
|
+
? withManagementCors(serveSessionBootstrap(session), req, config)
|
|
1959
|
+
: withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1960
|
+
}
|
|
1961
|
+
if (req.method === "POST") {
|
|
1962
|
+
// This endpoint is reachable WITHOUT a credential — that is the point of a pairing
|
|
1963
|
+
// exchange — so the body limit has to hold against a caller who controls the
|
|
1964
|
+
// framing. A declared Content-Length is a claim, not a bound: omit the header and
|
|
1965
|
+
// `Number(null ?? "0")` is 0, send `Transfer-Encoding: chunked` and there is no
|
|
1966
|
+
// header at all. Both used to pass the pre-check and land in `req.text()`, which
|
|
1967
|
+
// buffers whatever arrives. The post-check then measured a string the process had
|
|
1968
|
+
// already been forced to hold.
|
|
1969
|
+
//
|
|
1970
|
+
// So the declared length is only a cheap early reject, and the real bound is
|
|
1971
|
+
// applied while reading: stop at limit+1 bytes and never accumulate more.
|
|
1972
|
+
const declaredLength = Number(req.headers.get("content-length") ?? "0");
|
|
1973
|
+
if (!Number.isFinite(declaredLength) || declaredLength > GUI_PAIRING_EXCHANGE_BODY_LIMIT) {
|
|
1974
|
+
return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1975
|
+
}
|
|
1976
|
+
const bounded = await readBoundedRequestText(req, GUI_PAIRING_EXCHANGE_BODY_LIMIT);
|
|
1977
|
+
if (bounded === null) {
|
|
1978
|
+
return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1979
|
+
}
|
|
1980
|
+
const text = bounded;
|
|
1981
|
+
let body: unknown;
|
|
1982
|
+
try {
|
|
1983
|
+
body = JSON.parse(text);
|
|
1984
|
+
} catch {
|
|
1985
|
+
return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1986
|
+
}
|
|
1987
|
+
if (!body || typeof body !== "object" || Array.isArray(body)
|
|
1988
|
+
|| Object.keys(body as Record<string, unknown>).length !== 1
|
|
1989
|
+
|| typeof (body as Record<string, unknown>).grant !== "string") {
|
|
1990
|
+
return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1991
|
+
}
|
|
1992
|
+
const pairing = managementAuth.available
|
|
1993
|
+
? consumeGuiPairingGrant(req, body, config, managementAuth, Date.now(), {
|
|
1994
|
+
ingress: ingress === "hub-management" ? "hub-management" : "public",
|
|
1995
|
+
peerAddress: requestServer.requestIP(req)?.address ?? null,
|
|
1996
|
+
tailscaleUser: ingress === "hub-management" ? req.headers.get("Tailscale-User-Login") : null,
|
|
1997
|
+
browserOrigin: req.headers.get("Origin") ?? "",
|
|
1998
|
+
})
|
|
1999
|
+
: null;
|
|
2000
|
+
if (pairing && "allowed" in pairing) {
|
|
2001
|
+
return withManagementCors(Response.json({ error: "pairing exchange refused" }, {
|
|
2002
|
+
status: 429,
|
|
2003
|
+
headers: { "Cache-Control": "no-store", "Retry-After": String(pairing.retryAfterSeconds) },
|
|
2004
|
+
}), req, config);
|
|
2005
|
+
}
|
|
2006
|
+
return pairing
|
|
2007
|
+
? withManagementCors(serveSessionBootstrap(pairing), req, config)
|
|
2008
|
+
: withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
2009
|
+
}
|
|
2010
|
+
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
|
|
2011
|
+
}
|
|
1732
2012
|
const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes("."))
|
|
1733
|
-
? issueGuiSession(req, config, managementAuth
|
|
2013
|
+
? issueGuiSession(req, config, managementAuth, {
|
|
2014
|
+
trustedTailscaleIngress: ingress === "hub-management",
|
|
2015
|
+
})
|
|
1734
2016
|
: null;
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
2017
|
+
const guiFile = serveGuiFile(
|
|
2018
|
+
url.pathname,
|
|
2019
|
+
undefined,
|
|
2020
|
+
guiSessionCandidate ?? undefined,
|
|
2021
|
+
config.runtimeRole ?? "standalone",
|
|
2022
|
+
);
|
|
1741
2023
|
if (guiFile) return guiFile;
|
|
1742
2024
|
if (url.pathname === "/" && req.method === "GET") {
|
|
1743
2025
|
return jsonResponse(rootFallbackPayload());
|
|
@@ -1985,6 +2267,23 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1985
2267
|
throw error;
|
|
1986
2268
|
}
|
|
1987
2269
|
}
|
|
2270
|
+
if (managementIngressPort !== null) {
|
|
2271
|
+
try {
|
|
2272
|
+
managementIngressServer = Bun.serve<WsData>({
|
|
2273
|
+
...serveOptions,
|
|
2274
|
+
port: managementIngressPort,
|
|
2275
|
+
hostname: "127.0.0.1",
|
|
2276
|
+
});
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
// Preserve the management bind failure while synchronously initiating rollback of every
|
|
2279
|
+
// listener already opened in this startup transaction. startServer must not become async.
|
|
2280
|
+
for (const bound of [loopbackServer, server]) {
|
|
2281
|
+
if (!bound) continue;
|
|
2282
|
+
try { void bound.stop(true); } catch { /* report the original bind error */ }
|
|
2283
|
+
}
|
|
2284
|
+
throw error;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
1988
2287
|
} catch (error) {
|
|
1989
2288
|
userCostOverlayReconciler?.stop();
|
|
1990
2289
|
backgroundLifecycle?.releaseAfterFailedStart();
|
|
@@ -1995,6 +2294,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1995
2294
|
bindNativeMainStartupLifecycle(server, nativeMainLifecycle);
|
|
1996
2295
|
const nativeStop = server.stop.bind(server);
|
|
1997
2296
|
const loopbackListenerRef = loopbackServer;
|
|
2297
|
+
const managementIngressRef = managementIngressServer;
|
|
1998
2298
|
Object.defineProperty(server, "stop", {
|
|
1999
2299
|
configurable: true,
|
|
2000
2300
|
value: async (closeActiveConnections?: boolean): Promise<void> => {
|
|
@@ -2006,13 +2306,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2006
2306
|
...(loopbackListenerRef
|
|
2007
2307
|
? [() => loopbackListenerRef.stop(closeActiveConnections)]
|
|
2008
2308
|
: []),
|
|
2309
|
+
...(managementIngressRef
|
|
2310
|
+
? [() => managementIngressRef.stop(closeActiveConnections)]
|
|
2311
|
+
: []),
|
|
2009
2312
|
async () => {
|
|
2010
2313
|
userCostOverlayReconciler?.stop();
|
|
2011
2314
|
},
|
|
2012
2315
|
],
|
|
2013
2316
|
async () => {
|
|
2014
|
-
|
|
2015
|
-
|
|
2317
|
+
try {
|
|
2318
|
+
await backgroundLifecycle.release();
|
|
2319
|
+
await releaseNativeMainStartupLifecycle(server);
|
|
2320
|
+
} finally {
|
|
2321
|
+
// icacls.exe from hardenConfigDir() holds the config dir open; a caller that
|
|
2322
|
+
// removes the dir right after stop() settles would hit EPERM/EBUSY on Windows
|
|
2323
|
+
// otherwise. Runs even when an earlier release rejected — that rejection still
|
|
2324
|
+
// propagates, but not before the child is drained.
|
|
2325
|
+
await flushConfigDirHardening(startupConfigDir);
|
|
2326
|
+
}
|
|
2016
2327
|
},
|
|
2017
2328
|
);
|
|
2018
2329
|
},
|
|
@@ -2040,6 +2351,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2040
2351
|
console.warn(` remote clients. Not for shared or multi-tenant hosts.`);
|
|
2041
2352
|
}
|
|
2042
2353
|
|
|
2354
|
+
if (managementIngressServer) {
|
|
2355
|
+
const managementPort = managementIngressServer.port ?? managementIngressPort;
|
|
2356
|
+
console.log(`🔒 Hub management ingress active on http://127.0.0.1:${managementPort}`);
|
|
2357
|
+
console.log(` GUI and /api/* only; data, health, readiness, and WebSockets are disabled.`);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2043
2360
|
// Prime pool-account quota in the background so the rotation engine has real
|
|
2044
2361
|
// usage scores from the first routing decision, even when the dashboard is
|
|
2045
2362
|
// never opened (the common CLI/WSL case). Fire-and-forget: never blocks the
|
|
@@ -2077,5 +2394,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2077
2394
|
activateLab(config, labConfigDir);
|
|
2078
2395
|
}
|
|
2079
2396
|
|
|
2397
|
+
// Reset-credit auto-redemption (#822) is opt-in; a default install constructs nothing here.
|
|
2398
|
+
// Activation is synchronous (timer registration only); network work happens on the timer.
|
|
2399
|
+
if (config.resetCreditAutoRedeem?.enabled === true) {
|
|
2400
|
+
activateResetCreditAutoRedeem(config, {
|
|
2401
|
+
accountId: MAIN_CODEX_ACCOUNT_ID,
|
|
2402
|
+
...createResetCreditWhamClient(config, MAIN_CODEX_ACCOUNT_ID),
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2080
2406
|
return server;
|
|
2081
2407
|
}
|
|
@@ -1041,6 +1041,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1041
1041
|
...models.filter(m => !isDisabled(m.provider, m.id)).map(m => `${m.provider}/${m.id}`),
|
|
1042
1042
|
];
|
|
1043
1043
|
const aliases: { id: string; display_name: string }[] = [];
|
|
1044
|
+
// Resolved once, not per model: with the global fast switch on, Claude Code discovers the
|
|
1045
|
+
// fast identity, so the dashboard must list the same id rather than the umbrella one.
|
|
1046
|
+
const cursorFastIdFor = config.fastMode === true
|
|
1047
|
+
? (await import("../../adapters/cursor/catalog")).cursorFastIdFor
|
|
1048
|
+
: undefined;
|
|
1044
1049
|
for (const slug of listCatalogNativeSlugs()) {
|
|
1045
1050
|
// Readable CLI-surface alias with hash fallback (devlog 050 / audit 051 #2) —
|
|
1046
1051
|
// the same shared helper the /v1/models ?ids=cli path uses.
|
|
@@ -1048,7 +1053,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1048
1053
|
}
|
|
1049
1054
|
for (const m of models) {
|
|
1050
1055
|
if (isDisabled(m.provider, m.id)) continue;
|
|
1051
|
-
|
|
1056
|
+
const listedId = (m.provider === "cursor" ? cursorFastIdFor?.(m.id) : undefined) ?? m.id;
|
|
1057
|
+
aliases.push({ id: claudeCodeAlias(m.provider, listedId), display_name: `${listedId} (${m.provider})` });
|
|
1052
1058
|
}
|
|
1053
1059
|
const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models, nativeContextLimits(config));
|
|
1054
1060
|
const webSearchOverride = config.claudeCode?.webSearchSidecar;
|