@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
@@ -15,7 +15,7 @@ import { createToolChoiceResolver, namespacedToolName } from "../types";
15
15
  import { responsesRequestSchema } from "./schema";
16
16
  import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
17
17
  import { lookupReplayThoughtSignature } from "./thought-signature-replay";
18
- import { compactionItemToText, isCompactionItemType } from "./compaction";
18
+ import { CHECKPOINT_CONTINUATION_NOTE, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "./compaction";
19
19
  import { previousResponseReplayPrefixLength } from "./state";
20
20
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
21
21
  import { hasRoutedIdentity, nameRoutedIdentity } from "../adapters/identity";
@@ -193,6 +193,7 @@ export function parseRequest(
193
193
  // Remote compaction v2: the input tail carries `{type:"compaction_trigger"}` and Codex expects a
194
194
  // synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server.
195
195
  let compactionRequest = false;
196
+ let decodedCheckpoint = false;
196
197
  let contextCompactionBoundary = false;
197
198
  let continuationConversationMessageIndex: number | undefined;
198
199
 
@@ -261,6 +262,7 @@ export function parseRequest(
261
262
  if (inputIndex >= replayedInputPrefixLength) contextCompactionBoundary = true;
262
263
  const encrypted = (item as { encrypted_content?: unknown }).encrypted_content;
263
264
  if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue;
265
+ if (typeof encrypted === "string" && decodeCompactionSummary(encrypted) !== null) decodedCheckpoint = true;
264
266
  pendingReasoning.length = 0;
265
267
  messages.push({
266
268
  role: "user",
@@ -586,6 +588,10 @@ export function parseRequest(
586
588
  .map(t => loadedToolNames.has(namespacedToolName(t.namespace, t.name))
587
589
  ? { ...t, loadedFromToolSearch: true }
588
590
  : t);
591
+ // #6764: the decoded checkpoint is ordinary user text on this route. Say in the system prompt
592
+ // what it is, so a retained earlier request that it reports as completed is not repeated.
593
+ // A compaction request is a summarizer turn and must not be told to continue.
594
+ if (decodedCheckpoint && !compactionRequest) systemPrompt.push(CHECKPOINT_CONTINUATION_NOTE);
589
595
  const context: OcxContext = {
590
596
  ...(systemPrompt.length > 0 ? { systemPrompt } : {}),
591
597
  messages,
@@ -47,6 +47,18 @@ import { googleVertexLocationConfigError } from "../providers/google-vertex-loca
47
47
  import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in";
48
48
 
49
49
  let _corsOrigin = "http://localhost:10100";
50
+ // Listener identity is process-local. Never infer this trust from forwarded headers: the
51
+ // request handler marks a Request only after resolving the actual Bun Server instance.
52
+ const trustedHubManagementRequests = new WeakSet<Request>();
53
+
54
+ export function markTrustedHubManagementRequest(req: Request): void {
55
+ trustedHubManagementRequests.add(req);
56
+ }
57
+
58
+ export function inheritTrustedHubManagementRequest(source: Request, target: Request): void {
59
+ if (trustedHubManagementRequests.has(source)) trustedHubManagementRequests.add(target);
60
+ }
61
+
50
62
  export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
51
63
  /** The proxy's own listening port. No admission check uses it: both loopback predicates key on hostname alone. */
52
64
  export function configuredPort(): string {
@@ -144,6 +156,24 @@ export function managementRequestOrigin(req: Request, config: OcxConfig): string
144
156
  return null;
145
157
  }
146
158
  }
159
+ if (trustedHubManagementRequests.has(req)) {
160
+ if (config.runtimeRole !== "hub") return null;
161
+ const canonical = canonicalHubManagementOrigin(config.hub?.managementPublicOrigin);
162
+ if (!canonical) return null;
163
+ try {
164
+ // The dedicated listener may receive HTTP from a local TLS terminator, so its URL
165
+ // scheme is not authoritative. Host must still name exactly the configured public
166
+ // authority; forwarded host/proto headers are deliberately ignored.
167
+ const requestAuthority = new URL(`https://${host}`);
168
+ if (requestAuthority.username || requestAuthority.password || requestAuthority.pathname !== "/"
169
+ || requestAuthority.search || requestAuthority.hash || requestAuthority.host !== new URL(canonical).host) {
170
+ return null;
171
+ }
172
+ return canonical;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
147
177
  if (!isApiAuthRequired(config)) return null;
148
178
  if (config.runtimeRole === "hub" && config.hub?.managementPublicOrigin) {
149
179
  try {
@@ -437,6 +467,18 @@ export function resolveDataPlaneAdmissionSecret(
437
467
  return null;
438
468
  }
439
469
 
470
+ function canonicalHubManagementOrigin(value: unknown): string | null {
471
+ if (typeof value !== "string") return null;
472
+ try {
473
+ const parsed = new URL(value);
474
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/"
475
+ || parsed.search || parsed.hash) return null;
476
+ return parsed.origin;
477
+ } catch {
478
+ return null;
479
+ }
480
+ }
481
+
440
482
  /** The principal an authenticated admission belongs to, or undefined for loopback. */
441
483
  export function contextPrincipalIdOf(admission: DataPlaneAdmission | undefined): string | undefined {
442
484
  return admission && "contextPrincipalId" in admission ? admission.contextPrincipalId : undefined;
@@ -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
+ }
@@ -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 };
@@ -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,
@@ -499,6 +499,7 @@ export async function drainAndShutdown(
499
499
  // ordinary in-flight turns. A stuck scoped owner must not pin shutdown forever.
500
500
  const deadline = Date.now() + Math.max(0, timeoutMs);
501
501
  beginShutdownDrain();
502
+ runDrainStartHooks();
502
503
  const temporaryDrainsSettled = await waitForTemporaryDrainsUntil(deadline);
503
504
  if (!temporaryDrainsSettled) {
504
505
  console.warn("Temporary drain lease did not settle before the shutdown deadline; forcing shutdown");
@@ -1,6 +1,7 @@
1
1
  import { ensureManagementClaudeIntercept, interceptStartRefusal, interceptStatus } from "./claude-intercept-routes";
2
2
  import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state";
3
3
  import { captureDesktopAppliedMarker, commitDesktopAppliedMarker } from "../../claude/desktop-applied-marker";
4
+ import { isPassiveRead } from "../../lib/passive-read";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { readFileSync } from "node:fs";
6
7
  import { dirname } from "node:path";
@@ -230,6 +231,32 @@ async function injectionModelOptions(config: OcxConfig, models: readonly Catalog
230
231
  return [...nativeModels, ...routedModels];
231
232
  }
232
233
 
234
+ /**
235
+ * The GET /api/injection-model body. `routed` is the discovered routed catalog, or `null` in a
236
+ * passive read (a Remote Link Child's, #6959). That body lists the native
237
+ * options and the current pick, which is all a read-only Child shows.
238
+ */
239
+ export async function injectionModelView(config: OcxConfig, routed: readonly CatalogModel[] | null) {
240
+ const { CODEX_REASONING_LEVELS } = await import("../../reasoning-effort");
241
+ const available = await injectionModelOptions(config, routed ?? []);
242
+ const picked = config.injectionModel;
243
+ if (routed === null && picked && !available.some(option => option.namespaced === picked)) {
244
+ const slash = picked.indexOf("/");
245
+ available.push(slash > 0
246
+ ? { provider: picked.slice(0, slash), model: picked.slice(slash + 1), namespaced: picked }
247
+ : { provider: "openai", model: picked, namespaced: picked });
248
+ }
249
+ return {
250
+ multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
251
+ syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
252
+ model: config.injectionModel ?? null,
253
+ effort: config.injectionEffort ?? null,
254
+ prompt: config.injectionPrompt ?? null,
255
+ efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
256
+ available,
257
+ };
258
+ }
259
+
233
260
  export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise<Response | null> {
234
261
  const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
235
262
 
@@ -620,16 +647,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
620
647
  // effort the prompt tells the agent to pass to spawn_agent. GET returns the current
621
648
  // picks + available models/efforts; PUT sets or clears them.
622
649
  if (url.pathname === "/api/injection-model" && req.method === "GET") {
623
- const { CODEX_REASONING_LEVELS } = await import("../../reasoning-effort");
624
- return jsonResponse({
625
- multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
626
- syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
627
- model: config.injectionModel ?? null,
628
- effort: config.injectionEffort ?? null,
629
- prompt: config.injectionPrompt ?? null,
630
- efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
631
- available: await injectionModelOptions(config, await fetchAllModels(config)),
632
- });
650
+ return jsonResponse(await injectionModelView(config, isPassiveRead() ? null : await fetchAllModels(config)));
633
651
  }
634
652
  // Read-only: sizes the described delegated work and proposes a model and effort from the
635
653
  // same options GET offers. The page applies an accepted proposal through the PUT below.
@@ -19,6 +19,7 @@ import { captureAnthropicPhysicalSendOwnership, anthropicPhysicalSendOwnershipIs
19
19
  import { anthropicCooldownRecoveryFor } from "../../providers/quota/anthropic-cooldown-recovery";
20
20
  import { captureProviderAccountQuotaEpoch } from "../../providers/quota/account-cache";
21
21
  import { captureOAuthAccountSelection, getAccountSet, getAccountCredentialWithStatus, listAccounts } from "../../oauth/store";
22
+ import { admitAnthropicAccountAfterReset, type AnthropicResetAdmissionEvidence } from "../../providers/anthropic-reset-admission";
22
23
  import {
23
24
  ANTHROPIC_RESET_GRANT_ID_RE,
24
25
  AnthropicResetGrantError,
@@ -74,6 +75,8 @@ export interface AnthropicResetGrantRouteDeps {
74
75
  listAccountIds: () => string[];
75
76
  activeAccountId: () => string | null;
76
77
  accessTokenFor: (accountId: string) => Promise<string>;
78
+ /** Lift local admission after a journaled reset, bounded by its journal evidence. Tests omit it. */
79
+ admitReset?: (accountId: string, evidence: AnthropicResetAdmissionEvidence) => Promise<void>;
77
80
  }
78
81
 
79
82
  function defaultDepsFor(ctx: ManagementContext, provider: AnthropicInstanceId): AnthropicResetGrantRouteDeps | null {
@@ -109,6 +112,7 @@ function defaultDepsFor(ctx: ManagementContext, provider: AnthropicInstanceId):
109
112
  if (!targetCurrent() || !owner) throw new Error("reset owner changed");
110
113
  return snapshot.accessToken;
111
114
  },
115
+ admitReset: (accountId, evidence) => admitAnthropicAccountAfterReset(provider, accountId, evidence),
112
116
  };
113
117
  }
114
118
 
@@ -209,6 +213,16 @@ async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGr
209
213
  }
210
214
  if (deps.isCurrent?.() === false) return scopedFail(401, "auth_failed");
211
215
  const ledger = { journalPath: deps.journalPath, now: deps.now?.() };
216
+ // Retry-after cooldowns survive a usage probe. A journaled reset, including a
217
+ // replay after the process died before admission, has to lift this account,
218
+ // but only refusals recorded before the claim was journaled and only for the
219
+ // windows the journal says were cleared. A failed reread must not turn a
220
+ // spent grant into an error.
221
+ const admitJournaledReset = async (code: string, evidence: AnthropicResetAdmissionEvidence): Promise<void> => {
222
+ if (code !== "reset" || deps.isCurrent?.() === false) return;
223
+ try { await deps.admitReset?.(accountId, evidence); }
224
+ catch { /* The grant is already spent. */ }
225
+ };
212
226
 
213
227
  // A new operation must pass the spend gate on a fresh read. A same-id retry of
214
228
  // an open record skips it: that attempt was already gated, and upstream
@@ -242,6 +256,7 @@ async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGr
242
256
  }
243
257
  switch (begin.kind) {
244
258
  case "replay":
259
+ await admitJournaledReset(begin.code, { claimedAt: begin.claimedAt, cleared: begin.cleared });
245
260
  return jsonResponse(
246
261
  { provider, code: begin.code, replayed: true, resetsLeft: begin.resetsLeft, accountId, grantId, operationId },
247
262
  200, ctx.req, ctx.config,
@@ -268,7 +283,7 @@ async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGr
268
283
  let settled;
269
284
  try {
270
285
  settled = settleAnthropicResetOperation(
271
- { operationId, code: answer.code, resetsLeft: answer.resetsLeft },
286
+ { operationId, code: answer.code, resetsLeft: answer.resetsLeft, cleared: answer.cleared },
272
287
  { journalPath: deps.journalPath, now: deps.now?.() },
273
288
  );
274
289
  } catch {
@@ -278,6 +293,7 @@ async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGr
278
293
  // Report what the journal holds. An overlapping same-id attempt may have
279
294
  // settled first; its answer is the canonical one.
280
295
  const replayed = settled.code !== answer.code || settled.resetsLeft !== answer.resetsLeft;
296
+ await admitJournaledReset(settled.code, { claimedAt: settled.claimedAt, cleared: settled.cleared });
281
297
  return jsonResponse(
282
298
  {
283
299
  provider,
@@ -96,12 +96,18 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis
96
96
  return jsonResponse({ error: "invalid JSON body" }, 400, req, config);
97
97
  }
98
98
 
99
- let model: string;
100
- let effort: string | undefined;
99
+ // An omitted model leaves the role's model pin (or its inheritance) alone, an omitted effort
100
+ // keeps the effort, and an effort of null clears it. A body with neither changes nothing, which
101
+ // is a client mistake rather than a save, so it is refused before any role file is read.
102
+ if (body?.model === undefined && body?.effort === undefined) {
103
+ return jsonResponse({ error: "send a model, an effort, or both", code: "empty_update" }, 400, req, config);
104
+ }
105
+ let model: string | undefined;
106
+ let effort: string | null | undefined;
101
107
  let toml: { status: "written" | "unchanged" };
102
108
  try {
103
- model = roles.validateAgentRoleModel(body?.model);
104
- effort = body?.effort === undefined || body.effort === null ? undefined : roles.validateAgentRoleEffort(body.effort);
109
+ model = body?.model === undefined ? undefined : roles.validateAgentRoleModel(body.model);
110
+ effort = body?.effort === undefined ? undefined : body.effort === null ? null : roles.validateAgentRoleEffort(body.effort);
105
111
  toml = roles.writeCodexAgentRoleModel(role, model, codexHome, effort);
106
112
  } catch (error) {
107
113
  if (error instanceof roles.AgentRoleModelError) {
@@ -115,10 +121,17 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis
115
121
  // The role TOML is what Codex obeys, so its write stands even when the omo mirror cannot follow.
116
122
  let omoStatus: ReturnType<typeof omo.writeOmoRoleModel> | "write_failed";
117
123
  try {
118
- const reasoning = effort === undefined ? undefined : omo.omoReasoningFor(effort);
124
+ const reasoning = effort === undefined ? undefined : effort === null ? null : omo.omoReasoningFor(effort);
119
125
  omoStatus = omo.writeOmoRoleModel(role, model, omo.omoJsoncPath(), reasoning);
120
126
  } catch {
121
127
  omoStatus = "write_failed";
122
128
  }
123
- return jsonResponse({ ok: true, role, model, ...(effort ? { effort } : {}), toml, omoJsonc: { status: omoStatus } }, 200, req, config);
129
+ return jsonResponse({
130
+ ok: true,
131
+ role,
132
+ ...(model !== undefined ? { model } : {}),
133
+ ...(effort !== undefined ? { effort } : {}),
134
+ toml,
135
+ omoJsonc: { status: omoStatus },
136
+ }, 200, req, config);
124
137
  }