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