@bitkyc08/opencodex 2.39.0 → 2.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
package/src/config.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  apiKeyTransportConfigError,
9
9
  booleanRecordConfigError,
10
10
  modelAdapterRecordConfigError,
11
+ modelDisplayNamesConfigError,
11
12
  nonBlankStringArrayConfigError,
12
13
  normalizeNonBlankStringArray,
13
14
  positiveIntegerConfigError,
@@ -61,6 +62,7 @@ import { providerDestinationConfigError } from "./lib/destination-policy";
61
62
  import { redactSecretString } from "./lib/redact";
62
63
  import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
63
64
  import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases";
65
+ import { MODEL_DISCOVERY_MAX_MODELS } from "./providers/model-discovery-limits";
64
66
  import { vercelGatewayRoutingConfigError } from "./providers/vercel-gateway-routing";
65
67
  import {
66
68
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
@@ -74,6 +76,7 @@ import {
74
76
  type FastWire,
75
77
  type ProviderCostOverlay,
76
78
  } from "./types";
79
+ import type { OcxRuntimeRole } from "./types/config";
77
80
  import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
78
81
  import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget";
79
82
  import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
@@ -118,6 +121,11 @@ export {
118
121
  type AtomicWriteIO,
119
122
  } from "./config/atomic-write";
120
123
  import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
124
+ import {
125
+ describeProxyForLog,
126
+ readWindowsSystemProxy,
127
+ type WindowsProxyRegistryReader,
128
+ } from "./lib/windows-system-proxy";
121
129
  export { expandUserPath, getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
122
130
  export {
123
131
  getPidPath,
@@ -493,6 +501,17 @@ const fastWireSchema = z.object({
493
501
  if (error) ctx.addIssue({ code: "custom", message: error });
494
502
  }).transform(fastWire => fastWire as FastWire);
495
503
 
504
+ const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => {
505
+ const error = modelDisplayNamesConfigError(value);
506
+ if (error) ctx.addIssue({ code: "custom", message: error });
507
+ }).transform(value => {
508
+ const labels = Object.create(null) as Record<string, string>;
509
+ for (const [modelId, displayName] of Object.entries(value as Record<string, string>)) {
510
+ labels[modelId] = displayName;
511
+ }
512
+ return labels;
513
+ });
514
+
496
515
  /**
497
516
  * Zod schema for one provider entry: known fields are validated strictly while unknown
498
517
  * fields pass through (preserved for runtime extensions).
@@ -502,6 +521,7 @@ const providerConfigSchema = z.object({
502
521
  baseUrl: z.string().min(1),
503
522
  alias: z.string().optional(),
504
523
  modelAliases: z.record(z.string(), z.string()).optional(),
524
+ modelDisplayNames: modelDisplayNamesSchema.optional(),
505
525
  defaultAliases: z.boolean().optional(),
506
526
  requestPacing: requestPacingSchema.optional().catch(undefined),
507
527
  mcpMaxTools: z.number().int().positive().optional(),
@@ -524,10 +544,20 @@ const providerConfigSchema = z.object({
524
544
  upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
525
545
  .nullish()
526
546
  .transform(value => value ?? undefined),
547
+ // Opt-in upstream Responses WebSocket for OpenAI-compatible providers (e.g.
548
+ // aggregators whose WebSocket ingress is measurably faster than SSE). The
549
+ // canonical ChatGPT backend WS selection is independent of this flag.
550
+ upstreamWebsocket: z.boolean().optional(),
527
551
  directGeminiWireRenames: z.boolean().optional(),
528
552
  noStructuredOutputModels: z.array(z.string().min(1))
529
553
  .transform(normalizeNonBlankStringArray)
530
554
  .optional(),
555
+ retainModels: z.array(z.string().min(1))
556
+ .transform(normalizeNonBlankStringArray)
557
+ .optional(),
558
+ omitReasoningEffortWithToolsModels: z.array(z.string().min(1))
559
+ .transform(normalizeNonBlankStringArray)
560
+ .optional(),
531
561
  retryOn429: retryOn429PolicySchema.optional(),
532
562
  transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(),
533
563
  codexAccountMode: z.enum(["pool", "direct"]).optional(),
@@ -551,6 +581,7 @@ export {
551
581
  apiKeyTransportConfigError,
552
582
  booleanRecordConfigError,
553
583
  modelAdapterRecordConfigError,
584
+ modelDisplayNamesConfigError,
554
585
  nonBlankStringArrayConfigError,
555
586
  normalizeNonBlankStringArray,
556
587
  positiveIntegerConfigError,
@@ -830,6 +861,13 @@ const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
830
861
  * it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched
831
862
  * clients, so a junk first entry would mask a valid later one.
832
863
  */
864
+ const pendingApiKeyRotationSchema = z.object({
865
+ id: z.string().trim().min(1).max(256),
866
+ key: z.string().refine(isUsableApiKeySecret),
867
+ createdAt: z.string().datetime({ offset: true }),
868
+ expiresAt: z.string().datetime({ offset: true }),
869
+ }).strict();
870
+
833
871
  const apiKeyEntrySchema = z.object({
834
872
  key: z.string().refine(isUsableApiKeySecret),
835
873
  // Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`,
@@ -837,6 +875,8 @@ const apiKeyEntrySchema = z.object({
837
875
  id: z.string().catch(""),
838
876
  name: z.string().catch(""),
839
877
  createdAt: z.string().catch(""),
878
+ // A damaged overlap record must never discard the still-authoritative key.
879
+ pendingRotation: pendingApiKeyRotationSchema.optional().catch(undefined),
840
880
  }).passthrough();
841
881
 
842
882
  /**
@@ -861,8 +901,115 @@ const agentTaskRecoverySchema = z.object({
861
901
  cacheEntries: z.number().int().min(1).max(512).optional(),
862
902
  }).strict();
863
903
 
904
+ const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]);
905
+
906
+ function canonicalHttpOrigin(value: string): string | null {
907
+ try {
908
+ const parsed = new URL(value);
909
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
910
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
911
+ return parsed.origin;
912
+ } catch {
913
+ return null;
914
+ }
915
+ }
916
+
917
+ const hubConfigSchema = z.object({
918
+ managementPublicOrigin: z.string().transform((value, ctx) => {
919
+ const origin = canonicalHttpOrigin(value);
920
+ if (!origin) {
921
+ ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
922
+ return z.NEVER;
923
+ }
924
+ return origin;
925
+ }).optional(),
926
+ // A malformed hand edit disables only the optional ingress. Live writes are rejected by
927
+ // managementIngressConfigError before this load-time degradation can hide the mistake.
928
+ managementIngress: z.union([
929
+ z.object({ enabled: z.literal(false) }).strict(),
930
+ z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(),
931
+ ]).optional().catch(undefined),
932
+ }).strict();
933
+
934
+ const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => {
935
+ if (new TextEncoder().encode(value).byteLength > 320) {
936
+ ctx.addIssue({ code: "custom", message: "must be at most 320 UTF-8 bytes" });
937
+ }
938
+ if (/[\x00-\x1f\x7f]/.test(value)) {
939
+ ctx.addIssue({ code: "custom", message: "must not contain ASCII control characters" });
940
+ }
941
+ });
942
+
943
+ const remoteGuiConfigSchema = z.object({
944
+ allowedTailscaleUsers: z.array(tailscaleUserSchema).max(64).superRefine((users, ctx) => {
945
+ const seen = new Set<string>();
946
+ for (let index = 0; index < users.length; index++) {
947
+ const user = users[index]!;
948
+ if (seen.has(user)) {
949
+ ctx.addIssue({ code: "custom", path: [index], message: "must contain unique users after trimming" });
950
+ }
951
+ seen.add(user);
952
+ }
953
+ }).optional(),
954
+ // Retired (see OcxRemoteGuiConfig): accepted so an existing file still loads, ignored by
955
+ // the pairing path. Removing it from a strict schema would reject the whole config.
956
+ allowInsecureHttp: z.boolean().optional(),
957
+ }).strict();
958
+
959
+ const connectedClientIdSchema = z.enum(["codex", "claude"]);
960
+ const clientTimestampSchema = z.string().datetime({ offset: true });
961
+ const clientOriginSchema = z.string().transform((value, ctx) => {
962
+ const origin = canonicalHttpOrigin(value);
963
+ if (!origin) {
964
+ ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
965
+ return z.NEVER;
966
+ }
967
+ return origin;
968
+ });
969
+ const clientConnectionSchema = z.object({
970
+ serverUrl: clientOriginSchema,
971
+ managementUrl: clientOriginSchema,
972
+ managementTransport: z.enum(["direct", "relay"]),
973
+ selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => {
974
+ if (new Set(clients).size !== clients.length) {
975
+ ctx.addIssue({ code: "custom", message: "must contain unique client ids" });
976
+ }
977
+ }),
978
+ tokenEnv: z.literal("OPENCODEX_API_AUTH_TOKEN"),
979
+ apiKeyId: z.string().trim().min(1).max(256),
980
+ tokenFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
981
+ protocolVersion: z.literal(1),
982
+ connectedAt: clientTimestampSchema,
983
+ catalogFingerprint: z.string().min(1).max(512).optional(),
984
+ // base64 of the pre-connect catalog, or "" for "there was none". Bounded above the
985
+ // catalog size cap so a legitimate snapshot round-trips.
986
+ priorCatalog: z.string().max(64 * 1024 * 1024).optional(),
987
+ catalogSyncedAt: clientTimestampSchema.optional(),
988
+ pendingOperation: z.object({
989
+ kind: z.literal("rotate"),
990
+ rotationId: z.string().trim().min(1).max(256),
991
+ newKeyIssuedAt: clientTimestampSchema,
992
+ oldKeyBackupPath: z.string().min(1),
993
+ }).strict().superRefine((operation, ctx) => {
994
+ const expected = join(getConfigDir(), "service-api-token.prev");
995
+ if (operation.oldKeyBackupPath !== expected) {
996
+ ctx.addIssue({ code: "custom", path: ["oldKeyBackupPath"], message: `must equal ${expected}` });
997
+ }
998
+ }).optional(),
999
+ }).strict();
1000
+
864
1001
  const configSchema = z.object({
865
1002
  port: z.number().int().min(0).max(65535).default(10100),
1003
+ // A malformed hand edit must disable only remote-role behavior, not discard
1004
+ // providers or data-plane keys. Live writes are rejected explicitly below.
1005
+ runtimeRole: runtimeRoleSchema.optional().catch(undefined),
1006
+ // Malformed optional remote blocks disable only remote GUI behavior. Live
1007
+ // candidates are rejected explicitly by remoteGuiConfigError below.
1008
+ hub: hubConfigSchema.optional().catch(undefined),
1009
+ remoteGui: remoteGuiConfigSchema.optional().catch(undefined),
1010
+ // A malformed present client block must remain diagnosable from raw config and
1011
+ // fail closed through src/client/state.ts; unrelated provider state still loads.
1012
+ client: clientConnectionSchema.optional().catch(undefined),
866
1013
  managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
867
1014
  // Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
868
1015
  upstreamHostCircuitThreshold: z.number().int()
@@ -870,6 +1017,12 @@ const configSchema = z.object({
870
1017
  .max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD)
871
1018
  .optional()
872
1019
  .catch(undefined),
1020
+ // Opt-in outbound body ceiling. An invalid hand edit disables only this guard, matching the
1021
+ // circuit threshold above: a malformed number must not make the proxy refuse traffic.
1022
+ maxUpstreamBodyBytes: z.number().int()
1023
+ .min(0)
1024
+ .optional()
1025
+ .catch(undefined),
873
1026
  appOwnedMemoryBudgetMb: z.number().int()
874
1027
  .min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
875
1028
  .max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
@@ -924,6 +1077,7 @@ const configSchema = z.object({
924
1077
  z.array(z.string().trim().min(1)).min(1),
925
1078
  ).optional().catch(undefined),
926
1079
  codexShimAutoRestore: z.boolean().optional(),
1080
+ codexDesktopAuthless: z.boolean().optional().catch(undefined),
927
1081
  pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
928
1082
  codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
929
1083
  // Selection order is a preference, not a safety control like pause: a malformed
@@ -938,6 +1092,10 @@ const configSchema = z.object({
938
1092
  // Same degrade-not-reject rule: a malformed hand edit hides Spark rather than discarding the
939
1093
  // whole config. Hidden is also the default, so `catch(false)` and the default agree.
940
1094
  showCodexSparkQuota: z.boolean().optional().catch(false),
1095
+ resetCreditAutoRedeem: z.object({
1096
+ enabled: z.boolean().optional(),
1097
+ leadTimeMinutes: z.number().int().min(1).max(60).optional(),
1098
+ }).optional().catch(undefined),
941
1099
  // Model ids excluded from the Grok Build managed block (dashboard switches).
942
1100
  grokExcludedModels: z.array(z.string()).optional(),
943
1101
  // Invalid values degrade to undefined ("auto") instead of failing the whole
@@ -1109,6 +1267,16 @@ const configSchema = z.object({
1109
1267
  message: modelCostsError,
1110
1268
  });
1111
1269
  }
1270
+ const modelDisplayNamesError = modelDisplayNamesConfigError(
1271
+ (provider as { modelDisplayNames?: unknown }).modelDisplayNames,
1272
+ );
1273
+ if (modelDisplayNamesError) {
1274
+ ctx.addIssue({
1275
+ code: "custom",
1276
+ path: ["providers", redactSecretString(name), "modelDisplayNames"],
1277
+ message: modelDisplayNamesError,
1278
+ });
1279
+ }
1112
1280
  const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig);
1113
1281
  if (apiKeyTransportError) {
1114
1282
  ctx.addIssue({
@@ -1242,6 +1410,28 @@ const configSchema = z.object({
1242
1410
  message: structuredOutputOptOutError,
1243
1411
  });
1244
1412
  }
1413
+ const retainModelsError = nonBlankStringArrayConfigError(
1414
+ (provider as { retainModels?: unknown }).retainModels,
1415
+ "retainModels",
1416
+ );
1417
+ if (retainModelsError) {
1418
+ ctx.addIssue({
1419
+ code: "custom",
1420
+ path: ["providers", redactSecretString(name), "retainModels"],
1421
+ message: retainModelsError,
1422
+ });
1423
+ }
1424
+ const toolReasoningOptOutError = nonBlankStringArrayConfigError(
1425
+ (provider as { omitReasoningEffortWithToolsModels?: unknown }).omitReasoningEffortWithToolsModels,
1426
+ "omitReasoningEffortWithToolsModels",
1427
+ );
1428
+ if (toolReasoningOptOutError) {
1429
+ ctx.addIssue({
1430
+ code: "custom",
1431
+ path: ["providers", redactSecretString(name), "omitReasoningEffortWithToolsModels"],
1432
+ message: toolReasoningOptOutError,
1433
+ });
1434
+ }
1245
1435
  if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) {
1246
1436
  // Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider.
1247
1437
  // Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them.
@@ -1706,6 +1896,47 @@ function warnDegradedAgentTaskRecovery(rawParsed: unknown): void {
1706
1896
  if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
1707
1897
  }
1708
1898
 
1899
+ function malformedRuntimeRoleWarning(rawParsed: unknown): string | null {
1900
+ const raw = rawConfigRecord(rawParsed);
1901
+ if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null;
1902
+ if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null;
1903
+ return 'runtimeRole ignored: expected "standalone", "hub", or "client"; falling back to "standalone"';
1904
+ }
1905
+
1906
+ function warnDegradedRuntimeRole(rawParsed: unknown): void {
1907
+ const warning = malformedRuntimeRoleWarning(rawParsed);
1908
+ if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
1909
+ }
1910
+
1911
+ function malformedOptionalRemoteBlockWarning(
1912
+ rawParsed: unknown,
1913
+ key: "hub" | "remoteGui",
1914
+ ): string | null {
1915
+ const raw = rawConfigRecord(rawParsed);
1916
+ if (!raw || !Object.hasOwn(raw, key) || raw[key] === undefined) return null;
1917
+ const schema = key === "hub" ? hubConfigSchema : remoteGuiConfigSchema;
1918
+ const result = schema.safeParse(raw[key]);
1919
+ if (result.success) return null;
1920
+ const field = result.error.issues[0]?.path.join(".");
1921
+ return `${key}${field ? `.${field}` : ""} ignored: invalid remote GUI configuration`;
1922
+ }
1923
+
1924
+ function malformedClientConnectionWarning(rawParsed: unknown): string | null {
1925
+ const raw = rawConfigRecord(rawParsed);
1926
+ if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null;
1927
+ const result = clientConnectionSchema.safeParse(raw.client);
1928
+ if (result.success) return null;
1929
+ const field = result.error.issues[0]?.path.join(".");
1930
+ return `client${field ? `.${field}` : ""} invalid: remote client mode is disabled until config.json is repaired`;
1931
+ }
1932
+
1933
+ function warnDegradedOptionalRemoteBlocks(rawParsed: unknown): void {
1934
+ for (const key of ["hub", "remoteGui"] as const) {
1935
+ const warning = malformedOptionalRemoteBlockWarning(rawParsed, key);
1936
+ if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
1937
+ }
1938
+ }
1939
+
1709
1940
  type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
1710
1941
 
1711
1942
  function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
@@ -1844,6 +2075,7 @@ export function loadConfig(): OcxConfig {
1844
2075
  const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
1845
2076
  const parsed = JSON.parse(raw);
1846
2077
  sanitizeAliasesForLoad(parsed);
2078
+ sanitizeModelDisplayNamesForLoad(parsed);
1847
2079
  sanitizeRetryOn429ForLoad(parsed);
1848
2080
  sanitizeModelCostsForLoad(parsed);
1849
2081
  const result = configSchema.safeParse(parsed);
@@ -1859,6 +2091,8 @@ export function loadConfig(): OcxConfig {
1859
2091
  warnDegradedCodexAccountPicker(parsed);
1860
2092
  warnDegradedUpstreamHostCircuitThreshold(parsed);
1861
2093
  warnDegradedAgentTaskRecovery(parsed);
2094
+ warnDegradedRuntimeRole(parsed);
2095
+ warnDegradedOptionalRemoteBlocks(parsed);
1862
2096
  return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
1863
2097
  }
1864
2098
  // Schema validation failed — merge defaults into the raw object instead of
@@ -1883,6 +2117,8 @@ export function loadConfig(): OcxConfig {
1883
2117
  warnDegradedCodexAccountPicker(parsed);
1884
2118
  warnDegradedUpstreamHostCircuitThreshold(parsed);
1885
2119
  warnDegradedAgentTaskRecovery(parsed);
2120
+ warnDegradedRuntimeRole(parsed);
2121
+ warnDegradedOptionalRemoteBlocks(parsed);
1886
2122
  return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
1887
2123
  }
1888
2124
  // Still failing, but if every complaint is about one or more named entries
@@ -1903,6 +2139,8 @@ export function loadConfig(): OcxConfig {
1903
2139
  warnDegradedCodexAccountPicker(parsed);
1904
2140
  warnDegradedUpstreamHostCircuitThreshold(parsed);
1905
2141
  warnDegradedAgentTaskRecovery(parsed);
2142
+ warnDegradedRuntimeRole(parsed);
2143
+ warnDegradedOptionalRemoteBlocks(parsed);
1906
2144
  return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
1907
2145
  }
1908
2146
  }
@@ -1952,6 +2190,38 @@ function sanitizeAliasesForLoad(raw: unknown): void {
1952
2190
  }
1953
2191
  }
1954
2192
 
2193
+ /** Hand-edited display-name mistakes disable only the bad label. */
2194
+ function sanitizeModelDisplayNamesForLoad(raw: unknown): void {
2195
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
2196
+ const root = raw as Record<string, unknown>;
2197
+ if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return;
2198
+ for (const [providerName, providerValue] of Object.entries(root.providers as Record<string, unknown>)) {
2199
+ if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue;
2200
+ const provider = providerValue as Record<string, unknown>;
2201
+ const value = provider.modelDisplayNames;
2202
+ if (value === undefined) continue;
2203
+ const providerLabel = JSON.stringify(redactSecretString(providerName));
2204
+ if (!value || typeof value !== "object" || Array.isArray(value)
2205
+ || Object.entries(value).length > MODEL_DISCOVERY_MAX_MODELS) {
2206
+ console.warn(`Ignoring invalid modelDisplayNames map for provider ${providerLabel} in config.json`);
2207
+ delete provider.modelDisplayNames;
2208
+ continue;
2209
+ }
2210
+ const labels = value as Record<string, unknown>;
2211
+ for (const [modelId, rawDisplayName] of Object.entries(labels)) {
2212
+ const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : rawDisplayName;
2213
+ if (modelDisplayNamesConfigError({ [modelId]: displayName })) {
2214
+ const safeModelId = JSON.stringify(redactSecretString(modelId));
2215
+ console.warn(`Ignoring invalid modelDisplayNames entry ${safeModelId} for provider ${providerLabel} in config.json`);
2216
+ delete labels[modelId];
2217
+ } else {
2218
+ labels[modelId] = displayName;
2219
+ }
2220
+ }
2221
+ if (Object.keys(labels).length === 0) delete provider.modelDisplayNames;
2222
+ }
2223
+ }
2224
+
1955
2225
  /** Refresh the user cost-overlay registry from `config` and return it unchanged. */
1956
2226
  function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
1957
2227
  refreshUserCostOverlays(config);
@@ -2003,6 +2273,14 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
2003
2273
  if (hostCircuitWarning) warnings.push(hostCircuitWarning);
2004
2274
  const recoveryWarning = malformedAgentTaskRecoveryWarning(rawParsed);
2005
2275
  if (recoveryWarning) warnings.push(recoveryWarning);
2276
+ const runtimeRoleWarning = malformedRuntimeRoleWarning(rawParsed);
2277
+ if (runtimeRoleWarning) warnings.push(runtimeRoleWarning);
2278
+ const hubWarning = malformedOptionalRemoteBlockWarning(rawParsed, "hub");
2279
+ if (hubWarning) warnings.push(hubWarning);
2280
+ const remoteGuiWarning = malformedOptionalRemoteBlockWarning(rawParsed, "remoteGui");
2281
+ if (remoteGuiWarning) warnings.push(remoteGuiWarning);
2282
+ const clientWarning = malformedClientConnectionWarning(rawParsed);
2283
+ if (clientWarning) warnings.push(clientWarning);
2006
2284
  if (syncDisabledReason) {
2007
2285
  warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
2008
2286
  }
@@ -2094,6 +2372,53 @@ function agentTaskRecoveryError(value: unknown): string | null {
2094
2372
  return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
2095
2373
  }
2096
2374
 
2375
+ function runtimeRoleError(value: unknown): string | null {
2376
+ const raw = rawConfigRecord(value);
2377
+ if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null;
2378
+ if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null;
2379
+ return 'schema_invalid: runtimeRole: must be one of "standalone", "hub", or "client"';
2380
+ }
2381
+
2382
+ function remoteGuiConfigError(value: unknown): string | null {
2383
+ const raw = rawConfigRecord(value);
2384
+ if (!raw) return null;
2385
+ for (const [key, schema] of [
2386
+ ["hub", hubConfigSchema],
2387
+ ["remoteGui", remoteGuiConfigSchema],
2388
+ ] as const) {
2389
+ if (!Object.hasOwn(raw, key) || raw[key] === undefined) continue;
2390
+ const result = schema.safeParse(raw[key]);
2391
+ if (result.success) continue;
2392
+ const issue = result.error.issues[0];
2393
+ const field = issue?.path.join(".");
2394
+ return `schema_invalid: ${key}${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
2395
+ }
2396
+ return null;
2397
+ }
2398
+
2399
+ function clientConnectionConfigError(value: unknown): string | null {
2400
+ const raw = rawConfigRecord(value);
2401
+ if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null;
2402
+ const result = clientConnectionSchema.safeParse(raw.client);
2403
+ if (result.success) return null;
2404
+ const issue = result.error.issues[0];
2405
+ const field = issue?.path.join(".");
2406
+ return `schema_invalid: client${field ? `.${field}` : ""}: ${issue?.message ?? "invalid client connection"}`;
2407
+ }
2408
+
2409
+ function clientRolePairError(value: unknown): string | null {
2410
+ const raw = rawConfigRecord(value);
2411
+ if (!raw) return null;
2412
+ const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined;
2413
+ if (raw.runtimeRole === "client" && !hasClient) {
2414
+ return "schema_invalid: runtimeRole client requires a complete client connection";
2415
+ }
2416
+ if (hasClient && raw.runtimeRole !== "client") {
2417
+ return "schema_invalid: client connection requires runtimeRole client";
2418
+ }
2419
+ return null;
2420
+ }
2421
+
2097
2422
  /**
2098
2423
  * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
2099
2424
  * malformed selection-order map to undefined, which on a write would drop every entry the
@@ -2200,6 +2525,52 @@ function loopbackListenerPortError(value: unknown): string | null {
2200
2525
  return null;
2201
2526
  }
2202
2527
 
2528
+ /**
2529
+ * Validate the hub management ingress at the live-write boundary.
2530
+ *
2531
+ * The persisted schema intentionally degrades a malformed hand edit to disabled so a typo in
2532
+ * this opt-in listener cannot discard providers or credentials. A live config mutation must not
2533
+ * get that leniency: it receives an exact field error before the degrading schema is applied.
2534
+ */
2535
+ function managementIngressConfigError(value: unknown): string | null {
2536
+ const raw = rawConfigRecord(value);
2537
+ if (!raw) return null;
2538
+ const hub = rawConfigRecord(raw.hub);
2539
+ if (!hub || !Object.hasOwn(hub, "managementIngress") || hub.managementIngress === undefined) return null;
2540
+ const ingress = rawConfigRecord(hub.managementIngress);
2541
+ if (!ingress) {
2542
+ return "schema_invalid: hub.managementIngress: must be an object or omitted";
2543
+ }
2544
+ if (typeof ingress.enabled !== "boolean") {
2545
+ return "schema_invalid: hub.managementIngress.enabled: must be a boolean";
2546
+ }
2547
+ const keys = Object.keys(ingress);
2548
+ if (ingress.enabled === false) {
2549
+ return keys.length === 1
2550
+ ? null
2551
+ : "schema_invalid: hub.managementIngress: disabled ingress accepts only enabled";
2552
+ }
2553
+ if (keys.some(key => key !== "enabled" && key !== "port")) {
2554
+ return "schema_invalid: hub.managementIngress: contains an unsupported field";
2555
+ }
2556
+ const ingressPort = ingress.port;
2557
+ if (typeof ingressPort !== "number" || !Number.isInteger(ingressPort) || ingressPort < 1 || ingressPort > 65535) {
2558
+ return "schema_invalid: hub.managementIngress.port: must be an integer port when enabled";
2559
+ }
2560
+ if (raw.runtimeRole !== "hub") {
2561
+ return "schema_invalid: hub.managementIngress: enabled ingress requires runtimeRole hub";
2562
+ }
2563
+ const proxyPort = typeof raw.port === "number" ? raw.port : 10100;
2564
+ if (proxyPort === ingressPort) {
2565
+ return "schema_invalid: hub.managementIngress.port: must differ from the proxy port";
2566
+ }
2567
+ const loopback = rawConfigRecord(raw.unauthenticatedLoopbackListener);
2568
+ if (loopback?.enabled === true && loopback.port === ingressPort) {
2569
+ return "schema_invalid: hub.managementIngress.port: must differ from unauthenticatedLoopbackListener.port";
2570
+ }
2571
+ return null;
2572
+ }
2573
+
2203
2574
  export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
2204
2575
  const boundaryError = blankHostnameError(value)
2205
2576
  ?? claudeSubagentEffortError(value)
@@ -2211,7 +2582,12 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
2211
2582
  ?? codexAccountPickerEnabledError(value)
2212
2583
  ?? emptyCompletionRetryError(value)
2213
2584
  ?? oauthOpenBrowserError(value)
2214
- ?? loopbackListenerPortError(value);
2585
+ ?? runtimeRoleError(value)
2586
+ ?? remoteGuiConfigError(value)
2587
+ ?? clientConnectionConfigError(value)
2588
+ ?? clientRolePairError(value)
2589
+ ?? loopbackListenerPortError(value)
2590
+ ?? managementIngressConfigError(value);
2215
2591
  if (boundaryError) return { ok: false, error: boundaryError };
2216
2592
  const result = configSchema.safeParse(value);
2217
2593
  if (result.success) {
@@ -2227,6 +2603,7 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
2227
2603
  // Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
2228
2604
  // schema and send the caller a default-config fallback (the config command could then
2229
2605
  // persist that fallback over the user's providers/keys).
2606
+ sanitizeModelDisplayNamesForLoad(parsed);
2230
2607
  sanitizeRetryOn429ForLoad(parsed);
2231
2608
  sanitizeModelCostsForLoad(parsed);
2232
2609
  const result = configSchema.safeParse(parsed);
@@ -2563,6 +2940,9 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync
2563
2940
  */
2564
2941
  function persistConfigUnlocked(config: OcxConfig): boolean {
2565
2942
  const configPath = getConfigPath();
2943
+ const rawBeforeWrite = readRawConfigJson();
2944
+ const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config);
2945
+ if (clientPersistenceError) throw new Error(clientPersistenceError);
2566
2946
  // External editors can add provider rows the live config deliberately does
2567
2947
  // not route with yet; merge them at the serialization boundary so an
2568
2948
  // unrelated in-process save cannot erase the provider or its overlay.
@@ -2690,7 +3070,7 @@ export function mutatePersistedConfig<T>(
2690
3070
 
2691
3071
  const projected = projectCustomModelCatalogMigration(
2692
3072
  commitBase.diagnostics.config,
2693
- confirmedConfig,
3073
+ projectConfigRebaseProvenance(confirmedConfig),
2694
3074
  );
2695
3075
  if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
2696
3076
  return { status: "committed", value: confirmed.value };
@@ -2699,6 +3079,31 @@ export function mutatePersistedConfig<T>(
2699
3079
  });
2700
3080
  }
2701
3081
 
3082
+ function failClosedClientPersistenceError(
3083
+ raw: Record<string, unknown> | undefined,
3084
+ candidate: OcxConfig,
3085
+ ): string | null {
3086
+ if (!raw) return null;
3087
+ const rawHasClient = Object.hasOwn(raw, "client") && raw.client !== undefined;
3088
+ const rawRole = raw.runtimeRole;
3089
+ const rawRoleValid = rawRole === undefined
3090
+ || rawRole === "standalone"
3091
+ || rawRole === "hub"
3092
+ || rawRole === "client";
3093
+ const rawClientValid = !rawHasClient || clientConnectionSchema.safeParse(raw.client).success;
3094
+ const rawPairValid = rawRoleValid
3095
+ && ((rawRole === "client" && rawHasClient && rawClientValid)
3096
+ || (rawRole !== "client" && !rawHasClient));
3097
+ if (rawPairValid) return null;
3098
+
3099
+ const candidateValid = candidate.runtimeRole === "client"
3100
+ && clientConnectionSchema.safeParse(candidate.client).success;
3101
+ const deletions = configRebaseDeletionKeys(candidate);
3102
+ const explicitClear = deletions.has("client") && deletions.has("runtimeRole");
3103
+ if (candidateValid || explicitClear) return null;
3104
+ return "config write refused: malformed or mismatched remote client state must be repaired or explicitly cleared";
3105
+ }
3106
+
2702
3107
  export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
2703
3108
  return config.websockets === true;
2704
3109
  }
@@ -3123,6 +3528,10 @@ export function multiAgentGuidanceEnabled(
3123
3528
  return config.multiAgentGuidanceEnabled !== false;
3124
3529
  }
3125
3530
 
3531
+ export function runtimeRole(config: Pick<OcxConfig, "runtimeRole">): OcxRuntimeRole {
3532
+ return config.runtimeRole ?? "standalone";
3533
+ }
3534
+
3126
3535
  export function getDefaultConfig(): OcxConfig {
3127
3536
  // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
3128
3537
  // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
@@ -3189,6 +3598,14 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements
3189
3598
  * that makes outbound provider requests (server start, catalog sync).
3190
3599
  */
3191
3600
  export function applyProxyEnv(config: OcxConfig): void {
3601
+ applyProxyEnvWith(config);
3602
+ }
3603
+
3604
+ /** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */
3605
+ export function applyProxyEnvWith(
3606
+ config: OcxConfig,
3607
+ auto: { reader?: WindowsProxyRegistryReader; platform?: NodeJS.Platform } = {},
3608
+ ): void {
3192
3609
  // `proxy` and `noProxy` are not declared in the top-level schema, which ends in
3193
3610
  // `.passthrough()`, so whatever is on disk arrives here verbatim. A non-string value
3194
3611
  // reached string-only methods and threw out of this function, and it runs once per
@@ -3196,13 +3613,40 @@ export function applyProxyEnv(config: OcxConfig): void {
3196
3613
  // malformed values with a privacy-safe warning instead: they cannot express a routing
3197
3614
  // intent, and refusing to start is a worse answer than starting without them.
3198
3615
  const rawProxy = config.proxy;
3199
- const proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined;
3616
+ let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined;
3200
3617
  if (!proxy) {
3201
3618
  if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy");
3202
3619
  return;
3203
3620
  }
3621
+ if (proxy.trim().toLowerCase() === "auto") {
3622
+ // #1525 slice 1: one startup read of the Windows static proxy. Never copy the literal
3623
+ // "auto" into HTTP_PROXY; every non-proxy outcome leaves outbound routing as it was.
3624
+ if (process.env.HTTP_PROXY?.trim() || process.env.http_proxy?.trim()
3625
+ || process.env.HTTPS_PROXY?.trim() || process.env.https_proxy?.trim()) {
3626
+ console.log("[opencodex] proxy \"auto\": existing HTTP_PROXY/HTTPS_PROXY environment wins; system proxy not consulted");
3627
+ proxy = undefined;
3628
+ } else {
3629
+ const found = readWindowsSystemProxy(auto.reader, auto.platform);
3630
+ if (found.kind === "proxy") {
3631
+ console.log(`[opencodex] proxy "auto": using Windows system proxy ${describeProxyForLog(found.url)}`);
3632
+ proxy = found.url;
3633
+ } else {
3634
+ const reason = found.kind === "unsupported"
3635
+ ? "only Windows system proxy discovery is supported; using direct egress on this OS"
3636
+ : found.kind === "disabled"
3637
+ ? "Windows system proxy is disabled; using direct egress"
3638
+ : found.kind === "socks-only"
3639
+ ? "Windows system proxy is SOCKS-only, which HTTP_PROXY cannot express; using direct egress"
3640
+ : "Windows proxy settings could not be read; using direct egress";
3641
+ console.log(`[opencodex] proxy "auto": ${reason}`);
3642
+ proxy = undefined;
3643
+ }
3644
+ }
3645
+ }
3646
+ if (proxy) {
3204
3647
  if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
3205
3648
  if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
3649
+ }
3206
3650
  const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
3207
3651
  const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
3208
3652
  const seen = new Set(entries.map(e => e.toLowerCase()));