@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/config.ts CHANGED
@@ -27,8 +27,12 @@ import {
27
27
  isValidCodexAccountNamespaceTarget,
28
28
  MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
29
29
  } from "./codex/account-namespace-match";
30
+ import { isCodexAccountPriorityKey } from "./codex/account-priority";
31
+ import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health";
32
+ import { parseAccountPriority } from "./codex/pool-rotation";
30
33
  import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
31
34
  import { routingProfileIssues } from "./routing/profile";
35
+ import { POLICY_NAMESPACE } from "./routing/profile-namespace";
32
36
  import {
33
37
  forgetEphemeralSecretPath,
34
38
  hardenSecretDir,
@@ -945,6 +949,33 @@ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
945
949
  }
946
950
  }).pipe(z.record(z.string(), z.string()));
947
951
 
952
+ const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR =
953
+ "codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers";
954
+ const CODEX_ACCOUNT_PRIORITY_KEY_ERROR =
955
+ "selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
956
+ const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR =
957
+ "selection order must be an integer between -100 and 100";
958
+
959
+ const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
960
+
961
+ const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
962
+ (value): value is Record<string, unknown> => !!value
963
+ && typeof value === "object"
964
+ && !Array.isArray(value)
965
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
966
+ { error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR },
967
+ ).superRefine((priorities, ctx) => {
968
+ // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
969
+ for (const [accountId, priority] of Object.entries(priorities)) {
970
+ if (!isCodexAccountPriorityKey(accountId)) {
971
+ ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR });
972
+ }
973
+ if (parseAccountPriority(priority) === null) {
974
+ ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR });
975
+ }
976
+ }
977
+ }).pipe(z.record(z.string(), z.number().int()));
978
+
948
979
  /**
949
980
  * Deliberately permissive. A user's config is not ours to invalidate: a strict
950
981
  * entry fails the whole parse, and loadConfig's fallback then backs the file up
@@ -987,11 +1018,18 @@ const apiKeyEntrySchema = z.object({
987
1018
  const clientIntegrationsSchema = z.object({
988
1019
  codex: z.boolean().optional().catch(undefined),
989
1020
  grok: z.boolean().optional().catch(undefined),
1021
+ "claude-desktop": z.boolean().optional().catch(undefined),
990
1022
  }).passthrough();
991
1023
 
992
1024
  const configSchema = z.object({
993
1025
  port: z.number().int().min(0).max(65535).default(10100),
994
1026
  managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
1027
+ // Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
1028
+ upstreamHostCircuitThreshold: z.number().int()
1029
+ .min(0)
1030
+ .max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD)
1031
+ .optional()
1032
+ .catch(undefined),
995
1033
  appOwnedMemoryBudgetMb: z.number().int()
996
1034
  .min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
997
1035
  .max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
@@ -1004,6 +1042,14 @@ const configSchema = z.object({
1004
1042
  // is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time
1005
1043
  // rejection lives in validateConfigCandidate() so bad values still surface to the caller.
1006
1044
  hostname: z.string().trim().min(1).optional().catch(undefined),
1045
+ // Discriminated on `enabled` so a disabled entry cannot be forced to carry a port, and an
1046
+ // enabled one cannot omit it (#1102). A malformed value degrades to undefined rather than
1047
+ // failing the whole parse: this is an opt-in convenience surface, and a hand-edit typo here
1048
+ // must never reset providers/apiKeys through the backup-and-defaults repair path.
1049
+ unauthenticatedLoopbackListener: z.union([
1050
+ z.object({ enabled: z.literal(false) }),
1051
+ z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }),
1052
+ ]).optional().catch(undefined),
1007
1053
  providers: z.record(z.string(), providerConfigSchema),
1008
1054
  defaultProvider: z.string().min(1).default("openai"),
1009
1055
  openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
@@ -1024,6 +1070,15 @@ const configSchema = z.object({
1024
1070
  codexShimAutoRestore: z.boolean().optional(),
1025
1071
  pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
1026
1072
  codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
1073
+ // Selection order is a preference, not a safety control like pause: a malformed
1074
+ // map degrades to "no ordering" rather than failing the parse, so a hand-edited
1075
+ // typo cannot trip the backup-and-defaults repair path and wipe providers or
1076
+ // pool accounts. Warning emitted in loadConfig.
1077
+ codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined),
1078
+ activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined),
1079
+ // A malformed hand edit must degrade to false without discarding providers, accounts,
1080
+ // or the exact selector map. Live writes remain strict.
1081
+ codexAccountPickerEnabled: z.boolean().optional().catch(false),
1027
1082
  // Model ids excluded from the Grok Build managed block (dashboard switches).
1028
1083
  grokExcludedModels: z.array(z.string()).optional(),
1029
1084
  // Invalid values degrade to undefined ("auto") instead of failing the whole
@@ -1076,6 +1131,7 @@ const configSchema = z.object({
1076
1131
  const configuredProviderNamespaces = new Set([
1077
1132
  COMBO_NAMESPACE,
1078
1133
  OPENAI_CODEX_PROVIDER_ID,
1134
+ POLICY_NAMESPACE,
1079
1135
  ...Object.keys(config.providers),
1080
1136
  ].map(codexProviderNamespaceKey));
1081
1137
  const namespaceTargets = new Set(
@@ -1087,7 +1143,7 @@ const configSchema = z.object({
1087
1143
  ctx.addIssue({
1088
1144
  code: "custom",
1089
1145
  path: ["codexAccountNamespaces", namespace],
1090
- message: "account selectors must not collide with configured provider or combo namespaces",
1146
+ message: "account selectors must not collide with configured provider, combo, or routing policy namespaces",
1091
1147
  });
1092
1148
  }
1093
1149
  if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) {
@@ -1493,6 +1549,32 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
1493
1549
  }
1494
1550
  }
1495
1551
 
1552
+ /**
1553
+ * Companion to {@link warnDegradedStreamMode} for a malformed selection-order map.
1554
+ * Priority is a preference, so the schema drops the whole map rather than failing
1555
+ * the parse — say so once, otherwise the pool silently reverts to flat ordering.
1556
+ */
1557
+ function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
1558
+ const record = rawConfigRecord(rawParsed);
1559
+ const warnings: string[] = [];
1560
+ // The pin degrades silently otherwise, which reads as the manual selection simply
1561
+ // not having survived the restart.
1562
+ if (record?.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) {
1563
+ warnings.push("activeCodexAccountPinned is not a valid account id — the manually selected account is no longer pinned");
1564
+ }
1565
+ const raw = record?.codexAccountPriorities;
1566
+ if (raw !== undefined && validated.codexAccountPriorities === undefined) {
1567
+ warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled");
1568
+ }
1569
+ return warnings;
1570
+ }
1571
+
1572
+ function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void {
1573
+ for (const warning of degradedCodexAccountPriorityWarnings(rawParsed, validated)) {
1574
+ console.warn(`⚠️ config.json ${warning}`);
1575
+ }
1576
+ }
1577
+
1496
1578
  /**
1497
1579
  * The apiKeys schema salvages entry by entry rather than failing the parse, so a
1498
1580
  * dropped key is otherwise invisible — and it will not be re-saved by the next
@@ -1621,6 +1703,23 @@ function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
1621
1703
  }
1622
1704
  }
1623
1705
 
1706
+ function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null {
1707
+ const raw = rawConfigRecord(rawParsed);
1708
+ if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
1709
+ const threshold = raw.upstreamHostCircuitThreshold;
1710
+ if (threshold === undefined) return null;
1711
+ if (typeof threshold === "number"
1712
+ && Number.isInteger(threshold)
1713
+ && threshold >= 0
1714
+ && threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
1715
+ return `upstreamHostCircuitThreshold ignored: expected an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
1716
+ }
1717
+
1718
+ function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void {
1719
+ const warning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
1720
+ if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
1721
+ }
1722
+
1624
1723
  type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
1625
1724
 
1626
1725
  function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
@@ -1650,6 +1749,18 @@ function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField
1650
1749
  return `${field} ignored: expected ${expected}`;
1651
1750
  }
1652
1751
 
1752
+ function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null {
1753
+ const raw = rawConfigRecord(rawParsed);
1754
+ if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null;
1755
+ if (typeof raw.codexAccountPickerEnabled === "boolean") return null;
1756
+ return "codexAccountPickerEnabled ignored: expected a boolean";
1757
+ }
1758
+
1759
+ function warnDegradedCodexAccountPicker(rawParsed: unknown): void {
1760
+ const warning = malformedCodexAccountPickerWarning(rawParsed);
1761
+ if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
1762
+ }
1763
+
1653
1764
  function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null {
1654
1765
  if (config.syncCodexSubagentDefaults !== true) return null;
1655
1766
  const malformed = malformedNativeSubagentFields(rawParsed);
@@ -1698,8 +1809,11 @@ export function loadConfig(): OcxConfig {
1698
1809
  warnDegradedStreamMode(parsed, config);
1699
1810
  warnDegradedHostname(parsed, config);
1700
1811
  warnDegradedApiKeys(parsed, config);
1812
+ warnDegradedCodexAccountPriorities(parsed, config);
1701
1813
  warnDegradedClaudeSubagentEffort(parsed);
1702
1814
  warnDegradedNativeSubagentConfig(parsed, config);
1815
+ warnDegradedCodexAccountPicker(parsed);
1816
+ warnDegradedUpstreamHostCircuitThreshold(parsed);
1703
1817
  return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
1704
1818
  }
1705
1819
  // Schema validation failed — merge defaults into the raw object instead of
@@ -1717,8 +1831,11 @@ export function loadConfig(): OcxConfig {
1717
1831
  const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
1718
1832
  warnDegradedHostname(parsed, config);
1719
1833
  warnDegradedApiKeys(parsed, config);
1834
+ warnDegradedCodexAccountPriorities(parsed, config);
1720
1835
  warnDegradedClaudeSubagentEffort(parsed);
1721
1836
  warnDegradedNativeSubagentConfig(parsed, config);
1837
+ warnDegradedCodexAccountPicker(parsed);
1838
+ warnDegradedUpstreamHostCircuitThreshold(parsed);
1722
1839
  return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
1723
1840
  }
1724
1841
  // Merge couldn't fix it — truly broken config
@@ -1763,10 +1880,15 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
1763
1880
  const rawEffort = rawClaudeSubagentEffort(rawParsed);
1764
1881
  const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
1765
1882
  const warnings = configPlaceholderWarnings(normalized);
1883
+ warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
1766
1884
  if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
1767
1885
  warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
1768
1886
  }
1769
1887
  warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning));
1888
+ const pickerWarning = malformedCodexAccountPickerWarning(rawParsed);
1889
+ if (pickerWarning) warnings.push(pickerWarning);
1890
+ const hostCircuitWarning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
1891
+ if (hostCircuitWarning) warnings.push(hostCircuitWarning);
1770
1892
  if (syncDisabledReason) {
1771
1893
  warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
1772
1894
  }
@@ -1836,6 +1958,44 @@ function appOwnedMemoryBudgetError(value: unknown): string | null {
1836
1958
  return null;
1837
1959
  }
1838
1960
 
1961
+ function upstreamHostCircuitThresholdError(value: unknown): string | null {
1962
+ const raw = rawConfigRecord(value);
1963
+ if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
1964
+ const threshold = raw.upstreamHostCircuitThreshold;
1965
+ if (threshold === undefined) return null;
1966
+ if (typeof threshold === "number"
1967
+ && Number.isInteger(threshold)
1968
+ && threshold >= 0
1969
+ && threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
1970
+ return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
1971
+ }
1972
+
1973
+ /**
1974
+ * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
1975
+ * malformed selection-order map to undefined, which on a write would drop every entry the
1976
+ * user had accumulated and still report success. A load-time degrade leaves the raw map in
1977
+ * the file to be repaired by hand; a degraded write erases it. One bad `ocx config set`
1978
+ * must not cost the whole map, so a live caller is told instead.
1979
+ */
1980
+ function codexAccountPrioritiesError(value: unknown): string | null {
1981
+ const raw = rawConfigRecord(value);
1982
+ if (!raw) return null;
1983
+ if (raw.codexAccountPriorities !== undefined) {
1984
+ const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities);
1985
+ if (!parsed.success) {
1986
+ return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities.");
1987
+ }
1988
+ }
1989
+ // Tested as a string rather than coerced: `String(123)` matches the id pattern, so a
1990
+ // coercing guard waves a non-string pin through to the schema, where `.catch(undefined)`
1991
+ // drops it and reports the write as a success — the exact silent-degrade this guards.
1992
+ const pin = raw.activeCodexAccountPinned;
1993
+ if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) {
1994
+ return "schema_invalid: activeCodexAccountPinned: must be an account id";
1995
+ }
1996
+ return null;
1997
+ }
1998
+
1839
1999
  function googleAntigravityStaticCatalogVersionError(value: unknown): string | null {
1840
2000
  const raw = rawConfigRecord(value);
1841
2001
  if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null;
@@ -1844,12 +2004,71 @@ function googleAntigravityStaticCatalogVersionError(value: unknown): string | nu
1844
2004
  return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted";
1845
2005
  }
1846
2006
 
2007
+ function codexAccountPickerEnabledError(value: unknown): string | null {
2008
+ const raw = rawConfigRecord(value);
2009
+ if (!raw) return null;
2010
+ const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerEnabled");
2011
+ if (!descriptor) {
2012
+ return "codexAccountPickerEnabled" in raw
2013
+ ? "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted"
2014
+ : null;
2015
+ }
2016
+ if (!("value" in descriptor)) {
2017
+ return "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted";
2018
+ }
2019
+ const enabled = descriptor.value;
2020
+ if (enabled === undefined || typeof enabled === "boolean") return null;
2021
+ return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted";
2022
+ }
2023
+
1847
2024
  /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
2025
+ /**
2026
+ * Reject a loopback-listener port that collides with the proxy port (#1102).
2027
+ *
2028
+ * The schema can only check the shape of each field on its own; the two ports being distinct
2029
+ * is a relationship between them. Letting the pair through would surface as a startup failure
2030
+ * after the public listener already bound, which reads like an unrelated port conflict.
2031
+ *
2032
+ * This is write-time only, matching `blankHostnameError`: a live caller can be told the value
2033
+ * is wrong, whereas a hand-edited config on the read path degrades to undefined rather than
2034
+ * resetting the whole file.
2035
+ */
2036
+ function loopbackListenerPortError(value: unknown): string | null {
2037
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2038
+ const listener = (value as Record<string, unknown>).unauthenticatedLoopbackListener;
2039
+ if (listener === undefined) return null;
2040
+ if (!listener || typeof listener !== "object" || Array.isArray(listener)) {
2041
+ return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted";
2042
+ }
2043
+ const entry = listener as Record<string, unknown>;
2044
+ // `enabled` must be a real boolean. The schema's `.catch(undefined)` would otherwise DELETE
2045
+ // a `"true"` string entry and report success, leaving an operator convinced they enabled an
2046
+ // unauthenticated listener that is in fact off. Load-time still degrades quietly — a hand
2047
+ // edit must not reset the file — but a live caller gets told.
2048
+ if (typeof entry.enabled !== "boolean") {
2049
+ return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean";
2050
+ }
2051
+ if (entry.enabled !== true) return null;
2052
+ const listenerPort = entry.port;
2053
+ if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) {
2054
+ return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled";
2055
+ }
2056
+ const proxyPort = (value as Record<string, unknown>).port;
2057
+ if (typeof proxyPort === "number" && proxyPort === listenerPort) {
2058
+ return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port";
2059
+ }
2060
+ return null;
2061
+ }
2062
+
1848
2063
  export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
1849
2064
  const boundaryError = blankHostnameError(value)
1850
2065
  ?? claudeSubagentEffortError(value)
1851
2066
  ?? appOwnedMemoryBudgetError(value)
1852
- ?? googleAntigravityStaticCatalogVersionError(value);
2067
+ ?? upstreamHostCircuitThresholdError(value)
2068
+ ?? googleAntigravityStaticCatalogVersionError(value)
2069
+ ?? codexAccountPrioritiesError(value)
2070
+ ?? codexAccountPickerEnabledError(value)
2071
+ ?? loopbackListenerPortError(value);
1853
2072
  if (boundaryError) return { ok: false, error: boundaryError };
1854
2073
  const result = configSchema.safeParse(value);
1855
2074
  if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) };
@@ -900,7 +900,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
900
900
  }
901
901
 
902
902
  const sse = bridgeToResponsesSSE(
903
- produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
903
+ produce(), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => {
904
904
  internalAbort.abort("client closed responses stream");
905
905
  }, 2_000,
906
906
  {
@@ -78,6 +78,25 @@ function claimNamesDifferentHome(
78
78
  return false;
79
79
  }
80
80
 
81
+ /**
82
+ * Map a service-manager claim backend to the `ServiceInstallState.backend`
83
+ * value it corresponds to. `scheduler` (Task Scheduler) and `winsw` (native)
84
+ * are the two Windows manager backends; launchd/systemd claims have no Windows
85
+ * backend and can never mismatch a v2 state file.
86
+ */
87
+ function claimBackendToStateBackend(backend: ServiceManagerClaim["backend"]): "scheduler" | "native" | null {
88
+ if (backend === "scheduler") return "scheduler";
89
+ if (backend === "winsw") return "native";
90
+ return null;
91
+ }
92
+
93
+ /** True when the recorded state backend disagrees with the manager claim. Legacy v1 means scheduler. */
94
+ function claimBackendMismatchesState(claim: ServiceManagerClaim, state: { backend?: "scheduler" | "native" }): boolean {
95
+ const expected = claimBackendToStateBackend(claim.backend);
96
+ if (expected === null) return false;
97
+ return (state.backend ?? "scheduler") !== expected;
98
+ }
99
+
81
100
  export interface OwnershipDeps extends ProbeDeps {
82
101
  /**
83
102
  * Which state paths to consult. Injectable because the default set includes
@@ -125,7 +144,14 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
125
144
  };
126
145
  }
127
146
 
128
- const manager = inspectServiceManagerInstallation(deps);
147
+ // The manager assets live under the effective OPENCODEX_HOME. Production
148
+ // callers do not inject ProbeDeps.configDir, so derive it from the same
149
+ // current-home snapshot used for ownership comparison rather than silently
150
+ // falling back to <homedir>/.opencodex.
151
+ const manager = inspectServiceManagerInstallation({
152
+ ...deps,
153
+ configDir: deps.configDir ?? current.opencodexHome,
154
+ });
129
155
  if (manager.kind === "unknown") {
130
156
  return { ownership: "unknown", reason: manager.reason };
131
157
  }
@@ -146,6 +172,17 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
146
172
  reason: `${disagreeing.backend} is installed from ${disagreeing.definitionPath}, which names different homes than the recorded service state`,
147
173
  };
148
174
  }
175
+ // A manager backend that disagrees with the recorded state (e.g. state says
176
+ // native/WinSW but a scheduler task is found) is an interrupted backend
177
+ // switch: it does not prove which manager owns the installation. v1 state
178
+ // predates the field and is scheduler by contract.
179
+ const stateBackendMismatch = valid.find(state => manager.claims.some(claim => claimBackendMismatchesState(claim, state.state)));
180
+ if (stateBackendMismatch) {
181
+ return {
182
+ ownership: "unknown",
183
+ reason: `the service state records backend ${stateBackendMismatch.state.backend ?? "scheduler"} but ${manager.claims[0]?.backend ?? "a service manager"} is installed`,
184
+ };
185
+ }
149
186
  // Definition agrees. Valid state agreeing with it is ownership; no state at
150
187
  // all beside an installed definition is not, because the definition is the
151
188
  // claim and nothing here recorded making it.
@@ -162,4 +199,4 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
162
199
  return valid.length === 0
163
200
  ? { ownership: "owned", reason: "no service state and no service manager claim" }
164
201
  : { ownership: "owned", reason: "the recorded service state names these homes" };
165
- }
202
+ }
@@ -94,7 +94,7 @@ export function decideEagerRelay(
94
94
  * Windows preserves the decision for no-rewrite traffic. Darwin permits only
95
95
  * explicit config opt-in; `auto` remains tee even on a future fixed runtime.
96
96
  * Returns the normalized effective decision, or null when platform policy,
97
- * rewrite needs, or a Darwin non-config-eager mode selects tee.
97
+ * Windows rewrite needs, or a Darwin non-config-eager mode selects tee.
98
98
  */
99
99
  export function selectEagerPath(
100
100
  platform: NodeJS.Platform,
@@ -103,12 +103,12 @@ export function selectEagerPath(
103
103
  version: string = Bun.version,
104
104
  minFixed: string | null = MIN_FIXED_BUN_VERSION,
105
105
  ): EagerRelayDecision | null {
106
- if (needsClientRewrite || (platform !== "win32" && platform !== "darwin")) {
106
+ if (platform !== "win32" && platform !== "darwin") {
107
107
  return null;
108
108
  }
109
109
 
110
110
  const decision = decideEagerRelay(mode, version, minFixed);
111
- if (platform === "win32") return decision;
111
+ if (platform === "win32") return needsClientRewrite ? null : decision;
112
112
  return decision.reason === "config-eager" ? decision : null;
113
113
  }
114
114
 
@@ -13,6 +13,47 @@ export type SseRecord =
13
13
  | { kind: "event"; event?: string; data: string }
14
14
  | { kind: "comment"; comment: string };
15
15
 
16
+ /**
17
+ * Extract one SSE field value from a single line, or null when the line is a different field.
18
+ *
19
+ * The space after the colon is OPTIONAL in text/event-stream: `data:{"a":1}` is as valid as
20
+ * `data: {"a":1}`. Parsers that hardcoded `startsWith("data: ")` silently dropped every frame
21
+ * from a producer that omits it, which surfaced as a completed turn with no content (#1170).
22
+ *
23
+ * Strips at most ONE leading space — the same rule `decodeServerSentEvents` applies below — so a
24
+ * payload that legitimately begins with whitespace keeps the rest of it. Does not trim the value:
25
+ * callers own that choice, and some of them intentionally keep trailing bytes.
26
+ */
27
+ export function sseFieldValue(line: string, field: string): string | null {
28
+ if (!line.startsWith(field)) return null;
29
+ const rest = line.slice(field.length);
30
+ // A colonless field line is the field with an empty value per the SSE rules, and
31
+ // `decodeServerSentEvents` below treats it that way (`colon < 0` -> valueStart = line.length).
32
+ // These helpers must not disagree with the decoder they mirror.
33
+ if (rest.length === 0) return "";
34
+ if (!rest.startsWith(":")) return null;
35
+ return rest.startsWith(": ") ? rest.slice(2) : rest.slice(1);
36
+ }
37
+
38
+ /**
39
+ * Offset-only variant of {@link sseFieldValue} for parsers that index into a larger buffer.
40
+ *
41
+ * Returns the index where the field's value begins within `text`, or -1 when the line at
42
+ * `[lineStart, lineEnd)` is a different field. Slicing nothing matters for the live Claude relay,
43
+ * whose translator-budget accounting reserves bytes by offset — materializing the line first would
44
+ * allocate the very string the budget exists to bound.
45
+ */
46
+ export function sseFieldOffset(text: string, lineStart: number, lineEnd: number, field: string): number {
47
+ if (!text.startsWith(field, lineStart)) return -1;
48
+ let valueStart = lineStart + field.length;
49
+ // Colonless field line: empty value, positioned at end-of-line (matches the decoder).
50
+ if (valueStart >= lineEnd) return lineEnd;
51
+ if (text[valueStart] !== ":") return -1;
52
+ valueStart += 1;
53
+ if (valueStart < lineEnd && text[valueStart] === " ") valueStart += 1;
54
+ return valueStart;
55
+ }
56
+
16
57
  /**
17
58
  * Decode text/event-stream records across arbitrary fetch chunk boundaries.
18
59
  *
@@ -0,0 +1,73 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { isLocalAttestationSecret } from "./local-management-attestation";
3
+
4
+ export const SYSTEM_RESTART_METHOD = "POST";
5
+ export const SYSTEM_RESTART_PATH = "/api/system/restart";
6
+ export const SYSTEM_RESTART_CAPABILITY_VERSION = "v1";
7
+ export const SYSTEM_RESTART_EXPECTED_PID_HEADER = "x-opencodex-restart-expected-pid";
8
+ export const SYSTEM_RESTART_NONCE_HEADER = "x-opencodex-restart-nonce";
9
+ export const SYSTEM_RESTART_CAPABILITY_HEADER = "x-opencodex-restart-capability";
10
+
11
+ /** Fixed drain and replacement budgets shared by the server, CLI, and tray. */
12
+ export const MEMORY_DRAIN_RESTART_MS = 60_000;
13
+ export const REPLACEMENT_READY_TIMEOUT_MS = 70_000;
14
+
15
+ const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
16
+
17
+ export type ExpectedSystemRestartPid =
18
+ | { kind: "absent" }
19
+ | { kind: "invalid" }
20
+ | { kind: "present"; pid: number };
21
+
22
+ export function parseExpectedSystemRestartPid(value: string | null): ExpectedSystemRestartPid {
23
+ if (value === null) return { kind: "absent" };
24
+ if (!/^[1-9]\d*$/.test(value)) return { kind: "invalid" };
25
+ const pid = Number(value);
26
+ return Number.isSafeInteger(pid) ? { kind: "present", pid } : { kind: "invalid" };
27
+ }
28
+
29
+ function restartCapabilityPayload(
30
+ nonce: string,
31
+ method: string,
32
+ path: string,
33
+ pid: number,
34
+ port: number,
35
+ ): string | null {
36
+ if (!BASE64URL_256.test(nonce)) return null;
37
+ if (method !== SYSTEM_RESTART_METHOD || path !== SYSTEM_RESTART_PATH) return null;
38
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
39
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
40
+ return `opencodex-system-restart-v1\n${nonce}\n${method}\n${path}\n${pid}\n${port}`;
41
+ }
42
+
43
+ /** Process-scoped, operation-only authorization. It is not a reusable management credential. */
44
+ export function createSystemRestartCapability(
45
+ secret: string,
46
+ nonce: string,
47
+ method: string,
48
+ path: string,
49
+ pid: number,
50
+ port: number,
51
+ ): string | null {
52
+ if (!isLocalAttestationSecret(secret)) return null;
53
+ const payload = restartCapabilityPayload(nonce, method, path, pid, port);
54
+ if (!payload) return null;
55
+ return createHmac("sha256", secret).update(payload).digest("base64url");
56
+ }
57
+
58
+ export function verifySystemRestartCapability(
59
+ secret: string,
60
+ nonce: string | null,
61
+ method: string,
62
+ path: string,
63
+ pid: number,
64
+ port: number,
65
+ capability: string | null,
66
+ ): boolean {
67
+ if (!nonce || !capability || !BASE64URL_256.test(capability)) return false;
68
+ const expected = createSystemRestartCapability(secret, nonce, method, path, pid, port);
69
+ if (!expected) return false;
70
+ const expectedBytes = Buffer.from(expected);
71
+ const actualBytes = Buffer.from(capability);
72
+ return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
73
+ }