@bitkyc08/opencodex 2.83.0 → 2.84.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 (216) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/ollama-native.ts +29 -1
  10. package/src/adapters/openai-chat/messages.ts +8 -4
  11. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  12. package/src/adapters/openai-responses/passthrough.ts +3 -0
  13. package/src/adapters/openai-responses/request-strips.ts +10 -2
  14. package/src/adapters/qoder/adapter.ts +16 -14
  15. package/src/bridge/errors.ts +19 -0
  16. package/src/bridge.ts +1 -1
  17. package/src/claude/context-windows.ts +54 -1
  18. package/src/claude/intercept/connect-proxy.ts +10 -3
  19. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  20. package/src/claude/intercept/picker-listener.ts +49 -1
  21. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  22. package/src/claude/intercept/settings.ts +101 -9
  23. package/src/cli/account-api.ts +65 -0
  24. package/src/cli/account-auth.ts +26 -2
  25. package/src/cli/account.ts +41 -5
  26. package/src/cli/capabilities-access-remote.ts +8 -0
  27. package/src/cli/capabilities-base.ts +13 -1
  28. package/src/cli/catalog-command-result.ts +2 -1
  29. package/src/cli/claude.ts +15 -6
  30. package/src/cli/config-command.ts +8 -2
  31. package/src/cli/debug.ts +8 -0
  32. package/src/cli/dispatch.ts +16 -3
  33. package/src/cli/export-command.ts +14 -1
  34. package/src/cli/hub-disable.ts +99 -0
  35. package/src/cli/hub.ts +4 -2
  36. package/src/cli/index.ts +13 -4
  37. package/src/cli/link-child.ts +104 -0
  38. package/src/cli/link-enrollment.ts +96 -0
  39. package/src/cli/link.ts +39 -24
  40. package/src/cli/observe-stream.ts +6 -1
  41. package/src/cli/observe.ts +10 -2
  42. package/src/cli/provider.ts +12 -0
  43. package/src/cli/registry.ts +5 -4
  44. package/src/cli/runtime-api.ts +27 -2
  45. package/src/cli/status.ts +104 -20
  46. package/src/client/connect.ts +39 -4
  47. package/src/client/hub-client.ts +14 -7
  48. package/src/client/hub-state.ts +25 -14
  49. package/src/client/link-home.ts +248 -0
  50. package/src/client/link-management-relay.ts +61 -0
  51. package/src/client/link-relay.ts +6 -1
  52. package/src/client/link-status.ts +14 -0
  53. package/src/client/machine-api.ts +14 -7
  54. package/src/client/machine-listener.ts +29 -4
  55. package/src/clients/config-export.ts +46 -16
  56. package/src/clients/omo-role-models.ts +6 -3
  57. package/src/codex/agent-role-models.ts +26 -8
  58. package/src/codex/auth-api/account-list.ts +17 -2
  59. package/src/codex/auth-api/account-selection.ts +17 -7
  60. package/src/codex/auth-api/routes.ts +5 -1
  61. package/src/codex/autostart-health.ts +3 -1
  62. package/src/codex/catalog/bundled.ts +5 -1
  63. package/src/codex/catalog/effort.ts +2 -2
  64. package/src/codex/catalog/metadata.ts +4 -2
  65. package/src/codex/catalog/native-models.ts +16 -2
  66. package/src/codex/native-profile-startup.ts +4 -1
  67. package/src/codex/prompt-journal.ts +20 -2
  68. package/src/codex/prompt-layers/encoding.ts +49 -0
  69. package/src/codex/prompt-layers/import-source.ts +30 -0
  70. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  71. package/src/codex/prompt-layers/toml-read.ts +52 -4
  72. package/src/codex/prompt-layers.ts +383 -20
  73. package/src/codex/prompt-lock-claim.ts +13 -3
  74. package/src/codex/prompt-lock-evidence.ts +3 -3
  75. package/src/codex/prompt-lock-owner.ts +14 -6
  76. package/src/codex/prompt-text-probe.ts +34 -5
  77. package/src/codex/quota.ts +13 -6
  78. package/src/codex/role-auto-assign.ts +2 -5
  79. package/src/codex/routing/active-account.ts +21 -0
  80. package/src/codex/routing.ts +1 -1
  81. package/src/codex/runtime.ts +196 -8
  82. package/src/codex/shim-overlay.ts +8 -4
  83. package/src/codex/shim.ts +1 -1
  84. package/src/combos/failover.ts +3 -0
  85. package/src/config/api-key-reconcile.ts +34 -0
  86. package/src/config/diagnostics.ts +16 -0
  87. package/src/config/feature-flags.ts +5 -0
  88. package/src/config/live-reconcile.ts +43 -29
  89. package/src/config/live-topology.ts +29 -0
  90. package/src/config/load-degrade.ts +2 -1
  91. package/src/config/paths.ts +3 -0
  92. package/src/config/salvage.ts +3 -1
  93. package/src/config/schema/config-schema.ts +2 -0
  94. package/src/generated/compatibility-version.json +311 -171
  95. package/src/grok/grpc-web.ts +23 -1
  96. package/src/grok/reset-coupon-ledger.ts +75 -5
  97. package/src/grok/reset-coupons.ts +4 -2
  98. package/src/integrations/mutation-plan.ts +13 -7
  99. package/src/integrations/registry.ts +45 -11
  100. package/src/integrations/state.ts +19 -7
  101. package/src/integrations/target.ts +42 -5
  102. package/src/integrations/writer.ts +14 -6
  103. package/src/lib/app-owned-memory-stores.ts +4 -0
  104. package/src/lib/app-owned-memory.ts +11 -0
  105. package/src/lib/json-byte-size.ts +31 -0
  106. package/src/lib/optional-shutdown-hooks.ts +35 -0
  107. package/src/lib/outbound-credential-registry.ts +210 -0
  108. package/src/lib/passive-read.ts +35 -0
  109. package/src/lib/shadow-call.ts +44 -5
  110. package/src/link/codex-settings.ts +155 -0
  111. package/src/link/management-routes.ts +89 -0
  112. package/src/link/remote-probe.ts +38 -0
  113. package/src/link/ssh-argv.ts +26 -5
  114. package/src/link/ssh-runner.ts +58 -27
  115. package/src/link/status-projection.ts +22 -4
  116. package/src/link/supervisor.ts +144 -49
  117. package/src/link/tunnel-state.ts +11 -4
  118. package/src/oauth/anthropic-continuity.ts +4 -3
  119. package/src/oauth/anthropic-identity.ts +13 -2
  120. package/src/oauth/anthropic-model-quota.ts +13 -1
  121. package/src/oauth/anthropic-routing.ts +230 -23
  122. package/src/oauth/anthropic.ts +18 -4
  123. package/src/oauth/google-antigravity.ts +24 -7
  124. package/src/oauth/index.ts +24 -15
  125. package/src/oauth/kiro-account-identity.ts +106 -0
  126. package/src/oauth/kiro.ts +48 -5
  127. package/src/oauth/plan.ts +29 -0
  128. package/src/oauth/store.ts +40 -3
  129. package/src/oauth/types.ts +2 -0
  130. package/src/plugins/loader.ts +11 -0
  131. package/src/providers/anthropic-billing-ownership.ts +74 -0
  132. package/src/providers/anthropic-billing-recovery.ts +31 -0
  133. package/src/providers/anthropic-billing-refusal.ts +55 -0
  134. package/src/providers/anthropic-reset-admission.ts +62 -0
  135. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  136. package/src/providers/api-key-resolve.ts +17 -2
  137. package/src/providers/key-failover.ts +52 -5
  138. package/src/providers/model-rename-startup.ts +5 -3
  139. package/src/providers/quota/account-cache.ts +11 -0
  140. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  141. package/src/providers/quota/report-cache.ts +14 -0
  142. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  143. package/src/providers/quota.ts +4 -1
  144. package/src/providers/registry/entries-core.ts +14 -14
  145. package/src/providers/registry/entries-extended.ts +0 -1
  146. package/src/providers/registry/model-seeds.ts +17 -0
  147. package/src/providers/request-pacing.ts +9 -0
  148. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  149. package/src/responses/compaction.ts +14 -0
  150. package/src/responses/parser.ts +7 -1
  151. package/src/server/auth-cors.ts +42 -0
  152. package/src/server/configured-credentials.ts +194 -0
  153. package/src/server/index/link-management.ts +58 -0
  154. package/src/server/index/optional-listeners.ts +5 -1
  155. package/src/server/index/serve-options.ts +13 -0
  156. package/src/server/lifecycle.ts +2 -1
  157. package/src/server/management/agent-settings-routes.ts +28 -10
  158. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  159. package/src/server/management/codex-agent-role-routes.ts +19 -6
  160. package/src/server/management/codex-prompt-routes.ts +136 -5
  161. package/src/server/management/codex-role-auto-assign.ts +1 -1
  162. package/src/server/management/grok-coupon-routes.ts +78 -27
  163. package/src/server/management/link-enrollment.ts +192 -0
  164. package/src/server/management/link-route-state.ts +185 -0
  165. package/src/server/management/link-routes.ts +82 -402
  166. package/src/server/management/model-rows.ts +3 -1
  167. package/src/server/management/provider-overwrite-carry.ts +10 -0
  168. package/src/server/management/provider-routes.ts +1 -0
  169. package/src/server/management/route-registry.ts +5 -3
  170. package/src/server/management/shared.ts +3 -0
  171. package/src/server/management-api.ts +3 -1
  172. package/src/server/messages-native.ts +68 -42
  173. package/src/server/relay-eager.ts +2 -0
  174. package/src/server/request-log-filter.ts +6 -1
  175. package/src/server/request-log-terminal-status.ts +63 -6
  176. package/src/server/responses/adapter-dispatch.ts +232 -79
  177. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  178. package/src/server/responses/codex-ws-exchange.ts +147 -6
  179. package/src/server/responses/codex-ws-wire.ts +203 -0
  180. package/src/server/responses/collaboration.ts +36 -7
  181. package/src/server/responses/compact.ts +126 -48
  182. package/src/server/responses/context-overflow.ts +13 -1
  183. package/src/server/responses/core-codex-account.ts +105 -1
  184. package/src/server/responses/core-combo-failure.ts +14 -0
  185. package/src/server/responses/core-combo.ts +7 -2
  186. package/src/server/responses/core-errors.ts +29 -1
  187. package/src/server/responses/core-options.ts +2 -0
  188. package/src/server/responses/deferred-collab-surface.ts +158 -0
  189. package/src/server/responses/deferred-send-reporter.ts +25 -0
  190. package/src/server/responses/fetch-helpers.ts +12 -1
  191. package/src/server/responses/passthrough-delivery.ts +4 -1
  192. package/src/server/responses/passthrough-dispatch.ts +15 -4
  193. package/src/server/responses/request-prepare.ts +6 -4
  194. package/src/server/responses/request-transport.ts +6 -8
  195. package/src/server/responses/sidecar-execution.ts +11 -2
  196. package/src/server/responses/ws-upstream.ts +9 -1
  197. package/src/server/startup-action-control.ts +22 -2
  198. package/src/server/startup-health-cache.ts +4 -0
  199. package/src/server/system-env-shell.ts +7 -2
  200. package/src/server/system-env.ts +11 -1
  201. package/src/service/cli.ts +5 -1
  202. package/src/service/desktop-startup.ts +147 -34
  203. package/src/service/desktop-supervision.d.mts +27 -1
  204. package/src/service/desktop-supervision.mjs +111 -5
  205. package/src/service/health-sentinel-launch.ts +176 -0
  206. package/src/service/health-sentinel-main.ts +447 -0
  207. package/src/service/health-sentinel-policy.ts +241 -0
  208. package/src/service/health-sentinel-probe.ts +54 -0
  209. package/src/service/health-sentinel-windows.ts +87 -0
  210. package/src/service/linux-linger.ts +20 -0
  211. package/src/types/config.ts +5 -0
  212. package/src/usage/expected-prices.ts +4 -0
  213. package/src/usage/log.ts +19 -1
  214. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  215. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  216. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
@@ -74,6 +74,16 @@ import {
74
74
  assertNoClientDisconnectPending, assertClientConnectionUnchanged, sameClientConnectionOwner,
75
75
  } from "./state";
76
76
  import { assertClientCatalogCompatible, type CatalogCompatibilityDeps } from "./catalog-compatibility";
77
+ import { overlayHomeCodexSettings } from "../link/codex-settings";
78
+ import {
79
+ clearHomeCodexSettingsRecord,
80
+ fetchHomeCodexSettings,
81
+ resolveHomeCodexSettings,
82
+ recordHomeCodexSettings,
83
+ writeHomeCodexSettingsRecord,
84
+ type HomeCodexSettingsSource,
85
+ type LinkHomeDeps,
86
+ } from "./link-home";
77
87
  import { hubStateCachePath } from "./hub-state";
78
88
  import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state";
79
89
  import { isLinkPort } from "../link/ports";
@@ -110,6 +120,8 @@ export interface ClientConnectDeps {
110
120
  now?: () => Date;
111
121
  lifecycleLockDeps?: ClientLifecycleLockDeps;
112
122
  catalogCompatibility?: CatalogCompatibilityDeps;
123
+ /** Link mode: how the Home's Codex settings are read (#6957). */
124
+ linkHome?: LinkHomeDeps;
113
125
  }
114
126
 
115
127
  export interface RotateClientOptions {
@@ -687,7 +699,16 @@ export async function connectClient(
687
699
 
688
700
  const config = earlyConfig ?? loadConfig();
689
701
  const target = routingTarget(serverUrl, linkMode ? config.port : undefined, config);
690
- const injectConfig = { ...config, syncResumeHistory: false };
702
+ // A link Child writes the Home's OpenCodex-owned Codex settings, not its own (#6957). A read
703
+ // that fails here keeps the local values; the next `ocx sync` applies the Home's.
704
+ // Read only: the record is written after the connection commits, so a rollback leaves none.
705
+ const homeSettings = linkMode && linkMetadata && options.selectedClients.includes("codex")
706
+ ? await fetchHomeCodexSettings({ transport: "link", link: linkMetadata, apiKeyId }, admissionToken, deps.linkHome)
707
+ : undefined;
708
+ const injectConfig = {
709
+ ...(homeSettings?.kind === "fresh" ? overlayHomeCodexSettings(config, homeSettings.document.settings) : config),
710
+ syncResumeHistory: false,
711
+ };
691
712
  const preflight = await injectCodexConfig(config.port, injectConfig, {
692
713
  validateOnly: true,
693
714
  routingTarget: target,
@@ -734,6 +755,12 @@ export async function connectClient(
734
755
  commitClientConnection(connection);
735
756
  committed = true;
736
757
  }), deps.lifecycleLockDeps);
758
+ // Recorded only when the injection that applied it ran (not with --no-sync).
759
+ if (homeSettings?.kind === "fresh" && linkMetadata && injectionCommitted) {
760
+ try {
761
+ writeHomeCodexSettingsRecord({ linkId: linkMetadata.linkId, fetchedAt: now, document: homeSettings.document });
762
+ } catch { /* the next sync records it; the applied config is already committed */ }
763
+ }
737
764
  return connection;
738
765
  } catch (error) {
739
766
  const rollbackFailures: string[] = [];
@@ -780,7 +807,7 @@ export async function connectClient(
780
807
  export async function syncConnectedClient(
781
808
  _options: { restartCodex?: boolean } = {},
782
809
  deps: ClientConnectDeps = {},
783
- ): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean }> {
810
+ ): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean; homeSettings?: HomeCodexSettingsSource }> {
784
811
  if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
785
812
  const initial = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
786
813
  assertNoClientDisconnectPending();
@@ -825,21 +852,28 @@ export async function syncConnectedClient(
825
852
  if (next.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint) throw new Error("client_token_changed");
826
853
  };
827
854
  let injected = false;
855
+ let homeSettings: HomeCodexSettingsSource | undefined;
828
856
  if (next.selectedClients.includes("codex")) {
829
- const config = loadConfig();
857
+ let config = loadConfig();
858
+ // #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog.
859
+ if (isLinkConnection(next)) {
860
+ homeSettings = await resolveHomeCodexSettings(next, initial.token.token, deps.linkHome);
861
+ if (homeSettings.source !== "local") config = overlayHomeCodexSettings(config, homeSettings.document.settings);
862
+ }
830
863
  const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
831
864
  routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH,
832
865
  journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite,
833
866
  });
834
867
  if (!result.success || result.status === "skipped") throw new Error(result.message);
835
868
  injected = true;
869
+ if (homeSettings && next.link) recordHomeCodexSettings(next.link.linkId, homeSettings);
836
870
  }
837
871
  const cacheSynced = withClientLifecycleSync(() => {
838
872
  beforeClientWrite();
839
873
  // Cache invalidation acquires K itself (N -> K -> C); never call it while C is held.
840
874
  return invalidateCodexModelsCache({ allowWhenDesiredDisabled: true });
841
875
  }, deps.lifecycleLockDeps);
842
- return { catalogWritten: downloaded !== undefined, cacheSynced, injected, stale };
876
+ return { catalogWritten: downloaded !== undefined, cacheSynced, injected, stale, ...(homeSettings ? { homeSettings } : {}) };
843
877
  }
844
878
 
845
879
  /**
@@ -1035,6 +1069,7 @@ export async function disconnectClient(
1035
1069
  if (!(error instanceof ClientLinkStateError)) throw error;
1036
1070
  }
1037
1071
  if (sidecarLinkId === connection.link.linkId) clearClientLinkState(connection.link.linkId);
1072
+ try { clearHomeCodexSettingsRecord(); } catch { /* derived data; never fails a disconnect */ }
1038
1073
  }
1039
1074
  removeHubStateCache();
1040
1075
  requireDesktopResult(finishRemoteDesktopCleanup(held, receipt.owner));
@@ -485,7 +485,7 @@ export async function fetchHubUsage(
485
485
  serverUrl: string,
486
486
  admissionToken: string,
487
487
  query: URLSearchParams,
488
- options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
488
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch; peer?: "hub" | "Home" } = {},
489
489
  ): Promise<HubUsageReport> {
490
490
  const origin = normalizeHubOrigin(serverUrl);
491
491
  if (!isHubTransportPermitted(origin)) {
@@ -498,9 +498,12 @@ export async function fetchHubUsage(
498
498
  }, options.timeoutMs);
499
499
  if (!response.ok) {
500
500
  try { await response.body?.cancel(); } catch { /* best effort */ }
501
- const message = response.status === 404 ? "Hub does not support client usage; upgrade the hub"
502
- : response.status === 401 || response.status === 403 ? "Hub rejected this client's usage credential"
503
- : `Hub usage request failed (${response.status})`;
501
+ // A Remote Link Child reads the same route from its Home (#6957).
502
+ const peer = options.peer ?? "hub";
503
+ const Peer = peer === "Home" ? "Home" : "Hub";
504
+ const message = response.status === 404 ? `${Peer} does not support client usage; upgrade the ${peer}`
505
+ : response.status === 401 || response.status === 403 ? `${Peer} rejected this ${peer === "Home" ? "Child's link key" : "client's usage credential"}`
506
+ : `${Peer} usage request failed (${response.status})`;
504
507
  throw new HubClientError(`hub_usage_http_${response.status}`, message, response.status);
505
508
  }
506
509
  if (!jsonCompatibleContentType(response)) {
@@ -527,16 +530,20 @@ export async function fetchHubUsage(
527
530
  */
528
531
  export async function fetchHubState(
529
532
  serverUrl: string,
530
- admissionToken: string,
533
+ admissionToken: string | null,
531
534
  options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
532
535
  ): Promise<HubStateDTO> {
533
536
  const origin = normalizeHubOrigin(serverUrl);
534
537
  const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/hub-state`, {
535
538
  method: "GET",
536
- headers: new Headers({ Accept: "application/json", "x-opencodex-api-key": admissionToken }),
539
+ headers: new Headers({ Accept: "application/json", ...(admissionToken === null ? {} : { "x-opencodex-api-key": admissionToken }) }),
537
540
  }, options.timeoutMs, "headers");
538
541
  if (response.status === 404) {
539
- try { await response.body?.cancel(); } catch { /* best effort */ }
542
+ let body: unknown = null;
543
+ try { body = JSON.parse(await boundedText(response, 4096, { inactivityTimeoutMs: safeTimeout(options.timeoutMs) })); } catch { /* legacy/non-JSON 404 stays unsupported */ }
544
+ if (body && typeof body === "object" && !Array.isArray(body) && (body as { error?: { code?: unknown } }).error?.code === "hub_state_not_a_hub") {
545
+ throw new HubClientError("hub_state_not_a_hub", "the remote host does not publish hub state", 404);
546
+ }
540
547
  throw new HubClientError("hub_state_unsupported", "Hub does not serve /v1/hub-state; upgrade the hub", 404);
541
548
  }
542
549
  if (!response.ok) {
@@ -124,37 +124,43 @@ export function writeCachedHubState(owner: HubStateOwner, state: HubStateDTO, fe
124
124
  * banner reading `state unavailable (hub_state_http_507)` sends the reader looking for a client
125
125
  * bug when the hub has in fact answered and said something.
126
126
  */
127
- export function hubStateFailureReason(error: unknown): string {
127
+ export function hubStateFailureReason(error: unknown, peer: "hub" | "Home" = "hub"): string {
128
+ // A Remote Link Child reads the same route from its Home (#6957); the sentences name the peer
129
+ // the operator actually configured.
130
+ const holder = peer === "Home" ? "this Child's link key" : "this client's data key";
128
131
  if (error instanceof HubClientError) {
129
132
  switch (error.code) {
133
+ case "hub_state_not_a_hub":
134
+ return "the remote host does not publish hub state";
130
135
  case "hub_state_unsupported":
131
- return "this hub is too old to report its state; upgrade the hub";
136
+ return `this ${peer} is too old to report its state; upgrade the ${peer}`;
132
137
  case "hub_state_unauthorized":
133
- return "the hub rejected this client's data key";
138
+ return `the ${peer} rejected ${holder}`;
134
139
  case "hub_state_schema_invalid":
135
140
  case "hub_state_invalid":
136
- return "the hub returned an unreadable hub-state document";
141
+ return `the ${peer} returned an unreadable hub-state document`;
137
142
  case "hub_state_content_type_invalid":
138
143
  // Usually a captive portal, a TLS-terminating proxy or an error page in front of the
139
144
  // hub: the request reached SOMETHING, and that something is not the hub's API.
140
- return "the hub's state response was not JSON";
145
+ return `the ${peer}'s state response was not JSON`;
141
146
  case "body_too_large":
142
- return "the hub's state response exceeded the allowed size";
147
+ return `the ${peer}'s state response exceeded the allowed size`;
143
148
  case "unreachable":
144
- return "the hub is unreachable";
149
+ return `the ${peer} is unreachable`;
145
150
  case "redirect_refused":
146
- return "the hub redirected the state request";
151
+ return `the ${peer} redirected the state request`;
147
152
  default: {
148
153
  const status = error.code.startsWith("hub_state_http_")
149
154
  ? error.code.slice("hub_state_http_".length)
150
155
  : null;
151
156
  return status && /^\d+$/.test(status)
152
- ? `the hub answered HTTP ${status} to the state request`
157
+ ? `the ${peer} answered HTTP ${status} to the state request`
153
158
  : error.code;
154
159
  }
155
160
  }
156
161
  }
157
- return "the hub state could not be read";
162
+ if (error instanceof Error && error.name === "LinkRelayAuthenticationError") return error.message;
163
+ return `the ${peer} state could not be read`;
158
164
  }
159
165
 
160
166
  export interface ResolveHubStateOptions {
@@ -167,6 +173,8 @@ export interface ResolveHubStateOptions {
167
173
  * token for a different connection, and the operator acts on that difference.
168
174
  */
169
175
  withheldTokenReason?: string;
176
+ /** Identity-checked local Child transport that strips credentials and owns Home admission. */
177
+ localRelayFetchImpl?: typeof fetch;
170
178
  timeoutMs?: number;
171
179
  fetchImpl?: typeof fetch;
172
180
  now?: number;
@@ -174,6 +182,8 @@ export interface ResolveHubStateOptions {
174
182
  allowNetwork?: boolean;
175
183
  /** False skips the cache write, for read-only callers. */
176
184
  persist?: boolean;
185
+ /** Which peer the sentences name: a hub, or a Remote Link Child's Home. */
186
+ peer?: "hub" | "Home";
177
187
  }
178
188
 
179
189
  function withAge(
@@ -201,20 +211,21 @@ export async function resolveHubState(options: ResolveHubStateOptions): Promise<
201
211
  ? withAge("cache", cached.state, cached.fetchedAt, now, reason)
202
212
  : withAge("unavailable", null, undefined, now, reason);
203
213
  };
204
- if (!options.token) {
214
+ if (!options.token && !options.localRelayFetchImpl) {
205
215
  return fromCache(options.withheldTokenReason ?? "this client has no usable data-plane token");
206
216
  }
207
217
  if (options.allowNetwork === false) return fromCache("a live hub read was not attempted");
208
218
  let state: HubStateDTO;
209
219
  try {
210
- state = await fetchHubState(options.owner.serverUrl, options.token, {
220
+ state = await fetchHubState(options.owner.serverUrl, options.localRelayFetchImpl ? null : options.token, {
211
221
  timeoutMs: options.timeoutMs ?? DEFAULT_HUB_STATE_TIMEOUT_MS,
212
- ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
222
+ ...((options.localRelayFetchImpl ?? options.fetchImpl) ? { fetchImpl: options.localRelayFetchImpl ?? options.fetchImpl } : {}),
213
223
  });
214
224
  } catch (error) {
215
225
  // Deliberately no local-state fallback here. A stale cache is still the HUB's state; the
216
226
  // client's own providers and logins are not, at any age.
217
- return fromCache(hubStateFailureReason(error));
227
+ if (error instanceof HubClientError && error.code === "hub_state_not_a_hub") return withAge("unavailable", null, undefined, now, hubStateFailureReason(error, options.peer));
228
+ return fromCache(hubStateFailureReason(error, options.peer));
218
229
  }
219
230
  const fetchedAt = new Date(now).toISOString();
220
231
  if (options.persist !== false) writeCachedHubState(options.owner, state, fetchedAt);
@@ -0,0 +1,248 @@
1
+ /**
2
+ * A Remote Link Child's view of its Home (#6957): the Home's name for refusal and status text,
3
+ * the Home's OpenCodex-owned Codex settings, and authenticated read-only management reads.
4
+ *
5
+ * Every read goes through the connection-bound link relay (`fetchBoundLinkRelay`): the link key
6
+ * is sent only after the listener on the tunnel port proves it holds the same link identity, so a
7
+ * process that took the tunnel port never sees the key.
8
+ */
9
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
12
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
13
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
14
+ import { hardenSecretDir } from "../lib/windows-secret-acl";
15
+ import {
16
+ parseHomeCodexSettingsDocument,
17
+ type HomeCodexSettingsDocument,
18
+ } from "../link/codex-settings";
19
+ import {
20
+ LINK_CODEX_SETTINGS_PATH,
21
+ LINK_MANAGEMENT_PREFIX,
22
+ linkManagementReadAllowed,
23
+ } from "../link/management-routes";
24
+ import { linkDir } from "../link/paths";
25
+ import type { OcxClientConnectionConfig } from "../types";
26
+ import { fetchBoundLinkRelay } from "./link-relay-transport";
27
+ import { readClientLinkState, type ClientLinkState } from "./link-state";
28
+
29
+ const RECORD_MAX_BYTES = 64 * 1024;
30
+ const SETTINGS_MAX_BYTES = 64 * 1024;
31
+ const READ_TIMEOUT_MS = 10_000;
32
+
33
+ export interface LinkHomeDeps {
34
+ /** Trusted complete-send test seam; production uses the connection-bound transport. */
35
+ fetchImpl?: typeof fetch;
36
+ timeoutMs?: number;
37
+ }
38
+
39
+ /** The connection fields a Home read needs; connect calls these before its state is committed. */
40
+ export type LinkHomeConnection = Pick<OcxClientConnectionConfig, "transport" | "link" | "apiKeyId">;
41
+
42
+ export interface HomeCodexSettingsRecord {
43
+ linkId: string;
44
+ fetchedAt: string;
45
+ document: HomeCodexSettingsDocument;
46
+ }
47
+
48
+ export function homeCodexSettingsPath(): string {
49
+ return join(linkDir(), "home-codex-settings.json");
50
+ }
51
+
52
+ function linkTarget(connection: LinkHomeConnection, admissionKey: string) {
53
+ if (connection.transport !== "link" || !connection.link) throw new Error("not a Remote Link Child");
54
+ return {
55
+ tunnelPort: connection.link.tunnelPort,
56
+ linkId: connection.link.linkId,
57
+ apiKeyId: connection.apiKeyId,
58
+ admissionKey,
59
+ };
60
+ }
61
+
62
+ /** The last applied Home settings for this link, or null when absent, malformed or another link's. */
63
+ export function readHomeCodexSettingsRecord(linkId: string, path = homeCodexSettingsPath()): HomeCodexSettingsRecord | null {
64
+ try {
65
+ const stat = lstatSync(path);
66
+ if (!stat.isFile() || stat.size > RECORD_MAX_BYTES) return null;
67
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
68
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
69
+ const record = raw as Record<string, unknown>;
70
+ if (record.linkId !== linkId || typeof record.fetchedAt !== "string" || Number.isNaN(Date.parse(record.fetchedAt))) return null;
71
+ const document = parseHomeCodexSettingsDocument(record.document);
72
+ return document ? { linkId, fetchedAt: record.fetchedAt, document } : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export function writeHomeCodexSettingsRecord(record: HomeCodexSettingsRecord, path = homeCodexSettingsPath()): void {
79
+ const dir = linkDir();
80
+ assertNotRealHomeUnderTest(join(dir, ".."));
81
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ if (process.platform === "win32") hardenSecretDir(dir, { required: true });
83
+ else chmodSync(dir, 0o700);
84
+ atomicWriteFile(path, `${JSON.stringify(record, null, 2)}\n`);
85
+ if (process.platform !== "win32") chmodSync(path, 0o600);
86
+ }
87
+
88
+ export function clearHomeCodexSettingsRecord(path = homeCodexSettingsPath()): void {
89
+ try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
90
+ }
91
+
92
+ /**
93
+ * The Home's name as this Child knows it: the SSH alias of a Child-initiated link, else the
94
+ * host name the Home published with its Codex settings, else null ("its Home").
95
+ */
96
+ export function linkHomeName(
97
+ connection: OcxClientConnectionConfig,
98
+ readSidecar: () => ClientLinkState | null = readClientLinkState,
99
+ ): string | null {
100
+ const linkId = connection.link?.linkId;
101
+ if (!linkId) return null;
102
+ try {
103
+ const sidecar = readSidecar();
104
+ if (sidecar && sidecar.linkId === linkId) return sidecar.alias;
105
+ } catch { /* an unreadable sidecar names nothing */ }
106
+ return readHomeCodexSettingsRecord(linkId)?.document.home.name ?? null;
107
+ }
108
+
109
+ /** "Home home-box" or "its Home": the phrase every link Child message uses. */
110
+ export function linkHomePhrase(
111
+ connection: OcxClientConnectionConfig,
112
+ readSidecar?: () => ClientLinkState | null,
113
+ ): string {
114
+ const name = linkHomeName(connection, readSidecar);
115
+ return name ? `Home ${name}` : "its Home";
116
+ }
117
+
118
+ /**
119
+ * A `fetch` for the existing data-plane reads (`/v1/hub-state`, `/v1/usage`) that sends each
120
+ * request through the connection-bound link relay, so the link key reaches only the Home.
121
+ */
122
+ export function linkBoundFetch(connection: LinkHomeConnection, admissionKey: string): typeof fetch {
123
+ const target = linkTarget(connection, admissionKey);
124
+ return ((input: RequestInfo | URL, init?: RequestInit) => {
125
+ // A Request keeps its method, headers and signal unless init overrides them.
126
+ const request = input instanceof Request ? input : null;
127
+ const url = request ? request.url : String(input);
128
+ return fetchBoundLinkRelay(target, url, {
129
+ ...(request ? { method: request.method, headers: request.headers, signal: request.signal } : {}),
130
+ ...(init ?? {}),
131
+ redirect: "manual",
132
+ });
133
+ }) as typeof fetch;
134
+ }
135
+
136
+ /**
137
+ * One authenticated, read-only management read on the Home. `path` is a Home management path
138
+ * with an optional query (`/api/providers`); anything outside the shared allowlist throws before
139
+ * a request is built.
140
+ */
141
+ export async function fetchLinkHomeManagement(
142
+ connection: LinkHomeConnection,
143
+ admissionKey: string,
144
+ path: string,
145
+ init: { method?: "GET" | "HEAD"; signal?: AbortSignal } = {},
146
+ deps: LinkHomeDeps = {},
147
+ ): Promise<Response> {
148
+ const method = init.method ?? "GET";
149
+ const local = new URL(path, "http://link.invalid");
150
+ if (local.origin !== "http://link.invalid" || !linkManagementReadAllowed(method, local.pathname, local.searchParams)) {
151
+ throw new Error("path is not readable over the Remote Link");
152
+ }
153
+ const target = linkTarget(connection, admissionKey);
154
+ const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_MANAGEMENT_PREFIX}${local.pathname}${local.search}`;
155
+ const signal = init.signal
156
+ ? AbortSignal.any([init.signal, AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS)])
157
+ : AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS);
158
+ const request: RequestInit = {
159
+ method,
160
+ headers: { accept: "application/json", authorization: `Bearer ${admissionKey}` },
161
+ redirect: "manual",
162
+ signal,
163
+ };
164
+ return deps.fetchImpl ? deps.fetchImpl(destination, request) : fetchBoundLinkRelay(target, destination, request);
165
+ }
166
+
167
+ export type HomeCodexSettingsFetch =
168
+ | { kind: "fresh"; document: HomeCodexSettingsDocument }
169
+ | { kind: "unsupported" }
170
+ | { kind: "failed"; reason: string };
171
+
172
+ export async function fetchHomeCodexSettings(
173
+ connection: LinkHomeConnection,
174
+ admissionKey: string,
175
+ deps: LinkHomeDeps = {},
176
+ ): Promise<HomeCodexSettingsFetch> {
177
+ let response: Response;
178
+ try {
179
+ const target = linkTarget(connection, admissionKey);
180
+ const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_CODEX_SETTINGS_PATH}`;
181
+ const request: RequestInit = {
182
+ method: "GET",
183
+ headers: { accept: "application/json", authorization: `Bearer ${admissionKey}` },
184
+ redirect: "manual",
185
+ signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS),
186
+ };
187
+ response = deps.fetchImpl ? await deps.fetchImpl(destination, request) : await fetchBoundLinkRelay(target, destination, request);
188
+ } catch (error) {
189
+ return { kind: "failed", reason: error instanceof Error && error.name === "LinkRelayAuthenticationError" ? error.message : "the Home is unreachable" };
190
+ }
191
+ if (response.status === 404) {
192
+ try { await response.body?.cancel(); } catch { /* best effort */ }
193
+ return { kind: "unsupported" };
194
+ }
195
+ if (!response.ok) {
196
+ try { await response.body?.cancel(); } catch { /* best effort */ }
197
+ return { kind: "failed", reason: `the Home answered HTTP ${response.status}` };
198
+ }
199
+ try {
200
+ const result = await readBoundedResponseBytes(response, { maxBytes: SETTINGS_MAX_BYTES, signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS) });
201
+ if (result.oversized) return { kind: "failed", reason: "the Home's settings exceeded the allowed size" };
202
+ const document = parseHomeCodexSettingsDocument(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(result.bytes)));
203
+ return document ? { kind: "fresh", document } : { kind: "failed", reason: "the Home returned an unreadable settings document" };
204
+ } catch {
205
+ return { kind: "failed", reason: "the Home returned an unreadable settings document" };
206
+ }
207
+ }
208
+
209
+ export type HomeCodexSettingsSource =
210
+ | { source: "home"; document: HomeCodexSettingsDocument; fetchedAt: string }
211
+ | { source: "cached"; document: HomeCodexSettingsDocument; fetchedAt: string; reason: string }
212
+ | { source: "local"; reason: string };
213
+
214
+ /**
215
+ * The settings a sync applies. A failed read reuses the last record for this link so a brief
216
+ * outage never flips the Child back to its local values; a Home that predates the route leaves
217
+ * the Child's local values in place and says so. A fresh read is NOT recorded here: the caller
218
+ * records it with `recordHomeCodexSettings` only after the injection that applies it succeeds,
219
+ * because `ocx status` reads the record as "applied".
220
+ */
221
+ export async function resolveHomeCodexSettings(
222
+ connection: LinkHomeConnection,
223
+ admissionKey: string,
224
+ deps: LinkHomeDeps & { now?: () => Date } = {},
225
+ ): Promise<HomeCodexSettingsSource> {
226
+ const linkId = connection.link?.linkId;
227
+ if (!linkId) return { source: "local", reason: "not a Remote Link Child" };
228
+ const fetched = await fetchHomeCodexSettings(connection, admissionKey, deps);
229
+ if (fetched.kind === "fresh") {
230
+ return { source: "home", document: fetched.document, fetchedAt: (deps.now ?? (() => new Date()))().toISOString() };
231
+ }
232
+ if (fetched.kind === "unsupported") {
233
+ clearHomeCodexSettingsRecord();
234
+ return { source: "local", reason: "the Home does not publish Codex settings; upgrade the Home" };
235
+ }
236
+ const cached = readHomeCodexSettingsRecord(linkId);
237
+ return cached
238
+ ? { source: "cached", document: cached.document, fetchedAt: cached.fetchedAt, reason: fetched.reason }
239
+ : { source: "local", reason: fetched.reason };
240
+ }
241
+
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;
245
+ try {
246
+ writeHomeCodexSettingsRecord({ linkId, fetchedAt: settings.fetchedAt, document: settings.document });
247
+ } catch { /* the next sync records it */ }
248
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The Child end of the Remote Link management relay (#6957).
3
+ *
4
+ * The Child's dashboard reaches its Home's management reads at `/api/machine/hub-relay/api/*`,
5
+ * the same base a hub client's dashboard uses. The browser authenticates to the Child with the
6
+ * Child's own dashboard session; this relay drops every browser header, builds a fresh GET for
7
+ * the Home's `/v1/link-management` prefix and sends it through the connection-bound link relay,
8
+ * which attaches the link key only after the tunnel listener proves the link identity. Anything
9
+ * outside the shared read allowlist is refused here, before a request exists.
10
+ */
11
+ import { HUB_RELAY_RESPONSE_BODY_MAX_BYTES } from "./hub-relay";
12
+ import type { LinkIngress } from "./link-ingress";
13
+ import { relayLinkDataRequest } from "./link-relay";
14
+ import {
15
+ LINK_MANAGEMENT_PREFIX,
16
+ linkChildManagementRefusal,
17
+ linkManagementReadAllowed,
18
+ linkManagementRouteAllowed,
19
+ } from "../link/management-routes";
20
+
21
+ export const LINK_CHILD_READ_ONLY = "link_child_read_only";
22
+
23
+ export function linkChildRefusalResponse(homePhrase: string, linkId: string, operation: string): Response {
24
+ return Response.json({
25
+ error: linkChildManagementRefusal(homePhrase, linkId, operation),
26
+ code: LINK_CHILD_READ_ONLY,
27
+ linkId,
28
+ }, { status: 403, headers: { "cache-control": "no-store" } });
29
+ }
30
+
31
+ export async function relayLinkManagementRead(
32
+ req: Request,
33
+ suffix: string,
34
+ ingress: LinkIngress,
35
+ homePhrase: string,
36
+ ): Promise<Response> {
37
+ const linkId = ingress.linkId ?? "unknown";
38
+ const query = suffix.indexOf("?");
39
+ const pathname = query === -1 ? suffix : suffix.slice(0, query);
40
+ const search = query === -1 ? "" : suffix.slice(query);
41
+ if (!linkManagementReadAllowed(req.method, pathname, new URLSearchParams(search.slice(1)))) {
42
+ return linkChildRefusalResponse(homePhrase, linkId, `${req.method.toUpperCase()} ${pathname}`);
43
+ }
44
+ const admissionKey = ingress.linkKey();
45
+ if (!admissionKey) return Response.json({ error: "link_credential_unavailable" }, { status: 503 });
46
+ const outbound = new Request(`http://127.0.0.1${LINK_MANAGEMENT_PREFIX}${pathname}${search}`, {
47
+ method: req.method,
48
+ headers: { accept: "application/json" },
49
+ signal: req.signal,
50
+ });
51
+ return relayLinkDataRequest(outbound, {
52
+ tunnelPort: ingress.tunnelPort,
53
+ admissionKey,
54
+ linkId: ingress.linkId,
55
+ apiKeyId: ingress.apiKeyId,
56
+ }, {
57
+ ...ingress.relay,
58
+ bodyLimitBytes: HUB_RELAY_RESPONSE_BODY_MAX_BYTES,
59
+ routeAllowed: linkManagementRouteAllowed,
60
+ });
61
+ }
@@ -69,6 +69,11 @@ export interface LinkRelayDeps {
69
69
  /** The longest a request waits for a reconnecting tunnel, from its first wait. */
70
70
  holdMs?: number;
71
71
  now?: () => number;
72
+ /**
73
+ * The route table this relay enforces. Defaults to the data-plane table; only the Child's
74
+ * authenticated management relay passes the read-only management table (#6957).
75
+ */
76
+ routeAllowed?: (url: URL, req: Request) => boolean;
72
77
  }
73
78
 
74
79
  export const LINK_RELAY_RETRY_AFTER_SECONDS = 1;
@@ -305,7 +310,7 @@ export async function relayLinkDataRequest(
305
310
  deps: LinkRelayDeps = {},
306
311
  ): Promise<Response> {
307
312
  const url = new URL(req.url);
308
- if (!linkRouteAllowed(url, req)) return jsonError(404, "not_found");
313
+ if (!(deps.routeAllowed ?? linkRouteAllowed)(url, req)) return jsonError(404, "not_found");
309
314
  let destination: string;
310
315
  try { destination = linkRelayDestination(url, target); } catch { return jsonError(404, "not_found"); }
311
316
  const validation = validateHubRelayRequestHeaders(linkFramingHeaders(req.headers));
@@ -1,3 +1,7 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { isMissingPathError } from "../config/atomic-write";
3
+ import { readClientLinkState, childLinkMarkerPath } from "./link-state";
4
+ import type { ClientLinkIdentity } from "../link/status-projection";
1
5
  import type { LinkStatusDto } from "../link/status-projection";
2
6
  import type { ClientLinkState } from "./link-state";
3
7
  import type { ClientLinkSupervisorStatus } from "./link-tunnel";
@@ -37,3 +41,13 @@ function childState(sidecar: ClientLinkSidecarRead, supervisor: ClientLinkSuperv
37
41
  reason: supervisor.probe ?? (tunnel.kind === "failed" ? tunnel.reason : null),
38
42
  };
39
43
  }
44
+
45
+ /** Only client-owned evidence can establish who owns the tunnel. Unreadable evidence stays unknown. */
46
+ export function clientLinkStatusIdentity(linkId: string): ClientLinkIdentity | null {
47
+ try {
48
+ const sidecar = readClientLinkState();
49
+ if (sidecar) return sidecar.linkId === linkId ? { direction: "client-initiated", alias: sidecar.alias } : null;
50
+ try { lstatSync(childLinkMarkerPath()); return null; }
51
+ catch (error) { return isMissingPathError(error) ? { direction: "hub-initiated" } : null; }
52
+ } catch { return null; }
53
+ }
@@ -3,6 +3,7 @@ import { diagnoseCodexShim, installCodexShim, uninstallCodexShim } from "../code
3
3
  import { readManagementJsonBody } from "../server/management/body";
4
4
  import type { OcxClientConnectionConfig } from "../types";
5
5
  import { disconnectClient, syncConnectedClient } from "./connect";
6
+ import { linkHomeName } from "./link-home";
6
7
  import { isLinkConnection } from "./state";
7
8
 
8
9
  export type HubReachability = "unknown" | "online" | "offline" | "unauthorized";
@@ -19,6 +20,8 @@ export interface MachineStatusV1 {
19
20
  connectedAt: string;
20
21
  catalogSyncedAt?: string;
21
22
  hubReachability: HubReachability;
23
+ /** Present only on a Remote Link Child: the dashboard shows "managed by Home <name>". */
24
+ link?: { linkId: string; home: string | null; readOnly: true };
22
25
  }
23
26
 
24
27
  export interface MachineApiDeps {
@@ -27,6 +30,8 @@ export interface MachineApiDeps {
27
30
  scheduleStandaloneRecycle: (disconnectedTokenFingerprint: string) => void;
28
31
  hubReachability?: () => HubReachability;
29
32
  setHubReachability?: (value: HubReachability) => void;
33
+ /** Link mode: the Home's name as this listener knows it (sidecar alias or recorded host name). */
34
+ linkHomeName?: () => string | null;
30
35
  }
31
36
 
32
37
  const defaultDeps: MachineApiDeps = {
@@ -52,24 +57,26 @@ async function jsonBody(req: Request): Promise<unknown | Response> {
52
57
  function statusPayload(req: Request, state: OcxClientConnectionConfig, deps: MachineApiDeps): MachineStatusV1 {
53
58
  const machineBase = new URL(req.url).origin;
54
59
  // A link Child's management URL is the tunnel, whose hub-link ingress serves no /api/* and no
55
- // session bootstrap. Its dashboard reads the Child's own listener instead (`/api/link/status`).
60
+ // session bootstrap. Its dashboard therefore uses this listener's relay base: the browser holds
61
+ // only this listener's session, and the listener relays allowlisted reads to the Home with the
62
+ // link key (#6957, src/client/link-management-relay.ts).
56
63
  const linkMode = isLinkConnection(state);
64
+ const relay = linkMode || state.managementTransport === "relay";
57
65
  return {
58
66
  mode: "client",
59
67
  connected: true,
60
68
  machineBase,
61
- sharedBase: linkMode
62
- ? machineBase
63
- : state.managementTransport === "relay"
64
- ? `${machineBase}/api/machine/hub-relay`
65
- : state.managementUrl,
69
+ sharedBase: relay ? `${machineBase}/api/machine/hub-relay` : state.managementUrl,
66
70
  sharedServerOrigin: linkMode ? machineBase : state.managementUrl,
67
- managementTransport: state.managementTransport,
71
+ managementTransport: relay ? "relay" : state.managementTransport,
68
72
  apiKeyId: state.apiKeyId,
69
73
  protocolVersion: state.protocolVersion,
70
74
  connectedAt: state.connectedAt,
71
75
  ...(state.catalogSyncedAt ? { catalogSyncedAt: state.catalogSyncedAt } : {}),
72
76
  hubReachability: deps.hubReachability?.() ?? "unknown",
77
+ ...(linkMode && state.link
78
+ ? { link: { linkId: state.link.linkId, home: deps.linkHomeName ? deps.linkHomeName() : linkHomeName(state), readOnly: true as const } }
79
+ : {}),
73
80
  };
74
81
  }
75
82