@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
@@ -0,0 +1,193 @@
1
+ import { constants } from "node:fs";
2
+ import { chmod, lstat, mkdir, open, rename, unlink } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
5
+ import { hardenSecretDirAsync, hardenSecretPathAsync, reattributeHardenedSecretPath, windowsSecretAclApplies } from "../lib/windows-secret-acl";
6
+ import { linkDir } from "./paths";
7
+ import type { TunnelState } from "./tunnel-state";
8
+
9
+ export type LinkDiagnosticRecord = {
10
+ event: "owner_lookup" | "relay_retry" | "relay_failure" | "relay_recovered" | "tunnel_transition";
11
+ outcome: "match" | "mismatch" | "lookup_error" | "deadline" | "stale" | "unavailable"
12
+ | "identity_unknown" | "identity_mismatch" | "auth_timeout" | "auth_failed"
13
+ | "transport_error" | "client_abort" | "header_timeout" | "recovered"
14
+ // tunnel_transition: the Home's -R tunnel changed state, and why (src/link/tunnel-history.ts).
15
+ | "connected" | "spawned" | "stopped" | "auth" | "hostkey" | "forward" | "remote_forward_conflict"
16
+ | "timeout" | "network" | "unknown" | "spawn_failed" | "listener_unavailable";
17
+ requestId?: string;
18
+ checkId?: string;
19
+ generation?: number;
20
+ state?: string;
21
+ previousState?: string;
22
+ elapsedMs?: number;
23
+ attempt?: number;
24
+ tunnelPort?: number;
25
+ retryAfterMs?: number;
26
+ };
27
+ export type LinkDiagnosticSink = (record: LinkDiagnosticRecord) => void;
28
+
29
+ const EVENTS = new Set(["owner_lookup", "relay_retry", "relay_failure", "relay_recovered", "tunnel_transition"]);
30
+ const OUTCOMES = new Set([
31
+ "match", "mismatch", "lookup_error", "deadline", "stale", "unavailable", "identity_unknown",
32
+ "identity_mismatch", "auth_timeout", "auth_failed", "transport_error", "client_abort", "header_timeout", "recovered",
33
+ "connected", "spawned", "stopped", "auth", "hostkey", "forward", "remote_forward_conflict", "timeout", "network",
34
+ "unknown", "spawn_failed", "listener_unavailable",
35
+ ]);
36
+ const STATES = new Set<TunnelState["kind"]>(["idle", "connecting", "connected", "reconnecting", "failed"]);
37
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
38
+ const MAX_BYTES = 256 * 1024;
39
+
40
+ /** Copy only bounded enum, UUID and numeric fields; unknown fields never reach disk. */
41
+ function metadata(record: LinkDiagnosticRecord): LinkDiagnosticRecord | null {
42
+ if (!record) return null;
43
+ const { event, outcome, state, previousState } = record;
44
+ if (!EVENTS.has(event) || !OUTCOMES.has(outcome)) return null;
45
+ const result: LinkDiagnosticRecord = { event, outcome };
46
+ for (const key of ["requestId", "checkId"] as const) {
47
+ const value = record[key];
48
+ if (typeof value === "string" && value.length === 36 && UUID.test(value)) result[key] = value;
49
+ }
50
+ if (typeof state === "string" && STATES.has(state as TunnelState["kind"])) result.state = state;
51
+ if (typeof previousState === "string" && STATES.has(previousState as TunnelState["kind"])) result.previousState = previousState;
52
+ for (const key of ["generation", "elapsedMs", "attempt", "retryAfterMs"] as const) {
53
+ const value = record[key];
54
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER
55
+ && (key === "elapsedMs" || key === "retryAfterMs" || Number.isSafeInteger(value))) result[key] = value;
56
+ }
57
+ const { tunnelPort } = record;
58
+ if (typeof tunnelPort === "number" && Number.isInteger(tunnelPort) && tunnelPort >= 1 && tunnelPort <= 65_535) result.tunnelPort = tunnelPort;
59
+ return result;
60
+ }
61
+
62
+ /** Append through a private no-follow handle, keeping at most one 256 KiB rotated backup. */
63
+ async function append(dir: string, line: string): Promise<void> {
64
+ assertNotRealHomeUnderTest(dirname(dir));
65
+ await mkdir(dir, { recursive: true, mode: 0o700 });
66
+ const directory = await lstat(dir);
67
+ if (!directory.isDirectory() || directory.isSymbolicLink()) return;
68
+ const windows = windowsSecretAclApplies();
69
+ if (windows) await hardenSecretDirAsync(dir, { required: true });
70
+ else await chmod(dir, 0o700);
71
+ const path = join(dir, "diagnostics.jsonl");
72
+ const backup = `${path}.1`;
73
+ let file: Awaited<ReturnType<typeof open>> | undefined;
74
+ try {
75
+ // No read of log contents, and no following a file symlink or writing to a pipe.
76
+ const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK;
77
+ file = await open(path, flags, 0o600);
78
+ const current = await file.stat();
79
+ if (!current.isFile() || current.nlink !== 1) return;
80
+ if (windows) await hardenSecretPathAsync(path, { required: true });
81
+ else await file.chmod(0o600);
82
+ const previous = await lstat(backup).catch(error => {
83
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
84
+ return null;
85
+ });
86
+ if (previous) {
87
+ if (!previous.isFile() || previous.isSymbolicLink() || previous.nlink !== 1) return;
88
+ if (previous.size > MAX_BYTES) await unlink(backup);
89
+ else if (windows) await hardenSecretPathAsync(backup, { required: true });
90
+ else await chmod(backup, 0o600);
91
+ }
92
+ if (current.size > MAX_BYTES) await file.truncate(0);
93
+ else if (current.size + Buffer.byteLength(line) > MAX_BYTES) {
94
+ await file.close();
95
+ file = undefined;
96
+ await rename(path, backup);
97
+ file = await open(path, flags, 0o600);
98
+ const fresh = await file.stat();
99
+ if (!fresh.isFile() || fresh.nlink !== 1 || fresh.size + Buffer.byteLength(line) > MAX_BYTES) return;
100
+ if (windows) await hardenSecretPathAsync(path, { required: true });
101
+ else await file.chmod(0o600);
102
+ }
103
+ await file.write(line);
104
+ if (windows) {
105
+ const written = await file.stat({ bigint: true });
106
+ const named = await lstat(path, { bigint: true });
107
+ if (!named.isSymbolicLink() && named.dev === written.dev && named.ino === written.ino) {
108
+ // Windows publishes content-write ChangeTime on close; retain only the same object's ACL memo.
109
+ await file.close();
110
+ file = undefined;
111
+ reattributeHardenedSecretPath(path);
112
+ }
113
+ }
114
+ } finally {
115
+ await file?.close();
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Create a best-effort metadata sink with one-second event/outcome buckets and a bounded queue.
121
+ * File and ACL work starts on the first eligible record and never blocks admission. flush() waits
122
+ * at most one second; its timeout does not cancel the writer or guarantee durable storage.
123
+ */
124
+ export function createLinkDiagnosticSink(
125
+ { configDir, now = Date.now }: { configDir?: string; now?: () => number } = {},
126
+ ): LinkDiagnosticSink & { flush: (timeoutMs?: number) => Promise<void> } {
127
+ // At most 56 buckets; correlation IDs never become keys or retained memory.
128
+ const buckets = new Map<string, { at: number; suppressed: number }>();
129
+ const queue: Array<{ dir: string; line: string }> = [];
130
+ let flight: Promise<void> | undefined;
131
+ const drain = async () => {
132
+ while (queue.length) {
133
+ const record = queue.shift()!;
134
+ try { await append(record.dir, record.line); } catch { /* Best effort; never publish raw errors. */ }
135
+ }
136
+ };
137
+ const startWriter = () => {
138
+ if (flight) return;
139
+ flight = drain().finally(() => {
140
+ flight = undefined;
141
+ if (queue.length) startWriter();
142
+ });
143
+ };
144
+ const sink: LinkDiagnosticSink = record => {
145
+ try {
146
+ const safe = metadata(record);
147
+ if (!safe) return;
148
+ const at = now();
149
+ if (!Number.isSafeInteger(at) || at < 0) return;
150
+ const key = `${safe.event}:${safe.outcome}`;
151
+ const bucket = buckets.get(key);
152
+ // Wall-clock rollback starts a new window, even when the writer is backed up.
153
+ if (bucket && at >= bucket.at && at - bucket.at < 1_000) {
154
+ bucket.suppressed = Math.min(Number.MAX_SAFE_INTEGER, bucket.suppressed + 1);
155
+ return;
156
+ }
157
+ if (queue.length >= 56) {
158
+ buckets.set(key, { at: Math.min(bucket?.at ?? at, at),
159
+ suppressed: Math.min(Number.MAX_SAFE_INTEGER, (bucket?.suppressed ?? 0) + 1) });
160
+ return;
161
+ }
162
+ const suppressed = bucket?.suppressed ?? 0;
163
+ // Failed writes are rate-limited too; diagnostics cannot hammer an unavailable disk.
164
+ buckets.set(key, { at, suppressed: 0 });
165
+ queue.push({ dir: linkDir(configDir), line: `${JSON.stringify({ ...safe, timestamp: at, ...(suppressed ? { suppressed } : {}) })}\n` });
166
+ startWriter();
167
+ } catch { /* Diagnostics must never change request admission or expose raw errors. */ }
168
+ };
169
+ return Object.assign(sink, { flush: async (timeoutMs = 1_000) => {
170
+ if (!flight) return;
171
+ // Keep shutdown bounded even if an asynchronous filesystem or ACL operation stalls.
172
+ // Timeout leaves the existing writer alone; it may still finish before process exit.
173
+ let timer: ReturnType<typeof setTimeout> | undefined;
174
+ try {
175
+ await Promise.race([
176
+ (async () => { while (flight) await flight; })(),
177
+ new Promise<void>(resolve => { timer = setTimeout(resolve, Math.min(1_000, Math.max(0, timeoutMs))); }),
178
+ ]);
179
+ } catch { /* Best effort; never publish raw errors. */ }
180
+ finally { clearTimeout(timer); }
181
+ } });
182
+ }
183
+
184
+ let defaultSink: ReturnType<typeof createLinkDiagnosticSink> | undefined;
185
+ /** Lazily enqueue sanitized evidence in the default sink without propagating diagnostic failures. */
186
+ export function recordLinkDiagnostic(record: LinkDiagnosticRecord): void {
187
+ try { (defaultSink ??= createLinkDiagnosticSink())(record); } catch { /* Best effort. */ }
188
+ }
189
+
190
+ /** Drain existing diagnostics for at most one second without activating an unused sink. */
191
+ export async function flushLinkDiagnostics(): Promise<void> {
192
+ await defaultSink?.flush();
193
+ }
@@ -29,6 +29,8 @@ export const LINK_MANAGEMENT_READ_PATHS: readonly string[] = [
29
29
  "/api/sidecar-settings",
30
30
  "/api/v2",
31
31
  "/api/provider-quotas",
32
+ "/api/quota-pool",
33
+ "/api/fallback",
32
34
  "/api/codex-auth/active",
33
35
  "/api/codex-auth/accounts",
34
36
  "/api/oauth/accounts",
@@ -0,0 +1,100 @@
1
+ /**
2
+ * What a Remote Link Child tells its Home about itself, and what the Home remembers of it.
3
+ *
4
+ * A Child sends its release version and its peer feature ids (`src/remote/features.ts`) as two
5
+ * headers on the credential-free relay-auth challenge it already sends before every relayed
6
+ * request (`src/client/link-relay-transport.ts`). The Home records them only after the caller
7
+ * proof verifies, so an observation is bound to the link key; it is still the Child's own claim
8
+ * and is used for display and compatibility verdicts, never for admission.
9
+ *
10
+ * A Home older than this report ignores the headers, and a Child older than it sends none: the
11
+ * Home then knows only the relay-auth version the challenge itself names. Observations live in
12
+ * the Home's memory and start empty after a restart; the next relayed request refills them.
13
+ */
14
+ export const LINK_PEER_REPORT_VERSION = "1";
15
+ export const LINK_PEER_VERSION_HEADER = "x-opencodex-link-peer-version";
16
+ export const LINK_PEER_FEATURES_HEADER = "x-opencodex-link-peer-features";
17
+
18
+ const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]{1,128})?$/;
19
+ const FEATURE_ID = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,79}$/;
20
+ const MAX_FEATURES = 64;
21
+ const MAX_LINKS = 256;
22
+
23
+ export interface LinkPeerReport {
24
+ /** True when the challenge carried either report header, valid or not; false for a pre-report Child. */
25
+ reported: boolean;
26
+ version: string | null;
27
+ features: string[] | null;
28
+ }
29
+
30
+ /** Header values a Child sends. Invalid input yields no header rather than a malformed one. */
31
+ export function linkPeerReportHeaders(version: string, features: readonly string[]): Record<string, string> {
32
+ const headers: Record<string, string> = {};
33
+ if (RELEASE_VERSION.test(version)) headers[LINK_PEER_VERSION_HEADER] = version;
34
+ const ids = parseFeatureList(features.join(","));
35
+ if (ids) headers[LINK_PEER_FEATURES_HEADER] = ids.join(",");
36
+ return headers;
37
+ }
38
+
39
+ function parseFeatureList(value: string | null): string[] | null {
40
+ if (value === null || value === "") return null;
41
+ const ids = value.split(",");
42
+ if (ids.length > MAX_FEATURES || new Set(ids).size !== ids.length || ids.some(id => !FEATURE_ID.test(id))) return null;
43
+ return ids;
44
+ }
45
+
46
+ /** Home side: read a report from challenge headers. Each field is dropped on its own when invalid. */
47
+ export function parseLinkPeerReport(headers: Headers): LinkPeerReport {
48
+ const version = headers.get(LINK_PEER_VERSION_HEADER);
49
+ const features = headers.get(LINK_PEER_FEATURES_HEADER);
50
+ return {
51
+ reported: version !== null || features !== null,
52
+ version: version !== null && RELEASE_VERSION.test(version) ? version : null,
53
+ features: parseFeatureList(features),
54
+ };
55
+ }
56
+
57
+ export interface LinkPeerObservation extends LinkPeerReport {
58
+ /** The relay-auth version of the verified challenge. */
59
+ relayAuth: string;
60
+ observedAt: string;
61
+ }
62
+
63
+ /** A challenge for a known link and key that named a relay-auth version this Home does not speak. */
64
+ export interface LinkPeerRefusal {
65
+ relayAuth: string;
66
+ refusedAt: string;
67
+ }
68
+
69
+ export interface LinkPeerEntry {
70
+ observed: LinkPeerObservation | null;
71
+ refused: LinkPeerRefusal | null;
72
+ }
73
+
74
+ export interface LinkPeerObservations {
75
+ observe(linkId: string, relayAuth: string, report: LinkPeerReport): void;
76
+ refuse(linkId: string, relayAuth: string): void;
77
+ get(linkId: string): LinkPeerEntry;
78
+ }
79
+
80
+ export function createLinkPeerObservations(now: () => Date = () => new Date()): LinkPeerObservations {
81
+ const entries = new Map<string, LinkPeerEntry>();
82
+ const update = (linkId: string, change: Partial<LinkPeerEntry>) => {
83
+ const current = entries.get(linkId) ?? { observed: null, refused: null };
84
+ entries.delete(linkId);
85
+ entries.set(linkId, { ...current, ...change });
86
+ // Only known link ids reach here; the cap bounds memory if the store churns for a long time.
87
+ if (entries.size > MAX_LINKS) entries.delete(entries.keys().next().value!);
88
+ };
89
+ return {
90
+ observe(linkId, relayAuth, report) {
91
+ update(linkId, { observed: { relayAuth, ...report, observedAt: now().toISOString() }, refused: null });
92
+ },
93
+ refuse(linkId, relayAuth) {
94
+ update(linkId, { refused: { relayAuth, refusedAt: now().toISOString() } });
95
+ },
96
+ get(linkId) {
97
+ return entries.get(linkId) ?? { observed: null, refused: null };
98
+ },
99
+ };
100
+ }
@@ -2,6 +2,12 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
 
3
3
  export const LINK_RELAY_AUTH_PATH = "/.well-known/opencodex/link-relay-auth";
4
4
  export const LINK_RELAY_SESSION_HEADER = "x-opencodex-link-session";
5
+ /**
6
+ * Marks a challenge that only asks whether the Home answers (the Child's readiness preflight): the
7
+ * Home proves itself without reserving a session, so no data request can follow it and it holds
8
+ * neither session capacity nor a drain lease. An older Home ignores it and reserves as before.
9
+ */
10
+ export const LINK_RELAY_PROBE_HEADER = "x-opencodex-link-probe";
5
11
  export const LINK_RELAY_AUTH_VERSION = "2";
6
12
  export const LINK_RELAY_AUTH_TIMEOUT_MS = 5_000;
7
13
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Windows recognition for Remote Link Children.
3
+ *
4
+ * Remote Link drives its Child through a POSIX `sh` prelude, so a Windows Child is refused
5
+ * before any link key is issued or tunnel started. A Windows OpenSSH server shows up in one of
6
+ * three ways, depending on its default shell:
7
+ * - Git Bash, MSYS2 or Cygwin: `uname -s` reports MINGW*, MSYS*, CYGWIN* (or Windows*), and the
8
+ * ocx prelude exits with REMOTE_PLATFORM_UNSUPPORTED.
9
+ * - cmd.exe: a POSIX command such as `true` or `sh` is "not recognized as an internal or
10
+ * external command".
11
+ * - PowerShell: the same command "is not recognized as the name of a cmdlet".
12
+ *
13
+ * Hub mode (`ocx hub invite` on a Hub, `ocx connect` on the client) supports Windows clients, so
14
+ * every refusal points there.
15
+ */
16
+ import { buildWindowsProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED } from "./ssh-argv";
17
+ import type { SshRunner, SshRunResult } from "./ssh-runner";
18
+
19
+ export type RemotePlatform = "windows" | "posix" | "unknown";
20
+
21
+ const WINDOWS_UNAME = /^(?:Windows|MSYS|MINGW|CYGWIN)/i;
22
+ const POSIX_UNAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
23
+ // Windows PowerShell says "the name of a cmdlet", PowerShell 7 "a name of a cmdlet". Localized
24
+ // installs say neither, which is what confirmWindowsShell is for.
25
+ const WINDOWS_SHELL_REFUSAL = /is not recognized as (?:an internal or external command|(?:the|a) name of a cmdlet)/i;
26
+ /** ssh's own failure status: the remote shell never ran, so there is nothing to ask. */
27
+ const SSH_TRANSPORT_FAILURE = 255;
28
+
29
+ /** Remote argv that prints the kernel name; under cmd or PowerShell, `sh` itself is refused. */
30
+ export const REMOTE_UNAME_ARGV: readonly string[] = ["sh", "-c", "uname -s 2>/dev/null", "ocx-platform"];
31
+
32
+ /** Server-side refusal text, shared by the dashboard and the CLI's JSON consumers. */
33
+ export const WINDOWS_CHILD_REFUSAL = "Remote Link Children require macOS or Linux; Windows is unsupported. Connect a Windows computer in Hub mode: run ocx hub invite on a Hub, then the printed ocx connect command on the Windows computer.";
34
+
35
+ /** Does this output come from cmd.exe or PowerShell refusing a POSIX command? */
36
+ export function isWindowsShellRefusal(output: string): boolean {
37
+ return WINDOWS_SHELL_REFUSAL.test(output);
38
+ }
39
+
40
+ /** Classify one remote command result. Transport failures and odd output stay unknown. */
41
+ export function classifyRemotePlatform(result: SshRunResult): RemotePlatform {
42
+ if (result.code === REMOTE_PLATFORM_UNSUPPORTED) return "windows";
43
+ if (isWindowsShellRefusal(result.stderr) || isWindowsShellRefusal(result.stdout)) return "windows";
44
+ if (result.code !== 0) return "unknown";
45
+ const name = result.stdout.trim().split(/\r?\n/)[0] ?? "";
46
+ if (WINDOWS_UNAME.test(name)) return "windows";
47
+ return POSIX_UNAME.test(name) ? "posix" : "unknown";
48
+ }
49
+
50
+ /**
51
+ * Could this failure be a Windows shell whose refusal text is localized? Success, the prelude's own
52
+ * 125/127 answers and ssh transport failures are already explained, so they never cost another call.
53
+ */
54
+ export function mayBeWindowsShell(result: SshRunResult): boolean {
55
+ return result.code !== 0 && result.code !== REMOTE_PLATFORM_UNSUPPORTED
56
+ && result.code !== REMOTE_COMMAND_NOT_FOUND && result.code !== SSH_TRANSPORT_FAILURE;
57
+ }
58
+
59
+ /** Ask the host the fixed, locale-independent WINDOWS_OS_PROBE. Any failure answers false. */
60
+ export async function confirmWindowsShell(runner: SshRunner, alias: string, knownHostsFile: string, timeoutMs = 30_000): Promise<boolean> {
61
+ try {
62
+ const result = await runner.run(buildWindowsProbeArgv({ alias, knownHostsFile }), { timeoutMs });
63
+ return result.code === 0 && result.stdout.trim().split(/\r?\n/)[0]?.trim() === "Windows_NT";
64
+ } catch { return false; }
65
+ }
66
+
67
+ /** classifyRemotePlatform, plus the locale-independent follow-up when the answer could be Windows. */
68
+ export async function detectRemotePlatform(runner: SshRunner, alias: string, knownHostsFile: string, result: SshRunResult): Promise<RemotePlatform> {
69
+ const platform = classifyRemotePlatform(result);
70
+ if (platform !== "unknown" || !mayBeWindowsShell(result)) return platform;
71
+ return await confirmWindowsShell(runner, alias, knownHostsFile) ? "windows" : "unknown";
72
+ }
73
+
74
+ /** The CLI sentence for a refused Windows Child. */
75
+ export function windowsChildRefusal(alias: string): string {
76
+ return `${alias} is a Windows computer. Remote Link Children require macOS or Linux, so no link key was issued and no tunnel was started. `
77
+ + `Connect it in Hub mode instead: run 'ocx hub invite' on a Hub, then run the printed 'ocx connect' command on ${alias}.`;
78
+ }
@@ -191,6 +191,25 @@ export function buildProbeArgv(options: ProbeArgvOptions): string[] {
191
191
  ];
192
192
  }
193
193
 
194
+ /**
195
+ * Locale-independent Windows check for a host whose SSH shell is cmd.exe or PowerShell. Both run
196
+ * `cmd /c echo %OS%`, and cmd expands it to `Windows_NT` regardless of display language. The
197
+ * command is a fixed literal; a POSIX shell answers "command not found" and nothing else.
198
+ */
199
+ export const WINDOWS_OS_PROBE = "cmd /c echo %OS%";
200
+
201
+ /** Run WINDOWS_OS_PROBE against an already trusted (pinned or just-probed) host key only. */
202
+ export function buildWindowsProbeArgv(options: { alias: string; knownHostsFile: string }): string[] {
203
+ const alias = assertSshAlias(options.alias);
204
+ return [
205
+ "ssh", "-T",
206
+ ...commonOptions(alias, options.knownHostsFile, "yes"),
207
+ "-o", "ClearAllForwardings=yes",
208
+ "--", alias,
209
+ WINDOWS_OS_PROBE,
210
+ ];
211
+ }
212
+
194
213
  /** Read the fingerprint of the host key recorded by a probe. */
195
214
  export function buildFingerprintArgv(tempKnownHostsFile: string): string[] {
196
215
  return ["ssh-keygen", "-l", "-f", assertKnownHostsPath(tempKnownHostsFile)];
@@ -2,6 +2,9 @@ import type { LinkStore } from "./store";
2
2
  import type { LinkTunnelStatus } from "./supervisor";
3
3
  import type { TunnelState } from "./tunnel-state";
4
4
  import { readCompensation, type CompensationStore } from "./compensation";
5
+ import { EMPTY_LINK_CHILD_PARITY, type LinkChildParity } from "./child-parity";
6
+ import { linkPeerStatus, type LinkLocalPeer, type LinkPeerStatus } from "./compatibility";
7
+ import type { LinkPeerEntry } from "./peer-report";
5
8
  import type { OcxClientConnectionConfig } from "../types";
6
9
 
7
10
  export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
@@ -9,7 +12,7 @@ export type LinkWireState = "connecting" | "connected" | "reconnecting" | "faile
9
12
  export interface LinkStatusDto {
10
13
  role: "standalone" | "home" | "child";
11
14
  listener: { state: "off" | "listening" | "failed"; port: number | null };
12
- links: Array<{
15
+ links: Array<LinkChildParity & {
13
16
  id: string;
14
17
  alias: string;
15
18
  direction: "hub-initiated" | "client-initiated";
@@ -17,6 +20,10 @@ export interface LinkStatusDto {
17
20
  since: string;
18
21
  reason: string | null;
19
22
  tunnelPort: number;
23
+ /** Home only: what the link carried from this Child, and the compatibility verdict. */
24
+ peer?: LinkPeerStatus;
25
+ /** Home-initiated links on request (`?diagnostics=1`): retry progress and the last failure. */
26
+ tunnel?: LinkTunnelDiagnostics;
20
27
  }>;
21
28
  child: null | {
22
29
  alias: string;
@@ -24,6 +31,54 @@ export interface LinkStatusDto {
24
31
  since: string;
25
32
  reason: string | null;
26
33
  };
34
+ /** This build's version and link protocol, the side every `peer` verdict compares against. */
35
+ local?: LinkLocalPeer;
36
+ }
37
+
38
+ /**
39
+ * The Home's account of one `-R` tunnel: ssh attempts since it was last connected, when the next
40
+ * one is due (null while one runs or none is scheduled), the last state change and the last
41
+ * failure. `cause` is a fixed word from `tunnel-history.ts`, never ssh output.
42
+ */
43
+ export interface LinkTunnelDiagnostics {
44
+ attempt: number;
45
+ nextRetryAt: string | null;
46
+ lastTransition: { from: LinkWireState; to: LinkWireState; at: string; cause: string | null } | null;
47
+ lastFailure: { cause: string; at: string } | null;
48
+ }
49
+
50
+ function iso(at: number): string {
51
+ return new Date(at).toISOString();
52
+ }
53
+
54
+ /** Add `tunnel` to each Home-initiated link the supervisor reports. */
55
+ export function attachLinkTunnelDiagnostics(dto: LinkStatusDto, supervisorStates: readonly LinkTunnelStatus[]): LinkStatusDto {
56
+ const byId = new Map(supervisorStates.map(status => [status.linkId, status]));
57
+ for (const link of dto.links) {
58
+ const status = byId.get(link.id);
59
+ if (!status || status.direction !== "hub-initiated") continue;
60
+ const { state, history } = status;
61
+ const due = (state.kind === "failed" || state.kind === "reconnecting") && state.retryAt !== undefined && state.inFlight !== true
62
+ ? state.retryAt : null;
63
+ link.tunnel = {
64
+ attempt: history?.attempt ?? 0,
65
+ nextRetryAt: due === null ? null : iso(due),
66
+ lastTransition: history?.lastTransition
67
+ ? { from: history.lastTransition.from, to: history.lastTransition.to, at: iso(history.lastTransition.at), cause: history.lastTransition.cause }
68
+ : null,
69
+ lastFailure: history?.lastFailure ? { cause: history.lastFailure.cause, at: iso(history.lastFailure.at) } : null,
70
+ };
71
+ }
72
+ return dto;
73
+ }
74
+
75
+ /** Add this build's identity and, on a Home, each Child's observed peer status. */
76
+ export function attachLinkPeers(dto: LinkStatusDto, local: LinkLocalPeer, observation: (linkId: string) => LinkPeerEntry): LinkStatusDto {
77
+ if (dto.role === "home") {
78
+ for (const link of dto.links) link.peer = linkPeerStatus(local, observation(link.id));
79
+ }
80
+ dto.local = local;
81
+ return dto;
27
82
  }
28
83
 
29
84
  export interface ClientLinkIdentity { direction: "hub-initiated" | "client-initiated"; alias?: string }
@@ -75,6 +130,8 @@ export function projectLinkStatus(
75
130
  compensation: CompensationStore = readCompensation(),
76
131
  clientChild: LinkStatusDto["child"] = null,
77
132
  identity: ClientLinkIdentity | null = null,
133
+ /** The Home's view of each Child's last apply (OCX-6); every field is null without a report. */
134
+ parity: (linkId: string) => LinkChildParity = () => EMPTY_LINK_CHILD_PARITY,
78
135
  ): LinkStatusDto {
79
136
  const byId = new Map(supervisorStates.map(status => [status.linkId, status]));
80
137
  const links = store.links.map(record => ({
@@ -83,6 +140,7 @@ export function projectLinkStatus(
83
140
  direction: record.direction,
84
141
  ...stateDetails(record, byId.get(record.id), compensation),
85
142
  tunnelPort: record.tunnelPort,
143
+ ...parity(record.id),
86
144
  }));
87
145
  const childRecord = config.runtimeRole === "client"
88
146
  ? (config.client?.transport === "link" ? store.links.find(record => record.id === config.client?.link?.linkId) : store.links[0])
@@ -101,7 +159,10 @@ export function projectLinkStatus(
101
159
  : client?.link ? { alias: identity?.alias ?? client.serverUrl, state: "idle" as const, since: client.connectedAt, reason: null } : null);
102
160
  if (client?.link && child && identity) {
103
161
  const active = links.find(link => link.id === client.link!.linkId);
104
- const projected = { id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias };
162
+ const projected = {
163
+ id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias,
164
+ ...parity(client.link.linkId),
165
+ };
105
166
  if (active) Object.assign(active, projected, compensation.entries[active.id] && childRecord ? stateDetails(childRecord, byId.get(active.id), compensation) : {});
106
167
  else links.push(projected);
107
168
  }