@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import type { CatalogModel } from "../../codex/catalog";
4
4
  import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
@@ -22,8 +22,9 @@ import {
22
22
  startLoginFlow,
23
23
  submitManualLoginCode,
24
24
  } from "../../oauth";
25
- import { removeCredential } from "../../oauth/store";
25
+ import { OAuthMutationBusyError, removeCredential } from "../../oauth/store";
26
26
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
27
+ import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
27
28
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
28
29
  import { deriveProviderPresets } from "../../providers/derive";
29
30
  import { providerCodexAccountMode } from "../../providers/registry";
@@ -59,15 +60,57 @@ import { drainAndShutdown } from "../lifecycle";
59
60
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
60
61
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
61
62
  import type { PersistedUsageAttempt } from "../../usage/log";
62
- import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
63
+ import { AUTH_MATRIX, isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
63
64
  import { applySystemEnvToggle } from "../system-env";
64
65
  import { buildApiAccessEndpoints } from "./api-access";
65
66
 
66
67
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
67
68
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
68
69
  import type { ManagementContext } from "./context";
70
+ import { readManagementJsonBody, readManagementJsonBodyOr, rethrowManagementBodyTooLarge } from "./body";
69
71
  import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
70
72
 
73
+ /**
74
+ * Parses a bounded JSON object body, or null. Malformed JSON is swallowed; an
75
+ * oversized body still throws so the management dispatcher can return 413.
76
+ * a malformed body, which used to surface as a 500 from the key routes.
77
+ */
78
+ async function readJsonBody(req: Request): Promise<Record<string, unknown> | null> {
79
+ try {
80
+ const parsed: unknown = await readManagementJsonBody(req);
81
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
82
+ ? parsed as Record<string, unknown>
83
+ : null;
84
+ } catch (error) {
85
+ rethrowManagementBodyTooLarge(error);
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * The single place key-name rules live. The config read schema is deliberately
92
+ * permissive so an existing config can never become unloadable; this is the write
93
+ * boundary that keeps new junk out. A non-string name used to reach `.trim()` and
94
+ * throw.
95
+ */
96
+ function validateKeyName(
97
+ raw: unknown,
98
+ opts: { required: boolean },
99
+ ): { value: string } | { error: string } {
100
+ if (raw === undefined || raw === null) {
101
+ return opts.required ? { error: "name required" } : { value: "" };
102
+ }
103
+ if (typeof raw !== "string") return { error: "name must be a string" };
104
+ // Check the RAW string: trimming first would silently accept "deploy\n" by
105
+ // deleting the very character being rejected.
106
+ // eslint-disable-next-line no-control-regex
107
+ if (/[\u0000-\u001f\u007f]/.test(raw)) return { error: "invalid name" };
108
+ const value = raw.trim();
109
+ if (opts.required && !value) return { error: "name required" };
110
+ if (value.length > 64) return { error: "name too long" };
111
+ return { value };
112
+ }
113
+
71
114
  export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<Response | null> {
72
115
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
73
116
 
@@ -85,7 +128,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
85
128
  // the provider's loopback callback server (inside this process) captures the redirect in the
86
129
  // background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
87
130
  if (url.pathname === "/api/oauth/login" && req.method === "POST") {
88
- const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
131
+ const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
89
132
  const provider = (body.provider ?? "").trim().toLowerCase();
90
133
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
91
134
  const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
@@ -111,7 +154,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
111
154
  // startLoginFlow returns the authorization URL before background persistence completes.
112
155
  // Three-way reconcile settled disk changes so a failed login cannot leave a provider
113
156
  // live-only and an in-flight management mutation cannot be erased before it saves.
114
- onSettled: () => reconcileLiveConfigFromDisk(config, persistedBaseline),
157
+ onSettled: () => {
158
+ reconcileLiveConfigFromDisk(config, persistedBaseline);
159
+ reconcileLiveStateStores();
160
+ },
115
161
  });
116
162
  if (authUrl && !deviceCode) {
117
163
  // Open the browser server-side (the proxy runs on the user's machine) — the GUI's
@@ -121,6 +167,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
121
167
  }
122
168
  return jsonResponse({ url: authUrl, instructions, deviceCode });
123
169
  } catch (err) {
170
+ if (err instanceof OAuthMutationBusyError) throw err;
124
171
  return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 409);
125
172
  }
126
173
  }
@@ -128,7 +175,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
128
175
  // Cancel an in-progress browser/device OAuth login (GUI "Cancel" / modal close). Guarded by
129
176
  // the same public predicate as /api/oauth/login — only publicly startable flows are cancellable.
130
177
  if (url.pathname === "/api/oauth/login/cancel" && req.method === "POST") {
131
- const body = await req.json().catch(() => ({})) as { provider?: string };
178
+ const body = await readManagementJsonBodyOr(req, {}) as { provider?: string };
132
179
  const provider = (body.provider ?? "").trim().toLowerCase();
133
180
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
134
181
  const { cancelLoginFlow } = await import("../../oauth");
@@ -139,7 +186,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
139
186
  // Manual fallback for browser OAuth: paste the final redirect URL (or authorization code)
140
187
  // when the browser cannot reach the loopback callback (remote/SSH/blocked localhost).
141
188
  if (url.pathname === "/api/oauth/login/code" && req.method === "POST") {
142
- const body = await req.json().catch(() => ({})) as { provider?: string; input?: string; code?: string };
189
+ const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; input?: string; code?: string };
143
190
  const provider = (body.provider ?? "").trim().toLowerCase();
144
191
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
145
192
  const input = typeof body.input === "string" ? body.input : typeof body.code === "string" ? body.code : "";
@@ -161,6 +208,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
161
208
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
162
209
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
163
210
  await removeCredential(provider);
211
+ reconcileLiveStateStores();
164
212
  clearLoginState(provider);
165
213
  // Drop cached/last-good quota rows tied to the removed credential.
166
214
  const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
@@ -223,7 +271,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
223
271
  });
224
272
  }
225
273
  if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") {
226
- const body = await req.json().catch(() => ({})) as { provider?: string; accountId?: string };
274
+ const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; accountId?: string };
227
275
  const provider = (body.provider ?? "").trim().toLowerCase();
228
276
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
229
277
  if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
@@ -253,7 +301,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
253
301
  });
254
302
  }
255
303
  if (url.pathname === "/api/oauth/accounts/pool" && (req.method === "PUT" || req.method === "PATCH")) {
256
- const parsedBody = await req.json().catch(() => ({}));
304
+ const parsedBody = await readManagementJsonBodyOr(req, {});
257
305
  if (!isPlainRecord(parsedBody)) {
258
306
  return jsonResponse({ error: "body must be an object" }, 400);
259
307
  }
@@ -306,6 +354,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
306
354
  ...(stickyLimit !== undefined ? { stickyLimit } : {}),
307
355
  };
308
356
  saveConfigPreservingClaudeCode(config);
357
+ reconcileLiveStateStores();
309
358
  return jsonResponse({
310
359
  ok: true,
311
360
  provider,
@@ -317,7 +366,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
317
366
  });
318
367
  }
319
368
  if (url.pathname === "/api/oauth/accounts/clear-cooldown" && req.method === "POST") {
320
- const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown };
369
+ const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown };
321
370
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
322
371
  const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
323
372
  if (provider !== "anthropic") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 400);
@@ -328,7 +377,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
328
377
  }
329
378
 
330
379
  if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
331
- const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown; alias?: unknown };
380
+ const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown; alias?: unknown };
332
381
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
333
382
  const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
334
383
  const alias = typeof body.alias === "string" ? body.alias.trim() : "";
@@ -348,6 +397,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
348
397
  if (!id) return jsonResponse({ error: "missing id" }, 400);
349
398
  const { removeAccount, getAccountSet } = await import("../../oauth/store");
350
399
  if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404);
400
+ reconcileLiveStateStores();
351
401
  if (provider === "anthropic") {
352
402
  const { clearAnthropicAccountCooldown, clearAnthropicSessionAffinityForAccount } = await import("../../oauth/anthropic-routing");
353
403
  clearAnthropicAccountCooldown(id);
@@ -370,7 +420,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
370
420
  return jsonResponse(listProviderApiKeys(config, name));
371
421
  }
372
422
  if (url.pathname === "/api/providers/keys" && req.method === "POST") {
373
- const body = await req.json().catch(() => ({})) as { name?: string; key?: string; label?: string };
423
+ const body = await readManagementJsonBodyOr(req, {}) as { name?: string; key?: string; label?: string };
374
424
  const name = (body.name ?? "").trim();
375
425
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
376
426
  if (typeof body.key !== "string" || !body.key.trim()) return jsonResponse({ error: "key is required" }, 400);
@@ -386,7 +436,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
386
436
  return jsonResponse({ ok: true, id: result.id }, 201);
387
437
  }
388
438
  if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
389
- const body = await req.json().catch(() => ({})) as { name?: string; id?: string };
439
+ const body = await readManagementJsonBodyOr(req, {}) as { name?: string; id?: string };
390
440
  const name = (body.name ?? "").trim();
391
441
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
392
442
  if (!body.id) return jsonResponse({ error: "missing id" }, 400);
@@ -401,7 +451,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
401
451
  return jsonResponse({ ok: true, name, activeId: body.id });
402
452
  }
403
453
  if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") {
404
- const body = await req.json().catch(() => ({})) as { name?: unknown; id?: unknown; alias?: unknown };
454
+ const body = await readManagementJsonBodyOr(req, {}) as { name?: unknown; id?: unknown; alias?: unknown };
405
455
  const name = typeof body.name === "string" ? body.name.trim() : "";
406
456
  const id = typeof body.id === "string" ? body.id.trim() : "";
407
457
  const alias = typeof body.alias === "string" ? body.alias.trim() : "";
@@ -440,32 +490,71 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
440
490
  requestHost: req.headers.get("host"),
441
491
  requestOrigin: req.headers.get("origin"),
442
492
  });
493
+ const { readApiKeyUsageRollup } = await import("./api-key-usage");
494
+ const { rollup, attributionSince, historyTruncated } = await readApiKeyUsageRollup(keys.map(k => k.id), config.managementUsageMaxReadBytes);
443
495
  return jsonResponse({
444
- keys: keys.map(k => ({ id: k.id, name: k.name, prefix: k.key.slice(0, 8) + "...", createdAt: k.createdAt })),
496
+ // 8 random hex past the fixed `ocx_data_` literal: enough to tell two keys
497
+ // apart in a list, with 128 bits of the tail still unrevealed. Masking only
498
+ // 8 characters showed `ocx_data...` for every key ever generated.
499
+ keys: keys.map(k => ({
500
+ id: k.id,
501
+ name: k.name,
502
+ prefix: k.key.slice(0, 17) + "...",
503
+ createdAt: k.createdAt,
504
+ usage: rollup.get(k.id) ?? { requests7d: 0, totalRequests: 0 },
505
+ })),
506
+ // Dataset-level and singular: it describes the usage log, not any one key.
507
+ ...(attributionSince ? { attributionSince } : {}),
508
+ ...(historyTruncated ? { historyTruncated: true } : {}),
509
+ authMatrix: AUTH_MATRIX,
445
510
  ...endpoints,
446
511
  }, 200, req, config);
447
512
  }
448
513
 
449
514
  if (url.pathname === "/api/keys" && req.method === "POST") {
450
- const body = await req.json() as { name?: string };
451
- const name = (body.name ?? "").trim() || "default";
452
- // Generate key from provider keys hash + random salt
453
- const providerKeys = Object.values(config.providers).map(p => p.apiKey ?? "").filter(Boolean).join("|");
454
- const salt = crypto.randomUUID();
455
- const hashInput = `${providerKeys}|${salt}|${Date.now()}`;
456
- const hashBuf = new Bun.CryptoHasher("sha256").update(hashInput).digest();
457
- const key = "ocx_data_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
458
- const entry = { id: crypto.randomUUID(), name, key, createdAt: new Date().toISOString() };
515
+ const body = await readJsonBody(req);
516
+ if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
517
+ const nameField = validateKeyName(body.name, { required: false });
518
+ if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config);
519
+ const name = nameField.value || "default";
520
+ // A direct random draw. The previous derivation hashed every configured
521
+ // provider API key into the input, which was never needed for uniqueness and
522
+ // made this secret's safety argument depend on string concatenation rather
523
+ // than the RNG. 20 bytes is the same 40 hex characters as before, so nothing
524
+ // that pattern-matches the key shape changes.
525
+ const key = "ocx_data_" + randomBytes(20).toString("hex");
526
+ const entry = { id: randomUUID(), name, key, createdAt: new Date().toISOString() };
459
527
  config.apiKeys = [...(config.apiKeys ?? []), entry];
460
528
  saveConfigPreservingClaudeCode(config);
529
+ reconcileLiveStateStores();
461
530
  return jsonResponse({ id: entry.id, name: entry.name, key: entry.key, createdAt: entry.createdAt }, 201, req, config);
462
531
  }
463
532
 
533
+ if (url.pathname === "/api/keys" && req.method === "PATCH") {
534
+ const body = await readJsonBody(req);
535
+ if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
536
+ if (typeof body.id !== "string" || !body.id) return jsonResponse({ error: "id required" }, 400, req, config);
537
+ const nameField = validateKeyName(body.name, { required: true });
538
+ if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config);
539
+ const entry = (config.apiKeys ?? []).find(k => k.id === body.id);
540
+ if (!entry) return jsonResponse({ error: "key not found" }, 404, req, config);
541
+ entry.name = nameField.value;
542
+ saveConfigPreservingClaudeCode(config);
543
+ reconcileLiveStateStores();
544
+ // Never echo key material from a rename.
545
+ return jsonResponse({ id: entry.id, name: entry.name, createdAt: entry.createdAt }, 200, req, config);
546
+ }
547
+
464
548
  if (url.pathname === "/api/keys" && req.method === "DELETE") {
465
- const body = await req.json() as { id?: string };
466
- if (!body.id) return jsonResponse({ error: "id required" }, 400, req, config);
549
+ const body = await readJsonBody(req);
550
+ if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
551
+ if (typeof body.id !== "string" || !body.id) return jsonResponse({ error: "id required" }, 400, req, config);
552
+ const before = (config.apiKeys ?? []).length;
467
553
  config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id);
554
+ // A stale id must not read as a successful revocation.
555
+ if (config.apiKeys.length === before) return jsonResponse({ error: "key not found" }, 404, req, config);
468
556
  saveConfigPreservingClaudeCode(config);
557
+ reconcileLiveStateStores();
469
558
  return jsonResponse({ success: true }, 200, req, config);
470
559
  }
471
560
  return null;
@@ -23,6 +23,7 @@ import {
23
23
  } from "../../oauth";
24
24
  import { removeCredential } from "../../oauth/store";
25
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
+ import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
26
27
  import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
27
28
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
28
29
  import { deriveProviderPresets } from "../../providers/derive";
@@ -67,6 +68,7 @@ import { applySystemEnvToggle } from "../system-env";
67
68
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
68
69
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
69
70
  import type { ManagementContext } from "./context";
71
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
70
72
 
71
73
  export async function handleProviderRoutes(ctx: ManagementContext): Promise<Response | null> {
72
74
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
@@ -96,7 +98,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
96
98
  // which would re-save the masked keys from GET). Live routing picks it up immediately.
97
99
  if (url.pathname === "/api/providers" && req.method === "POST") {
98
100
  let body: { name?: unknown; provider?: unknown; setDefault?: boolean };
99
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
101
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
100
102
  const name = typeof body.name === "string" ? body.name.trim() : "";
101
103
  const providerError = providerManagementConfigError(name, body.provider);
102
104
  if (providerError) return jsonResponse({ error: providerError }, 400);
@@ -118,6 +120,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
118
120
  const allowBenchmarkAddresses = name === "openai" && isCanonicalOpenAiForwardProvider(prov);
119
121
  const resolvedError = await providerDestinationResolvedError(name, prov, { allowBenchmarkAddresses });
120
122
  if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
123
+ if (body.setDefault !== undefined && typeof body.setDefault !== "boolean") {
124
+ return jsonResponse({ error: "setDefault must be a boolean" }, 400);
125
+ }
126
+ if (body.setDefault === true && prov.disabled) {
127
+ return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
128
+ }
121
129
  // Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
122
130
  // doesn't send — merge it in so the sidecars are gated correctly.
123
131
  enrichProviderFromCatalog(name, prov);
@@ -127,8 +135,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
127
135
  const existingPool = config.providers[name]?.apiKeyPool;
128
136
  if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool;
129
137
  config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
130
- if (body.setDefault) config.defaultProvider = name;
138
+ if (body.setDefault === true) config.defaultProvider = name;
131
139
  save(config);
140
+ reconcileLiveStateStores();
132
141
  if (prov.apiKey && prov.apiKeyPool) {
133
142
  const { addProviderApiKey } = await import("../../providers/api-keys");
134
143
  addProviderApiKey(config, name, prov.apiKey);
@@ -143,10 +152,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
143
152
  const name = url.searchParams.get("name")?.trim();
144
153
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
145
154
  let rawBody: unknown;
146
- try { rawBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
155
+ try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
147
156
  if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider patch body must be a plain object" }, 400);
148
157
  const keys = Object.keys(rawBody);
149
158
  const hasMode = Object.hasOwn(rawBody, "codexAccountMode");
159
+ const hasSetDefault = Object.hasOwn(rawBody, "setDefault");
150
160
 
151
161
  // codexAccountMode keeps its dedicated side-effect path (quota cache clear, thread map
152
162
  // clear, pool prime) and is mutually exclusive with every other patch field.
@@ -166,6 +176,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
166
176
  const { saveConfigPreservingClaudeCode: save } = await import("../../config");
167
177
  config.providers.openai = { ...provider, codexAccountMode: mode };
168
178
  save(config);
179
+ reconcileLiveStateStores();
169
180
  (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
170
181
  (deps.clearThreadAccountMap ?? clearThreadAccountMap)();
171
182
  if (mode === "pool") {
@@ -179,6 +190,23 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
179
190
  return jsonResponse({ success: true, name: "openai", codexAccountMode: mode });
180
191
  }
181
192
 
193
+ // Default-provider changes must be a deliberate, standalone action. This keeps
194
+ // routing changes out of ordinary provider edits and lets the dashboard expose a
195
+ // simple "Set as default" control without round-tripping the full config.
196
+ if (hasSetDefault) {
197
+ if (keys.length !== 1 || rawBody.setDefault !== true) {
198
+ return jsonResponse({ error: "setDefault must be true and cannot be combined with other patch fields" }, 400);
199
+ }
200
+ if (config.providers[name]!.disabled) {
201
+ return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
202
+ }
203
+ const { saveConfigPreservingClaudeCode: save } = await import("../../config");
204
+ config.defaultProvider = name;
205
+ save(config);
206
+ reconcileLiveStateStores();
207
+ return jsonResponse({ success: true, name, defaultProvider: name });
208
+ }
209
+
182
210
  // Field-mask editor: apply recognized fields onto a copy, then validate the MERGED
183
211
  // provider (canonical-seed guard covers openai; local-guard covers registry key providers).
184
212
  // API keys are never writable here — the api-keys endpoints own pool-integrated key writes.
@@ -302,6 +330,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
302
330
  const { saveConfigPreservingClaudeCode: save } = await import("../../config");
303
331
  config.providers[name] = stripRegistryOnlyStaticHeaders(name, next);
304
332
  save(config);
333
+ reconcileLiveStateStores();
305
334
  if (editorTouched) {
306
335
  const { clearModelCache } = await import("../../codex/model-cache");
307
336
  clearModelCache(name);
@@ -417,7 +446,22 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
417
446
  if (url.pathname === "/api/providers" && req.method === "DELETE") {
418
447
  const name = url.searchParams.get("name")?.trim();
419
448
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
420
- if (name === config.defaultProvider) return jsonResponse({ error: "cannot delete the default provider; set another default first" }, 400);
449
+ // Config validation requires a default provider. Reassigning before deletion keeps
450
+ // the persisted config valid and makes removal of the current default a one-step UI
451
+ // operation. Prefer the first remaining *enabled* provider so DELETE cannot leave a
452
+ // disabled default that setDefault / disable already refuse. Object-key order is the
453
+ // documented configuration order and is stable through JSON persistence.
454
+ const fallbackDefault = name === config.defaultProvider
455
+ ? Object.entries(config.providers)
456
+ .find(([provider, providerConfig]) => provider !== name && providerConfig.disabled !== true)
457
+ ?.[0]
458
+ : undefined;
459
+ if (name === config.defaultProvider && !fallbackDefault) {
460
+ return jsonResponse({
461
+ error: "cannot delete the default provider when no enabled replacement remains",
462
+ code: "last_provider",
463
+ }, 409);
464
+ }
421
465
  const dependentCombos = Object.entries(config.combos ?? {})
422
466
  .filter(([, combo]) => combo.targets.some(target => target.provider === name))
423
467
  .map(([id]) => id)
@@ -425,17 +469,20 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
425
469
  if (dependentCombos.length > 0) {
426
470
  return jsonResponse({
427
471
  error: `cannot delete provider "${name}" while combos depend on it`,
472
+ code: "provider_has_dependent_combos",
428
473
  combos: dependentCombos,
429
474
  }, 409);
430
475
  }
431
476
  const { saveConfigPreservingClaudeCode: save } = await import("../../config");
477
+ if (fallbackDefault) config.defaultProvider = fallbackDefault;
432
478
  delete config.providers[name];
433
479
  setProviderContextCap(config, name, false);
434
480
  save(config);
481
+ reconcileLiveStateStores();
435
482
  const { clearModelCache: clearCache } = await import("../../codex/model-cache");
436
483
  clearCache(name);
437
484
  await refreshCodexCatalogBestEffort();
438
- return jsonResponse({ success: true });
485
+ return jsonResponse({ success: true, ...(fallbackDefault ? { defaultProvider: fallbackDefault } : {}) });
439
486
  }
440
487
 
441
488
  if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
@@ -444,7 +491,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
444
491
 
445
492
  if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
446
493
  let body: { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
447
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
494
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
448
495
  const { saveConfigPreservingClaudeCode: save } = await import("../../config");
449
496
  const { clearModelCache } = await import("../../codex/model-cache");
450
497
  const respond = () => jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
@@ -457,6 +504,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
457
504
  const affected = Object.keys(providerContextCaps(config));
458
505
  setGlobalContextCapValue(config, body.value);
459
506
  save(config);
507
+ reconcileLiveStateStores();
460
508
  for (const provider of affected) clearModelCache(provider);
461
509
  await refreshCodexCatalogBestEffort();
462
510
  return respond();
@@ -471,6 +519,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
471
519
  const names = Object.keys(config.providers);
472
520
  setAllProviderContextCaps(config, names, body.setAll);
473
521
  save(config);
522
+ reconcileLiveStateStores();
474
523
  for (const provider of new Set([...before, ...names])) clearModelCache(provider);
475
524
  await refreshCodexCatalogBestEffort();
476
525
  return respond();
@@ -489,6 +538,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
489
538
  }
490
539
  setProviderContextCap(config, provider, body.enabled);
491
540
  save(config);
541
+ reconcileLiveStateStores();
492
542
  clearModelCache(provider);
493
543
  await refreshCodexCatalogBestEffort();
494
544
  return respond();
@@ -214,14 +214,14 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid
214
214
 
215
215
  /** Shared Desktop profile DTO builder for the management API and CLI. */
216
216
  export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) {
217
- const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../../codex/catalog");
217
+ const { filterCatalogVisibleModels, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
218
218
  const { DESKTOP_SUPPORTS_1M_THRESHOLD } = await import("../../claude/desktop-3p");
219
219
  const { reconcileDesktopProfile, renderDesktopProfile } = await import("../../claude/desktop-profile");
220
220
  const routed = filterCatalogVisibleModels(await fetchAllModels(config), config);
221
221
  const profileModels: DesktopProfileModel[] = [
222
222
  // Native rows carry their real context window from the same accessor the Grok sync
223
223
  // uses — otherwise Sol's 372k and gpt-5.5's 272k render as blank on Desktop.
224
- ...visibleNativeSlugs(config).map(id => {
224
+ ...desktopVisibleNativeSlugs(config).map(id => {
225
225
  const contextWindow = nativeOpenAiContextWindow(id);
226
226
  return { route: `native/${id}`, label: `${id} (native)`,
227
227
  ...(contextWindow !== undefined ? { contextWindow } : {}) };
@@ -233,6 +233,19 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla
233
233
  })),
234
234
  ];
235
235
  const profile = reconcileDesktopProfile(stored ?? config.claudeCode?.desktopProfile, profileModels);
236
+ if (config.claudeCode?.desktopNativeModels === false) {
237
+ for (const route of Object.keys(profile.assignments)) {
238
+ if (route.startsWith("native/")) delete profile.assignments[route];
239
+ }
240
+ for (const family of ["opus", "fable", "sonnet", "haiku"] as const) {
241
+ const current = profile.defaults[family];
242
+ if (current?.startsWith("native/")) {
243
+ profile.defaults[family] = Object.keys(profile.assignments)
244
+ .filter(route => profile.assignments[route]?.family === family)
245
+ .sort()[0] ?? null;
246
+ }
247
+ }
248
+ }
236
249
  const available = new Set(profileModels.map(model => model.route));
237
250
  const modelByRoute = new Map(profileModels.map(model => [model.route, model]));
238
251
  // Effort support: routed models with a non-empty reasoningEfforts ladder support effort;
@@ -241,7 +254,7 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla
241
254
  for (const m of routed) {
242
255
  effortByRoute.set(`${m.provider}/${m.id}`, Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length > 0);
243
256
  }
244
- for (const id of visibleNativeSlugs(config)) {
257
+ for (const id of desktopVisibleNativeSlugs(config)) {
245
258
  effortByRoute.set(`native/${id}`, true);
246
259
  }
247
260
  const models = Object.keys(profile.assignments).sort().map(route => ({
@@ -8,10 +8,42 @@
8
8
  * output is ever serialized here — starring runs through the user's own `gh` CLI and
9
9
  * this surface only learns the yes/no answer. `gh` writes the authenticated account
10
10
  * name to stderr, so that output is discarded at the source rather than forwarded.
11
+ *
12
+ * The star POST additionally refuses agent-driven programmatic callers. Management
13
+ * auth proves the caller reached the admin token, not that a person chose to star:
14
+ * a coding agent runs on the user's machine and can read that token from disk, so
15
+ * the CLI's "ask the user" deferral would be bypassable with one `curl` here.
16
+ *
17
+ * The dashboard button must keep working even when the proxy itself was started by
18
+ * an agent, which is the common case — the person is at the browser, not at the
19
+ * spawning shell. A GUI click is therefore distinguished by its browser session
20
+ * evidence (a same-origin `Origin` plus the minted CSRF header, both already
21
+ * verified by the management auth gate) rather than by the proxy's own env.
11
22
  */
12
23
  import { jsonResponse } from "../auth-cors";
24
+ import { agentDrivenMarkers, isAgentDriven } from "../../cli/agent-driven";
13
25
  import type { ManagementContext } from "./context";
14
26
 
27
+ /**
28
+ * True when this request carries the browser-session evidence a dashboard click
29
+ * always has: an `Origin` (only a browser sends one) plus the per-session CSRF
30
+ * token, which `requireManagementAuth` has already matched against the minted
31
+ * session before dispatch. A shell/HTTP caller holding only the admin token has
32
+ * neither, which is exactly the case the agent guard is aimed at.
33
+ */
34
+ function hasBrowserSessionEvidence(req: Request): boolean {
35
+ return !!req.headers.get("Origin")?.trim()
36
+ && !!req.headers.get("x-opencodex-csrf-token")?.trim()
37
+ && !!req.headers.get("x-opencodex-gui-origin")?.trim();
38
+ }
39
+
40
+ // Known edge, deliberately fail-closed: a non-loopback operator dashboard signs in
41
+ // with the raw admin token instead of a minted GUI session, so its clicks carry no
42
+ // CSRF header. If that proxy was *also* started from an agent shell, the button is
43
+ // refused and the response names the one-line `gh` command to run by hand. A
44
+ // service-run proxy (`OCX_SERVICE`, the usual remote setup) is not agent-driven and
45
+ // is unaffected.
46
+
15
47
  export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Response | null> {
16
48
  const { req, url } = ctx;
17
49
 
@@ -21,7 +53,24 @@ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Respo
21
53
  }
22
54
 
23
55
  if (url.pathname === "/api/github/star" && req.method === "POST") {
24
- const { starRepository } = await import("../../github/star-state");
56
+ const { STAR_REPO, STAR_REPO_URL, starRepository } = await import("../../github/star-state");
57
+ // Starring uses the user's GitHub identity, so consent must come from the
58
+ // account owner. An agent-driven caller without browser-session evidence
59
+ // cannot have obtained it, and must relay the question instead of answering
60
+ // it with an HTTP call.
61
+ if (isAgentDriven() && !hasBrowserSessionEvidence(req)) {
62
+ return jsonResponse({
63
+ ok: false,
64
+ state: "not-starred",
65
+ repo: STAR_REPO,
66
+ url: STAR_REPO_URL,
67
+ code: "agent_consent_required",
68
+ message:
69
+ `Refused: agent session detected (${agentDrivenMarkers().slice(0, 3).join(", ")}) and this request `
70
+ + `carries no dashboard session. Starring writes to the user's own GitHub account, so ask the user `
71
+ + `directly and only if they say yes run: gh api -X PUT /user/starred/${STAR_REPO}`,
72
+ }, 403);
73
+ }
25
74
  const result = await starRepository();
26
75
  return jsonResponse({
27
76
  ...result.status,
@@ -6,11 +6,14 @@
6
6
  * recycle to reclaim RSS, not a teardown.
7
7
  *
8
8
  * Respawn policy (matches real supervisor configs in src/service.ts):
9
- * - Supervised child (`OCX_SERVICE=1` + service installed): exit(1) so
9
+ * - Supervised child (`OCX_SERVICE=1` + viable service): exit(1) so
10
10
  * failure-only supervisors (systemd Restart=on-failure, WinSW onfailure,
11
11
  * Task Scheduler ERRORLEVEL loop) bring the proxy back.
12
12
  * - Otherwise: detached `ocx start --port <live>` (bypasses ensure's
13
13
  * codexAutoStart gate), mark recycle so exit cleanup keeps injection, exit(0).
14
+ * Installed-but-stale/missing service assets are NOT treated as supervised —
15
+ * exit(1) would leave the proxy dead with `Service: installed, stale or missing
16
+ * service assets` and a /healthz timeout.
14
17
  * - If detached spawn fails (sync throw or pre-start `error`): exit(1) without
15
18
  * markRecycling — after drain the listen socket is already closed, so a latch
16
19
  * reset cannot recover serving. Clear inherited `OCX_SERVICE` so exit cleanup
@@ -27,7 +30,7 @@ import {
27
30
  markRecyclingForExit,
28
31
  setDraining,
29
32
  } from "../lifecycle";
30
- import { isServiceInstalled } from "../../service";
33
+ import { isServiceViable } from "../../service";
31
34
  import { readRuntimePort } from "../../config";
32
35
 
33
36
  /** Fixed v1 drain window for the memory-card action (not config-driven). */
@@ -35,7 +38,8 @@ export const MEMORY_DRAIN_RESTART_MS = 60_000;
35
38
 
36
39
  export interface SystemRestartIo {
37
40
  drainAndShutdown?: typeof drainAndShutdown;
38
- isServiceInstalled?: () => boolean;
41
+ /** True when a background service can actually respawn this process after exit(1). */
42
+ isServiceViable?: () => boolean;
39
43
  isSupervisedServiceChild?: () => boolean;
40
44
  /** Must resolve only after the replacement process has actually started. */
41
45
  spawnStart?: (port?: number) => void | Promise<void>;
@@ -66,8 +70,11 @@ function resolveListenPort(): number | undefined {
66
70
  return undefined;
67
71
  }
68
72
 
69
- function isSupervisedServiceChild(): boolean {
70
- return process.env.OCX_SERVICE === "1" && isServiceInstalled();
73
+ function isSupervisedServiceChild(io: SystemRestartIo = {}): boolean {
74
+ if (process.env.OCX_SERVICE !== "1") return false;
75
+ // Presence is not enough: stale/missing service assets report installed but will not
76
+ // respawn after exit(1). Dashboard status/recovery must fall through to detached start.
77
+ return (io.isServiceViable ?? isServiceViable)();
71
78
  }
72
79
 
73
80
  /** Stable, path-free spawn failure label for logs (never interpolate err.message). */
@@ -137,7 +144,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
137
144
  schedule(async () => {
138
145
  const drain = io.drainAndShutdown ?? drainAndShutdown;
139
146
  await drain(undefined, MEMORY_DRAIN_RESTART_MS);
140
- const supervised = (io.isSupervisedServiceChild ?? isSupervisedServiceChild)();
147
+ const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
141
148
  if (supervised) {
142
149
  // Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
143
150
  (io.exitProcess ?? ((code: number) => { process.exit(code); }))(1);