@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
@@ -23,7 +23,8 @@ export async function runCatalogAction(
23
23
  if (error instanceof RuntimeApiError) {
24
24
  if (error.code) {
25
25
  console.error(`Error: ${error.code === "proxy_not_running" ? PROXY_NOT_RUNNING_MESSAGE
26
- : "This listener has no management API. Run management commands on the connected Hub. No request was sent."}`);
26
+ : error.code === "link_child_management_unavailable" ? terminalSafeText(error.message)
27
+ : "This listener has no management API. Run management commands on the connected Hub. No request was sent."}`);
27
28
  return 1;
28
29
  }
29
30
  const code = own(error.body, "code") ?? own(own(error.body, "error"), "code");
package/src/cli/claude.ts CHANGED
@@ -12,7 +12,7 @@ import { spawn } from "node:child_process";
12
12
  import { loadConfig } from "../config";
13
13
  import { injectClaudeAgentDefs } from "../claude/agents-inject";
14
14
  import { CLAUDE_ALIAS_PREFIX_CURRENT, CLAUDE_ALIAS_PREFIX_CURRENT_V2, CLAUDE_ALIAS_PREFIX_V1, CLAUDE_ALIAS_PREFIX_V2 } from "../claude/alias";
15
- import { claudeToolSearchEnv, effectiveModelEnv, resolveAutoContext } from "../claude/context-windows";
15
+ import { claudeTierDisplayName, claudeToolSearchEnv, effectiveModelEnv, isClaudeTierLabelSlot, resolveAutoContext } from "../claude/context-windows";
16
16
  import { claudeConfigDir, fetchGatewayModels, writeGatewayModelCache } from "../claude/gateway-cache";
17
17
  import { commandInvocation } from "../lib/win-exec";
18
18
  import { isProxyAdmissionSecret } from "../server/auth-cors";
@@ -211,10 +211,12 @@ export function buildClaudeEnv(
211
211
  // real cost to a documented entry point, and the escape hatch is the launcher: run
212
212
  // through `ocx` (the published bin) and genuine shell exports are preserved by proof.
213
213
  deleteUntrustedAnthropicSlots(env, deps);
214
- const setDefault = (name: string, value: string | undefined) => {
215
- if (value === undefined || value.length === 0) return;
216
- if (env[name] !== undefined && env[name] !== "") return; // user wins
214
+ /** Returns whether the value was written; a value the user already has wins. */
215
+ const setDefault = (name: string, value: string | undefined): boolean => {
216
+ if (value === undefined || value.length === 0) return false;
217
+ if (env[name] !== undefined && env[name] !== "") return false; // user wins
217
218
  env[name] = value;
219
+ return true;
218
220
  };
219
221
  if (config.claudeCode?.subagentModelForce !== undefined) {
220
222
  const forced = resolveSubagentForceModel(config, contextWindows, deps.forceAvailableSelectors !== undefined ? { selectors: deps.forceAvailableSelectors } : { entries: deps.forceAvailable ?? [] });
@@ -415,7 +417,9 @@ export function buildClaudeEnv(
415
417
  // with automatic [1m] context-variant marking when the slot's target model has an
416
418
  // authoritative >=1M window (Claude Code then accounts 1M, compaction preserved).
417
419
  for (const [name, value] of Object.entries(effectiveModelEnv(config.claudeCode, contextWindows, auto))) {
418
- setDefault(name, value);
420
+ // A tier slot this launch wrote gets its picker label too (#6926). A slot the user exported
421
+ // stays unlabelled, and a `<slot>_NAME` the user exported wins.
422
+ if (setDefault(name, value) && isClaudeTierLabelSlot(name)) setDefault(`${name}_NAME`, claudeTierDisplayName(value));
419
423
  }
420
424
  return env;
421
425
  }
@@ -682,7 +686,12 @@ export function buildNativeClaudeEnv(
682
686
  const providerNames = Object.keys(config.providers);
683
687
  for (const name of MODEL_ENV_SLOT_NAMES) {
684
688
  const value = env[name];
685
- if (value && isProxyOnlyModelId(value, providerNames)) delete env[name];
689
+ if (!value || !isProxyOnlyModelId(value, providerNames)) continue;
690
+ delete env[name];
691
+ // The label opencodex paired with this slot names a proxy route the native session cannot use.
692
+ const labelName = `${name}_NAME`;
693
+ const label = claudeTierDisplayName(value);
694
+ if (isClaudeTierLabelSlot(name) && label !== undefined && env[labelName] === label) delete env[labelName];
686
695
  }
687
696
  if (deps.allowRootSkipPermissions === true && !env.IS_SANDBOX) env.IS_SANDBOX = "1";
688
697
  return env;
@@ -60,11 +60,14 @@ export function remoteHubConfigNote(
60
60
  // alone describes a machine that cannot read its hub, and `ocx status` is still the command
61
61
  // that has the facts — so the note points there in every case, connected or not.
62
62
  const connected = connection.state === "connected" && connection.token === "owned";
63
+ const link = config.client.transport === "link";
63
64
  const note = connection.state !== "connected"
64
65
  ? `this machine is configured as a client but its connection is ${connection.state}${connection.reason ? ` (${connection.reason})` : ""}; run ocx connect status`
65
66
  : connection.token !== "owned"
66
- ? `this machine is configured as a client but its hub data-plane token is ${connection.token}; run ocx connect status`
67
- : "provider credentials and model availability live on the hub; run ocx status";
67
+ ? `this machine is configured as a client but its ${link ? "Remote Link key" : "hub data-plane token"} is ${connection.token}; run ocx connect status`
68
+ : link
69
+ ? "this machine is a Remote Link Child: provider credentials, model availability and the Codex settings OpenCodex writes come from its Home; the settings in this file are local and inactive while linked; run ocx status"
70
+ : "provider credentials and model availability live on the hub; run ocx status";
68
71
  return { connected, origin: config.client.serverUrl, note };
69
72
  }
70
73
 
@@ -235,6 +238,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
235
238
  const path = args.shift();
236
239
  if (!path) throw new CliUsageError("config path is required", USAGE);
237
240
  rejectArgs(args, USAGE);
241
+ await (await import("./link-child")).printLinkChildLocalNotice("The values");
238
242
  const diagnostics = readConfigDiagnostics();
239
243
  if (diagnostics.source === "fallback" || diagnostics.error) {
240
244
  console.error(`Warning: Config at ${getConfigPath()} is invalid or unreadable; defaults are being shown for invalid settings. Run: ocx config validate. Inspect: ocx config show --source.`);
@@ -246,6 +250,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
246
250
  return;
247
251
  }
248
252
  if (action === "set" || action === "unset") {
253
+ await (await import("./link-child")).printLinkChildLocalWriteNotice();
249
254
  const path = args.shift();
250
255
  const raw = action === "set" ? args.shift() : undefined;
251
256
  if (!path || (action === "set" && raw === undefined)) throw new CliUsageError(action === "unset" ? "config path is required" : "config path and value are required", USAGE);
@@ -316,6 +321,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
316
321
  return;
317
322
  }
318
323
  if (action === "import") {
324
+ await (await import("./link-child")).printLinkChildLocalWriteNotice();
319
325
  const path = args.shift();
320
326
  const yes = takeFlag(args, "--yes");
321
327
  if (!path) throw new CliUsageError("import path is required", USAGE);
package/src/cli/debug.ts CHANGED
@@ -10,6 +10,14 @@ async function requireLiveProxy() {
10
10
  console.error("Proxy is not running. Start it with: ocx start");
11
11
  process.exit(1);
12
12
  }
13
+ if (live.role === "client") {
14
+ // Debug capture is the serving proxy's; a connected client has no management API (#6957).
15
+ const { readLinkChildContext, linkChildRefusal } = await import("./link-child");
16
+ const link = readLinkChildContext();
17
+ console.error(link ? linkChildRefusal(link, "ocx debug")
18
+ : "This listener runs in the client role and has no management API. Run this command on its serving hub. No request was sent.");
19
+ process.exit(1);
20
+ }
13
21
  return live;
14
22
  }
15
23
 
@@ -31,7 +31,7 @@ import { restoreNativeCodexAsync, type CodexNativeRestoreResult } from "../codex
31
31
  import { stripGrokConfig } from "../grok/inject";
32
32
  import { handleRestartScopeAfterWrite, readRestartScope, type RestartScope } from "./restart-scope";
33
33
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
34
- import { isJsonOption, takeFlag, terminalSafeError } from "./runtime-api";
34
+ import { isJsonOption, takeFlag, terminalSafeError, terminalSafeText } from "./runtime-api";
35
35
  import { printStopSummary, type StopOutcome } from "./stop-report";
36
36
  import { parseStopApproval, type StopApproval } from "./stop-approval";
37
37
  import type { ResolveArgs } from "./resolve";
@@ -407,9 +407,22 @@ const commandRunners: Record<string, CommandRunner> = {
407
407
  try {
408
408
  const { syncConnectedClient } = await import("../client/connect");
409
409
  const result = await syncConnectedClient({ restartCodex });
410
+ const link = clientState.value.transport === "link";
410
411
  console.log(result.stale
411
- ? "Hub unavailable; retained and applied the last-known-good remote catalog (stale)."
412
- : "Remote hub catalog synchronized.");
412
+ ? `${link ? "Home" : "Hub"} unavailable; retained and applied the last-known-good remote catalog (stale).`
413
+ : link ? "Remote Link Home catalog synchronized." : "Remote hub catalog synchronized.");
414
+ if (result.homeSettings) {
415
+ const { describeHomeCodexSettings } = await import("../link/codex-settings");
416
+ const settings = result.homeSettings;
417
+ if (settings.source === "local") {
418
+ console.log(`Codex settings: kept this machine's local values (${terminalSafeText(settings.reason)}).`);
419
+ } else {
420
+ console.log(settings.source === "home"
421
+ ? `Codex settings applied from Home ${terminalSafeText(settings.document.home.name)}:`
422
+ : `Codex settings applied from the last recorded Home read (${terminalSafeText(settings.reason)}):`);
423
+ for (const line of describeHomeCodexSettings(settings.document.settings)) console.log(` ${terminalSafeText(line)}`);
424
+ }
425
+ }
413
426
  await handleConnectedSyncCatalogWrite(result, restartScope);
414
427
  // `process.exitCode` rather than a literal 0, for the same reason every other
415
428
  // runner does it (tests/cli/cli-transport-honesty.test.ts): the catalog-write helper
@@ -29,6 +29,7 @@
29
29
  import { writeFileSync } from "node:fs";
30
30
  import { loadConfig } from "../config";
31
31
  import {
32
+ ClientPathError,
32
33
  EXPORT_CLIENTS,
33
34
  EXPORT_CLIENT_IDS,
34
35
  buildClientConfigText,
@@ -193,6 +194,18 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep
193
194
  const clientConfig = built.document;
194
195
  const text = built.text;
195
196
 
197
+ // Resolve the human-facing destination BEFORE writing anything. A client whose
198
+ // config location cannot be determined (a relative override, or ZCode bootstrap
199
+ // settings naming an unknown data root) still exports the document: the export
200
+ // writes nothing into the client and the destination is advisory text.
201
+ let destination: string;
202
+ try {
203
+ destination = spec.destination(process.env);
204
+ } catch (error) {
205
+ if (!(error instanceof ClientPathError)) throw error;
206
+ destination = `unknown (${error.message})`;
207
+ }
208
+
196
209
  // Every serializer already ends with exactly one newline.
197
210
  if (out !== undefined) writeExport(out, text, force);
198
211
  // stderr, so `--json` stdout stays a standalone JSON document.
@@ -208,7 +221,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep
208
221
  ...text.trimEnd().split("\n"),
209
222
  "",
210
223
  ...(out !== undefined ? [`Wrote ${out}`] : []),
211
- `Destination: ${spec.destination(process.env)}`,
224
+ `Destination: ${destination}`,
212
225
  "Merge this generated configuration into that file; do not replace it.",
213
226
  `Before launching: ${spec.exportHint}`,
214
227
  `${modelCount} model${modelCount === 1 ? "" : "s"}; ${modelsWithoutLimits} omit context limits (the client applies its own defaults).`,
@@ -0,0 +1,99 @@
1
+ import { mutatePersistedConfig, validateConfigCandidate } from "../config";
2
+ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
3
+ import { inspectDesktopSupervision } from "../service/desktop-supervision.mjs";
4
+ import { probeServiceInstallation } from "../service/cli";
5
+ import { redactSecretString } from "../lib/redact";
6
+ import { CliUsageError, printData, runCliAction } from "./runtime-api";
7
+ import type { OcxConfig } from "../types";
8
+
9
+ export const HUB_DISABLE_USAGE = "ocx hub disable [--revoke-clients --client-key <id> ...] [--json]";
10
+ const HUB_KEYS = ["hub", "unauthenticatedLoopbackListener", "runtimeRole", "hostname"] as const;
11
+ const PUBLISHING_NOTE = "External publishing is unchanged. Check tailscale serve status and any other frontend for mappings to the old hub ports.";
12
+
13
+ export interface HubDisableOptions { json: boolean; revokeClients: boolean; clientKeys: string[] }
14
+ export interface HubDisableDeps {
15
+ mutateConfig?: typeof mutatePersistedConfig;
16
+ restartHint?: () => string;
17
+ }
18
+ interface HubDisableResult {
19
+ removed: string[];
20
+ keys: { id: string; name: string; createdAt: string; revoked: boolean }[];
21
+ revoked: string[];
22
+ }
23
+
24
+ export function parseHubDisableArgs(args: string[]): HubDisableOptions {
25
+ const options: HubDisableOptions = { json: false, revokeClients: false, clientKeys: [] };
26
+ for (let index = 0; index < args.length; index++) {
27
+ const arg = args[index];
28
+ if (arg === "--json" && !options.json) options.json = true;
29
+ else if (arg === "--revoke-clients" && !options.revokeClients) options.revokeClients = true;
30
+ else if (arg === "--client-key") {
31
+ const id = args[++index];
32
+ if (!id || id.startsWith("-") || options.clientKeys.includes(id)) throw new CliUsageError("Each --client-key requires a unique exact key ID.", HUB_DISABLE_USAGE);
33
+ options.clientKeys.push(id);
34
+ } else throw new CliUsageError("Unknown or repeated hub disable argument.", HUB_DISABLE_USAGE);
35
+ }
36
+ if (options.clientKeys.length && !options.revokeClients) throw new CliUsageError("--client-key requires --revoke-clients.", HUB_DISABLE_USAGE);
37
+ return options;
38
+ }
39
+
40
+ /** Selection is explicit: legacy keys have editable names but no trustworthy issuance marker. */
41
+ export function disableHubConfig(config: OcxConfig, options: HubDisableOptions): HubDisableResult {
42
+ if (config.runtimeRole !== "hub") throw new CliUsageError("This machine is not a hub; no settings or keys were changed.");
43
+ const keys = config.apiKeys ?? [];
44
+ const secrets = keys.flatMap(key => [key.key, key.pendingRotation?.key]).filter((secret): secret is string => Boolean(secret));
45
+ const safeMetadata = (value: string) => redactSecretString(secrets.reduce((text, secret) => text.split(secret).join("[REDACTED]"), value));
46
+ if (options.revokeClients && keys.length && !options.clientKeys.length) {
47
+ throw new CliUsageError("Keys have no verified client origin. List IDs with 'ocx access key list --json', then repeat --client-key <id> for each client key to revoke.", HUB_DISABLE_USAGE);
48
+ }
49
+ const selected = new Set(options.clientKeys);
50
+ if (options.clientKeys.some(id => !keys.some(key => key.id === id))) {
51
+ throw new CliUsageError("A selected client key ID is not in the current config; no settings or keys were changed.");
52
+ }
53
+ const candidate = structuredClone(config);
54
+ const removed = HUB_KEYS.filter(key => Object.hasOwn(candidate, key));
55
+ for (const key of HUB_KEYS) delete candidate[key];
56
+ if (options.revokeClients) candidate.apiKeys = keys.filter(key => !selected.has(key.id));
57
+ const validated = validateConfigCandidate(candidate);
58
+ if (!validated.ok) throw new CliUsageError(validated.error);
59
+ // Keep deletion intent so a later live save cannot resurrect the hub's old bindings.
60
+ for (const key of HUB_KEYS) deleteConfigTopLevelKey(config, key);
61
+ Object.assign(config, validated.config);
62
+ return {
63
+ removed,
64
+ keys: keys.map(key => ({ id: safeMetadata(key.id), name: safeMetadata(key.name), createdAt: safeMetadata(key.createdAt), revoked: selected.has(key.id) })),
65
+ revoked: options.revokeClients ? [...selected].map(safeMetadata) : [],
66
+ };
67
+ }
68
+
69
+ export function hubDisableRestartHint(): string {
70
+ try {
71
+ if (inspectDesktopSupervision().kind === "desktop") return "Restart to apply: ocx restart (OpenCodex Desktop supervises the replacement).";
72
+ const service = probeServiceInstallation();
73
+ if (service.state === "installed") return "Restart to apply: ocx service restart.";
74
+ } catch { /* An unavailable probe must not prevent an already validated config change. */ }
75
+ return "Restart to apply: use your current proxy supervisor; for a manually started proxy, ocx restart.";
76
+ }
77
+
78
+ export async function runHubDisable(args: string[], deps: HubDisableDeps = {}): Promise<number> {
79
+ return runCliAction(async () => {
80
+ const options = parseHubDisableArgs(args);
81
+ const outcome = (deps.mutateConfig ?? mutatePersistedConfig)(config => ({
82
+ changed: true, value: disableHubConfig(config, options),
83
+ }));
84
+ if (outcome.status === "unavailable") throw new Error(`Hub disable could not read or commit the config (${outcome.reason}); retry after fixing the config.`);
85
+ let restart: string;
86
+ try { restart = (deps.restartHint ?? hubDisableRestartHint)(); }
87
+ catch { restart = "Restart to apply: use your current proxy supervisor."; }
88
+ const result = { ok: true, ...outcome.value, restart, note: PUBLISHING_NOTE, keyOrigin: "unverified", effectiveAfterRestart: true };
89
+ // Explicit field projection excludes secrets and pending rotations; names are arbitrary input.
90
+ const safe = { ...result, keys: result.keys.map(key => ({ ...key, name: redactSecretString(key.name) })) };
91
+ printData(safe, options.json, [
92
+ `Removed hub settings: ${safe.removed.join(", ")}.`,
93
+ ...safe.keys.map(key => `${key.revoked ? "Revoked" : "Retained"} saved key: ${key.id} (${key.name})`),
94
+ "Saved keys have no verified client origin; revocation uses only explicitly selected IDs.",
95
+ restart, PUBLISHING_NOTE,
96
+ "Restart is required to apply all saved settings; this command does not terminate existing sessions.",
97
+ ]);
98
+ });
99
+ }
package/src/cli/hub.ts CHANGED
@@ -7,9 +7,10 @@ import {
7
7
  type GuiPairRequestResult,
8
8
  } from "./gui-pair-client";
9
9
  import type { RuntimeApiDeps } from "./runtime-api";
10
+ import { HUB_DISABLE_USAGE, runHubDisable, type HubDisableDeps } from "./hub-disable";
10
11
 
11
12
  export const HUB_USAGE =
12
- "ocx hub invite [--json] [--data-url <origin>] [--management-url <origin>] [--clients codex,claude]";
13
+ "ocx hub invite [--json] [--data-url <origin>] [--management-url <origin>] [--clients codex,claude]\n " + HUB_DISABLE_USAGE;
13
14
 
14
15
  const PAIRING_WARNING = "Pairing codes are secret, single-use, and expire quickly. Do not save them.";
15
16
 
@@ -23,7 +24,7 @@ const PAIRING_WARNING = "Pairing codes are secret, single-use, and expire quickl
23
24
  */
24
25
  const DEFAULT_CLIENT_BROWSER_ORIGIN = "http://localhost:10100";
25
26
 
26
- export interface HubCommandDeps extends RuntimeApiDeps {
27
+ export interface HubCommandDeps extends RuntimeApiDeps, HubDisableDeps {
27
28
  loadConfig: () => OcxConfig;
28
29
  findLiveProxy?: () => Promise<LiveProxy | null>;
29
30
  requestPairingGrant?: (
@@ -361,6 +362,7 @@ async function runInvite(args: string[], deps: HubCommandDeps): Promise<number>
361
362
  }
362
363
 
363
364
  export async function runHubCommand(args: string[], deps: HubCommandDeps): Promise<number> {
365
+ if (args[0] === "disable") return runHubDisable(args.slice(1), deps);
364
366
  if (args[0] !== "invite") {
365
367
  console.error(`Usage: ${HUB_USAGE}`);
366
368
  return 1;
package/src/cli/index.ts CHANGED
@@ -11,6 +11,7 @@ import { packageVersion } from "../lib/package-version";
11
11
  import { codexHomeIsAbsent } from "../codex/codex-home-owner";
12
12
  import { getCodexHome } from "../codex/paths";
13
13
  import { admitUpdateRestartChild } from "./update-restart-child";
14
+ import { HEALTH_SENTINEL_STANDALONE_ARG, startServiceHealthSentinel } from "../service/health-sentinel-launch";
14
15
  import { UpdateRestartRequired } from "./update-restart-candidate";
15
16
  import { describeUpdateRestartFailure, restartFromCurrentInstallation } from "./update-restart";
16
17
  import { findGuiDist } from "../server/gui-static";
@@ -212,6 +213,7 @@ initializeNodeLauncherContext();
212
213
  // The compiled executable is also the capture-only MCP server's launcher.
213
214
  // Handle this private entrypoint before CLI preflight or command dispatch.
214
215
  if (process.argv[2] === "__keyring-load-check") { console.log(JSON.stringify((await import("../lib/keyring-native")).inspectKeyringBinding())); process.exit(0); }
216
+ if (process.argv[2] === HEALTH_SENTINEL_STANDALONE_ARG) process.exit(await (await import("../service/health-sentinel-main")).runHealthSentinelProcess());
215
217
 
216
218
  // The compiled executable also launches the isolated MCP servers.
217
219
  // Handle these private entrypoints before CLI preflight or command dispatch.
@@ -623,6 +625,7 @@ async function handleStart(options: { block?: boolean } = {}) {
623
625
  // Records are visible now; background work may observe this runtime without a gap.
624
626
  scheduleCatalogPrewarm();
625
627
  installCrashGuards();
628
+ let healthSentinel: ReturnType<typeof startServiceHealthSentinel> = null; // opt-in #6671, started once ready
626
629
  // No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it
627
630
  // is about to transform; snapshotting earlier only captured a baseline that could
628
631
  // already be stale by the time injection ran (#477).
@@ -646,6 +649,7 @@ async function handleStart(options: { block?: boolean } = {}) {
646
649
  try { routingHealer?.stop(); } catch { /* best-effort */ }
647
650
  try { guardian.stop(); } catch { /* best-effort */ }
648
651
  try { historyGuardian?.stop(); } catch { /* best-effort */ }
652
+ try { healthSentinel?.stop(); } catch { /* best-effort */ }
649
653
  // Dashboard drain-and-restart (#563) must not tear down injection: the replacement
650
654
  // process expects Codex/Grok/env fences to still be in place. A sibling owns none of them.
651
655
  const teardown = decideStartExitTeardown({ sibling: siblingStart, recycling: isRecyclingForExit(), ocxService: process.env.OCX_SERVICE });
@@ -746,6 +750,7 @@ async function handleStart(options: { block?: boolean } = {}) {
746
750
  );
747
751
  if (!startupSync.ran) console.log(startupLeftCodexNativeLine(localClientSkipReason(config), server.port ?? port));
748
752
  await refreshOwnedRaycastCatalog(config, port);
753
+ if (!cleaned && !shuttingDown) healthSentinel = startServiceHealthSentinel(config); // after startup reconciliation; never mid-boot or mid-drain
749
754
  // #1046: one warning per startup, after BOTH writes. The server's cache
750
755
  // invalidation happens first and the catalog sync second, so the mtime is only
751
756
  // final here — and neither write site warns on its own, or a boot that hits
@@ -1717,7 +1722,7 @@ async function handleStatus() {
1717
1722
  // are tagged `(local)`. A reader who sees neither draws the wrong conclusion from a correct
1718
1723
  // report — an agent read `xai ✗ not logged in` off a client and decided the hub could not
1719
1724
  // serve grok.
1720
- const remoteHubBanner = remoteHubBannerLine(status.json.remoteHub);
1725
+ const remoteHubBanner = remoteHubBannerLine(status.json.remoteHub, status.json.connection.transport);
1721
1726
  if (remoteHubBanner) console.log(remoteHubBanner);
1722
1727
  // `(local)` only while connected: on a standalone install every line is local, and tagging
1723
1728
  // them all would be noise that trains the reader to skip the tag.
@@ -1798,7 +1803,9 @@ async function handleStatus() {
1798
1803
  if (status.json.hub) {
1799
1804
  for (const line of hubStatusLines(status.json.hub)) console.log(` ${line}`);
1800
1805
  }
1801
- console.log(` Remote hub: ${status.json.connection.state}${status.json.connection.serverUrl ? ` (${status.json.connection.serverUrl})` : ""}`);
1806
+ console.log(status.json.remoteHub.link
1807
+ ? ` Remote Link Home: ${status.json.connection.state} (${status.json.remoteHub.link.homePhrase}, ${status.json.remoteHub.link.linkId})`
1808
+ : ` ${status.json.connection.transport === "link" ? "Remote Link Home" : "Remote hub"}: ${status.json.connection.state}${status.json.connection.serverUrl ? ` (${status.json.connection.serverUrl})` : ""}`);
1802
1809
  if (status.json.connection.state === "invalid" || status.json.connection.state === "mismatched") {
1803
1810
  console.log(` ⚠️ ${status.json.connection.reason}`);
1804
1811
  }
@@ -1854,10 +1861,12 @@ async function handleStatus() {
1854
1861
  // that decide what a request can route to. The local block still prints — an operator debugging
1855
1862
  // a half-migrated machine needs to see it — but under a heading that says it is not in use, and
1856
1863
  // below the hub's, so the hub's is what a reader (or an agent) encounters first (#4236).
1857
- for (const line of remoteHubStatusLines(status.json.remoteHub)) console.log(` ${line}`);
1864
+ for (const line of remoteHubStatusLines(status.json.remoteHub, status.json.connection.transport)) console.log(` ${line}`);
1858
1865
  if (status.json.remoteHub.connected) {
1859
1866
  console.log(status.json.remoteHub.stateSource === "unavailable"
1860
- ? " Local-only credential state (this is NOT the hub's; the hub's state could not be read):"
1867
+ ? status.json.connection.transport === "link"
1868
+ ? " Local-only credential state (Home state is unavailable over Remote Link):"
1869
+ : " Local-only credential state (this is NOT the hub's; the hub's state could not be read):"
1861
1870
  : " Local-only (not used for routing while connected):");
1862
1871
  }
1863
1872
  console.log(` OAuth logins${local}:`);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Management commands on a Remote Link Child (#6957).
3
+ *
4
+ * A link Child's listener serves no management API, and the Home owns every setting that decides
5
+ * routing. Each management command therefore does one of two things: an allowlisted read goes
6
+ * to the Home through the connection-bound link relay, and anything else is refused with the one
7
+ * message `linkChildManagementRefusal` builds, which names the Home and the link.
8
+ */
9
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
10
+ import { readServiceApiTokenState } from "../lib/service-secrets";
11
+ import { linkChildManagementRefusal, linkManagementReadAllowed } from "../link/management-routes";
12
+ import { readClientConnectionState } from "../client/state";
13
+ import { fetchLinkHomeManagement, linkHomePhrase, type LinkHomeDeps } from "../client/link-home";
14
+ import type { OcxClientConnectionConfig } from "../types";
15
+
16
+ const RESPONSE_MAX_BYTES = 32 * 1024 * 1024;
17
+
18
+ export interface LinkChildContext {
19
+ connection: OcxClientConnectionConfig;
20
+ linkId: string;
21
+ homePhrase: string;
22
+ }
23
+
24
+ /** The link Child this CLI runs on, or null on a standalone, a hub or a hub client. */
25
+ export function readLinkChildContext(read: typeof readClientConnectionState = readClientConnectionState): LinkChildContext | null {
26
+ let state: ReturnType<typeof readClientConnectionState>;
27
+ try { state = read(); } catch { return null; }
28
+ if (state.kind !== "connected" || state.value.transport !== "link" || !state.value.link) return null;
29
+ return { connection: state.value, linkId: state.value.link.linkId, homePhrase: linkHomePhrase(state.value) };
30
+ }
31
+
32
+ export function linkChildRefusal(context: LinkChildContext, operation?: string): string {
33
+ return linkChildManagementRefusal(context.homePhrase, context.linkId, operation);
34
+ }
35
+
36
+ /**
37
+ * The stderr label for a local read on a link Child (`ocx provider list`, `ocx config get`):
38
+ * the values are real, but they are this machine's and the Home's apply.
39
+ */
40
+ export function linkChildLocalNotice(what: string): string | null {
41
+ const link = readLinkChildContext();
42
+ if (!link) return null;
43
+ return `Note: this machine is a Remote Link Child managed by ${link.homePhrase} (link ${link.linkId}). `
44
+ + `${what} below are this machine's local configuration. Its provider, model and routing settings are inactive while it is linked; the Home's apply. Run 'ocx status' for the Home's state.`;
45
+ }
46
+
47
+ export async function printLinkChildLocalNotice(what: string): Promise<void> {
48
+ const notice = linkChildLocalNotice(what);
49
+ if (notice) console.error(notice);
50
+ }
51
+
52
+ /** `ocx config set|unset|import` on a link Child: the write is local and its routing parts inactive. */
53
+ export async function printLinkChildLocalWriteNotice(): Promise<void> {
54
+ const link = readLinkChildContext();
55
+ if (!link) return;
56
+ console.error(`Note: this machine is a Remote Link Child managed by ${link.homePhrase} (link ${link.linkId}). `
57
+ + "This writes this machine's local configuration; its provider, model and routing settings stay inactive while it is linked, and the Home's apply.");
58
+ }
59
+
60
+ /** The refusal for a local provider change on a link Child, or null elsewhere. */
61
+ export function linkChildProviderChangeRefusal(operation: string): string | null {
62
+ const link = readLinkChildContext();
63
+ return link ? linkChildRefusal(link, operation) : null;
64
+ }
65
+
66
+ /** Can this management request be answered by reading the Home? */
67
+ export function linkChildReadable(method: string, path: string): boolean {
68
+ const url = new URL(path.startsWith("/") ? path : `/${path}`, "http://cli.invalid");
69
+ return linkManagementReadAllowed(method, url.pathname, url.searchParams);
70
+ }
71
+
72
+ /** One allowlisted read from the Home. Throws with an operator sentence when it cannot be sent. */
73
+ export async function readLinkHomeJson(
74
+ context: LinkChildContext,
75
+ path: string,
76
+ deps: LinkHomeDeps = {},
77
+ ): Promise<{ status: number; body: unknown }> {
78
+ const token = readServiceApiTokenState();
79
+ if (token.kind !== "present" || token.fingerprint !== context.connection.tokenFingerprint) {
80
+ throw new Error(`The enrolled link key for ${context.homePhrase} is missing or changed; re-link this machine from the Home.`);
81
+ }
82
+ let response: Response;
83
+ try {
84
+ response = await fetchLinkHomeManagement(context.connection, token.token, path.startsWith("/") ? path : `/${path}`, {}, deps);
85
+ } catch (error) {
86
+ const detail = error instanceof Error && error.name === "LinkRelayAuthenticationError" ? error.message : "the link tunnel did not answer";
87
+ throw new Error(`Could not read ${context.homePhrase} over Remote Link (${context.linkId}): ${detail}.`);
88
+ }
89
+ const result = await readBoundedResponseBytes(response, { maxBytes: RESPONSE_MAX_BYTES, signal: AbortSignal.timeout(30_000) });
90
+ if (result.oversized) throw new Error(`${context.homePhrase} returned a response larger than the CLI accepts.`);
91
+ const text = new TextDecoder().decode(result.bytes);
92
+ let body: unknown = null;
93
+ if (text) {
94
+ try { body = JSON.parse(text); } catch { body = text; }
95
+ }
96
+ // A Home that predates the relay answers its listener's generic unknown-endpoint 404.
97
+ const error = body && typeof body === "object" ? (body as Record<string, unknown>).error : undefined;
98
+ const unknownEndpoint = error === "not_found"
99
+ || (!!error && typeof error === "object" && (error as Record<string, unknown>).code === "not_found");
100
+ if (response.status === 404 && unknownEndpoint) {
101
+ return { status: 503, body: { error: `${context.homePhrase} does not serve Remote Link management reads; upgrade the Home.` } };
102
+ }
103
+ return { status: response.status, body };
104
+ }
@@ -0,0 +1,96 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { assertSshAlias } from "../link/ssh-argv";
3
+ import { CliUsageError, RuntimeApiError, runtimeBaseUrl, rejectArgs, takeJsonFlag, takeOption, printData } from "./runtime-api";
4
+ import type { LinkCliDeps } from "./link";
5
+
6
+ const FINGERPRINT = /^SHA256:[A-Za-z0-9+/]{16,88}={0,2}$/;
7
+ const ENROLLMENT_DEADLINE_MS = 360_000;
8
+ const PHASES: Record<string, string> = {
9
+ remote_port_failed: "remote port selection", remote_port_timeout: "remote port selection",
10
+ child_preflight_failed: "Child preflight", child_preflight_timeout: "Child preflight",
11
+ child_restart_unsupported: "Child restart preflight", child_curl_missing: "Child curl preflight",
12
+ home_setup_failed: "Home setup", home_setup_timeout: "Home setup",
13
+ remote_connect_failed: "Child enrollment", remote_connect_timeout: "Child enrollment",
14
+ child_restart_failed: "Child restart", child_restart_timeout: "Child restart",
15
+ child_relay_failed: "Child relay verification", child_relay_timeout: "Child relay verification",
16
+ child_runtime_not_client: "Child runtime role verification",
17
+ remote_platform_unsupported: "remote platform verification", remote_ocx_missing: "remote OpenCodex discovery",
18
+ };
19
+ const RECOVERY = "Run ocx disconnect and ocx restart on the Child before retrying.";
20
+ type LinkRequest = <T>(path: string, init: RequestInit, deps: LinkCliDeps) => Promise<T>;
21
+
22
+ function record(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
23
+ function offered(value: unknown): { alias: string; fingerprint: string; keyType: string; ocxVersion: string } {
24
+ if (!record(value) || Object.keys(value).sort().join(",") !== "alias,fingerprint,keyType,ocxVersion"
25
+ || typeof value.alias !== "string" || typeof value.fingerprint !== "string" || !FINGERPRINT.test(value.fingerprint)
26
+ || typeof value.keyType !== "string" || !/^[A-Za-z0-9@._+-]{1,64}$/.test(value.keyType)) throw new Error("Invalid host probe response");
27
+ if (typeof value.ocxVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]{1,128})?$/.test(value.ocxVersion)) throw new Error("Invalid host version response");
28
+ return { alias: value.alias, fingerprint: value.fingerprint, keyType: value.keyType, ocxVersion: value.ocxVersion };
29
+ }
30
+
31
+ export async function runLinkEnrollment(command: "probe" | "add", args: string[], deps: LinkCliDeps, request: LinkRequest, usage: string): Promise<void> {
32
+ const wantsJson = takeJsonFlag(args);
33
+ const expected = takeOption(args, "--expect-fingerprint");
34
+ const alias = args.shift();
35
+ if (!alias) throw new CliUsageError(command + " requires an SSH alias", usage);
36
+ try { assertSshAlias(alias); } catch { throw new CliUsageError("Invalid SSH alias", usage); }
37
+ rejectArgs(args, usage);
38
+ if (command === "probe" && expected !== undefined) throw new CliUsageError("--expect-fingerprint is only for link add", usage);
39
+ if (expected !== undefined && !FINGERPRINT.test(expected)) throw new CliUsageError("Expected a SHA256 SSH fingerprint", usage);
40
+ if (command === "add" && !expected && !(deps.stdinImpl ?? process.stdin).isTTY) {
41
+ throw new CliUsageError("Non-interactive link add requires --expect-fingerprint", usage);
42
+ }
43
+ const timeout = deps.enrollmentTimeoutMs ?? ENROLLMENT_DEADLINE_MS;
44
+ if (!Number.isFinite(timeout) || timeout < 1 || timeout > ENROLLMENT_DEADLINE_MS) throw new Error("Invalid link enrollment deadline");
45
+ const controller = new AbortController();
46
+ let timer: ReturnType<typeof setTimeout> | undefined;
47
+ const expiry = new Promise<never>((_, reject) => {
48
+ timer = setTimeout(() => { controller.abort(); reject(new Error("Link enrollment timed out; its outcome is uncertain. Run ocx link status on the Home and ocx connect status on the Child before retrying.")); }, timeout);
49
+ });
50
+ let boundDeps = deps;
51
+ const post = async <T>(path: string, body: unknown): Promise<T> => {
52
+ controller.signal.throwIfAborted();
53
+ const result = await request<T>(path, { method: "POST", redirect: "error", signal: controller.signal, headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, boundDeps);
54
+ controller.signal.throwIfAborted();
55
+ return result;
56
+ };
57
+ let rl: ReturnType<typeof createInterface> | undefined;
58
+ const work = async () => {
59
+ boundDeps = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
60
+ controller.signal.throwIfAborted();
61
+ const probe = offered(await post<unknown>("/api/link/probe", { alias, includeVersion: true }));
62
+ if (probe.alias !== alias) throw new Error("Host probe alias did not match");
63
+ if (command === "probe") { printData(probe, wantsJson, [probe.alias + " " + probe.keyType + " " + probe.fingerprint + " opencodex " + probe.ocxVersion]); return; }
64
+ if (expected && expected !== probe.fingerprint) throw new CliUsageError("The offered host fingerprint does not match --expect-fingerprint");
65
+ if (!expected) {
66
+ let approved: boolean;
67
+ if (deps.confirmFingerprint) approved = await deps.confirmFingerprint(probe);
68
+ else {
69
+ rl = createInterface({ input: deps.stdinImpl ?? process.stdin, output: process.stderr });
70
+ approved = (await rl.question("Confirm " + alias + " " + probe.keyType + " " + probe.fingerprint + " opencodex " + probe.ocxVersion + " (type yes): ", { signal: controller.signal })).trim() === "yes";
71
+ }
72
+ controller.signal.throwIfAborted();
73
+ if (!approved) throw new CliUsageError("Host fingerprint was not confirmed");
74
+ }
75
+ const confirm = await post<unknown>("/api/link/confirm-host", { alias, fingerprint: probe.fingerprint });
76
+ if (!record(confirm) || Object.keys(confirm).sort().join(",") !== "alias,fingerprint,ocxVersion" || confirm.alias !== alias || confirm.fingerprint !== probe.fingerprint
77
+ || typeof confirm.ocxVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]{1,128})?$/.test(confirm.ocxVersion)) throw new Error("Invalid host confirmation response");
78
+ const result = await post<unknown>("/api/link/apply", { alias, fingerprint: probe.fingerprint });
79
+ if (!record(result) || Object.keys(result).join(",") !== "linkId" || typeof result.linkId !== "string" || !/^lnk_[0-9a-f]{16}$/.test(result.linkId)) throw new Error("Invalid link apply response");
80
+ printData({ linkId: result.linkId, alias, state: "connected", childRole: "client" }, wantsJson, ["Connected " + alias + " as a Child (" + result.linkId + ")."]);
81
+ };
82
+ try { await Promise.race([work(), expiry]); }
83
+ catch (error) {
84
+ if (error instanceof RuntimeApiError && record(error.body)) {
85
+ const code = record(error.body.error) ? error.body.error.code : undefined;
86
+ const phase = typeof code === "string" && Object.hasOwn(PHASES, code) ? PHASES[code] : undefined;
87
+ const message = phase ? "Remote Link failed during " + phase : error.message;
88
+ if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + RECOVERY : ""));
89
+ }
90
+ throw error;
91
+ } finally {
92
+ if (timer) clearTimeout(timer);
93
+ controller.abort();
94
+ rl?.close();
95
+ }
96
+ }