@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,194 @@
1
+ /**
2
+ * Credentials this installation is configured with, read when an upstream diagnostic is checked.
3
+ *
4
+ * A gateway can echo a credential it learned before this process ever sent it: an inactive pool
5
+ * key, another provider's key, an OAuth token from an earlier session. Learning only from sends
6
+ * would let such an echo be warned and finalized before the value is first dispatched, and a warning
7
+ * cannot be retracted. So the request log also refuses any diagnostic that repeats a configured
8
+ * value: every provider's apiKey and apiKeyPool keys (keychain and env references resolved), every
9
+ * configured header value, every credential-named provider field, and every token in the OAuth
10
+ * store. The set is rebuilt when the bytes of config.json or auth.json change, a stored keychain
11
+ * secret is replaced in this process, or a referenced environment variable changes.
12
+ *
13
+ * Freshness is decided by content, never by file metadata. Every check reads both stores and
14
+ * compares a digest of the bytes it read; the rebuild parses those same bytes. An inode/size/mtime
15
+ * key missed a same-length rewrite inside one timestamp tick (coarse on Windows) and any writer that
16
+ * preserves or backdates mtime, and then kept serving the previous, complete-looking view (#6923).
17
+ * The cost is two small reads per check; checks run only for retained upstream diagnostics.
18
+ *
19
+ * Reads are observe-only: no hardening, backup, repair or warning. A file that exists but cannot be
20
+ * read, parsed or recognized as the expected shape, or a keychain reference that cannot be resolved,
21
+ * makes coverage incomplete and callers fail closed; an incomplete set is retried after a short delay
22
+ * so a keychain that becomes available restores diagnostics. Values are held in memory only and
23
+ * never serialized or exposed.
24
+ *
25
+ * Not covered: keychain secrets are cached per process (`readKeychain` in
26
+ * src/providers/api-key-resolve.ts), so a secret replaced by another process is seen only after
27
+ * this process re-resolves it.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import { readFileSync, statSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { getConfigDir } from "../config/paths";
33
+ import { resolveEnvValue } from "../config/proxy-env";
34
+ import { isKeychainReference, providerKeyResolutionGeneration, resolveProviderApiKeyQuietly } from "../providers/api-key-resolve";
35
+ import { credentialComponents, credentialSetMatches } from "../lib/outbound-credential-registry";
36
+ import { SENSITIVE_KEY_PATTERN } from "../lib/redact";
37
+
38
+ const CREDENTIAL_FIELD = /(?:key|token|secret|password|passwd|credential|cookie|authorization)$/i;
39
+ /** How long an incomplete set is trusted before its sources are read again. */
40
+ export const INCOMPLETE_RETRY_MS = 30_000;
41
+
42
+ export interface ConfiguredCredentials {
43
+ readonly complete: boolean;
44
+ matches(value: string): boolean;
45
+ }
46
+
47
+ interface Snapshot {
48
+ /** The only object handed out: cache metadata (including env values) stays private here. */
49
+ readonly view: ConfiguredCredentials;
50
+ readonly signature: string;
51
+ readonly envNames: readonly string[];
52
+ readonly envFingerprint: string;
53
+ readonly builtAt: number;
54
+ }
55
+
56
+ let cached: Snapshot | undefined;
57
+
58
+ /** One read of a store: its state for the signature and, for a readable file, the bytes read. */
59
+ interface StoreRead {
60
+ /** "absent", "not-a-file", "unreadable", or "sha256:<hex>" of exactly the bytes in `text`. */
61
+ readonly state: string;
62
+ readonly text?: string;
63
+ }
64
+
65
+ function readStore(path: string): StoreRead {
66
+ try {
67
+ if (!statSync(path).isFile()) return { state: "not-a-file" };
68
+ } catch (error) {
69
+ // Only a store that is not there at all counts as intentionally absent.
70
+ return { state: (error as NodeJS.ErrnoException)?.code === "ENOENT" ? "absent" : "unreadable" };
71
+ }
72
+ let bytes: Buffer;
73
+ try {
74
+ bytes = readFileSync(path);
75
+ } catch {
76
+ // Present a moment ago, gone or unreadable now (a replacement in progress): fail closed.
77
+ return { state: "unreadable" };
78
+ }
79
+ return { state: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, text: bytes.toString("utf8") };
80
+ }
81
+
82
+ function parseStore(read: StoreRead): { ok: boolean; value?: unknown } {
83
+ if (read.state === "absent") return { ok: true };
84
+ if (read.text === undefined) return { ok: false };
85
+ try {
86
+ return { ok: true, value: JSON.parse(read.text.replace(/^\uFEFF/, "")) };
87
+ } catch {
88
+ return { ok: false };
89
+ }
90
+ }
91
+
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+
96
+ function envFingerprint(names: readonly string[]): string {
97
+ return names.map(name => process.env[name] ?? "\u0000unset").join("\u0000");
98
+ }
99
+
100
+ /** Mirrors resolveEnvValue: a braced or bare dollar reference reads the environment. */
101
+ function envReference(raw: string): string | undefined {
102
+ if (!raw.startsWith("$")) return undefined;
103
+ return /^\$\{(\w+)\}$/.exec(raw)?.[1] ?? raw.slice(1);
104
+ }
105
+
106
+ function build(configRead: StoreRead, authRead: StoreRead, signature: string, now: number): Snapshot {
107
+ const values = new Set<string>();
108
+ const envNames = new Set<string>();
109
+ let complete = true;
110
+ const add = (raw: string | undefined) => {
111
+ if (raw) for (const part of credentialComponents(raw)) values.add(part);
112
+ };
113
+ const noteEnv = (raw: string) => {
114
+ const name = envReference(raw);
115
+ if (name) envNames.add(name);
116
+ };
117
+ const addReference = (raw: unknown) => {
118
+ if (typeof raw !== "string" || !raw) return;
119
+ noteEnv(raw);
120
+ const resolved = resolveProviderApiKeyQuietly(raw);
121
+ if (resolved === undefined && isKeychainReference(raw)) complete = false;
122
+ add(resolved);
123
+ };
124
+ const isCredentialField = (field: string) => CREDENTIAL_FIELD.test(field) || SENSITIVE_KEY_PATTERN.test(field);
125
+ const walkProvider = (node: unknown, depth: number) => {
126
+ if (depth > 8) return;
127
+ if (Array.isArray(node)) { for (const item of node) walkProvider(item, depth + 1); return; }
128
+ if (!isRecord(node)) return;
129
+ for (const [field, value] of Object.entries(node)) {
130
+ if (field === "headers" && isRecord(value)) {
131
+ for (const header of Object.values(value)) {
132
+ if (typeof header !== "string") continue;
133
+ noteEnv(header);
134
+ add(resolveEnvValue(header) ?? header);
135
+ }
136
+ } else if (typeof value === "string") {
137
+ if (isCredentialField(field)) addReference(value);
138
+ } else {
139
+ walkProvider(value, depth + 1);
140
+ }
141
+ }
142
+ };
143
+ const walkAuth = (node: unknown, depth: number) => {
144
+ if (depth > 8) return;
145
+ if (Array.isArray(node)) { for (const item of node) walkAuth(item, depth + 1); return; }
146
+ if (!isRecord(node)) return;
147
+ for (const [field, value] of Object.entries(node)) {
148
+ if (typeof value === "string") {
149
+ if (field === "access" || field === "refresh" || isCredentialField(field)) add(value);
150
+ } else {
151
+ walkAuth(value, depth + 1);
152
+ }
153
+ }
154
+ };
155
+
156
+ const configSig = configRead.state;
157
+ const authSig = authRead.state;
158
+ const config = parseStore(configRead);
159
+ const auth = parseStore(authRead);
160
+ if (!config.ok || !auth.ok) complete = false;
161
+ // An existing file must have the shape its loader expects; anything else hides credentials.
162
+ if (configSig !== "absent" && config.ok && (!isRecord(config.value)
163
+ || (config.value.providers !== undefined && !isRecord(config.value.providers)))) complete = false;
164
+ if (authSig !== "absent" && auth.ok && !isRecord(auth.value)) complete = false;
165
+ if (isRecord(config.value) && isRecord(config.value.providers)) walkProvider(config.value.providers, 0);
166
+ if (isRecord(auth.value)) walkAuth(auth.value, 0);
167
+
168
+ const names = [...envNames].sort();
169
+ const view: ConfiguredCredentials = Object.freeze({
170
+ complete,
171
+ matches: (value: string) => credentialSetMatches(values, candidate => values.has(candidate), value),
172
+ });
173
+ return {
174
+ view,
175
+ signature,
176
+ envNames: names,
177
+ envFingerprint: envFingerprint(names),
178
+ builtAt: now,
179
+ };
180
+ }
181
+
182
+ /** The configured credential set for the active config home, rebuilt when any input changes. */
183
+ export function configuredCredentials(now = Date.now()): ConfiguredCredentials {
184
+ const dir = getConfigDir();
185
+ const configRead = readStore(join(dir, "config.json"));
186
+ const authRead = readStore(join(dir, "auth.json"));
187
+ const signature = `${configRead.state}|${authRead.state}|${providerKeyResolutionGeneration()}|${dir}`;
188
+ const stale = !cached
189
+ || cached.signature !== signature
190
+ || cached.envFingerprint !== envFingerprint(cached.envNames)
191
+ || (!cached.view.complete && now - cached.builtAt >= INCOMPLETE_RETRY_MS);
192
+ if (stale) cached = build(configRead, authRead, signature, now);
193
+ return cached!.view;
194
+ }
@@ -71,20 +71,37 @@ function capacityResponse(pathname: string, error: InboundBodyCapacityError): Re
71
71
  }, { status: 503, headers: { "Retry-After": "1" } });
72
72
  }
73
73
 
74
- function retainUntilResponseSettles(response: Response, release: () => void): Response {
74
+ function retainUntilResponseSettles(response: Response, signal: AbortSignal, release: () => void): Response {
75
75
  if (!response.body) {
76
76
  release();
77
77
  return response;
78
78
  }
79
79
  const reader = response.body.getReader();
80
80
  let cancelling = false;
81
+ let cancellation: Promise<void> | undefined;
81
82
  let finalized = false;
83
+ let bodyController!: ReadableStreamDefaultController<Uint8Array>;
84
+ const onAbort = (): void => {
85
+ if (finalized) return;
86
+ bodyController.error(signal.reason);
87
+ void cancel(signal.reason).catch(() => undefined);
88
+ };
82
89
  const finalize = (): void => {
83
90
  if (finalized) return;
84
91
  finalized = true;
92
+ signal.removeEventListener("abort", onAbort);
85
93
  try { reader.releaseLock(); } finally { release(); }
86
94
  };
95
+ const cancel = (reason: unknown): Promise<void> => {
96
+ if (cancellation) return cancellation;
97
+ if (finalized) return Promise.resolve();
98
+ cancelling = true;
99
+ // Share one settlement even if explicit cancellation races request abort.
100
+ cancellation = Promise.resolve().then(() => reader.cancel(reason)).finally(finalize);
101
+ return cancellation;
102
+ };
87
103
  const body = new ReadableStream<Uint8Array>({
104
+ start(controller) { bodyController = controller; },
88
105
  async pull(controller) {
89
106
  try {
90
107
  const result = await reader.read();
@@ -103,18 +120,18 @@ function retainUntilResponseSettles(response: Response, release: () => void): Re
103
120
  controller.error(error);
104
121
  }
105
122
  },
106
- async cancel(reason) {
107
- cancelling = true;
108
- try { await reader.cancel(reason); } finally { finalize(); }
109
- },
123
+ cancel,
110
124
  }, { highWaterMark: 0 });
111
125
  // This is the outermost HTTP wrapper, after protocol conversion, relay
112
126
  // preflight and request logging; no internal relay marker is consumed later.
113
- return new Response(body, {
127
+ const wrapped = new Response(body, {
114
128
  status: response.status,
115
129
  statusText: response.statusText,
116
130
  headers: response.headers,
117
131
  });
132
+ signal.addEventListener("abort", onAbort, { once: true });
133
+ if (signal.aborted) onAbort();
134
+ return wrapped;
118
135
  }
119
136
 
120
137
  /**
@@ -155,7 +172,7 @@ export async function withRaisedInboundBodyAdmission(
155
172
  reservedInboundBodyBytes -= maxBytes;
156
173
  };
157
174
  try {
158
- return retainUntilResponseSettles(await work(), release);
175
+ return retainUntilResponseSettles(await work(), req.signal, release);
159
176
  } catch (error) {
160
177
  release();
161
178
  throw error;
@@ -8,9 +8,18 @@ import {
8
8
  } from "../../link/store";
9
9
  import { linkStorePath } from "../../link/paths";
10
10
  import { linkRouteAllowed } from "../../link/routes";
11
+ import { withConfigMutationLockSync } from "../../config/mutation-lock";
11
12
 
12
13
  export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid";
13
14
 
15
+ /** Internal signal: the store's port differs from the port this bind landed on. */
16
+ class LinkListenerPortMismatchError extends Error {
17
+ constructor(storedPort: number, boundPort: number) {
18
+ super(`stored port ${storedPort} differs from bound port ${boundPort}`);
19
+ this.name = "LinkListenerPortMismatchError";
20
+ }
21
+ }
22
+
14
23
  export interface LinkListenerStartContext<T> {
15
24
  dispatch: (req: Request, server: Server<T>) => Promise<Response>;
16
25
  maxRequestBodySize: number;
@@ -118,29 +127,33 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
118
127
  closeWithoutAwait(bound);
119
128
  return;
120
129
  }
121
- if (store.listenerPort === null) {
122
- const port = bound.port;
123
- if (!port || port === 0) {
124
- closeWithoutAwait(bound);
125
- reportFailure("bind", new Error("Bun did not report a concrete listener port"), "bind");
126
- return;
127
- }
128
- try {
130
+ const port = bound.port;
131
+ if (!port || port === 0) {
132
+ closeWithoutAwait(bound);
133
+ reportFailure("bind", new Error("Bun did not report a concrete listener port"), "bind");
134
+ return;
135
+ }
136
+ try {
137
+ // Revalidate the admission and persist a new port in one mutation section.
138
+ // Even a known-port bind can race the deletion of its last recorded link.
139
+ const needed = withConfigMutationLockSync(() => {
129
140
  const current = readStore(storePath);
141
+ if (current.links.length === 0) return false;
130
142
  if (current.listenerPort !== null && current.listenerPort !== port) {
131
- // Another writer fixed a different port while this bind ran. Tunnels target the stored
132
- // port, so serving on this one would strand them: give it up and let the next
133
- // ensureStarted() bind the stored port.
134
- closeWithoutAwait(bound);
135
- reportFailure("listenerPort persistence", new Error(`stored port ${current.listenerPort} differs from bound port ${port}`), "persist");
136
- return;
143
+ throw new LinkListenerPortMismatchError(current.listenerPort, port);
137
144
  }
138
- writeStore(storePath, { ...current, listenerPort: port });
139
- } catch (error) {
145
+ if (current.listenerPort === null) writeStore(storePath, { ...current, listenerPort: port });
146
+ return true;
147
+ });
148
+ if (!needed) {
140
149
  closeWithoutAwait(bound);
141
- reportFailure("listenerPort persistence", error, "persist");
150
+ setStatus("off", null, null);
142
151
  return;
143
152
  }
153
+ } catch (error) {
154
+ closeWithoutAwait(bound);
155
+ reportFailure("listenerPort persistence", error, "persist");
156
+ return;
144
157
  }
145
158
  listener = bound as Server<T>;
146
159
  relaySessions = sessions;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The Home end of the Remote Link management relay (#6957).
3
+ *
4
+ * Reached only on the hub-link listener, after the request has passed its link-key admission
5
+ * (`resolveApiAuth` restricted to recorded link keys) and the shared route table in
6
+ * `src/link/management-routes.ts`. The management handler sees a fresh request built here: no
7
+ * caller header, cookie, Origin or credential crosses from the link, and the principal is
8
+ * absent, so no consent- or capability-gated branch can be reached. Only allowlisted reads run.
9
+ * They run as passive reads (`src/lib/passive-read.ts`): no discovery, config write or diagnostic (#6959).
10
+ */
11
+ import type { OcxConfig } from "../../types";
12
+ import { handleManagementAPI, type ManagementApiDeps } from "../management-api";
13
+ import type { ManagementSessionControl } from "../management-auth";
14
+ import { runPassiveRead } from "../../lib/passive-read";
15
+ import { LINK_CODEX_SETTINGS_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes";
16
+ import { projectHomeCodexSettings } from "../../link/codex-settings";
17
+ import { packageVersion } from "../../lib/package-version";
18
+
19
+ const VERSION = packageVersion("0.0.0");
20
+ const KEPT_RESPONSE_HEADERS = ["content-type", "content-language", "etag", "last-modified"];
21
+
22
+ function notFound(): Response {
23
+ return Response.json({ error: "not_found" }, { status: 404, headers: { "cache-control": "no-store" } });
24
+ }
25
+
26
+ export async function handleLinkManagementRequest(
27
+ req: Request,
28
+ url: URL,
29
+ config: OcxConfig,
30
+ deps: ManagementApiDeps,
31
+ sessionControl?: ManagementSessionControl,
32
+ ): Promise<Response> {
33
+ if (!linkManagementRouteAllowed(url, req)) return notFound();
34
+ if (url.pathname === LINK_CODEX_SETTINGS_PATH) {
35
+ return Response.json(projectHomeCodexSettings(config, VERSION), { headers: { "cache-control": "no-store" } });
36
+ }
37
+ const target = linkManagementTarget(url);
38
+ if (!target) return notFound();
39
+ const local = new URL(`${target.pathname}${target.search}`, "http://127.0.0.1");
40
+ // The handlers branch on GET; an admitted HEAD runs as GET and its body is dropped below.
41
+ const inner = new Request(local.href, {
42
+ method: "GET",
43
+ headers: { accept: "application/json", host: "127.0.0.1" },
44
+ });
45
+ const response = await runPassiveRead(() =>
46
+ handleManagementAPI(inner, local, config, deps, undefined, sessionControl, { trustedLoopback: false }));
47
+ if (!response) return notFound();
48
+ const headers = new Headers({ "cache-control": "no-store" });
49
+ for (const name of KEPT_RESPONSE_HEADERS) {
50
+ const value = response.headers.get(name);
51
+ if (value) headers.set(name, value);
52
+ }
53
+ if (req.method === "HEAD") {
54
+ try { await response.body?.cancel(); } catch { /* best effort */ }
55
+ return new Response(null, { status: response.status, headers });
56
+ }
57
+ return new Response(response.body, { status: response.status, headers });
58
+ }
@@ -14,6 +14,7 @@ import {
14
14
  type LinkListenerStatus,
15
15
  } from "./link-listener";
16
16
  import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor";
17
+ import { linkManagementRouteAllowed } from "../../link/management-routes";
17
18
  import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
18
19
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
19
20
  export { LINK_INGRESS_HOSTNAME } from "./link-listener";
@@ -77,7 +78,10 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
77
78
  if (claudeIntercept.ownsListener(server)) return "claude-intercept";
78
79
  return undefined;
79
80
  },
80
- linkRouteAllowed,
81
+ // The Home's link listener admits the data plane plus the read-only management relay
82
+ // (#6957). The Child's own ingress keeps the data-only table, so a local caller on the Child
83
+ // cannot reach the Home's management reads without the Child's dashboard session.
84
+ linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req),
81
85
  linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(),
82
86
  onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener),
83
87
  notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId),
@@ -1,5 +1,6 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
2
  import type { StartServerDeps } from "./startup-warnings";
3
+ import { isLinkManagementPath } from "../../link/management-routes";
3
4
  import {
4
5
  GUI_PAIRING_EXCHANGE_BODY_LIMIT,
5
6
  REMOTE_WORKSPACE_PAIRING_BODY_LIMIT,
@@ -96,6 +97,7 @@ import {
96
97
  isAllowedManagementOrigin,
97
98
  isApiAuthRequired,
98
99
  isLoopbackHostname,
100
+ markTrustedHubManagementRequest,
99
101
  jsonResponse,
100
102
  admissionFields,
101
103
  resolveApiAuth,
@@ -313,6 +315,11 @@ export function createServeOptions(ctx: ServeOptionsContext) {
313
315
  maxRequestBodySize: inboundBodyLimitBytes,
314
316
  async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
315
317
  const ingress = ingressForServer(requestServer);
318
+ // Bind management-origin trust to the actual Bun listener object resolved above.
319
+ // Neither Host nor any X-Forwarded-* header can manufacture this process-local mark.
320
+ if (ingress === "hub-management" && config.runtimeRole === "hub") {
321
+ markTrustedHubManagementRequest(req);
322
+ }
316
323
  const requestUrl = codexCompatibleUrl(req.url);
317
324
  const linkPolicyView = ingress === "hub-link" ? linkPolicy() : undefined;
318
325
  // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
@@ -367,6 +374,12 @@ export function createServeOptions(ctx: ServeOptionsContext) {
367
374
  if (requestUrl.pathname === "/v1/catalog" && requestUrl.search === ""
368
375
  && (req.method === "GET" || req.method === "HEAD")
369
376
  && admission.kind === "configured") onAuthenticatedCatalog(admission.keyId);
377
+ // #6957: the Child's read-only view of this Home. Admitted by the link key above and by
378
+ // the shared table in src/link/management-routes.ts; never reachable on another ingress.
379
+ if (isLinkManagementPath(requestUrl.pathname)) {
380
+ const { handleLinkManagementRequest } = await import("./link-management");
381
+ return withCors(await handleLinkManagementRequest(req, requestUrl, config, requestManagementApiDeps, managementSessionControl), req, policy);
382
+ }
370
383
  }
371
384
  const url = requestUrl;
372
385
  const admissionOptions = { linkIngress: policy.linkIngress?.allowedKeyIds };
@@ -1594,8 +1607,11 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1594
1607
  // `policy`, not `config`: this route is now served on the unauthenticated loopback
1595
1608
  // listener too (#4236), and only the receiving listener's view produces CORS headers
1596
1609
  // that match the admission decision made above.
1597
- return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
1598
- await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
1610
+ // Promote a caller `x-session-id` before admission, as Responses and Messages do (#6520):
1611
+ // the ChatGPT bridge forwards only canonical session headers, so the raw marker is lost.
1612
+ const sessionReq = withCallerSessionIdentity(req, admission);
1613
+ return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => withCors(
1614
+ await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
1599
1615
  req,
1600
1616
  policy,
1601
1617
  ), { requestId, start, logCtx });
@@ -1716,7 +1732,9 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1716
1732
  if (audioController) registerTurn(audioController, turnAdmissionLease);
1717
1733
  const acquisition = audioController
1718
1734
  ? clearableDeadline(120_000, AbortSignal.any([req.signal, audioController.signal])) : undefined;
1735
+ const detachClientAbort = turnAdmissionLease.bindAbortSignal(req.signal);
1719
1736
  const releaseAcquisition = () => {
1737
+ detachClientAbort();
1720
1738
  acquisition?.clear();
1721
1739
  if (audioController) unregisterTurn(audioController);
1722
1740
  else turnAdmissionLease.release();
@@ -1835,6 +1853,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1835
1853
  return withCors(formatErrorResponse(502, "upstream_error", "Audio WebSocket upgrade failed"), req, policy);
1836
1854
  }
1837
1855
  if (upgraded) {
1856
+ detachClientAbort();
1838
1857
  acquisition?.clear();
1839
1858
  finalizeLiveRequest(101);
1840
1859
  return undefined as unknown as Response;
@@ -502,7 +502,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
502
502
  work: (lease: ActiveTurnLease) => Promise<Response>,
503
503
  refusalLog?: WorkflowRefusalLog,
504
504
  ): Promise<Response> {
505
- const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
505
+ const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers), req.signal);
506
506
  if (!lease) return serverBusyResponse(req, "active turns", policy);
507
507
  // Root, lane, and the refusal that follows from them, all live in ./workflow-refusal.
508
508
  let workflow: ReturnType<typeof admitHttpWorkflowTurn>;
@@ -8,7 +8,7 @@ import {
8
8
  } from "../storage/policy-job";
9
9
  import { abortRestoreTrashJobAsync } from "../storage/restore-job";
10
10
  import { stopStorageCleanupScheduler } from "../storage/policy-scheduler";
11
- import { runOptionalShutdownHooks } from "../lib/optional-shutdown-hooks";
11
+ import { runDrainStartHooks, runOptionalShutdownHooks } from "../lib/optional-shutdown-hooks";
12
12
  import { stopStateStoreSweeper } from "../lib/state-store-sweeper";
13
13
  import {
14
14
  cancelQueuedStorageWorkerSpawns,
@@ -36,6 +36,7 @@ export const SESSION_LANE_ID_BYTES = 32;
36
36
  const turnGate = createAdmissionGate("active_turns", MAX_ACTIVE_TURNS);
37
37
  export interface ActiveTurnLease extends AdmissionLease {
38
38
  attach(lease: AdmissionLease): void;
39
+ bindAbortSignal(signal: AbortSignal): () => void;
39
40
  bindAbortController(ac: AbortController): void;
40
41
  beginCodexAccountSelection(): CodexAccountSelectionAdmission;
41
42
  isTransferred(): boolean;
@@ -169,7 +170,7 @@ export function resetLifecycleDrainStateForTests(): void {
169
170
  serverStartupReleaseFlights = new WeakMap<ReturnType<typeof Bun.serve>, Promise<void>>();
170
171
  releaseServerStartupLifecycleImpl = releaseNativeMainStartupLifecycle;
171
172
  }
172
- export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
173
+ export function tryAdmitTurn(sessionLaneId?: string, clientSignal?: AbortSignal): ActiveTurnLease | null {
173
174
  if (isDraining()) return null;
174
175
  const opaqueSessionLaneId = sessionLaneId
175
176
  ? createHash("sha256").update(sessionLaneId).digest("hex").slice(0, SESSION_LANE_ID_BYTES)
@@ -192,6 +193,8 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
192
193
  }
193
194
  const controllers = new Set<AbortController>();
194
195
  const attachedLeases = new Set<AdmissionLease>();
196
+ const abortListeners = new Map<AbortSignal, () => void>();
197
+ let cancellationReason: unknown;
195
198
  let active = true;
196
199
  let transferred = false;
197
200
  let nativeMainClaimed = false;
@@ -200,22 +203,40 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
200
203
  if (!active) attachedLease.release();
201
204
  else attachedLeases.add(attachedLease);
202
205
  },
206
+ bindAbortSignal(signal) {
207
+ if (!active || abortListeners.has(signal)) return () => {};
208
+ const onAbort = () => {
209
+ cancellationReason = signal.reason;
210
+ const boundControllers = [...controllers];
211
+ lease.release();
212
+ for (const controller of boundControllers) controller.abort(cancellationReason);
213
+ };
214
+ abortListeners.set(signal, onAbort);
215
+ signal.addEventListener("abort", onAbort, { once: true });
216
+ if (signal.aborted) onAbort();
217
+ return () => {
218
+ if (abortListeners.get(signal) !== onAbort) return;
219
+ signal.removeEventListener("abort", onAbort);
220
+ abortListeners.delete(signal);
221
+ };
222
+ },
203
223
  bindAbortController(ac) {
204
224
  knownTurnControllers.add(ac);
205
225
  if (!active) {
206
- ac.abort(new Error("turn already settled"));
226
+ ac.abort(cancellationReason ?? new Error("turn already settled"));
207
227
  return;
208
228
  }
209
229
  transferred = true;
210
230
  controllers.add(ac);
211
231
  activeTurns.set(ac, lease);
232
+ lease.bindAbortSignal(ac.signal);
212
233
  },
213
234
  beginCodexAccountSelection() {
214
235
  const mainProfileDraining = nativeMainDrainOwners.size > 0;
215
- let selectionActive = !mainProfileDraining;
236
+ let selectionActive = active && !mainProfileDraining;
216
237
  let released = false;
217
238
  if (selectionActive) nativeMainSelections += 1;
218
- return {
239
+ const selection: CodexAccountSelectionAdmission = {
219
240
  mainProfileDraining,
220
241
  claimMainProfile() {
221
242
  if (released || mainProfileDraining || !active) return false;
@@ -228,18 +249,23 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
228
249
  release() {
229
250
  if (released) return;
230
251
  released = true;
252
+ attachedLeases.delete(selection);
231
253
  if (selectionActive) {
232
254
  selectionActive = false;
233
255
  nativeMainSelections = Math.max(0, nativeMainSelections - 1);
234
256
  }
235
257
  },
236
258
  };
259
+ lease.attach(selection);
260
+ return selection;
237
261
  },
238
262
  isTransferred() { return transferred; },
239
263
  release() {
240
264
  if (!active) return;
241
265
  active = false;
242
266
  admittedTurns.delete(lease);
267
+ for (const [signal, listener] of abortListeners) signal.removeEventListener("abort", listener);
268
+ abortListeners.clear();
243
269
  for (const controller of controllers) {
244
270
  if (activeTurns.get(controller) === lease) activeTurns.delete(controller);
245
271
  }
@@ -258,6 +284,7 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
258
284
  },
259
285
  };
260
286
  admittedTurns.add(lease);
287
+ if (clientSignal) lease.bindAbortSignal(clientSignal);
261
288
  return lease;
262
289
  }
263
290
  export function codexAccountSelectionForTurn(
@@ -472,6 +499,7 @@ export async function drainAndShutdown(
472
499
  // ordinary in-flight turns. A stuck scoped owner must not pin shutdown forever.
473
500
  const deadline = Date.now() + Math.max(0, timeoutMs);
474
501
  beginShutdownDrain();
502
+ runDrainStartHooks();
475
503
  const temporaryDrainsSettled = await waitForTemporaryDrainsUntil(deadline);
476
504
  if (!temporaryDrainsSettled) {
477
505
  console.warn("Temporary drain lease did not settle before the shutdown deadline; forcing shutdown");