@bitkyc08/opencodex 2.50.0 → 2.51.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 (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-D7BdZpZm.js} +1 -1
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/qoder/adapter.ts +69 -1
  6. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  7. package/src/claude/agents-inject.ts +29 -5
  8. package/src/claude/desktop-3p.ts +31 -3
  9. package/src/claude/gateway-cache.ts +12 -21
  10. package/src/cli/capabilities.ts +28 -0
  11. package/src/cli/claude-agent-startup-sync.ts +26 -1
  12. package/src/cli/claude.ts +138 -20
  13. package/src/cli/config-command.ts +67 -1
  14. package/src/cli/connect.ts +181 -14
  15. package/src/cli/dispatch.ts +53 -9
  16. package/src/cli/doctor.ts +9 -2
  17. package/src/cli/ensure-desired-integrations.ts +10 -0
  18. package/src/cli/gui-pair-client.ts +1 -12
  19. package/src/cli/help.ts +4 -1
  20. package/src/cli/hub.ts +367 -0
  21. package/src/cli/index.ts +94 -30
  22. package/src/cli/launcher-context.ts +1 -1
  23. package/src/cli/registry.ts +43 -3
  24. package/src/cli/status.ts +325 -5
  25. package/src/cli/version-skew.ts +4 -1
  26. package/src/cli.ts +2 -2
  27. package/src/client/catalog-compatibility.ts +192 -0
  28. package/src/client/connect.ts +31 -0
  29. package/src/client/hub-client.ts +52 -0
  30. package/src/client/hub-state.ts +214 -0
  31. package/src/codex/account-usability.ts +48 -12
  32. package/src/codex/auth-api.ts +49 -5
  33. package/src/codex/catalog/effort.ts +67 -8
  34. package/src/codex/catalog/sync.ts +85 -0
  35. package/src/codex/codex-write-lock.ts +11 -2
  36. package/src/codex/desired-state.ts +47 -1
  37. package/src/codex/inject-coordination.ts +10 -5
  38. package/src/codex/inject.ts +26 -10
  39. package/src/codex/loopback-target.ts +45 -0
  40. package/src/codex/routing.ts +48 -1
  41. package/src/codex/runtime.ts +37 -3
  42. package/src/codex/sync.ts +29 -9
  43. package/src/codex/warmup.ts +21 -4
  44. package/src/config/pending-teardown.ts +1 -1
  45. package/src/config.ts +126 -12
  46. package/src/generated/compatibility-version.json +136 -76
  47. package/src/grok/status.ts +9 -1
  48. package/src/integrations/config-io.ts +54 -1
  49. package/src/lib/bun-runtime.ts +1 -1
  50. package/src/lib/gui-pair-capability.ts +27 -0
  51. package/src/lib/local-destinations.ts +162 -0
  52. package/src/lib/package-tree-integrity.ts +1 -1
  53. package/src/lib/process-control.ts +130 -20
  54. package/src/lib/service-secrets.ts +28 -0
  55. package/src/lib/test-home-guard.ts +49 -0
  56. package/src/providers/opencode-go-transport.ts +9 -1
  57. package/src/providers/quota.ts +5 -1
  58. package/src/providers/registry.ts +34 -5
  59. package/src/remote/hub-state.ts +182 -0
  60. package/src/server/auth-cors.ts +5 -0
  61. package/src/server/chat-completions.ts +6 -3
  62. package/src/server/claude-messages.ts +7 -1
  63. package/src/server/hub-state.ts +98 -0
  64. package/src/server/index.ts +124 -6
  65. package/src/server/management/api-access.ts +14 -3
  66. package/src/server/management/config-routes.ts +2 -2
  67. package/src/server/management/cursor-integration-routes.ts +13 -4
  68. package/src/server/proxy-liveness.ts +7 -1
  69. package/src/server/request-log-conversation.ts +41 -1
  70. package/src/server/responses/codex-auth-error.ts +18 -1
  71. package/src/server/responses/codex-ws-exchange.ts +36 -4
  72. package/src/server/responses/codex-ws-wire.ts +75 -4
  73. package/src/server/responses/compact.ts +20 -9
  74. package/src/server/responses/core.ts +57 -10
  75. package/src/server/responses/policy-fallback.ts +7 -1
  76. package/src/server/system-env-shell.ts +14 -2
  77. package/src/server/system-env.ts +106 -14
  78. package/src/service.ts +906 -94
  79. package/src/types/config.ts +57 -4
  80. package/src/update/badge.ts +3 -2
  81. package/src/update/index.ts +317 -64
  82. package/src/update/install-detection.d.mts +6 -0
  83. package/src/update/install-detection.mjs +73 -0
  84. package/src/update/job.ts +101 -49
  85. package/src/update/pnpm-global-install.d.mts +144 -0
  86. package/src/update/pnpm-global-install.mjs +591 -0
  87. package/src/update/pnpm-invocation.d.mts +43 -0
  88. package/src/update/pnpm-invocation.mjs +141 -0
  89. package/src/update/registry-integrity.d.mts +16 -0
  90. package/src/update/registry-integrity.mjs +37 -0
  91. package/src/update/transactional-install.d.mts +1 -1
  92. package/src/update/transactional-install.mjs +101 -7
  93. package/src/update/tray-update-plan.mjs +1 -1
  94. package/src/vision/plan.ts +13 -3
  95. package/src/vision/routed-describe.ts +51 -20
@@ -42,6 +42,8 @@ import {
42
42
  resolveCodexModelEntitlements,
43
43
  type CodexModelEntitlementSnapshot,
44
44
  } from "../model-entitlements";
45
+ import { isAccountNeedsReauth } from "../account-runtime-state";
46
+ import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "../account-label";
45
47
 
46
48
 
47
49
  import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
@@ -1708,6 +1710,75 @@ export function finalizeAutoReviewModelOverride(
1708
1710
  return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels);
1709
1711
  }
1710
1712
 
1713
+ /**
1714
+ * Why an account-gated native model stopped being offered, but only when the answer is one the
1715
+ * operator can act on.
1716
+ *
1717
+ * Suppression is an omission: the row is never built, so there is no catalog entry for a reason
1718
+ * to ride on and no downstream consumer that could explain it later. #4212's reporter watched
1719
+ * their models disappear and reasonably concluded the proxy was broken, because every surface
1720
+ * that changed said nothing about the account that caused it.
1721
+ *
1722
+ * Returns `undefined` for the ordinary case — an account that is simply not entitled to a gated
1723
+ * model. That is the default state for most installations, it is not news, and warning about it
1724
+ * on every sync would bury the one case that matters. A credential the operator must repair is
1725
+ * the case that matters, so that is the only one this speaks up about.
1726
+ *
1727
+ * Accounts are named with the durable `p`-prefixed log label, the same identifier the dashboard
1728
+ * shows, never the raw pool id or the email.
1729
+ */
1730
+ export function gatedNativeReauthSuppressionReason(args: {
1731
+ snapshot: CodexModelEntitlementSnapshot;
1732
+ slug: string;
1733
+ eligibleAccountIds?: ReadonlySet<string>;
1734
+ needsReauth: (accountId: string) => boolean;
1735
+ label: (accountId: string) => string;
1736
+ }): string | undefined {
1737
+ const observed = [...args.snapshot.modelsByAccount.keys()]
1738
+ .filter(accountId => !args.eligibleAccountIds || args.eligibleAccountIds.has(accountId))
1739
+ // Only accounts that could actually have served THIS model. An account upstream positively
1740
+ // denied is not why the model is missing, and blaming it would send the operator to repair a
1741
+ // credential that was never going to help. `unknown` has to stay in: an account whose roster
1742
+ // could not be confirmed reports `unknown` rather than `granted`, and a credential stuck on
1743
+ // a failed refresh is exactly that account.
1744
+ .filter(accountId => (
1745
+ codexModelEntitlementStateForAccount(args.snapshot, accountId, args.slug) !== "denied"
1746
+ ));
1747
+ const stuck = observed.filter(accountId => args.needsReauth(accountId));
1748
+ if (stuck.length === 0) return undefined;
1749
+ const names = stuck.map(accountId => args.label(accountId)).sort().join(", ");
1750
+ return stuck.length === observed.length
1751
+ ? `every Codex account that could serve it needs reauthentication (${names})`
1752
+ : `${stuck.length} of ${observed.length} Codex accounts that could serve it need reauthentication (${names})`;
1753
+ }
1754
+
1755
+ /** Durable, operator-facing label for a pool account id; never the raw id or the email. */
1756
+ function gatedNativeAccountLabel(config: OcxConfig, accountId: string): string {
1757
+ // Direct mode narrows eligibility to the native main credential, so this is the account most
1758
+ // likely to be named here. `codexAuthContextLogLabel` calls it "main" everywhere else; hashing
1759
+ // it into a `p`-prefixed digest would name the one account the operator cannot look up.
1760
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return "main";
1761
+ const account = (config.codexAccounts ?? []).find(candidate => candidate.id === accountId);
1762
+ return account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(accountId);
1763
+ }
1764
+
1765
+ const warnedGatedNativeSuppression = new Set<string>();
1766
+
1767
+ /** Test seam: the warn-once memory is process-global, so a case needs to be able to clear it. */
1768
+ export function resetGatedNativeSuppressionWarningsForTests(): void {
1769
+ warnedGatedNativeSuppression.clear();
1770
+ }
1771
+
1772
+ function warnGatedNativeSuppressedOnce(slug: string, reason: string): void {
1773
+ const signature = `${slug}\u0000${reason}`;
1774
+ if (warnedGatedNativeSuppression.has(signature)) return;
1775
+ warnedGatedNativeSuppression.add(signature);
1776
+ console.warn(
1777
+ `[opencodex] catalog sync: ${slug} is not being offered because ${reason}. `
1778
+ + "Sign in again to restore it.",
1779
+ );
1780
+ }
1781
+
1711
1782
  /**
1712
1783
  * Mescla o catálogo retido com os modelos visíveis e as configurações atuais,
1713
1784
  * incluindo os nomes nativos. Tenta preservar o backup original e usa a permissão
@@ -1777,6 +1848,20 @@ function writeRetainedCatalogSync({
1777
1848
  const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => (
1778
1849
  !availableBareGatedNativeSlugs.has(slug)
1779
1850
  )));
1851
+ // #4212: this set is the whole record of a model vanishing, and it is a set of strings that
1852
+ // nothing downstream ever asks a question of. Explain it here, while the entitlement snapshot
1853
+ // that produced it is still in scope, because after this point the model is simply absent and
1854
+ // no later surface can tell "never entitled" apart from "the account broke this morning".
1855
+ for (const slug of unavailableGatedNativeSlugs) {
1856
+ const reason = gatedNativeReauthSuppressionReason({
1857
+ snapshot: modelEntitlements,
1858
+ slug,
1859
+ eligibleAccountIds: bareEligibleAccountIds,
1860
+ needsReauth: isAccountNeedsReauth,
1861
+ label: accountId => gatedNativeAccountLabel(config, accountId),
1862
+ });
1863
+ if (reason) warnGatedNativeSuppressedOnce(slug, reason);
1864
+ }
1780
1865
  const suppressedBareNativeSlugs = new Set([
1781
1866
  ...desktopAllowlistSuppressedNativeSlugs(config),
1782
1867
  ...unavailableGatedNativeSlugs,
@@ -66,7 +66,7 @@ export type CodexWriteLockRefusalReason =
66
66
 
67
67
  export type CodexWriteLockResult<T> =
68
68
  | { status: "acquired"; value: T; waitedMs: number; lockId: string }
69
- | { status: "skipped"; reason: "desired_disabled" | "desired_enabled"; waitedMs: number }
69
+ | { status: "skipped"; reason: CodexWriteLockSkipReason; waitedMs: number }
70
70
  | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number; lockId: string }
71
71
  | {
72
72
  status: "refused";
@@ -127,9 +127,18 @@ export interface CodexWriteCommitContext {
127
127
  readonly coordinator: CodexCoordinatorTransaction;
128
128
  }
129
129
 
130
+ /**
131
+ * Why an under-lock policy re-read refused the write.
132
+ *
133
+ * `hub-gated` is not the user's switch: a hub declines to rewrite its own local clients, and
134
+ * reporting that as "integration is OFF" sent operators hunting for a toggle they never set
135
+ * (#4236).
136
+ */
137
+ export type CodexWriteLockSkipReason = "desired_disabled" | "desired_enabled" | "hub-gated";
138
+
130
139
  /** A synchronous under-lock policy re-read proved the requested apply stale. */
131
140
  export class CodexWriteLockSkipped extends Error {
132
- constructor(readonly reason: "desired_disabled" | "desired_enabled") {
141
+ constructor(readonly reason: CodexWriteLockSkipReason) {
133
142
  super(reason);
134
143
  this.name = "CodexWriteLockSkipped";
135
144
  }
@@ -76,11 +76,57 @@ type LocalClientSyncConfig = Pick<
76
76
  "clientIntegrations" | "runtimeRole" | "unauthenticatedLoopbackListener"
77
77
  >;
78
78
 
79
- function localClientSyncAllowed(config: LocalClientSyncConfig): boolean {
79
+ export function localClientSyncAllowed(config: LocalClientSyncConfig): boolean {
80
80
  return config.runtimeRole !== "hub"
81
81
  || config.unauthenticatedLoopbackListener?.enabled === true;
82
82
  }
83
83
 
84
+ /**
85
+ * The one sentence every hub-gated skip says (#4236).
86
+ *
87
+ * The gate is a reasonable decision; reporting it as "Codex integration is OFF" was not. An
88
+ * operator whose `clientIntegrations` says nothing — or says `true` — was told their own switch
89
+ * was off, and `ocx restore back` went further and blamed a competing writer that did not
90
+ * exist. Name the gate and name the key that opens it.
91
+ */
92
+ export const HUB_GATED_SKIP_MESSAGE =
93
+ "This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unless "
94
+ + "unauthenticatedLoopbackListener is enabled.";
95
+
96
+ /** Why a local-client write was skipped. The gate outranks the toggle: it is the surprising one. */
97
+ export type LocalClientSkipReason = "desired_disabled" | "hub-gated";
98
+
99
+ /**
100
+ * "hub-gated" is claimed only when the toggle is ON and the gate is what stopped the write.
101
+ * With the toggle OFF the gate is moot: telling that operator to enable the loopback listener
102
+ * would send them to a key that cannot make the sync happen — the mirror image of the defect
103
+ * this reason exists to fix.
104
+ */
105
+ export function localClientSkipReason(
106
+ config: LocalClientSyncConfig,
107
+ client: DurableIntentClientId = "codex",
108
+ ): LocalClientSkipReason {
109
+ return integrationEnabled(config, client) && !localClientSyncAllowed(config)
110
+ ? "hub-gated"
111
+ : "desired_disabled";
112
+ }
113
+
114
+ /**
115
+ * Pick the skip message for a snapshot: today's toggle text, or the hub-gate sentence.
116
+ *
117
+ * `hubSuffix` states what the hub-gated path still did (a catalog refresh, say) so the
118
+ * composed line stays as informative as the toggle one it replaces.
119
+ */
120
+ export function localClientSkipMessage(
121
+ config: LocalClientSyncConfig,
122
+ integrationOffMessage: string,
123
+ hubSuffix?: string,
124
+ client: DurableIntentClientId = "codex",
125
+ ): string {
126
+ if (localClientSkipReason(config, client) !== "hub-gated") return integrationOffMessage;
127
+ return hubSuffix ? `${HUB_GATED_SKIP_MESSAGE} ${hubSuffix}` : HUB_GATED_SKIP_MESSAGE;
128
+ }
129
+
84
130
  export function shouldSyncCodexOnStart(config: LocalClientSyncConfig): boolean {
85
131
  // A hub is a server for OTHER machines: it must not rewrite its own host's
86
132
  // Codex/Claude/Grok client configs on startup (interview decision Q6, and the
@@ -8,7 +8,8 @@ import { createHash } from "node:crypto";
8
8
  import { existsSync, lstatSync, readFileSync } from "node:fs";
9
9
 
10
10
  import { atomicWriteFile } from "../config";
11
- import type { CodexWriteLockResult } from "./codex-write-lock";
11
+ import type { CodexWriteLockResult, CodexWriteLockSkipReason } from "./codex-write-lock";
12
+ import { HUB_GATED_SKIP_MESSAGE } from "./desired-state";
12
13
  import { inspectCodexCoordinatorPath } from "./coordinator-doctor";
13
14
  import { JOURNAL_PATH } from "./journal";
14
15
  import { updateIntegrationRecord } from "./integration-record";
@@ -443,16 +444,20 @@ export function recomputeInjectWitness(options: {
443
444
  export function codexInjectLockOutcome(
444
445
  result: Exclude<CodexWriteLockResult<unknown>, { status: "acquired" }>,
445
446
  ): { success: false; message: string; retryable: boolean } | {
446
- success: true; status: "skipped"; skippedReason: "desired_disabled" | "desired_enabled"; message: string;
447
+ success: true; status: "skipped"; skippedReason: CodexWriteLockSkipReason; message: string;
447
448
  } {
448
449
  if (result.status === "skipped") {
449
450
  return {
450
451
  success: true,
451
452
  status: "skipped",
452
453
  skippedReason: result.reason,
453
- message: result.reason === "desired_disabled"
454
- ? "Codex integration is OFF; no Codex config, catalog, cache, or history was changed."
455
- : "Codex integration was re-enabled; native restore was skipped.",
454
+ // Three distinct facts, three sentences. The hub gate in particular must not borrow the
455
+ // toggle's wording — that is the phantom "integration is OFF" report from #4236.
456
+ message: result.reason === "hub-gated"
457
+ ? `${HUB_GATED_SKIP_MESSAGE} No Codex config, catalog, cache, or history was changed.`
458
+ : result.reason === "desired_disabled"
459
+ ? "Codex integration is OFF; no Codex config, catalog, cache, or history was changed."
460
+ : "Codex integration was re-enabled; native restore was skipped.",
456
461
  };
457
462
  }
458
463
  if (result.status === "busy") {
@@ -9,7 +9,11 @@ import {
9
9
  withConfigMutationLockSync,
10
10
  } from "../config";
11
11
  import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock";
12
- import { shouldSyncCodexOnStart } from "./desired-state";
12
+ import {
13
+ localClientSkipMessage,
14
+ localClientSkipReason,
15
+ shouldSyncCodexOnStart,
16
+ } from "./desired-state";
13
17
  import { resolveCodexHistoryTransition } from "./history-transition";
14
18
  import {
15
19
  buildInjectWitness,
@@ -79,9 +83,9 @@ import {
79
83
  type ManagedSubagentDefaults,
80
84
  } from "./subagent-defaults";
81
85
  import type { OcxConfig } from "../types";
82
- import { isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
86
+ import { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
83
87
 
84
- export { isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
88
+ export { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
85
89
 
86
90
  // Ownership predicates live in `./injected-marker` so `journal.ts` can reach them
87
91
  // without importing this module back. Re-exported for existing external callers.
@@ -214,8 +218,11 @@ export function standaloneCodexRoutingTarget(
214
218
  "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless" | "codexClientCompaction"
215
219
  >,
216
220
  ): CodexRoutingTarget {
221
+ // An enabled listener with no `port` is the companion form: it answers on `port` itself,
222
+ // bound to 127.0.0.1 (#4236). Resolving it through the shared helper is what makes the
223
+ // one-port hub work without every writer repeating `?? port`.
217
224
  const loopback = config?.unauthenticatedLoopbackListener;
218
- const effectivePort = loopback?.enabled ? loopback.port : port;
225
+ const effectivePort = effectiveLoopbackListenerPort(config, port) ?? port;
219
226
  const hostname = loopback?.enabled ? undefined : config?.hostname;
220
227
  const requiresAdmissionToken = loopback?.enabled ? false : shouldInjectApiAuthHeader(config);
221
228
  return {
@@ -887,7 +894,8 @@ export interface CodexInjectResult {
887
894
  success: boolean;
888
895
  message: string;
889
896
  status?: "skipped";
890
- skippedReason?: "desired_disabled" | "desired_enabled";
897
+ /** `hub-gated` is the hub-role gate (#4236), distinct from the user's own OFF switch. */
898
+ skippedReason?: "desired_disabled" | "desired_enabled" | "hub-gated";
891
899
  nativeSubagentDefaultsWarning?: string;
892
900
  }
893
901
 
@@ -1261,12 +1269,17 @@ export async function injectCodexConfig(
1261
1269
 
1262
1270
  if (eligibility.kind === "legacy-uncoordinated") {
1263
1271
  const applyLegacy = (): CodexInjectResult | undefined => {
1264
- if (!shouldSyncCodexOnStart(loadConfig())) {
1272
+ const legacyGateSnapshot = loadConfig();
1273
+ if (!shouldSyncCodexOnStart(legacyGateSnapshot)) {
1265
1274
  return {
1266
1275
  success: true,
1267
1276
  status: "skipped",
1268
- skippedReason: "desired_disabled",
1269
- message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
1277
+ skippedReason: localClientSkipReason(legacyGateSnapshot),
1278
+ message: localClientSkipMessage(
1279
+ legacyGateSnapshot,
1280
+ "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
1281
+ "No Codex config, catalog, cache, or history was changed.",
1282
+ ),
1270
1283
  };
1271
1284
  }
1272
1285
  runClientWriteGuard(options.beforeClientWrite);
@@ -1295,8 +1308,11 @@ export async function injectCodexConfig(
1295
1308
  }),
1296
1309
  },
1297
1310
  (ctx) => {
1298
- if (!shouldSyncCodexOnStart(loadConfig())) {
1299
- throw new CodexWriteLockSkipped("desired_disabled");
1311
+ const gateSnapshot = loadConfig();
1312
+ if (!shouldSyncCodexOnStart(gateSnapshot)) {
1313
+ // Carry WHY under the lock: "the hub does not write its own clients" and "the user
1314
+ // turned Codex off" produce the same no-write and must not produce the same sentence.
1315
+ throw new CodexWriteLockSkipped(localClientSkipReason(gateSnapshot));
1300
1316
  }
1301
1317
  // N and C are held here. Reject stale client work before publishing a
1302
1318
  // transition or capturing preimages; rejection must not compensate over
@@ -36,6 +36,51 @@ export function isLoopbackHostname(hostname: string | undefined): boolean {
36
36
  );
37
37
  }
38
38
 
39
+ /**
40
+ * Bind scope again, from the other side: a wildcard listener already answers on 127.0.0.1.
41
+ *
42
+ * This matters only for the port-less "companion" loopback listener. On `0.0.0.0`/`::` the
43
+ * public socket owns loopback on that port, so a companion bound to the same port would
44
+ * collide; on a specific non-loopback address (a tailnet or LAN IP) 127.0.0.1 is free.
45
+ */
46
+ export function isWildcardHostname(hostname: string | undefined): boolean {
47
+ const normalized = (hostname ?? "").trim().toLowerCase().replace(/\.$/, "").replace(/^\[(.*)\]$/, "$1");
48
+ if (normalized === "*" || normalized === "0") return true;
49
+ // Every spelling of the IPv4 unspecified address ("0.0.0.0", "00.0.0.000", …).
50
+ if (/^(0+\.){3}0+$/.test(normalized)) return true;
51
+ // Every spelling of the IPv6 unspecified address ("::", "::0", "0::", "0:0:0:0:0:0:0:0", …):
52
+ // nothing but zero groups and colons. A dual-stack `::` bind answers on 127.0.0.1 as well.
53
+ return normalized.includes(":") && /^[0:]+$/.test(normalized);
54
+ }
55
+
56
+ /**
57
+ * Is `{ enabled: true }` with no port legal for this bind address?
58
+ *
59
+ * The companion form binds `127.0.0.1:<proxy port>`, which is exactly the one-port hub shape:
60
+ * remote clients dial `hostname:port`, local processes dial `127.0.0.1:port`, and every
61
+ * hardcoded `http://127.0.0.1:<proxy port>` integration works with no rewriting. It is legal
62
+ * only when the public listener is NOT already holding that loopback address.
63
+ */
64
+ export function loopbackCompanionAllowed(hostname: string | undefined): boolean {
65
+ return !isLoopbackHostname(hostname) && !isWildcardHostname(hostname);
66
+ }
67
+
68
+ /**
69
+ * The port local callers reach the unauthenticated listener on, or null when it is off.
70
+ *
71
+ * One resolver for every reader (#4236): an enabled listener with no `port` is the companion
72
+ * form and answers on the public port. Callers must not repeat `?? port` — the day the default
73
+ * changes, a forgotten site points a client config at a closed socket.
74
+ */
75
+ export function effectiveLoopbackListenerPort(
76
+ config: Pick<OcxConfig, "unauthenticatedLoopbackListener"> | undefined,
77
+ publicPort: number,
78
+ ): number | null {
79
+ const listener = config?.unauthenticatedLoopbackListener;
80
+ if (!listener?.enabled) return null;
81
+ return listener.port ?? publicPort;
82
+ }
83
+
39
84
  export function shouldInjectApiAuthHeader(
40
85
  config: Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener"> | undefined,
41
86
  ): boolean {
@@ -19,7 +19,7 @@ import {
19
19
  selectPriorityTier,
20
20
  } from "./pool-rotation";
21
21
  import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
22
- import { isThirtyDayOnlyCodexPlan } from "./plan";
22
+ import { codexPlanKey, isThirtyDayOnlyCodexPlan } from "./plan";
23
23
  import {
24
24
  MAIN_CODEX_ACCOUNT_ID,
25
25
  getMainAccountPlan,
@@ -1004,6 +1004,50 @@ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()):
1004
1004
  return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
1005
1005
  }
1006
1006
 
1007
+ /**
1008
+ * Plan keys the operator excluded from automatic rotation. Absent or empty means no policy, so an
1009
+ * existing install rotates exactly as before. Compared with `codexPlanKey` because the stored plan
1010
+ * is an unrestricted provider string whose casing this repository does not control.
1011
+ */
1012
+ function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet<string> | undefined {
1013
+ const configured = config.codexPool?.excludedPlans;
1014
+ if (!configured?.length) return undefined;
1015
+ const keys = configured
1016
+ .map(plan => codexPlanKey(plan))
1017
+ .filter((key): key is string => key !== undefined);
1018
+ return keys.length > 0 ? new Set(keys) : undefined;
1019
+ }
1020
+
1021
+ /**
1022
+ * Whether the operator's plan policy removes this account from automatic selection.
1023
+ *
1024
+ * Modelled on pause rather than usability: an excluded account keeps its credential, quota history,
1025
+ * and affinity, stays visible on the account surface, and is still reachable by explicit account
1026
+ * selection. Only automatic rotation skips it, which is the distinction #4211 asked for.
1027
+ *
1028
+ * It is checked in the same two places pause is checked, and that is not redundancy. The eligible
1029
+ * list is consulted only when routing picks a NEW account; an already-active or already-affined
1030
+ * account is served straight from {@link isCodexAccountSelectable}. A lapsed subscription leaves
1031
+ * behind exactly that account, so a policy that filtered only the eligible list would miss the case
1032
+ * it exists for.
1033
+ *
1034
+ * `__main__` is exempt. {@link getPoolAccountPlanForSelection} withholds the main plan during a
1035
+ * selection-only drain so routing never reads the fenced native credential for it, so a rule that
1036
+ * covered main would disagree with itself between drain and ordinary routing.
1037
+ */
1038
+ function isCodexAccountPlanExcluded(
1039
+ config: OcxConfig,
1040
+ accountId: string,
1041
+ precomputed?: ReadonlySet<string>,
1042
+ ): boolean {
1043
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return false;
1044
+ // Callers that test a whole list pass the set once rather than rebuilding it per row.
1045
+ const excluded = precomputed ?? excludedCodexPoolPlanKeys(config);
1046
+ if (!excluded) return false;
1047
+ const plan = codexPlanKey(getPoolAccountPlan(config, accountId));
1048
+ return plan !== undefined && excluded.has(plan);
1049
+ }
1050
+
1007
1051
  function isCodexAccountSelectable(
1008
1052
  config: OcxConfig,
1009
1053
  accountId: string,
@@ -1012,6 +1056,7 @@ function isCodexAccountSelectable(
1012
1056
  selectionOptions?: CodexAccountUsabilityOptions,
1013
1057
  ): boolean {
1014
1058
  return !isCodexAccountPaused(config, accountId)
1059
+ && !isCodexAccountPlanExcluded(config, accountId)
1015
1060
  && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
1016
1061
  && !isCodexAccountSoftAvoided(accountId, now)
1017
1062
  && isCodexAccountUsable(config, accountId, selectionOptions);
@@ -1242,10 +1287,12 @@ function getEligiblePoolAccounts(
1242
1287
  selectionOptions?: CodexAccountUsabilityOptions,
1243
1288
  skipFailoverReadyCandidates = false,
1244
1289
  ): readonly string[] {
1290
+ const excludedPlans = excludedCodexPoolPlanKeys(config);
1245
1291
  const ids = (config.codexAccounts ?? [])
1246
1292
  .filter(account => isSelectableCodexPoolAccount(account)
1247
1293
  && account.id !== excludeId
1248
1294
  && !isCodexAccountPaused(config, account.id)
1295
+ && !isCodexAccountPlanExcluded(config, account.id, excludedPlans)
1249
1296
  && !isAccountNeedsReauth(account.id)
1250
1297
  && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
1251
1298
  .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
@@ -427,13 +427,47 @@ function sameRuntimeCommand(a: string, b: string): boolean {
427
427
  return a.trim().toLowerCase() === b.trim().toLowerCase();
428
428
  }
429
429
 
430
- /** True when a persisted clamp diagnostic still applies to the currently selected runtime. */
430
+ /**
431
+ * Rungs OpenCodex no longer lets the observed-runtime intersection remove, so a persisted
432
+ * diagnostic naming only these describes a policy that is gone rather than a live restriction.
433
+ * This is the single copy of the exemption: `catalog/effort.ts` imports it for the clamp
434
+ * predicate, and a leftover file written before the exemption must not keep warning about
435
+ * rungs the next sync will stop removing.
436
+ */
437
+ export const UNCLAMPABLE_REASONING_EFFORTS: ReadonlySet<string> = new Set(["max", "ultra"]);
438
+
439
+ /** Removals that still describe a real restriction, ignoring rungs nothing clamps any more. */
440
+ export function liveRemovedEfforts(
441
+ diagnostic: EffortClampDiagnostic | null | undefined,
442
+ ): readonly string[] {
443
+ if (!diagnostic) return [];
444
+ return diagnostic.removedEfforts.filter(effort => !UNCLAMPABLE_REASONING_EFFORTS.has(effort));
445
+ }
446
+
447
+ /**
448
+ * True when a persisted clamp diagnostic still applies to the currently selected runtime.
449
+ *
450
+ * Two ways a stored diagnostic stops describing reality:
451
+ *
452
+ * 1. Every rung it names is one nothing clamps any more, so the file is inert until the next
453
+ * sync unlinks it.
454
+ * 2. The binary at that path was upgraded in place. Windows updates Codex without moving the
455
+ * executable, so path equality alone kept a 0.135.0 observation "current" for a 0.154.0
456
+ * runtime whose own bundled catalog carried the rungs the diagnostic claimed were missing.
457
+ * A known version mismatch therefore wins over a path match; an unknown version on either
458
+ * side stays conservative, because absence of a version is not evidence of an upgrade.
459
+ */
431
460
  export function effortClampAppliesToRuntime(
432
461
  diagnostic: EffortClampDiagnostic | null | undefined,
433
462
  runtime: Pick<ResolvedCodexRuntime, "command" | "version">,
434
463
  ): boolean {
435
- if (!diagnostic || diagnostic.removedEfforts.length === 0) return false;
436
- if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) return true;
464
+ if (!diagnostic || liveRemovedEfforts(diagnostic).length === 0) return false;
465
+ if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) {
466
+ if (diagnostic.runtimeVersion && runtime.version) {
467
+ return diagnostic.runtimeVersion === runtime.version;
468
+ }
469
+ return true;
470
+ }
437
471
  return Boolean(
438
472
  diagnostic.runtimeVersion
439
473
  && runtime.version
package/src/codex/sync.ts CHANGED
@@ -5,7 +5,12 @@ import { applyProxyEnv, loadConfig } from "../config";
5
5
  import type { OcxConfig } from "../types";
6
6
  import { collectOrcaCodexHomeDiagnostic } from "./home";
7
7
  import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation";
8
- import { shouldSyncCodexOnStart } from "./desired-state";
8
+ import {
9
+ localClientSkipMessage,
10
+ localClientSkipReason,
11
+ shouldSyncCodexOnStart,
12
+ type LocalClientSkipReason,
13
+ } from "./desired-state";
9
14
  import { admitCodexWrite, type CodexAdmission } from "./admission";
10
15
  import type { CodexCatalogSyncOptions } from "./catalog/sync";
11
16
  import { resetCodexAppServerCatalogStateCache } from "./app-server-processes";
@@ -18,7 +23,8 @@ export interface CodexSyncResult {
18
23
  */
19
24
  status: "applied" | "skipped" | "catalog-only" | "refused";
20
25
  ok: boolean;
21
- skippedReason?: "desired_disabled";
26
+ /** `hub-gated` is the hub-role gate, not the user's toggle — the two read very differently. */
27
+ skippedReason?: LocalClientSkipReason;
22
28
  /** Present when unattended convergence refused another service's native home. */
23
29
  authority?: "service-home";
24
30
  added: number;
@@ -85,19 +91,24 @@ export async function syncModelsToCodex(
85
91
  // durable user switch and must be read again at this production boundary: a
86
92
  // PUT OFF while provider discovery is in flight cannot be allowed to commit
87
93
  // through an older captured object.
88
- const desiredDisabled = !shouldSyncCodexOnStart(loadConfig());
94
+ const gateSnapshot = loadConfig();
95
+ const desiredDisabled = !shouldSyncCodexOnStart(gateSnapshot);
89
96
  const catalogEvenWhenNotInjected = options.catalogEvenWhenNotInjected === true;
90
97
  if (desiredDisabled && !catalogEvenWhenNotInjected) {
91
98
  return {
92
99
  status: "skipped",
93
- skippedReason: "desired_disabled",
100
+ skippedReason: localClientSkipReason(gateSnapshot),
94
101
  ok: true,
95
102
  added: 0,
96
103
  catalogPath: null,
97
104
  catalogExists: false,
98
105
  catalogWritten: false,
99
106
  cacheSynced: false,
100
- message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
107
+ message: localClientSkipMessage(
108
+ gateSnapshot,
109
+ "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
110
+ "No Codex config, catalog, cache, or history was changed.",
111
+ ),
101
112
  };
102
113
  }
103
114
  // Catalog gathering precedes injection and can itself write the native
@@ -131,8 +142,16 @@ export async function syncModelsToCodex(
131
142
  applyProxyEnv(config);
132
143
  const refreshed = await refreshCatalogForSync(config, deps, { allowWhenDesiredDisabled: true }, log);
133
144
  const message = refreshed.catalogWritten || refreshed.cacheSynced
134
- ? "Codex integration is OFF; catalog and models cache refreshed, Codex config untouched."
135
- : "Codex integration is OFF; catalog refresh skipped, Codex config untouched.";
145
+ ? localClientSkipMessage(
146
+ gateSnapshot,
147
+ "Codex integration is OFF; catalog and models cache refreshed, Codex config untouched.",
148
+ "Catalog and models cache refreshed, Codex config untouched.",
149
+ )
150
+ : localClientSkipMessage(
151
+ gateSnapshot,
152
+ "Codex integration is OFF; catalog refresh skipped, Codex config untouched.",
153
+ "Catalog refresh skipped, Codex config untouched.",
154
+ );
136
155
  return {
137
156
  status: "catalog-only",
138
157
  ok: true,
@@ -242,8 +261,9 @@ export async function syncModelsToCodex(
242
261
  if (result.status === "skipped") {
243
262
  return {
244
263
  status: "skipped",
245
- // The apply direction's only under-lock policy skip is desired OFF.
246
- skippedReason: "desired_disabled",
264
+ // The apply direction's only under-lock policy skips are desired OFF and the hub gate;
265
+ // carry whichever the injector reported so the caller can say the honest thing.
266
+ skippedReason: result.skippedReason === "hub-gated" ? "hub-gated" : "desired_disabled",
247
267
  ok: true,
248
268
  added: 0,
249
269
  catalogPath: null,
@@ -28,7 +28,8 @@ export interface CodexWarmupOptions {
28
28
 
29
29
  const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
30
30
  const DEFAULT_MODEL = "gpt-5.4-mini";
31
- const FALLBACK_MODELS = ["gpt-5.5"];
31
+ const FALLBACK_MODELS = ["gpt-5.5", "gpt-5.6-luna"];
32
+ const isRetryableWarmupStatus = (status?: number): boolean => status === 400 || status === 404;
32
33
  const DEFAULT_TIMEOUT_MS = 30_000;
33
34
  const MAX_TIMEOUT_MS = 0x7fff_ffff;
34
35
  const MAX_ERROR_BODY_BYTES = 2048;
@@ -63,6 +64,17 @@ export function codexWarmupFailureReason(err: unknown): string {
63
64
  return safeWarmupReason(err);
64
65
  }
65
66
 
67
+ /**
68
+ * 400 and 404 mean the model or the account is not provisioned for this request, not that the
69
+ * credential is bad. Sharing the retry predicate keeps the operator-facing diagnostic from
70
+ * disagreeing with the fallback policy that produced the final error.
71
+ */
72
+ export function isCodexWarmupProvisioningFailure(err: unknown): boolean {
73
+ return err instanceof CodexWarmupError
74
+ && err.code === "http_status"
75
+ && isRetryableWarmupStatus(err.status);
76
+ }
77
+
66
78
  function eventTypeFromData(data: unknown): string | undefined {
67
79
  if (!data || typeof data !== "object") return undefined;
68
80
  const record = data as Record<string, unknown>;
@@ -286,8 +298,8 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
286
298
  await tryWarmup(options, primaryModel);
287
299
  return;
288
300
  } catch (err) {
289
- // Retry with fallback models on 400 (model may not be available for this account).
290
- if (!(err instanceof CodexWarmupError) || err.status !== 400) throw err;
301
+ // Retry with fallback models on 400 or 404 (model may not be provisioned or available for this account).
302
+ if (!(err instanceof CodexWarmupError) || !isRetryableWarmupStatus(err.status)) throw err;
291
303
  let lastErr = err;
292
304
  for (const fallback of FALLBACK_MODELS) {
293
305
  if (fallback === primaryModel) continue;
@@ -295,7 +307,12 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
295
307
  await tryWarmup(options, fallback);
296
308
  return;
297
309
  } catch (retryErr) {
298
- if (retryErr instanceof CodexWarmupError) lastErr = retryErr;
310
+ if (retryErr instanceof CodexWarmupError) {
311
+ lastErr = retryErr;
312
+ if (!isRetryableWarmupStatus(retryErr.status)) throw retryErr;
313
+ } else {
314
+ throw retryErr;
315
+ }
299
316
  }
300
317
  }
301
318
  throw lastErr;
@@ -175,7 +175,7 @@ export function listPendingTeardowns(): OutstandingTeardown[] {
175
175
  }
176
176
  const out: OutstandingTeardown[] = [];
177
177
  for (const name of names) {
178
- // One naming rule, shared with the npm launcher: the two lanes drifting apart is
178
+ // One naming rule, shared with the package launcher: the two lanes drifting apart is
179
179
  // exactly how the Node updater stopped seeing receipts at all.
180
180
  if (!isPendingTeardownFileName(name)) continue;
181
181
  const nonce = pendingTeardownNonceFromFileName(name)!;