@yansigit/opencodex 2.32.0 → 2.33.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 (108) hide show
  1. package/README.md +2 -2
  2. package/gui/dist/assets/index-DKLr4LTE.js +102 -0
  3. package/gui/dist/assets/index-DrSQdTRd.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +6 -5
  6. package/src/adapters/anthropic.ts +20 -6
  7. package/src/adapters/azure.ts +20 -4
  8. package/src/adapters/base.ts +3 -1
  9. package/src/adapters/command-code.ts +40 -7
  10. package/src/adapters/cursor/live-transport.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +158 -7
  12. package/src/adapters/cursor/protobuf-request.ts +33 -15
  13. package/src/adapters/cursor/request-builder.ts +4 -3
  14. package/src/adapters/cursor/tool-definitions.ts +27 -1
  15. package/src/adapters/cursor/types.ts +4 -3
  16. package/src/adapters/cursor.ts +9 -0
  17. package/src/adapters/google-antigravity-replay.ts +2 -2
  18. package/src/adapters/google-antigravity-wire.ts +7 -0
  19. package/src/adapters/google-errors.ts +6 -2
  20. package/src/adapters/google-http.ts +30 -7
  21. package/src/adapters/google-truncation.ts +5 -0
  22. package/src/adapters/google-wire-compiler.ts +38 -6
  23. package/src/adapters/google.ts +148 -26
  24. package/src/adapters/kiro-tools.ts +20 -9
  25. package/src/adapters/openai-chat.ts +9 -0
  26. package/src/adapters/openai-responses.ts +1 -1
  27. package/src/bridge.ts +112 -9
  28. package/src/claude/context-windows.ts +16 -9
  29. package/src/cli/doctor.ts +2 -2
  30. package/src/cli/index.ts +9 -2
  31. package/src/cli/provider.ts +6 -0
  32. package/src/cli/status.ts +23 -0
  33. package/src/codex/auth-api.ts +4 -2
  34. package/src/codex/autostart-health.ts +16 -0
  35. package/src/codex/catalog/aggregation.ts +12 -12
  36. package/src/codex/catalog/effort.ts +18 -3
  37. package/src/codex/catalog/metadata.ts +27 -1
  38. package/src/codex/catalog/model-metadata.ts +39 -12
  39. package/src/codex/catalog/parsing.ts +38 -27
  40. package/src/codex/catalog/provider-fetch.ts +198 -133
  41. package/src/codex/catalog/sync.ts +1 -1
  42. package/src/codex/convergence.ts +5 -0
  43. package/src/codex/shim.ts +56 -3
  44. package/src/config/provider-validation.ts +37 -0
  45. package/src/config.ts +78 -2
  46. package/src/generated/compatibility-version.json +120 -96
  47. package/src/images/loop.ts +37 -6
  48. package/src/lib/azure-identity.ts +154 -0
  49. package/src/lib/debug.ts +42 -0
  50. package/src/lib/errors.ts +14 -0
  51. package/src/lib/provider-outbound.ts +45 -33
  52. package/src/lib/provider-tls-profile.ts +309 -0
  53. package/src/lib/proxy-env.ts +49 -0
  54. package/src/lib/redact.ts +10 -1
  55. package/src/oauth/antigravity-routing.ts +282 -236
  56. package/src/oauth/callback-server.ts +22 -2
  57. package/src/oauth/command-code.ts +5 -16
  58. package/src/oauth/google-antigravity.ts +42 -5
  59. package/src/oauth/index.ts +15 -3
  60. package/src/oauth/kimi.ts +9 -1
  61. package/src/oauth/open-browser-choice.ts +26 -0
  62. package/src/oauth/store.ts +6 -0
  63. package/src/providers/antigravity-quota.ts +3 -1
  64. package/src/providers/api-keys.ts +2 -1
  65. package/src/providers/auto-compact-budget.ts +65 -0
  66. package/src/providers/derive.ts +4 -0
  67. package/src/providers/key-failover.ts +5 -1
  68. package/src/providers/openai-tiers.ts +5 -0
  69. package/src/providers/provider-id-rewrite.ts +1 -0
  70. package/src/providers/quota.ts +59 -13
  71. package/src/providers/registry.ts +3 -1
  72. package/src/providers/request-pacing.ts +33 -6
  73. package/src/providers/xai-transport.ts +21 -0
  74. package/src/responses/google-provider-options.ts +36 -0
  75. package/src/responses/namespace-tool-compat.ts +84 -4
  76. package/src/responses/parser.ts +11 -0
  77. package/src/responses/provider-opaque-metadata.ts +3 -3
  78. package/src/responses/schema.ts +37 -0
  79. package/src/responses/state.ts +94 -4
  80. package/src/router.ts +8 -2
  81. package/src/server/auth-cors.ts +28 -0
  82. package/src/server/images.ts +19 -35
  83. package/src/server/management/agent-settings-routes.ts +205 -15
  84. package/src/server/management/combo-routes.ts +6 -0
  85. package/src/server/management/config-routes.ts +31 -5
  86. package/src/server/management/model-rows.ts +4 -0
  87. package/src/server/management/oauth-account-routes.ts +25 -4
  88. package/src/server/management/provider-routes.ts +113 -15
  89. package/src/server/management/routing-profile-routes.ts +3 -0
  90. package/src/server/request-log.ts +21 -0
  91. package/src/server/responses/agent-task-recovery.ts +1 -1
  92. package/src/server/responses/compact.ts +30 -1
  93. package/src/server/responses/core.ts +359 -153
  94. package/src/server/responses/empty-completion-guard.ts +35 -6
  95. package/src/server/responses/fetch-helpers.ts +18 -5
  96. package/src/server/responses/v2-native-parent-override.ts +59 -0
  97. package/src/server/responses/ws-upstream.ts +75 -2
  98. package/src/server/responses-undeclared-tool-guard.ts +90 -8
  99. package/src/service.ts +1 -1
  100. package/src/types/config.ts +16 -1
  101. package/src/types/provider.ts +16 -0
  102. package/src/types/request.ts +28 -0
  103. package/src/types/tools.ts +27 -0
  104. package/src/types.ts +6 -0
  105. package/src/web-search/gemini-executor.ts +6 -4
  106. package/src/web-search/loop.ts +42 -6
  107. package/gui/dist/assets/index-BG43zwVe.js +0 -102
  108. package/gui/dist/assets/index-CiSI-jrP.css +0 -1
package/src/codex/shim.ts CHANGED
@@ -603,6 +603,47 @@ function backupPathFor(path: string): string {
603
603
  return ext ? `${path.slice(0, -ext.length)}.opencodex-real${ext}` : `${path}.opencodex-real`;
604
604
  }
605
605
 
606
+ /**
607
+ * True when a Codex binary lives inside a version manager's install tree.
608
+ *
609
+ * These trees are rewritten in place on upgrade, which destroys both the shim
610
+ * and the sibling .opencodex-real backup it restores from (#2412). The tempting
611
+ * repair — adopt the newly installed binary as a fresh original — is wrong
612
+ * twice: it records a provenance that never happened, and the next upgrade wipes
613
+ * it again, so the repair silently un-repairs on the version manager's schedule.
614
+ *
615
+ * Scope is the three managers named in the report. nvm/fnm/npm-prefix are
616
+ * deliberately excluded: a false positive here refuses a restore that would
617
+ * otherwise be correct.
618
+ */
619
+ export function isVersionManagerOwnedCodexPath(path: string): boolean {
620
+ const normalized = path.replace(/\\/g, "/").toLowerCase();
621
+ return normalized.includes("/mise/installs/")
622
+ || normalized.includes("/mise/shims/")
623
+ || normalized.includes("/.asdf/installs/")
624
+ || normalized.includes("/.asdf/shims/")
625
+ || normalized.includes("/.volta/");
626
+ }
627
+
628
+ /**
629
+ * Why auto-restore refused, in the operator's own terms. Auto-restore used to
630
+ * return a bare `{ status: "ineligible" }`, and the CLI warns only when a
631
+ * message is present, so `ocx start`, `ocx ensure`, and `ocx service repair` all
632
+ * reported success while routing quietly stayed native (#2412, the cause behind
633
+ * the misleading green status in #2411).
634
+ */
635
+ function destroyedShimMessage(file: ShimFileState): string {
636
+ const wrapper = existsSync(file.wrapperPath)
637
+ ? isShim(file.wrapperPath) ? "present but unusable" : "present but not an opencodex shim"
638
+ : "missing";
639
+ const backup = existsSync(file.backupPath) ? "present" : "missing";
640
+ const base = `Codex autostart shim not restored: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`;
641
+ if (!isVersionManagerOwnedCodexPath(file.wrapperPath)) {
642
+ return `${base} Re-run 'ocx codex-shim install' once the Codex binary is stable.`;
643
+ }
644
+ return `${base} This Codex binary is owned by a version manager (mise/asdf/volta), so opencodex will not wrap it as a new original — the next upgrade would overwrite the shim and its backup again. Route through Codex instead with 'ocx start', and use 'ocx service install' for autostart.`;
645
+ }
646
+
606
647
  function shQuote(value: string): string {
607
648
  return `'${value.replace(/'/g, "'\\''")}'`;
608
649
  }
@@ -2039,14 +2080,20 @@ export function autoRestoreCodexShim(options: {
2039
2080
  if (seen.has(file.wrapperPath)) return { status: "ineligible" };
2040
2081
  seen.add(file.wrapperPath);
2041
2082
  if (file.preserveOnly) {
2042
- if (!existsSync(file.backupPath) || existsSync(file.originalPath)) return { status: "ineligible" };
2083
+ if (!existsSync(file.backupPath) || existsSync(file.originalPath)) {
2084
+ return { status: "ineligible", message: destroyedShimMessage(file) };
2085
+ }
2043
2086
  continue;
2044
2087
  }
2045
- if (!existsSync(file.wrapperPath) || !hasUsableBackingPath(file)) return { status: "ineligible" };
2088
+ if (!existsSync(file.wrapperPath) || !hasUsableBackingPath(file)) {
2089
+ return { status: "ineligible", message: destroyedShimMessage(file) };
2090
+ }
2046
2091
  const probe = stableShimPathProbe(file.wrapperPath);
2047
2092
  if (!probe) return { status: "deferred" };
2048
2093
  if (probe.prefix.includes(SHIM_MARKER)) {
2049
- if (!isHealthyShimProbe(probe, state.platform)) return { status: "ineligible" };
2094
+ if (!isHealthyShimProbe(probe, state.platform)) {
2095
+ return { status: "ineligible", message: destroyedShimMessage(file) };
2096
+ }
2050
2097
  if (state.platform !== "win32" && !isCurrentUnixShimProbe(probe)) {
2051
2098
  obsoleteShimProbes.set(file.wrapperPath, probe);
2052
2099
  continue;
@@ -2054,6 +2101,12 @@ export function autoRestoreCodexShim(options: {
2054
2101
  healthyCount += 1;
2055
2102
  continue;
2056
2103
  }
2104
+ // A surviving backup would otherwise let the replacement path below wrap the
2105
+ // version manager's NEW binary as a fresh original — the same adoption the
2106
+ // missing-backup case refuses, arriving through the back door.
2107
+ if (isVersionManagerOwnedCodexPath(file.wrapperPath)) {
2108
+ return { status: "ineligible", message: destroyedShimMessage(file) };
2109
+ }
2057
2110
  replacementProbes.set(file.wrapperPath, probe);
2058
2111
  }
2059
2112
 
@@ -11,6 +11,7 @@ import {
11
11
  const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
12
12
  const SENSITIVE_PROVIDER_HEADERS = new Set([
13
13
  "authorization",
14
+ "api-key",
14
15
  "cookie",
15
16
  "set-cookie",
16
17
  "proxy-authorization",
@@ -19,6 +20,42 @@ const SENSITIVE_PROVIDER_HEADERS = new Set([
19
20
  "x-amz-security-token",
20
21
  ]);
21
22
  const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);
23
+ const AZURE_ADAPTERS = new Set(["azure", "azure-openai"]);
24
+
25
+ export function isAzureIdentityProvider(provider: Pick<OcxProviderConfig, "adapter" | "azureCredential">): boolean {
26
+ return AZURE_ADAPTERS.has(provider.adapter) && provider.azureCredential?.type === "default-azure-credential";
27
+ }
28
+
29
+ /** Shared semantic boundary for Azure's exact keyless identity mode. */
30
+ export function azureCredentialConfigError(provider: {
31
+ adapter?: unknown;
32
+ azureCredential?: unknown;
33
+ apiKey?: unknown;
34
+ apiKeyPool?: unknown;
35
+ authMode?: unknown;
36
+ }): string | null {
37
+ const credential = provider.azureCredential;
38
+ if (credential === undefined) return null;
39
+ if (!AZURE_ADAPTERS.has(provider.adapter as string)) return "azureCredential is supported only by azure adapters";
40
+ if (!credential || typeof credential !== "object" || Array.isArray(credential)) {
41
+ return "azureCredential must be an object";
42
+ }
43
+ const record = credential as Record<string, unknown>;
44
+ for (const key of Object.keys(record)) {
45
+ if (key !== "type" && key !== "managedIdentityClientId") return "azureCredential has an unrecognized field";
46
+ }
47
+ if (record.type !== "default-azure-credential") return 'azureCredential.type must be "default-azure-credential"';
48
+ if (Object.hasOwn(record, "managedIdentityClientId")
49
+ && (typeof record.managedIdentityClientId !== "string" || !record.managedIdentityClientId.trim())) {
50
+ return "azureCredential.managedIdentityClientId must be a non-empty string";
51
+ }
52
+ if (Object.hasOwn(provider, "apiKey")) return "azureCredential conflicts with apiKey";
53
+ if (Object.hasOwn(provider, "apiKeyPool")) return "azureCredential conflicts with apiKeyPool";
54
+ if (provider.authMode !== undefined && provider.authMode !== "key") {
55
+ return "azureCredential requires authMode key or omitted";
56
+ }
57
+ return null;
58
+ }
22
59
 
23
60
  /** Validate a provider destination without coupling DTO callers to config persistence. */
24
61
  export function providerBaseUrlConfigError(baseUrl: string): string | null {
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@ import * as z from "zod/v4";
6
6
  import { isValidProviderName, hasOwnProvider } from "./config/provider-name";
7
7
  import {
8
8
  apiKeyTransportConfigError,
9
+ azureCredentialConfigError,
9
10
  booleanRecordConfigError,
10
11
  modelAdapterRecordConfigError,
11
12
  nonBlankStringArrayConfigError,
@@ -59,6 +60,7 @@ import { recordOwnedConfigPath } from "./lib/config-ownership";
59
60
  import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
60
61
  import { providerDestinationConfigError } from "./lib/destination-policy";
61
62
  import { redactSecretString } from "./lib/redact";
63
+ import { antigravityOAuthDestinationConfigError, providerTlsProfileConfigError } from "./lib/provider-tls-profile";
62
64
  import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
63
65
  import {
64
66
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
@@ -73,6 +75,7 @@ import {
73
75
  type ProviderCostOverlay,
74
76
  } from "./types";
75
77
  import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
78
+ import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget";
76
79
  import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
77
80
  import {
78
81
  getProviderRegistryEntry,
@@ -439,18 +442,21 @@ const requestPacingRuleSchema = z.object({
439
442
  // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs.
440
443
  requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
441
444
  minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
442
- }).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, {
443
- message: "request pacing rules need requestsPerMinute or minIntervalMs",
445
+ jitterMs: z.number().int().min(0).max(60_000).optional(),
446
+ }).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined || value.jitterMs !== undefined, {
447
+ message: "request pacing rules need requestsPerMinute, minIntervalMs, or jitterMs",
444
448
  });
445
449
 
446
450
  const requestPacingSchema = z.object({
447
451
  enabled: z.boolean(),
448
452
  requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
449
453
  minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
454
+ jitterMs: z.number().int().min(0).max(60_000).optional(),
450
455
  models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(),
451
456
  }).strict().refine(value => value.enabled === false
452
457
  || value.requestsPerMinute !== undefined
453
458
  || value.minIntervalMs !== undefined
459
+ || value.jitterMs !== undefined
454
460
  || (value.models !== undefined && Object.keys(value.models).length > 0), {
455
461
  message: "enabled request pacing needs a provider rule or model override",
456
462
  });
@@ -479,7 +485,14 @@ const fastWireSchema = z.object({
479
485
  const providerConfigSchema = z.object({
480
486
  adapter: z.string().min(1),
481
487
  baseUrl: z.string().min(1),
488
+ azureCredential: z.object({
489
+ type: z.literal("default-azure-credential"),
490
+ managedIdentityClientId: z.string().trim().min(1).optional(),
491
+ }).strict().transform(value => value.managedIdentityClientId === undefined
492
+ ? value
493
+ : { ...value, managedIdentityClientId: value.managedIdentityClientId.trim() }).optional(),
482
494
  requestPacing: requestPacingSchema.optional().catch(undefined),
495
+ tlsProfile: z.literal("antigravity-browser").optional(),
483
496
  mcpMaxTools: z.number().int().positive().optional(),
484
497
  mcpMaxSchemaBytes: z.number().int().positive().optional(),
485
498
  mcpMaxResultBytes: z.number().int().positive().optional(),
@@ -524,6 +537,8 @@ const providerConfigSchema = z.object({
524
537
  export { isValidProviderName, hasOwnProvider } from "./config/provider-name";
525
538
  export {
526
539
  apiKeyTransportConfigError,
540
+ azureCredentialConfigError,
541
+ isAzureIdentityProvider,
527
542
  booleanRecordConfigError,
528
543
  modelAdapterRecordConfigError,
529
544
  nonBlankStringArrayConfigError,
@@ -836,6 +851,11 @@ const agentTaskRecoverySchema = z.object({
836
851
  cacheEntries: z.number().int().min(1).max(512).optional(),
837
852
  }).strict();
838
853
 
854
+ const v2NativeParentOverrideSchema = z.object({
855
+ enabled: z.boolean().optional(),
856
+ model: z.string().trim().min(1).optional(),
857
+ }).strict();
858
+
839
859
  const configSchema = z.object({
840
860
  port: z.number().int().min(0).max(65535).default(10100),
841
861
  managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
@@ -869,6 +889,9 @@ const configSchema = z.object({
869
889
  defaultProvider: z.string().min(1).default("openai"),
870
890
  // A retry can be billable, so absence and malformed hand edits both stay off.
871
891
  emptyCompletionRetry: z.boolean().optional().catch(false),
892
+ // A malformed hand edit must not silently stop opening the browser: fall back
893
+ // to undefined, which resolves to the historical auto-open behavior.
894
+ oauthOpenBrowser: z.boolean().optional().catch(undefined),
872
895
  openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
873
896
  // Invalid hand edits must not discard an otherwise usable config.
874
897
  googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined),
@@ -876,6 +899,8 @@ const configSchema = z.object({
876
899
  providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
877
900
  contextCapValue: z.number().int().positive().optional(),
878
901
  multiAgentGuidanceEnabled: z.boolean().optional(),
902
+ // Invalid hand edits disable only this experimental opt-in subtree.
903
+ v2NativeParentOverride: v2NativeParentOverrideSchema.optional().catch(undefined),
879
904
  // Invalid optional recovery config must not discard unrelated provider/account state.
880
905
  agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined),
881
906
  // These selections pre-date schema validation and used to pass through as
@@ -1044,6 +1069,14 @@ const configSchema = z.object({
1044
1069
  message: responsesPathError,
1045
1070
  });
1046
1071
  }
1072
+ const tlsProfileError = providerTlsProfileConfigError(name, provider);
1073
+ if (tlsProfileError) {
1074
+ ctx.addIssue({
1075
+ code: "custom",
1076
+ path: ["providers", redactSecretString(name), "tlsProfile"],
1077
+ message: tlsProfileError,
1078
+ });
1079
+ }
1047
1080
  const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers);
1048
1081
  if (headersError) {
1049
1082
  ctx.addIssue({
@@ -1070,6 +1103,14 @@ const configSchema = z.object({
1070
1103
  message: apiKeyTransportError,
1071
1104
  });
1072
1105
  }
1106
+ const azureCredentialError = azureCredentialConfigError(provider);
1107
+ if (azureCredentialError) {
1108
+ ctx.addIssue({
1109
+ code: "custom",
1110
+ path: ["providers", redactSecretString(name), "azureCredential"],
1111
+ message: azureCredentialError,
1112
+ });
1113
+ }
1073
1114
  const modelAdaptersError = modelAdapterRecordConfigError(
1074
1115
  (provider as { modelAdapters?: unknown }).modelAdapters,
1075
1116
  "modelAdapters",
@@ -1107,6 +1148,17 @@ const configSchema = z.object({
1107
1148
  message: maxInputError,
1108
1149
  });
1109
1150
  }
1151
+ const autoCompactError = modelAutoCompactTokenLimitsConfigError(
1152
+ (provider as { modelAutoCompactTokenLimits?: unknown }).modelAutoCompactTokenLimits,
1153
+ { requireNativeIds: name === OPENAI_CODEX_PROVIDER_ID },
1154
+ );
1155
+ if (autoCompactError) {
1156
+ ctx.addIssue({
1157
+ code: "custom",
1158
+ path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"],
1159
+ message: autoCompactError,
1160
+ });
1161
+ }
1110
1162
  const reasoningSummariesError = booleanRecordConfigError(
1111
1163
  (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
1112
1164
  "modelSupportsReasoningSummaries",
@@ -2043,6 +2095,16 @@ function agentTaskRecoveryError(value: unknown): string | null {
2043
2095
  return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
2044
2096
  }
2045
2097
 
2098
+ function v2NativeParentOverrideError(value: unknown): string | null {
2099
+ const raw = rawConfigRecord(value);
2100
+ if (!raw || !Object.hasOwn(raw, "v2NativeParentOverride") || raw.v2NativeParentOverride === undefined) return null;
2101
+ const result = v2NativeParentOverrideSchema.safeParse(raw.v2NativeParentOverride);
2102
+ if (result.success) return null;
2103
+ const issue = result.error.issues[0];
2104
+ const field = issue?.path.join(".");
2105
+ return `schema_invalid: v2NativeParentOverride${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
2106
+ }
2107
+
2046
2108
  /**
2047
2109
  * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
2048
2110
  * malformed selection-order map to undefined, which on a write would drop every entry the
@@ -2102,6 +2164,14 @@ function emptyCompletionRetryError(value: unknown): string | null {
2102
2164
  return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted";
2103
2165
  }
2104
2166
 
2167
+ function oauthOpenBrowserError(value: unknown): string | null {
2168
+ const raw = rawConfigRecord(value);
2169
+ if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null;
2170
+ const enabled = raw.oauthOpenBrowser;
2171
+ if (enabled === undefined || typeof enabled === "boolean") return null;
2172
+ return "schema_invalid: oauthOpenBrowser: must be a boolean or omitted";
2173
+ }
2174
+
2105
2175
  /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
2106
2176
  /**
2107
2177
  * Reject a loopback-listener port that collides with the proxy port (#1102).
@@ -2148,15 +2218,21 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
2148
2218
  ?? appOwnedMemoryBudgetError(value)
2149
2219
  ?? upstreamHostCircuitThresholdError(value)
2150
2220
  ?? agentTaskRecoveryError(value)
2221
+ ?? v2NativeParentOverrideError(value)
2151
2222
  ?? googleAntigravityStaticCatalogVersionError(value)
2152
2223
  ?? codexAccountPrioritiesError(value)
2153
2224
  ?? codexAccountPickerEnabledError(value)
2154
2225
  ?? emptyCompletionRetryError(value)
2226
+ ?? oauthOpenBrowserError(value)
2155
2227
  ?? loopbackListenerPortError(value);
2156
2228
  if (boundaryError) return { ok: false, error: boundaryError };
2157
2229
  const result = configSchema.safeParse(value);
2158
2230
  if (result.success) {
2159
2231
  const config = normalizeApiKeyIds(result.data as OcxConfig);
2232
+ for (const [name, provider] of Object.entries(config.providers)) {
2233
+ const antigravityError = antigravityOAuthDestinationConfigError(name, provider);
2234
+ if (antigravityError) return { ok: false, error: `providers.${name}.baseUrl: ${antigravityError}` };
2235
+ }
2160
2236
  if (value && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, "subagentRoles")) {
2161
2237
  const parsedRoles = parseSubagentRoles((value as { subagentRoles?: unknown }).subagentRoles);
2162
2238
  if (!parsedRoles.ok) return { ok: false, error: `schema_invalid: ${parsedRoles.error}` };