@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
package/src/config.ts CHANGED
@@ -3,8 +3,16 @@ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import * as z from "zod/v4";
6
- import { comboConfigIssues } from "./combos/types";
7
- import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
6
+ import {
7
+ CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
8
+ codexAccountNamespaceForModel,
9
+ codexProviderNamespaceKey,
10
+ isValidCodexAccountNamespaceTarget,
11
+ MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
12
+ } from "./codex/account-namespace-match";
13
+ import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
14
+ import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/windows-secret-acl";
15
+ import { recordOwnedConfigPath } from "./lib/config-ownership";
8
16
  import { providerDestinationConfigError } from "./lib/destination-policy";
9
17
  import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
10
18
  import {
@@ -12,12 +20,13 @@ import {
12
20
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
13
21
  OPENAI_PROVIDER_TIER_VERSION,
14
22
  REASONING_SUMMARY_DELIVERY_VALUES,
23
+ type OcxClaudeCodeConfig,
15
24
  type OcxConfig,
16
25
  type OcxProviderConfig,
17
26
  } from "./types";
18
- import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers";
27
+ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
19
28
  import { parseDesktopProfile } from "./claude/desktop-profile";
20
- import { modelRecordValue } from "./reasoning-effort";
29
+ import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
21
30
 
22
31
  let _atomicSeq = 0;
23
32
 
@@ -90,6 +99,7 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
90
99
  truncate: target => truncateSync(target, 0),
91
100
  unlink: unlinkSync,
92
101
  }): void {
102
+ recordOwnedConfigPath(resolveConfigDir(), path);
93
103
  const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
94
104
  let hardened = false;
95
105
  try {
@@ -128,6 +138,90 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
128
138
  }
129
139
  }
130
140
 
141
+ /** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */
142
+ export interface AtomicWriteAsyncIO {
143
+ write: (path: string, content: string) => void | Promise<void>;
144
+ harden: (path: string) => void | Promise<void>;
145
+ rename: (source: string, destination: string) => void | Promise<void>;
146
+ truncate: (path: string) => void | Promise<void>;
147
+ unlink: (path: string) => void | Promise<void>;
148
+ }
149
+
150
+ async function renameAtomicFileAsync(source: string, destination: string): Promise<void> {
151
+ for (let attempt = 0; ; attempt += 1) {
152
+ try {
153
+ renameSync(source, destination);
154
+ return;
155
+ } catch (error) {
156
+ const code = (error as NodeJS.ErrnoException).code;
157
+ const transientWindowsError = process.platform === "win32"
158
+ && (code === "EBUSY" || code === "EPERM" || code === "EACCES");
159
+ if (!transientWindowsError || attempt >= 2) throw error;
160
+ await Bun.sleep(25 * (attempt + 1));
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Async atomic write (#612): same temp+harden+rename and residual-temp policy as
167
+ * atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed
168
+ * by the final destination path (not the unique temp, not the parent directory).
169
+ */
170
+ export async function atomicWriteFileAsync(
171
+ path: string,
172
+ content: string,
173
+ io?: AtomicWriteAsyncIO,
174
+ ): Promise<void> {
175
+ const effective: AtomicWriteAsyncIO = io ?? {
176
+ write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
177
+ harden: async target => {
178
+ try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
179
+ if (process.platform === "win32") {
180
+ await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path });
181
+ }
182
+ },
183
+ rename: renameAtomicFileAsync,
184
+ truncate: target => truncateSync(target, 0),
185
+ unlink: unlinkSync,
186
+ };
187
+ const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
188
+ let hardened = false;
189
+ try {
190
+ await effective.write(tmp, content);
191
+ await effective.harden(tmp);
192
+ hardened = true;
193
+ await effective.rename(tmp, path);
194
+ } catch (cause) {
195
+ let scrubbed = false;
196
+ try {
197
+ await effective.truncate(tmp);
198
+ scrubbed = true;
199
+ } catch (error) {
200
+ if (isMissingPathError(error)) scrubbed = true;
201
+ else {
202
+ try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
203
+ }
204
+ }
205
+ let removed = false;
206
+ try {
207
+ await effective.unlink(tmp);
208
+ removed = true;
209
+ } catch (error) {
210
+ if (isMissingPathError(error)) removed = true;
211
+ else {
212
+ try { await effective.unlink(tmp); removed = true; }
213
+ catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
214
+ }
215
+ }
216
+ if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
217
+ if (!removed && !hardened) {
218
+ try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
219
+ }
220
+ if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
221
+ throw cause;
222
+ }
223
+ }
224
+
131
225
  export class OpenAiTierBackupCleanupError extends Error {
132
226
  constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
133
227
  }
@@ -339,6 +433,7 @@ const warnedConfigFallbacks = new Set<string>();
339
433
  const providerConfigSchema = z.object({
340
434
  adapter: z.string().min(1),
341
435
  baseUrl: z.string().min(1),
436
+ apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
342
437
  responsesPath: z.string().min(1).optional(),
343
438
  allowPrivateNetwork: z.boolean().optional(),
344
439
  codexAccountMode: z.enum(["pool", "direct"]).optional(),
@@ -410,6 +505,23 @@ export function providerHeadersConfigError(headers: unknown): string | null {
410
505
  return null;
411
506
  }
412
507
 
508
+ /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
509
+ export function apiKeyTransportConfigError(
510
+ provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
511
+ ): string | null {
512
+ if (provider.apiKeyTransport === undefined) return null;
513
+ if (provider.apiKeyTransport !== "x-api-key" && provider.apiKeyTransport !== "bearer") {
514
+ return 'apiKeyTransport must be "x-api-key" or "bearer"';
515
+ }
516
+ if (provider.adapter !== "anthropic") {
517
+ return "apiKeyTransport is supported only by the anthropic adapter";
518
+ }
519
+ if (provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") {
520
+ return "apiKeyTransport requires Anthropic API-key authentication";
521
+ }
522
+ return null;
523
+ }
524
+
413
525
  export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
414
526
  if (value === undefined) return null;
415
527
  if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
@@ -507,15 +619,77 @@ export function modelAdapterRecordConfigError(
507
619
  return null;
508
620
  }
509
621
 
622
+ const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR =
623
+ "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids";
624
+ const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR =
625
+ "account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys";
626
+ const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR =
627
+ "account selector targets must be @main or valid Codex pool-account ids";
628
+ const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR =
629
+ "account selectors must not collide with configured Codex pool-account ids or account selector targets";
630
+
631
+ function configuredCodexPoolAccountIds(value: unknown): Set<string> {
632
+ const accountIds = new Set<string>();
633
+ if (!Array.isArray(value)) return accountIds;
634
+ for (const account of value) {
635
+ if (!account || typeof account !== "object" || Array.isArray(account)) continue;
636
+ const { id, isMain } = account as { id?: unknown; isMain?: unknown };
637
+ if (typeof id === "string" && isMain !== true) accountIds.add(id);
638
+ }
639
+ return accountIds;
640
+ }
641
+
642
+ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
643
+ (value): value is Record<string, unknown> => !!value
644
+ && typeof value === "object"
645
+ && !Array.isArray(value)
646
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
647
+ { error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR },
648
+ ).superRefine((accountNamespaces, ctx) => {
649
+ // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
650
+ for (const [namespace, accountId] of Object.entries(accountNamespaces)) {
651
+ if (!isValidProviderName(namespace)) {
652
+ ctx.addIssue({
653
+ code: "custom",
654
+ path: [namespace],
655
+ message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR,
656
+ });
657
+ }
658
+ if (!isValidCodexAccountNamespaceTarget(accountId)) {
659
+ ctx.addIssue({
660
+ code: "custom",
661
+ path: [namespace],
662
+ message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR,
663
+ });
664
+ }
665
+ }
666
+ }).pipe(z.record(z.string(), z.string()));
667
+
510
668
  const configSchema = z.object({
511
669
  port: z.number().int().min(0).max(65535).default(10100),
670
+ // A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()`
671
+ // carries no `hostname` key, so the backup-and-defaults repair path below cannot merge one
672
+ // away — a hand-edited `"hostname": ""` would fail twice and reset providers/apiKeys to
673
+ // defaults, which is strictly worse than the bind bug this validation exists for. Degrading
674
+ // is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time
675
+ // rejection lives in validateConfigCandidate() so bad values still surface to the caller.
676
+ hostname: z.string().trim().min(1).optional().catch(undefined),
512
677
  providers: z.record(z.string(), providerConfigSchema),
513
678
  defaultProvider: z.string().min(1).default("openai"),
514
679
  openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
515
680
  providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
516
681
  contextCapValue: z.number().int().positive().optional(),
517
682
  multiAgentGuidanceEnabled: z.boolean().optional(),
683
+ // These selections pre-date schema validation and used to pass through as
684
+ // unknown fields. Invalid hand edits must disable only the optional
685
+ // delegation/native-default feature, not reject the whole config and hide
686
+ // otherwise valid providers, accounts, or the configured listen port.
687
+ injectionModel: z.string().optional().catch(undefined),
688
+ injectionEffort: z.string().optional().catch(undefined),
689
+ syncCodexSubagentDefaults: z.boolean().optional().catch(undefined),
518
690
  codexShimAutoRestore: z.boolean().optional(),
691
+ pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
692
+ codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
519
693
  // Model ids excluded from the Grok Build managed block (dashboard switches).
520
694
  grokExcludedModels: z.array(z.string()).optional(),
521
695
  // Invalid values degrade to undefined ("auto") instead of failing the whole
@@ -526,15 +700,48 @@ const configSchema = z.object({
526
700
  const claudeCode = (config as { claudeCode?: unknown }).claudeCode;
527
701
  if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) {
528
702
  ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" });
529
- } else if (claudeCode && "desktopProfile" in claudeCode && (claudeCode as { desktopProfile?: unknown }).desktopProfile !== undefined) {
530
- try {
531
- parseDesktopProfile((claudeCode as { desktopProfile?: unknown }).desktopProfile);
532
- } catch (error) {
533
- ctx.addIssue({
534
- code: "custom",
535
- path: ["claudeCode", "desktopProfile"],
536
- message: error instanceof Error ? error.message : String(error),
537
- });
703
+ } else if (claudeCode) {
704
+ const claude = claudeCode as { desktopProfile?: unknown };
705
+ if (claude.desktopProfile !== undefined) {
706
+ try {
707
+ parseDesktopProfile(claude.desktopProfile);
708
+ } catch (error) {
709
+ ctx.addIssue({
710
+ code: "custom",
711
+ path: ["claudeCode", "desktopProfile"],
712
+ message: error instanceof Error ? error.message : String(error),
713
+ });
714
+ }
715
+ }
716
+ }
717
+
718
+ const accountNamespaces = config.codexAccountNamespaces;
719
+ if (accountNamespaces) {
720
+ const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts);
721
+ const configuredProviderNamespaces = new Set([
722
+ COMBO_NAMESPACE,
723
+ OPENAI_CODEX_PROVIDER_ID,
724
+ ...Object.keys(config.providers),
725
+ ].map(codexProviderNamespaceKey));
726
+ const namespaceTargets = new Set(
727
+ Object.values(accountNamespaces)
728
+ .filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET),
729
+ );
730
+ for (const namespace of Object.keys(accountNamespaces)) {
731
+ if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) {
732
+ ctx.addIssue({
733
+ code: "custom",
734
+ path: ["codexAccountNamespaces", namespace],
735
+ message: "account selectors must not collide with configured provider or combo namespaces",
736
+ });
737
+ }
738
+ if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) {
739
+ ctx.addIssue({
740
+ code: "custom",
741
+ path: ["codexAccountNamespaces", namespace],
742
+ message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR,
743
+ });
744
+ }
538
745
  }
539
746
  }
540
747
  for (const name of Object.keys(config.providers)) {
@@ -600,6 +807,14 @@ const configSchema = z.object({
600
807
  message: headersError,
601
808
  });
602
809
  }
810
+ const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig);
811
+ if (apiKeyTransportError) {
812
+ ctx.addIssue({
813
+ code: "custom",
814
+ path: ["providers", name, "apiKeyTransport"],
815
+ message: apiKeyTransportError,
816
+ });
817
+ }
603
818
  const modelAdaptersError = modelAdapterRecordConfigError(
604
819
  (provider as { modelAdapters?: unknown }).modelAdapters,
605
820
  "modelAdapters",
@@ -698,6 +913,16 @@ const configSchema = z.object({
698
913
  ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" });
699
914
  } else {
700
915
  for (const [id, raw] of Object.entries(combos as Record<string, unknown>)) {
916
+ const alias = raw && typeof raw === "object" && !Array.isArray(raw)
917
+ ? (raw as { alias?: unknown }).alias
918
+ : undefined;
919
+ if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) {
920
+ ctx.addIssue({
921
+ code: "custom",
922
+ path: ["combos", id, "alias"],
923
+ message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
924
+ });
925
+ }
701
926
  // Pass the full map so cross-combo rules (alias uniqueness) apply at load time
702
927
  // too, not just via the management API; each combo is excluded from its own check.
703
928
  for (const issue of comboConfigIssues(id, raw, config.providers, {
@@ -773,6 +998,114 @@ function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void
773
998
  }
774
999
  }
775
1000
 
1001
+ /**
1002
+ * Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind
1003
+ * falls back to loopback, which is the safe direction but not what the file asked for —
1004
+ * say so once instead of silently ignoring the field.
1005
+ */
1006
+ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
1007
+ if (!rawParsed || typeof rawParsed !== "object") return;
1008
+ const raw = (rawParsed as Record<string, unknown>).hostname;
1009
+ if (raw !== undefined && validated.hostname === undefined) {
1010
+ console.warn(`⚠️ config.json hostname ${JSON.stringify(raw)} is not a usable bind address — falling back to 127.0.0.1`);
1011
+ }
1012
+ }
1013
+
1014
+ const CLAUDE_SUBAGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
1015
+
1016
+ function isClaudeSubagentEffort(value: unknown): value is NonNullable<OcxClaudeCodeConfig["subagentEffort"]> {
1017
+ return typeof value === "string" && CLAUDE_SUBAGENT_EFFORTS.includes(value as typeof CLAUDE_SUBAGENT_EFFORTS[number]);
1018
+ }
1019
+
1020
+ function rawClaudeSubagentEffort(rawParsed: unknown): unknown {
1021
+ const raw = rawConfigRecord(rawParsed);
1022
+ const claudeCode = raw?.claudeCode;
1023
+ if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) return undefined;
1024
+ return (claudeCode as Record<string, unknown>).subagentEffort;
1025
+ }
1026
+
1027
+ function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCode"] {
1028
+ if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) {
1029
+ return claudeCode as OcxConfig["claudeCode"];
1030
+ }
1031
+ const normalized = { ...claudeCode } as Record<string, unknown>;
1032
+ if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
1033
+ delete normalized.subagentEffort;
1034
+ }
1035
+ return normalized as OcxConfig["claudeCode"];
1036
+ }
1037
+
1038
+ function normalizeClaudeSubagentEffort(config: OcxConfig, rawParsed: unknown): OcxConfig {
1039
+ const rawEffort = rawClaudeSubagentEffort(rawParsed);
1040
+ if (rawEffort === undefined || isClaudeSubagentEffort(rawEffort)) return config;
1041
+ return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) };
1042
+ }
1043
+
1044
+ function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
1045
+ const rawEffort = rawClaudeSubagentEffort(rawParsed);
1046
+ if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
1047
+ console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
1048
+ }
1049
+ }
1050
+
1051
+ type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
1052
+
1053
+ function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
1054
+ return rawParsed !== null && typeof rawParsed === "object" && !Array.isArray(rawParsed)
1055
+ ? rawParsed as Record<string, unknown>
1056
+ : null;
1057
+ }
1058
+
1059
+ function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] {
1060
+ const raw = rawConfigRecord(rawParsed);
1061
+ if (!raw) return [];
1062
+ const malformed: NativeSubagentPersistedField[] = [];
1063
+ if (Object.hasOwn(raw, "injectionModel") && typeof raw.injectionModel !== "string") {
1064
+ malformed.push("injectionModel");
1065
+ }
1066
+ if (Object.hasOwn(raw, "injectionEffort") && typeof raw.injectionEffort !== "string") {
1067
+ malformed.push("injectionEffort");
1068
+ }
1069
+ if (Object.hasOwn(raw, "syncCodexSubagentDefaults") && typeof raw.syncCodexSubagentDefaults !== "boolean") {
1070
+ malformed.push("syncCodexSubagentDefaults");
1071
+ }
1072
+ return malformed;
1073
+ }
1074
+
1075
+ function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField): string {
1076
+ const expected = field === "syncCodexSubagentDefaults" ? "a boolean" : "a string";
1077
+ return `${field} ignored: expected ${expected}`;
1078
+ }
1079
+
1080
+ function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null {
1081
+ if (config.syncCodexSubagentDefaults !== true) return null;
1082
+ const malformed = malformedNativeSubagentFields(rawParsed);
1083
+ if (malformed.includes("injectionModel")) return "injectionModel must be a string";
1084
+ if (!config.injectionModel?.trim()) return "a nonblank injectionModel is required";
1085
+ if (malformed.includes("injectionEffort")) return "injectionEffort must be a string or omitted";
1086
+ if (config.injectionEffort !== undefined && !isCodexReasoningEffort(config.injectionEffort)) {
1087
+ return "injectionEffort must be a supported Codex reasoning effort";
1088
+ }
1089
+ return null;
1090
+ }
1091
+
1092
+ function normalizeNativeSubagentSync(config: OcxConfig, rawParsed?: unknown): OcxConfig {
1093
+ if (!nativeSubagentSyncDisabledReason(config, rawParsed)) return config;
1094
+ const normalized = { ...config };
1095
+ delete normalized.syncCodexSubagentDefaults;
1096
+ return normalized;
1097
+ }
1098
+
1099
+ function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig): void {
1100
+ for (const field of malformedNativeSubagentFields(rawParsed)) {
1101
+ console.warn(`⚠️ config.json ${malformedNativeSubagentFieldWarning(field)}. Other settings were preserved.`);
1102
+ }
1103
+ const reason = nativeSubagentSyncDisabledReason(config, rawParsed);
1104
+ if (reason) {
1105
+ console.warn(`⚠️ config.json syncCodexSubagentDefaults was disabled: ${reason}. Other settings were preserved.`);
1106
+ }
1107
+ }
1108
+
776
1109
  export function loadConfig(): OcxConfig {
777
1110
  const dir = getConfigDir();
778
1111
  const configPath = getConfigPath();
@@ -787,8 +1120,12 @@ export function loadConfig(): OcxConfig {
787
1120
  const parsed = JSON.parse(raw);
788
1121
  const result = configSchema.safeParse(parsed);
789
1122
  if (result.success) {
790
- warnDegradedStreamMode(parsed, result.data as OcxConfig);
791
- return result.data as OcxConfig;
1123
+ const config = result.data as OcxConfig;
1124
+ warnDegradedStreamMode(parsed, config);
1125
+ warnDegradedHostname(parsed, config);
1126
+ warnDegradedClaudeSubagentEffort(parsed);
1127
+ warnDegradedNativeSubagentConfig(parsed, config);
1128
+ return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
792
1129
  }
793
1130
  // Schema validation failed — merge defaults into the raw object instead of
794
1131
  // discarding it entirely, so pool accounts and providers survive a missing
@@ -802,7 +1139,11 @@ export function loadConfig(): OcxConfig {
802
1139
  const retryResult = configSchema.safeParse(merged);
803
1140
  if (retryResult.success) {
804
1141
  warnConfigRepaired(configPath, result.error);
805
- return retryResult.data as OcxConfig;
1142
+ const config = retryResult.data as OcxConfig;
1143
+ warnDegradedHostname(parsed, config);
1144
+ warnDegradedClaudeSubagentEffort(parsed);
1145
+ warnDegradedNativeSubagentConfig(parsed, config);
1146
+ return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
806
1147
  }
807
1148
  // Merge couldn't fix it — truly broken config
808
1149
  warnAndBackupInvalidConfig(configPath, result.error);
@@ -832,16 +1173,35 @@ function configPlaceholderWarnings(config: OcxConfig): string[] {
832
1173
  return warnings;
833
1174
  }
834
1175
 
835
- function validFileConfigDiagnostics(config: OcxConfig): ConfigDiagnostics {
836
- const warnings = configPlaceholderWarnings(config);
1176
+ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): ConfigDiagnostics {
1177
+ // Unsafe hand-edited optional values are disabled in memory instead of rejecting
1178
+ // the entire config, which would hide unrelated providers/accounts. The next
1179
+ // ordinary save persists the normalized absence.
1180
+ const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed);
1181
+ const rawEffort = rawClaudeSubagentEffort(rawParsed);
1182
+ const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
1183
+ const warnings = configPlaceholderWarnings(normalized);
1184
+ if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
1185
+ warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
1186
+ }
1187
+ warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning));
1188
+ if (syncDisabledReason) {
1189
+ warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
1190
+ }
837
1191
  return {
838
- config,
1192
+ config: normalized,
839
1193
  source: "file",
840
1194
  error: null,
841
1195
  ...(warnings.length > 0 ? { warnings } : {}),
842
1196
  };
843
1197
  }
844
1198
 
1199
+ export function subagentDefaultSyncEffective(
1200
+ config: Pick<OcxConfig, "syncCodexSubagentDefaults" | "injectionModel">,
1201
+ ): boolean {
1202
+ return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim());
1203
+ }
1204
+
845
1205
  function mergeConfigDefaults(parsed: unknown): unknown {
846
1206
  if (!parsed || typeof parsed !== "object") return parsed;
847
1207
  const defaults = getDefaultConfig();
@@ -861,8 +1221,32 @@ function schemaDiagnosticsError(error: z.ZodError): string {
861
1221
  return details.length > 0 ? `schema_invalid: ${details.join("; ")}` : "schema_invalid";
862
1222
  }
863
1223
 
1224
+ /**
1225
+ * Reject a hostname the schema deliberately degrades on read. Load-time has to keep a
1226
+ * blank value non-fatal (see the `hostname` field comment), but an incoming write is a
1227
+ * live caller who can be told the value is wrong — silently rewriting it to loopback
1228
+ * would look like the bind succeeded on the address they asked for.
1229
+ */
1230
+ function blankHostnameError(value: unknown): string | null {
1231
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1232
+ const hostname = (value as Record<string, unknown>).hostname;
1233
+ if (hostname === undefined) return null;
1234
+ if (typeof hostname !== "string" || !hostname.trim()) {
1235
+ return "schema_invalid: hostname: must be a nonblank bind address";
1236
+ }
1237
+ return null;
1238
+ }
1239
+
1240
+ function claudeSubagentEffortError(value: unknown): string | null {
1241
+ const effort = rawClaudeSubagentEffort(value);
1242
+ if (effort === undefined || isClaudeSubagentEffort(effort)) return null;
1243
+ return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`;
1244
+ }
1245
+
864
1246
  /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
865
1247
  export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
1248
+ const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value);
1249
+ if (boundaryError) return { ok: false, error: boundaryError };
866
1250
  const result = configSchema.safeParse(value);
867
1251
  if (result.success) return { ok: true, config: result.data as OcxConfig };
868
1252
  return { ok: false, error: schemaDiagnosticsError(result.error) };
@@ -878,12 +1262,12 @@ export function readConfigDiagnostics(): ConfigDiagnostics {
878
1262
  const parsed = JSON.parse(raw);
879
1263
  const result = configSchema.safeParse(parsed);
880
1264
  if (result.success) {
881
- return validFileConfigDiagnostics(result.data as OcxConfig);
1265
+ return validFileConfigDiagnostics(result.data as OcxConfig, parsed);
882
1266
  }
883
1267
 
884
1268
  const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed));
885
1269
  if (retryResult.success) {
886
- return validFileConfigDiagnostics(retryResult.data as OcxConfig);
1270
+ return validFileConfigDiagnostics(retryResult.data as OcxConfig, parsed);
887
1271
  }
888
1272
 
889
1273
  return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
@@ -928,6 +1312,16 @@ export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolea
928
1312
  */
929
1313
  const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
930
1314
 
1315
+ /**
1316
+ * The live config retains the address of the socket Bun actually opened, while
1317
+ * this map retains the operator's desired address for the next process start.
1318
+ * Keeping them separate prevents an unrelated live save from restoring a stale
1319
+ * externally exposed bind after OAuth adopted a newer loopback disk config.
1320
+ */
1321
+ type PersistedServerBinding = Pick<OcxConfig, "port" | "hostname">;
1322
+
1323
+ const persistedLiveServerBinding = new WeakMap<OcxConfig, PersistedServerBinding>();
1324
+
931
1325
  /**
932
1326
  * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on
933
1327
  * first save — arming lazily would lose exactly the hand edit made before that first
@@ -964,6 +1358,116 @@ function deepEqual(a: unknown, b: unknown): boolean {
964
1358
  return true;
965
1359
  }
966
1360
 
1361
+ const MISSING_CONFIG_VALUE = Symbol("missing-config-value");
1362
+ type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE;
1363
+
1364
+ function isPlainConfigRecord(value: ConfigMergeValue): value is Record<string, unknown> {
1365
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1366
+ const prototype = Object.getPrototypeOf(value);
1367
+ return prototype === Object.prototype || prototype === null;
1368
+ }
1369
+
1370
+ function ownConfigValue(record: Record<string, unknown>, key: string): ConfigMergeValue {
1371
+ return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE;
1372
+ }
1373
+
1374
+ function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue {
1375
+ return value === MISSING_CONFIG_VALUE ? value : structuredClone(value);
1376
+ }
1377
+
1378
+ function reconcileConfigRecord(
1379
+ live: Record<string, unknown>,
1380
+ baseline: Record<string, unknown>,
1381
+ persisted: Record<string, unknown>,
1382
+ skippedKeys?: ReadonlySet<string>,
1383
+ ): void {
1384
+ const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]);
1385
+ for (const key of keys) {
1386
+ if (skippedKeys?.has(key)) continue;
1387
+ const merged = reconcileConfigValue(
1388
+ ownConfigValue(baseline, key),
1389
+ ownConfigValue(live, key),
1390
+ ownConfigValue(persisted, key),
1391
+ );
1392
+ if (merged === MISSING_CONFIG_VALUE) delete live[key];
1393
+ else live[key] = merged;
1394
+ }
1395
+ }
1396
+
1397
+ function reconcileConfigValue(
1398
+ baseline: ConfigMergeValue,
1399
+ live: ConfigMergeValue,
1400
+ persisted: ConfigMergeValue,
1401
+ ): ConfigMergeValue {
1402
+ const liveChanged = !deepEqual(live, baseline);
1403
+ const persistedChanged = !deepEqual(persisted, baseline);
1404
+
1405
+ if (!liveChanged) {
1406
+ if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) {
1407
+ live.splice(0, live.length, ...structuredClone(persisted));
1408
+ return live;
1409
+ }
1410
+ if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) {
1411
+ reconcileConfigRecord(
1412
+ live,
1413
+ isPlainConfigRecord(baseline) ? baseline : {},
1414
+ persisted,
1415
+ );
1416
+ return live;
1417
+ }
1418
+ return cloneConfigValue(persisted);
1419
+ }
1420
+
1421
+ if (!persistedChanged) return live;
1422
+
1423
+ if (isPlainConfigRecord(live)
1424
+ && isPlainConfigRecord(persisted)
1425
+ && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) {
1426
+ reconcileConfigRecord(
1427
+ live,
1428
+ isPlainConfigRecord(baseline) ? baseline : {},
1429
+ persisted,
1430
+ );
1431
+ }
1432
+ // Same-leaf conflicts prefer the pending live management mutation.
1433
+ return live;
1434
+ }
1435
+
1436
+ /**
1437
+ * Reconcile an async OAuth disk commit into the shared live config without erasing
1438
+ * management mutations that have not saved yet. The baseline is a normalized disk
1439
+ * snapshot from immediately before login; disjoint object edits merge recursively,
1440
+ * while same-leaf conflicts prefer live state.
1441
+ */
1442
+ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void {
1443
+ const diagnostics = readConfigDiagnostics();
1444
+ if (diagnostics.source === "fallback") {
1445
+ throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`);
1446
+ }
1447
+ const persisted = diagnostics.config;
1448
+ const claudeGuardArmed = claudeCodeBaseline.has(config);
1449
+ const pendingLiveClaudeMutation = claudeGuardArmed
1450
+ && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config));
1451
+
1452
+ persistedLiveServerBinding.set(config, {
1453
+ port: persisted.port,
1454
+ ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}),
1455
+ });
1456
+
1457
+ reconcileConfigRecord(
1458
+ config as unknown as Record<string, unknown>,
1459
+ persistedBaseline as unknown as Record<string, unknown>,
1460
+ persisted as unknown as Record<string, unknown>,
1461
+ new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]),
1462
+ );
1463
+
1464
+ if (claudeGuardArmed && !pendingLiveClaudeMutation) {
1465
+ if (persisted.claudeCode === undefined) delete config.claudeCode;
1466
+ else config.claudeCode = structuredClone(persisted.claudeCode);
1467
+ claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
1468
+ }
1469
+ }
1470
+
967
1471
  /** The literal file, with no schema merge or default injection. */
968
1472
  function readRawConfigJson(): Record<string, unknown> | undefined {
969
1473
  try {
@@ -979,6 +1483,28 @@ function readRawConfigJson(): Record<string, unknown> | undefined {
979
1483
  }
980
1484
  }
981
1485
 
1486
+ /**
1487
+ * Read only schema-valid binding fields from the literal file. Missing fields mean
1488
+ * their schema defaults; malformed fields keep the last known persisted value.
1489
+ */
1490
+ function readPersistedServerBinding(
1491
+ raw: Record<string, unknown>,
1492
+ baseline: PersistedServerBinding,
1493
+ ): PersistedServerBinding {
1494
+ const port = raw.port === undefined
1495
+ ? 10100
1496
+ : (typeof raw.port === "number"
1497
+ && Number.isInteger(raw.port)
1498
+ && raw.port >= 0
1499
+ && raw.port <= 65535
1500
+ ? raw.port
1501
+ : baseline.port);
1502
+ const hostname = raw.hostname === undefined
1503
+ ? undefined
1504
+ : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname);
1505
+ return { port, ...(hostname !== undefined ? { hostname } : {}) };
1506
+ }
1507
+
982
1508
  /**
983
1509
  * The save entry point for every writer holding a LIVE server config.
984
1510
  *
@@ -993,18 +1519,33 @@ function readRawConfigJson(): Record<string, unknown> | undefined {
993
1519
  * guarantee.
994
1520
  */
995
1521
  export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
1522
+ const bindingBaseline = persistedLiveServerBinding.get(config);
1523
+ const onDisk = claudeCodeBaseline.has(config) || bindingBaseline
1524
+ ? readRawConfigJson()
1525
+ : undefined;
996
1526
  if (claudeCodeBaseline.has(config)) {
997
- const onDisk = readRawConfigJson();
998
1527
  if (onDisk !== undefined) {
999
1528
  const baseline = claudeCodeBaseline.get(config);
1000
- const diskChanged = !deepEqual(onDisk.claudeCode, baseline);
1529
+ const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode);
1530
+ const diskChanged = !deepEqual(persistedClaudeCode, baseline);
1001
1531
  const weChanged = !deepEqual(config.claudeCode, baseline);
1002
1532
  if (diskChanged && !weChanged) {
1003
- config.claudeCode = onDisk.claudeCode as OcxConfig["claudeCode"];
1533
+ config.claudeCode = persistedClaudeCode;
1004
1534
  }
1005
1535
  }
1006
1536
  }
1007
- saveConfig(config);
1537
+ const persistedBinding = bindingBaseline && onDisk
1538
+ ? readPersistedServerBinding(onDisk, bindingBaseline)
1539
+ : bindingBaseline;
1540
+ if (persistedBinding) {
1541
+ const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port };
1542
+ if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
1543
+ else persistedConfig.hostname = persistedBinding.hostname;
1544
+ saveConfig(persistedConfig);
1545
+ persistedLiveServerBinding.set(config, persistedBinding);
1546
+ } else {
1547
+ saveConfig(config);
1548
+ }
1008
1549
  if (claudeCodeBaseline.has(config)) {
1009
1550
  claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
1010
1551
  }