@bitkyc08/opencodex 2.82.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 (266) 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-DTBC8n7-.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/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -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
 
@@ -23,6 +23,8 @@ import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-statu
23
23
  import type { ClientLinkSupervisorStatus } from "./link-tunnel";
24
24
  import { packageVersion } from "../lib/package-version";
25
25
  import { linkRouteAllowed } from "../link/routes";
26
+ import { linkHomeName, linkHomePhrase } from "./link-home";
27
+ import { relayLinkManagementRead } from "./link-management-relay";
26
28
 
27
29
  const VERSION = packageVersion("0.0.0");
28
30
  const GUI_SPA_PATHS = new Set([
@@ -100,6 +102,8 @@ export function startMachineListener(
100
102
  if (linkMode && !connection.link) throw new Error("link machine listener requires link transport metadata");
101
103
  const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
102
104
  let hubReachability: HubReachability = "unknown";
105
+ const readSidecar = deps.readSidecar ?? (() => readClientLinkState());
106
+ const sidecarOrNull = () => { const read = readSidecarSafely(readSidecar); return read === "invalid" ? null : read; };
103
107
  const machineApiDeps: MachineApiDeps = {
104
108
  sync: deps.machineApi?.sync ?? syncConnectedClient,
105
109
  disconnect: deps.machineApi?.disconnect ?? disconnectClient,
@@ -108,8 +112,11 @@ export function startMachineListener(
108
112
  }),
109
113
  hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability),
110
114
  setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
115
+ ...(linkMode ? { linkHomeName: () => linkHomeName(connection, sidecarOrNull) } : {}),
111
116
  };
112
- const relayEnabled = !linkMode && connection.managementTransport === "relay";
117
+ // A link Child relays its dashboard's management reads to the Home over the link (#6957);
118
+ // a hub client relays over its management transport when that is `relay`.
119
+ const relayEnabled = linkMode || connection.managementTransport === "relay";
113
120
  // Link mode only. Everything is resolved once here, so a relayed request reads no file and
114
121
  // builds no policy: the key is cached, the loopback policy is fixed at bind like the listener.
115
122
  const inboundBodyLimit = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
@@ -123,8 +130,6 @@ export function startMachineListener(
123
130
  relay: { fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
124
131
  }
125
132
  : null;
126
- const readSidecar = deps.readSidecar ?? (() => readClientLinkState());
127
-
128
133
  return (deps.serve ?? (options => Bun.serve(options)))({
129
134
  port: port ?? config.port ?? 10100,
130
135
  hostname: "127.0.0.1",
@@ -147,9 +152,29 @@ export function startMachineListener(
147
152
  }
148
153
  if (url.pathname.startsWith("/api/machine/hub-relay/")) {
149
154
  if (!relayEnabled) return json404(req);
155
+ const prefix = "/api/machine/hub-relay";
156
+ if (linkIngress) {
157
+ const suffix = `${url.pathname.slice(prefix.length)}${url.search}`;
158
+ // The dashboard's shared plane bootstraps here. On a link Child that plane is this
159
+ // listener, so it receives this listener's own session; no Home credential exists.
160
+ if (suffix === "/opencodex-session") {
161
+ if (req.method !== "GET") return json404(req);
162
+ const session = issueGuiSession(req, config, managementAuth, { trustedTailscaleIngress: false });
163
+ return session ? serveSessionBootstrap(session) : Response.json({ error: "session unavailable" }, { status: 503 });
164
+ }
165
+ const authError = requireManagementAuth(req, managementAuth, config);
166
+ if (authError) return authError;
167
+ if (managementPrincipal(req, managementAuth, config) !== "gui-session") {
168
+ return Response.json({ error: "opencodex machine GUI session required" }, { status: 401 });
169
+ }
170
+ const response = await relayLinkManagementRead(req, suffix, linkIngress, linkHomePhrase(connection, sidecarOrNull));
171
+ if (response.status === 401) hubReachability = "unauthorized";
172
+ else if (response.status >= 500) hubReachability = "offline";
173
+ else if (response.status !== 403) hubReachability = "online";
174
+ return response;
175
+ }
150
176
  const authError = requireMachineAuth(req, managementAuth, config);
151
177
  if (authError) return authError;
152
- const prefix = "/api/machine/hub-relay";
153
178
  const suffix = `${url.pathname.slice(prefix.length)}${url.search}`;
154
179
  const response = await relayHubManagementRequest(req, suffix, {
155
180
  managementUrl: connection.managementUrl,
@@ -2,6 +2,7 @@
2
2
  import { getModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
3
3
  import { SCHEMA_REQUIRED_OUTPUT_BUDGET } from "./constants";
4
4
  import { expandFastExportModels } from "./fast-models";
5
+ import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
5
6
  import type { OpencodeCatalogModel, ExportModel, ExportClientId, ManagedContribution } from "./contracts";
6
7
  import type { OcxConfig } from "../../types";
7
8
  import { shouldInjectApiAuthHeader } from "../../codex/inject";
@@ -84,6 +85,21 @@ export function inputModalitiesForClient(
84
85
  return kept.length > 0 ? kept : null;
85
86
  }
86
87
 
88
+ /**
89
+ * Effort ladder a ZCode surface can offer as picker values, or undefined when the row
90
+ * has nothing selectable.
91
+ *
92
+ * Both ZCode exports — the legacy `v2/config.json` block's `reasoning.variants` and the
93
+ * `v2/provider_config.json` store's `optionSpecs.reasoningLevel.values` — carry the same
94
+ * Codex vocabulary: the client validates the chosen level against the emitted list and
95
+ * forwards it verbatim, and `/v1/responses` reads that vocabulary natively. `none` is a
96
+ * Codex omit-sentinel, not a picker option, so it never survives into the values.
97
+ */
98
+ export function zcodeSelectableEfforts(efforts: readonly string[] | undefined): string[] | undefined {
99
+ const selectable = sanitizeCodexReasoningEfforts(efforts)?.filter(effort => effort !== "none");
100
+ return selectable && selectable.length > 0 ? selectable : undefined;
101
+ }
102
+
87
103
  /**
88
104
  * Input modalities opencode's model schema accepts (opencode.ai/config.json, both
89
105
  * `modalities.input` and `modalities.output`). Wider than our internal `text | image | audio`