@bitkyc08/opencodex 2.90.0 → 2.92.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 (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -78,9 +78,14 @@ import { overlayHomeCodexSettings } from "../link/codex-settings";
78
78
  import {
79
79
  clearHomeCodexSettingsRecord,
80
80
  fetchHomeCodexSettings,
81
+ homeCodexSettingsReceipt,
82
+ readHomeCodexSettingsRecord,
81
83
  resolveHomeCodexSettings,
82
84
  recordHomeCodexSettings,
85
+ withLinkApply,
86
+ withdrawChangingHomeCodexSettingsRecord,
83
87
  writeHomeCodexSettingsRecord,
88
+ type HomeCodexSettingsRecord,
84
89
  type HomeCodexSettingsSource,
85
90
  type LinkHomeDeps,
86
91
  } from "./link-home";
@@ -122,6 +127,8 @@ export interface ClientConnectDeps {
122
127
  catalogCompatibility?: CatalogCompatibilityDeps;
123
128
  /** Link mode: how the Home's Codex settings are read (#6957). */
124
129
  linkHome?: LinkHomeDeps;
130
+ /** Link mode: the apply lock's path (OCX-6); defaults to `<configDir>/link/apply-lock.sqlite`. */
131
+ linkApplyLockPath?: string;
125
132
  }
126
133
 
127
134
  export interface RotateClientOptions {
@@ -549,9 +556,22 @@ function assertConnectingState(expectedTokenFingerprint?: string): void {
549
556
  }
550
557
 
551
558
  /** Enroll a client key, keeping its pending ownership visible until commit or rollback. */
559
+ /**
560
+ * Connect this machine to a hub or, with `transport: "link"`, to a Remote Link Home. A link
561
+ * connect holds the apply lock throughout (OCX-6), because it applies the Home's Codex settings
562
+ * and records that apply exactly as `ocx sync` does.
563
+ */
552
564
  export async function connectClient(
553
565
  options: ConnectOptions,
554
566
  deps: ClientConnectDeps = {},
567
+ ): Promise<OcxClientConnectionConfig> {
568
+ if ((options.transport ?? "hub") !== "link") return connectClientUnlocked(options, deps);
569
+ return withLinkApply(() => connectClientUnlocked(options, deps), deps.linkApplyLockPath);
570
+ }
571
+
572
+ async function connectClientUnlocked(
573
+ options: ConnectOptions,
574
+ deps: ClientConnectDeps = {},
555
575
  ): Promise<OcxClientConnectionConfig> {
556
576
  deps.signal?.throwIfAborted();
557
577
  const rawFetch = deps.fetchImpl ?? fetch;
@@ -755,11 +775,17 @@ export async function connectClient(
755
775
  commitClientConnection(connection);
756
776
  committed = true;
757
777
  }), deps.lifecycleLockDeps);
758
- // Recorded only when the injection that applied it ran (not with --no-sync).
778
+ // Recorded only when the injection that applied it ran (not with --no-sync). The connection is
779
+ // already committed, so a failed record write must not reach the rollback below: it is a
780
+ // warning, and `ocx status` reports no recorded apply until `ocx sync` writes one.
759
781
  if (homeSettings?.kind === "fresh" && linkMetadata && injectionCommitted) {
760
782
  try {
761
- writeHomeCodexSettingsRecord({ linkId: linkMetadata.linkId, fetchedAt: now, document: homeSettings.document });
762
- } catch { /* the next sync records it; the applied config is already committed */ }
783
+ writeHomeCodexSettingsRecord(homeCodexSettingsReceipt(
784
+ linkMetadata.linkId, homeSettings.document, now, (deps.now ?? (() => new Date()))().toISOString(), null,
785
+ ));
786
+ } catch {
787
+ console.warn("Warning: the Home's Codex settings were applied, but the apply record was not written; run 'ocx sync' to record it.");
788
+ }
763
789
  }
764
790
  return connection;
765
791
  } catch (error) {
@@ -804,10 +830,34 @@ export async function connectClient(
804
830
  }
805
831
  }
806
832
 
833
+ export interface ConnectedSyncResult {
834
+ catalogWritten: boolean;
835
+ cacheSynced: boolean;
836
+ injected: boolean;
837
+ stale: boolean;
838
+ homeSettings?: HomeCodexSettingsSource;
839
+ /** The apply record this sync committed or kept (link Child with Home settings only). */
840
+ appliedRecord?: HomeCodexSettingsRecord;
841
+ /** The Home's settings were injected but their record could not be written (OCX-6). */
842
+ applyRecordFailed?: true;
843
+ }
844
+
845
+ /**
846
+ * Refresh the catalog and Codex config from the hub or Home. Every sync holds the apply lock
847
+ * (OCX-6), so a sync that finds a link connection inside, whatever an earlier unlocked read said,
848
+ * never interleaves with another apply, and its record names what its own injection wrote.
849
+ */
807
850
  export async function syncConnectedClient(
851
+ options: { restartCodex?: boolean } = {},
852
+ deps: ClientConnectDeps = {},
853
+ ): Promise<ConnectedSyncResult> {
854
+ return withLinkApply(() => syncConnectedClientUnlocked(options, deps), deps.linkApplyLockPath);
855
+ }
856
+
857
+ async function syncConnectedClientUnlocked(
808
858
  _options: { restartCodex?: boolean } = {},
809
859
  deps: ClientConnectDeps = {},
810
- ): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean; homeSettings?: HomeCodexSettingsSource }> {
860
+ ): Promise<ConnectedSyncResult> {
811
861
  if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
812
862
  const initial = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
813
863
  assertNoClientDisconnectPending();
@@ -853,12 +903,18 @@ export async function syncConnectedClient(
853
903
  };
854
904
  let injected = false;
855
905
  let homeSettings: HomeCodexSettingsSource | undefined;
906
+ let appliedRecord: HomeCodexSettingsRecord | undefined;
907
+ let applyRecordFailed = false;
856
908
  if (next.selectedClients.includes("codex")) {
857
909
  let config = loadConfig();
858
910
  // #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog.
859
911
  if (isLinkConnection(next)) {
860
912
  homeSettings = await resolveHomeCodexSettings(next, initial.token.token, deps.linkHome);
861
913
  if (homeSettings.source !== "local") config = overlayHomeCodexSettings(config, homeSettings.document.settings);
914
+ // About to write different settings: until their record commits, report no applied digest
915
+ // rather than the previous one (OCX-6). A failed injection then reads "unknown", never a
916
+ // digest of values Codex no longer has or never got.
917
+ if (homeSettings.source === "home" && next.link) withdrawChangingHomeCodexSettingsRecord(next.link.linkId, homeSettings.document);
862
918
  }
863
919
  const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
864
920
  routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH,
@@ -866,14 +922,34 @@ export async function syncConnectedClient(
866
922
  });
867
923
  if (!result.success || result.status === "skipped") throw new Error(result.message);
868
924
  injected = true;
869
- if (homeSettings && next.link) recordHomeCodexSettings(next.link.linkId, homeSettings);
925
+ if (homeSettings && next.link) {
926
+ const linkId = next.link.linkId;
927
+ const settings = homeSettings;
928
+ try {
929
+ appliedRecord = withClientLifecycleSync(() => {
930
+ beforeClientWrite();
931
+ return recordHomeCodexSettings(linkId, settings, deps.now) ?? readHomeCodexSettingsRecord(linkId) ?? undefined;
932
+ }, deps.lifecycleLockDeps);
933
+ } catch (error) {
934
+ // An ownership change is not a disk problem: report it as itself.
935
+ if (error instanceof Error && (error.message === "client_token_changed" || error.message === "client_connection_changed")) throw error;
936
+ // The injection committed, so the cache refresh below still runs; the caller reports this.
937
+ try { clearHomeCodexSettingsRecord(); } catch { /* status already reads no recorded apply or reports the write */ }
938
+ applyRecordFailed = true;
939
+ }
940
+ }
870
941
  }
871
942
  const cacheSynced = withClientLifecycleSync(() => {
872
943
  beforeClientWrite();
873
944
  // Cache invalidation acquires K itself (N -> K -> C); never call it while C is held.
874
945
  return invalidateCodexModelsCache({ allowWhenDesiredDisabled: true });
875
946
  }, deps.lifecycleLockDeps);
876
- return { catalogWritten: downloaded !== undefined, cacheSynced, injected, stale, ...(homeSettings ? { homeSettings } : {}) };
947
+ return {
948
+ catalogWritten: downloaded !== undefined, cacheSynced, injected, stale,
949
+ ...(homeSettings ? { homeSettings } : {}),
950
+ ...(appliedRecord ? { appliedRecord } : {}),
951
+ ...(applyRecordFailed ? { applyRecordFailed } : {}),
952
+ };
877
953
  }
878
954
 
879
955
  /**
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OCX-11: the stable answer a Remote Link Child or Hub client gives when its Home cannot be
3
+ * reached. The relay keeps its status, `error` string and retry semantics; `code` and
4
+ * `message` are added so callers (Codex, GOAT) read one contract. The client runtime has no
5
+ * provider dispatch, so nothing is ever served from this device's local providers. Whether GOAT
6
+ * may move the work to them is the Home's per-device `fallback.devices.<id>.localFallback`.
7
+ */
8
+ export const HOME_UNREACHABLE_CODE = "home_unreachable";
9
+
10
+ export const HOME_UNREACHABLE_MESSAGE = "The Home is unreachable, and OpenCodex did not serve this request "
11
+ + "from this device's local providers. Retry once the Home is back. Allowing local fallback for this "
12
+ + "device is set on the Home: ocx fallback local on --device <id>.";
13
+
14
+ export function homeUnreachableBody(error: string): { error: string; code: string; message: string } {
15
+ return { error, code: HOME_UNREACHABLE_CODE, message: HOME_UNREACHABLE_MESSAGE };
16
+ }
@@ -1,4 +1,5 @@
1
1
  import { stripMachineAuthHeaders } from "./machine-auth";
2
+ import { homeUnreachableBody } from "./home-unreachable";
2
3
 
3
4
  export interface HubRelayTarget {
4
5
  managementUrl: string;
@@ -73,6 +74,11 @@ function relayError(status: number, error: string): Response {
73
74
  return Response.json({ error }, { status });
74
75
  }
75
76
 
77
+ /** The Hub did not answer (OCX-11 `home_unreachable`); the 502 status is kept. */
78
+ function hubUnreachable(): Response {
79
+ return Response.json(homeUnreachableBody("hub relay unavailable"), { status: 502 });
80
+ }
81
+
76
82
  function canonicalOrigin(value: string): string | null {
77
83
  try {
78
84
  const url = new URL(value);
@@ -275,12 +281,12 @@ export async function relayHubManagementRequest(
275
281
  });
276
282
  } catch {
277
283
  cleanup();
278
- return relayError(502, "hub relay unavailable");
284
+ return hubUnreachable();
279
285
  }
280
286
  if (signal.aborted) {
281
287
  cleanup();
282
288
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
283
- return relayError(502, "hub relay unavailable");
289
+ return hubUnreachable();
284
290
  }
285
291
  if (upstream.status >= 300 && upstream.status < 400) {
286
292
  cleanup();
@@ -13,9 +13,11 @@ import { readBoundedResponseBytes } from "../lib/bounded-body";
13
13
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
14
14
  import { hardenSecretDir } from "../lib/windows-secret-acl";
15
15
  import {
16
+ codexSettingsDigest,
16
17
  parseHomeCodexSettingsDocument,
17
18
  type HomeCodexSettingsDocument,
18
19
  } from "../link/codex-settings";
20
+ import { LINK_CHILD_PARITY_PATH, type LinkChildParityReport } from "../link/child-parity";
19
21
  import {
20
22
  LINK_CODEX_SETTINGS_PATH,
21
23
  LINK_MANAGEMENT_PREFIX,
@@ -23,10 +25,17 @@ import {
23
25
  } from "../link/management-routes";
24
26
  import { linkDir } from "../link/paths";
25
27
  import type { OcxClientConnectionConfig } from "../types";
28
+ import { withClientLifecycle } from "./lifecycle-lock";
26
29
  import { fetchBoundLinkRelay } from "./link-relay-transport";
27
30
  import { readClientLinkState, type ClientLinkState } from "./link-state";
28
31
 
29
32
  const RECORD_MAX_BYTES = 64 * 1024;
33
+
34
+ function isIsoTimestamp(value: unknown): value is string {
35
+ if (typeof value !== "string" || value.length !== 24) return false;
36
+ const parsed = Date.parse(value);
37
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
38
+ }
30
39
  const SETTINGS_MAX_BYTES = 64 * 1024;
31
40
  const READ_TIMEOUT_MS = 10_000;
32
41
 
@@ -42,6 +51,11 @@ export type LinkHomeConnection = Pick<OcxClientConnectionConfig, "transport" | "
42
51
  export interface HomeCodexSettingsRecord {
43
52
  linkId: string;
44
53
  fetchedAt: string;
54
+ /**
55
+ * When this Child first applied these settings (OCX-6, P5). A re-apply of the same digest for
56
+ * the same link keeps it, so an idempotent `ocx sync` never makes a fresh runtime look stale.
57
+ */
58
+ appliedAt: string;
45
59
  document: HomeCodexSettingsDocument;
46
60
  }
47
61
 
@@ -69,7 +83,9 @@ export function readHomeCodexSettingsRecord(linkId: string, path = homeCodexSett
69
83
  const record = raw as Record<string, unknown>;
70
84
  if (record.linkId !== linkId || typeof record.fetchedAt !== "string" || Number.isNaN(Date.parse(record.fetchedAt))) return null;
71
85
  const document = parseHomeCodexSettingsDocument(record.document);
72
- return document ? { linkId, fetchedAt: record.fetchedAt, document } : null;
86
+ // Records written before OCX-6 carry no apply time; their read time is the closest one.
87
+ const appliedAt = isIsoTimestamp(record.appliedAt) ? record.appliedAt : record.fetchedAt;
88
+ return document ? { linkId, fetchedAt: record.fetchedAt, appliedAt, document } : null;
73
89
  } catch {
74
90
  return null;
75
91
  }
@@ -89,6 +105,59 @@ export function clearHomeCodexSettingsRecord(path = homeCodexSettingsPath()): vo
89
105
  try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
90
106
  }
91
107
 
108
+ /**
109
+ * The record an apply of `document` produces: the apply time is `appliedAt` unless the previous
110
+ * record is for the same link and the same settings digest, in which case it is kept.
111
+ */
112
+ export function homeCodexSettingsReceipt(
113
+ linkId: string,
114
+ document: HomeCodexSettingsDocument,
115
+ fetchedAt: string,
116
+ appliedAt: string,
117
+ previous: HomeCodexSettingsRecord | null = readHomeCodexSettingsRecord(linkId),
118
+ ): HomeCodexSettingsRecord {
119
+ const unchanged = previous !== null && previous.linkId === linkId
120
+ && codexSettingsDigest(previous.document.settings) === codexSettingsDigest(document.settings);
121
+ return { linkId, fetchedAt, appliedAt: unchanged ? previous.appliedAt : appliedAt, document };
122
+ }
123
+
124
+ export function linkApplyLockPath(): string {
125
+ return join(linkDir(), "apply-lock.sqlite");
126
+ }
127
+
128
+ /**
129
+ * Before an apply writes settings that differ from the recorded ones, withdraw the record, so a
130
+ * failed injection or record write reads as "no recorded apply" instead of the previous digest
131
+ * (OCX-6). Re-applying the same settings keeps it. Returns whether the record was withdrawn.
132
+ */
133
+ export function withdrawChangingHomeCodexSettingsRecord(linkId: string, document: HomeCodexSettingsDocument): boolean {
134
+ const previous = readHomeCodexSettingsRecord(linkId);
135
+ if (!previous || codexSettingsDigest(previous.document.settings) === codexSettingsDigest(document.settings)) return false;
136
+ clearHomeCodexSettingsRecord();
137
+ return true;
138
+ }
139
+
140
+ /**
141
+ * Serialize applies of the Home's settings on this Child, from the Home read through the Codex
142
+ * injection to the record write, so a receipt always names the settings the injection wrote.
143
+ * Outermost in the client lock order (before N, L and C); a second apply fails fast.
144
+ */
145
+ export async function withLinkApply<T>(work: () => Promise<T>, lockPath: string = linkApplyLockPath()): Promise<T> {
146
+ const dir = linkDir();
147
+ assertNotRealHomeUnderTest(join(dir, ".."));
148
+ // Only a refusal of the apply lock itself means "another apply"; an error from inside `work`
149
+ // (including a busy ordinary lifecycle lock) passes through as itself.
150
+ let entered = false;
151
+ try {
152
+ return await withClientLifecycle(() => { entered = true; return work(); }, { lockPath });
153
+ } catch (error) {
154
+ if (!entered && error instanceof Error && error.message === "client_lifecycle_busy") {
155
+ throw new Error("another Home settings apply is in progress on this machine; retry when it finishes");
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+
92
161
  /**
93
162
  * The Home's name as this Child knows it: the SSH alias of a Child-initiated link, else the
94
163
  * host name the Home published with its Codex settings, else null ("its Home").
@@ -239,10 +308,54 @@ export async function resolveHomeCodexSettings(
239
308
  : { source: "local", reason: fetched.reason };
240
309
  }
241
310
 
242
- /** Record settings a successful injection applied. Best effort: the config is already written. */
243
- export function recordHomeCodexSettings(linkId: string, settings: HomeCodexSettingsSource): void {
244
- if (settings.source !== "home") return;
311
+ /**
312
+ * Record settings a successful injection applied and return the committed record; null when the
313
+ * source was not a fresh Home read. A write failure throws: the caller reports it, because
314
+ * `ocx status` and the Home read this record as the proof of what was applied.
315
+ */
316
+ export function recordHomeCodexSettings(
317
+ linkId: string,
318
+ settings: HomeCodexSettingsSource,
319
+ now: () => Date = () => new Date(),
320
+ ): HomeCodexSettingsRecord | null {
321
+ if (settings.source !== "home") return null;
322
+ const record = homeCodexSettingsReceipt(linkId, settings.document, settings.fetchedAt, now().toISOString());
323
+ writeHomeCodexSettingsRecord(record);
324
+ return record;
325
+ }
326
+
327
+ /**
328
+ * Tell the Home what this Child last applied and when its runtime started (OCX-6). Resolves to
329
+ * `"accepted"`, `"unsupported"` for a Home that predates the route, or `"failed"`.
330
+ */
331
+ export async function sendLinkChildParityReport(
332
+ connection: LinkHomeConnection,
333
+ admissionKey: string,
334
+ report: LinkChildParityReport,
335
+ deps: LinkHomeDeps = {},
336
+ ): Promise<"accepted" | "unsupported" | "failed"> {
337
+ let response: Response;
245
338
  try {
246
- writeHomeCodexSettingsRecord({ linkId, fetchedAt: settings.fetchedAt, document: settings.document });
247
- } catch { /* the next sync records it */ }
339
+ const target = linkTarget(connection, admissionKey);
340
+ const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_CHILD_PARITY_PATH}`;
341
+ const body = JSON.stringify(report);
342
+ const request: RequestInit = {
343
+ method: "POST",
344
+ headers: {
345
+ accept: "application/json",
346
+ authorization: `Bearer ${admissionKey}`,
347
+ "content-type": "application/json",
348
+ "content-length": String(Buffer.byteLength(body)),
349
+ },
350
+ body,
351
+ redirect: "manual",
352
+ signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS),
353
+ };
354
+ response = deps.fetchImpl ? await deps.fetchImpl(destination, request) : await fetchBoundLinkRelay(target, destination, request);
355
+ } catch {
356
+ return "failed";
357
+ }
358
+ try { await response.body?.cancel(); } catch { /* best effort */ }
359
+ if (response.status === 404) return "unsupported";
360
+ return response.status === 204 ? "accepted" : "failed";
248
361
  }
@@ -76,7 +76,7 @@ function liftRequestIdleTimer(req: Request, server: Pick<Server<unknown>, "timeo
76
76
  * fetch, so a rebinding or cross-site browser page cannot reach the Home through this port.
77
77
  * - Without the committed link key nothing is fetched, and the caller gets
78
78
  * `503 link_credential_unavailable`.
79
- * - `/readyz` stays local: it reports this listener, not the tunnel.
79
+ * - `/readyz` stays local and is never relayed; the listener answers it from `link-readiness.ts`.
80
80
  * - A relayed request is exempt from the listener's idle limit, like a standalone data route.
81
81
  */
82
82
  export function handleLinkIngress(
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The Child half of the OCX-6 report: tell the Home, over the link, when this Child last applied
3
+ * the Home's Codex settings, which digest it applied and when this runtime started.
4
+ *
5
+ * Runs in a linked client runtime. The first report goes out shortly after start (the tunnel may
6
+ * still be coming up, so failures back off: 2, 5, 15, 30 then 60 seconds), and then once a minute,
7
+ * so a Home that restarted and forgot the report learns it again within a minute. A Home that
8
+ * predates the route (404) is not asked again. Timers are unref'd and never keep the process alive.
9
+ */
10
+ import { codexSettingsDigest } from "../link/codex-settings";
11
+ import type { LinkChildParityReport } from "../link/child-parity";
12
+ import type { OcxClientConnectionConfig } from "../types";
13
+ import { readHomeCodexSettingsRecord, sendLinkChildParityReport, type HomeCodexSettingsRecord } from "./link-home";
14
+
15
+ export const LINK_PARITY_RETRY_MS = [2_000, 5_000, 15_000, 30_000, 60_000] as const;
16
+ export const LINK_PARITY_PERIOD_MS = 60_000;
17
+
18
+ /** Process-wide, so a reporter rebuilt by a restart rollback keeps counting up (see `sequence`). */
19
+ let reportSequence = 0;
20
+ function nextReportSequence(): number {
21
+ reportSequence += 1;
22
+ return reportSequence;
23
+ }
24
+
25
+ export interface LinkParityReporter {
26
+ start(): void;
27
+ stop(): void;
28
+ }
29
+
30
+ export interface LinkParityReporterDeps {
31
+ connection: OcxClientConnectionConfig;
32
+ linkKey: () => string | null;
33
+ /** ISO start time of this runtime. */
34
+ runtimeStartedAt: string;
35
+ readRecord?: (linkId: string) => HomeCodexSettingsRecord | null;
36
+ send?: typeof sendLinkChildParityReport;
37
+ setTimer?: (callback: () => void, ms: number) => unknown;
38
+ clearTimer?: (timer: unknown) => void;
39
+ }
40
+
41
+ /** The report for this Child's current record; null without link metadata. */
42
+ export function linkChildParityReport(
43
+ connection: OcxClientConnectionConfig,
44
+ runtimeStartedAt: string,
45
+ record: HomeCodexSettingsRecord | null,
46
+ sequence: number = nextReportSequence(),
47
+ ): LinkChildParityReport | null {
48
+ const linkId = connection.link?.linkId;
49
+ const started = Date.parse(runtimeStartedAt);
50
+ if (!linkId || !Number.isFinite(started)) return null;
51
+ const applied = record ? Date.parse(record.appliedAt) : Number.NaN;
52
+ return Number.isFinite(applied)
53
+ ? { linkId, lastApplyAt: applied, runtimeStartedAt: started, appliedCodexSettingsDigest: codexSettingsDigest(record!.document.settings), sequence }
54
+ : { linkId, lastApplyAt: null, runtimeStartedAt: started, appliedCodexSettingsDigest: null, sequence };
55
+ }
56
+
57
+ export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkParityReporter {
58
+ const readRecord = deps.readRecord ?? readHomeCodexSettingsRecord;
59
+ const send = deps.send ?? sendLinkChildParityReport;
60
+ const setTimer = deps.setTimer ?? ((callback: () => void, ms: number) => {
61
+ const timer = setTimeout(callback, ms);
62
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
63
+ return timer;
64
+ });
65
+ const clearTimer = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType<typeof setTimeout>));
66
+ let timer: unknown;
67
+ let stopped = true;
68
+ let failures = 0;
69
+
70
+ const schedule = (ms: number) => {
71
+ if (stopped) return;
72
+ timer = setTimer(() => { void tick(); }, ms);
73
+ };
74
+
75
+ const tick = async (): Promise<void> => {
76
+ timer = undefined;
77
+ if (stopped) return;
78
+ const key = deps.linkKey();
79
+ const linkId = deps.connection.link?.linkId;
80
+ let record: HomeCodexSettingsRecord | null = null;
81
+ try { record = linkId ? readRecord(linkId) : null; } catch { record = null; }
82
+ const report = linkChildParityReport(deps.connection, deps.runtimeStartedAt, record);
83
+ const outcome = key && report ? await send(deps.connection, key, report).catch(() => "failed" as const) : "failed";
84
+ if (outcome === "unsupported") { stopped = true; return; }
85
+ if (outcome === "accepted") {
86
+ failures = 0;
87
+ schedule(LINK_PARITY_PERIOD_MS);
88
+ return;
89
+ }
90
+ failures = Math.min(failures + 1, LINK_PARITY_RETRY_MS.length - 1);
91
+ schedule(LINK_PARITY_RETRY_MS[failures]!);
92
+ };
93
+
94
+ return {
95
+ start() {
96
+ if (!stopped) return;
97
+ stopped = false;
98
+ failures = 0;
99
+ schedule(LINK_PARITY_RETRY_MS[0]);
100
+ },
101
+ stop() {
102
+ stopped = true;
103
+ if (timer !== undefined) clearTimer(timer);
104
+ timer = undefined;
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Whether a Remote Link Child can serve relayed requests right now, for its `GET /readyz`.
3
+ *
4
+ * `/healthz` keeps meaning "this process answers". A Child whose tunnel is down still answers it,
5
+ * yet every relayed turn fails with 503, so readiness is where that shows: not ready, with one
6
+ * fixed reason word. Nothing here carries ssh output, host names, ports or keys.
7
+ *
8
+ * - A Child-initiated link reads its own tunnel supervisor; no network traffic.
9
+ * - A Home-initiated link has no local tunnel to inspect (the Home runs `ssh -R`), so it runs
10
+ * the credential-free relay preflight through the tunnel port. One preflight runs at a time, a
11
+ * verdict is reused for `LINK_READINESS_TTL_MS`, and a request waits at most
12
+ * `LINK_READINESS_WAIT_MS` for a running preflight before it answers `link_unverified`. An
13
+ * unauthenticated caller can therefore cause at most one preflight per TTL.
14
+ */
15
+ import { LINK_RELAY_AUTH_TIMEOUT_MS } from "../link/relay-auth";
16
+ import { isLinkPort } from "../link/ports";
17
+ import { probeLinkRelayPreflight, type BoundLinkRelayTarget, type LinkRelayPreflightResult } from "./link-relay-transport";
18
+ import type { ClientLinkSupervisorStatus } from "./link-tunnel";
19
+
20
+ export type LinkReadinessReason =
21
+ | "tunnel_idle" | "tunnel_connecting" | "tunnel_failed" | "tunnel_unavailable" | "sidecar_invalid"
22
+ | "home_unreachable" | "home_unauthorized" | "link_auth_failed" | "link_credential_unavailable"
23
+ | "link_unverified";
24
+
25
+ export type LinkReadiness = { ready: true } | { ready: false; reason: LinkReadinessReason };
26
+
27
+ export const LINK_READINESS_TTL_MS = 5_000;
28
+ export const LINK_READINESS_WAIT_MS = 500;
29
+
30
+ const READY: LinkReadiness = { ready: true };
31
+ const notReady = (reason: LinkReadinessReason): LinkReadiness => ({ ready: false, reason });
32
+
33
+ /**
34
+ * A Child-initiated link from its supervisor. The keyed probe the supervisor runs every 30 seconds
35
+ * while connected is display-only for the tunnel, but a Home that stopped answering or refuses the
36
+ * key cannot serve a turn, so readiness follows it. A Home whose own startup readiness is pending
37
+ * still serves relayed requests (the probe's `home_not_ready`), so that reads ready.
38
+ */
39
+ export function clientInitiatedLinkReadiness(status: ClientLinkSupervisorStatus): LinkReadiness {
40
+ if (status.kind === "failed") return notReady("sidecar_invalid");
41
+ if (status.kind === "stopped") return notReady("tunnel_idle");
42
+ const { state, probe } = status;
43
+ if (state.kind === "connected") {
44
+ if (probe === "home_unreachable") return notReady("home_unreachable");
45
+ if (probe === "unauthorized") return notReady("home_unauthorized");
46
+ return READY;
47
+ }
48
+ if (state.kind === "connecting" || state.kind === "reconnecting") return notReady("tunnel_connecting");
49
+ if (state.kind === "failed") return notReady(state.inFlight ? "tunnel_connecting" : "tunnel_failed");
50
+ return notReady("tunnel_idle");
51
+ }
52
+
53
+ function fromPreflight(result: LinkRelayPreflightResult): LinkReadiness {
54
+ if (result === "ok") return READY;
55
+ return notReady(result === "unreachable" ? "home_unreachable" : "link_auth_failed");
56
+ }
57
+
58
+ export interface HomeInitiatedLinkReadinessDeps {
59
+ target: Omit<BoundLinkRelayTarget, "admissionKey">;
60
+ linkKey: () => string | null;
61
+ probe?: (target: BoundLinkRelayTarget, options: { timeoutMs: number }) => Promise<LinkRelayPreflightResult>;
62
+ now?: () => number;
63
+ /** Resolves after `ms`; the wait for a running preflight. */
64
+ delay?: (ms: number) => Promise<void>;
65
+ }
66
+
67
+ /** The readiness of a Home-initiated link, from a shared, rate-limited relay preflight. */
68
+ export function createHomeInitiatedLinkReadiness(deps: HomeInitiatedLinkReadinessDeps): () => Promise<LinkReadiness> {
69
+ const probe = deps.probe ?? probeLinkRelayPreflight;
70
+ const now = deps.now ?? Date.now;
71
+ const delay = deps.delay ?? (ms => new Promise<void>(resolve => {
72
+ const timer = setTimeout(resolve, ms);
73
+ (timer as { unref?: () => void }).unref?.();
74
+ }));
75
+ let last: { at: number; verdict: LinkReadiness } | undefined;
76
+ let flight: Promise<LinkReadiness> | undefined;
77
+ const run = (admissionKey: string): Promise<LinkReadiness> => {
78
+ flight ??= probe({ ...deps.target, admissionKey }, { timeoutMs: LINK_RELAY_AUTH_TIMEOUT_MS })
79
+ .then(fromPreflight, () => notReady("home_unreachable"))
80
+ .then(verdict => {
81
+ last = { at: now(), verdict };
82
+ return verdict;
83
+ })
84
+ .finally(() => { flight = undefined; });
85
+ return flight;
86
+ };
87
+ return async () => {
88
+ if (!isLinkPort(deps.target.tunnelPort)) return notReady("tunnel_unavailable");
89
+ let admissionKey: string | null;
90
+ try { admissionKey = deps.linkKey(); } catch { admissionKey = null; }
91
+ if (!admissionKey) return notReady("link_credential_unavailable");
92
+ const at = now();
93
+ if (last && at >= last.at && at - last.at < LINK_READINESS_TTL_MS) return last.verdict;
94
+ const unverified = notReady("link_unverified");
95
+ return await Promise.race([run(admissionKey), delay(LINK_READINESS_WAIT_MS).then(() => unverified)]);
96
+ };
97
+ }