@bitkyc08/opencodex 2.49.0 → 2.51.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
package/src/config.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
47
47
  } from "./codex/account-namespace-match";
48
48
  import { isCodexAccountPriorityKey } from "./codex/account-priority";
49
+ import { loopbackCompanionAllowed } from "./codex/loopback-target";
49
50
  import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health";
50
51
  import {
51
52
  adoptCustomModelCatalogMigration,
@@ -73,6 +74,7 @@ import {
73
74
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
74
75
  OPENAI_PROVIDER_TIER_VERSION,
75
76
  pinnedWireAdapter,
77
+ PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS,
76
78
  UPSTREAM_HTTP_VERSION_VALUES,
77
79
  type OcxClaudeCodeConfig,
78
80
  type OcxConfig,
@@ -497,6 +499,47 @@ export function requestPacingConfigError(value: unknown): string | null {
497
499
  return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
498
500
  }
499
501
 
502
+ /**
503
+ * Bounds for the opt-in passthrough web-search bridge (`providers.<name>.webSearchBridge`,
504
+ * #3761). Strict for the same reason `retryOn429` is: a misspelled key here would silently
505
+ * leave the bridge disarmed while the operator believes they enabled it. `endpoint` is only
506
+ * shape-checked here; `planPassthroughWebSearchBridge` re-validates the origin before any key
507
+ * is sent to it, because config validation is not an authorization boundary.
508
+ */
509
+ const providerWebSearchBridgeSchema = z.object({
510
+ enabled: z.boolean().optional(),
511
+ backend: z.enum(PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS).optional(),
512
+ maxSearches: z.number().int().min(1).max(10).optional(),
513
+ timeoutMs: z.number().int().min(1_000).max(600_000).optional(),
514
+ endpoint: z.string().min(1).optional(),
515
+ }).strict();
516
+
517
+ export function providerWebSearchBridgeConfigError(value: unknown): string | null {
518
+ if (value === undefined) return null;
519
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
520
+ return "webSearchBridge must be a plain object";
521
+ }
522
+ const parsed = providerWebSearchBridgeSchema.safeParse(value);
523
+ if (!parsed.success) {
524
+ return "webSearchBridge accepts only enabled (boolean), backend "
525
+ + `(${PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS.join("|")}), maxSearches (1..10), `
526
+ + "timeoutMs (1000..600000), and endpoint (absolute http(s) URL)";
527
+ }
528
+ const endpoint = parsed.data.endpoint;
529
+ if (endpoint !== undefined) {
530
+ let url: URL;
531
+ try {
532
+ url = new URL(endpoint);
533
+ } catch {
534
+ return "webSearchBridge.endpoint must be an absolute http(s) URL";
535
+ }
536
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
537
+ return "webSearchBridge.endpoint must be an absolute http(s) URL";
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+
500
543
  const fastWireSchema = z.object({
501
544
  kind: z.string(),
502
545
  canonicalToWire: z.record(z.string().trim(), z.string().trim()),
@@ -600,6 +643,10 @@ const providerConfigSchema = z.object({
600
643
  repairInvalidIds: z.boolean().optional(),
601
644
  }).strict().optional(),
602
645
  responsesSnapshotRepair: z.boolean().optional(),
646
+ // Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable
647
+ // bridge block must never send an operator through invalid-config recovery for an opt-in
648
+ // feature that is off by default. The management write boundary still rejects it loudly.
649
+ webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined),
603
650
  xaiResponsesXSearch: z.boolean().optional(),
604
651
  xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
605
652
  }).passthrough();
@@ -990,6 +1037,18 @@ const hubConfigSchema = z.object({
990
1037
  }
991
1038
  return origin;
992
1039
  }).optional(),
1040
+ // Same canonical-origin rule as managementPublicOrigin, and deliberately NOT `.catch`ed:
1041
+ // a mistyped data origin must be rejected at write time, because silently dropping it
1042
+ // makes `ocx hub invite` print the `http://<hostname>:<port>` fallback that the operator
1043
+ // set this field precisely to replace.
1044
+ dataPublicOrigin: z.string().transform((value, ctx) => {
1045
+ const origin = canonicalHttpOrigin(value);
1046
+ if (!origin) {
1047
+ ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
1048
+ return z.NEVER;
1049
+ }
1050
+ return origin;
1051
+ }).optional(),
993
1052
  // A malformed hand edit disables only the optional ingress. Live writes are rejected by
994
1053
  // managementIngressConfigError before this load-time degradation can hide the mistake.
995
1054
  managementIngress: z.union([
@@ -1065,6 +1124,17 @@ const clientConnectionSchema = z.object({
1065
1124
  }).optional(),
1066
1125
  }).strict();
1067
1126
 
1127
+ /**
1128
+ * Codex pool selection policy section.
1129
+ *
1130
+ * `.strict()` like its neighbour: a typo in an optional feature section should surface as a
1131
+ * rejected write rather than a silently ignored key that leaves the operator believing they
1132
+ * excluded something.
1133
+ */
1134
+ const codexPoolSchema = z.object({
1135
+ excludedPlans: z.array(z.string().trim().min(1)).optional(),
1136
+ }).strict();
1137
+
1068
1138
  /**
1069
1139
  * Quota-reset notification section.
1070
1140
  *
@@ -1100,6 +1170,9 @@ const configSchema = z.object({
1100
1170
  // candidates are rejected explicitly by remoteGuiConfigError below.
1101
1171
  hub: hubConfigSchema.optional().catch(undefined),
1102
1172
  remoteGui: remoteGuiConfigSchema.optional().catch(undefined),
1173
+ // A malformed privacy block must never be read as "unmask": .catch(undefined) drops it and
1174
+ // emailMaskingEnabled then falls back to masked, which is also what an absent block means.
1175
+ privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined),
1103
1176
  // A malformed present client block must remain diagnosable from raw config and
1104
1177
  // fail closed through src/client/state.ts; unrelated provider state still loads.
1105
1178
  client: clientConnectionSchema.optional().catch(undefined),
@@ -1118,6 +1191,15 @@ const configSchema = z.object({
1118
1191
  .min(0)
1119
1192
  .optional()
1120
1193
  .catch(undefined),
1194
+ // Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default
1195
+ // rather than failing the parse, matching the outbound guard above: a malformed number must
1196
+ // not change what the proxy admits. The hard ceiling is NOT enforced here — because of that
1197
+ // `.catch`, and because a config object can be built without this schema at all — but in
1198
+ // `resolveInboundBodyLimitBytes()`, which every reader goes through.
1199
+ maxInboundBodyBytes: z.number().int()
1200
+ .min(0)
1201
+ .optional()
1202
+ .catch(undefined),
1121
1203
  appOwnedMemoryBudgetMb: z.number().int()
1122
1204
  .min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
1123
1205
  .max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
@@ -1130,13 +1212,16 @@ const configSchema = z.object({
1130
1212
  // is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time
1131
1213
  // rejection lives in validateConfigCandidate() so bad values still surface to the caller.
1132
1214
  hostname: z.string().trim().min(1).optional().catch(undefined),
1133
- // Discriminated on `enabled` so a disabled entry cannot be forced to carry a port, and an
1134
- // enabled one cannot omit it (#1102). A malformed value degrades to undefined rather than
1135
- // failing the whole parse: this is an opt-in convenience surface, and a hand-edit typo here
1136
- // must never reset providers/apiKeys through the backup-and-defaults repair path.
1215
+ // Discriminated on `enabled` so a disabled entry cannot be forced to carry a port (#1102).
1216
+ // An enabled one MAY omit it: that is the companion form, which binds 127.0.0.1 on the proxy
1217
+ // port and is legal only off a loopback/wildcard bind — a relationship between two fields, so
1218
+ // it is enforced in validateConfigCandidate() and again at startup, not here (#4236).
1219
+ // A malformed value degrades to undefined rather than failing the whole parse: this is an
1220
+ // opt-in convenience surface, and a hand-edit typo here must never reset providers/apiKeys
1221
+ // through the backup-and-defaults repair path.
1137
1222
  unauthenticatedLoopbackListener: z.union([
1138
1223
  z.object({ enabled: z.literal(false) }),
1139
- z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }),
1224
+ z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535).optional() }),
1140
1225
  ]).optional().catch(undefined),
1141
1226
  providers: z.record(z.string(), providerConfigSchema),
1142
1227
  modelPinnedEfforts: modelPinnedEffortsSchema.optional(),
@@ -1191,6 +1276,10 @@ const configSchema = z.object({
1191
1276
  codexDesktopAuthless: z.boolean().optional().catch(undefined),
1192
1277
  codexClientCompaction: z.boolean().optional().catch(undefined),
1193
1278
  pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
1279
+ // A malformed policy degrades to "no policy" rather than failing the parse, so a hand-edited
1280
+ // typo cannot trip the backup-and-defaults repair path and wipe providers or pool accounts.
1281
+ // Silently ignoring it would be its own trap, so the write path rejects it and loadConfig warns.
1282
+ codexPool: codexPoolSchema.optional().catch(undefined),
1194
1283
  codexQuotaAutoRefresh: codexQuotaAutoRefreshSchema.optional().catch(undefined),
1195
1284
  codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
1196
1285
  // Selection order is a preference, not a safety control like pause: a malformed
@@ -2102,6 +2191,20 @@ function malformedQuotaResetNotifyWarning(rawParsed: unknown): string | null {
2102
2191
  return `quotaResetNotify${field ? `.${field}` : ""} ignored: invalid quota-reset notification configuration`;
2103
2192
  }
2104
2193
 
2194
+ /**
2195
+ * Same silent-in-the-wrong-direction failure as the notification block: a dropped pool policy means
2196
+ * the accounts the operator meant to exclude keep taking traffic, and the only visible symptom is
2197
+ * traffic going somewhere it was supposed to stop going.
2198
+ */
2199
+ function malformedCodexPoolWarning(rawParsed: unknown): string | null {
2200
+ const raw = rawConfigRecord(rawParsed);
2201
+ if (!raw || !Object.hasOwn(raw, "codexPool")) return null;
2202
+ const result = codexPoolSchema.safeParse(raw.codexPool);
2203
+ if (result.success) return null;
2204
+ const field = result.error.issues[0]?.path.join(".");
2205
+ return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`;
2206
+ }
2207
+
2105
2208
  /**
2106
2209
  * Warn once per load that the section was dropped.
2107
2210
  *
@@ -2114,6 +2217,18 @@ function warnDegradedQuotaResetNotify(rawParsed: unknown): void {
2114
2217
  if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2115
2218
  }
2116
2219
 
2220
+ /**
2221
+ * Warn once per load that the pool policy was dropped.
2222
+ *
2223
+ * `.catch(undefined)` turns a malformed policy into a SUCCESSFUL parse, so without this the proxy
2224
+ * starts, rotates onto the accounts the operator meant to exclude, and prints nothing. The visible
2225
+ * symptom would be traffic going exactly where it was told not to go.
2226
+ */
2227
+ function warnDegradedCodexPool(rawParsed: unknown): void {
2228
+ const warning = malformedCodexPoolWarning(rawParsed);
2229
+ if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2230
+ }
2231
+
2117
2232
  type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
2118
2233
 
2119
2234
  function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
@@ -2273,6 +2388,7 @@ export function loadConfig(): OcxConfig {
2273
2388
  warnDegradedRuntimeRole(parsed);
2274
2389
  warnDegradedOptionalRemoteBlocks(parsed);
2275
2390
  warnDegradedQuotaResetNotify(parsed);
2391
+ warnDegradedCodexPool(parsed);
2276
2392
  return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2277
2393
  }
2278
2394
  // Schema validation failed — merge defaults into the raw object instead of
@@ -2301,6 +2417,7 @@ export function loadConfig(): OcxConfig {
2301
2417
  warnDegradedRuntimeRole(parsed);
2302
2418
  warnDegradedOptionalRemoteBlocks(parsed);
2303
2419
  warnDegradedQuotaResetNotify(parsed);
2420
+ warnDegradedCodexPool(parsed);
2304
2421
  return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2305
2422
  }
2306
2423
  // Still failing, but if every complaint is about one or more named entries
@@ -2325,6 +2442,7 @@ export function loadConfig(): OcxConfig {
2325
2442
  warnDegradedRuntimeRole(parsed);
2326
2443
  warnDegradedOptionalRemoteBlocks(parsed);
2327
2444
  warnDegradedQuotaResetNotify(parsed);
2445
+ warnDegradedCodexPool(parsed);
2328
2446
  return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2329
2447
  }
2330
2448
  }
@@ -2469,6 +2587,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
2469
2587
  if (clientWarning) warnings.push(clientWarning);
2470
2588
  const notifyWarning = malformedQuotaResetNotifyWarning(rawParsed);
2471
2589
  if (notifyWarning) warnings.push(notifyWarning);
2590
+ const codexPoolWarning = malformedCodexPoolWarning(rawParsed);
2591
+ if (codexPoolWarning) warnings.push(codexPoolWarning);
2472
2592
  if (syncDisabledReason) {
2473
2593
  warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
2474
2594
  }
@@ -2617,6 +2737,21 @@ function quotaResetNotifyError(value: unknown): string | null {
2617
2737
  return `schema_invalid: quotaResetNotify${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
2618
2738
  }
2619
2739
 
2740
+ /**
2741
+ * The read path degrades a malformed pool policy to undefined, which for an exclusion policy means
2742
+ * the excluded accounts quietly keep serving traffic. Reject it on write so `ocx config set` cannot
2743
+ * create a policy that looks applied and is not.
2744
+ */
2745
+ function codexPoolError(value: unknown): string | null {
2746
+ const raw = rawConfigRecord(value);
2747
+ if (!raw || !Object.hasOwn(raw, "codexPool") || raw.codexPool === undefined) return null;
2748
+ const result = codexPoolSchema.safeParse(raw.codexPool);
2749
+ if (result.success) return null;
2750
+ const issue = result.error.issues[0];
2751
+ const field = issue?.path.join(".");
2752
+ return `schema_invalid: codexPool${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
2753
+ }
2754
+
2620
2755
  /**
2621
2756
  * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
2622
2757
  * malformed selection-order map to undefined, which on a write would drop every entry the
@@ -2701,15 +2836,22 @@ function oauthOpenBrowserError(value: unknown): string | null {
2701
2836
 
2702
2837
  /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
2703
2838
  /**
2704
- * Reject a loopback-listener port that collides with the proxy port (#1102).
2839
+ * Reject a loopback-listener port that collides with the proxy port (#1102), and a port-less
2840
+ * companion listener on a bind address that already owns 127.0.0.1 (#4236).
2705
2841
  *
2706
- * The schema can only check the shape of each field on its own; the two ports being distinct
2707
- * is a relationship between them. Letting the pair through would surface as a startup failure
2708
- * after the public listener already bound, which reads like an unrelated port conflict.
2842
+ * The schema can only check the shape of each field on its own; the two ports being distinct —
2843
+ * and the port-less form being compatible with `hostname` — are relationships between fields.
2844
+ * Letting either through would surface as a startup failure after the public listener already
2845
+ * bound, which reads like an unrelated port conflict.
2846
+ *
2847
+ * Both keys are read from the same candidate, so `ocx config set hostname 127.0.0.1` on a host
2848
+ * whose listener is already the companion form is refused by this same check, with the same
2849
+ * message, rather than breaking the next start.
2709
2850
  *
2710
2851
  * This is write-time only, matching `blankHostnameError`: a live caller can be told the value
2711
2852
  * is wrong, whereas a hand-edited config on the read path degrades to undefined rather than
2712
- * resetting the whole file.
2853
+ * resetting the whole file. `assertLoopbackListenerBindable` repeats the decision at startup so
2854
+ * a hand edit that skipped this boundary fails with the same sentence instead of EADDRINUSE.
2713
2855
  */
2714
2856
  function loopbackListenerPortError(value: unknown): string | null {
2715
2857
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
@@ -2727,17 +2869,46 @@ function loopbackListenerPortError(value: unknown): string | null {
2727
2869
  return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean";
2728
2870
  }
2729
2871
  if (entry.enabled !== true) return null;
2872
+ const hostname = typeof (value as Record<string, unknown>).hostname === "string"
2873
+ ? (value as Record<string, unknown>).hostname as string
2874
+ : undefined;
2875
+ const proxyPort = (value as Record<string, unknown>).port;
2730
2876
  const listenerPort = entry.port;
2877
+ // The companion form. `port` omitted means "same port as the public listener, on 127.0.0.1",
2878
+ // which only exists as a free address when the public listener is bound somewhere else.
2879
+ if (listenerPort === undefined) {
2880
+ return loopbackCompanionBindError(
2881
+ hostname,
2882
+ typeof proxyPort === "number" ? proxyPort : 10100,
2883
+ );
2884
+ }
2731
2885
  if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) {
2732
- return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled";
2886
+ return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled, or omitted to share the proxy port";
2733
2887
  }
2734
- const proxyPort = (value as Record<string, unknown>).port;
2735
2888
  if (typeof proxyPort === "number" && proxyPort === listenerPort) {
2736
2889
  return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port";
2737
2890
  }
2738
2891
  return null;
2739
2892
  }
2740
2893
 
2894
+ /**
2895
+ * The one sentence both the write boundary and startup use for an impossible companion bind.
2896
+ *
2897
+ * Exported so `startServer` can fail with the identical text: an operator who hand-edited the
2898
+ * file past `validateConfigCandidate` must read the same diagnosis, not EADDRINUSE.
2899
+ */
2900
+ export function loopbackCompanionBindError(
2901
+ hostname: string | undefined,
2902
+ proxyPort: number,
2903
+ ): string | null {
2904
+ if (loopbackCompanionAllowed(hostname)) return null;
2905
+ const bind = (hostname ?? "").trim() || "127.0.0.1";
2906
+ return "schema_invalid: unauthenticatedLoopbackListener: a port-less listener binds "
2907
+ + `127.0.0.1:${proxyPort}, which the public listener on hostname "${bind}" already holds. `
2908
+ + "Either set a distinct unauthenticatedLoopbackListener.port, or remove the listener — a "
2909
+ + "loopback bind already admits local callers without a credential.";
2910
+ }
2911
+
2741
2912
  /**
2742
2913
  * Validate the hub management ingress at the live-write boundary.
2743
2914
  *
@@ -2792,6 +2963,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
2792
2963
  ?? upstreamHostCircuitThresholdError(value)
2793
2964
  ?? agentTaskRecoveryError(value)
2794
2965
  ?? quotaResetNotifyError(value)
2966
+ ?? codexPoolError(value)
2795
2967
  ?? googleAntigravityStaticCatalogVersionError(value)
2796
2968
  ?? codexAccountPrioritiesError(value)
2797
2969
  ?? codexQuotaAutoRefreshError(value)