@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.
Files changed (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -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
- releaseLiveSidebandAdmission(ws);
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
- function attachLiveSidebandUpstream(
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
- const url = ws.data.liveUpstreamUrl;
460
- if (!url) {
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
- try {
466
- // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
467
- upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {});
468
- } catch {
469
- closeLiveSideband(ws, 1011, "upstream connect failed");
470
- return;
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
- if (typeof event.data === "string") ws.send(event.data);
500
- else if (event.data instanceof ArrayBuffer) ws.send(event.data);
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(runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()))),
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") return false;
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, deps.managementApi, principal, managementSessionControl);
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 admission = resolveApiAuth(req, policy);
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 = await handleLive(req, config, logCtx, turnAdmissionLease);
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
- if (liveSidebandTarget) {
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 admission = resolveApiAuth(req, policy);
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 = await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease);
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
- turnAdmissionLease.release();
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
- turnAdmissionLease.release();
2769
+ releaseAcquisition();
2216
2770
  addFinalRequestLog(requestId, start, logCtx, resolved.status);
2217
2771
  return withCors(resolved, req, policy);
2218
2772
  }
2219
- addFinalRequestLog(requestId, start, logCtx, 101);
2220
- if (requestServer.upgrade(req, {
2221
- data: {
2222
- kind: "live-sideband",
2223
- liveUpstreamUrl: resolved.upstreamWsUrl,
2224
- liveUpstreamHeaders: resolved.headers,
2225
- livePending: [],
2226
- livePendingBytes: 0,
2227
- liveOpened: false,
2228
- liveTurnAdmissionLease: turnAdmissionLease,
2229
- } satisfies WsData,
2230
- })) return undefined as unknown as Response;
2231
- turnAdmissionLease.release();
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/* only; data, health, readiness, and WebSockets are disabled.`);
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