@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913
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/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
package/src/server/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation";
|
|
2
|
+
|
|
3
|
+
import { AuxiliaryListenerBindError } from "./ports";
|
|
1
4
|
import { markActivity } from "../lib/sidecar-tracker";
|
|
2
5
|
import { knownModelIdsForProvider } from "../router";
|
|
3
6
|
import {
|
|
@@ -8,6 +11,8 @@ import {
|
|
|
8
11
|
buildResponsesWsData,
|
|
9
12
|
sendResponseToWebSocket,
|
|
10
13
|
sendTextFrame,
|
|
14
|
+
type LiveSidebandUpstreamFailure,
|
|
15
|
+
type LiveSidebandUpstreamHandoff,
|
|
11
16
|
type WsData,
|
|
12
17
|
} from "./ws-bridge";
|
|
13
18
|
import type { Server, ServerWebSocket } from "bun";
|
|
@@ -63,6 +68,7 @@ import { activateLab, labActivationRequired } from "../lib/lab-activation";
|
|
|
63
68
|
import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup";
|
|
64
69
|
import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup";
|
|
65
70
|
import { runModelRenameStartupMigration } from "../providers/model-rename-startup";
|
|
71
|
+
import { runDevinProviderMergeStartupMigration } from "../providers/devin-provider-merge-migration";
|
|
66
72
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
67
73
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
68
74
|
import type { StorageCleanupPolicy } from "../types";
|
|
@@ -150,6 +156,7 @@ import {
|
|
|
150
156
|
} from "./relay";
|
|
151
157
|
export {
|
|
152
158
|
consumeForInspection,
|
|
159
|
+
codexSafetyBufferingFilterOptions,
|
|
153
160
|
relaySseWithFailedTail,
|
|
154
161
|
relaySseWithHeartbeat,
|
|
155
162
|
relayWithAbort,
|
|
@@ -204,6 +211,13 @@ import {
|
|
|
204
211
|
} from "../codex/native-profile-startup";
|
|
205
212
|
import { handleImages } from "./images";
|
|
206
213
|
import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
|
|
214
|
+
import { handleAudioTranscriptions } from "./audio-transcriptions";
|
|
215
|
+
import { resolveAudioAdmission, TRANSCRIPTION_MODEL } from "./audio-upstream";
|
|
216
|
+
import { resolveAudioClient } from "./audio-client";
|
|
217
|
+
import { resolveDictationSocket } from "./audio-dictation";
|
|
218
|
+
import { handleExternalLive, resolveExternalLiveSocket } from "./audio-live";
|
|
219
|
+
import { EXTERNAL_CALL_PREFIX, LiveCallBindings } from "./live-call-bindings";
|
|
220
|
+
import { clearableDeadline } from "../lib/abort";
|
|
207
221
|
import { handleSearch } from "./search";
|
|
208
222
|
import { handleContextHistory } from "./context-history";
|
|
209
223
|
import { codexCompatibleUrl, contextEndpoint, contextRelayActivated } from "../codex/context-compat";
|
|
@@ -255,6 +269,7 @@ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
|
|
|
255
269
|
// class could inject a header break or a control character into a response we control.
|
|
256
270
|
const REMOTE_CATALOG_KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
257
271
|
const GUI_PAIRING_EXCHANGE_BODY_LIMIT = 4 * 1024;
|
|
272
|
+
const REMOTE_WORKSPACE_PAIRING_BODY_LIMIT = 32 * 1024;
|
|
258
273
|
|
|
259
274
|
/**
|
|
260
275
|
* Read at most `limit` bytes of a request body, or refuse.
|
|
@@ -321,6 +336,29 @@ function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmissio
|
|
|
321
336
|
const LIVE_SIDEBAND_PENDING_MAX = 32;
|
|
322
337
|
const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024;
|
|
323
338
|
const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000;
|
|
339
|
+
/**
|
|
340
|
+
* Bound the pre-upgrade upstream handshake. A sideband join that cannot reach 101
|
|
341
|
+
* must fail the client upgrade promptly rather than hold it open indefinitely.
|
|
342
|
+
*/
|
|
343
|
+
export const LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS = 10_000;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Outcome of the upstream sideband handshake performed before the client upgrade.
|
|
347
|
+
*
|
|
348
|
+
* `ok: false` carries the HTTP status the client upgrade must fail with. Only an
|
|
349
|
+
* upgrade failure reaches codex-rs as a connect error, and only a connect error
|
|
350
|
+
* ends its sideband reconnect loop (`realtime_conversation/sideband.rs`: the `Err`
|
|
351
|
+
* arm always breaks). A 101 followed by a close is instead read as `TransportLost`
|
|
352
|
+
* and retried forever against the same, permanently dead call id.
|
|
353
|
+
*/
|
|
354
|
+
export type LiveSidebandUpstreamOpenResult =
|
|
355
|
+
| {
|
|
356
|
+
ok: true;
|
|
357
|
+
socket: WebSocket;
|
|
358
|
+
/** Owns capture and terminal events until the downstream relay attaches. */
|
|
359
|
+
handoff: LiveSidebandUpstreamHandoff;
|
|
360
|
+
}
|
|
361
|
+
| { ok: false; status: number; code: string; message: string; socket?: WebSocket };
|
|
324
362
|
|
|
325
363
|
export function exceedsLiveSidebandFrameByteLimit(frameBytes: number): boolean {
|
|
326
364
|
return frameBytes > MAX_WS_FRAME_BYTES;
|
|
@@ -355,6 +393,7 @@ export function enqueueLiveSidebandPendingFrame(
|
|
|
355
393
|
type LiveSidebandWebSocketFactory = (
|
|
356
394
|
url: string,
|
|
357
395
|
headers: Record<string, string>,
|
|
396
|
+
protocols?: string[],
|
|
358
397
|
) => WebSocket;
|
|
359
398
|
|
|
360
399
|
function releaseLiveSidebandAdmission(ws: ServerWebSocket<WsData>): void {
|
|
@@ -387,8 +426,22 @@ function finalizeLiveSideband(ws: ServerWebSocket<WsData>, upstream?: WebSocket)
|
|
|
387
426
|
ws.data.liveUpstream = undefined;
|
|
388
427
|
ws.data.livePending = undefined;
|
|
389
428
|
ws.data.livePendingBytes = undefined;
|
|
429
|
+
if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
|
|
430
|
+
if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer);
|
|
431
|
+
ws.data.liveConnectTimer = undefined;
|
|
432
|
+
ws.data.liveSessionTimer = undefined;
|
|
433
|
+
ws.data.liveUpstreamHeaders = undefined;
|
|
434
|
+
ws.data.liveUpstreamProtocols = undefined;
|
|
435
|
+
ws.data.liveValidateFrame = undefined;
|
|
436
|
+
if (ws.data.liveAbortListener) ws.data.liveAbortSignal?.removeEventListener("abort", ws.data.liveAbortListener);
|
|
437
|
+
ws.data.liveAbortSignal = undefined;
|
|
438
|
+
ws.data.liveAbortListener = undefined;
|
|
390
439
|
ws.data.cancel = undefined;
|
|
391
|
-
|
|
440
|
+
const finish = ws.data.liveFinish;
|
|
441
|
+
ws.data.liveFinish = undefined;
|
|
442
|
+
try { finish?.(ws.data.liveOutcome); }
|
|
443
|
+
catch { console.warn("[audio] upstream accounting failed during close"); }
|
|
444
|
+
finally { releaseLiveSidebandAdmission(ws); }
|
|
392
445
|
}
|
|
393
446
|
|
|
394
447
|
function armLiveSidebandCloseFallback(ws: ServerWebSocket<WsData>, upstream: WebSocket): void {
|
|
@@ -418,9 +471,55 @@ function armLiveSidebandCloseFallback(ws: ServerWebSocket<WsData>, upstream: Web
|
|
|
418
471
|
}, LIVE_SIDEBAND_CLOSE_FALLBACK_MS);
|
|
419
472
|
}
|
|
420
473
|
|
|
474
|
+
function closeLiveSidebandBeforeUpgrade(
|
|
475
|
+
upstream: WebSocket,
|
|
476
|
+
release: () => void,
|
|
477
|
+
code = 1000,
|
|
478
|
+
reason = "",
|
|
479
|
+
): void {
|
|
480
|
+
// There is no downstream socket to own this transport yet. Mirror
|
|
481
|
+
// closeLiveSideband's bounded close contract directly: release only after a
|
|
482
|
+
// close event or an observed CLOSED state, never merely after requesting close.
|
|
483
|
+
let released = false;
|
|
484
|
+
let fallback: ReturnType<typeof setTimeout> | undefined;
|
|
485
|
+
const releaseOnce = (): void => {
|
|
486
|
+
if (released) return;
|
|
487
|
+
released = true;
|
|
488
|
+
if (fallback !== undefined) clearTimeout(fallback);
|
|
489
|
+
release();
|
|
490
|
+
};
|
|
491
|
+
upstream.addEventListener("close", releaseOnce, { once: true });
|
|
492
|
+
if (upstream.readyState === WebSocket.CLOSED) {
|
|
493
|
+
releaseOnce();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
fallback = setTimeout(() => {
|
|
497
|
+
if (upstream.readyState === WebSocket.CLOSED) {
|
|
498
|
+
releaseOnce();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
upstream.close(1000, "upstream close timeout");
|
|
503
|
+
} catch {
|
|
504
|
+
/* retain ownership until CLOSED is observed */
|
|
505
|
+
}
|
|
506
|
+
if ((upstream.readyState as number) === 3) releaseOnce();
|
|
507
|
+
}, LIVE_SIDEBAND_CLOSE_FALLBACK_MS);
|
|
508
|
+
try {
|
|
509
|
+
upstream.close(code, reason);
|
|
510
|
+
} catch {
|
|
511
|
+
/* the bounded fallback retries without releasing ownership */
|
|
512
|
+
}
|
|
513
|
+
if ((upstream.readyState as number) === 3) releaseOnce();
|
|
514
|
+
}
|
|
515
|
+
|
|
421
516
|
function closeLiveSideband(ws: ServerWebSocket<WsData>, code = 1000, reason = ""): void {
|
|
422
517
|
if (ws.data.liveClosing) return;
|
|
423
518
|
ws.data.liveClosing = true;
|
|
519
|
+
if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
|
|
520
|
+
if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer);
|
|
521
|
+
ws.data.liveConnectTimer = undefined;
|
|
522
|
+
ws.data.liveSessionTimer = undefined;
|
|
424
523
|
ws.data.livePending = undefined;
|
|
425
524
|
ws.data.livePendingBytes = undefined;
|
|
426
525
|
ws.data.cancel = undefined;
|
|
@@ -450,32 +549,278 @@ function closeLiveSideband(ws: ServerWebSocket<WsData>, code = 1000, reason = ""
|
|
|
450
549
|
}
|
|
451
550
|
}
|
|
452
551
|
|
|
453
|
-
|
|
552
|
+
/**
|
|
553
|
+
* Dial the upstream sideband and report whether its handshake reached 101.
|
|
554
|
+
*
|
|
555
|
+
* Bun's client WebSocket does not surface the upstream handshake status, so the
|
|
556
|
+
* result is "opened" or "failed" and nothing finer. That is sufficient for the
|
|
557
|
+
* property this exists to guarantee: the client is never told the relay is live
|
|
558
|
+
* when it is not. Frames the upstream sends before the client socket exists are
|
|
559
|
+
* captured and handed back by `drain`, because a session preamble such as
|
|
560
|
+
* `session.created` arrives immediately after the upstream opens.
|
|
561
|
+
*/
|
|
562
|
+
export function openLiveSidebandUpstream(
|
|
563
|
+
url: string,
|
|
564
|
+
headers: Record<string, string>,
|
|
565
|
+
createWebSocket: LiveSidebandWebSocketFactory = (socketUrl, socketHeaders) => (
|
|
566
|
+
new WebSocket(socketUrl, { headers: socketHeaders } as unknown as string[])
|
|
567
|
+
),
|
|
568
|
+
timeoutMs: number = LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
|
|
569
|
+
signal?: AbortSignal,
|
|
570
|
+
): Promise<LiveSidebandUpstreamOpenResult> {
|
|
571
|
+
return new Promise(resolve => {
|
|
572
|
+
let socket: WebSocket;
|
|
573
|
+
try {
|
|
574
|
+
socket = createWebSocket(url, headers);
|
|
575
|
+
} catch {
|
|
576
|
+
resolve({ ok: false, status: 502, code: "upstream_error", message: "voice upstream connect failed" });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const buffered: Array<string | Buffer> = [];
|
|
581
|
+
let bufferedBytes = 0;
|
|
582
|
+
let capturing = true;
|
|
583
|
+
let settled = false;
|
|
584
|
+
let terminalFailure: LiveSidebandUpstreamFailure | undefined;
|
|
585
|
+
let removeAbortListener = (): void => {};
|
|
586
|
+
|
|
587
|
+
const finish = (result: LiveSidebandUpstreamOpenResult): void => {
|
|
588
|
+
if (settled) return;
|
|
589
|
+
settled = true;
|
|
590
|
+
clearTimeout(timer);
|
|
591
|
+
removeAbortListener();
|
|
592
|
+
resolve(result);
|
|
593
|
+
};
|
|
594
|
+
const timer = setTimeout(() => {
|
|
595
|
+
const failure = { status: 504, code: "upstream_timeout", message: "voice upstream did not open in time" };
|
|
596
|
+
terminalFailure = failure;
|
|
597
|
+
capturing = false;
|
|
598
|
+
buffered.length = 0;
|
|
599
|
+
bufferedBytes = 0;
|
|
600
|
+
finish({ ok: false, ...failure, socket });
|
|
601
|
+
try {
|
|
602
|
+
socket.close();
|
|
603
|
+
} catch {
|
|
604
|
+
/* ignore */
|
|
605
|
+
}
|
|
606
|
+
}, timeoutMs);
|
|
607
|
+
|
|
608
|
+
const failCapture = (failure: LiveSidebandUpstreamFailure): void => {
|
|
609
|
+
if (!capturing || terminalFailure) return;
|
|
610
|
+
terminalFailure = failure;
|
|
611
|
+
capturing = false;
|
|
612
|
+
buffered.length = 0;
|
|
613
|
+
bufferedBytes = 0;
|
|
614
|
+
finish({ ok: false, ...failure, socket });
|
|
615
|
+
try {
|
|
616
|
+
socket.close(1009, "sideband preamble overflow");
|
|
617
|
+
} catch {
|
|
618
|
+
/* the terminal failure is already retained for the downstream handoff */
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
const handoff: LiveSidebandUpstreamHandoff = {
|
|
622
|
+
failure: () => terminalFailure,
|
|
623
|
+
take: () => {
|
|
624
|
+
capturing = false;
|
|
625
|
+
if (terminalFailure) return { ok: false, failure: terminalFailure };
|
|
626
|
+
const frames = buffered.slice();
|
|
627
|
+
buffered.length = 0;
|
|
628
|
+
bufferedBytes = 0;
|
|
629
|
+
return { ok: true, frames };
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
socket.addEventListener("message", event => {
|
|
634
|
+
if (!capturing) return;
|
|
635
|
+
const frameBytes = webSocketFrameBytes(event.data);
|
|
636
|
+
if (exceedsLiveSidebandFrameByteLimit(frameBytes)) {
|
|
637
|
+
failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble frame is too large" });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (buffered.length >= LIVE_SIDEBAND_PENDING_MAX) {
|
|
641
|
+
failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream sent too many preamble frames" });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (exceedsLiveSidebandPendingByteLimit(bufferedBytes, frameBytes)) {
|
|
645
|
+
failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble is too large" });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (typeof event.data === "string") buffered.push(event.data);
|
|
649
|
+
else if (event.data instanceof ArrayBuffer) buffered.push(Buffer.from(new Uint8Array(event.data)));
|
|
650
|
+
else if (ArrayBuffer.isView(event.data)) {
|
|
651
|
+
buffered.push(Buffer.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength)));
|
|
652
|
+
} else return;
|
|
653
|
+
bufferedBytes += frameBytes;
|
|
654
|
+
});
|
|
655
|
+
socket.addEventListener("open", () => {
|
|
656
|
+
finish({
|
|
657
|
+
ok: true,
|
|
658
|
+
socket,
|
|
659
|
+
handoff,
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
socket.addEventListener("error", () => {
|
|
663
|
+
const failure = { status: 502, code: "upstream_error", message: "voice upstream rejected the sideband join" };
|
|
664
|
+
terminalFailure ??= failure;
|
|
665
|
+
capturing = false;
|
|
666
|
+
buffered.length = 0;
|
|
667
|
+
bufferedBytes = 0;
|
|
668
|
+
finish({ ok: false, ...terminalFailure, socket });
|
|
669
|
+
try {
|
|
670
|
+
socket.close();
|
|
671
|
+
} catch {
|
|
672
|
+
/* the terminal failure is already retained */
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
socket.addEventListener("close", event => {
|
|
676
|
+
const failure = {
|
|
677
|
+
status: 502,
|
|
678
|
+
code: "upstream_error",
|
|
679
|
+
message: `voice upstream closed before opening (code ${event.code})`,
|
|
680
|
+
closeCode: event.code,
|
|
681
|
+
closeReason: event.reason,
|
|
682
|
+
};
|
|
683
|
+
terminalFailure ??= failure;
|
|
684
|
+
capturing = false;
|
|
685
|
+
buffered.length = 0;
|
|
686
|
+
bufferedBytes = 0;
|
|
687
|
+
finish({ ok: false, ...terminalFailure, socket });
|
|
688
|
+
});
|
|
689
|
+
const abortOpen = (): void => {
|
|
690
|
+
const failure = { status: 499, code: "request_cancelled", message: "voice sideband join was cancelled" };
|
|
691
|
+
terminalFailure ??= failure;
|
|
692
|
+
capturing = false;
|
|
693
|
+
buffered.length = 0;
|
|
694
|
+
bufferedBytes = 0;
|
|
695
|
+
finish({ ok: false, ...terminalFailure, socket });
|
|
696
|
+
try {
|
|
697
|
+
socket.close();
|
|
698
|
+
} catch {
|
|
699
|
+
/* the cancelled join no longer owns the socket */
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
if (signal) {
|
|
703
|
+
signal.addEventListener("abort", abortOpen, { once: true });
|
|
704
|
+
removeAbortListener = () => signal.removeEventListener("abort", abortOpen);
|
|
705
|
+
if (signal.aborted) abortOpen();
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function attachLiveSidebandUpstream(
|
|
454
711
|
ws: ServerWebSocket<WsData>,
|
|
455
|
-
createWebSocket: LiveSidebandWebSocketFactory = (url, headers) => (
|
|
456
|
-
new WebSocket(url, { headers } as unknown as string[])
|
|
712
|
+
createWebSocket: LiveSidebandWebSocketFactory = (url, headers, protocols) => (
|
|
713
|
+
new WebSocket(url, { headers, protocols } as unknown as string[])
|
|
457
714
|
),
|
|
458
715
|
): void {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
closeLiveSideband(ws, 1011, "missing upstream");
|
|
716
|
+
if (ws.data.liveAbortSignal?.aborted) {
|
|
717
|
+
closeLiveSideband(ws, 1000, "audio connection canceled");
|
|
462
718
|
return;
|
|
463
719
|
}
|
|
720
|
+
const preOpened = ws.data.liveUpstream;
|
|
464
721
|
let upstream: WebSocket;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
722
|
+
if (preOpened) {
|
|
723
|
+
upstream = preOpened;
|
|
724
|
+
} else {
|
|
725
|
+
const url = ws.data.liveUpstreamUrl;
|
|
726
|
+
if (!url) {
|
|
727
|
+
closeLiveSideband(ws, 1011, "missing upstream");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
// Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
|
|
732
|
+
upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}, ws.data.liveUpstreamProtocols);
|
|
733
|
+
} catch {
|
|
734
|
+
closeLiveSideband(ws, 1011, "upstream connect failed");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
471
737
|
}
|
|
472
738
|
ws.data.liveUpstream = upstream;
|
|
739
|
+
ws.data.liveUpstreamHeaders = undefined;
|
|
740
|
+
ws.data.liveUpstreamProtocols = undefined;
|
|
473
741
|
ws.data.liveClosing = false;
|
|
474
742
|
ws.data.cancel = () => closeLiveSideband(ws, 1000, "client closed");
|
|
743
|
+
if (ws.data.liveMaxSessionMs !== undefined) {
|
|
744
|
+
ws.data.liveConnectTimer = setTimeout(() => {
|
|
745
|
+
ws.data.liveOutcome = "timeout";
|
|
746
|
+
closeLiveSideband(ws, 1011, "audio connection timed out");
|
|
747
|
+
}, 10_000);
|
|
748
|
+
ws.data.liveSessionTimer = setTimeout(() => closeLiveSideband(ws, 1000, "audio session expired"), ws.data.liveMaxSessionMs);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
upstream.addEventListener("close", (event) => {
|
|
752
|
+
if (ws.data.liveUpstream !== upstream) return;
|
|
753
|
+
if (ws.data.liveFinish && !ws.data.liveClosing && event.code !== 1000) ws.data.liveOutcome = "connect_error";
|
|
754
|
+
ws.data.liveClosing = true;
|
|
755
|
+
finalizeLiveSideband(ws, upstream);
|
|
756
|
+
try {
|
|
757
|
+
const external = ws.data.liveMaxSessionMs !== undefined;
|
|
758
|
+
const validCode = event.code === 1000 || (event.code >= 1001 && event.code <= 1014 && ![1004, 1005, 1006].includes(event.code))
|
|
759
|
+
|| (event.code >= 3000 && event.code <= 4999);
|
|
760
|
+
ws.close(external && !validCode ? 1011 : event.code || 1000, external ? "audio upstream closed" : event.reason || "");
|
|
761
|
+
} catch {
|
|
762
|
+
/* ignore */
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
upstream.addEventListener("error", () => {
|
|
766
|
+
if (ws.data.liveUpstream !== upstream) return;
|
|
767
|
+
if (ws.data.liveFinish && !ws.data.liveClosing) ws.data.liveOutcome = "connect_error";
|
|
768
|
+
closeLiveSideband(ws, 1011, "upstream error");
|
|
769
|
+
});
|
|
770
|
+
if (ws.data.liveAbortSignal) {
|
|
771
|
+
ws.data.liveAbortListener = () => closeLiveSideband(ws, 1000, "audio connection canceled");
|
|
772
|
+
ws.data.liveAbortSignal.addEventListener("abort", ws.data.liveAbortListener, { once: true });
|
|
773
|
+
if (ws.data.liveAbortSignal.aborted) closeLiveSideband(ws, 1000, "audio connection canceled");
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (preOpened) {
|
|
777
|
+
// The upstream opened before this socket existed, so its `open` event has already
|
|
778
|
+
// fired and the listener below will never run. Its early frames were captured for
|
|
779
|
+
// us; forward the capture now rather than dropping the session preamble.
|
|
780
|
+
const handoff = ws.data.liveUpstreamHandoff;
|
|
781
|
+
ws.data.liveUpstreamHandoff = undefined;
|
|
782
|
+
const takeover = handoff?.take();
|
|
783
|
+
if (!takeover?.ok || preOpened.readyState !== WebSocket.OPEN) {
|
|
784
|
+
const failure = takeover && !takeover.ok ? takeover.failure : undefined;
|
|
785
|
+
closeLiveSideband(
|
|
786
|
+
ws,
|
|
787
|
+
failure?.closeCode ?? 1011,
|
|
788
|
+
failure?.closeReason ?? "upstream closed before relay attachment",
|
|
789
|
+
);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
ws.data.liveOpened = true;
|
|
793
|
+
// The upstream opened before this socket existed, so the "open" listener
|
|
794
|
+
// below can never fire for it. Disarm the connect watchdog exactly as that
|
|
795
|
+
// listener would, or every session with a max lifetime is force-closed ten
|
|
796
|
+
// seconds after attach. The session timer stays armed: it bounds the whole
|
|
797
|
+
// session, not the connect phase.
|
|
798
|
+
if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
|
|
799
|
+
ws.data.liveConnectTimer = undefined;
|
|
800
|
+
for (const frame of takeover.frames) {
|
|
801
|
+
try {
|
|
802
|
+
// Mirror the live message listener exactly: same ceiling, same diagnostic
|
|
803
|
+
// record. These frames are upstream-to-client like any other.
|
|
804
|
+
if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(frame))) {
|
|
805
|
+
closeLiveSideband(ws, 1009, "message too large");
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
logLiveSidebandFrame("u2c", frame);
|
|
809
|
+
ws.send(frame);
|
|
810
|
+
} catch {
|
|
811
|
+
closeLiveSideband(ws, 1011, "client send failed");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
475
816
|
|
|
476
817
|
upstream.addEventListener("open", () => {
|
|
477
818
|
if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return;
|
|
478
819
|
ws.data.liveOpened = true;
|
|
820
|
+
if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
|
|
821
|
+
ws.data.liveConnectTimer = undefined;
|
|
822
|
+
// An accepted transport alone does not prove inference/quota recovery.
|
|
823
|
+
// Keep healthy closes neutral; explicit transport failures are recorded below.
|
|
479
824
|
const pending = ws.data.livePending ?? [];
|
|
480
825
|
ws.data.livePending = undefined;
|
|
481
826
|
ws.data.livePendingBytes = undefined;
|
|
@@ -496,29 +841,19 @@ function attachLiveSidebandUpstream(
|
|
|
496
841
|
return;
|
|
497
842
|
}
|
|
498
843
|
logLiveSidebandFrame("u2c", event.data);
|
|
499
|
-
|
|
500
|
-
|
|
844
|
+
let sent: number;
|
|
845
|
+
if (typeof event.data === "string") sent = ws.send(event.data);
|
|
846
|
+
else if (event.data instanceof ArrayBuffer) sent = ws.send(event.data);
|
|
501
847
|
else if (ArrayBuffer.isView(event.data)) {
|
|
502
|
-
ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength));
|
|
503
|
-
} else ws.send(event.data as Buffer);
|
|
848
|
+
sent = ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength));
|
|
849
|
+
} else sent = ws.send(event.data as Buffer);
|
|
850
|
+
if (ws.data.liveMaxSessionMs !== undefined && (sent === 0 || ws.getBufferedAmount() > MAX_WS_FRAME_BYTES)) {
|
|
851
|
+
closeLiveSideband(ws, 1013, "audio client backpressure");
|
|
852
|
+
}
|
|
504
853
|
} catch {
|
|
505
854
|
closeLiveSideband(ws, 1011, "client send failed");
|
|
506
855
|
}
|
|
507
856
|
});
|
|
508
|
-
upstream.addEventListener("close", (event) => {
|
|
509
|
-
if (ws.data.liveUpstream !== upstream) return;
|
|
510
|
-
ws.data.liveClosing = true;
|
|
511
|
-
finalizeLiveSideband(ws, upstream);
|
|
512
|
-
try {
|
|
513
|
-
ws.close(event.code || 1000, event.reason || "");
|
|
514
|
-
} catch {
|
|
515
|
-
/* ignore */
|
|
516
|
-
}
|
|
517
|
-
});
|
|
518
|
-
upstream.addEventListener("error", () => {
|
|
519
|
-
if (ws.data.liveUpstream !== upstream) return;
|
|
520
|
-
closeLiveSideband(ws, 1011, "upstream error");
|
|
521
|
-
});
|
|
522
857
|
}
|
|
523
858
|
|
|
524
859
|
// GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
|
|
@@ -659,18 +994,30 @@ export function warnAgentTaskRecoveryStartup(config: {
|
|
|
659
994
|
console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior.");
|
|
660
995
|
}
|
|
661
996
|
|
|
997
|
+
export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void {
|
|
998
|
+
if (config.plaintextV2AgentMessages !== true) return;
|
|
999
|
+
console.warn("⚠️ Experimental plaintext V2 agent messages are enabled.");
|
|
1000
|
+
console.warn(" Eligible ChatGPT collaboration calls may carry plaintext message arguments. HTTPS remains encrypted, but task text may be retained in Codex history, selected providers, and local response/debug state.");
|
|
1001
|
+
console.warn(" This depends on undocumented ChatGPT and Codex behavior; it does not decrypt existing tasks.");
|
|
1002
|
+
}
|
|
1003
|
+
|
|
662
1004
|
export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
|
|
663
1005
|
const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret();
|
|
664
1006
|
// Captured before loadConfig() starts the optional ACL flight so stop() drains the same dir
|
|
665
1007
|
// even if OPENCODEX_HOME changes underneath a long-lived process.
|
|
666
1008
|
const startupConfigDir = getConfigDir();
|
|
667
1009
|
const startupConfig = migrateStartupSubagentModels(
|
|
668
|
-
runModelRenameStartupMigration(
|
|
1010
|
+
runModelRenameStartupMigration(
|
|
1011
|
+
runDevinProviderMergeStartupMigration(
|
|
1012
|
+
runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig())),
|
|
1013
|
+
),
|
|
1014
|
+
),
|
|
669
1015
|
);
|
|
670
1016
|
// Reconcile disk-backed presets first: it replaces provider rows and must not undo
|
|
671
1017
|
// an in-memory wire upgrade when that upgrade's persistence is temporarily unavailable.
|
|
672
1018
|
reconcileOAuthProviders(startupConfig);
|
|
673
1019
|
const config = migrateStartupZaiResponses(migrateStartupXaiResponses(startupConfig));
|
|
1020
|
+
warnPlaintextV2AgentMessagesStartup(config);
|
|
674
1021
|
warnAgentTaskRecoveryStartup(config);
|
|
675
1022
|
setLiveStateStoreConfig(config);
|
|
676
1023
|
applyProxyEnv(config);
|
|
@@ -678,6 +1025,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
678
1025
|
const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
|
|
679
1026
|
const managementSessionControl = createManagementSessionControl(managementAuth);
|
|
680
1027
|
let userCostOverlayReconciler: { stop(): void } | null = null;
|
|
1028
|
+
const liveCallBindings = new LiveCallBindings();
|
|
681
1029
|
// Arm synchronously before listen. A pending journal therefore makes __main__ unusable
|
|
682
1030
|
// before any request can resolve its physical credential, while health/management/Pool stay live.
|
|
683
1031
|
reconcileLiveStateStores();
|
|
@@ -846,6 +1194,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
846
1194
|
if (path === "/v1/responses/compact") return req.method === "POST";
|
|
847
1195
|
if (path === "/v1/messages" || path === "/v1/chat/completions") return req.method === "POST";
|
|
848
1196
|
if (path === "/v1/messages/count_tokens") return req.method === "POST";
|
|
1197
|
+
if (path === "/v1/audio/transcriptions") return req.method === "POST";
|
|
1198
|
+
if (path === "/v1/audio/transcriptions/stream") return req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
849
1199
|
if (path === "/v1/alpha/search") return req.method === "POST";
|
|
850
1200
|
if (contextEndpoint(path)) return req.method === "POST";
|
|
851
1201
|
if (path === "/v1/images/generations" || path === "/v1/images/edits") {
|
|
@@ -878,7 +1228,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
878
1228
|
*/
|
|
879
1229
|
function managementIngressRouteAllowed(url: URL, req: Request): boolean {
|
|
880
1230
|
const rawPath = url.pathname;
|
|
881
|
-
if (req.headers.get("upgrade")?.toLowerCase() === "websocket")
|
|
1231
|
+
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
1232
|
+
return remoteWorkspaceEnabled(config) && rawPath === "/remote-workspace/agent";
|
|
1233
|
+
}
|
|
1234
|
+
if (rawPath === "/remote-workspace/pair") return remoteWorkspaceEnabled(config) && req.method === "POST";
|
|
882
1235
|
if (rawPath === "/opencodex-session") return req.method === "GET" || req.method === "POST";
|
|
883
1236
|
if (rawPath.startsWith("/api/")) return true;
|
|
884
1237
|
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
@@ -1074,6 +1427,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1074
1427
|
}
|
|
1075
1428
|
let backgroundLifecycle: ReturnType<typeof acquireServerBackgroundLifecycle> | null = null;
|
|
1076
1429
|
let unregisterQuotaAutoRefresh: (() => void) | null = null;
|
|
1430
|
+
let remoteWorkspaceStopping = false;
|
|
1431
|
+
let remoteWorkspaceShutdown: (() => Promise<void>) | undefined;
|
|
1432
|
+
const managementApiDeps: ManagementApiDeps = {
|
|
1433
|
+
...deps.managementApi,
|
|
1434
|
+
remoteWorkspaceStopping: () => remoteWorkspaceStopping,
|
|
1435
|
+
onRemoteWorkspaceShutdown: shutdown => { remoteWorkspaceShutdown = shutdown; },
|
|
1436
|
+
};
|
|
1437
|
+
let workspaceRuntimeFlight: Promise<typeof import("../remote-control/workspace-runtime")> | undefined;
|
|
1438
|
+
const loadRemoteWorkspaceRuntime = () => {
|
|
1439
|
+
workspaceRuntimeFlight ??= import("../remote-control/workspace-runtime");
|
|
1440
|
+
remoteWorkspaceShutdown = async () => {
|
|
1441
|
+
const runtime = await workspaceRuntimeFlight!;
|
|
1442
|
+
const sessions = deps.managementApi?.remoteWorkspaceSessions ?? runtime.initializedRemoteWorkspaceSessionsForConfig(config);
|
|
1443
|
+
const hub = deps.managementApi?.remoteWorkspaceHub ?? runtime.initializedRemoteWorkspaceHubForConfig(config);
|
|
1444
|
+
try { await sessions?.shutdown(); } finally { hub?.closeAllConnections(); }
|
|
1445
|
+
};
|
|
1446
|
+
return workspaceRuntimeFlight;
|
|
1447
|
+
};
|
|
1077
1448
|
try {
|
|
1078
1449
|
backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy);
|
|
1079
1450
|
unregisterQuotaAutoRefresh = (deps.registerCodexQuotaAutoRefreshWorker
|
|
@@ -1174,6 +1545,133 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1174
1545
|
});
|
|
1175
1546
|
}
|
|
1176
1547
|
|
|
1548
|
+
// An OCX-only executor exchanges one short-lived pairing code for a device-scoped
|
|
1549
|
+
// token. This is intentionally outside /api: management auth belongs to the browser
|
|
1550
|
+
// that created the grant, while the new device owns only that one-time code.
|
|
1551
|
+
if (url.pathname === "/remote-workspace/pair" && req.method === "POST") {
|
|
1552
|
+
if (!remoteWorkspaceEnabled(config)) {
|
|
1553
|
+
return Response.json({ error: "Remote Workspace is not enabled on this OpenCodex instance." }, { status: 404 });
|
|
1554
|
+
}
|
|
1555
|
+
// Browser JavaScript must use the authenticated dashboard route. Refusing Origin-bearing
|
|
1556
|
+
// requests leaves this exchange to an explicit OCX device process and avoids turning a
|
|
1557
|
+
// copied pairing code into a cross-site enrollment action.
|
|
1558
|
+
if (req.headers.get("origin") !== null) {
|
|
1559
|
+
return Response.json({ error: "Remote Workspace device pairing does not accept browser-origin requests." }, {
|
|
1560
|
+
status: 403,
|
|
1561
|
+
headers: { "cache-control": "no-store" },
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
const [{ remoteWorkspaceHubForConfig }, { RemoteWorkspacePairingRateLimitError }] = await Promise.all([
|
|
1565
|
+
loadRemoteWorkspaceRuntime(),
|
|
1566
|
+
import("../remote-control/workspace-hub"),
|
|
1567
|
+
]);
|
|
1568
|
+
if (remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 });
|
|
1569
|
+
const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config);
|
|
1570
|
+
// A loopback socket alone cannot prove that Tailscale Serve supplied its identity header:
|
|
1571
|
+
// another local process can connect directly and forge it. Pairing therefore uses only the
|
|
1572
|
+
// kernel-observed peer on every listener; proxied management users intentionally share the
|
|
1573
|
+
// loopback bucket rather than gaining a header-rotation bypass.
|
|
1574
|
+
const peer = requestServer.requestIP(req)?.address ?? "unknown";
|
|
1575
|
+
const pairingSource = `${ingress}:${peer}`;
|
|
1576
|
+
const rateLimitResponse = (error: unknown): Response | null => {
|
|
1577
|
+
if (!(error instanceof RemoteWorkspacePairingRateLimitError)) return null;
|
|
1578
|
+
return Response.json({ error: "Remote Workspace pairing is temporarily rate limited." }, {
|
|
1579
|
+
status: 429,
|
|
1580
|
+
headers: {
|
|
1581
|
+
"cache-control": "no-store",
|
|
1582
|
+
"retry-after": String(error.retryAfterSeconds),
|
|
1583
|
+
},
|
|
1584
|
+
});
|
|
1585
|
+
};
|
|
1586
|
+
try {
|
|
1587
|
+
// Check the existing source block before reading or parsing an attacker-controlled body.
|
|
1588
|
+
// pairDevice checks again after the await and records only code-shaped authentication
|
|
1589
|
+
// failures, so malformed JSON cannot allocate one limiter entry per request.
|
|
1590
|
+
hub.assertPairingSourceAllowed(pairingSource);
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
const limited = rateLimitResponse(error);
|
|
1593
|
+
if (limited) return limited;
|
|
1594
|
+
throw error;
|
|
1595
|
+
}
|
|
1596
|
+
const declaredLength = Number(req.headers.get("content-length") ?? "0");
|
|
1597
|
+
if (!Number.isFinite(declaredLength) || declaredLength > REMOTE_WORKSPACE_PAIRING_BODY_LIMIT) {
|
|
1598
|
+
return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 });
|
|
1599
|
+
}
|
|
1600
|
+
const text = await readBoundedRequestText(req, REMOTE_WORKSPACE_PAIRING_BODY_LIMIT);
|
|
1601
|
+
if (text === null) return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 });
|
|
1602
|
+
if (remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 });
|
|
1603
|
+
let body: unknown;
|
|
1604
|
+
try { body = JSON.parse(text); }
|
|
1605
|
+
catch { return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 }); }
|
|
1606
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
1607
|
+
return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 });
|
|
1608
|
+
}
|
|
1609
|
+
const record = body as Record<string, unknown>;
|
|
1610
|
+
const required = ["code", "name", "platform", "publicKey", "roots"];
|
|
1611
|
+
const allowed = new Set([...required, "capabilities"]);
|
|
1612
|
+
if (required.some(key => !Object.hasOwn(record, key))
|
|
1613
|
+
|| Object.keys(record).some(key => !allowed.has(key))) {
|
|
1614
|
+
return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 });
|
|
1615
|
+
}
|
|
1616
|
+
try {
|
|
1617
|
+
const paired = hub.pairDevice(record, pairingSource);
|
|
1618
|
+
return Response.json(paired, { status: 201, headers: { "cache-control": "no-store" } });
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
const limited = rateLimitResponse(error);
|
|
1621
|
+
if (limited) return limited;
|
|
1622
|
+
const message = error instanceof Error ? error.message : "Remote Workspace pairing failed.";
|
|
1623
|
+
const conflict = /already in use|limit reached/i.test(message);
|
|
1624
|
+
return Response.json({ error: message }, {
|
|
1625
|
+
status: conflict ? 409 : 401,
|
|
1626
|
+
headers: { "cache-control": "no-store" },
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Each executor holds one device-scoped bearer and opens one outbound WSS. The token is
|
|
1632
|
+
// authenticated only at upgrade and never enters ws.data; subsequent frames are bound to
|
|
1633
|
+
// the device identity and per-session signed E2EE handshake.
|
|
1634
|
+
if (url.pathname === "/remote-workspace/agent" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
1635
|
+
if (!remoteWorkspaceEnabled(config) || req.headers.get("origin") !== null) {
|
|
1636
|
+
return Response.json({ error: "Remote Workspace agent upgrade refused." }, { status: 403 });
|
|
1637
|
+
}
|
|
1638
|
+
const authorization = req.headers.get("authorization") ?? "";
|
|
1639
|
+
const match = /^Bearer (ocxrw_[A-Za-z0-9_-]{43})$/.exec(authorization);
|
|
1640
|
+
if (!match) return Response.json({ error: "Remote Workspace device authentication required." }, { status: 401 });
|
|
1641
|
+
const { remoteWorkspaceHubForConfig } = await loadRemoteWorkspaceRuntime();
|
|
1642
|
+
const { RemoteWorkspaceHubAgentConnection } = await import("../remote-control/workspace-agent-connection");
|
|
1643
|
+
if (remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 });
|
|
1644
|
+
const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config);
|
|
1645
|
+
const device = hub.authenticateDeviceToken(match[1]!);
|
|
1646
|
+
if (!device) return Response.json({ error: "Remote Workspace device authentication failed." }, { status: 401 });
|
|
1647
|
+
const upgraded = requestServer.upgrade(req, {
|
|
1648
|
+
data: {
|
|
1649
|
+
kind: "remote-workspace-agent",
|
|
1650
|
+
remoteWorkspaceOpen: socket => {
|
|
1651
|
+
const connection = new RemoteWorkspaceHubAgentConnection({
|
|
1652
|
+
deviceId: device.id,
|
|
1653
|
+
devicePublicKey: device.publicKey,
|
|
1654
|
+
hubIdentity: hub.identity(),
|
|
1655
|
+
capabilities: device.capabilities,
|
|
1656
|
+
onCapabilities: capabilities => hub.updateDeviceCapabilities(device.id, capabilities),
|
|
1657
|
+
socket: {
|
|
1658
|
+
send: value => {
|
|
1659
|
+
if (socket.send(value) === 0) throw new Error("remote workspace socket send dropped");
|
|
1660
|
+
},
|
|
1661
|
+
close: (code, reason) => socket.close(code, reason),
|
|
1662
|
+
},
|
|
1663
|
+
});
|
|
1664
|
+
hub.attachConnection(device.id, connection);
|
|
1665
|
+
socket.data.remoteWorkspaceClose = () => hub.detachConnection(device.id, connection);
|
|
1666
|
+
return connection;
|
|
1667
|
+
},
|
|
1668
|
+
} satisfies WsData,
|
|
1669
|
+
});
|
|
1670
|
+
return upgraded
|
|
1671
|
+
? undefined as unknown as Response
|
|
1672
|
+
: Response.json({ error: "Remote Workspace WebSocket upgrade failed." }, { status: 426 });
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1177
1675
|
// Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
|
|
1178
1676
|
// handshake-time only, so capture inbound headers and thread them into the pipeline.
|
|
1179
1677
|
if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
@@ -1306,7 +1804,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1306
1804
|
}), req, config);
|
|
1307
1805
|
}
|
|
1308
1806
|
}
|
|
1309
|
-
const mgmtResponse = await handleManagementAPI(req, url, config,
|
|
1807
|
+
const mgmtResponse = await handleManagementAPI(req, url, config, managementApiDeps, principal, managementSessionControl);
|
|
1310
1808
|
if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
|
|
1311
1809
|
return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
|
|
1312
1810
|
}
|
|
@@ -1393,6 +1891,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1393
1891
|
);
|
|
1394
1892
|
}
|
|
1395
1893
|
|
|
1894
|
+
if (url.pathname === "/v1/usage" && req.method === "GET") {
|
|
1895
|
+
const { handleHubUsage } = await import("./hub-usage");
|
|
1896
|
+
return handleHubUsage(req, config, policy);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1396
1899
|
if (url.pathname === "/v1/hub-state" && (req.method === "GET" || req.method === "HEAD")) {
|
|
1397
1900
|
// #4236: a connected client had no way to learn which providers this hub can actually
|
|
1398
1901
|
// serve, so `ocx status` on the client reported the CLIENT's empty credential store as
|
|
@@ -2142,6 +2645,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2142
2645
|
));
|
|
2143
2646
|
}
|
|
2144
2647
|
|
|
2648
|
+
if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") {
|
|
2649
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
2650
|
+
if (isDraining()) return drainingResponse(req, policy);
|
|
2651
|
+
const admission = resolveAudioAdmission(req.headers, config);
|
|
2652
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
2653
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
2654
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin audio request blocked"), req, policy);
|
|
2655
|
+
}
|
|
2656
|
+
const start = Date.now();
|
|
2657
|
+
const requestId = nextRequestLogId(start);
|
|
2658
|
+
const logCtx: RequestLogContext = { model: TRANSCRIPTION_MODEL, provider: "unknown", ...admissionFields(admission) };
|
|
2659
|
+
return runAdmittedHttpTurn(req, policy, async lease => {
|
|
2660
|
+
const response = await handleAudioTranscriptions(req, config, logCtx, admission, lease);
|
|
2661
|
+
addFinalRequestLog(requestId, start, logCtx, response.status);
|
|
2662
|
+
return withCors(response, req, policy);
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2145
2666
|
// ChatGPT / Codex App voice (GPT‑Live / Frameless Bidi) + OpenAI Realtime call-create.
|
|
2146
2667
|
// Clients hit either /v1/live (Frameless App) or /v1/realtime/calls (codex RealtimeCallClient /
|
|
2147
2668
|
// public Realtime API). Sideband WS joins are handled just below.
|
|
@@ -2153,7 +2674,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2153
2674
|
if (isDraining()) {
|
|
2154
2675
|
return drainingResponse(req, policy);
|
|
2155
2676
|
}
|
|
2156
|
-
const
|
|
2677
|
+
const audioClient = resolveAudioClient(req, config);
|
|
2678
|
+
if (audioClient instanceof Response) return withCors(audioClient, req, policy);
|
|
2679
|
+
const admission = audioClient?.admission ?? resolveApiAuth(req, policy);
|
|
2157
2680
|
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
2158
2681
|
if (!isAllowedRequestOrigin(req, policy)) {
|
|
2159
2682
|
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
@@ -2166,7 +2689,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2166
2689
|
...admissionFields(admission),
|
|
2167
2690
|
};
|
|
2168
2691
|
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
2169
|
-
const response =
|
|
2692
|
+
const response = audioClient
|
|
2693
|
+
? await handleExternalLive(req, config, logCtx, { client: audioClient, lease: turnAdmissionLease, bindings: liveCallBindings })
|
|
2694
|
+
: await handleLive(req, config, logCtx, turnAdmissionLease);
|
|
2170
2695
|
addFinalRequestLog(
|
|
2171
2696
|
requestId,
|
|
2172
2697
|
start,
|
|
@@ -2186,11 +2711,19 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2186
2711
|
const liveSidebandTarget = req.headers.get("upgrade")?.toLowerCase() === "websocket"
|
|
2187
2712
|
? parseLiveSidebandTarget(url.pathname, url.searchParams, url.search.replace(/^\?/, ""))
|
|
2188
2713
|
: null;
|
|
2189
|
-
|
|
2714
|
+
const dictationSocket = url.pathname === "/v1/audio/transcriptions/stream"
|
|
2715
|
+
&& req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
2716
|
+
if (liveSidebandTarget || dictationSocket) {
|
|
2190
2717
|
if (isDraining()) {
|
|
2191
2718
|
return drainingResponse(req, policy);
|
|
2192
2719
|
}
|
|
2193
|
-
const
|
|
2720
|
+
const audioClient = resolveAudioClient(req, config, dictationSocket);
|
|
2721
|
+
if (audioClient instanceof Response) return withCors(audioClient, req, policy);
|
|
2722
|
+
if (!audioClient && liveSidebandTarget && "callId" in liveSidebandTarget
|
|
2723
|
+
&& liveSidebandTarget.callId.startsWith(EXTERNAL_CALL_PREFIX)) {
|
|
2724
|
+
return withCors(formatErrorResponse(401, "authentication_error", "Live call requires its creator API key"), req, policy);
|
|
2725
|
+
}
|
|
2726
|
+
const admission = audioClient?.admission ?? resolveApiAuth(req, policy);
|
|
2194
2727
|
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
2195
2728
|
if (!isAllowedRequestOrigin(req, policy)) {
|
|
2196
2729
|
return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy);
|
|
@@ -2204,31 +2737,128 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2204
2737
|
};
|
|
2205
2738
|
const turnAdmissionLease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
|
|
2206
2739
|
if (!turnAdmissionLease) return serverBusyResponse(req, "active turns", policy);
|
|
2740
|
+
const audioController = audioClient ? new AbortController() : undefined;
|
|
2741
|
+
if (audioController) registerTurn(audioController, turnAdmissionLease);
|
|
2742
|
+
const acquisition = audioController
|
|
2743
|
+
? clearableDeadline(120_000, AbortSignal.any([req.signal, audioController.signal])) : undefined;
|
|
2744
|
+
const releaseAcquisition = () => {
|
|
2745
|
+
acquisition?.clear();
|
|
2746
|
+
if (audioController) unregisterTurn(audioController);
|
|
2747
|
+
else turnAdmissionLease.release();
|
|
2748
|
+
};
|
|
2207
2749
|
let resolved;
|
|
2208
2750
|
try {
|
|
2209
|
-
resolved =
|
|
2751
|
+
resolved = dictationSocket && audioClient
|
|
2752
|
+
? await resolveDictationSocket(audioClient, config, logCtx, turnAdmissionLease, acquisition?.signal)
|
|
2753
|
+
: liveSidebandTarget && audioClient
|
|
2754
|
+
? await resolveExternalLiveSocket(audioClient, config, logCtx, liveSidebandTarget, { lease: turnAdmissionLease, bindings: liveCallBindings, signal: acquisition?.signal })
|
|
2755
|
+
: liveSidebandTarget
|
|
2756
|
+
? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease)
|
|
2757
|
+
: formatErrorResponse(401, "authentication_error", "opencodex API key required");
|
|
2210
2758
|
} catch (error) {
|
|
2211
|
-
|
|
2759
|
+
releaseAcquisition();
|
|
2212
2760
|
throw error;
|
|
2213
2761
|
}
|
|
2762
|
+
if (acquisition?.signal.aborted) {
|
|
2763
|
+
try { if (!(resolved instanceof Response) && "finish" in resolved) resolved.finish(); }
|
|
2764
|
+
finally { releaseAcquisition(); }
|
|
2765
|
+
return withCors(formatErrorResponse(req.signal.aborted ? 499 : acquisition.didExpire() ? 504 : 503,
|
|
2766
|
+
"upstream_error", acquisition.didExpire() ? "Audio connection timed out" : "Audio connection canceled"), req, policy);
|
|
2767
|
+
}
|
|
2214
2768
|
if (resolved instanceof Response) {
|
|
2215
|
-
|
|
2769
|
+
releaseAcquisition();
|
|
2216
2770
|
addFinalRequestLog(requestId, start, logCtx, resolved.status);
|
|
2217
2771
|
return withCors(resolved, req, policy);
|
|
2218
2772
|
}
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2773
|
+
const audio = "finish" in resolved ? resolved : undefined;
|
|
2774
|
+
const finish = audio ? (outcome?: number | "timeout" | "connect_error") => {
|
|
2775
|
+
try { audio.finish(outcome); }
|
|
2776
|
+
finally { releaseAcquisition(); }
|
|
2777
|
+
} : undefined;
|
|
2778
|
+
const discardUpgrade = () => {
|
|
2779
|
+
if (finish) finish();
|
|
2780
|
+
else releaseAcquisition();
|
|
2781
|
+
};
|
|
2782
|
+
if (req.signal.aborted) {
|
|
2783
|
+
discardUpgrade();
|
|
2784
|
+
return withCors(formatErrorResponse(499, "client_closed_request", "Audio connection canceled"), req, policy);
|
|
2785
|
+
}
|
|
2786
|
+
const upstreamHandshake = await openLiveSidebandUpstream(
|
|
2787
|
+
resolved.upstreamWsUrl,
|
|
2788
|
+
resolved.headers,
|
|
2789
|
+
(url, headers) => (deps.liveSidebandWebSocketFactory ?? ((socketUrl, socketHeaders, protocols) => (
|
|
2790
|
+
new WebSocket(socketUrl, { headers: socketHeaders, protocols } as unknown as string[])
|
|
2791
|
+
)))(url, headers, audio?.protocols),
|
|
2792
|
+
LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
|
|
2793
|
+
req.signal,
|
|
2794
|
+
);
|
|
2795
|
+
if (!upstreamHandshake.ok) {
|
|
2796
|
+
if (upstreamHandshake.socket) {
|
|
2797
|
+
closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
|
|
2798
|
+
} else {
|
|
2799
|
+
discardUpgrade();
|
|
2800
|
+
}
|
|
2801
|
+
addFinalRequestLog(requestId, start, logCtx, upstreamHandshake.status);
|
|
2802
|
+
console.error("[live] sideband upstream handshake failed: " + upstreamHandshake.message);
|
|
2803
|
+
return withCors(
|
|
2804
|
+
formatErrorResponse(upstreamHandshake.status, upstreamHandshake.code, upstreamHandshake.message),
|
|
2805
|
+
req,
|
|
2806
|
+
policy,
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2809
|
+
const handoffFailure = upstreamHandshake.handoff.failure();
|
|
2810
|
+
if (handoffFailure || upstreamHandshake.socket.readyState !== WebSocket.OPEN) {
|
|
2811
|
+
closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
|
|
2812
|
+
const failure = handoffFailure ?? {
|
|
2813
|
+
status: 502,
|
|
2814
|
+
code: "upstream_error",
|
|
2815
|
+
message: "voice upstream closed before client upgrade",
|
|
2816
|
+
};
|
|
2817
|
+
addFinalRequestLog(requestId, start, logCtx, failure.status);
|
|
2818
|
+
return withCors(formatErrorResponse(failure.status, failure.code, failure.message), req, policy);
|
|
2819
|
+
}
|
|
2820
|
+
let upgraded = false;
|
|
2821
|
+
try {
|
|
2822
|
+
upgraded = requestServer.upgrade(req, {
|
|
2823
|
+
...(audioClient?.protocol ? { headers: { "sec-websocket-protocol": audioClient.protocol } } : {}),
|
|
2824
|
+
data: {
|
|
2825
|
+
kind: "live-sideband",
|
|
2826
|
+
liveUpstream: upstreamHandshake.socket,
|
|
2827
|
+
liveUpstreamUrl: resolved.upstreamWsUrl,
|
|
2828
|
+
liveUpstreamHeaders: resolved.headers,
|
|
2829
|
+
liveUpstreamHandoff: upstreamHandshake.handoff,
|
|
2830
|
+
admission,
|
|
2831
|
+
liveUpstreamProtocols: audio?.protocols,
|
|
2832
|
+
liveValidateFrame: audio?.validateFrame,
|
|
2833
|
+
liveMaxSessionMs: audio?.maxSessionMs,
|
|
2834
|
+
liveFinish: finish,
|
|
2835
|
+
liveAbortSignal: audioController?.signal,
|
|
2836
|
+
livePending: [],
|
|
2837
|
+
livePendingBytes: 0,
|
|
2838
|
+
liveOpened: true,
|
|
2839
|
+
liveTurnAdmissionLease: turnAdmissionLease,
|
|
2840
|
+
} satisfies WsData,
|
|
2841
|
+
});
|
|
2842
|
+
} catch {
|
|
2843
|
+
try {
|
|
2844
|
+
upstreamHandshake.handoff.take();
|
|
2845
|
+
} catch {
|
|
2846
|
+
/* ignore */
|
|
2847
|
+
}
|
|
2848
|
+
closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
|
|
2849
|
+
return withCors(formatErrorResponse(502, "upstream_error", "Audio WebSocket upgrade failed"), req, policy);
|
|
2850
|
+
}
|
|
2851
|
+
if (upgraded) {
|
|
2852
|
+
acquisition?.clear();
|
|
2853
|
+
addFinalRequestLog(requestId, start, logCtx, 101);
|
|
2854
|
+
return undefined as unknown as Response;
|
|
2855
|
+
}
|
|
2856
|
+
try {
|
|
2857
|
+
upstreamHandshake.handoff.take();
|
|
2858
|
+
} catch {
|
|
2859
|
+
/* ignore */
|
|
2860
|
+
}
|
|
2861
|
+
closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
|
|
2232
2862
|
return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
|
|
2233
2863
|
}
|
|
2234
2864
|
|
|
@@ -2327,6 +2957,19 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2327
2957
|
// Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
|
|
2328
2958
|
// Live sideband sockets (kind=live-sideband) are a transparent bidirectional relay instead.
|
|
2329
2959
|
open(ws: ServerWebSocket<WsData>) {
|
|
2960
|
+
if (ws.data.kind === "remote-workspace-agent") {
|
|
2961
|
+
const open = ws.data.remoteWorkspaceOpen;
|
|
2962
|
+
if (!open) {
|
|
2963
|
+
ws.close(1011, "remote workspace connection unavailable");
|
|
2964
|
+
return;
|
|
2965
|
+
}
|
|
2966
|
+
try {
|
|
2967
|
+
ws.data.remoteWorkspaceConnection = open(ws);
|
|
2968
|
+
} catch {
|
|
2969
|
+
ws.close(1011, "remote workspace connection failed");
|
|
2970
|
+
}
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2330
2973
|
if (ws.data.kind === "live-sideband") {
|
|
2331
2974
|
if (!ws.data.liveTurnAdmissionLease) {
|
|
2332
2975
|
closeLiveSideband(ws, 1013, "server busy");
|
|
@@ -2343,8 +2986,20 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2343
2986
|
registerCodexWebSocket(ws);
|
|
2344
2987
|
},
|
|
2345
2988
|
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
2989
|
+
if (ws.data.kind === "remote-workspace-agent") {
|
|
2990
|
+
try {
|
|
2991
|
+
ws.data.remoteWorkspaceConnection?.receive(raw);
|
|
2992
|
+
} catch {
|
|
2993
|
+
ws.close(1008, "remote workspace protocol error");
|
|
2994
|
+
}
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2346
2997
|
if (ws.data.kind === "live-sideband") {
|
|
2347
2998
|
if (ws.data.liveClosing) return;
|
|
2999
|
+
if (ws.data.liveValidateFrame && !ws.data.liveValidateFrame(raw)) {
|
|
3000
|
+
closeLiveSideband(ws, 1008, "invalid audio event");
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
2348
3003
|
const rawBytes = webSocketFrameBytes(raw);
|
|
2349
3004
|
if (exceedsLiveSidebandFrameByteLimit(rawBytes)) {
|
|
2350
3005
|
closeLiveSideband(ws, 1009, "message too large");
|
|
@@ -2370,6 +3025,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2370
3025
|
}
|
|
2371
3026
|
try {
|
|
2372
3027
|
sendUpstreamFrame(upstream, raw);
|
|
3028
|
+
if (ws.data.liveMaxSessionMs !== undefined && upstream.bufferedAmount > MAX_WS_FRAME_BYTES) {
|
|
3029
|
+
closeLiveSideband(ws, 1013, "audio upstream backpressure");
|
|
3030
|
+
}
|
|
2373
3031
|
} catch {
|
|
2374
3032
|
closeLiveSideband(ws, 1011, "upstream send failed");
|
|
2375
3033
|
}
|
|
@@ -2521,6 +3179,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2521
3179
|
})();
|
|
2522
3180
|
},
|
|
2523
3181
|
close(ws: ServerWebSocket<WsData>) {
|
|
3182
|
+
if (ws.data.kind === "remote-workspace-agent") {
|
|
3183
|
+
ws.data.remoteWorkspaceClose?.();
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
2524
3186
|
if (ws.data.kind === "live-sideband") {
|
|
2525
3187
|
closeLiveSideband(ws);
|
|
2526
3188
|
return;
|
|
@@ -2556,7 +3218,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2556
3218
|
} catch {
|
|
2557
3219
|
/* the original bind error is the one worth reporting */
|
|
2558
3220
|
}
|
|
2559
|
-
throw error;
|
|
3221
|
+
throw new AuxiliaryListenerBindError("unauthenticatedLoopbackListener", loopbackListenerPort, "127.0.0.1", error);
|
|
2560
3222
|
}
|
|
2561
3223
|
}
|
|
2562
3224
|
if (managementIngressPort !== null) {
|
|
@@ -2573,7 +3235,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2573
3235
|
if (!bound) continue;
|
|
2574
3236
|
try { void bound.stop(true); } catch { /* report the original bind error */ }
|
|
2575
3237
|
}
|
|
2576
|
-
throw error;
|
|
3238
|
+
throw new AuxiliaryListenerBindError("hub.managementIngress", managementIngressPort, "127.0.0.1", error);
|
|
2577
3239
|
}
|
|
2578
3240
|
}
|
|
2579
3241
|
} catch (error) {
|
|
@@ -2591,6 +3253,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2591
3253
|
Object.defineProperty(server, "stop", {
|
|
2592
3254
|
configurable: true,
|
|
2593
3255
|
value: async (closeActiveConnections?: boolean): Promise<void> => {
|
|
3256
|
+
remoteWorkspaceStopping = true;
|
|
3257
|
+
liveCallBindings.clear();
|
|
2594
3258
|
// The orchestration lives in `runListenerShutdown` so its two competing properties —
|
|
2595
3259
|
// cleanup completes, failure propagates — are testable without a live socket.
|
|
2596
3260
|
await runListenerShutdown(
|
|
@@ -2602,6 +3266,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2602
3266
|
...(managementIngressRef
|
|
2603
3267
|
? [() => managementIngressRef.stop(closeActiveConnections)]
|
|
2604
3268
|
: []),
|
|
3269
|
+
async () => { await remoteWorkspaceShutdown?.(); },
|
|
2605
3270
|
async () => {
|
|
2606
3271
|
try {
|
|
2607
3272
|
userCostOverlayReconciler?.stop();
|
|
@@ -2658,7 +3323,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
2658
3323
|
if (managementIngressServer) {
|
|
2659
3324
|
const managementPort = managementIngressServer.port ?? managementIngressPort;
|
|
2660
3325
|
console.log(`🔒 Hub management ingress active on http://127.0.0.1:${managementPort}`);
|
|
2661
|
-
console.log(` GUI and /api
|
|
3326
|
+
console.log(` GUI and /api/*; opted-in Remote Workspace pairing/agent only; data, health, and readiness are disabled.`);
|
|
2662
3327
|
}
|
|
2663
3328
|
|
|
2664
3329
|
// Prime pool-account quota in the background so the rotation engine has real
|