@bitkyc08/opencodex 2.83.0 → 2.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/ollama-native.ts +29 -1
  10. package/src/adapters/openai-chat/messages.ts +8 -4
  11. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  12. package/src/adapters/openai-responses/passthrough.ts +3 -0
  13. package/src/adapters/openai-responses/request-strips.ts +10 -2
  14. package/src/adapters/qoder/adapter.ts +16 -14
  15. package/src/bridge/errors.ts +19 -0
  16. package/src/bridge.ts +1 -1
  17. package/src/claude/context-windows.ts +54 -1
  18. package/src/claude/intercept/connect-proxy.ts +10 -3
  19. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  20. package/src/claude/intercept/picker-listener.ts +49 -1
  21. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  22. package/src/claude/intercept/settings.ts +101 -9
  23. package/src/cli/account-api.ts +65 -0
  24. package/src/cli/account-auth.ts +26 -2
  25. package/src/cli/account.ts +41 -5
  26. package/src/cli/capabilities-access-remote.ts +8 -0
  27. package/src/cli/capabilities-base.ts +13 -1
  28. package/src/cli/catalog-command-result.ts +2 -1
  29. package/src/cli/claude.ts +15 -6
  30. package/src/cli/config-command.ts +8 -2
  31. package/src/cli/debug.ts +8 -0
  32. package/src/cli/dispatch.ts +16 -3
  33. package/src/cli/export-command.ts +14 -1
  34. package/src/cli/hub-disable.ts +99 -0
  35. package/src/cli/hub.ts +4 -2
  36. package/src/cli/index.ts +13 -4
  37. package/src/cli/link-child.ts +104 -0
  38. package/src/cli/link-enrollment.ts +96 -0
  39. package/src/cli/link.ts +39 -24
  40. package/src/cli/observe-stream.ts +6 -1
  41. package/src/cli/observe.ts +10 -2
  42. package/src/cli/provider.ts +12 -0
  43. package/src/cli/registry.ts +5 -4
  44. package/src/cli/runtime-api.ts +27 -2
  45. package/src/cli/status.ts +104 -20
  46. package/src/client/connect.ts +39 -4
  47. package/src/client/hub-client.ts +14 -7
  48. package/src/client/hub-state.ts +25 -14
  49. package/src/client/link-home.ts +248 -0
  50. package/src/client/link-management-relay.ts +61 -0
  51. package/src/client/link-relay.ts +6 -1
  52. package/src/client/link-status.ts +14 -0
  53. package/src/client/machine-api.ts +14 -7
  54. package/src/client/machine-listener.ts +29 -4
  55. package/src/clients/config-export.ts +46 -16
  56. package/src/clients/omo-role-models.ts +6 -3
  57. package/src/codex/agent-role-models.ts +26 -8
  58. package/src/codex/auth-api/account-list.ts +17 -2
  59. package/src/codex/auth-api/account-selection.ts +17 -7
  60. package/src/codex/auth-api/routes.ts +5 -1
  61. package/src/codex/autostart-health.ts +3 -1
  62. package/src/codex/catalog/bundled.ts +5 -1
  63. package/src/codex/catalog/effort.ts +2 -2
  64. package/src/codex/catalog/metadata.ts +4 -2
  65. package/src/codex/catalog/native-models.ts +16 -2
  66. package/src/codex/native-profile-startup.ts +4 -1
  67. package/src/codex/prompt-journal.ts +20 -2
  68. package/src/codex/prompt-layers/encoding.ts +49 -0
  69. package/src/codex/prompt-layers/import-source.ts +30 -0
  70. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  71. package/src/codex/prompt-layers/toml-read.ts +52 -4
  72. package/src/codex/prompt-layers.ts +383 -20
  73. package/src/codex/prompt-lock-claim.ts +13 -3
  74. package/src/codex/prompt-lock-evidence.ts +3 -3
  75. package/src/codex/prompt-lock-owner.ts +14 -6
  76. package/src/codex/prompt-text-probe.ts +34 -5
  77. package/src/codex/quota.ts +13 -6
  78. package/src/codex/role-auto-assign.ts +2 -5
  79. package/src/codex/routing/active-account.ts +21 -0
  80. package/src/codex/routing.ts +1 -1
  81. package/src/codex/runtime.ts +196 -8
  82. package/src/codex/shim-overlay.ts +8 -4
  83. package/src/codex/shim.ts +1 -1
  84. package/src/combos/failover.ts +3 -0
  85. package/src/config/api-key-reconcile.ts +34 -0
  86. package/src/config/diagnostics.ts +16 -0
  87. package/src/config/feature-flags.ts +5 -0
  88. package/src/config/live-reconcile.ts +43 -29
  89. package/src/config/live-topology.ts +29 -0
  90. package/src/config/load-degrade.ts +2 -1
  91. package/src/config/paths.ts +3 -0
  92. package/src/config/salvage.ts +3 -1
  93. package/src/config/schema/config-schema.ts +2 -0
  94. package/src/generated/compatibility-version.json +311 -171
  95. package/src/grok/grpc-web.ts +23 -1
  96. package/src/grok/reset-coupon-ledger.ts +75 -5
  97. package/src/grok/reset-coupons.ts +4 -2
  98. package/src/integrations/mutation-plan.ts +13 -7
  99. package/src/integrations/registry.ts +45 -11
  100. package/src/integrations/state.ts +19 -7
  101. package/src/integrations/target.ts +42 -5
  102. package/src/integrations/writer.ts +14 -6
  103. package/src/lib/app-owned-memory-stores.ts +4 -0
  104. package/src/lib/app-owned-memory.ts +11 -0
  105. package/src/lib/json-byte-size.ts +31 -0
  106. package/src/lib/optional-shutdown-hooks.ts +35 -0
  107. package/src/lib/outbound-credential-registry.ts +210 -0
  108. package/src/lib/passive-read.ts +35 -0
  109. package/src/lib/shadow-call.ts +44 -5
  110. package/src/link/codex-settings.ts +155 -0
  111. package/src/link/management-routes.ts +89 -0
  112. package/src/link/remote-probe.ts +38 -0
  113. package/src/link/ssh-argv.ts +26 -5
  114. package/src/link/ssh-runner.ts +58 -27
  115. package/src/link/status-projection.ts +22 -4
  116. package/src/link/supervisor.ts +144 -49
  117. package/src/link/tunnel-state.ts +11 -4
  118. package/src/oauth/anthropic-continuity.ts +4 -3
  119. package/src/oauth/anthropic-identity.ts +13 -2
  120. package/src/oauth/anthropic-model-quota.ts +13 -1
  121. package/src/oauth/anthropic-routing.ts +230 -23
  122. package/src/oauth/anthropic.ts +18 -4
  123. package/src/oauth/google-antigravity.ts +24 -7
  124. package/src/oauth/index.ts +24 -15
  125. package/src/oauth/kiro-account-identity.ts +106 -0
  126. package/src/oauth/kiro.ts +48 -5
  127. package/src/oauth/plan.ts +29 -0
  128. package/src/oauth/store.ts +40 -3
  129. package/src/oauth/types.ts +2 -0
  130. package/src/plugins/loader.ts +11 -0
  131. package/src/providers/anthropic-billing-ownership.ts +74 -0
  132. package/src/providers/anthropic-billing-recovery.ts +31 -0
  133. package/src/providers/anthropic-billing-refusal.ts +55 -0
  134. package/src/providers/anthropic-reset-admission.ts +62 -0
  135. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  136. package/src/providers/api-key-resolve.ts +17 -2
  137. package/src/providers/key-failover.ts +52 -5
  138. package/src/providers/model-rename-startup.ts +5 -3
  139. package/src/providers/quota/account-cache.ts +11 -0
  140. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  141. package/src/providers/quota/report-cache.ts +14 -0
  142. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  143. package/src/providers/quota.ts +4 -1
  144. package/src/providers/registry/entries-core.ts +14 -14
  145. package/src/providers/registry/entries-extended.ts +0 -1
  146. package/src/providers/registry/model-seeds.ts +17 -0
  147. package/src/providers/request-pacing.ts +9 -0
  148. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  149. package/src/responses/compaction.ts +14 -0
  150. package/src/responses/parser.ts +7 -1
  151. package/src/server/auth-cors.ts +42 -0
  152. package/src/server/configured-credentials.ts +194 -0
  153. package/src/server/index/link-management.ts +58 -0
  154. package/src/server/index/optional-listeners.ts +5 -1
  155. package/src/server/index/serve-options.ts +13 -0
  156. package/src/server/lifecycle.ts +2 -1
  157. package/src/server/management/agent-settings-routes.ts +28 -10
  158. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  159. package/src/server/management/codex-agent-role-routes.ts +19 -6
  160. package/src/server/management/codex-prompt-routes.ts +136 -5
  161. package/src/server/management/codex-role-auto-assign.ts +1 -1
  162. package/src/server/management/grok-coupon-routes.ts +78 -27
  163. package/src/server/management/link-enrollment.ts +192 -0
  164. package/src/server/management/link-route-state.ts +185 -0
  165. package/src/server/management/link-routes.ts +82 -402
  166. package/src/server/management/model-rows.ts +3 -1
  167. package/src/server/management/provider-overwrite-carry.ts +10 -0
  168. package/src/server/management/provider-routes.ts +1 -0
  169. package/src/server/management/route-registry.ts +5 -3
  170. package/src/server/management/shared.ts +3 -0
  171. package/src/server/management-api.ts +3 -1
  172. package/src/server/messages-native.ts +68 -42
  173. package/src/server/relay-eager.ts +2 -0
  174. package/src/server/request-log-filter.ts +6 -1
  175. package/src/server/request-log-terminal-status.ts +63 -6
  176. package/src/server/responses/adapter-dispatch.ts +232 -79
  177. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  178. package/src/server/responses/codex-ws-exchange.ts +147 -6
  179. package/src/server/responses/codex-ws-wire.ts +203 -0
  180. package/src/server/responses/collaboration.ts +36 -7
  181. package/src/server/responses/compact.ts +126 -48
  182. package/src/server/responses/context-overflow.ts +13 -1
  183. package/src/server/responses/core-codex-account.ts +105 -1
  184. package/src/server/responses/core-combo-failure.ts +14 -0
  185. package/src/server/responses/core-combo.ts +7 -2
  186. package/src/server/responses/core-errors.ts +29 -1
  187. package/src/server/responses/core-options.ts +2 -0
  188. package/src/server/responses/deferred-collab-surface.ts +158 -0
  189. package/src/server/responses/deferred-send-reporter.ts +25 -0
  190. package/src/server/responses/fetch-helpers.ts +12 -1
  191. package/src/server/responses/passthrough-delivery.ts +4 -1
  192. package/src/server/responses/passthrough-dispatch.ts +15 -4
  193. package/src/server/responses/request-prepare.ts +6 -4
  194. package/src/server/responses/request-transport.ts +6 -8
  195. package/src/server/responses/sidecar-execution.ts +11 -2
  196. package/src/server/responses/ws-upstream.ts +9 -1
  197. package/src/server/startup-action-control.ts +22 -2
  198. package/src/server/startup-health-cache.ts +4 -0
  199. package/src/server/system-env-shell.ts +7 -2
  200. package/src/server/system-env.ts +11 -1
  201. package/src/service/cli.ts +5 -1
  202. package/src/service/desktop-startup.ts +147 -34
  203. package/src/service/desktop-supervision.d.mts +27 -1
  204. package/src/service/desktop-supervision.mjs +111 -5
  205. package/src/service/health-sentinel-launch.ts +176 -0
  206. package/src/service/health-sentinel-main.ts +447 -0
  207. package/src/service/health-sentinel-policy.ts +241 -0
  208. package/src/service/health-sentinel-probe.ts +54 -0
  209. package/src/service/health-sentinel-windows.ts +87 -0
  210. package/src/service/linux-linger.ts +20 -0
  211. package/src/types/config.ts +5 -0
  212. package/src/usage/expected-prices.ts +4 -0
  213. package/src/usage/log.ts +19 -1
  214. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  215. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  216. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
@@ -21,7 +21,7 @@ import {
21
21
  cmdRoutes,
22
22
  cmdStrategy,
23
23
  } from "./account-extended";
24
- import { apiError, apiJson, classifyAccount, fetchRows, proxyUnreachable, resolveBaseUrl, type AccountDeps, type AccountRow, type AccountType, type ApiResult }
24
+ import { accountClassificationConfig, apiError, apiJson, classifyAccount, fetchRows, proxyUnreachable, resolveBaseUrl, type AccountDeps, type AccountRow, type AccountType, type ApiResult }
25
25
  from "./account-api";
26
26
 
27
27
  export { classifyAccount } from "./account-api";
@@ -199,7 +199,12 @@ async function cmdList(rest: string[], deps: AccountDeps): Promise<number> {
199
199
  console.error(ACCOUNT_USAGE);
200
200
  return 1;
201
201
  }
202
- const config = deps.loadConfigImpl?.() ?? loadConfig();
202
+ const classification = await accountClassificationConfig(deps, deps.loadConfigImpl?.() ?? loadConfig());
203
+ if ("error" in classification) {
204
+ console.error(`Error: ${classification.error}`);
205
+ return 1;
206
+ }
207
+ const config = classification.config;
203
208
  const baseUrl = await resolveBaseUrl(deps);
204
209
  if (!baseUrl) return proxyUnreachable();
205
210
 
@@ -289,7 +294,12 @@ async function cmdCurrent(rest: string[], deps: AccountDeps): Promise<number> {
289
294
  console.error(ACCOUNT_USAGE);
290
295
  return 1;
291
296
  }
292
- const config = deps.loadConfigImpl?.() ?? loadConfig();
297
+ const classification = await accountClassificationConfig(deps, deps.loadConfigImpl?.() ?? loadConfig());
298
+ if ("error" in classification) {
299
+ console.error(`Error: ${classification.error}`);
300
+ return 1;
301
+ }
302
+ const config = classification.config;
293
303
  const c = classifyAccount(config, name);
294
304
  if ("error" in c) {
295
305
  console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
@@ -332,7 +342,20 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise<number> {
332
342
  console.error(ACCOUNT_USAGE);
333
343
  return 1;
334
344
  }
335
- const config = deps.loadConfigImpl?.() ?? loadConfig();
345
+ // #6957: an account change on a link Child is refused before any Home read.
346
+ if (!deps.baseUrl) {
347
+ const refusal = (await import("./link-child")).linkChildProviderChangeRefusal("ocx account use");
348
+ if (refusal) {
349
+ console.error(`Error: ${refusal}`);
350
+ return 1;
351
+ }
352
+ }
353
+ const classification = await accountClassificationConfig(deps, deps.loadConfigImpl?.() ?? loadConfig());
354
+ if ("error" in classification) {
355
+ console.error(`Error: ${classification.error}`);
356
+ return 1;
357
+ }
358
+ const config = classification.config;
336
359
  const c = classifyAccount(config, name);
337
360
  if ("error" in c) {
338
361
  console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
@@ -392,7 +415,20 @@ async function cmdClear(rest: string[], deps: AccountDeps): Promise<number> {
392
415
  console.error(ACCOUNT_USAGE);
393
416
  return 1;
394
417
  }
395
- const config = deps.loadConfigImpl?.() ?? loadConfig();
418
+ // #6957: an account change on a link Child is refused before any Home read.
419
+ if (!deps.baseUrl) {
420
+ const refusal = (await import("./link-child")).linkChildProviderChangeRefusal("ocx account clear");
421
+ if (refusal) {
422
+ console.error(`Error: ${refusal}`);
423
+ return 1;
424
+ }
425
+ }
426
+ const classification = await accountClassificationConfig(deps, deps.loadConfigImpl?.() ?? loadConfig());
427
+ if ("error" in classification) {
428
+ console.error(`Error: ${classification.error}`);
429
+ return 1;
430
+ }
431
+ const config = classification.config;
396
432
  const c = classifyAccount(config, name);
397
433
  if ("error" in c) {
398
434
  console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
@@ -2,6 +2,14 @@ import type { Capability } from "./capability-types";
2
2
 
3
3
  /** Verified existing handlers only; local operations never claim management routes. */
4
4
  export const ACCESS_REMOTE_CAPABILITIES: readonly Capability[] = [
5
+ {
6
+ command: ["hub", "disable"], summary: "Return a hub to standalone in one validated config write.",
7
+ usage: "ocx hub disable [--revoke-clients --client-key <id> ...] [--json]",
8
+ routes: [],
9
+ flags: [{ name: "--revoke-clients", value: "boolean", summary: "Revoke only explicitly selected saved client key IDs." }, { name: "--client-key", value: "string", summary: "Exact saved key ID to revoke; repeatable, requires --revoke-clients." }, { name: "--json", value: "boolean", summary: "Emit the result and restart guidance as JSON." }],
10
+ mutates: true, json: "envelope",
11
+ details: ["Removes hub, unauthenticatedLoopbackListener, runtimeRole and hostname atomically after validation; non-hub roles refuse without changes.", "Existing keys have no verified client origin. Names never select keys; --revoke-clients requires exact --client-key IDs when keys exist. Unselected keys remain.", "Prints only safe key metadata, never plaintext keys or pending rotations. Restart advice is informational; the running proxy and external publishing are unchanged."],
12
+ },
5
13
  {
6
14
  command: ["access", "key"], summary: "Manage API access keys and inspect masked metadata.",
7
15
  usage: "ocx access key list [--json]",
@@ -26,6 +26,8 @@ export const CAPABILITIES: readonly Capability[] = [
26
26
  bannerLines: ["ocx chatgpt <sub> Experimental app-server shim: launch|restore|status (macOS)"],
27
27
  details: ["Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher."],
28
28
  },
29
+ {"command": ["link","probe"],"summary": "Probe an SSH host fingerprint and OpenCodex version without trusting it.","routes": [{"method": "POST","path": "/api/link/probe"}],"flags": [{"name": "--json","value": "boolean","summary": "Emit alias, fingerprint, key type and version as JSON."}],"mutates": true,"json": "payload","usage": "ocx link probe <alias> [--json]","details": ["Requires loopback admin authority on the Home. Stores only a temporary pending offer; does not persist host trust or issue a link key."]},
30
+ {"command": ["link","add"],"summary": "Connect an SSH Child to this Home after confirming its fingerprint.","routes": [{"method": "POST","path": "/api/link/probe"},{"method": "POST","path": "/api/link/confirm-host"},{"method": "POST","path": "/api/link/apply"}],"flags": [{"name": "--expect-fingerprint","value": "string","summary": "Exact SHA256 host fingerprint; required without an interactive TTY."},{"name": "--json","value": "boolean","summary": "Emit the verified link result as JSON."}],"mutates": true,"json": "payload","usage": "ocx link add <alias> [--expect-fingerprint SHA256:…] [--json]","details": ["Requires loopback admin authority on the Home. Without a fingerprint, requires a TTY and explicit yes after showing the offered fingerprint. Never prints the link key.","Preflights bound Child restart support and curl, connects, restarts once and verifies a fresh authenticated catalog relay before success. Post-connect failures can leave Child config; run ocx disconnect and ocx restart there before retrying."]},
29
31
  {
30
32
  command: ["link","port"],
31
33
  summary: "Allocate a free loopback port for a remote home link.",
@@ -65,6 +67,16 @@ export const CAPABILITIES: readonly Capability[] = [
65
67
  details: ["Requires a valid link ID and loopback admin authority. Explicit force additionally requires --yes.","Ordinary revoke always emits {linkId} JSON; forced revoke has human output or a JSON cleanup receipt. Already-revoked link_not_found is idempotent success.","Ordinary revoke retains its no-body and idempotent behavior. Forced success reports remoteCleanup:skipped from invocation intent; already-missing link reports unverified. Neither proves the remote client disconnected.","If forced cleanup was needed, run ocx disconnect on the remote client. No new enrollment, SSH trust or credential action is implied."],
66
68
  usage: "ocx link revoke --link-id <id> [--force --yes] [--json]",
67
69
  },
70
+ {
71
+ command: ["link","reconnect"],
72
+ summary: "Restart a Home-initiated link's SSH tunnel now.",
73
+ routes: [{"method":"POST","path":"/api/link/{id}/reconnect"}],
74
+ flags: [{"name":"--link-id","value":"string","required":true,"summary":"Home-initiated link id whose tunnel to restart."},{"name":"--json","value":"boolean","summary":"Emit the restarted link id as JSON."}],
75
+ mutates: true,
76
+ json: "envelope",
77
+ usage: "ocx link reconnect --link-id <id> [--json]",
78
+ details: ["Requires a valid link ID and loopback admin authority. Stops the current ssh child, clears any backoff or failed state and starts a fresh attempt; read ocx link status for the outcome.","A Child-initiated link is refused with link_not_home_initiated: its own computer owns and retries that tunnel.","Always emits {linkId} JSON."],
79
+ },
68
80
  {
69
81
  command: ["remote-workspace","pair"],
70
82
  summary: "Enroll this executor with one Hub using a one-time code from stdin and locally approved roots.",
@@ -717,7 +729,7 @@ export const CAPABILITIES: readonly Capability[] = [
717
729
  flags: [{"name":"--json","value":"boolean","summary":"Emit the role list, the write result or the proposals as JSON."},{"name":"--effort","value":"string","summary":"set: also write the role's reasoning effort."},{"name":"--model","value":"string","summary":"suggest: size the roles with this model instead of the Codex default model."},{"name":"--apply","value":"boolean","summary":"suggest: write every proposal through the role model write."}],
718
730
  mutates: true,
719
731
  json: "payload",
720
- details: ["A bare invocation reads and never writes.","Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles, and set and suggest are refused.","set rewrites only the root model value of $CODEX_HOME/agents/<role>.toml, plus model_reasoning_effort with --effort; omo.jsonc gets the same model and, when LazyCodex has that level, the effort as reasoning; it is skipped when absent or when it contains comments.","suggest sizes every role with one model call and prints proposals without writing; --apply writes each proposed model, and its effort when the role file already sets model_reasoning_effort."],
732
+ details: ["A bare invocation reads and never writes.","Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles, and set and suggest are refused.","set rewrites only the root model value of $CODEX_HOME/agents/<role>.toml, plus model_reasoning_effort with --effort; omo.jsonc gets the same model and, when LazyCodex has that level, the effort as reasoning; it is skipped when absent or when it contains comments.","suggest sizes every role with one model call and prints proposals without writing; --apply writes each proposed model and effort."],
721
733
  usage: "ocx agent roles [status] [--json]; ocx agent roles set <role> <model> [--effort <level>] [--json]; ocx agent roles suggest [--model <id>] [--apply] [--json]",
722
734
  },
723
735
  {
@@ -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
+ }