@bitkyc08/opencodex 2.90.0 → 2.91.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 (132) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/{App-DgxkE6Md.js → App-nZ34cR5G.js} +18 -18
  3. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-TmaHa54u.js → index-DGXS51sp.js} +12 -12
  5. package/gui/dist/assets/{quota-summary-B16x5DZZ.js → quota-summary-DO1Sx5TM.js} +1 -1
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  10. package/src/adapters/openai-responses/passthrough.ts +2 -1
  11. package/src/adapters/openai-responses/request-strips.ts +32 -0
  12. package/src/adapters/openai-responses/transport.ts +6 -0
  13. package/src/chat/inbound.ts +20 -2
  14. package/src/chat/outbound.ts +36 -4
  15. package/src/chat/reasoning-details.ts +137 -0
  16. package/src/cli/capabilities-agents-routing.ts +50 -0
  17. package/src/cli/capabilities-base.ts +15 -2
  18. package/src/cli/capabilities-observe-system.ts +4 -4
  19. package/src/cli/codex-shim-autorestore.ts +5 -0
  20. package/src/cli/dispatch.ts +30 -0
  21. package/src/cli/fallback.ts +127 -0
  22. package/src/cli/help.ts +1 -0
  23. package/src/cli/index.ts +1 -2
  24. package/src/cli/link-apply-restart.ts +72 -0
  25. package/src/cli/link-child.ts +6 -2
  26. package/src/cli/link-cleanup.ts +95 -0
  27. package/src/cli/link-enrollment.ts +4 -0
  28. package/src/cli/link.ts +84 -9
  29. package/src/cli/provider.ts +44 -2
  30. package/src/cli/registry.ts +13 -1
  31. package/src/cli/restart-failure.ts +4 -0
  32. package/src/cli/runtime-api.ts +1 -1
  33. package/src/cli/status-codex-settings.ts +56 -0
  34. package/src/cli/status-fallback.ts +29 -0
  35. package/src/cli/status-install.ts +52 -0
  36. package/src/cli/status.ts +20 -1
  37. package/src/cli/system-restart-client.ts +32 -1
  38. package/src/cli/update-restart-child.ts +4 -3
  39. package/src/cli/update-restart-home.ts +20 -8
  40. package/src/client/connect.ts +82 -6
  41. package/src/client/home-unreachable.ts +16 -0
  42. package/src/client/hub-relay.ts +8 -2
  43. package/src/client/link-home.ts +119 -6
  44. package/src/client/link-parity-reporter.ts +107 -0
  45. package/src/client/link-relay-transport.ts +8 -1
  46. package/src/client/link-relay.ts +27 -3
  47. package/src/client/machine-listener.ts +83 -1
  48. package/src/client/runtime-restart.ts +127 -0
  49. package/src/client/runtime.ts +137 -25
  50. package/src/codex/auth-context.ts +10 -6
  51. package/src/codex/catalog/parsing.ts +4 -0
  52. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  53. package/src/codex/quota-history.ts +7 -0
  54. package/src/codex/quota.ts +10 -0
  55. package/src/codex/subagent-model-fallback.ts +24 -1
  56. package/src/combos/index.ts +1 -0
  57. package/src/combos/resolve.ts +12 -0
  58. package/src/config/diagnostics.ts +5 -0
  59. package/src/config/load-degrade.ts +2 -0
  60. package/src/config/schema/config-schema.ts +3 -0
  61. package/src/config/schema/fallback-policy.ts +40 -0
  62. package/src/generated/compatibility-version.json +205 -97
  63. package/src/lib/authorization-scheme.ts +95 -0
  64. package/src/lib/provider-client-headers.ts +1 -0
  65. package/src/lib/redact.ts +2 -2
  66. package/src/lib/system-restart-contract.ts +24 -0
  67. package/src/lib/test-home-guard.ts +78 -11
  68. package/src/link/child-parity.ts +124 -0
  69. package/src/link/codex-settings.ts +37 -0
  70. package/src/link/compatibility.ts +119 -0
  71. package/src/link/management-routes.ts +2 -0
  72. package/src/link/peer-report.ts +100 -0
  73. package/src/link/remote-platform.ts +78 -0
  74. package/src/link/ssh-argv.ts +19 -0
  75. package/src/link/status-projection.ts +24 -2
  76. package/src/oauth/index.ts +1 -1
  77. package/src/oauth/kimi.ts +2 -0
  78. package/src/oauth/nous.ts +4 -1
  79. package/src/oauth/store.ts +2 -1
  80. package/src/protocols/codecs/chat.ts +3 -3
  81. package/src/protocols/encoders/adapter-events.ts +26 -2
  82. package/src/protocols/encoders/chat.ts +55 -3
  83. package/src/providers/mistral-transport.ts +37 -4
  84. package/src/providers/quota/antigravity.ts +2 -8
  85. package/src/quota-pool/assemble.ts +326 -0
  86. package/src/quota-pool/contract.ts +58 -0
  87. package/src/quota-pool/identity.ts +203 -0
  88. package/src/remote/features.ts +74 -0
  89. package/src/remote/protocol.ts +2 -0
  90. package/src/remote/served-by.ts +57 -0
  91. package/src/responses/parser.ts +7 -0
  92. package/src/responses/reasoning-envelope.ts +12 -0
  93. package/src/routing/fallback-policy.ts +290 -0
  94. package/src/routing/fallback-state.ts +179 -0
  95. package/src/server/audio-client.ts +2 -1
  96. package/src/server/audio-dictation.ts +2 -1
  97. package/src/server/audio-upstream.ts +2 -1
  98. package/src/server/auth-cors.ts +6 -5
  99. package/src/server/background-lifecycle.ts +4 -0
  100. package/src/server/chat-completions.ts +8 -1
  101. package/src/server/gui-session.ts +2 -1
  102. package/src/server/index/link-listener.ts +11 -1
  103. package/src/server/index/link-management.ts +47 -0
  104. package/src/server/index/link-relay-sessions.ts +11 -2
  105. package/src/server/index/optional-listeners.ts +7 -2
  106. package/src/server/index/serve-options.ts +19 -2
  107. package/src/server/live.ts +5 -0
  108. package/src/server/management/context.ts +1 -1
  109. package/src/server/management/fallback-routes.ts +75 -0
  110. package/src/server/management/link-enrollment.ts +21 -3
  111. package/src/server/management/link-route-state.ts +2 -0
  112. package/src/server/management/link-routes.ts +12 -1
  113. package/src/server/management/quota-pool-routes.ts +24 -0
  114. package/src/server/management/route-registry.ts +3 -0
  115. package/src/server/management-api.ts +16 -0
  116. package/src/server/management-auth.ts +2 -1
  117. package/src/server/responses/agent-task-recovery.ts +1 -1
  118. package/src/server/responses/compact.ts +2 -2
  119. package/src/server/responses/core-auth.ts +11 -1
  120. package/src/server/responses/core-combo.ts +72 -1
  121. package/src/server/responses/encrypted-payload.ts +3 -3
  122. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  123. package/src/server/responses/policy-fallback.ts +44 -1
  124. package/src/server/responses/request-prepare.ts +9 -1
  125. package/src/server/restart-replacement.ts +50 -2
  126. package/src/server/ws-bridge.ts +5 -0
  127. package/src/service/cli.ts +25 -2
  128. package/src/service/diagnostics.ts +20 -6
  129. package/src/service/status-json.ts +116 -0
  130. package/src/types/config.ts +7 -0
  131. package/src/types/fallback.ts +38 -0
  132. package/gui/dist/assets/App-dnkJ85te.css +0 -1
@@ -1,4 +1,4 @@
1
- import { realpathSync, statSync } from "node:fs";
1
+ import { realpathSync, statSync, type BigIntStats } from "node:fs";
2
2
  import { readClientConnectionState } from "../client/state";
3
3
  import { readConfigDiagnostics } from "../config/diagnostics";
4
4
  import { getConfigDir } from "../config/paths";
@@ -6,23 +6,34 @@ import { currentCodexHome } from "../service/state";
6
6
  import { captureUpdateRestartServiceRecord, type UpdateRestartServiceRecord, type UpdateRestartServiceRecordDeps } from "./update-restart-service-record";
7
7
  import { probeUpdateRestartSupervision, type UpdateRestartSupervisionDeps } from "./update-restart-supervision";
8
8
 
9
+ /**
10
+ * `fileId` is the exact identity ("dev:ino" from a bigint stat). `dev` and `ino` are doubles and
11
+ * cannot be: an NTFS file id carries the MFT sequence in its high bits, so above 2^53 a replacement
12
+ * directory with a nearby MFT index can round to the same `ino` as the one it replaced.
13
+ */
14
+ export interface UpdateRestartDirectory { path: string; dev: number; ino: number; fileId: string }
15
+
9
16
  export interface UpdateRestartHome {
10
- config: { path: string; dev: number; ino: number };
11
- codex: { path: string; dev: number; ino: number };
17
+ config: UpdateRestartDirectory;
18
+ codex: UpdateRestartDirectory;
12
19
  revision: number;
13
20
  serviceRecord: UpdateRestartServiceRecord;
14
21
  }
15
22
 
16
- function physicalDirectory(path: string): UpdateRestartHome["config"] {
23
+ export type UpdateRestartDirectoryStat = Pick<BigIntStats, "dev" | "ino" | "isDirectory">;
24
+
25
+ function physicalDirectory(path: string, stat: (path: string) => UpdateRestartDirectoryStat): UpdateRestartDirectory {
17
26
  const canonical = realpathSync.native(path);
18
- const stat = statSync(canonical);
19
- if (!stat.isDirectory()) throw new Error("update_restart_home_unverified");
20
- return { path: canonical, dev: stat.dev, ino: stat.ino };
27
+ const observed = stat(canonical);
28
+ if (!observed.isDirectory()) throw new Error("update_restart_home_unverified");
29
+ return { path: canonical, dev: Number(observed.dev), ino: Number(observed.ino), fileId: `${observed.dev}:${observed.ino}` };
21
30
  }
22
31
 
23
32
  export interface UpdateRestartHomeDeps {
24
33
  record?: UpdateRestartServiceRecordDeps;
25
34
  supervision?: UpdateRestartSupervisionDeps;
35
+ /** Test seam for the directory identity source; production reads a bigint stat. */
36
+ statDirectory?: (path: string) => UpdateRestartDirectoryStat;
26
37
  }
27
38
 
28
39
  /** Capture eligibility from the same bytes that supply the frozen fingerprint. */
@@ -32,7 +43,8 @@ export function readUpdateRestartHome(deps: UpdateRestartHomeDeps = {}): UpdateR
32
43
  if (owner.kind !== "none" || (state.kind === "state" && platform !== "darwin" && platform !== "linux")) {
33
44
  throw new Error("update_restart_owner_unverified");
34
45
  }
35
- return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision, serviceRecord };
46
+ const stat = deps.statDirectory ?? (path => statSync(path, { bigint: true }));
47
+ return { config: physicalDirectory(getConfigDir(), stat), codex: physicalDirectory(currentCodexHome(), stat), revision: owner.revision, serviceRecord };
36
48
  }
37
49
 
38
50
  export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt = Date.now() + 2000, deps: UpdateRestartHomeDeps = {}): void {
@@ -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
  }
@@ -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
+ }
@@ -4,6 +4,9 @@ import { Readable } from "node:stream";
4
4
  import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
5
5
  import { serviceApiTokenFingerprint } from "../lib/service-secrets";
6
6
  import { clearableDeadline } from "../lib/abort";
7
+ import { packageVersion } from "../lib/package-version";
8
+ import { linkPeerReportHeaders } from "../link/peer-report";
9
+ import { buildPeerFeatures } from "../remote/features";
7
10
  import {
8
11
  LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
9
12
  LINK_RELAY_SESSION_HEADER, linkRelayChallenge, linkRelayProofMatches,
@@ -148,6 +151,8 @@ async function responseOnConnection(incoming: IncomingMessage, agent: SingleConn
148
151
  }, { highWaterMark: 0 }), { status, headers });
149
152
  }
150
153
 
154
+ let peerReport: Record<string, string> | undefined;
155
+
151
156
  /** Authenticate and stream one request on the SAME socket; never fall back to an ordinary fetch. */
152
157
  export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destination: string, init: RequestInit): Promise<Response> {
153
158
  if (!target.linkId || !target.apiKeyId) throw new LinkRelayAuthenticationError();
@@ -160,7 +165,9 @@ export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destinat
160
165
  const agent = new SingleConnectionAgent({ keepAlive: true, maxSockets: 1, maxFreeSockets: 1 });
161
166
  const deadline = clearableDeadline(LINK_RELAY_AUTH_TIMEOUT_MS, init.signal ?? undefined);
162
167
  try {
163
- const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal }, agent);
168
+ // The challenge also carries this Child's version and features for the Home's link status.
169
+ peerReport ??= linkPeerReportHeaders(packageVersion("0.0.0"), buildPeerFeatures());
170
+ const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal, headers: peerReport }, agent);
164
171
  const socket = agent.socket;
165
172
  const proof = response.headers["x-opencodex-link-proof"];
166
173
  if (response.statusCode !== 204 || typeof proof !== "string" || !linkRelayProofMatches(proof, challenge.expected)) {
@@ -1,4 +1,5 @@
1
1
  import { fetchBoundLinkRelay, LinkRelayAuthenticationError } from "./link-relay-transport";
2
+ import { homeUnreachableBody } from "./home-unreachable";
2
3
  import {
3
4
  boundedRelayResponseStream,
4
5
  filterRelayHeaders,
@@ -112,6 +113,29 @@ function jsonError(status: number, error: string, retry = false): Response {
112
113
  return Response.json({ error }, { status, headers });
113
114
  }
114
115
 
116
+ /** A retryable 503 for a Home this Child cannot reach (OCX-11 `home_unreachable`). */
117
+ function homeUnreachable(error: string): Response {
118
+ return Response.json(homeUnreachableBody(error), {
119
+ status: 503,
120
+ headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
121
+ });
122
+ }
123
+
124
+ /**
125
+ * The peer answered but the connection-bound relay authentication failed. That is not a Home
126
+ * outage (an unrecognized protocol needs an upgrade or a re-link), so it must never read as
127
+ * `home_unreachable`, which GOAT's device-local fallback keys on. The status and Retry-After it
128
+ * carried before OCX-11 are kept: a lost bound socket can succeed on a fresh connection.
129
+ */
130
+ export const LINK_RELAY_AUTH_FAILED_CODE = "link_relay_auth_failed";
131
+
132
+ function relayAuthenticationFailed(error: string): Response {
133
+ return Response.json({ error, code: LINK_RELAY_AUTH_FAILED_CODE }, {
134
+ status: 503,
135
+ headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
136
+ });
137
+ }
138
+
115
139
  export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
116
140
  if (!isLinkPort(target.tunnelPort)) {
117
141
  throw new RangeError("invalid link tunnel port");
@@ -369,7 +393,7 @@ export async function relayLinkDataRequest(
369
393
  const tunnelUnavailable = async (): Promise<Response> => {
370
394
  cleanup();
371
395
  try { await body?.cancel(); } catch { /* best effort */ }
372
- return jsonError(503, "link tunnel unavailable", true);
396
+ return homeUnreachable("link tunnel unavailable");
373
397
  };
374
398
  const readyForFetch = async (): Promise<boolean> => {
375
399
  if (!tunnel) return false;
@@ -393,7 +417,7 @@ export async function relayLinkDataRequest(
393
417
  if (error instanceof LinkRelayAuthenticationError) {
394
418
  cleanup();
395
419
  try { await body?.cancel(); } catch { /* an upload might already be drained */ }
396
- return jsonError(503, error.message, true);
420
+ return relayAuthenticationFailed(error.message);
397
421
  }
398
422
  if (bodyOverflow) {
399
423
  cleanup();
@@ -408,7 +432,7 @@ export async function relayLinkDataRequest(
408
432
  if (relayAbort.signal.aborted) {
409
433
  cleanup();
410
434
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
411
- return jsonError(503, "link tunnel unavailable", true);
435
+ return homeUnreachable("link tunnel unavailable");
412
436
  }
413
437
 
414
438
  const sse = isSse(upstream.headers);