@bitkyc08/opencodex 2.60.0 → 2.61.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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-CH6C5H7x.js +50 -0
  5. package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
  6. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  7. package/gui/dist/assets/index-wpTOyepx.js +86 -0
  8. package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -1,3 +1,4 @@
1
+ import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import type { CatalogModel } from "../../codex/catalog";
@@ -131,6 +132,17 @@ function persistDesktopProfileField(
131
132
  return { ok: true };
132
133
  }
133
134
 
135
+ async function persistDesktopModeField(
136
+ config: OcxConfig,
137
+ desktopMode: "first-party" | "gateway",
138
+ ): Promise<{ ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" }> {
139
+ const { recordClaudeDesktopMode } = await import("../../claude/desktop-first-party");
140
+ const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode));
141
+ if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason };
142
+ recordClaudeDesktopMode(config, desktopMode);
143
+ return { ok: true };
144
+ }
145
+
134
146
  export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } {
135
147
  return {
136
148
  currentBytes: grokApplyFlight?.bytes ?? 0,
@@ -1002,7 +1014,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1002
1014
  if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") {
1003
1015
  try {
1004
1016
  // #859: the CLI delegates here so the registry is built in the serving
1005
- // process. Accept an optional mode; default stays static for back-compat.
1017
+ // process. `mode` selects the Desktop mode: `first-party` (default when the
1018
+ // install has no gateway profile) or `gateway`; the legacy gateway shapes
1019
+ // (static/hybrid/discovery) still select gateway with that library layout.
1006
1020
  let mode: "static" | "hybrid" | "discovery" = "static";
1007
1021
  let parsed: unknown;
1008
1022
  try {
@@ -1011,12 +1025,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1011
1025
  rethrowManagementBodyTooLarge(error);
1012
1026
  return jsonResponse({ error: "invalid JSON body" }, 400);
1013
1027
  }
1028
+ const { resolveClaudeDesktopApplyMode, applyDesktopFirstParty, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party");
1014
1029
  const requested = (parsed as { mode?: unknown } | null)?.mode;
1030
+ let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config);
1015
1031
  if (requested !== undefined) {
1016
1032
  if (requested === "static" || requested === "hybrid" || requested === "discovery") {
1017
1033
  mode = requested;
1034
+ desktopMode = "gateway";
1035
+ } else if (requested === "gateway" || requested === "first-party") {
1036
+ desktopMode = requested;
1018
1037
  } else {
1019
- return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400);
1038
+ return jsonResponse({ error: "mode must be first-party, gateway, static, hybrid, or discovery" }, 400);
1020
1039
  }
1021
1040
  }
1022
1041
  // #859: a delegated CLI apply carries the profile it just saved — the
@@ -1032,6 +1051,60 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1032
1051
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
1033
1052
  }
1034
1053
  }
1054
+ if (desktopMode === "first-party") {
1055
+ const { setIntegrationEnabled } = await import("../../codex/desired-state");
1056
+ const desired = setIntegrationEnabled("claude-desktop", true);
1057
+ if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500);
1058
+ mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true);
1059
+ const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p");
1060
+ const rollback = captureDesktopFirstPartyRollback(config);
1061
+ const applied = applyDesktopFirstParty(config);
1062
+ if (!applied.ok) {
1063
+ const { firstPartyRefusalMessage } = await import("./native-integration-routes");
1064
+ return jsonResponse({
1065
+ error: firstPartyRefusalMessage(applied.reason, applied.path),
1066
+ code: "claude_desktop_first_party_refused",
1067
+ reason: applied.reason,
1068
+ path: applied.path,
1069
+ }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500);
1070
+ }
1071
+ // Remove the gateway only after the replacement env was written.
1072
+ const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
1073
+ const library = inspectDesktop3pConfigLibrary({ appliedFingerprint });
1074
+ let gatewayRemoved = false;
1075
+ if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") {
1076
+ const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true });
1077
+ if (!removed.ok) {
1078
+ const restored = removed.changed || !applied.changed || rollback();
1079
+ const modeSaved = !removed.changed || (await persistDesktopModeField(config, "first-party")).ok;
1080
+ const warning = [restored ? "" : "first-party settings rollback did not complete",
1081
+ modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; ");
1082
+ return jsonResponse({
1083
+ error: removed.kind === "cleanup_incomplete"
1084
+ ? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active."
1085
+ : "The gateway profile could not be removed safely, so first-party mode was not applied.",
1086
+ code: "claude_desktop_gateway_removal_failed",
1087
+ ...(warning ? { warning } : {}),
1088
+ reason: removed.reason ?? removed.kind,
1089
+ ...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}),
1090
+ }, removed.kind === "cleanup_incomplete" ? 500 : 409);
1091
+ }
1092
+ gatewayRemoved = removed.changed;
1093
+ }
1094
+ const modeSaved = await persistDesktopModeField(config, "first-party");
1095
+ return jsonResponse({
1096
+ ok: true,
1097
+ mode: "first-party",
1098
+ saved: modeSaved.ok,
1099
+ applied: true,
1100
+ changed: applied.changed || gatewayRemoved,
1101
+ gatewayRemoved,
1102
+ path: applied.path,
1103
+ proxyPort: applied.proxyPort,
1104
+ caCertPath: applied.env.NODE_EXTRA_CA_CERTS,
1105
+ ...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }),
1106
+ });
1107
+ }
1035
1108
  const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state");
1036
1109
  const desired = setIntegrationEnabled("claude-desktop", true);
1037
1110
  if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500);
@@ -1083,42 +1156,31 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1083
1156
  nativeContextLimits(latest),
1084
1157
  );
1085
1158
  if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500);
1159
+ const committed = persistCommittedDesktopGateway(config, state.profile, result.fingerprint);
1160
+ const modeWarning = committed.ok ? undefined : `Gateway applied, but its mode/profile state was not saved (${committed.reason}).`;
1161
+ // The durable marker describes the committed gateway even if old-mode cleanup fails.
1162
+ const firstPartyRemoved = removeDesktopFirstParty();
1163
+ if (!firstPartyRemoved.ok) {
1164
+ return jsonResponse({
1165
+ error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`,
1166
+ code: "claude_desktop_first_party_refused", reason: firstPartyRemoved.reason,
1167
+ applied: true, saved: committed.ok, mode: "gateway", path: result.path,
1168
+ ...(modeWarning ? { warning: modeWarning } : {}),
1169
+ }, 500);
1170
+ }
1086
1171
  const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy");
1087
- const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({
1088
- platform: deps.platform ?? process.platform,
1089
- });
1172
+ const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform });
1090
1173
  const policyWarning = claudeDesktopPolicyWarning(policyState);
1091
- // Persist applied fingerprint + timestamp so GUI can show saved-vs-applied state.
1092
- if (result.fingerprint) {
1093
- // The Desktop write already landed, so a failed bookkeeping save is not
1094
- // an apply failure: report the miss instead of claiming a clean apply.
1095
- const marked = persistDesktopProfileField(config, {
1096
- ...state.profile,
1097
- appliedFingerprint: result.fingerprint,
1098
- appliedAt: new Date().toISOString(),
1099
- });
1100
- if (!marked.ok) {
1101
- return jsonResponse({
1102
- ok: true,
1103
- applied: true,
1104
- saved: false,
1105
- path: result.path,
1106
- fingerprint: result.fingerprint,
1107
- warning: [
1108
- `Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`,
1109
- policyWarning,
1110
- ].filter(Boolean).join(" "),
1111
- });
1112
- }
1113
- }
1174
+ const warning = [modeWarning, policyWarning].filter(Boolean).join(" ");
1114
1175
  return jsonResponse({
1115
1176
  ok: true,
1116
- saved: true,
1177
+ mode: "gateway",
1178
+ saved: committed.ok,
1117
1179
  applied: true,
1118
1180
  path: result.path,
1119
1181
  fingerprint: result.fingerprint,
1120
1182
  policyState,
1121
- ...(policyWarning ? { warning: policyWarning } : {}),
1183
+ ...(warning ? { warning } : {}),
1122
1184
  });
1123
1185
  } catch (error) {
1124
1186
  rethrowManagementBodyTooLarge(error);
@@ -1131,21 +1193,39 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1131
1193
  try {
1132
1194
  const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state");
1133
1195
  const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p");
1196
+ const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty } = await import("../../claude/desktop-first-party");
1197
+ const { getClaudeInterceptState } = await import("../../claude/intercept/runtime");
1134
1198
  const persisted = loadConfig();
1135
1199
  const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
1136
1200
  const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint });
1137
1201
  const desiredEnabled = claudeDesktopIntegrationEnabled(persisted);
1138
- const applied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted";
1202
+ const gatewayApplied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted";
1203
+ // A gateway profile on disk is what Desktop actually runs, whatever the saved mode says.
1204
+ const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted);
1205
+ const firstPartySeen = inspectDesktopFirstParty(persisted);
1206
+ const intercept = getClaudeInterceptState();
1207
+ const firstParty = {
1208
+ settingsState: firstPartySeen.settings.kind,
1209
+ applied: firstPartySeen.applied,
1210
+ stale: firstPartySeen.stale,
1211
+ interceptEnabled: firstPartySeen.interceptEnabled,
1212
+ interceptRunning: intercept !== null,
1213
+ proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort,
1214
+ caCertPath: firstPartySeen.caCertPath,
1215
+ };
1216
+ const applied = mode === "first-party" ? firstPartySeen.applied : gatewayApplied;
1139
1217
  // "Needs update" is only meaningful while the integration is wanted. When the
1140
1218
  // durable switch is OFF, a leftover drifted profile is residue to clear — not
1141
1219
  // a stale apply the operator should refresh.
1142
- const stale = desiredEnabled && observed.kind === "gateway_drifted";
1220
+ const stale = desiredEnabled && (mode === "first-party" ? firstPartySeen.stale : observed.kind === "gateway_drifted");
1143
1221
  const { getDesktopHealth } = await import("../../claude/desktop-health");
1144
1222
  const { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy");
1145
1223
  const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({
1146
1224
  platform: deps.platform ?? process.platform,
1147
1225
  });
1148
- const policy = claudeDesktopPolicyHealth(policyState);
1226
+ // Managed-policy conflicts only matter for the gateway profile; first-party mode never
1227
+ // touches Desktop's own configuration.
1228
+ const policy = claudeDesktopPolicyHealth(mode === "first-party" ? "absent" : policyState);
1149
1229
  const health = {
1150
1230
  ...getDesktopHealth(),
1151
1231
  ok: policy.ok,
@@ -1153,14 +1233,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1153
1233
  policy,
1154
1234
  };
1155
1235
  const policyConflict = desiredEnabled && !policy.ok;
1156
- const baseDrift = desiredEnabled ? !applied || stale : applied || observed.kind === "unsafe";
1236
+ const firstPartyResidue = mode === "gateway" && (firstPartySeen.applied || firstPartySeen.stale);
1237
+ const baseDrift = desiredEnabled
1238
+ ? !applied || stale || firstPartyResidue
1239
+ : applied || gatewayApplied || firstPartySeen.applied || firstPartySeen.stale || observed.kind === "unsafe";
1157
1240
  const driftReason = policyConflict
1158
1241
  ? policy.state === "present" ? "managed_policy_present" : "managed_policy_unknown"
1159
1242
  : desiredEnabled
1160
- ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : null
1161
- : applied ? "desired_off_gateway_selected" : null;
1243
+ ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : firstPartyResidue ? "first_party_residue" : null
1244
+ : gatewayApplied ? "desired_off_gateway_selected" : firstPartySeen.applied || firstPartySeen.stale ? "desired_off_first_party_env" : null;
1162
1245
  return jsonResponse({
1163
1246
  desiredEnabled,
1247
+ mode,
1248
+ firstParty,
1164
1249
  installed: observed.kind !== "not_installed",
1165
1250
  observedKind: observed.kind,
1166
1251
  applied,
@@ -0,0 +1,77 @@
1
+ import {
2
+ applyCompanionSettingsPatch,
3
+ DEFAULT_COMPANION_SETTINGS,
4
+ loadCompanionSettings,
5
+ saveCompanionSettings,
6
+ } from "../../companion/settings";
7
+ import { jsonResponse } from "../auth-cors";
8
+ import { openUrl } from "../../lib/open-url";
9
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
10
+ import type { ManagementContext } from "./context";
11
+
12
+ let companionPresence: { lastSeenAt: number; kind: "menuBar" | "desktop" } | null = null;
13
+
14
+ export function resetCompanionPresenceForTests(): void {
15
+ companionPresence = null;
16
+ }
17
+
18
+ function response(): Response {
19
+ const loaded = loadCompanionSettings();
20
+ return jsonResponse({
21
+ settings: loaded.settings,
22
+ updatedAt: loaded.updatedAt,
23
+ defaults: DEFAULT_COMPANION_SETTINGS,
24
+ companion: companionPresence ?? { lastSeenAt: null },
25
+ ...(loaded.corrupt ? { corrupt: true } : {}),
26
+ });
27
+ }
28
+
29
+ export async function handleCompanionRoutes(ctx: ManagementContext): Promise<Response | null> {
30
+ if (ctx.url.pathname === "/api/companion/open-in-browser" && ctx.req.method === "POST") {
31
+ let body: unknown;
32
+ try {
33
+ body = await readManagementJsonBody(ctx.req);
34
+ } catch (error) {
35
+ rethrowManagementBodyTooLarge(error);
36
+ return jsonResponse({ error: "invalid path" }, 400, ctx.req, ctx.config);
37
+ }
38
+ const path = body && typeof body === "object" && !Array.isArray(body)
39
+ ? (body as { path?: unknown }).path
40
+ : undefined;
41
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.length > 512) {
42
+ return jsonResponse({ error: "invalid path" }, 400, ctx.req, ctx.config);
43
+ }
44
+ const url = `http://127.0.0.1:${ctx.config.port}${path}`;
45
+ openUrl(url);
46
+ return jsonResponse({ ok: true, url }, 200, ctx.req, ctx.config);
47
+ }
48
+ if (ctx.url.pathname === "/api/companion/settings" && ctx.req.method === "GET") {
49
+ const userAgent = ctx.req.headers.get("user-agent") ?? "";
50
+ const kind = userAgent.startsWith("OpenCodexMenuBar/") ? "menuBar"
51
+ : userAgent.startsWith("OpenCodexDesktop/") ? "desktop"
52
+ : null;
53
+ if (kind) companionPresence = { lastSeenAt: Date.now(), kind };
54
+ return response();
55
+ }
56
+ if (ctx.url.pathname !== "/api/companion/settings" || ctx.req.method !== "PUT") return null;
57
+ let body: unknown;
58
+ try {
59
+ body = await readManagementJsonBody(ctx.req);
60
+ } catch (error) {
61
+ rethrowManagementBodyTooLarge(error);
62
+ return jsonResponse({ error: "invalid JSON body" }, 400, ctx.req, ctx.config);
63
+ }
64
+ if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid settings body" }, 400, ctx.req, ctx.config);
65
+ const input = body as { reset?: unknown; settings?: unknown };
66
+ if (input.reset === true) {
67
+ saveCompanionSettings(DEFAULT_COMPANION_SETTINGS);
68
+ return response();
69
+ }
70
+ if (!("settings" in input)) return jsonResponse({ error: "provide settings or reset:true" }, 400, ctx.req, ctx.config);
71
+ const current = loadCompanionSettings();
72
+ if (current.corrupt) return jsonResponse({ error: "Companion settings could not be read; use reset:true to replace them explicitly.", code: "companion_settings_corrupt" }, 409, ctx.req, ctx.config);
73
+ const updated = applyCompanionSettingsPatch(current.settings, input.settings);
74
+ if ("error" in updated) return jsonResponse(updated, 400, ctx.req, ctx.config);
75
+ saveCompanionSettings(updated);
76
+ return response();
77
+ }
@@ -22,6 +22,7 @@ import {
22
22
  upsertOAuthProvider,
23
23
  } from "../../oauth";
24
24
  import { removeCredential } from "../../oauth/store";
25
+ import { getFailureProjection } from "../../usage/failure-projection-cache";
25
26
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
27
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
27
28
  import { deriveProviderPresets } from "../../providers/derive";
@@ -179,6 +180,24 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
179
180
  }
180
181
 
181
182
  if (url.pathname === "/api/usage" && req.method === "GET") {
183
+ // A sub-resource on the same route rather than a route of its own. It answers a different
184
+ // question -- which failures keep recurring, rather than what was spent -- and it costs a
185
+ // ledger scan, so it is opt-in: a dashboard asking for the usage summary must not pay for
186
+ // a projection it did not ask for.
187
+ if (url.searchParams.get("failures") === "1") {
188
+ const projection = await getFailureProjection();
189
+ return jsonResponse({
190
+ fingerprintVersion: projection.fingerprintVersion,
191
+ historyIncomplete: projection.historyIncomplete,
192
+ unattributedFailures: projection.unattributedFailures,
193
+ invalidTimestampFailures: projection.invalidTimestampFailures,
194
+ // Every field here is a closed roster member, a count or a timestamp. The scanner's
195
+ // checkpoint identity, the configured provider name, the model and the account label
196
+ // stay internal -- a grouping that promises to carry no content has to keep that
197
+ // promise at its boundary too.
198
+ failures: projection.groups,
199
+ });
200
+ }
182
201
  const range = parseRange(url.searchParams.get("range"));
183
202
  const surface = parseUsageSurface(url.searchParams.get("surface"));
184
203
  let window: UsageTimeWindow | undefined;
@@ -1,3 +1,4 @@
1
+ import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state";
1
2
  /**
2
3
  * Toggle routes for the integrations that are NOT file-merged clients.
3
4
  *
@@ -18,13 +19,22 @@
18
19
  * 011 (Claude Code), 012 (Grok).
19
20
  */
20
21
  import { join } from "node:path";
21
- import { loadConfig, saveConfigPreservingClaudeCode } from "../../config";
22
+ import { loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../../config";
22
23
  import { readRuntimePort } from "../../config/process-state";
23
24
  import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog";
24
25
  import { getCodexHome } from "../../codex/paths";
25
26
  import { providerContextCap } from "../../providers/context-cap";
26
27
  import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
27
28
  import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
29
+ import {
30
+ applyDesktopFirstParty,
31
+ captureDesktopFirstPartyRollback,
32
+ inspectDesktopFirstParty,
33
+ recordClaudeDesktopMode,
34
+ removeDesktopFirstParty,
35
+ resolveClaudeDesktopApplyMode,
36
+ type ClaudeDesktopMode,
37
+ } from "../../claude/desktop-first-party";
28
38
  import { projectGrokCatalog } from "../../grok/catalog";
29
39
  import { injectGrokConfig, stripGrokConfig } from "../../grok/inject";
30
40
  import { inspectGrokConfig } from "../../grok/inspect";
@@ -47,7 +57,9 @@ export type NativeRefusalReason =
47
57
  | "write_failed"
48
58
  | "metadata_unreadable"
49
59
  | "cleanup_incomplete"
50
- | "desired_state_changed";
60
+ | "desired_state_changed"
61
+ | "foreign_env"
62
+ | "intercept_disabled";
51
63
 
52
64
  export interface NativeStatus {
53
65
  clientId: NativeIntegrationClientId;
@@ -126,7 +138,7 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus {
126
138
  const seen = inspectDesktop3pConfigLibrary({
127
139
  appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null,
128
140
  });
129
- const state: NativeStatus["state"] = seen.kind === "gateway_ours"
141
+ const gatewayState: NativeStatus["state"] = seen.kind === "gateway_ours"
130
142
  ? "current"
131
143
  : seen.kind === "unsafe" || seen.kind === "broken" ? "unsafe" : "absent";
132
144
  const disableBlocked = seen.kind === "unsafe" || seen.kind === "broken" || seen.kind === "foreign"
@@ -135,6 +147,14 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus {
135
147
  message: "Claude Desktop configuration cannot be changed safely.",
136
148
  }
137
149
  : null;
150
+ // First-party mode lives in Claude Code's settings.json, not in Desktop's library. A leftover
151
+ // gateway profile still counts as current (it is what Desktop is actually running).
152
+ const firstParty = resolveClaudeDesktopApplyMode(config) === "first-party" && gatewayState === "absent"
153
+ ? inspectDesktopFirstParty(config)
154
+ : null;
155
+ const state: NativeStatus["state"] = firstParty
156
+ ? firstParty.settings.kind === "unreadable" ? "unsafe" : firstParty.applied ? "current" : "absent"
157
+ : gatewayState;
138
158
  return {
139
159
  clientId: "claude-desktop",
140
160
  state,
@@ -599,6 +619,28 @@ async function handleGrokToggle(ctx: ManagementContext): Promise<Response> {
599
619
  }
600
620
  }
601
621
 
622
+ export function firstPartyRefusalMessage(
623
+ reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env",
624
+ path: string,
625
+ ): string {
626
+ switch (reason) {
627
+ case "intercept_disabled":
628
+ return "First-party mode needs the Claude intercept proxy, which is off in this configuration (claudeCode.intercept.enabled / client role). Use gateway mode instead.";
629
+ case "ca_unavailable":
630
+ return `The local intercept certificate could not be created (${path}).`;
631
+ case "unreadable":
632
+ return `Claude Code settings could not be parsed (${path}); nothing was written.`;
633
+ case "foreign_env":
634
+ return `Claude Code settings already set HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${path}); remove them first or use gateway mode.`;
635
+ }
636
+ }
637
+
638
+ /** Record which Desktop mode is applied; `false` when the config file could not be updated. */
639
+ function persistDesktopModeMarker(desktopMode: ClaudeDesktopMode): boolean {
640
+ const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode));
641
+ return outcome.status !== "unavailable";
642
+ }
643
+
602
644
  let claudeDesktopToggleFlight: Promise<Response> | null = null;
603
645
 
604
646
  async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Response> {
@@ -626,6 +668,11 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Respon
626
668
  const fingerprint = current.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
627
669
 
628
670
  if (!body.enabled) {
671
+ const firstPartyRemoved = removeDesktopFirstParty();
672
+ if (!firstPartyRemoved.ok) {
673
+ return postCommitRefusal(409, "claude-desktop", "write_failed",
674
+ `Claude Code settings could not be read (${firstPartyRemoved.path}); the first-party proxy env was left in place.`, { desiredEnabled });
675
+ }
629
676
  const removed = (ctx.deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint: fingerprint });
630
677
  if (removed.kind === "cleanup_incomplete") {
631
678
  return postCommitRefusal(500, "claude-desktop", "cleanup_incomplete",
@@ -636,9 +683,52 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Respon
636
683
  return postCommitRefusal(409, "claude-desktop", removed.reason === "metadata_unreadable" ? "metadata_unreadable" : "write_failed",
637
684
  "Claude Desktop configuration could not be changed safely.", { desiredEnabled });
638
685
  }
686
+ const changed = removed.changed || firstPartyRemoved.changed;
687
+ return jsonResponse({
688
+ ok: true, clientId: "claude-desktop", changed, state: "absent", desiredEnabled,
689
+ message: changed ? "Claude Desktop integration disabled." : "Claude Desktop integration is already off.",
690
+ } satisfies NativeToggleEnvelope);
691
+ }
692
+
693
+ if (resolveClaudeDesktopApplyMode(current) === "first-party") {
694
+ const rollback = captureDesktopFirstPartyRollback(current);
695
+ const applied = applyDesktopFirstParty(current);
696
+ if (!applied.ok) {
697
+ const reason = applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? applied.reason : "write_failed";
698
+ return postCommitRefusal(applied.reason === "unreadable" || applied.reason === "ca_unavailable" ? 500 : 409, "claude-desktop", reason,
699
+ firstPartyRefusalMessage(applied.reason, applied.path), { desiredEnabled });
700
+ }
701
+ const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: fingerprint });
702
+ let gatewayRemoved = false;
703
+ if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") {
704
+ const removed = (ctx.deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint: fingerprint, replaceWhileEnabled: true });
705
+ if (!removed.ok && !removed.changed && applied.changed && !rollback()) {
706
+ return postCommitRefusal(500, "claude-desktop", "write_failed",
707
+ "Gateway cleanup and first-party settings rollback did not complete.", { desiredEnabled });
708
+ }
709
+ const partialModeWarning = !removed.ok && removed.changed && !persistDesktopModeMarker("first-party")
710
+ ? " First-party is active but its mode marker was not saved." : "";
711
+ if (removed.kind === "cleanup_incomplete") {
712
+ return postCommitRefusal(500, "claude-desktop", "cleanup_incomplete",
713
+ "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active." + partialModeWarning,
714
+ { desiredEnabled, residualPaths: removed.residualPaths ?? [] });
715
+ }
716
+ if (!removed.ok) {
717
+ return postCommitRefusal(409, "claude-desktop", removed.reason === "metadata_unreadable" ? "metadata_unreadable" : "write_failed",
718
+ "The gateway profile could not be removed safely; the mode switch is incomplete." + partialModeWarning, { desiredEnabled });
719
+ }
720
+ gatewayRemoved = removed.changed;
721
+ }
722
+ const modeSaved = persistDesktopModeMarker("first-party");
723
+ const changed = applied.changed || gatewayRemoved;
639
724
  return jsonResponse({
640
- ok: true, clientId: "claude-desktop", changed: removed.changed, state: "absent", desiredEnabled,
641
- message: removed.changed ? "Claude Desktop integration disabled." : "Claude Desktop integration is already off.",
725
+ ok: true, clientId: "claude-desktop", changed, state: "current", desiredEnabled,
726
+ message: [
727
+ changed
728
+ ? "Claude Desktop integration enabled (first-party). Fully quit and reopen Claude Desktop."
729
+ : "Claude Desktop integration is already on.",
730
+ modeSaved ? "" : "The first-party mode marker could not be saved to config; status may report the mode as unsaved.",
731
+ ].filter(Boolean).join(" "),
642
732
  } satisfies NativeToggleEnvelope);
643
733
  }
644
734
 
@@ -665,9 +755,16 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Respon
665
755
  nativeContextLimits(latest),
666
756
  );
667
757
  if (!result.written) return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled: latestDesiredEnabled });
758
+ const committed = persistCommittedDesktopGateway(ctx.config, latest.claudeCode?.desktopProfile, result.fingerprint);
759
+ const stateWarning = committed.ok ? "" : " The committed gateway mode/profile state was not saved.";
760
+ const removed = removeDesktopFirstParty();
761
+ if (!removed.ok) return postCommitRefusal(500, "claude-desktop", "write_failed", "Gateway applied, but first-party settings cleanup did not complete." + stateWarning, { desiredEnabled: latestDesiredEnabled });
668
762
  return jsonResponse({
669
763
  ok: true, clientId: "claude-desktop", changed: true, state: "current", desiredEnabled: latestDesiredEnabled,
670
- message: "Claude Desktop integration enabled.",
764
+ message: [
765
+ "Claude Desktop integration enabled.",
766
+ stateWarning,
767
+ ].filter(Boolean).join(" "),
671
768
  } satisfies NativeToggleEnvelope);
672
769
  } catch {
673
770
  return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled });
@@ -210,7 +210,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
210
210
  const { shouldOpenBrowserForLogin } = await import("../../oauth/open-browser-choice");
211
211
  if (authUrl && !deviceCode && shouldOpenBrowserForLogin(body.openBrowser, config)) {
212
212
  const { openUrl } = await import("../../lib/open-url");
213
- openUrl(authUrl);
213
+ void openUrl(authUrl);
214
214
  }
215
215
  return jsonResponse({ url: authUrl, instructions, deviceCode });
216
216
  } catch (err) {
@@ -869,6 +869,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
869
869
  name: k.name,
870
870
  prefix: k.key.slice(0, 17) + "...",
871
871
  createdAt: k.createdAt,
872
+ // Scope is metadata, not secret: an operator has to be able to read
873
+ // what a key may reach without minting a replacement to find out.
874
+ ...(k.allowedProviders ? { allowedProviders: [...k.allowedProviders] } : {}),
875
+ ...(k.allowedModels ? { allowedModels: [...k.allowedModels] } : {}),
872
876
  ...(k.pendingRotation ? { pendingRotation: {
873
877
  id: k.pendingRotation.id,
874
878
  createdAt: k.pendingRotation.createdAt,
@@ -952,15 +956,49 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
952
956
  const body = await readJsonBody(req);
953
957
  if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
954
958
  if (typeof body.id !== "string" || !body.id) return jsonResponse({ error: "id required" }, 400, req, config);
955
- const nameField = validateKeyName(body.name, { required: true });
956
- if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config);
957
- const entry = (config.apiKeys ?? []).find(k => k.id === body.id);
958
- if (!entry) return jsonResponse({ error: "key not found" }, 404, req, config);
959
- entry.name = nameField.value;
959
+ const existing = (config.apiKeys ?? []).find(k => k.id === body.id);
960
+ if (!existing) return jsonResponse({ error: "key not found" }, 404, req, config);
961
+ const entry = { ...existing };
962
+ // Rename and scope are independent edits. A scope-only PATCH must not have
963
+ // to restate the name, and a rename must not silently widen a scope, so
964
+ // each field is applied only when the caller actually sent it.
965
+ const renaming = body.name !== undefined;
966
+ const scopingProviders = body.allowedProviders !== undefined;
967
+ const scopingModels = body.allowedModels !== undefined;
968
+ if (!renaming && !scopingProviders && !scopingModels) {
969
+ return jsonResponse({ error: "name, allowedProviders or allowedModels required" }, 400, req, config);
970
+ }
971
+ if (renaming) {
972
+ const nameField = validateKeyName(body.name, { required: true });
973
+ if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config);
974
+ entry.name = nameField.value;
975
+ }
976
+ for (const [field, sent] of [["allowedProviders", scopingProviders], ["allowedModels", scopingModels]] as const) {
977
+ if (!sent) continue;
978
+ const value = body[field];
979
+ // `null` and `[]` both clear the list back to unrestricted; anything else
980
+ // must be a list of non-empty strings, because a silently ignored malformed
981
+ // scope would read as "allowed everything" to whoever set it.
982
+ if (value === null) { delete entry[field]; continue; }
983
+ if (!Array.isArray(value) || value.some(item => typeof item !== "string" || !item.trim() || item.length > 256)) {
984
+ return jsonResponse({ error: `${field} must be a list of non-empty names` }, 400, req, config);
985
+ }
986
+ const normalized = [...new Set((value as string[]).map(item => item.trim()))];
987
+ if (normalized.length === 0) delete entry[field];
988
+ else entry[field] = normalized;
989
+ }
990
+ // Publish the validated replacement only after every field is accepted.
991
+ config.apiKeys = config.apiKeys!.map(key => key === existing ? entry : key);
960
992
  saveConfigPreservingClaudeCode(config);
961
993
  reconcileLiveStateStores();
962
994
  // Never echo key material from a rename.
963
- return jsonResponse({ id: entry.id, name: entry.name, createdAt: entry.createdAt }, 200, req, config);
995
+ return jsonResponse({
996
+ id: entry.id,
997
+ name: entry.name,
998
+ createdAt: entry.createdAt,
999
+ ...(entry.allowedProviders ? { allowedProviders: [...entry.allowedProviders] } : {}),
1000
+ ...(entry.allowedModels ? { allowedModels: [...entry.allowedModels] } : {}),
1001
+ }, 200, req, config);
964
1002
  }
965
1003
 
966
1004
  if (url.pathname === "/api/keys" && req.method === "DELETE") {
@@ -240,6 +240,12 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
240
240
  { method: "GET", path: "/api/storage/trash", module: "server/management/logs-usage-routes", mutates: false },
241
241
  { method: "GET", path: "/api/storage/trash/restore/test-stream", module: "server/management/logs-usage-routes", mutates: false, exempt: { reason: "test-seam", why: "Opt-in streaming seam declared at src/storage/restore-job.ts:34." } },
242
242
  { method: "GET", path: "/api/usage", module: "server/management/logs-usage-routes", mutates: false },
243
+ // server/management/usage-timeline-routes
244
+ { method: "GET", path: "/api/usage/timeline", module: "server/management/usage-timeline-routes", mutates: false },
245
+ // server/management/companion-routes
246
+ { method: "POST", path: "/api/companion/open-in-browser", module: "server/management/companion-routes", mutates: true, exempt: { reason: "session-only", why: "Dashboard-only navigation helper; the GUI session opens its current view in the system browser, and there is no standalone CLI operation to drive." } },
247
+ { method: "GET", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: false },
248
+ { method: "PUT", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: true },
243
249
  { method: "POST", path: "/api/storage/cleanup", module: "server/management/logs-usage-routes", mutates: true },
244
250
  { method: "POST", path: "/api/storage/cleanup-policy/run", module: "server/management/logs-usage-routes", mutates: true },
245
251
  { method: "POST", path: "/api/storage/cleanup/preview", module: "server/management/logs-usage-routes", mutates: true },