@bitkyc08/opencodex 2.90.0 → 2.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -24,7 +24,11 @@ export type TrustedNodeLaunchContext = {
24
24
  codexCliPath: string | null;
25
25
  path: string | null;
26
26
  pathExt: string | null;
27
+ home: string | null;
28
+ nodePath: string | null;
27
29
  managerRoots: CodexCliVersionManagerRoots | null;
30
+ transportEnv: Readonly<Record<string, string>>;
31
+ arch: NodeJS.Architecture | null;
28
32
  configDir: string;
29
33
  }> | null;
30
34
  };
@@ -50,6 +54,27 @@ function parseVersionManagerRoots(value: unknown): CodexCliVersionManagerRoots |
50
54
  return Object.freeze(Object.fromEntries(entries)) as CodexCliVersionManagerRoots;
51
55
  }
52
56
 
57
+ function parseTransportEnv(value: unknown): Readonly<Record<string, string>> | undefined {
58
+ // Older snapshots convey no transport settings. Never fill them from Bun's ambient env.
59
+ if (value === undefined) return Object.freeze({});
60
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
61
+ const entries = Object.entries(value);
62
+ if (entries.some(([name, setting]) => !CODEX_CLI_TRANSPORT_ENV_KEYS.includes(name.toLowerCase())
63
+ || typeof setting !== "string" || setting.length > 32 * 1024)) return undefined;
64
+ return Object.freeze(Object.fromEntries(entries));
65
+ }
66
+
67
+ const NODE_ARCHITECTURES: ReadonlySet<string> = new Set<NodeJS.Architecture>([
68
+ "arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc64", "riscv64", "s390x", "x64",
69
+ ]);
70
+
71
+ function parseLauncherArch(value: unknown): NodeJS.Architecture | null | undefined {
72
+ // Older snapshots predate the field; absent stays null (Bun's own arch is the
73
+ // pre-existing fallback). Any other non-architecture value is untrusted.
74
+ if (value === undefined || value === null) return null;
75
+ return typeof value === "string" && NODE_ARCHITECTURES.has(value) ? value as NodeJS.Architecture : undefined;
76
+ }
77
+
53
78
  /** Consume the internal proof before normal CLI argument parsing. */
54
79
  export function initializeNodeLauncherContext(
55
80
  argv: string[] = process.argv,
@@ -70,7 +95,7 @@ export function initializeNodeLauncherContext(
70
95
  delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
71
96
  trustedContext = null;
72
97
 
73
- if (proofArgs.length !== 1 || !raw || raw.length > 64 * 1024) return null;
98
+ if (proofArgs.length !== 1 || !raw || raw.length > NODE_LAUNCH_CONTEXT_MAX_CHARS) return null;
74
99
  const proof = proofArgs[0]!;
75
100
  if (!isLaunchProof(proof)) return null;
76
101
 
@@ -95,6 +120,12 @@ export function initializeNodeLauncherContext(
95
120
  const managerRoots = inspection && typeof inspection === "object" && !Array.isArray(inspection)
96
121
  ? parseVersionManagerRoots((inspection as Record<string, unknown>).managerRoots)
97
122
  : null;
123
+ const transportEnv = inspection && typeof inspection === "object" && !Array.isArray(inspection)
124
+ ? parseTransportEnv((inspection as Record<string, unknown>).transportEnv)
125
+ : undefined;
126
+ const arch = inspection && typeof inspection === "object" && !Array.isArray(inspection)
127
+ ? parseLauncherArch((inspection as Record<string, unknown>).arch)
128
+ : undefined;
98
129
  const codexCliInspectionEnv = inspection === null || inspection === undefined
99
130
  ? null
100
131
  : inspection && typeof inspection === "object" && !Array.isArray(inspection)
@@ -102,15 +133,27 @@ export function initializeNodeLauncherContext(
102
133
  const value = (inspection as Record<string, unknown>)[key];
103
134
  return value === null || typeof value === "string";
104
135
  })
136
+ // Older launchers predate the lookup-seed slots; absent stays null so
137
+ // the fallback lane simply remains unverifiable rather than untrusted.
138
+ && ["home", "nodePath"].every(key => {
139
+ const value = (inspection as Record<string, unknown>)[key];
140
+ return value === undefined || value === null || typeof value === "string";
141
+ })
105
142
  && typeof (inspection as Record<string, unknown>).configDir === "string"
106
143
  && (inspection as Record<string, string>).configDir.length > 0
107
144
  && (inspection as Record<string, string>).configDir.length <= 32 * 1024
108
145
  && managerRoots !== undefined
146
+ && transportEnv !== undefined
147
+ && arch !== undefined
109
148
  ? Object.freeze({
110
149
  codexCliPath: (inspection as Record<string, string | null>).codexCliPath ?? null,
111
150
  path: (inspection as Record<string, string | null>).path ?? null,
112
151
  pathExt: (inspection as Record<string, string | null>).pathExt ?? null,
152
+ home: (inspection as Record<string, string | null>).home ?? null,
153
+ nodePath: (inspection as Record<string, string | null>).nodePath ?? null,
113
154
  managerRoots,
155
+ transportEnv,
156
+ arch,
114
157
  configDir: (inspection as Record<string, string>).configDir,
115
158
  })
116
159
  : undefined;
@@ -125,4 +168,8 @@ export function initializeNodeLauncherContext(
125
168
  export function trustedNodeLauncherContext(): TrustedNodeLaunchContext | null {
126
169
  return trustedContext;
127
170
  }
128
- import { CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS } from "../update/codex-cli-update-launch-policy.mjs";
171
+ import {
172
+ CODEX_CLI_TRANSPORT_ENV_KEYS,
173
+ CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,
174
+ NODE_LAUNCH_CONTEXT_MAX_CHARS,
175
+ } from "../update/codex-cli-update-launch-policy.mjs";
@@ -0,0 +1,72 @@
1
+ /**
2
+ * After `ocx sync` applies the Home's Codex settings on a Remote Link Child, restart the Child's
3
+ * runtime so it started after the apply (OCX-6, P5).
4
+ *
5
+ * Idempotent: the record keeps its apply time while the settings digest is unchanged, so a runtime
6
+ * that already started after it is left alone, and a sync after a failed restart retries it. The
7
+ * restart is `ocx restart`'s own (`handleProxyRestart`); this module only decides whether to call
8
+ * it and never starts a runtime that was not running, except to recover one an accepted restart
9
+ * left absent.
10
+ */
11
+ import type { HomeCodexSettingsRecord } from "../client/link-home";
12
+ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
13
+ import type { ProxyRestartStartOutcome } from "./tray-proxy";
14
+
15
+ export type ApplyRestartOutcome =
16
+ | { kind: "current" }
17
+ | { kind: "not-running" }
18
+ | { kind: "connection-changed" }
19
+ | { kind: "restarted" }
20
+ | { kind: "failed" };
21
+
22
+ export interface ApplyRestartDeps {
23
+ findLive?: () => Promise<LiveProxy | null>;
24
+ /** Epoch ms the live client runtime reports as its start, or null when it reports none. */
25
+ readStartedAt?: (live: LiveProxy) => Promise<number | null>;
26
+ restart: (startWhenStopped: (recoveringLiveRestart: boolean) => Promise<ProxyRestartStartOutcome>) => Promise<boolean>;
27
+ recoveryStart: (recoveringLiveRestart: boolean) => Promise<ProxyRestartStartOutcome>;
28
+ /** Still the connection this sync applied for (rechecked right before the restart). */
29
+ connectionUnchanged: () => boolean;
30
+ }
31
+
32
+ export async function readClientRuntimeStartedAt(live: LiveProxy, fetchImpl: typeof fetch = fetch): Promise<number | null> {
33
+ try {
34
+ const response = await fetchImpl(`http://${probeHostname(live.hostname)}:${live.port}/healthz`, {
35
+ headers: { accept: "application/json" },
36
+ redirect: "manual",
37
+ signal: AbortSignal.timeout(2_000),
38
+ });
39
+ if (!response.ok) return null;
40
+ const body = await response.json() as { pid?: unknown; startedAt?: unknown } | null;
41
+ if (body?.pid !== live.pid || typeof body.startedAt !== "string") return null;
42
+ const startedAt = Date.parse(body.startedAt);
43
+ return Number.isFinite(startedAt) ? startedAt : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export async function restartRuntimeAfterApply(record: HomeCodexSettingsRecord, deps: ApplyRestartDeps): Promise<ApplyRestartOutcome> {
50
+ const live = await (deps.findLive ?? (() => findLiveProxy()))();
51
+ if (!live || live.role !== "client") return { kind: "not-running" };
52
+ const appliedAt = Date.parse(record.appliedAt);
53
+ // A runtime that reports no start time predates OCX-6 and cannot prove it is current.
54
+ const startedAt = await (deps.readStartedAt ?? (target => readClientRuntimeStartedAt(target)))(live);
55
+ if (startedAt !== null && Number.isFinite(appliedAt) && startedAt >= appliedAt) return { kind: "current" };
56
+ if (!deps.connectionUnchanged()) return { kind: "connection-changed" };
57
+ const ok = await deps.restart(async recovering => recovering
58
+ ? deps.recoveryStart(true)
59
+ : { status: "skipped" });
60
+ return ok ? { kind: "restarted" } : { kind: "failed" };
61
+ }
62
+
63
+ /** One line for each outcome, printed by `ocx sync`. */
64
+ export function applyRestartMessage(outcome: ApplyRestartOutcome): string {
65
+ switch (outcome.kind) {
66
+ case "current": return "Client runtime already started after this apply; no restart needed.";
67
+ case "not-running": return "No client runtime is running; the applied settings take effect when it starts.";
68
+ case "connection-changed": return "The connection changed during sync; the client runtime was not restarted. Run 'ocx sync' again.";
69
+ case "restarted": return "Client runtime restarted after applying the Home's settings.";
70
+ case "failed": return "The client runtime restart did not complete; the previous runtime keeps serving. If a background service or the desktop app owns it, restart it there; otherwise retry with 'ocx restart' or 'ocx sync'.";
71
+ }
72
+ }
@@ -74,6 +74,7 @@ export async function readLinkHomeJson(
74
74
  context: LinkChildContext,
75
75
  path: string,
76
76
  deps: LinkHomeDeps = {},
77
+ init: { signal?: AbortSignal } = {},
77
78
  ): Promise<{ status: number; body: unknown }> {
78
79
  const token = readServiceApiTokenState();
79
80
  if (token.kind !== "present" || token.fingerprint !== context.connection.tokenFingerprint) {
@@ -81,12 +82,15 @@ export async function readLinkHomeJson(
81
82
  }
82
83
  let response: Response;
83
84
  try {
84
- response = await fetchLinkHomeManagement(context.connection, token.token, path.startsWith("/") ? path : `/${path}`, {}, deps);
85
+ response = await fetchLinkHomeManagement(context.connection, token.token, path.startsWith("/") ? path : `/${path}`,
86
+ init.signal ? { signal: init.signal } : {}, deps);
85
87
  } catch (error) {
86
88
  const detail = error instanceof Error && error.name === "LinkRelayAuthenticationError" ? error.message : "the link tunnel did not answer";
87
89
  throw new Error(`Could not read ${context.homePhrase} over Remote Link (${context.linkId}): ${detail}.`);
88
90
  }
89
- const result = await readBoundedResponseBytes(response, { maxBytes: RESPONSE_MAX_BYTES, signal: AbortSignal.timeout(30_000) });
91
+ // A caller's deadline (the status report's 2 s) bounds the body read as well as the request.
92
+ const readSignal = init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
93
+ const result = await readBoundedResponseBytes(response, { maxBytes: RESPONSE_MAX_BYTES, signal: readSignal });
90
94
  if (result.oversized) throw new Error(`${context.homePhrase} returned a response larger than the CLI accepts.`);
91
95
  const text = new TextDecoder().decode(result.bytes);
92
96
  let body: unknown = null;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `ocx link cleanup`: find Home-initiated Remote Link Children that run Windows and explain how to
3
+ * move each one to Hub mode. It never revokes or changes anything.
4
+ *
5
+ * Windows Children are refused at enrollment, but a link enrolled before that refusal existed keeps
6
+ * its record, key and reverse tunnel. Its record carries no platform, so this command asks the
7
+ * Child over the same pinned SSH trust the tunnel uses (`uname -s` through `sh`) and classifies the
8
+ * answer with `detectRemotePlatform` (a localized cmd or PowerShell refusal gets the fixed %OS% probe).
9
+ *
10
+ * The recovery order matters. An ordinary `ocx link revoke` runs `ocx disconnect` on the Child
11
+ * through the POSIX ocx prelude, which exits 125 on Windows, so for these Children the revoke must
12
+ * be forced after the Child disconnected itself.
13
+ */
14
+ import { linkKnownHostsPath, linkStorePath } from "../link/paths";
15
+ import { detectRemotePlatform, REMOTE_UNAME_ARGV, type RemotePlatform } from "../link/remote-platform";
16
+ import { buildExecArgv } from "../link/ssh-argv";
17
+ import { createSshRunner, sshFailureHint, sshRunnerErrorHint, type SshRunner } from "../link/ssh-runner";
18
+ import { readLinkStore, type LinkStore } from "../link/store";
19
+ import { CliUsageError, printData, rejectArgs, takeJsonFlag, takeOption } from "./runtime-api";
20
+
21
+ const LINK_ID = /^lnk_[0-9a-f]{16}$/;
22
+ const CHECK_TIMEOUT_MS = 30_000;
23
+
24
+ export interface LinkCleanupDeps {
25
+ readStore?: () => LinkStore;
26
+ sshRunner?: SshRunner;
27
+ knownHostsFile?: string;
28
+ }
29
+
30
+ export interface LinkCleanupEntry {
31
+ linkId: string;
32
+ alias: string;
33
+ direction: LinkStore["links"][number]["direction"];
34
+ platform: RemotePlatform | "not_checked";
35
+ reason: string | null;
36
+ recovery: string[];
37
+ }
38
+
39
+ export function windowsChildRecovery(linkId: string, alias: string): string[] {
40
+ return [
41
+ `On ${alias}, run 'ocx disconnect' and then 'ocx restart' so it stops using this link.`,
42
+ `On this Home, run 'ocx link revoke --link-id ${linkId} --force --yes'. A plain revoke cannot disconnect a Windows Child remotely.`,
43
+ `Then connect ${alias} in Hub mode: run 'ocx hub invite' on a Hub and the printed 'ocx connect' command on ${alias}.`,
44
+ ];
45
+ }
46
+
47
+ async function checkPlatform(runner: SshRunner, alias: string, knownHostsFile: string): Promise<{ platform: RemotePlatform; reason: string | null }> {
48
+ try {
49
+ const result = await runner.run(buildExecArgv({ alias, argv: REMOTE_UNAME_ARGV, knownHostsFile }), { timeoutMs: CHECK_TIMEOUT_MS });
50
+ const platform = await detectRemotePlatform(runner, alias, knownHostsFile, result);
51
+ return { platform, reason: platform === "unknown" ? (sshFailureHint(result.stderr) ?? `ssh exited ${result.code}`) : null };
52
+ } catch (error) {
53
+ return { platform: "unknown", reason: sshRunnerErrorHint(error) ?? "ssh could not run" };
54
+ }
55
+ }
56
+
57
+ function humanLines(entries: LinkCleanupEntry[]): string[] {
58
+ if (entries.length === 0) return ["No links are recorded on this Home.", "Nothing was revoked or changed."];
59
+ const lines: string[] = [];
60
+ for (const entry of entries) {
61
+ const label = `${entry.linkId} (${entry.alias})`;
62
+ if (entry.platform === "windows") {
63
+ lines.push(`${label}: Windows Child. Remote Link does not support Windows Children; this link predates that refusal.`);
64
+ for (const [index, step] of entry.recovery.entries()) lines.push(` ${index + 1}. ${step}`);
65
+ } else if (entry.platform === "posix") lines.push(`${label}: macOS or Linux Child; nothing to clean up.`);
66
+ else if (entry.platform === "not_checked") lines.push(`${label}: Child-initiated; this Home has no SSH route to check it.`);
67
+ else lines.push(`${label}: platform could not be checked (${entry.reason ?? "unknown"}); retry when it is reachable.`);
68
+ }
69
+ lines.push("Nothing was revoked or changed.");
70
+ return lines;
71
+ }
72
+
73
+ export async function runLinkCleanup(args: string[], deps: LinkCleanupDeps, usage: string): Promise<void> {
74
+ const wantsJson = takeJsonFlag(args);
75
+ const linkId = takeOption(args, "--link-id");
76
+ if (linkId !== undefined && !LINK_ID.test(linkId)) throw new CliUsageError("cleanup --link-id must be a valid link id", usage);
77
+ rejectArgs(args, usage);
78
+ const store = (deps.readStore ?? (() => readLinkStore(linkStorePath())))();
79
+ const records = linkId === undefined ? store.links : store.links.filter(link => link.id === linkId);
80
+ if (linkId !== undefined && records.length === 0) throw new CliUsageError(`No link ${linkId} is recorded on this Home.`, usage);
81
+ const runner = deps.sshRunner ?? createSshRunner();
82
+ const knownHostsFile = deps.knownHostsFile ?? linkKnownHostsPath();
83
+ const entries: LinkCleanupEntry[] = [];
84
+ // One at a time: each check is a bounded SSH command, and order keeps the report stable.
85
+ for (const record of records) {
86
+ const checked = record.direction === "hub-initiated"
87
+ ? await checkPlatform(runner, record.alias, knownHostsFile)
88
+ : { platform: "not_checked" as const, reason: null };
89
+ entries.push({
90
+ linkId: record.id, alias: record.alias, direction: record.direction, ...checked,
91
+ recovery: checked.platform === "windows" ? windowsChildRecovery(record.id, record.alias) : [],
92
+ });
93
+ }
94
+ printData({ links: entries, changed: false }, wantsJson, humanLines(entries));
95
+ }
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { assertSshAlias } from "../link/ssh-argv";
3
+ import { windowsChildRefusal } from "../link/remote-platform";
3
4
  import { CliUsageError, RuntimeApiError, runtimeBaseUrl, rejectArgs, takeJsonFlag, takeOption, printData } from "./runtime-api";
4
5
  import type { LinkCliDeps } from "./link";
5
6
 
@@ -83,6 +84,9 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[]
83
84
  catch (error) {
84
85
  if (error instanceof RuntimeApiError && record(error.body)) {
85
86
  const code = record(error.body.error) ? error.body.error.code : undefined;
87
+ // Refused at probe or confirm, before any key or tunnel: point at Hub mode, not at a phase.
88
+ // A residual response comes after key issuance; it keeps its Child recovery below.
89
+ if (code === "remote_platform_unsupported" && error.body.residual !== true) throw new Error(windowsChildRefusal(alias));
86
90
  const phase = typeof code === "string" && Object.hasOwn(PHASES, code) ? PHASES[code] : undefined;
87
91
  const message = phase ? "Remote Link failed during " + phase : error.message;
88
92
  if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + RECOVERY : ""));
package/src/cli/link.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import { clientLinkStatusIdentity } from "../client/link-status";
2
2
  import { clientLinkTunnelStatus } from "../client/link-tunnel";
3
3
  import { loadConfig } from "../config";
4
- import { projectLinkStatus, type ClientLinkIdentity, type LinkStatusConfig, type LinkStatusDto } from "../link/status-projection";
4
+ import {
5
+ projectLinkStatus, type ClientLinkIdentity, type LinkStatusConfig, type LinkStatusDto, type LinkTunnelDiagnostics, type LinkWireState,
6
+ } from "../link/status-projection";
7
+ import { TUNNEL_CAUSES } from "../link/tunnel-history";
8
+ import { isEpochMs, type LinkChildParity } from "../link/child-parity";
9
+ import { CODEX_SETTINGS_DIGEST_PATTERN } from "../link/codex-settings";
10
+ import type { LinkLocalPeer, LinkPeerStatus } from "../link/compatibility";
5
11
  import { runLinkEnrollment } from "./link-enrollment";
12
+ import { runLinkCleanup, type LinkCleanupDeps } from "./link-cleanup";
6
13
  import { assertSshAlias } from "../link/ssh-argv";
7
14
  import { linkStorePath } from "../link/paths";
8
15
  import { isLinkPort } from "../link/ports";
@@ -31,31 +38,16 @@ export const LINK_USAGE = `Usage:
31
38
  ocx link status [--json]
32
39
  ocx link revoke --link-id <id> [--force --yes] [--json]
33
40
  ocx link reconnect --link-id <id> [--json]
41
+ ocx link cleanup [--link-id <id>] [--json]
34
42
 
35
- Forced revoke skips remote cleanup. Run ocx disconnect on the remote client.`;
36
-
37
- type LinkState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
38
- type ListenerState = "off" | "listening" | "failed";
43
+ Forced revoke skips remote cleanup. Run ocx disconnect on the remote client.
44
+ Cleanup finds Windows Children and explains how to move them to Hub mode; it changes nothing.`;
39
45
 
40
- export interface LinkStatusPayload {
41
- role: "standalone" | "home" | "child";
42
- listener: { state: ListenerState; port: number | null };
43
- links: Array<{
44
- id: string;
45
- alias: string;
46
- direction: LinkDirection;
47
- state: LinkState;
48
- since: string;
49
- reason: string | null;
50
- tunnelPort: number;
51
- }>;
52
- child: null | {
53
- alias: string;
54
- state: LinkState;
55
- since: string;
56
- reason: string | null;
57
- };
58
- }
46
+ /**
47
+ * What `ocx link status` prints: the runtime's status DTO itself, so a field the server adds (like
48
+ * `peer` or the diagnostics `tunnel`) is part of this type the moment it is part of the wire.
49
+ */
50
+ export type LinkStatusPayload = LinkStatusDto;
59
51
 
60
52
  export interface LinkIssuePayload {
61
53
  linkId: string;
@@ -64,7 +56,7 @@ export interface LinkIssuePayload {
64
56
  listenerPort: number;
65
57
  }
66
58
 
67
- export interface LinkCliDeps extends RuntimeApiDeps {
59
+ export interface LinkCliDeps extends RuntimeApiDeps, Omit<LinkCleanupDeps, "readStore"> {
68
60
  /** Test seam can shorten, never extend, the whole enrollment deadline. */
69
61
  enrollmentTimeoutMs?: number;
70
62
  choosePort?: () => Promise<number>;
@@ -85,9 +77,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
85
77
  }
86
78
 
87
79
  function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
88
- const expected = new Set(keys);
80
+ assertKeys(value, keys, [], label);
81
+ }
82
+
83
+ /** Exact keys plus fields a newer proxy adds; an older proxy omits them. */
84
+ function assertKeys(value: Record<string, unknown>, keys: readonly string[], optional: readonly string[], label: string): void {
85
+ const allowed = new Set([...keys, ...optional]);
89
86
  const actual = Object.keys(value);
90
- if (actual.length !== expected.size || actual.some(key => !expected.has(key))) {
87
+ if (keys.some(key => !(key in value)) || actual.some(key => !allowed.has(key))) {
91
88
  throw new Error(`invalid link API response: ${label} fields`);
92
89
  }
93
90
  }
@@ -104,9 +101,42 @@ function validNullableString(value: unknown): value is string | null {
104
101
  return value === null || typeof value === "string";
105
102
  }
106
103
 
104
+ function validStringList(value: unknown): value is string[] {
105
+ return Array.isArray(value) && value.every(validString);
106
+ }
107
+
108
+ function validateLocal(value: unknown): LinkLocalPeer {
109
+ if (!isRecord(value)) throw new Error("invalid link API response: local");
110
+ assertExactKeys(value, ["version", "relayAuth", "features"], "local");
111
+ if (!validString(value.version) || !validString(value.relayAuth) || !validStringList(value.features)) {
112
+ throw new Error("invalid link API response: local fields");
113
+ }
114
+ return { version: value.version, relayAuth: value.relayAuth, features: [...value.features] };
115
+ }
116
+
117
+ function validatePeer(value: unknown, index: number): LinkPeerStatus {
118
+ const fail = (): never => { throw new Error(`invalid link API response: link ${index} peer`); };
119
+ if (!isRecord(value)) return fail();
120
+ assertExactKeys(value, ["observedAt", "version", "versionRange", "relayAuth", "features", "compatibility", "reasons"], `link ${index} peer`);
121
+ const range = value.versionRange;
122
+ if (!validNullableString(value.observedAt) || !validNullableString(value.version) || !validNullableString(value.relayAuth)
123
+ || (value.features !== null && !validStringList(value.features)) || !validStringList(value.reasons)
124
+ || !["compatible", "degraded", "incompatible", "unknown"].includes(value.compatibility as string)
125
+ || (range !== null && (!isRecord(range) || !validNullableString(range.min) || !validString(range.below)))) fail();
126
+ return {
127
+ observedAt: value.observedAt as string | null,
128
+ version: value.version as string | null,
129
+ versionRange: range === null ? null : { min: (range as Record<string, unknown>).min as string | null, below: (range as Record<string, unknown>).below as string },
130
+ relayAuth: value.relayAuth as string | null,
131
+ features: value.features === null ? null : [...value.features as string[]],
132
+ compatibility: value.compatibility as LinkPeerStatus["compatibility"],
133
+ reasons: [...value.reasons as string[]],
134
+ };
135
+ }
136
+
107
137
  function validateStatus(value: unknown): LinkStatusPayload {
108
138
  if (!isRecord(value)) throw new Error("invalid link API response: status object");
109
- assertExactKeys(value, ["role", "listener", "links", "child"], "status");
139
+ assertKeys(value, ["role", "listener", "links", "child"], ["local"], "status");
110
140
  if (value.role !== "standalone" && value.role !== "home" && value.role !== "child") {
111
141
  throw new Error("invalid link API response: status role");
112
142
  }
@@ -119,9 +149,12 @@ function validateStatus(value: unknown): LinkStatusPayload {
119
149
  throw new Error("invalid link API response: listener port");
120
150
  }
121
151
  if (!Array.isArray(value.links)) throw new Error("invalid link API response: links");
122
- const links = value.links.map((candidate, index) => {
152
+ const links = value.links.map((candidate, index): LinkStatusPayload["links"][number] => {
123
153
  if (!isRecord(candidate)) throw new Error(`invalid link API response: link ${index}`);
124
- assertExactKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], `link ${index}`);
154
+ // The OCX-6 parity fields are optional on the wire (an older runtime omits them) and always
155
+ // present in this output, null when absent.
156
+ assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer", "tunnel"], `link ${index}`);
157
+ const parity = validateParity(candidate, index);
125
158
  if (typeof candidate.id !== "string" || !LINK_ID.test(candidate.id)
126
159
  || !validString(candidate.alias) || (candidate.direction !== "hub-initiated" && candidate.direction !== "client-initiated")
127
160
  || (candidate.state !== "connecting" && candidate.state !== "connected" && candidate.state !== "reconnecting" && candidate.state !== "failed" && candidate.state !== "idle")
@@ -131,12 +164,15 @@ function validateStatus(value: unknown): LinkStatusPayload {
131
164
  return {
132
165
  id: candidate.id,
133
166
  alias: candidate.alias,
134
- direction: candidate.direction,
135
- state: candidate.state,
167
+ direction: candidate.direction as LinkDirection,
168
+ state: candidate.state as LinkWireState,
136
169
  since: candidate.since,
137
170
  reason: candidate.reason,
138
171
  tunnelPort: candidate.tunnelPort,
139
- } as LinkStatusPayload["links"][number];
172
+ ...parity,
173
+ ...(candidate.peer !== undefined ? { peer: validatePeer(candidate.peer, index) } : {}),
174
+ ...(candidate.tunnel !== undefined ? { tunnel: validateTunnel(candidate.tunnel, index) } : {}),
175
+ };
140
176
  });
141
177
  let child: LinkStatusPayload["child"] = null;
142
178
  if (value.child !== null) {
@@ -159,6 +195,7 @@ function validateStatus(value: unknown): LinkStatusPayload {
159
195
  listener: { state: value.listener.state, port: value.listener.port },
160
196
  links,
161
197
  child,
198
+ ...(value.local !== undefined ? { local: validateLocal(value.local) } : {}),
162
199
  };
163
200
  }
164
201
 
@@ -258,7 +295,9 @@ async function runStatus(args: string[], deps: LinkCliDeps): Promise<void> {
258
295
  }
259
296
  throw error;
260
297
  }
261
- const result = validateStatus(await linkRequest<unknown>("/api/link/status", {}, { ...deps, baseUrl }));
298
+ // A proxy older than the compatibility report or the tunnel diagnostics ignores the query and
299
+ // answers without those fields.
300
+ const result = validateStatus(await linkRequest<unknown>("/api/link/status?compatibility=1&diagnostics=1", {}, { ...deps, baseUrl }));
262
301
  console.log(JSON.stringify(result));
263
302
  }
264
303
 
@@ -326,6 +365,55 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}):
326
365
  else if (command === "status") await runStatus(args, deps);
327
366
  else if (command === "revoke") await runRevoke(args, deps);
328
367
  else if (command === "reconnect") await runReconnect(args, deps);
329
- else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect", LINK_USAGE);
368
+ else if (command === "cleanup") await runLinkCleanup(args, deps, LINK_USAGE);
369
+ else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup", LINK_USAGE);
330
370
  });
331
371
  }
372
+
373
+ const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt"] as const;
374
+ const WIRE_STATES = new Set(["connecting", "connected", "reconnecting", "failed", "idle"]);
375
+ const CAUSES = new Set<string>(TUNNEL_CAUSES);
376
+
377
+ function validIso(value: unknown): value is string {
378
+ return typeof value === "string" && value.length <= 64 && Number.isFinite(Date.parse(value));
379
+ }
380
+
381
+ function validateTunnel(value: unknown, index: number): LinkTunnelDiagnostics {
382
+ const fail = (): never => { throw new Error(`invalid link API response: link ${index} tunnel`); };
383
+ if (!isRecord(value)) return fail();
384
+ assertExactKeys(value, ["attempt", "nextRetryAt", "lastTransition", "lastFailure"], `link ${index} tunnel`);
385
+ const { attempt, nextRetryAt, lastTransition, lastFailure } = value;
386
+ if (typeof attempt !== "number" || !Number.isSafeInteger(attempt) || attempt < 0) fail();
387
+ if (nextRetryAt !== null && !validIso(nextRetryAt)) fail();
388
+ if (lastTransition !== null) {
389
+ if (!isRecord(lastTransition)) return fail();
390
+ assertExactKeys(lastTransition, ["from", "to", "at", "cause"], `link ${index} tunnel transition`);
391
+ if (!WIRE_STATES.has(lastTransition.from as string) || !WIRE_STATES.has(lastTransition.to as string)
392
+ || !validIso(lastTransition.at) || (lastTransition.cause !== null && !CAUSES.has(lastTransition.cause as string))) fail();
393
+ }
394
+ if (lastFailure !== null) {
395
+ if (!isRecord(lastFailure)) return fail();
396
+ assertExactKeys(lastFailure, ["cause", "at"], `link ${index} tunnel failure`);
397
+ if (!CAUSES.has(lastFailure.cause as string) || !validIso(lastFailure.at)) fail();
398
+ }
399
+ return value as unknown as LinkTunnelDiagnostics;
400
+ }
401
+
402
+ function validateParity(candidate: Record<string, unknown>, index: number): LinkChildParity {
403
+ const time = (key: "lastApplyAt" | "runtimeStartedAt" | "reportedAt"): number | null => {
404
+ const value = candidate[key];
405
+ if (value === undefined || value === null) return null;
406
+ if (!isEpochMs(value)) throw new Error(`invalid link API response: link ${index} ${key}`);
407
+ return value;
408
+ };
409
+ const digest = candidate.appliedCodexSettingsDigest;
410
+ if (digest !== undefined && digest !== null && (typeof digest !== "string" || !CODEX_SETTINGS_DIGEST_PATTERN.test(digest))) {
411
+ throw new Error(`invalid link API response: link ${index} appliedCodexSettingsDigest`);
412
+ }
413
+ return {
414
+ lastApplyAt: time("lastApplyAt"),
415
+ runtimeStartedAt: time("runtimeStartedAt"),
416
+ appliedCodexSettingsDigest: typeof digest === "string" ? digest : null,
417
+ reportedAt: time("reportedAt"),
418
+ };
419
+ }
@@ -28,6 +28,8 @@ import type { RuntimeApiDeps } from "./runtime-api";
28
28
  import { redactSecretArgs } from "./secret-args";
29
29
  import { projectLocalSyncResult, type LocalSyncResult } from "./local-sync-result";
30
30
  import { providerManagementConfigError } from "../server/auth-cors";
31
+ import { readClientConnectionState, type ClientConnectionState } from "../client/state";
32
+ import { SENSITIVE_KEY_PATTERN } from "../lib/redact";
31
33
 
32
34
  export interface ProviderCommandDeps extends RuntimeApiDeps {
33
35
  syncModels?: typeof syncModelsToCodex;
@@ -102,7 +104,46 @@ function validateAndSave(config: ReturnType<typeof loadConfig>): void {
102
104
  // provider list
103
105
  // ---------------------------------------------------------------------------
104
106
 
105
- function handleList(args: string[]): void {
107
+ /**
108
+ * Does this device's own provider config serve requests? A device linked to a Home — a Remote
109
+ * Link Child or a Hub-mode client — routes through the Home, so its local rows are shadowed.
110
+ * A standalone device and a Hub serve their own rows. An unreadable or inconsistent client
111
+ * state is not a link, the same reading `readLinkChildContext` takes.
112
+ */
113
+ export function localProvidersActive(read: () => ClientConnectionState = readClientConnectionState): boolean {
114
+ try {
115
+ return read().kind !== "connected";
116
+ } catch {
117
+ return true;
118
+ }
119
+ }
120
+
121
+ const SENSITIVE_QUERY_KEY = /key|token|secret|password|passwd|signature|^sig$|auth|credential/i;
122
+
123
+ /**
124
+ * The baseUrl as `provider list` prints it. Validation already rejects embedded credentials;
125
+ * this also covers a hand-edited config, dropping userinfo and masking credential-shaped query
126
+ * values while keeping ordinary parameters such as `api-version`.
127
+ */
128
+ export function providerListBaseUrl(baseUrl: string): string {
129
+ let parsed: URL;
130
+ try { parsed = new URL(baseUrl); } catch { return baseUrl; }
131
+ let changed = false;
132
+ if (parsed.username || parsed.password) {
133
+ parsed.username = "";
134
+ parsed.password = "";
135
+ changed = true;
136
+ }
137
+ for (const key of [...new Set(parsed.searchParams.keys())]) {
138
+ if (SENSITIVE_KEY_PATTERN.test(key) || SENSITIVE_QUERY_KEY.test(key)) {
139
+ parsed.searchParams.set(key, "redacted");
140
+ changed = true;
141
+ }
142
+ }
143
+ return changed ? parsed.toString() : baseUrl;
144
+ }
145
+
146
+ function handleList(args: string[], active: boolean = localProvidersActive()): void {
106
147
  const wantsJson = consumeFlag(args, "--json");
107
148
  const wantsJsonl = consumeFlag(args, "--jsonl");
108
149
  rejectUnknownArgs(args, "Usage: ocx provider list [--json|--jsonl]");
@@ -120,12 +161,13 @@ function handleList(args: string[]): void {
120
161
  return {
121
162
  name,
122
163
  adapter: prov.adapter,
123
- baseUrl: prov.baseUrl,
164
+ baseUrl: providerListBaseUrl(prov.baseUrl),
124
165
  authMode: prov.authMode ?? "key",
125
166
  defaultModel: prov.defaultModel ?? null,
126
167
  isDefault: name === config.defaultProvider,
127
168
  source: registryEntry ? "registry" : "custom",
128
169
  models: prov.models ?? [],
170
+ active,
129
171
  };
130
172
  });
131
173
 
@@ -149,7 +191,7 @@ function handleList(args: string[]): void {
149
191
  console.log(` ${name}${isDefault}${source} adapter=${prov.adapter}${model}`);
150
192
  }
151
193
 
152
- const available = PROVIDER_REGISTRY.filter(e => !configured.includes(e.id));
194
+ const available = PROVIDER_REGISTRY.filter(e => !e.deprecated && !configured.includes(e.id));
153
195
  if (available.length > 0) {
154
196
  console.log(`\nAvailable from registry (${available.length}):\n`);
155
197
  for (const entry of available) {
@@ -223,6 +265,7 @@ async function handleAdd(args: string[], deps: ProviderCommandDeps): Promise<voi
223
265
  const registryEntry = getProviderRegistryEntry(name);
224
266
 
225
267
  if (registryEntry) {
268
+ if (registryEntry.deprecated) console.warn(`Warning: provider "${name}" is deprecated: ${registryEntry.deprecated}`);
226
269
  provConfig = providerConfigSeed(registryEntry);
227
270
  if (apiKey) {
228
271
  const candidateError = providerCredentialInputError({ apiKey });