@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
@@ -0,0 +1,95 @@
1
+ /**
2
+ * One parser for the inbound `Authorization` scheme.
3
+ *
4
+ * Bearer reads in the proxy used `replace(/^Bearer\s+/i, "")`, which returns any other value
5
+ * unchanged. Codex 0.164 runtime agent instances (`features.runtime_agent_instances`) can send
6
+ * `Authorization: AgentAssertion <jwt>`, and that replace handed the whole value on as an opaque
7
+ * bearer. An agent assertion is never a bearer: it is not an opencodex admission secret, it is not
8
+ * a ChatGPT credential, and it must never be forwarded as `Bearer`.
9
+ *
10
+ * The legacy read is preserved for every other value, including a scheme-less token, so this file
11
+ * changes nothing except the AgentAssertion case.
12
+ */
13
+
14
+ /** The companion header Codex sends in `dual_header` mode next to an ordinary Bearer. */
15
+ export const AGENT_ASSERTION_HEADER = "x-openai-agent-assertion";
16
+
17
+ /** Stable error code for `Authorization: AgentAssertion`, which opencodex does not route. */
18
+ export const AGENT_ASSERTION_UNSUPPORTED_CODE = "agent_assertion_unsupported";
19
+
20
+ export const AGENT_ASSERTION_UNSUPPORTED_MESSAGE = "Codex runtime agent assertion_only authentication "
21
+ + "(Authorization: AgentAssertion) is not supported through opencodex pooling or routing. "
22
+ + "Use dual_header mode, or turn off features.runtime_agent_instances.";
23
+
24
+ export type AuthorizationScheme =
25
+ | { kind: "none" }
26
+ | { kind: "bearer"; token: string }
27
+ | { kind: "agent-assertion" }
28
+ | { kind: "other"; credential: string };
29
+
30
+ const BEARER_PREFIX = /^Bearer\s+/i;
31
+ // RFC 9110 auth schemes are case-insensitive; the scheme ends at whitespace or end of value.
32
+ const AGENT_ASSERTION_SCHEME = /^\s*AgentAssertion(?:\s|$)/i;
33
+
34
+ /** Classify an Authorization value. The assertion's own bytes are deliberately not returned. */
35
+ export function parseAuthorizationScheme(value: string | null | undefined): AuthorizationScheme {
36
+ if (value == null) return { kind: "none" };
37
+ if (AGENT_ASSERTION_SCHEME.test(value)) return { kind: "agent-assertion" };
38
+ if (BEARER_PREFIX.test(value)) {
39
+ const token = value.replace(BEARER_PREFIX, "").trim();
40
+ return token ? { kind: "bearer", token } : { kind: "none" };
41
+ }
42
+ const credential = value.trim();
43
+ return credential ? { kind: "other", credential } : { kind: "none" };
44
+ }
45
+
46
+ /**
47
+ * The credential the legacy `replace(/^Bearer\s+/i, "").trim()` read produced, except that an
48
+ * AgentAssertion value yields nothing. Use this wherever that replace used to be.
49
+ */
50
+ export function authorizationCredential(headers: Pick<Headers, "get">): string | undefined {
51
+ const parsed = parseAuthorizationScheme(headers.get("authorization"));
52
+ if (parsed.kind === "bearer") return parsed.token;
53
+ if (parsed.kind === "other") return parsed.credential;
54
+ return undefined;
55
+ }
56
+
57
+ /** Whether the request authenticates with `Authorization: AgentAssertion` (assertion_only). */
58
+ export function hasAgentAssertionAuthorization(headers: Pick<Headers, "get">): boolean {
59
+ return parseAuthorizationScheme(headers.get("authorization")).kind === "agent-assertion";
60
+ }
61
+
62
+ export class AgentAssertionUnsupportedError extends Error {
63
+ readonly code = AGENT_ASSERTION_UNSUPPORTED_CODE;
64
+ constructor() {
65
+ super(AGENT_ASSERTION_UNSUPPORTED_MESSAGE);
66
+ this.name = "AgentAssertionUnsupportedError";
67
+ }
68
+ }
69
+
70
+ /** The typed refusal. It never echoes the request's Authorization or assertion header. */
71
+ export function agentAssertionUnsupportedResponse(): Response {
72
+ return Response.json({
73
+ error: {
74
+ message: AGENT_ASSERTION_UNSUPPORTED_MESSAGE,
75
+ type: "invalid_request_error",
76
+ code: AGENT_ASSERTION_UNSUPPORTED_CODE,
77
+ },
78
+ }, { status: 400, headers: { "cache-control": "no-store" } });
79
+ }
80
+
81
+ /**
82
+ * Carry the dual_header assertion onto first-party Direct headers, and only when the outbound
83
+ * Authorization is the caller's own Bearer, byte for byte. A pool account, a substituted main
84
+ * credential, or a stripped Authorization means the assertion would accompany a credential it was
85
+ * not sent with, so it is dropped instead.
86
+ */
87
+ export function carryAgentAssertionForCallerBearer(target: Headers, caller: Pick<Headers, "get">): void {
88
+ target.delete(AGENT_ASSERTION_HEADER);
89
+ const assertion = caller.get(AGENT_ASSERTION_HEADER);
90
+ if (!assertion) return;
91
+ const callerAuth = parseAuthorizationScheme(caller.get("authorization"));
92
+ const outboundAuth = parseAuthorizationScheme(target.get("authorization"));
93
+ if (callerAuth.kind !== "bearer" || outboundAuth.kind !== "bearer" || callerAuth.token !== outboundAuth.token) return;
94
+ target.set(AGENT_ASSERTION_HEADER, assertion);
95
+ }
@@ -18,6 +18,10 @@ import type { CodexAccountCredentialRecord } from "../types";
18
18
 
19
19
  export type DecisionPoolRead = readonly { id: string; usable: boolean; windows?: readonly DecisionQuotaWindow[] }[];
20
20
  export interface MainDecisionRow { generation: number; usable: boolean }
21
+ /** Opaque, process-local producer receipt; only the active snapshot can validate it. */
22
+ export interface AntigravityQuotaReceipt { readonly kind: "antigravity" }
23
+ export interface AntigravityQuotaObservation { name: string; provider: OcxProviderConfig; receipt: AntigravityQuotaReceipt; windows: readonly DecisionQuotaWindow[] }
24
+ export interface DecisionKeyObservation { name: string; provider: OcxProviderConfig; windows: readonly DecisionQuotaWindow[] }
21
25
 
22
26
  export interface DecisionQuotaSink {
23
27
  observeKeyCredential(reference: string, resolved: string | undefined): void;
@@ -32,6 +36,8 @@ export interface DecisionQuotaSink {
32
36
  mainRosterChanged(row: MainDecisionRow | undefined): void;
33
37
  readKeyQuota(name: string, provider: OcxProviderConfig): readonly DecisionQuotaWindow[] | undefined;
34
38
  readPool(provider: string): DecisionPoolRead | undefined;
39
+ captureAntigravityReceipt(name: string, provider: OcxProviderConfig, id: string, generation: string): AntigravityQuotaReceipt | undefined;
40
+ replaceCollectedQuotas(keys: readonly DecisionKeyObservation[], oauth: readonly AntigravityQuotaObservation[]): void;
35
41
  }
36
42
 
37
43
  let sink: DecisionQuotaSink | undefined;
@@ -71,6 +77,10 @@ export function notifyCodexStoreLoaded(store: Readonly<Record<string, CodexAccou
71
77
  export function notifyMainRosterChanged(row: () => MainDecisionRow | undefined): void { forward(s => s.mainRosterChanged(row())); }
72
78
  export function readLoadedDecisionKeyQuota(name: string, provider: OcxProviderConfig): readonly DecisionQuotaWindow[] | undefined { return read(s => s.readKeyQuota(name, provider)); }
73
79
  export function readLoadedDecisionQuotaPool(provider: string): DecisionPoolRead | undefined { return read(s => s.readPool(provider)); }
80
+ /** Capture from the producer's already-loaded credential, never from a decision read. */
81
+ export function captureAntigravityQuotaReceipt(name: string, provider: OcxProviderConfig, id: string, generation: string): AntigravityQuotaReceipt | undefined { return read(s => s.captureAntigravityReceipt(name, provider, id, generation)); }
82
+ /** Accepted collector atomically replaces its key/instance evidence set. */
83
+ export function replaceCollectedDecisionQuotas(keys: readonly DecisionKeyObservation[], oauth: readonly AntigravityQuotaObservation[]): void { forward(s => s.replaceCollectedQuotas(keys, oauth)); }
74
84
 
75
85
  /** Test-only: drop any registration a previous test left behind. */
76
86
  export function resetDecisionQuotaSinkForTests(): void { sink = undefined; }
@@ -21,6 +21,7 @@ const BLOCKED_FORWARDED_CLIENT_HEADERS = new Set([
21
21
  "api-key",
22
22
  "x-goog-api-key",
23
23
  "x-oai-attestation",
24
+ "x-openai-agent-assertion",
24
25
  ]);
25
26
 
26
27
  const ALLOWED_FORWARDED_CLIENT_HEADERS = new Set([
package/src/lib/redact.ts CHANGED
@@ -7,7 +7,7 @@ export const REDACTED_SECRET = "[REDACTED]";
7
7
  * credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than
8
8
  * re-deriving a narrower local list.
9
9
  */
10
- export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
10
+ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|x-openai-agent-assertion|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
11
11
 
12
12
  /**
13
13
  * Colon-labelled credential headers echoed back inside an error body
@@ -40,7 +40,7 @@ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cook
40
40
  // Every letter position also accepts \u0001, the placeholder the fold emits for
41
41
  // an unresolved HTML named reference: `author&ii;zation` is the label with one
42
42
  // character we cannot name, and that is still the label.
43
- const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
43
+ const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-openai-agent-assertion|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
44
44
 
45
45
  const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW
46
46
  .replace(/(?<![\[\\])([A-Za-z])(?![\]\-])/g, "[$1\u0001]");
@@ -12,6 +12,30 @@ export const SYSTEM_RESTART_CAPABILITY_HEADER = "x-opencodex-restart-capability"
12
12
  export const MEMORY_DRAIN_RESTART_MS = 60_000;
13
13
  export const REPLACEMENT_READY_TIMEOUT_MS = 70_000;
14
14
 
15
+ /**
16
+ * A connected client runtime's whole restart, from accepting the request to serving again on the
17
+ * old process after a failed replacement (OCX-6). The CLI sends the request only while this budget
18
+ * still fits before its own replacement observation ends, so a rollback is never raced by the
19
+ * CLI's recovery start. The phases are measured from acceptance.
20
+ */
21
+ export const CLIENT_RESTART_BUDGET_MS = 90_000;
22
+ /** In-flight requests on the client listener get this long before the listener closes them. */
23
+ export const CLIENT_RESTART_DRAIN_MS = 20_000;
24
+ /** The replacement must answer by this point; a later one is stopped and the old runtime resumes. */
25
+ export const CLIENT_RESTART_READY_BY_MS = 70_000;
26
+ /** How long a rollback waits for the port the stopped replacement released. */
27
+ export const CLIENT_RESTART_REBIND_WAIT_MS = 10_000;
28
+ /** The 409 code a supervised client runtime answers a bound restart with. */
29
+ export const RESTART_SUPERVISED_ERROR = "restart_supervised";
30
+ /** `ocx restart` stops waiting for a replacement this long before its overall deadline. */
31
+ export const RESTART_REOBSERVE_RESERVE_MS = 10_000;
32
+ /**
33
+ * Room `ocx restart` must still have before it may ask a client runtime to restart: the client's
34
+ * whole budget, the reobserve reserve and a margin for the request itself. A later request is
35
+ * refused before it is sent, so a rollback always finishes while the CLI is still observing.
36
+ */
37
+ export const CLIENT_RESTART_REQUEST_MIN_REMAINING_MS = CLIENT_RESTART_BUDGET_MS + RESTART_REOBSERVE_RESERVE_MS + 5_000;
38
+
15
39
  /**
16
40
  * The env var a restarting process hands its replacement `ocx start`: the restarting (parent) pid.
17
41
  *
@@ -13,14 +13,15 @@
13
13
  * 1. It must be INERT in production. Guessing "am I a test?" from ecosystem variables
14
14
  * like NODE_ENV would brick `NODE_ENV=test ocx ...` for a user who did nothing
15
15
  * wrong — worse than the bug it prevents. Arming requires OCX_TEST_HOME_GUARD=1,
16
- * which only this repository's test preload sets.
16
+ * which this repository's test preload sets, or a process whose entry module is a
17
+ * Bun test file (see {@link isBunTestFileProcess}); no production entrypoint is one.
17
18
  * 2. It must fail CLOSED for code nobody has written yet. So it denies ONE path — the
18
19
  * captured production home — instead of allow-listing known-good test directories.
19
20
  * An allowlist would have to be opted into, and the test that forgets is exactly
20
21
  * how this incident happened.
21
22
  */
22
23
  import { homedir } from "node:os";
23
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
24
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
24
25
  import { realpathSync } from "node:fs";
25
26
  import { fileURLToPath } from "node:url";
26
27
 
@@ -109,8 +110,71 @@ export function protectedClaudeConfigDirsForTests(): readonly string[] {
109
110
  return PROTECTED_CLAUDE_TREES.map(tree => tree.path);
110
111
  }
111
112
 
113
+ /**
114
+ * The file names Bun's test runner discovers: `.test`, `_test`, `.spec` or `_spec` before a
115
+ * JS/TS extension. Under `bun test`, `Bun.main` names the test file being evaluated, in the
116
+ * main runner and in `--parallel` workers alike. Every production entrypoint — the CLI, the
117
+ * service daemon, the compiled sidecar (`/$bunfs/...`), a release script — has a different name.
118
+ */
119
+ const BUN_TEST_FILE = /[._](?:test|spec)\.[cm]?[jt]sx?$/i;
120
+
121
+ /**
122
+ * Whether this process runs a Bun test file as its entry module.
123
+ *
124
+ * This exists for the run that never loads `tests/preload.ts`. Bun resolves `bunfig.toml` from
125
+ * the CURRENT WORKING DIRECTORY, so `cd /tmp && bun test <repo>/tests/x.test.ts` skips the
126
+ * preload: HOME stays real, OPENCODEX_HOME stays unset, and before this check the guard stayed
127
+ * down, so an OAuth save landed in the developer's real `~/.opencodex/auth.json` (#7407).
128
+ * Bun exposes no runner flag, and NODE_ENV=test is both settable by users and overridable by the
129
+ * runner's caller, so the entry file's name is the one signal that production never carries.
130
+ *
131
+ * A file passed by explicit path under a non-test name (`bun test ./scratch.ts`) is not
132
+ * recognised; every repository suite is a `*.test.ts`, the only name `tests/test-layout.test.ts` admits.
133
+ *
134
+ * The converse also holds: `bun run ./something.test.ts` arms, because nothing distinguishes it
135
+ * from the runner. That is accepted. Requiring NODE_ENV=test as well would exempt every developer
136
+ * whose shell exports another NODE_ENV (`bun test` keeps an existing value), reopening #7407;
137
+ * the cost of the false positive is a refused real-home write from a script named like a test.
138
+ */
139
+ export function isBunTestFileProcess(): boolean {
140
+ const main = (globalThis as { Bun?: { main?: unknown } }).Bun?.main;
141
+ const entry = typeof main === "string" ? main : process.argv[1];
142
+ return typeof entry === "string" && BUN_TEST_FILE.test(basename(entry));
143
+ }
144
+
145
+ /**
146
+ * Armed when the preload (or a harness) set OCX_TEST_HOME_GUARD=1, or when this process runs a
147
+ * Bun test file and nobody set the flag. An explicit OCX_TEST_HOME_GUARD=0 is how a test case
148
+ * exercises the production branch of a guarded call, so it disarms even inside a test file.
149
+ */
112
150
  export function isTestHomeGuardArmed(): boolean {
113
- return process.env[GUARD_ENV] === "1";
151
+ const flag = process.env[GUARD_ENV];
152
+ if (flag === "1") return true;
153
+ if (flag === "0") return false;
154
+ return isBunTestFileProcess();
155
+ }
156
+
157
+ /**
158
+ * A test process that armed through its entry file publishes the flag, so the CLI and helper
159
+ * processes it spawns — whose own entry is not a test file — inherit the arming instead of
160
+ * writing the real home they also inherited. Runs at import, which in such a run happens before
161
+ * any test body. A production process never matches, so it never reaches the assignment. Any value
162
+ * but the explicit "0" opt-out is normalised, because an empty or stray value arms this process by
163
+ * name but would leave a non-test child, which cannot match by name, disarmed.
164
+ */
165
+ if (process.env[GUARD_ENV] !== "0" && process.env[GUARD_ENV] !== "1" && isBunTestFileProcess()) {
166
+ process.env[GUARD_ENV] = "1";
167
+ }
168
+
169
+ /**
170
+ * Appended to a refusal when no preload ran, because the fix there is different: the test did
171
+ * not forget OPENCODEX_HOME, the whole run started from a directory without bunfig.toml.
172
+ */
173
+ function missingPreloadHint(): string {
174
+ if (process.env.OCX_TEST_PRELOAD_PID !== undefined) return "";
175
+ return " This test process did not load tests/preload.ts — Bun reads bunfig.toml from the "
176
+ + "current working directory — so nothing was sandboxed. Run bun test from the repository "
177
+ + "root, or pass --preload <repo>/tests/preload.ts.";
114
178
  }
115
179
 
116
180
  /**
@@ -144,7 +208,8 @@ export function assertNotRealHomeUnderTest(dir: string): void {
144
208
  throw new Error(
145
209
  `refusing to write the real OpenCodex home (${PROTECTED_HOME}) from a test process. `
146
210
  + "Point OPENCODEX_HOME at a temp directory for this test, or inject persistence "
147
- + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).",
211
+ + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070)."
212
+ + missingPreloadHint(),
148
213
  );
149
214
  }
150
215
 
@@ -177,7 +242,8 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void {
177
242
  throw new Error(
178
243
  `refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. `
179
244
  + "Point CODEX_HOME at a temp directory for this test before writing Codex files "
180
- + "(auth.json, the catalog, models_cache.json, the journal or config.toml).",
245
+ + "(auth.json, the catalog, models_cache.json, the journal or config.toml)."
246
+ + missingPreloadHint(),
181
247
  );
182
248
  }
183
249
 
@@ -351,12 +417,13 @@ export function protectedRemovalReason(target: string): string | null {
351
417
  /**
352
418
  * Throw before a removal that would reach a protected tree.
353
419
  *
354
- * Deliberately NOT gated on {@link isTestHomeGuardArmed}. Arming happens in `tests/preload.ts`,
355
- * which Bun loads from the `bunfig.toml` it finds in the CURRENT WORKING DIRECTORY — so a run
356
- * started outside the repository arms nothing, leaves OPENCODEX_HOME unset, and resolves the
357
- * developer's real home. That unarmed run is precisely the one that caused the incident, so the
358
- * refusal has to hold without it. Nothing in production calls this; the callers are test
359
- * helpers, where the only cost of an unconditional check is a path comparison.
420
+ * Deliberately NOT gated on {@link isTestHomeGuardArmed}. `tests/preload.ts` is loaded from the
421
+ * `bunfig.toml` Bun finds in the CURRENT WORKING DIRECTORY, so a run started outside the
422
+ * repository leaves OPENCODEX_HOME unset and resolves the developer's real home. Such a run now
423
+ * arms through its test-file entry, but a helper launched as a plain script does not, and the
424
+ * incident was exactly this unsandboxed shape, so the refusal has to hold without arming.
425
+ * Nothing in production calls this; the callers are test helpers, where the only cost of an
426
+ * unconditional check is a path comparison.
360
427
  */
361
428
  export function assertRemovalOutsideProtectedTrees(target: string): void {
362
429
  const reason = protectedRemovalReason(target);
@@ -1,4 +1,4 @@
1
- import { isNonReplayableResponse, markResponseNonReplayable } from "./upstream-retry";
1
+ import { carryReplayRefusal, isNonReplayableResponse, markResponseNonReplayable } from "./upstream-retry";
2
2
 
3
3
  export const TRANSLATOR_MAX_CALL_ARGUMENT_BYTES = 2 * 1024 * 1024;
4
4
  export const TRANSLATOR_MAX_TURN_BYTES = 32 * 1024 * 1024;
@@ -413,8 +413,8 @@ export function finalizeTranslatorBudgetResponse(response: Response, budget: Tra
413
413
  if (signal?.aborted) finalize();
414
414
  else signal?.addEventListener("abort", finalize, { once: true });
415
415
  const finalizedResponse = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
416
- // The rebuild above drops identity markers. A refusal that must not be replayed
417
- // stays non-replayable through accounting finalization.
416
+ // Preserve both replay suppression and refusal provenance across the rebuild
417
+ // so deferred logging can distinguish synthetic refusals from upstream 429s.
418
418
  if (isNonReplayableResponse(response)) markResponseNonReplayable(finalizedResponse);
419
- return finalizedResponse;
419
+ return carryReplayRefusal(response, finalizedResponse);
420
420
  }
@@ -770,7 +770,10 @@ export async function fetchWithTransientRetry(
770
770
  for (let attempt = 0; sent < budget; attempt++) {
771
771
  // A non-replayable gateway status was settled after the request body had already left
772
772
  // for the origin; retrying it here is the automatic resend the marker exists to forbid.
773
- if (res.ok || !isTransientUpstreamStatus(res.status) || isNonReplayableResponse(res)) return res;
773
+ // An upstream that answers with the exact `x-should-retry: false` verdict has said the same
774
+ // thing about its own failure, so the ladder stops and hands the response back untouched.
775
+ if (res.ok || !isTransientUpstreamStatus(res.status) || isNonReplayableResponse(res)
776
+ || res.headers.get(REPLAY_REFUSAL_NO_RETRY_HEADER) === REPLAY_REFUSAL_NO_RETRY_VALUE) return res;
774
777
  // Checked before cancelResponseBodyBestEffort so an already-aborted caller never receives
775
778
  // a response whose body we just cancelled.
776
779
  if (opts.abortSignal?.aborted) return res;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * What a Remote Link Child tells its Home about the last settings apply (OCX-6).
3
+ *
4
+ * The Child knows when it last applied the Home's Codex settings, which digest it applied and when
5
+ * its runtime started; the Home knows none of it. The Child reports these facts over the link
6
+ * (`POST` to {@link LINK_CHILD_PARITY_PATH}, admitted by its link key) and the Home shows them on
7
+ * each link row of `GET /api/link/status` (`lastApplyAt`, `runtimeStartedAt`,
8
+ * `appliedCodexSettingsDigest`), so a peer can compare apply time with runtime start, and the
9
+ * applied digest with the Home's own, without reading the Child.
10
+ *
11
+ * Display only. A report never admits, routes or changes anything on the Home, carries no setting
12
+ * value, and lives in memory: a Home restart forgets it until the Child's next report (at most a
13
+ * minute later).
14
+ */
15
+ import { CODEX_SETTINGS_DIGEST_PATTERN } from "./codex-settings";
16
+ import { LINK_MANAGEMENT_PREFIX } from "./management-routes";
17
+
18
+ /** The one write a Child may send its Home over the link. */
19
+ export const LINK_CHILD_PARITY_PATH = `${LINK_MANAGEMENT_PREFIX}/child-parity`;
20
+ /** A report is four short fields; anything larger is refused while it is still being read. */
21
+ export const LINK_CHILD_PARITY_MAX_BYTES = 1024;
22
+
23
+ const LINK_ID = /^lnk_[0-9a-f]{16}$/;
24
+ /** 2001-09-09 .. 2286-11-20: any epoch-millisecond time a real clock reports. */
25
+ const MIN_EPOCH_MS = 1_000_000_000_000;
26
+ const MAX_EPOCH_MS = 9_999_999_999_999;
27
+
28
+ export interface LinkChildParityReport {
29
+ linkId: string;
30
+ /** Epoch ms of the Child's last successful apply of a different Home settings document. */
31
+ lastApplyAt: number | null;
32
+ /** Epoch ms of the Child runtime's process start. */
33
+ runtimeStartedAt: number;
34
+ /** `appliedCodexSettingsDigest` of the applied settings; null before any apply. */
35
+ appliedCodexSettingsDigest: string | null;
36
+ /**
37
+ * Increases with every report this runtime process sends. With `runtimeStartedAt` it orders
38
+ * reports, so a late POST from an older runtime or an earlier send never replaces a newer one.
39
+ */
40
+ sequence: number;
41
+ }
42
+
43
+ /** The per-link fields `GET /api/link/status` carries; every one is null until a report arrives. */
44
+ export interface LinkChildParity {
45
+ lastApplyAt: number | null;
46
+ runtimeStartedAt: number | null;
47
+ appliedCodexSettingsDigest: string | null;
48
+ /** Epoch ms the Home received the report (the Home's clock; the other times are the Child's). */
49
+ reportedAt: number | null;
50
+ }
51
+
52
+ export const EMPTY_LINK_CHILD_PARITY: LinkChildParity = Object.freeze({
53
+ lastApplyAt: null, runtimeStartedAt: null, appliedCodexSettingsDigest: null, reportedAt: null,
54
+ });
55
+
56
+ export function isEpochMs(value: unknown): value is number {
57
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= MIN_EPOCH_MS && value <= MAX_EPOCH_MS;
58
+ }
59
+
60
+ function exactKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
62
+ const actual = Object.keys(value);
63
+ return actual.length === keys.length && actual.every(key => keys.includes(key));
64
+ }
65
+
66
+ /** Strict: an unknown field, a non-integer time or a malformed digest rejects the report. */
67
+ export function parseLinkChildParityReport(value: unknown): LinkChildParityReport | null {
68
+ if (!exactKeys(value, ["linkId", "lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "sequence"])) return null;
69
+ const { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence } = value;
70
+ if (typeof linkId !== "string" || !LINK_ID.test(linkId)) return null;
71
+ if (lastApplyAt !== null && !isEpochMs(lastApplyAt)) return null;
72
+ if (!isEpochMs(runtimeStartedAt)) return null;
73
+ if (digest !== null && (typeof digest !== "string" || !CODEX_SETTINGS_DIGEST_PATTERN.test(digest))) return null;
74
+ // An apply has both a time and a digest; before the first apply it has neither.
75
+ if ((lastApplyAt === null) !== (digest === null)) return null;
76
+ if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence < 0) return null;
77
+ return { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence };
78
+ }
79
+
80
+ export interface LinkChildParityStore {
81
+ /**
82
+ * Keep the report for its link unless a newer one is already kept (by `runtimeStartedAt`, then
83
+ * `sequence`), dropping every entry whose link is not in `currentLinkIds`. True when kept.
84
+ */
85
+ record(report: LinkChildParityReport, currentLinkIds: ReadonlySet<string>): boolean;
86
+ get(linkId: string): LinkChildParity;
87
+ clear(): void;
88
+ }
89
+
90
+ export function createLinkChildParityStore(now: () => number = Date.now): LinkChildParityStore {
91
+ const reports = new Map<string, LinkChildParity & { sequence: number }>();
92
+ return {
93
+ record(report, currentLinkIds) {
94
+ for (const id of reports.keys()) if (!currentLinkIds.has(id)) reports.delete(id);
95
+ if (!currentLinkIds.has(report.linkId)) return false;
96
+ const kept = reports.get(report.linkId);
97
+ if (kept && (kept.runtimeStartedAt! > report.runtimeStartedAt
98
+ || (kept.runtimeStartedAt === report.runtimeStartedAt && kept.sequence >= report.sequence))) return false;
99
+ reports.set(report.linkId, {
100
+ lastApplyAt: report.lastApplyAt,
101
+ runtimeStartedAt: report.runtimeStartedAt,
102
+ appliedCodexSettingsDigest: report.appliedCodexSettingsDigest,
103
+ reportedAt: now(),
104
+ sequence: report.sequence,
105
+ });
106
+ return true;
107
+ },
108
+ get(linkId) {
109
+ const kept = reports.get(linkId);
110
+ if (!kept) return { ...EMPTY_LINK_CHILD_PARITY };
111
+ const { sequence: _sequence, ...parity } = kept;
112
+ return parity;
113
+ },
114
+ clear() { reports.clear(); },
115
+ };
116
+ }
117
+
118
+ /** The Home's reports: one process-wide store, bounded by the link store on every write. */
119
+ export const homeLinkChildParity: LinkChildParityStore = createLinkChildParityStore();
120
+
121
+ /** Home link listener: is this the Child's report (exact path, POST, no query, no upgrade)? */
122
+ export function linkChildParityRouteAllowed(url: URL, req: Request): boolean {
123
+ return url.pathname === LINK_CHILD_PARITY_PATH && req.method === "POST" && url.search === "" && !req.headers.has("upgrade");
124
+ }
@@ -23,6 +23,7 @@
23
23
  * It rides on its own authenticated link read rather than inside the catalog because the
24
24
  * catalog bytes are written verbatim to `model_catalog_json`, which Codex parses itself.
25
25
  */
26
+ import { createHash } from "node:crypto";
26
27
  import { hostname } from "node:os";
27
28
  import type { OcxConfig } from "../types";
28
29
 
@@ -153,3 +154,39 @@ export function describeHomeCodexSettings(settings: HomeCodexSettings): string[]
153
154
  `native sub-agent defaults: ${d ? `${d.model}${d.reasoningEffort ? ` (${d.reasoningEffort})` : ""}` : "not synced"}`,
154
155
  ];
155
156
  }
157
+
158
+ /** 64 lowercase hex digits: the only form a settings digest takes on the wire. */
159
+ export const CODEX_SETTINGS_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
160
+
161
+ /**
162
+ * The exact bytes {@link codexSettingsDigest} hashes: compact UTF-8 JSON in a fixed key order,
163
+ *
164
+ * {"version":1,"settings":{"fastMode":…,"multiAgentMode":…,"webSearchSidecarEnabled":…,
165
+ * "subagentDefaults":null|{"model":…,"reasoningEffort":…}}}
166
+ *
167
+ * This is a published contract (GOAT's hub recomputes it byte for byte), so the order is written
168
+ * out here rather than left to a canonicalizer, and every value is copied by name: whatever key
169
+ * order or extra field the input object has, the output is the same.
170
+ */
171
+ export function codexSettingsDigestPayload(settings: HomeCodexSettings): string {
172
+ const d = settings.subagentDefaults;
173
+ return JSON.stringify({
174
+ version: HOME_CODEX_SETTINGS_VERSION,
175
+ settings: {
176
+ fastMode: settings.fastMode,
177
+ multiAgentMode: settings.multiAgentMode,
178
+ webSearchSidecarEnabled: settings.webSearchSidecarEnabled,
179
+ subagentDefaults: d ? { model: d.model, reasoningEffort: d.reasoningEffort } : null,
180
+ },
181
+ });
182
+ }
183
+
184
+ /**
185
+ * `appliedCodexSettingsDigest` (OCX-6b): lowercase hex SHA-256 of
186
+ * {@link codexSettingsDigestPayload}. It covers the applied values only, never the Home's name or
187
+ * version, so a Home and a Child that applied the same values agree, and any value change differs.
188
+ * It carries no setting value, so it is safe wherever `ocx status --json` goes.
189
+ */
190
+ export function codexSettingsDigest(settings: HomeCodexSettings): string {
191
+ return createHash("sha256").update(codexSettingsDigestPayload(settings), "utf8").digest("hex");
192
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Home and Child compatibility over a Remote Link.
3
+ *
4
+ * `LINK_PROTOCOL_RELEASES` is the published record of which release first spoke each part of the
5
+ * link protocol. It is history, so it cannot be derived at run time; `tests/clients/link-peer-
6
+ * compatibility.test.ts` holds every id to the live feature registry and every release to the
7
+ * package version. The verdict uses only what the link carries: the relay-auth version of a
8
+ * verified challenge and, from 2.91.0, the Child's own version and feature report
9
+ * (`src/link/peer-report.ts`).
10
+ */
11
+ import { LINK_RELAY_AUTH_VERSION } from "./relay-auth";
12
+ import { LINK_PEER_REPORT_VERSION, type LinkPeerEntry } from "./peer-report";
13
+
14
+ export interface LinkProtocolRelease {
15
+ /** The peer feature id, as `GET /readyz` lists it. */
16
+ readonly id: string;
17
+ /** First release that speaks it on both sides of a link. */
18
+ readonly since: string;
19
+ readonly summary: string;
20
+ }
21
+
22
+ export const LINK_PEER_REPORT_FEATURE = `link-peer-report/${LINK_PEER_REPORT_VERSION}`;
23
+
24
+ export const LINK_PROTOCOL_RELEASES: readonly LinkProtocolRelease[] = [
25
+ { id: "relay-auth/2", since: "2.69.0", summary: "Connection-bound relay authentication before every relayed request." },
26
+ { id: "link-management-relay", since: "2.84.0", summary: "Read-only Home management and Home Codex settings for the Child." },
27
+ { id: LINK_PEER_REPORT_FEATURE, since: "2.91.0", summary: "The Child reports its version and features on the relay challenge." },
28
+ ];
29
+
30
+ export type LinkCompatibilityVerdict = "compatible" | "degraded" | "incompatible" | "unknown";
31
+
32
+ export interface LinkLocalPeer {
33
+ version: string;
34
+ relayAuth: string;
35
+ features: string[];
36
+ }
37
+
38
+ export interface LinkPeerStatus {
39
+ observedAt: string | null;
40
+ /** The Child's reported release, or null when it predates the report. */
41
+ version: string | null;
42
+ /** For an unreported version: the oldest release the observed protocol implies, and the first that would report. */
43
+ versionRange: { min: string | null; below: string } | null;
44
+ relayAuth: string | null;
45
+ features: string[] | null;
46
+ compatibility: LinkCompatibilityVerdict;
47
+ reasons: string[];
48
+ }
49
+
50
+ function releaseParts(version: string): number[] {
51
+ return version.split(/[-+]/, 1)[0]!.split(".").map(part => Number(part));
52
+ }
53
+
54
+ export function compareReleases(a: string, b: string): number {
55
+ const left = releaseParts(a), right = releaseParts(b);
56
+ for (let i = 0; i < 3; i++) {
57
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
58
+ if (diff !== 0) return Math.sign(diff);
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ function sinceOf(id: string): string | null {
64
+ return LINK_PROTOCOL_RELEASES.find(release => release.id === id)?.since ?? null;
65
+ }
66
+
67
+ function relayAuthNumber(value: string): number {
68
+ return /^\d{1,4}$/.test(value) ? Number(value) : Number.NaN;
69
+ }
70
+
71
+ export function linkPeerStatus(local: LinkLocalPeer, entry: LinkPeerEntry): LinkPeerStatus {
72
+ const { observed, refused } = entry;
73
+ if (refused && (!observed || observed.observedAt <= refused.refusedAt)) {
74
+ const newerChild = relayAuthNumber(refused.relayAuth) > relayAuthNumber(local.relayAuth);
75
+ return {
76
+ observedAt: refused.refusedAt, version: null, versionRange: null, relayAuth: refused.relayAuth, features: null,
77
+ compatibility: "incompatible",
78
+ reasons: [`The Child speaks relay-auth/${refused.relayAuth} and this Home speaks relay-auth/${local.relayAuth}, so relayed requests are refused. Upgrade the ${newerChild ? "Home" : "Child"}.`],
79
+ };
80
+ }
81
+ if (!observed) {
82
+ return {
83
+ observedAt: null, version: null, versionRange: null, relayAuth: null, features: null,
84
+ compatibility: "unknown", reasons: ["No relayed request from this Child since the Home started."],
85
+ };
86
+ }
87
+ const base = { observedAt: observed.observedAt, relayAuth: observed.relayAuth, features: observed.features };
88
+ if (!observed.reported) {
89
+ return {
90
+ ...base, version: null,
91
+ versionRange: { min: sinceOf(`relay-auth/${observed.relayAuth}`), below: sinceOf(LINK_PEER_REPORT_FEATURE)! },
92
+ compatibility: "compatible",
93
+ reasons: [`relay-auth/${observed.relayAuth} matches. The Child does not report its version; it predates ${LINK_PEER_REPORT_FEATURE}.`],
94
+ };
95
+ }
96
+ if (observed.version === null || observed.features === null) {
97
+ // A reporting Child whose version or feature list was missing or rejected: the Home cannot tell what it uses.
98
+ return {
99
+ ...base, version: observed.version, versionRange: null, compatibility: "unknown",
100
+ reasons: [`relay-auth/${observed.relayAuth} matches, but the Child's ${observed.version === null && observed.features === null ? "version and feature list were" : observed.version === null ? "version was" : "feature list was"} missing or invalid, so its features cannot be checked.`],
101
+ };
102
+ }
103
+ const childOnly = observed.features.filter(id => !local.features.includes(id));
104
+ if (childOnly.length > 0) {
105
+ return {
106
+ ...base, version: observed.version, versionRange: null, compatibility: "degraded",
107
+ reasons: [`The Child (${observed.version}) uses ${childOnly.join(", ")}, which this Home (${local.version}) does not serve. Upgrade the Home.`],
108
+ };
109
+ }
110
+ return {
111
+ ...base, version: observed.version, versionRange: null, compatibility: "compatible",
112
+ reasons: [compareReleases(observed.version, local.version) === 0 ? "Same release as this Home."
113
+ : `relay-auth/${observed.relayAuth} matches and this Home (${local.version}) serves every feature the Child (${observed.version}) uses.`],
114
+ };
115
+ }
116
+
117
+ export function localLinkPeer(version: string, features: readonly string[]): LinkLocalPeer {
118
+ return { version, relayAuth: LINK_RELAY_AUTH_VERSION, features: [...features] };
119
+ }