@bitkyc08/opencodex 2.83.0 → 2.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/ollama-native.ts +29 -1
  10. package/src/adapters/openai-chat/messages.ts +8 -4
  11. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  12. package/src/adapters/openai-responses/passthrough.ts +3 -0
  13. package/src/adapters/openai-responses/request-strips.ts +10 -2
  14. package/src/adapters/qoder/adapter.ts +16 -14
  15. package/src/bridge/errors.ts +19 -0
  16. package/src/bridge.ts +1 -1
  17. package/src/claude/context-windows.ts +54 -1
  18. package/src/claude/intercept/connect-proxy.ts +10 -3
  19. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  20. package/src/claude/intercept/picker-listener.ts +49 -1
  21. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  22. package/src/claude/intercept/settings.ts +101 -9
  23. package/src/cli/account-api.ts +65 -0
  24. package/src/cli/account-auth.ts +26 -2
  25. package/src/cli/account.ts +41 -5
  26. package/src/cli/capabilities-access-remote.ts +8 -0
  27. package/src/cli/capabilities-base.ts +13 -1
  28. package/src/cli/catalog-command-result.ts +2 -1
  29. package/src/cli/claude.ts +15 -6
  30. package/src/cli/config-command.ts +8 -2
  31. package/src/cli/debug.ts +8 -0
  32. package/src/cli/dispatch.ts +16 -3
  33. package/src/cli/export-command.ts +14 -1
  34. package/src/cli/hub-disable.ts +99 -0
  35. package/src/cli/hub.ts +4 -2
  36. package/src/cli/index.ts +13 -4
  37. package/src/cli/link-child.ts +104 -0
  38. package/src/cli/link-enrollment.ts +96 -0
  39. package/src/cli/link.ts +39 -24
  40. package/src/cli/observe-stream.ts +6 -1
  41. package/src/cli/observe.ts +10 -2
  42. package/src/cli/provider.ts +12 -0
  43. package/src/cli/registry.ts +5 -4
  44. package/src/cli/runtime-api.ts +27 -2
  45. package/src/cli/status.ts +104 -20
  46. package/src/client/connect.ts +39 -4
  47. package/src/client/hub-client.ts +14 -7
  48. package/src/client/hub-state.ts +25 -14
  49. package/src/client/link-home.ts +248 -0
  50. package/src/client/link-management-relay.ts +61 -0
  51. package/src/client/link-relay.ts +6 -1
  52. package/src/client/link-status.ts +14 -0
  53. package/src/client/machine-api.ts +14 -7
  54. package/src/client/machine-listener.ts +29 -4
  55. package/src/clients/config-export.ts +46 -16
  56. package/src/clients/omo-role-models.ts +6 -3
  57. package/src/codex/agent-role-models.ts +26 -8
  58. package/src/codex/auth-api/account-list.ts +17 -2
  59. package/src/codex/auth-api/account-selection.ts +17 -7
  60. package/src/codex/auth-api/routes.ts +5 -1
  61. package/src/codex/autostart-health.ts +3 -1
  62. package/src/codex/catalog/bundled.ts +5 -1
  63. package/src/codex/catalog/effort.ts +2 -2
  64. package/src/codex/catalog/metadata.ts +4 -2
  65. package/src/codex/catalog/native-models.ts +16 -2
  66. package/src/codex/native-profile-startup.ts +4 -1
  67. package/src/codex/prompt-journal.ts +20 -2
  68. package/src/codex/prompt-layers/encoding.ts +49 -0
  69. package/src/codex/prompt-layers/import-source.ts +30 -0
  70. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  71. package/src/codex/prompt-layers/toml-read.ts +52 -4
  72. package/src/codex/prompt-layers.ts +383 -20
  73. package/src/codex/prompt-lock-claim.ts +13 -3
  74. package/src/codex/prompt-lock-evidence.ts +3 -3
  75. package/src/codex/prompt-lock-owner.ts +14 -6
  76. package/src/codex/prompt-text-probe.ts +34 -5
  77. package/src/codex/quota.ts +13 -6
  78. package/src/codex/role-auto-assign.ts +2 -5
  79. package/src/codex/routing/active-account.ts +21 -0
  80. package/src/codex/routing.ts +1 -1
  81. package/src/codex/runtime.ts +196 -8
  82. package/src/codex/shim-overlay.ts +8 -4
  83. package/src/codex/shim.ts +1 -1
  84. package/src/combos/failover.ts +3 -0
  85. package/src/config/api-key-reconcile.ts +34 -0
  86. package/src/config/diagnostics.ts +16 -0
  87. package/src/config/feature-flags.ts +5 -0
  88. package/src/config/live-reconcile.ts +43 -29
  89. package/src/config/live-topology.ts +29 -0
  90. package/src/config/load-degrade.ts +2 -1
  91. package/src/config/paths.ts +3 -0
  92. package/src/config/salvage.ts +3 -1
  93. package/src/config/schema/config-schema.ts +2 -0
  94. package/src/generated/compatibility-version.json +311 -171
  95. package/src/grok/grpc-web.ts +23 -1
  96. package/src/grok/reset-coupon-ledger.ts +75 -5
  97. package/src/grok/reset-coupons.ts +4 -2
  98. package/src/integrations/mutation-plan.ts +13 -7
  99. package/src/integrations/registry.ts +45 -11
  100. package/src/integrations/state.ts +19 -7
  101. package/src/integrations/target.ts +42 -5
  102. package/src/integrations/writer.ts +14 -6
  103. package/src/lib/app-owned-memory-stores.ts +4 -0
  104. package/src/lib/app-owned-memory.ts +11 -0
  105. package/src/lib/json-byte-size.ts +31 -0
  106. package/src/lib/optional-shutdown-hooks.ts +35 -0
  107. package/src/lib/outbound-credential-registry.ts +210 -0
  108. package/src/lib/passive-read.ts +35 -0
  109. package/src/lib/shadow-call.ts +44 -5
  110. package/src/link/codex-settings.ts +155 -0
  111. package/src/link/management-routes.ts +89 -0
  112. package/src/link/remote-probe.ts +38 -0
  113. package/src/link/ssh-argv.ts +26 -5
  114. package/src/link/ssh-runner.ts +58 -27
  115. package/src/link/status-projection.ts +22 -4
  116. package/src/link/supervisor.ts +144 -49
  117. package/src/link/tunnel-state.ts +11 -4
  118. package/src/oauth/anthropic-continuity.ts +4 -3
  119. package/src/oauth/anthropic-identity.ts +13 -2
  120. package/src/oauth/anthropic-model-quota.ts +13 -1
  121. package/src/oauth/anthropic-routing.ts +230 -23
  122. package/src/oauth/anthropic.ts +18 -4
  123. package/src/oauth/google-antigravity.ts +24 -7
  124. package/src/oauth/index.ts +24 -15
  125. package/src/oauth/kiro-account-identity.ts +106 -0
  126. package/src/oauth/kiro.ts +48 -5
  127. package/src/oauth/plan.ts +29 -0
  128. package/src/oauth/store.ts +40 -3
  129. package/src/oauth/types.ts +2 -0
  130. package/src/plugins/loader.ts +11 -0
  131. package/src/providers/anthropic-billing-ownership.ts +74 -0
  132. package/src/providers/anthropic-billing-recovery.ts +31 -0
  133. package/src/providers/anthropic-billing-refusal.ts +55 -0
  134. package/src/providers/anthropic-reset-admission.ts +62 -0
  135. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  136. package/src/providers/api-key-resolve.ts +17 -2
  137. package/src/providers/key-failover.ts +52 -5
  138. package/src/providers/model-rename-startup.ts +5 -3
  139. package/src/providers/quota/account-cache.ts +11 -0
  140. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  141. package/src/providers/quota/report-cache.ts +14 -0
  142. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  143. package/src/providers/quota.ts +4 -1
  144. package/src/providers/registry/entries-core.ts +14 -14
  145. package/src/providers/registry/entries-extended.ts +0 -1
  146. package/src/providers/registry/model-seeds.ts +17 -0
  147. package/src/providers/request-pacing.ts +9 -0
  148. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  149. package/src/responses/compaction.ts +14 -0
  150. package/src/responses/parser.ts +7 -1
  151. package/src/server/auth-cors.ts +42 -0
  152. package/src/server/configured-credentials.ts +194 -0
  153. package/src/server/index/link-management.ts +58 -0
  154. package/src/server/index/optional-listeners.ts +5 -1
  155. package/src/server/index/serve-options.ts +13 -0
  156. package/src/server/lifecycle.ts +2 -1
  157. package/src/server/management/agent-settings-routes.ts +28 -10
  158. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  159. package/src/server/management/codex-agent-role-routes.ts +19 -6
  160. package/src/server/management/codex-prompt-routes.ts +136 -5
  161. package/src/server/management/codex-role-auto-assign.ts +1 -1
  162. package/src/server/management/grok-coupon-routes.ts +78 -27
  163. package/src/server/management/link-enrollment.ts +192 -0
  164. package/src/server/management/link-route-state.ts +185 -0
  165. package/src/server/management/link-routes.ts +82 -402
  166. package/src/server/management/model-rows.ts +3 -1
  167. package/src/server/management/provider-overwrite-carry.ts +10 -0
  168. package/src/server/management/provider-routes.ts +1 -0
  169. package/src/server/management/route-registry.ts +5 -3
  170. package/src/server/management/shared.ts +3 -0
  171. package/src/server/management-api.ts +3 -1
  172. package/src/server/messages-native.ts +68 -42
  173. package/src/server/relay-eager.ts +2 -0
  174. package/src/server/request-log-filter.ts +6 -1
  175. package/src/server/request-log-terminal-status.ts +63 -6
  176. package/src/server/responses/adapter-dispatch.ts +232 -79
  177. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  178. package/src/server/responses/codex-ws-exchange.ts +147 -6
  179. package/src/server/responses/codex-ws-wire.ts +203 -0
  180. package/src/server/responses/collaboration.ts +36 -7
  181. package/src/server/responses/compact.ts +126 -48
  182. package/src/server/responses/context-overflow.ts +13 -1
  183. package/src/server/responses/core-codex-account.ts +105 -1
  184. package/src/server/responses/core-combo-failure.ts +14 -0
  185. package/src/server/responses/core-combo.ts +7 -2
  186. package/src/server/responses/core-errors.ts +29 -1
  187. package/src/server/responses/core-options.ts +2 -0
  188. package/src/server/responses/deferred-collab-surface.ts +158 -0
  189. package/src/server/responses/deferred-send-reporter.ts +25 -0
  190. package/src/server/responses/fetch-helpers.ts +12 -1
  191. package/src/server/responses/passthrough-delivery.ts +4 -1
  192. package/src/server/responses/passthrough-dispatch.ts +15 -4
  193. package/src/server/responses/request-prepare.ts +6 -4
  194. package/src/server/responses/request-transport.ts +6 -8
  195. package/src/server/responses/sidecar-execution.ts +11 -2
  196. package/src/server/responses/ws-upstream.ts +9 -1
  197. package/src/server/startup-action-control.ts +22 -2
  198. package/src/server/startup-health-cache.ts +4 -0
  199. package/src/server/system-env-shell.ts +7 -2
  200. package/src/server/system-env.ts +11 -1
  201. package/src/service/cli.ts +5 -1
  202. package/src/service/desktop-startup.ts +147 -34
  203. package/src/service/desktop-supervision.d.mts +27 -1
  204. package/src/service/desktop-supervision.mjs +111 -5
  205. package/src/service/health-sentinel-launch.ts +176 -0
  206. package/src/service/health-sentinel-main.ts +447 -0
  207. package/src/service/health-sentinel-policy.ts +241 -0
  208. package/src/service/health-sentinel-probe.ts +54 -0
  209. package/src/service/health-sentinel-windows.ts +87 -0
  210. package/src/service/linux-linger.ts +20 -0
  211. package/src/types/config.ts +5 -0
  212. package/src/usage/expected-prices.ts +4 -0
  213. package/src/usage/log.ts +19 -1
  214. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  215. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  216. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
@@ -169,12 +169,16 @@ export const APP_OWNED_RETAINED_STORE_REGISTRATIONS = [
169
169
  category: "caches",
170
170
  snapshot: usageSummaryRetainedStoreSnapshot,
171
171
  evictOldest: evictOldestUsageSummaryForBudget,
172
+ // Deletes one in-memory summary entry.
173
+ passiveEvictable: true,
172
174
  },
173
175
  {
174
176
  id: "usage_snapshot",
175
177
  category: "caches",
176
178
  snapshot: usageSnapshotRetainedStoreSnapshot,
177
179
  evictOldest: evictOldestUsageSnapshot,
180
+ // Drops the retained ledger snapshot or one unpinned aggregate, both in memory.
181
+ passiveEvictable: true,
178
182
  },
179
183
  {
180
184
  id: "cursor_blobs",
@@ -1,3 +1,5 @@
1
+ import { isPassiveRead } from "./passive-read";
2
+
1
3
  export type AppOwnedRetainedCategory = "logs" | "caches" | "blobs" | "continuation";
2
4
  export type AppOwnedObservedCategory = "translator" | "serialized_tails";
3
5
 
@@ -14,6 +16,11 @@ export interface RetainedStoreRegistration {
14
16
  category: AppOwnedRetainedCategory;
15
17
  snapshot(): RetainedStoreSnapshot;
16
18
  evictOldest(): number;
19
+ /**
20
+ * Eviction only releases memory: no spill, snapshot or other write. Only such stores are
21
+ * demoted inside a passive read (#6959); the rest wait for this process's own next enforcement.
22
+ */
23
+ passiveEvictable?: true;
17
24
  }
18
25
 
19
26
  export interface ObservedBufferRegistration {
@@ -168,11 +175,15 @@ function nextCandidate(
168
175
  snapshots: ReadonlyMap<string, RetainedStoreSnapshot>,
169
176
  ineligible: Set<string>,
170
177
  ): RetainedStoreRegistration | undefined {
178
+ // A passive read (a Remote Link Child's, #6959) demotes only stores whose eviction is memory-only:
179
+ // a continuation spills to disk and a replay cache rewrites its snapshot.
180
+ const passive = isPassiveRead();
171
181
  for (const category of CATEGORY_ORDER) {
172
182
  let candidate: RetainedStoreRegistration | undefined;
173
183
  let candidateAt = Number.POSITIVE_INFINITY;
174
184
  for (const registration of retainedStores.values()) {
175
185
  if (registration.category !== category || ineligible.has(registration.id)) continue;
186
+ if (passive && registration.passiveEvictable !== true) continue;
176
187
  const snapshot = snapshots.get(registration.id);
177
188
  if (!snapshot || snapshot.evictableBytes <= 0) continue;
178
189
  const oldestAt = snapshot.oldestAt;
@@ -1,5 +1,36 @@
1
1
  import { TRANSLATOR_MAX_TURN_BYTES, TranslatorBudgetExceededError } from "./translator-budget";
2
2
 
3
+ /** Count one JSON string formed from parts without joining, serializing or encoding it. */
4
+ export function jsonStringPartsUtf8Bytes(parts: readonly string[], limit = TRANSLATOR_MAX_TURN_BYTES): number {
5
+ let bytes = 0;
6
+ let highSurrogate = false;
7
+ const add = (count: number) => {
8
+ if (count > limit - bytes) throw new TranslatorBudgetExceededError("request_copies", limit);
9
+ bytes += count;
10
+ };
11
+ add(2); // The joined string has one pair of JSON quotes.
12
+ for (const text of parts) {
13
+ if (text.length > limit - bytes) throw new TranslatorBudgetExceededError("request_copies", limit);
14
+ for (let i = 0; i < text.length; i++) {
15
+ const code = text.charCodeAt(i);
16
+ if (highSurrogate) {
17
+ highSurrogate = false;
18
+ if (code >= 0xdc00 && code <= 0xdfff) { add(4); continue; }
19
+ add(6);
20
+ }
21
+ if (code >= 0xd800 && code <= 0xdbff) highSurrogate = true;
22
+ else if (code === 0x22 || code === 0x5c || code === 8 || code === 9 || code === 10 || code === 12 || code === 13) add(2);
23
+ else if (code < 0x20) add(6);
24
+ else if (code < 0x80) add(1);
25
+ else if (code < 0x800) add(2);
26
+ else if (code >= 0xdc00 && code <= 0xdfff) add(6);
27
+ else add(3);
28
+ }
29
+ }
30
+ if (highSurrogate) add(6);
31
+ return bytes;
32
+ }
33
+
3
34
  /** Measure plain JSON data without allocating its serialized string or UTF-8 copy. */
4
35
  export function jsonUtf8Bytes(value: unknown, limit = TRANSLATOR_MAX_TURN_BYTES): number {
5
36
  let bytes = 0;
@@ -70,5 +70,40 @@ export function runOptionalShutdownHooks(): void {
70
70
  /** Test-only reset so an isolated lifecycle test does not inherit registrations. */
71
71
  export function resetOptionalShutdownHooksForTests(): void {
72
72
  hooks.clear();
73
+ drainStartHooks.clear();
74
+ drainStartHooksRan = false;
73
75
  hooksRan = false;
74
76
  }
77
+
78
+ const drainStartHooks = new Map<string, ShutdownHook>();
79
+ let drainStartHooksRan = false;
80
+
81
+ /**
82
+ * Register work that must run the moment a drain begins, before in-flight turns are awaited.
83
+ * The service health sentinel (#6671) uses it: a deliberate stop whose drain stalls must stay a
84
+ * stop, so the sentinel is withdrawn before the drain can look unresponsive. Same contract as
85
+ * {@link registerOptionalShutdownHook}: keyed, synchronous, best-effort, detachable.
86
+ */
87
+ export function registerDrainStartHook(key: string, hook: ShutdownHook): () => void {
88
+ drainStartHooks.set(key, hook);
89
+ return () => {
90
+ if (drainStartHooks.get(key) === hook) drainStartHooks.delete(key);
91
+ };
92
+ }
93
+
94
+ /** Run every drain-start hook. Never throws. */
95
+ export function runDrainStartHooks(): void {
96
+ drainStartHooksRan = true;
97
+ for (const [key, hook] of [...drainStartHooks]) {
98
+ try {
99
+ hook();
100
+ } catch (err) {
101
+ console.warn(`[shutdown] drain-start hook "${key}" failed:`, err instanceof Error ? err.message : err);
102
+ }
103
+ }
104
+ }
105
+
106
+ /** Whether a drain has begun in this process; work that must not start mid-drain checks this. */
107
+ export function didRunDrainStartHooks(): boolean {
108
+ return drainStartHooksRan;
109
+ }
@@ -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
+ }
@@ -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
+ }