@bitkyc08/opencodex 2.82.0 → 2.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Process-local record of credential values this process has handed to an upstream.
3
+ *
4
+ * Upstream diagnostics (error codes, request ids) are provider-controlled. A gateway can echo a
5
+ * credential it received back into those fields, and a credential with no recognizable format slips
6
+ * past pattern redaction. Configured credentials are read directly at capture time (see
7
+ * server/configured-credentials); this registry adds the values that only exist at send time, such
8
+ * as a client-forwarded bearer or a plugin-added header.
9
+ *
10
+ * Two tiers keep ordinary traffic from disabling diagnostics. Values of credential-named headers are
11
+ * strict: they match at any length, and once one is evicted coverage is incomplete for the rest of
12
+ * the process, so callers fail closed. Every other header value is best effort: it matches only at
13
+ * eight characters or more and its eviction never disables diagnostics, so per-turn metadata cannot
14
+ * exhaust anything that matters. Correlation ids (request ids, trace context) are left out of the
15
+ * best-effort tier because a gateway legitimately echoes them as its request id; a configured header
16
+ * of that name is still covered by the configured set. Values are held in memory only: nothing here
17
+ * is serialized, persisted or exposed.
18
+ */
19
+ import { SENSITIVE_KEY_PATTERN } from "./redact";
20
+
21
+ /** Containment only for credentials long enough that a diagnostic cannot contain one by chance. */
22
+ export const CREDENTIAL_SUBSTRING_MIN_LENGTH = 8;
23
+ const CREDENTIAL_HEADER_NAME = /api[-_]?key|secret|passw|credential|auth|(?:^|[-_])token$/i;
24
+ const CORRELATION_HEADER_NAME = /^(?:x-(?:client-)?request-id|request-id|traceparent|tracestate)$/i;
25
+ const AUTH_SCHEMES = new Set(["bearer", "basic", "token", "digest", "apikey", "api-key", "key", "sso-key", "negotiate", "dpop"]);
26
+ const TOKEN_CHAR = /[!#$%&'*+.^_`|~0-9A-Za-z-]/;
27
+ const BASIC_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
28
+ const PRINTABLE_USER_PASS = /^[\x20-\x7e]*:[\x20-\x7e]*$/;
29
+
30
+ /** True for a header (or config field) name whose value is a credential by its name. */
31
+ export function isCredentialName(name: string): boolean {
32
+ return SENSITIVE_KEY_PATTERN.test(name) || CREDENTIAL_HEADER_NAME.test(name);
33
+ }
34
+
35
+ /**
36
+ * Values of RFC 9110 auth-params (token BWS "=" BWS ( token / quoted-string )), cookie pairs and
37
+ * other name=value pairs, in one linear pass: every character is visited a bounded number of times,
38
+ * so a long header value cannot stall the event loop.
39
+ */
40
+ function parameterValues(value: string): string[] {
41
+ const out: string[] = [];
42
+ const n = value.length;
43
+ let unclosedFrom = n; // no closing quote exists for any opening quote at or after this index
44
+ let i = 0;
45
+ while (i < n) {
46
+ if (!TOKEN_CHAR.test(value[i]!)) { i++; continue; }
47
+ while (i < n && TOKEN_CHAR.test(value[i]!)) i++;
48
+ let j = i;
49
+ while (j < n && (value[j] === " " || value[j] === "\t")) j++;
50
+ if (value[j] !== "=") { i = j; continue; }
51
+ j++;
52
+ while (j < n && (value[j] === " " || value[j] === "\t")) j++;
53
+ if (value[j] === "\"") {
54
+ let k = j + 1;
55
+ let text = "";
56
+ let closed = false;
57
+ if (j < unclosedFrom) {
58
+ while (k < n) {
59
+ const char = value[k]!;
60
+ if (char === "\\" && k + 1 < n) { text += value[k + 1]; k += 2; continue; }
61
+ if (char === "\"") { closed = true; break; }
62
+ text += char;
63
+ k++;
64
+ }
65
+ }
66
+ if (closed) {
67
+ if (text) out.push(text);
68
+ i = k + 1;
69
+ } else {
70
+ unclosedFrom = Math.min(unclosedFrom, j);
71
+ i = j + 1;
72
+ }
73
+ continue;
74
+ }
75
+ const start = j;
76
+ while (j < n && !/[\s,;"]/.test(value[j]!)) j++;
77
+ const raw = value.slice(start, j);
78
+ if (raw && !/^=+$/.test(raw)) out.push(raw);
79
+ i = j;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * The whole value plus every part a server could authenticate with on its own: tokens split on
86
+ * whitespace, commas and semicolons with auth scheme words dropped, the value of each name=value
87
+ * parameter (cookies, digest; whitespace around "=" and quoted strings with escapes are parsed
88
+ * first, before any splitting), and both halves of a standard Basic credential.
89
+ */
90
+ export function credentialComponents(value: string): string[] {
91
+ const whole = value.trim();
92
+ if (!whole) return [];
93
+ const out = new Set([whole]);
94
+ for (const param of parameterValues(whole)) out.add(param);
95
+ let previous: string | undefined;
96
+ for (const part of whole.split(/[\s,;]+/)) {
97
+ if (!part) continue;
98
+ const lower = part.toLowerCase();
99
+ if (AUTH_SCHEMES.has(lower)) { previous = lower; continue; }
100
+ out.add(part);
101
+ if (previous === "basic" && BASIC_BASE64.test(part)) {
102
+ const decoded = Buffer.from(part, "base64").toString("utf8");
103
+ if (PRINTABLE_USER_PASS.test(decoded) && Buffer.from(decoded, "utf8").toString("base64").replace(/=+$/, "") === part.replace(/=+$/, "")) {
104
+ const colon = decoded.indexOf(":");
105
+ out.add(decoded.slice(0, colon));
106
+ out.add(decoded.slice(colon + 1));
107
+ }
108
+ }
109
+ previous = undefined;
110
+ }
111
+ out.delete("");
112
+ return [...out];
113
+ }
114
+
115
+ /** Exact equality at any length at or above minLength; containment for values of 8+ characters. */
116
+ export function credentialSetMatches(values: Iterable<string>, has: (value: string) => boolean, value: string, minLength = 1): boolean {
117
+ if (value.length >= minLength && has(value)) return true;
118
+ for (const secret of values) {
119
+ if (secret.length >= Math.max(minLength, CREDENTIAL_SUBSTRING_MIN_LENGTH) && value.includes(secret)) return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ export interface OutboundCredentialRegistryOptions {
125
+ maxEntries?: number;
126
+ maxBytes?: number;
127
+ maxValueBytes?: number;
128
+ }
129
+
130
+ /** Bounded LRU of values; eviction marks it incomplete. */
131
+ class BoundedValueSet {
132
+ readonly values = new Map<string, number>();
133
+ private totalBytes = 0;
134
+ complete = true;
135
+ constructor(private readonly maxEntries: number, private readonly maxBytes: number, private readonly maxValueBytes: number) {}
136
+
137
+ add(value: string): void {
138
+ if (!value) return;
139
+ const bytes = Buffer.byteLength(value, "utf8");
140
+ if (bytes > this.maxValueBytes) {
141
+ this.complete = false;
142
+ return;
143
+ }
144
+ if (this.values.has(value)) {
145
+ this.values.delete(value);
146
+ this.values.set(value, bytes);
147
+ return;
148
+ }
149
+ this.values.set(value, bytes);
150
+ this.totalBytes += bytes;
151
+ while (this.values.size > this.maxEntries || this.totalBytes > this.maxBytes) {
152
+ const oldest = this.values.keys().next().value as string;
153
+ this.totalBytes -= this.values.get(oldest)!;
154
+ this.values.delete(oldest);
155
+ this.complete = false;
156
+ }
157
+ }
158
+
159
+ matches(value: string, minLength: number): boolean {
160
+ return credentialSetMatches(this.values.keys(), candidate => this.values.has(candidate), value, minLength);
161
+ }
162
+ }
163
+
164
+ export class OutboundCredentialRegistry {
165
+ private readonly strict: BoundedValueSet;
166
+ private readonly observed: BoundedValueSet;
167
+
168
+ constructor(options: OutboundCredentialRegistryOptions = {}) {
169
+ const entries = options.maxEntries ?? 4096;
170
+ const bytes = options.maxBytes ?? 1 << 20;
171
+ const valueBytes = options.maxValueBytes ?? 16_384;
172
+ this.strict = new BoundedValueSet(entries, bytes, valueBytes);
173
+ this.observed = new BoundedValueSet(entries, bytes, valueBytes);
174
+ }
175
+
176
+ /** Record the components of every value in headers that are about to be sent. */
177
+ remember(headers: HeadersInit | Headers | undefined): void {
178
+ if (!headers) return;
179
+ new Headers(headers).forEach((value, name) => {
180
+ const strict = isCredentialName(name);
181
+ if (!strict && CORRELATION_HEADER_NAME.test(name)) return;
182
+ const tier = strict ? this.strict : this.observed;
183
+ for (const part of credentialComponents(value)) tier.add(part);
184
+ });
185
+ }
186
+
187
+ /** True when value repeats a recorded credential (strict at any length, best effort at 8+). */
188
+ matches(value: string): boolean {
189
+ return this.strict.matches(value, 1) || this.observed.matches(value, CREDENTIAL_SUBSTRING_MIN_LENGTH);
190
+ }
191
+
192
+ /** False once a credential-named value was dropped; callers must then fail closed. */
193
+ complete(): boolean {
194
+ return this.strict.complete;
195
+ }
196
+ }
197
+
198
+ let active = new OutboundCredentialRegistry();
199
+
200
+ /** The registry fed by the HTTP executor boundary and the Codex WebSocket dial. */
201
+ export function outboundCredentials(): OutboundCredentialRegistry {
202
+ return active;
203
+ }
204
+
205
+ /** Swap the active registry for sequential in-file tests; returns the restore function. */
206
+ export function setOutboundCredentialRegistryForTests(registry: OutboundCredentialRegistry): () => void {
207
+ const previous = active;
208
+ active = registry;
209
+ return () => { active = previous; };
210
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A passive management read: it shows what this process already knows and does not make it act on
3
+ * providers, accounts, processes or files (#6959).
4
+ *
5
+ * The Remote Link management relay runs every Child read inside this scope
6
+ * (`src/server/index/link-management.ts`), so a Child can show its Home's state without changing
7
+ * it or making it act. Inside the scope:
8
+ *
9
+ * - model discovery yields no routed models (`fetchAllModels`, the management model rows): it would
10
+ * reach provider upstreams, refresh Codex entitlements and persist a pending provider's initial
11
+ * model selection;
12
+ * - provider quota and the Codex account list are served from what the Home has observed, with no
13
+ * probe, token refresh, native-main claim or publication into routing evidence;
14
+ * - the startup-health diagnostic, the Codex runtime version probes and the bundled-catalog refresh
15
+ * are not started, so no process runs and no runtime record is written;
16
+ * - a native-main fence raised for unknown service ownership is reported, not re-inspected;
17
+ * - config-directory and credential-file hardening and malformed-file backups are skipped;
18
+ * - memory-budget enforcement demotes only stores registered as `passiveEvictable` (eviction that
19
+ * only releases memory: the usage caches), never a continuation spill or a replay snapshot.
20
+ *
21
+ * The Home's own dashboard and traffic keep those caches current. A passive read may still join
22
+ * work the Home already started; that work keeps the Home's scope and its writes are the Home's.
23
+ */
24
+ import { AsyncLocalStorage } from "node:async_hooks";
25
+
26
+ const passive = new AsyncLocalStorage<true>();
27
+
28
+ /** Run a management read passively, including everything it awaits or schedules. */
29
+ export function runPassiveRead<T>(run: () => Promise<T>): Promise<T> {
30
+ return passive.run(true, run);
31
+ }
32
+
33
+ export function isPassiveRead(): boolean {
34
+ return passive.getStore() === true;
35
+ }
package/src/lib/redact.ts CHANGED
@@ -7,7 +7,7 @@ export const REDACTED_SECRET = "[REDACTED]";
7
7
  * credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than
8
8
  * re-deriving a narrower local list.
9
9
  */
10
- export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
10
+ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
11
11
 
12
12
  /**
13
13
  * Colon-labelled credential headers echoed back inside an error body
@@ -40,7 +40,7 @@ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cook
40
40
  // Every letter position also accepts \u0001, the placeholder the fold emits for
41
41
  // an unresolved HTML named reference: `author&ii;zation` is the label with one
42
42
  // character we cannot name, and that is still the label.
43
- const CREDENTIAL_HEADER_LABEL_RAW = "x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
43
+ const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
44
44
 
45
45
  const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW
46
46
  .replace(/(?<![\[\\])([A-Za-z])(?![\]\-])/g, "[$1\u0001]");
@@ -81,19 +81,58 @@ export function shadowCallTargetsIntersect(
81
81
  && target.modelId.startsWith(source.modelId);
82
82
  }
83
83
 
84
+ /**
85
+ * Preserve explicit user turns and spawned children before either intercept site.
86
+ * HTTP provenance is `x-codex-turn-metadata` or `x-openai-subagent: collab_spawn`.
87
+ * `x-openai-subagent` is connection-level on both transports: a child session opens
88
+ * its own connection, so a `collab_spawn` upgrade header still names every frame.
89
+ * Turn metadata is per-frame on WebSocket (`CODEX_WS_FRAME_HEADERS`): only the frame's
90
+ * `client_metadata["x-codex-turn-metadata"]` describes this turn, because the
91
+ * upgrade's copy can describe an earlier user or helper turn.
92
+ * `request_kind: "turn"` alone is not user provenance (#1684). Maintenance
93
+ * markers such as `x-openai-subagent: compact`/`review` and metadata naming
94
+ * `memory_consolidation` remain eligible helpers, subject to memory routing.
95
+ * Missing/malformed metadata retains the legacy opt-in source-prefix behavior.
96
+ */
97
+ export function shadowCallPreservesSelectedModel(
98
+ body: unknown,
99
+ headers: Headers,
100
+ transport?: "websocket",
101
+ ): boolean {
102
+ if (headers.get("x-openai-subagent") === "collab_spawn") return true;
103
+ let metadata: unknown = headers.get("x-codex-turn-metadata");
104
+ if (transport === "websocket") {
105
+ const client = body && typeof body === "object" && !Array.isArray(body)
106
+ ? (body as Record<string, unknown>).client_metadata : undefined;
107
+ metadata = client && typeof client === "object" && !Array.isArray(client)
108
+ && Object.hasOwn(client, "x-codex-turn-metadata")
109
+ ? (client as Record<string, unknown>)["x-codex-turn-metadata"] : undefined;
110
+ }
111
+ if (typeof metadata !== "string") return false;
112
+ try {
113
+ const parsed: unknown = JSON.parse(metadata);
114
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
115
+ const turn = parsed as Record<string, unknown>;
116
+ return turn.subagent_kind === "thread_spawn"
117
+ || (turn.request_kind === "turn" && turn.thread_source === "user");
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
84
123
  /**
85
124
  * Decide whether a matching source model should use the opt-in intercept.
86
125
  *
87
126
  * Before Codex 0.147.0 this checked x-codex-turn-metadata and exempted
88
127
  * request_kind "turn". Codex 0.147.0 can label background helper calls as
89
- * "turn", causing them to bypass the intercept (#1684). The fix is to
90
- * intercept every configured shadow source model regardless of request kind.
128
+ * "turn", causing them to bypass the intercept (#1684). Request kind alone
129
+ * cannot exempt a configured source model: callers first preserve explicit
130
+ * user/child provenance with `shadowCallPreservesSelectedModel` (#6921).
91
131
  * A replacement intersecting the same provider+model source set remains a
92
132
  * no-op because rewriting it would only create self-interception (#2706).
93
133
  *
94
- * Callers skip this check entirely for spawned sub-agent turns
95
- * (`isThreadSpawnRequest`): `gpt-6-luna` is both the helper slug and a default
96
- * sub-agent model, and an explicitly spawned child must keep the model it chose.
134
+ * `gpt-6-luna` is both the helper slug and a selectable user/sub-agent model;
135
+ * explicit user turns and spawned children must keep the model they chose.
97
136
  */
98
137
  export function shouldInterceptShadowCall(
99
138
  modelId: string,
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The Codex settings a Remote Link Home decides for its Children (#6957).
3
+ *
4
+ * A link Child relays every model request to its Home, so everything the Home's proxy applies
5
+ * per request already follows the Home: effort caps, the sub-agent roster and fallback, the
6
+ * injection prompt, sidecar and compaction routing, and the catalog itself (served by
7
+ * `/v1/catalog`). What does NOT follow is the part of the Home's OpenCodex config that the
8
+ * injector writes into the Child's own Codex `config.toml`. This module carries exactly that
9
+ * part, which is every OpenCodex config field the Codex injection plan reads that is a policy
10
+ * decision rather than a fact about the Child's own listener:
11
+ *
12
+ * - `fastMode` -> `[features] fast_mode`
13
+ * - `multiAgentMode` -> the native `features.multi_agent_v2` reconcile (v1 mode)
14
+ * - `webSearchSidecar.enabled` -> the root `web_search` switch
15
+ * - `syncCodexSubagentDefaults` + `injectionModel` + `injectionEffort` -> the managed
16
+ * `[agents]` defaults
17
+ *
18
+ * Root `model_reasoning_effort` and the `desktop.*` picker keys are Codex app settings that
19
+ * OpenCodex never writes on any machine, so they are deliberately not part of this document.
20
+ * Listener facts (port, hostname, Desktop authless and client compaction routing forms, resume
21
+ * history) stay the Child's own because they describe how the Child's Codex reaches the Child.
22
+ *
23
+ * It rides on its own authenticated link read rather than inside the catalog because the
24
+ * catalog bytes are written verbatim to `model_catalog_json`, which Codex parses itself.
25
+ */
26
+ import { hostname } from "node:os";
27
+ import type { OcxConfig } from "../types";
28
+
29
+ export const HOME_CODEX_SETTINGS_VERSION = 1;
30
+ const MULTI_AGENT_MODES = ["v1", "default", "v2"] as const;
31
+ type MultiAgentMode = (typeof MULTI_AGENT_MODES)[number];
32
+
33
+ export interface HomeCodexSettings {
34
+ /** null: the Home leaves `fast_mode` alone. */
35
+ fastMode: boolean | null;
36
+ /** null: the Home has no explicit mode. */
37
+ multiAgentMode: MultiAgentMode | null;
38
+ webSearchSidecarEnabled: boolean;
39
+ subagentDefaults: { model: string; reasoningEffort: string | null } | null;
40
+ }
41
+
42
+ export interface HomeCodexSettingsDocument {
43
+ version: typeof HOME_CODEX_SETTINGS_VERSION;
44
+ home: { name: string; version: string };
45
+ settings: HomeCodexSettings;
46
+ }
47
+
48
+ const NAME_MAX = 255;
49
+ const VALUE_MAX = 256;
50
+
51
+ function safeLabel(value: string, max: number): string | null {
52
+ const trimmed = value.trim();
53
+ if (!trimmed || trimmed.length > max) return null;
54
+ for (const character of trimmed) {
55
+ const code = character.codePointAt(0) ?? 0;
56
+ if (code < 0x20 || code === 0x7f || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069)) return null;
57
+ }
58
+ return trimmed;
59
+ }
60
+
61
+ export function projectHomeCodexSettings(config: OcxConfig, version: string): HomeCodexSettingsDocument {
62
+ const mode = config.multiAgentMode;
63
+ // Same predicate as `subagentDefaultSyncEffective` (src/config/diagnostics.ts), inlined so
64
+ // this module stays inside the src/link import boundary.
65
+ const sync = config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim());
66
+ const effort = config.injectionEffort?.trim();
67
+ return {
68
+ version: HOME_CODEX_SETTINGS_VERSION,
69
+ home: { name: safeLabel(hostname(), NAME_MAX) ?? "Home", version },
70
+ settings: {
71
+ fastMode: typeof config.fastMode === "boolean" ? config.fastMode : null,
72
+ multiAgentMode: mode && (MULTI_AGENT_MODES as readonly string[]).includes(mode) ? mode : null,
73
+ webSearchSidecarEnabled: config.webSearchSidecar?.enabled !== false,
74
+ subagentDefaults: sync
75
+ ? { model: config.injectionModel!.trim(), reasoningEffort: effort ? effort : null }
76
+ : null,
77
+ },
78
+ };
79
+ }
80
+
81
+ function exactKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
82
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
83
+ const actual = Object.keys(value);
84
+ return actual.length === keys.length && actual.every(key => keys.includes(key));
85
+ }
86
+
87
+ /** Strict parse: an unknown field, a wrong type or an unsafe label rejects the whole document. */
88
+ export function parseHomeCodexSettingsDocument(value: unknown): HomeCodexSettingsDocument | null {
89
+ if (!exactKeys(value, ["version", "home", "settings"]) || value.version !== HOME_CODEX_SETTINGS_VERSION) return null;
90
+ const home = value.home;
91
+ if (!exactKeys(home, ["name", "version"]) || typeof home.name !== "string" || typeof home.version !== "string") return null;
92
+ const name = safeLabel(home.name, NAME_MAX);
93
+ const homeVersion = safeLabel(home.version, 64);
94
+ if (!name || !homeVersion) return null;
95
+ const s = value.settings;
96
+ if (!exactKeys(s, ["fastMode", "multiAgentMode", "webSearchSidecarEnabled", "subagentDefaults"])) return null;
97
+ if (s.fastMode !== null && typeof s.fastMode !== "boolean") return null;
98
+ if (s.multiAgentMode !== null && !(MULTI_AGENT_MODES as readonly unknown[]).includes(s.multiAgentMode)) return null;
99
+ if (typeof s.webSearchSidecarEnabled !== "boolean") return null;
100
+ let subagentDefaults: HomeCodexSettings["subagentDefaults"] = null;
101
+ if (s.subagentDefaults !== null) {
102
+ const d = s.subagentDefaults;
103
+ if (!exactKeys(d, ["model", "reasoningEffort"]) || typeof d.model !== "string") return null;
104
+ const model = safeLabel(d.model, VALUE_MAX);
105
+ if (!model) return null;
106
+ let reasoningEffort: string | null = null;
107
+ if (d.reasoningEffort !== null) {
108
+ if (typeof d.reasoningEffort !== "string" || !/^[a-z][a-z0-9_-]{0,31}$/.test(d.reasoningEffort)) return null;
109
+ reasoningEffort = d.reasoningEffort;
110
+ }
111
+ subagentDefaults = { model, reasoningEffort };
112
+ }
113
+ return {
114
+ version: HOME_CODEX_SETTINGS_VERSION,
115
+ home: { name, version: homeVersion },
116
+ settings: {
117
+ fastMode: s.fastMode as boolean | null,
118
+ multiAgentMode: s.multiAgentMode as MultiAgentMode | null,
119
+ webSearchSidecarEnabled: s.webSearchSidecarEnabled,
120
+ subagentDefaults,
121
+ },
122
+ };
123
+ }
124
+
125
+ /** The Child's config as the Codex injector should see it: local listener facts, Home policy. */
126
+ export function overlayHomeCodexSettings(config: OcxConfig, settings: HomeCodexSettings): OcxConfig {
127
+ const next: OcxConfig = {
128
+ ...config,
129
+ webSearchSidecar: { ...(config.webSearchSidecar ?? {}), enabled: settings.webSearchSidecarEnabled },
130
+ };
131
+ if (settings.fastMode === null) delete next.fastMode;
132
+ else next.fastMode = settings.fastMode;
133
+ if (settings.multiAgentMode === null) delete next.multiAgentMode;
134
+ else next.multiAgentMode = settings.multiAgentMode;
135
+ if (settings.subagentDefaults) {
136
+ next.injectionModel = settings.subagentDefaults.model;
137
+ next.syncCodexSubagentDefaults = true;
138
+ if (settings.subagentDefaults.reasoningEffort) next.injectionEffort = settings.subagentDefaults.reasoningEffort;
139
+ else delete next.injectionEffort;
140
+ } else {
141
+ next.syncCodexSubagentDefaults = false;
142
+ }
143
+ return next;
144
+ }
145
+
146
+ /** One line per applied value, for `ocx sync`, `ocx status` and the dashboard. */
147
+ export function describeHomeCodexSettings(settings: HomeCodexSettings): string[] {
148
+ const d = settings.subagentDefaults;
149
+ return [
150
+ `fast mode: ${settings.fastMode === null ? "unset" : settings.fastMode ? "on" : "off"}`,
151
+ `multi-agent mode: ${settings.multiAgentMode ?? "default"}`,
152
+ `web search sidecar: ${settings.webSearchSidecarEnabled ? "on" : "off"}`,
153
+ `native sub-agent defaults: ${d ? `${d.model}${d.reasoningEffort ? ` (${d.reasoningEffort})` : ""}` : "not synced"}`,
154
+ ];
155
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The read-only management surface a Remote Link Child may reach on its Home (#6957).
3
+ *
4
+ * Default deny: a path is readable only when it is listed here, the method is GET or HEAD, and
5
+ * the query does not ask the Home to refresh upstream state. Everything else is refused on both
6
+ * ends: the Child's listener and CLI refuse before sending, and the Home's link listener answers
7
+ * 404 for any other path under the prefix. No route here returns a credential value; provider
8
+ * and account projections report presence and masked identities only.
9
+ *
10
+ * A link read must not change the Home. The Home runs these reads passively
11
+ * (`src/lib/passive-read.ts`): model discovery, which reaches provider upstreams and persists the
12
+ * initial model selection of a pending provider, config writes and diagnostics do not run (#6959).
13
+ * `tests/server/link-management-relay.test.ts` reads every path here and asserts that the Home
14
+ * config and its upstreams are untouched.
15
+ */
16
+ export const LINK_MANAGEMENT_PREFIX = "/v1/link-management";
17
+ /** The Home's OpenCodex-owned Codex settings a Child applies when it syncs. */
18
+ export const LINK_CODEX_SETTINGS_PATH = `${LINK_MANAGEMENT_PREFIX}/codex-settings`;
19
+
20
+ export const LINK_MANAGEMENT_READ_PATHS: readonly string[] = [
21
+ "/api/system/health",
22
+ "/api/startup-health",
23
+ "/api/providers",
24
+ "/api/settings",
25
+ "/api/usage",
26
+ "/api/effort-caps",
27
+ "/api/injection-model",
28
+ "/api/shadow-call-settings",
29
+ "/api/sidecar-settings",
30
+ "/api/v2",
31
+ "/api/provider-quotas",
32
+ "/api/codex-auth/active",
33
+ "/api/codex-auth/accounts",
34
+ "/api/oauth/accounts",
35
+ "/api/providers/keys",
36
+ "/api/oauth/providers",
37
+ "/api/catalog",
38
+ "/api/system/memory",
39
+ "/api/provider-presets",
40
+ "/api/provider-context-caps",
41
+ ];
42
+ const READ_PATHS = new Set(LINK_MANAGEMENT_READ_PATHS);
43
+ /**
44
+ * Query keys that make a read trigger upstream work on the Home: forced refreshes and the
45
+ * per-account quota probes `?quota=1` starts on the OAuth-account and key-pool reads.
46
+ * `/api/models`, `/api/model-presets`, `/api/subagent-models` and `/api/subagent-model-fallback`
47
+ * are absent for the same reason: they run live provider discovery and may persist initial model
48
+ * selection. The preset preview exists only to decide a write a Child cannot make.
49
+ */
50
+ const REFRESH_KEYS = ["refresh", "reload", "force", "quota"];
51
+
52
+ /** Is `method path?query` (a Home management path, without the link prefix) readable over the link? */
53
+ export function linkManagementReadAllowed(method: string, pathname: string, search: URLSearchParams | string = ""): boolean {
54
+ const upper = method.toUpperCase();
55
+ if (upper !== "GET" && upper !== "HEAD") return false;
56
+ if (!READ_PATHS.has(pathname)) return false;
57
+ const query = typeof search === "string" ? new URLSearchParams(search) : search;
58
+ return !REFRESH_KEYS.some(key => query.has(key));
59
+ }
60
+
61
+ /** The Home management URL a prefixed link request names, or null when it names none. */
62
+ export function linkManagementTarget(url: URL): URL | null {
63
+ if (!url.pathname.startsWith(`${LINK_MANAGEMENT_PREFIX}/api/`)) return null;
64
+ const target = new URL(url.href);
65
+ target.pathname = url.pathname.slice(LINK_MANAGEMENT_PREFIX.length);
66
+ return target;
67
+ }
68
+
69
+ /** Home side: may the hub-link listener dispatch this request to the link management handler? */
70
+ export function linkManagementRouteAllowed(url: URL, req: Request): boolean {
71
+ if (req.headers.has("upgrade")) return false;
72
+ if (url.pathname === LINK_CODEX_SETTINGS_PATH) return req.method === "GET" && url.search === "";
73
+ const target = linkManagementTarget(url);
74
+ return target !== null && linkManagementReadAllowed(req.method, target.pathname, target.searchParams);
75
+ }
76
+
77
+ export function isLinkManagementPath(pathname: string): boolean {
78
+ return pathname === LINK_MANAGEMENT_PREFIX || pathname.startsWith(`${LINK_MANAGEMENT_PREFIX}/`);
79
+ }
80
+
81
+ /**
82
+ * The one refusal every management surface on a link Child uses: the dashboard relay, the CLI,
83
+ * and the Child's listener. It names the Home and the link and never suggests a relay transport.
84
+ * `homePhrase` is "Home <name>" or "its Home" when the Child does not know the Home's name.
85
+ */
86
+ export function linkChildManagementRefusal(homePhrase: string, linkId: string, operation = "This management command"): string {
87
+ return `This machine is a Remote Link Child managed by ${homePhrase} (link ${linkId}). `
88
+ + `${operation} runs on the Home, not on this Child: use the Home's dashboard or run the command there. No request was sent.`;
89
+ }
@@ -0,0 +1,38 @@
1
+ import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract";
2
+
3
+ export const LINK_COMMAND_WORK_MS = 15_000;
4
+ export const LINK_COMMAND_MAX_MS = 60_000;
5
+ export const LINK_RELAY_VERIFY_MS = 15_000;
6
+ export const LINK_CATALOG_MAX_BYTES = 4 * 1024 * 1024;
7
+
8
+ /** Probe latency pays for SSH setup without making the remote work window unbounded. */
9
+ export function linkCommandBudget(latencyMs = 0): number {
10
+ const latency = Number.isFinite(latencyMs) ? Math.max(0, latencyMs) : 0;
11
+ return Math.min(LINK_COMMAND_MAX_MS, LINK_COMMAND_WORK_MS + Math.ceil(latency * 4));
12
+ }
13
+
14
+ export function linkRestartBudget(latencyMs = 0): number {
15
+ return MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIMEOUT_MS + linkCommandBudget(latencyMs);
16
+ }
17
+
18
+ /** Credential-free Child HTTP probe. Ignore curlrc/proxies; never follow a redirect. */
19
+ export function remoteChildHttpArgv(port: number, path: "healthz" | "v1/catalog", timeoutMs = LINK_RELAY_VERIFY_MS): string[] {
20
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("invalid Child port");
21
+ const seconds = Math.max(1, Math.ceil(Math.min(LINK_RELAY_VERIFY_MS, timeoutMs) / 1000));
22
+ return ["sh", "-c", 'curl -q --noproxy "*" --silent --show-error --fail --connect-timeout 3 --max-time "$3" --write-out "\\n%{http_code}" "http://127.0.0.1:$1/$2"', "ocx-child-http", String(port), path, String(seconds)];
23
+ }
24
+
25
+ export const REMOTE_CURL_PREFLIGHT = ["sh", "-c", 'command -v curl >/dev/null 2>&1 && curl -q --version >/dev/null 2>&1', "ocx-curl-preflight"];
26
+
27
+ export function parseChildHttp(stdout: string): unknown {
28
+ const match = /\n200$/.exec(stdout.trimEnd());
29
+ if (!match) throw new Error("Child HTTP probe did not return 200");
30
+ return JSON.parse(stdout.trimEnd().slice(0, match.index));
31
+ }
32
+
33
+ export function isChildHealth(value: unknown, role: "standalone" | "client", expectedPort: number): boolean {
34
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
35
+ const body = value as Record<string, unknown>;
36
+ return body.port === expectedPort && body.service === "opencodex" && Number.isInteger(body.pid) && (body.pid as number) > 0
37
+ && (role === "client" ? body.role === "client" : (body.role === undefined || body.role === "standalone") && body.restartCapability === "v1");
38
+ }
@@ -131,15 +131,36 @@ export function buildExecArgv(options: ExecArgvOptions): string[] {
131
131
  ];
132
132
  }
133
133
 
134
+ /** The POSIX shell's exit status for a command it could not find. */
135
+ export const REMOTE_COMMAND_NOT_FOUND = 127;
136
+ /** Reserved prelude status: the remote OS cannot run the POSIX Remote Link workflow. */
137
+ export const REMOTE_PLATFORM_UNSUPPORTED = 125;
138
+
134
139
  /**
135
140
  * The remote sh script for every ocx call. `quoteRemote` single-quotes it, so the login shell
136
141
  * passes it through untouched and `$HOME`/`$PATH` expand in the remote `sh`; the arguments reach
137
- * ocx as `"$@"` without another round of parsing. The fallback directories are appended, so an
138
- * ocx the remote PATH already resolves keeps winning. A missing ocx exits 127.
142
+ * ocx as `"$@"` without another round of parsing. Desktop binaries precede PATH candidates;
143
+ * each candidate must run --version successfully. A missing runnable ocx exits 127.
139
144
  */
140
- export const REMOTE_OCX_SCRIPT = 'PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"';
141
- /** The POSIX shell's exit status for a command it could not find. */
142
- export const REMOTE_COMMAND_NOT_FOUND = 127;
145
+ export const REMOTE_OCX_SCRIPT = `export PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"
146
+ case "$(uname -s 2>/dev/null)" in Windows*|MSYS*|MINGW*|CYGWIN*) exit ${REMOTE_PLATFORM_UNSUPPORTED};; esac
147
+ try_ocx() {
148
+ if [ -x "$1" ] && "$1" --version >/dev/null 2>&1; then
149
+ executable=$1
150
+ shift
151
+ exec "$executable" "$@"
152
+ fi
153
+ }
154
+ for candidate in "$HOME/.opencodex-desktop/bin/ocx" "/Applications/OpenCodex.app/Contents/MacOS/ocx" "/usr/bin/ocx"; do
155
+ try_ocx "$candidate" "$@"
156
+ done
157
+ remaining=$PATH
158
+ while :; do
159
+ directory=\${remaining%%:*}
160
+ try_ocx "\${directory:-.}/ocx" "$@"
161
+ case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac
162
+ done
163
+ exit ${REMOTE_COMMAND_NOT_FOUND}`;
143
164
 
144
165
  export function remoteOcxArgv(args: readonly string[]): string[] {
145
166
  return ["sh", "-c", REMOTE_OCX_SCRIPT, "ocx", ...args];