@bitkyc08/opencodex 2.52.0 → 2.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-BlO4Yl6q.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,4 @@
1
+ import { modelCapabilitiesConfigError, mergeModelCapabilities } from "../../config/provider-validation";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { isDeepStrictEqual } from "node:util";
@@ -15,6 +16,7 @@ import {
15
16
  mutatePersistedConfig,
16
17
  nonBlankStringArrayConfigError,
17
18
  normalizeNonBlankStringArray,
19
+ normalizeAutoReviewModelOverrides,
18
20
  providerBaseUrlConfigError,
19
21
  providerHeadersConfigError,
20
22
  requestPacingConfigError,
@@ -34,7 +36,7 @@ import {
34
36
  upsertOAuthProvider,
35
37
  } from "../../oauth";
36
38
  import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
37
- import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError } from "../../config/provider-validation";
39
+ import { canonicalAutoReviewModelKey, mergeModelPinnedEfforts, modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError } from "../../config/provider-validation";
38
40
  import { replaceProviderAccountSet } from "../../oauth/store";
39
41
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
40
42
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
@@ -257,7 +259,10 @@ function providerEditorCandidate(
257
259
  if (namespaceCollision) return { ok: false, status: 409, error: namespaceCollision, code: "provider_namespace_conflict" };
258
260
  const merged = mergeProviderEditorRow(persisted.providers[name], baseline.providers[name], publicProvider);
259
261
  const transportCandidate = providerTransportValidationCandidate(merged as unknown as Record<string, unknown>);
260
- const providerError = providerManagementConfigError(name, transportCandidate)
262
+ // The editor merges onto the persisted row, so stored operator overlays (selectedModels,
263
+ // disabled, …) ride along in the candidate. They are owned by their own write boundaries;
264
+ // the seed check must only pin the canonical transport/auth keys.
265
+ const providerError = providerManagementConfigError(name, transportCandidate, { allowOperatorOverlays: true })
261
266
  ?? providerEmptyToolOutputConfigError(name, transportCandidate)
262
267
  ?? providerServiceTierConfigError(name, transportCandidate);
263
268
  if (providerError) return { ok: false, status: 400, error: providerError, code: "invalid_provider" };
@@ -287,6 +292,13 @@ function providerEditorCandidate(
287
292
  if (provider.modelPinnedReasoningEfforts !== undefined) {
288
293
  provider.modelPinnedReasoningEfforts = validated.config.providers[name]!.modelPinnedReasoningEfforts;
289
294
  }
295
+ const normalized = validated.config.providers[name]!;
296
+ if (normalized.autoReviewModel === undefined) delete provider.autoReviewModel;
297
+ else provider.autoReviewModel = normalized.autoReviewModel;
298
+ if (normalized.autoReviewModelOverrides === undefined) delete provider.autoReviewModelOverrides;
299
+ else provider.autoReviewModelOverrides = normalized.autoReviewModelOverrides;
300
+ if (normalized.modelCapabilities === undefined) delete provider.modelCapabilities;
301
+ else provider.modelCapabilities = normalized.modelCapabilities;
290
302
  }
291
303
  return { ok: true, config: candidate, removedProviders };
292
304
  }
@@ -490,6 +502,14 @@ function applyProviderPatchFields(
490
502
  }
491
503
  touched = true;
492
504
  }
505
+ if (Object.hasOwn(rawBody, "modelCapabilities")) {
506
+ const error = modelCapabilitiesConfigError(rawBody.modelCapabilities, true);
507
+ if (error) return { error };
508
+ const capabilities = mergeModelCapabilities(next.modelCapabilities, rawBody.modelCapabilities);
509
+ if (capabilities === undefined) delete next.modelCapabilities;
510
+ else next.modelCapabilities = capabilities;
511
+ touched = true;
512
+ }
493
513
  if (Object.hasOwn(rawBody, "modelContextWindows")) {
494
514
  const value = rawBody.modelContextWindows;
495
515
  if (value === null) {
@@ -518,6 +538,61 @@ function applyProviderPatchFields(
518
538
  if (error) return { error };
519
539
  touched = true;
520
540
  }
541
+ if (Object.hasOwn(rawBody, "autoReviewModel")) {
542
+ const value = rawBody.autoReviewModel;
543
+ if (value === null || value === "") {
544
+ delete next.autoReviewModel;
545
+ } else if (typeof value === "string" && value.trim()) {
546
+ next.autoReviewModel = value.trim();
547
+ } else {
548
+ return { error: "autoReviewModel must be a catalog selector string or null" };
549
+ }
550
+ touched = true;
551
+ }
552
+ if (Object.hasOwn(rawBody, "autoReviewModelOverrides")) {
553
+ const value = rawBody.autoReviewModelOverrides;
554
+ if (value === null) {
555
+ delete next.autoReviewModelOverrides;
556
+ } else if (isPlainRecord(value)) {
557
+ const merged: Record<string, string> = { ...(next.autoReviewModelOverrides ?? {}) };
558
+ const existingByCanonical = new Map<string, string>();
559
+ for (const existingKey of Object.keys(merged)) {
560
+ existingByCanonical.set(canonicalAutoReviewModelKey(existingKey), existingKey);
561
+ }
562
+ const submittedCanonicalKeys = new Set<string>();
563
+ for (const [model, target] of Object.entries(value)) {
564
+ const key = model.trim();
565
+ if (["__proto__", "prototype", "constructor"].includes(key)) {
566
+ return { error: "autoReviewModelOverrides keys must be non-reserved model ids" };
567
+ }
568
+ const canonicalKey = canonicalAutoReviewModelKey(model);
569
+ // Uniqueness is enforced before the tombstone branch: a clear and a set that normalize to
570
+ // the same key would otherwise resolve in object order instead of being rejected.
571
+ if (submittedCanonicalKeys.has(canonicalKey)) {
572
+ return { error: "autoReviewModelOverrides keys must be unique after trimming and slash normalization" };
573
+ }
574
+ submittedCanonicalKeys.add(canonicalKey);
575
+ if (target === null || target === "") {
576
+ const previousKey = existingByCanonical.get(canonicalKey);
577
+ if (previousKey !== undefined) delete merged[previousKey];
578
+ if (Object.hasOwn(merged, key)) delete merged[key];
579
+ continue;
580
+ }
581
+ if (typeof target !== "string" || !target.trim()) {
582
+ return { error: "autoReviewModelOverrides values must be catalog selectors, null, or empty to remove" };
583
+ }
584
+ const previousKey = existingByCanonical.get(canonicalKey);
585
+ if (previousKey !== undefined && previousKey !== key) delete merged[previousKey];
586
+ merged[key] = target.trim();
587
+ existingByCanonical.set(canonicalKey, key);
588
+ }
589
+ if (Object.keys(merged).length > 0) next.autoReviewModelOverrides = merged;
590
+ else delete next.autoReviewModelOverrides;
591
+ } else {
592
+ return { error: "autoReviewModelOverrides must be a plain object or null" };
593
+ }
594
+ touched = true;
595
+ }
521
596
  if (Object.hasOwn(rawBody, "modelAutoCompactTokenLimits")) {
522
597
  const value = rawBody.modelAutoCompactTokenLimits;
523
598
  const error = modelAutoCompactTokenLimitsConfigError(value, {
@@ -784,6 +859,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
784
859
  models: p.models ?? [],
785
860
  contextWindow: p.contextWindow,
786
861
  modelContextWindows: p.modelContextWindows,
862
+ modelCapabilities: p.modelCapabilities,
787
863
  pinnedReasoningEffort: p.pinnedReasoningEffort,
788
864
  modelPinnedReasoningEfforts: p.modelPinnedReasoningEfforts,
789
865
  modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
@@ -829,6 +905,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
829
905
  const providerError = providerManagementConfigError(
830
906
  name,
831
907
  providerTransportValidationCandidate(provider as unknown as Record<string, unknown>),
908
+ // Reload validates a row straight off disk, which legitimately carries stored
909
+ // operator overlays; only the canonical transport/auth keys need to match the seed.
910
+ { allowOperatorOverlays: true },
832
911
  )
833
912
  ?? providerEmptyToolOutputConfigError(name, provider);
834
913
  if (providerError) return jsonResponse({ error: "provider reload target invalid" }, 409);
@@ -996,6 +1075,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
996
1075
  try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
997
1076
  const name = typeof body.name === "string" ? body.name.trim() : "";
998
1077
  if (!isPlainRecord(body.provider)) return jsonResponse({ error: "provider must be a plain object" }, 400);
1078
+ // Same prohibition as PATCH: the canonical OpenAI row never carries these fields, and a clear
1079
+ // form would otherwise be normalized away before the merged-row guard could see it.
1080
+ if (name === "openai" && (Object.hasOwn(body.provider, "autoReviewModel") || Object.hasOwn(body.provider, "autoReviewModelOverrides"))) {
1081
+ return jsonResponse({ error: "provider openai must not include autoReviewModel or autoReviewModelOverrides" }, 400);
1082
+ }
999
1083
  const existing = config.providers[name];
1000
1084
  const aliasOwnershipError = providerAliasOverlayOwnershipError(body.provider, existing);
1001
1085
  if (aliasOwnershipError) return jsonResponse({ error: aliasOwnershipError }, 400);
@@ -1069,6 +1153,29 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1069
1153
  // erase hand-edited per-model prices from Logs/Usage estimates.
1070
1154
  const existingCosts = config.providers[name]?.modelCosts;
1071
1155
  if (existingCosts && !prov.modelCosts) prov.modelCosts = existingCosts;
1156
+ // The add/edit form also omits auto-review selectors. Preserve hand-configured
1157
+ // provider-wide and per-model targets across an unrelated overwrite; clearing is
1158
+ // explicit through PATCH with null.
1159
+ const submittedAutoReviewModel = Object.hasOwn(body.provider, "autoReviewModel");
1160
+ const submittedAutoReviewOverrides = Object.hasOwn(body.provider, "autoReviewModelOverrides");
1161
+ const existingAutoReviewModel = config.providers[name]?.autoReviewModel;
1162
+ if (!submittedAutoReviewModel && existingAutoReviewModel && !prov.autoReviewModel) prov.autoReviewModel = existingAutoReviewModel;
1163
+ const existingAutoReviewOverrides = config.providers[name]?.autoReviewModelOverrides;
1164
+ if (!submittedAutoReviewOverrides && existingAutoReviewOverrides && !prov.autoReviewModelOverrides) {
1165
+ prov.autoReviewModelOverrides = { ...existingAutoReviewOverrides };
1166
+ }
1167
+ if (prov.autoReviewModel !== undefined) {
1168
+ if (typeof prov.autoReviewModel === "string" && prov.autoReviewModel.trim()) {
1169
+ prov.autoReviewModel = prov.autoReviewModel.trim();
1170
+ } else {
1171
+ delete prov.autoReviewModel;
1172
+ }
1173
+ }
1174
+ if (prov.autoReviewModelOverrides !== undefined) {
1175
+ const normalizedOverrides = normalizeAutoReviewModelOverrides(prov.autoReviewModelOverrides);
1176
+ if (normalizedOverrides) prov.autoReviewModelOverrides = normalizedOverrides;
1177
+ else delete prov.autoReviewModelOverrides;
1178
+ }
1072
1179
  // And to the per-provider account-failover opt-out (#2568d). `ProviderPayload` has no
1073
1180
  // member for it either, so an add/edit save structurally cannot carry it — and dropping it
1074
1181
  // silently ENABLES rotation, because activation is presence-driven once the knob is gone.
@@ -1117,6 +1224,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1117
1224
  // completed during that wait remains authoritative instead of being overwritten by the
1118
1225
  // older ownership snapshot used to admit this POST.
1119
1226
  restorePersistedAliasOverlays(prov, config.providers[name]);
1227
+ const capabilities = Object.hasOwn(body.provider, "modelCapabilities")
1228
+ ? mergeModelCapabilities(undefined, prov.modelCapabilities)
1229
+ : mergeModelCapabilities(config.providers[name]?.modelCapabilities, undefined);
1230
+ if (capabilities === undefined) delete prov.modelCapabilities;
1231
+ else prov.modelCapabilities = capabilities;
1120
1232
  // The add/edit form omits wire choices. Read after DNS so a concurrent switch
1121
1233
  // remains authoritative, including the marker that protects it on the next boot.
1122
1234
  if (name === "xai") {
@@ -1260,6 +1372,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1260
1372
  if (Object.hasOwn(rawBody, "apiKey")) {
1261
1373
  return jsonResponse({ error: "apiKey cannot be patched here; use the provider API-key endpoints" }, 400);
1262
1374
  }
1375
+ // The canonical OpenAI row rejects these fields outright on create. A PATCH that only clears
1376
+ // or restates them would otherwise slip past the merged-row guard and answer 200, which reads
1377
+ // as acceptance for a field the provider does not support.
1378
+ if (name === "openai" && (Object.hasOwn(rawBody, "autoReviewModel") || Object.hasOwn(rawBody, "autoReviewModelOverrides"))) {
1379
+ return jsonResponse({ error: "provider openai must not include autoReviewModel or autoReviewModelOverrides" }, 400);
1380
+ }
1263
1381
  const applied = applyProviderPatchFields(name, config.providers[name]!, rawBody, keys, config);
1264
1382
  if ("error" in applied) return jsonResponse({ error: applied.error }, 400);
1265
1383
  const next = applied.next;
@@ -1271,6 +1389,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1271
1389
  : providerManagementConfigError(
1272
1390
  name,
1273
1391
  providerTransportValidationCandidate(next as unknown as Record<string, unknown>),
1392
+ // PATCH merges the mask onto the persisted row, which legitimately carries
1393
+ // stored operator overlays (selectedModels, disabled, …); only the canonical
1394
+ // transport/auth keys need to match the seed.
1395
+ { allowOperatorOverlays: true },
1274
1396
  )
1275
1397
  ?? providerEmptyToolOutputConfigError(name, next);
1276
1398
  if (providerError) return jsonResponse({ error: providerError }, 400);
@@ -1314,6 +1436,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1314
1436
  : providerManagementConfigError(
1315
1437
  name,
1316
1438
  providerTransportValidationCandidate(replay.next as unknown as Record<string, unknown>),
1439
+ { allowOperatorOverlays: true },
1317
1440
  )
1318
1441
  ?? providerEmptyToolOutputConfigError(name, replay.next);
1319
1442
  if (syncError) {
@@ -0,0 +1,140 @@
1
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
2
+ import { remoteWorkspaceEnabled } from "../../remote-control/workspace-activation";
3
+ import type { ManagementContext } from "./context";
4
+ import { isRemoteWorkspaceAgentProfile } from "../../remote-control/workspace-agent-protocol";
5
+
6
+ const REMOTE_WORKSPACE_PREFIX = "/api/remote-workspace";
7
+
8
+ function response(body: unknown, status = 200): Response {
9
+ return Response.json(body, { status, headers: { "cache-control": "no-store" } });
10
+ }
11
+
12
+ function sessionOnly(ctx: ManagementContext): Response | null {
13
+ return ctx.principal === "gui-session"
14
+ ? null
15
+ : response({ error: "A dashboard session is required for Remote Workspace changes." }, 403);
16
+ }
17
+
18
+ async function jsonObject(req: Request): Promise<Record<string, unknown>> {
19
+ let value: unknown;
20
+ try { value = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); throw new Error("Remote Workspace request body must be JSON."); }
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
22
+ throw new Error("Remote Workspace request body must be an object.");
23
+ }
24
+ return value as Record<string, unknown>;
25
+ }
26
+
27
+ function exactBodyKeys(
28
+ body: Record<string, unknown>,
29
+ required: readonly string[],
30
+ optional: readonly string[] = [],
31
+ ): void {
32
+ const allowed = new Set([...required, ...optional]);
33
+ if (required.some(key => !Object.hasOwn(body, key))
34
+ || Object.keys(body).some(key => !allowed.has(key))) {
35
+ throw new Error("Remote Workspace request body contains invalid fields.");
36
+ }
37
+ }
38
+
39
+ export async function handleRemoteWorkspaceRoutes(ctx: ManagementContext): Promise<Response | null> {
40
+ const { req, url, config } = ctx;
41
+ if (!url.pathname.startsWith(REMOTE_WORKSPACE_PREFIX)) return null;
42
+ if (url.pathname.length > REMOTE_WORKSPACE_PREFIX.length
43
+ && url.pathname[REMOTE_WORKSPACE_PREFIX.length] !== "/") return null;
44
+ if (!remoteWorkspaceEnabled(config)) {
45
+ return response({ available: false, reason: "Remote Workspace requires this OpenCodex instance to run as a hub." }, 409);
46
+ }
47
+ if (req.method !== "GET") {
48
+ const denied = sessionOnly(ctx);
49
+ if (denied) return denied;
50
+ }
51
+ if (ctx.deps.remoteWorkspaceStopping?.()) return response({ error: "Remote Workspace is stopping." }, 503);
52
+ const runtime = await import("../../remote-control/workspace-runtime");
53
+ if (ctx.deps.remoteWorkspaceStopping?.()) return response({ error: "Remote Workspace is stopping." }, 503);
54
+ const hub = ctx.deps.remoteWorkspaceHub ?? runtime.remoteWorkspaceHubForConfig(config);
55
+ const sessions = ctx.deps.remoteWorkspaceSessions ?? runtime.remoteWorkspaceSessionsForConfig(config);
56
+ ctx.deps.onRemoteWorkspaceShutdown?.(async () => {
57
+ const sessions = ctx.deps.remoteWorkspaceSessions ?? runtime.initializedRemoteWorkspaceSessionsForConfig(config);
58
+ const hub = ctx.deps.remoteWorkspaceHub ?? runtime.initializedRemoteWorkspaceHubForConfig(config);
59
+ try { await sessions?.shutdown(); } finally { hub?.closeAllConnections(); }
60
+ });
61
+ if (url.pathname === "/api/remote-workspace" && req.method === "GET") {
62
+ return response({
63
+ available: true,
64
+ devices: hub.listDevices(),
65
+ runtimes: await sessions.availability(),
66
+ sessions: sessions.list(),
67
+ });
68
+ }
69
+ if (url.pathname === "/api/remote-workspace/pairing" && req.method === "POST") {
70
+ const denied = sessionOnly(ctx);
71
+ if (denied) return denied;
72
+ try {
73
+ return response(hub.createPairingGrant(), 201);
74
+ } catch (error) {
75
+ rethrowManagementBodyTooLarge(error);
76
+ return response({ error: error instanceof Error ? error.message : "Remote Workspace pairing failed." }, 429);
77
+ }
78
+ }
79
+ const match = /^\/api\/remote-workspace\/devices\/([0-9a-f-]+)$/i.exec(url.pathname);
80
+ if (match && req.method === "DELETE") {
81
+ const denied = sessionOnly(ctx);
82
+ if (denied) return denied;
83
+ if (!hub.revokeDevice(match[1]!)) return response({ error: "Remote Workspace device not found." }, 404);
84
+ await Promise.all(sessions.list()
85
+ .filter(session => session.deviceId === match[1] && session.status !== "stopped")
86
+ .map(session => sessions.stop(session.id)));
87
+ return response({ ok: true });
88
+ }
89
+ if (url.pathname === "/api/remote-workspace/sessions" && req.method === "GET") {
90
+ return response({ sessions: sessions.list() });
91
+ }
92
+ if (url.pathname === "/api/remote-workspace/runtimes" && req.method === "GET") {
93
+ return response({ runtimes: await sessions.availability() });
94
+ }
95
+ if (url.pathname === "/api/remote-workspace/sessions" && req.method === "POST") {
96
+ const denied = sessionOnly(ctx);
97
+ if (denied) return denied;
98
+ try {
99
+ const body = await jsonObject(req);
100
+ exactBodyKeys(body, ["profile", "deviceId", "rootId"], ["accessMode"]);
101
+ if (!isRemoteWorkspaceAgentProfile(body.profile)
102
+ || typeof body.deviceId !== "string"
103
+ || typeof body.rootId !== "string"
104
+ || (body.accessMode !== undefined && body.accessMode !== "read-only" && body.accessMode !== "workspace")) {
105
+ throw new Error("profile, deviceId, rootId, and a valid accessMode are required.");
106
+ }
107
+ return response(await sessions.create({
108
+ profile: body.profile,
109
+ deviceId: body.deviceId,
110
+ rootId: body.rootId,
111
+ accessMode: body.accessMode === "workspace" ? "workspace" : "read-only",
112
+ }), 201);
113
+ } catch (error) {
114
+ rethrowManagementBodyTooLarge(error);
115
+ return response({ error: error instanceof Error ? error.message : "Remote Workspace session failed." }, 409);
116
+ }
117
+ }
118
+ const promptMatch = /^\/api\/remote-workspace\/sessions\/([0-9a-f-]+)\/prompt$/i.exec(url.pathname);
119
+ if (promptMatch && req.method === "POST") {
120
+ const denied = sessionOnly(ctx);
121
+ if (denied) return denied;
122
+ try {
123
+ const body = await jsonObject(req);
124
+ exactBodyKeys(body, ["prompt"]);
125
+ return response(sessions.submitPrompt(promptMatch[1]!, body.prompt), 202);
126
+ } catch (error) {
127
+ rethrowManagementBodyTooLarge(error);
128
+ return response({ error: error instanceof Error ? error.message : "Remote Workspace turn failed." }, 409);
129
+ }
130
+ }
131
+ const sessionMatch = /^\/api\/remote-workspace\/sessions\/([0-9a-f-]+)$/i.exec(url.pathname);
132
+ if (sessionMatch && req.method === "DELETE") {
133
+ const denied = sessionOnly(ctx);
134
+ if (denied) return denied;
135
+ return await sessions.stop(sessionMatch[1]!)
136
+ ? response({ ok: true })
137
+ : response({ error: "Remote Workspace session not found." }, 404);
138
+ }
139
+ return response({ error: "Unknown Remote Workspace operation." }, 404);
140
+ }
@@ -91,11 +91,17 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
91
91
  { method: "GET", path: "/api/codex-auth/active", module: "codex/auth-api", mutates: false },
92
92
  { method: "GET", path: "/api/codex-auth/login-status", module: "codex/auth-api", mutates: false },
93
93
  { method: "GET", path: "/api/codex-auth/quota", module: "codex/auth-api", mutates: false },
94
+ { method: "GET", path: "/api/codex-auth/quota/history", module: "codex/auth-api", mutates: false },
94
95
  { method: "GET", path: "/api/codex-auth/reset-credits", module: "codex/auth-api", mutates: false },
95
96
  { method: "PATCH", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true },
96
97
  { method: "POST", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: true },
97
98
  { method: "POST", path: "/api/codex-auth/accounts/clear-cooldown", module: "codex/auth-api", mutates: true },
98
99
  { method: "POST", path: "/api/codex-auth/accounts/refresh", module: "codex/auth-api", mutates: true },
100
+ // codex/main-device-reauth-api (#3898): the native-main device reauth namespace;
101
+ // /api/codex-auth/login stays pool-only and keeps rejecting __main__.
102
+ { method: "POST", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: true },
103
+ { method: "GET", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: false },
104
+ { method: "DELETE", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: true },
99
105
  { method: "POST", path: "/api/codex-auth/login", module: "codex/auth-api", mutates: true },
100
106
  { method: "POST", path: "/api/codex-auth/login/cancel", module: "codex/auth-api", mutates: true },
101
107
  { method: "POST", path: "/api/codex-auth/login/code", module: "codex/auth-api", mutates: true },
@@ -322,6 +328,15 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
322
328
  { method: "POST", path: "/api/storage/codex-logs/protect", module: "server/management/storage-log-guard-routes", mutates: true },
323
329
  { method: "POST", path: "/api/storage/codex-logs/repair", module: "server/management/storage-log-guard-routes", mutates: true },
324
330
  { method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true },
331
+ // server/management/remote-workspace-routes
332
+ { method: "GET", path: "/api/remote-workspace", module: "server/management/remote-workspace-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The first Remote Workspace slice exposes Hub status through the authenticated dashboard; a distinct CLI Hub-status verb is still owed and must not be confused with the Executor-local status command.", owner: "remote-workspace-cli-followup", ownerDoc: "docs-site/src/content/docs/reference/management-api.md" } },
333
+ { method: "GET", path: "/api/remote-workspace/runtimes", module: "server/management/remote-workspace-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The first Remote Workspace slice exposes Hub runtime availability through the authenticated dashboard; a distinct CLI Hub-status verb is still owed.", owner: "remote-workspace-cli-followup", ownerDoc: "docs-site/src/content/docs/reference/management-api.md" } },
334
+ { method: "GET", path: "/api/remote-workspace/sessions", module: "server/management/remote-workspace-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The first Remote Workspace slice exposes Hub session snapshots through the authenticated dashboard; a distinct CLI Hub-status verb is still owed.", owner: "remote-workspace-cli-followup", ownerDoc: "docs-site/src/content/docs/reference/management-api.md" } },
335
+ { method: "POST", path: "/api/remote-workspace/pairing", module: "server/management/remote-workspace-routes", mutates: true, exempt: { reason: "session-only", why: "Creating a device enrollment grant authorizes another computer, so only a dashboard consent session may request one." } },
336
+ { method: "POST", path: "/api/remote-workspace/sessions", module: "server/management/remote-workspace-routes", mutates: true, exempt: { reason: "session-only", why: "Starting a Hub-authenticated model session against a remote computer requires an interactive dashboard consent session." } },
337
+ { method: "POST", path: "/api/remote-workspace/sessions/{id}/prompt", module: "server/management/remote-workspace-routes", mutates: true, mechanism: "regex", exempt: { reason: "session-only", why: "A prompt can execute tools on the selected remote computer and therefore requires an interactive dashboard consent session." } },
338
+ { method: "DELETE", path: "/api/remote-workspace/devices/{id}", module: "server/management/remote-workspace-routes", mutates: true, mechanism: "regex", exempt: { reason: "session-only", why: "Revoking a Remote Workspace computer is an interactive dashboard identity action, not an admin-token automation verb." } },
339
+ { method: "DELETE", path: "/api/remote-workspace/sessions/{id}", module: "server/management/remote-workspace-routes", mutates: true, mechanism: "regex", exempt: { reason: "session-only", why: "Stopping an interactive Remote Workspace model session belongs to the dashboard session that controls it." } },
325
340
  // server/management/system-routes
326
341
  { method: "GET", path: "/api/system/health", module: "server/management/system-routes", mutates: false },
327
342
  { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false },
@@ -23,6 +23,7 @@ import {
23
23
 
24
24
  interface RetainedUsageAggregate {
25
25
  accumulator: UsageSummaryAccumulator;
26
+ usageIncomplete: boolean;
26
27
  revision: UsageLogRevision | null;
27
28
  identityKey: string;
28
29
  revisionKey: string;
@@ -35,6 +36,7 @@ interface RetainedUsageAggregate {
35
36
 
36
37
  export interface UsageAggregateResult {
37
38
  accumulator: UsageSummaryAccumulator;
39
+ usageIncomplete: boolean;
38
40
  revision: UsageLogRevision | null;
39
41
  processedThroughBytes: number;
40
42
  overlayVersion: number;
@@ -75,6 +77,7 @@ function resultFrom(
75
77
  ): UsageAggregateResult {
76
78
  return {
77
79
  accumulator: state.accumulator,
80
+ usageIncomplete: state.usageIncomplete,
78
81
  revision: state.revision,
79
82
  processedThroughBytes: state.processedThroughBytes,
80
83
  overlayVersion: state.overlayVersion,
@@ -99,6 +102,7 @@ function makeRetainedAggregate(
99
102
  ): RetainedUsageAggregate {
100
103
  return {
101
104
  accumulator,
105
+ usageIncomplete: scan.oversizedRows > 0,
102
106
  revision: scan.revision,
103
107
  identityKey: usageLogIdentityKey(scan.revision),
104
108
  revisionKey: usageLogRevisionKey(scan.revision),
@@ -126,9 +130,6 @@ async function rebuildAggregate(options: UsageAggregateOptions): Promise<UsageAg
126
130
  apiKeyAccumulator?.add(entry);
127
131
  },
128
132
  });
129
- if (scan.oversizedRows > 0) {
130
- throw new Error("usage ledger contains an oversized row");
131
- }
132
133
  if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) {
133
134
  lastError = new Error("usage aggregation inputs changed during rebuild");
134
135
  continue;
@@ -138,7 +139,10 @@ async function rebuildAggregate(options: UsageAggregateOptions): Promise<UsageAg
138
139
  const result = publishRetainedAggregate(state);
139
140
  if (apiKeyAccumulator && options.configuredApiKeyIds) {
140
141
  cacheApiKeyUsageFromRollup(
141
- apiKeyAccumulator.snapshot(),
142
+ {
143
+ ...apiKeyAccumulator.snapshot(),
144
+ ...(state.usageIncomplete ? { usageIncomplete: true as const, usageIncompleteReason: "oversized_rows" as const } : {}),
145
+ },
142
146
  options.configuredApiKeyIds,
143
147
  state.identityKey,
144
148
  state.revision?.size ?? 0,
@@ -180,8 +184,8 @@ async function appendAggregate(
180
184
  let rebuildAfterUnpin = false;
181
185
  try {
182
186
  // Clone first and publish only after the scanner verifies the captured
183
- // suffix. A callback error, mutation, or oversized row leaves retained
184
- // state byte-for-byte untouched.
187
+ // suffix. A callback error or mutation leaves retained state untouched.
188
+ // Skipped oversized rows retain the normal rows with an explicit diagnostic.
185
189
  const candidate = state.accumulator.clone();
186
190
  const scan = await scanUsageLedgerCooperatively({
187
191
  startAtBytes: state.processedThroughBytes,
@@ -189,10 +193,6 @@ async function appendAggregate(
189
193
  expectedProcessedThroughDigest: state.processedThroughDigest,
190
194
  onEntry: entry => candidate.add(entry),
191
195
  });
192
- if (scan.oversizedRows > 0) {
193
- if (retainedAggregate === state) retainedAggregate = null;
194
- throw new Error("usage ledger contains an oversized row");
195
- }
196
196
  if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) {
197
197
  if (retainedAggregate === state) retainedAggregate = null;
198
198
  rebuildAfterUnpin = true;
@@ -200,6 +200,9 @@ async function appendAggregate(
200
200
  const next: RetainedUsageAggregate = {
201
201
  ...state,
202
202
  accumulator: candidate,
203
+ // A partial unterminated row can be scanned again on the next append.
204
+ // Preserve a boolean diagnostic rather than double-counting omissions.
205
+ usageIncomplete: state.usageIncomplete || scan.oversizedRows > 0,
203
206
  revision: scan.revision,
204
207
  identityKey: usageLogIdentityKey(scan.revision),
205
208
  revisionKey: usageLogRevisionKey(scan.revision),
@@ -329,7 +332,6 @@ async function rebuildFilteredAggregate(
329
332
  const accumulator = createUsageSummaryAccumulator({ filter, mode: "row-unique", window });
330
333
  try {
331
334
  const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) });
332
- if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row");
333
335
  if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) {
334
336
  lastError = new Error("usage aggregation inputs changed during filtered scan");
335
337
  continue;
@@ -362,10 +364,6 @@ async function appendFilteredAggregate(
362
364
  expectedProcessedThroughDigest: state.processedThroughDigest,
363
365
  onEntry: entry => candidate.add(entry),
364
366
  });
365
- if (scan.oversizedRows > 0) {
366
- if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key);
367
- throw new Error("usage ledger contains an oversized row");
368
- }
369
367
  if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) {
370
368
  if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key);
371
369
  rebuildAfterUnpin = true;
@@ -373,6 +371,7 @@ async function appendFilteredAggregate(
373
371
  const next: RetainedUsageAggregate = {
374
372
  ...state,
375
373
  accumulator: candidate,
374
+ usageIncomplete: state.usageIncomplete || scan.oversizedRows > 0,
376
375
  revision: scan.revision,
377
376
  identityKey: usageLogIdentityKey(scan.revision),
378
377
  revisionKey: usageLogRevisionKey(scan.revision),
@@ -2,6 +2,8 @@ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../../l
2
2
  import type { UsageSummary } from "../../usage/summary";
3
3
 
4
4
  export type CachedUsageSummary = UsageSummary & {
5
+ usageIncomplete?: true;
6
+ usageIncompleteReason?: "oversized_rows";
5
7
  historyTruncated: boolean;
6
8
  truncatedPrefixBytes: number;
7
9
  entriesTruncated: boolean;
@@ -1,3 +1,4 @@
1
+ import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import type { CatalogModel } from "../codex/catalog";
@@ -149,6 +150,20 @@ async function handleGrokCouponRoutesOnDemand(ctx: ManagementContext): Promise<R
149
150
  return handleGrokCouponRoutes(ctx);
150
151
  }
151
152
 
153
+ async function handleRemoteWorkspaceRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
154
+ if (!pathInManagementNamespace(ctx.url.pathname, "/api/remote-workspace")) return null;
155
+ if (!remoteWorkspaceEnabled(ctx.config)) {
156
+ return Response.json({ available: false, reason: "Remote Workspace requires Hub mode and OCX_REMOTE_WORKSPACE_ENABLED=1.", devices: [], runtimes: {}, sessions: [] }, {
157
+ status: ctx.req.method === "GET" ? 200 : 404, headers: { "cache-control": "no-store" },
158
+ });
159
+ }
160
+ if (ctx.req.method !== "GET" && ctx.principal !== "gui-session") {
161
+ return Response.json({ error: "A dashboard session is required for Remote Workspace changes." }, { status: 403 });
162
+ }
163
+ const { handleRemoteWorkspaceRoutes } = await import("./management/remote-workspace-routes");
164
+ return handleRemoteWorkspaceRoutes(ctx);
165
+ }
166
+
152
167
  export async function handleManagementAPI(
153
168
  req: Request,
154
169
  url: URL,
@@ -242,6 +257,7 @@ export async function handleManagementAPI(
242
257
  let routed: Response | null;
243
258
  try {
244
259
  routed = handleSessionRoutes(ctx)
260
+ ?? (await handleRemoteWorkspaceRoutesOnDemand(ctx))
245
261
  ?? (await handleConfigRoutes(ctx))
246
262
  ?? (await handleStorageLogGuardRoutes(ctx))
247
263
  ?? (await handleLogsUsageRoutes(ctx))
@@ -388,6 +404,13 @@ export async function handleManagementAPI(
388
404
  }
389
405
 
390
406
  if (url.pathname.startsWith("/api/codex-auth/")) {
407
+ // Native-main device reauth (#3898): a dedicated namespace the generic
408
+ // codex-auth dispatch must not swallow (it would 404 as an unknown pool
409
+ // route). Same management origin/auth/session wrapping as every /api/*.
410
+ if (url.pathname === "/api/codex-auth/main/reauth-device") {
411
+ const { handleMainDeviceReauthAPI } = await import("../codex/main-device-reauth-api");
412
+ return handleMainDeviceReauthAPI(req, url, config);
413
+ }
391
414
  const { handleCodexAuthAPI } = await import("../codex/auth-api");
392
415
  const { ConfigMutationLockError } = await import("../config");
393
416
  const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store");
@@ -1,5 +1,22 @@
1
1
  import { createServer } from "node:net";
2
2
 
3
+ /** An auxiliary bind cannot be repaired by selecting a different public port. */
4
+ export class AuxiliaryListenerBindError extends Error {
5
+ constructor(
6
+ readonly listener: "unauthenticatedLoopbackListener" | "hub.managementIngress",
7
+ readonly port: number,
8
+ readonly hostname: string,
9
+ cause: unknown,
10
+ ) {
11
+ super(
12
+ `Could not bind ${listener} at ${hostname}:${port}. `
13
+ + `Check that listener's address and port; the public proxy port was not retried.`,
14
+ { cause },
15
+ );
16
+ this.name = "AuxiliaryListenerBindError";
17
+ }
18
+ }
19
+
3
20
  /** Temporary bind probes must not let accepted peers hold server.close() open. */
4
21
  function createProbeServer(): ReturnType<typeof createServer> {
5
22
  const server = createServer();
@@ -26,6 +26,7 @@
26
26
 
27
27
  import {
28
28
  adapterEofIncompleteFrame,
29
+ type CodexSafetyBufferingFilterOptions,
29
30
  createSseTerminalOutputBoundary,
30
31
  doneFrame,
31
32
  failedTailFrame,
@@ -84,6 +85,8 @@ export type EagerRelayOptions = {
84
85
  postCancelDrainBytes?: number;
85
86
  /** Last known upstream failure to preserve when EOF would otherwise become adapter_eof. */
86
87
  upstreamError?: string;
88
+ /** Optional client-facing hint policy; inspection retains original frames. */
89
+ terminalBoundary?: CodexSafetyBufferingFilterOptions;
87
90
  /** Injectable clock for tests. */
88
91
  now?: () => number;
89
92
  };
@@ -114,7 +117,7 @@ export function relaySseEagerBounded(
114
117
  const terminalEncoder = new TextEncoder();
115
118
  const adapterEofFrame = adapterEofIncompleteFrame(terminalEncoder);
116
119
  const terminalSentinel = doneFrame(terminalEncoder);
117
- const terminalBoundary = createSseTerminalOutputBoundary();
120
+ const terminalBoundary = createSseTerminalOutputBoundary(opts?.terminalBoundary);
118
121
  const activeRewrite: SseBlockRewrite | undefined = hooks.rewriteBlocks
119
122
  ?? (hooks.rewritePayload ? payloadRewriteAsBlockRewrite(hooks.rewritePayload) : undefined);
120
123
  const encodeFailedTail = (error: unknown): Uint8Array | null => {