@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925

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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -59,7 +59,7 @@ import {
59
59
  setDebugSettings,
60
60
  type DebugFlag,
61
61
  } from "../../lib/debug-settings";
62
- import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
62
+ import type { OcxApiKeyEntry, OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
63
63
  import { drainAndShutdown } from "../lifecycle";
64
64
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
65
65
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
@@ -147,6 +147,45 @@ function validateKeyName(
147
147
  return { value };
148
148
  }
149
149
 
150
+ export type IssuedApiKey = Pick<OcxApiKeyEntry, "id" | "name" | "key" | "createdAt">;
151
+
152
+ /** Shared one-time data-key issuance used by the dashboard and link transactions. */
153
+ export function issueApiKeyInProcess(config: OcxConfig, name: string): IssuedApiKey {
154
+ const checked = validateKeyName(name, { required: true });
155
+ if ("error" in checked) throw new Error(checked.error);
156
+ const entry: IssuedApiKey = {
157
+ id: randomUUID(),
158
+ name: checked.value,
159
+ key: `ocx_data_${randomBytes(20).toString("hex")}`,
160
+ createdAt: new Date().toISOString(),
161
+ };
162
+ const previous = config.apiKeys;
163
+ config.apiKeys = [...(previous ?? []), entry];
164
+ try {
165
+ saveConfigPreservingClaudeCode(config);
166
+ reconcileLiveStateStores();
167
+ } catch (error) {
168
+ config.apiKeys = previous;
169
+ throw error;
170
+ }
171
+ return entry;
172
+ }
173
+
174
+ /** Revoke and persist one data key; callers can retain their own record on false. */
175
+ export function revokeApiKeyInProcess(config: OcxConfig, id: string): boolean {
176
+ const before = config.apiKeys ?? [];
177
+ if (!before.some(key => key.id === id)) return false;
178
+ config.apiKeys = before.filter(key => key.id !== id);
179
+ try {
180
+ saveConfigPreservingClaudeCode(config);
181
+ reconcileLiveStateStores();
182
+ } catch (error) {
183
+ config.apiKeys = before;
184
+ throw error;
185
+ }
186
+ return true;
187
+ }
188
+
150
189
  function metaMuseConsentRequired(provider: string, principal: ManagementContext["principal"]): Response | null {
151
190
  if (provider !== "meta-muse" || principal === "gui-session") return null;
152
191
  return jsonResponse({
@@ -955,16 +994,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
955
994
  const nameField = validateKeyName(body.name, { required: false });
956
995
  if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config);
957
996
  const name = nameField.value || "default";
958
- // A direct random draw. The previous derivation hashed every configured
959
- // provider API key into the input, which was never needed for uniqueness and
960
- // made this secret's safety argument depend on string concatenation rather
961
- // than the RNG. 20 bytes is the same 40 hex characters as before, so nothing
962
- // that pattern-matches the key shape changes.
963
- const key = "ocx_data_" + randomBytes(20).toString("hex");
964
- const entry = { id: randomUUID(), name, key, createdAt: new Date().toISOString() };
965
- config.apiKeys = [...(config.apiKeys ?? []), entry];
966
- saveConfigPreservingClaudeCode(config);
967
- reconcileLiveStateStores();
997
+ const entry = issueApiKeyInProcess(config, name); // shared helper uses randomBytes(20)
968
998
  return jsonResponse({ id: entry.id, name: entry.name, key: entry.key, createdAt: entry.createdAt }, 201, req, config);
969
999
  }
970
1000
 
@@ -1021,12 +1051,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
1021
1051
  const body = await readJsonBody(req);
1022
1052
  if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
1023
1053
  if (typeof body.id !== "string" || !body.id) return jsonResponse({ error: "id required" }, 400, req, config);
1024
- const before = (config.apiKeys ?? []).length;
1025
- config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id);
1026
1054
  // A stale id must not read as a successful revocation.
1027
- if (config.apiKeys.length === before) return jsonResponse({ error: "key not found" }, 404, req, config);
1028
- saveConfigPreservingClaudeCode(config);
1029
- reconcileLiveStateStores();
1055
+ if (!revokeApiKeyInProcess(config, body.id)) return jsonResponse({ error: "key not found" }, 404, req, config);
1030
1056
  return jsonResponse({ success: true }, 200, req, config);
1031
1057
  }
1032
1058
  return null;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Protocol vocabulary and request-path preview for the dashboard.
3
+ *
4
+ * Loaded on demand from src/server/management-api.ts, like the other optional namespaces:
5
+ * the planner reaches the router and the ingress eligibility rules, and a static import
6
+ * would put them on every dashboard request.
7
+ *
8
+ * GET (optionally with `?provider=<name>`, src/protocols/provider-summary.ts) and the plan
9
+ * preview are read-only. The preview is computed from config alone
10
+ * (src/protocols/plan-snapshot.ts): it sends nothing upstream, advances no combo state, and
11
+ * never logs its input. PATCH /api/protocols/settings is the one writer; it validates in
12
+ * src/server/management/protocol-settings-patch.ts, persists through the locked
13
+ * saveConfigPreservingClaudeCode, and answers with the fresh GET shape. Authentication is
14
+ * inherited from the management chain.
15
+ */
16
+ import { jsonResponse } from "../auth-cors";
17
+ import { isProtocol, PROTOCOL_CONTRACT_VERSION } from "../../protocols/contract";
18
+ import { isProtocolFeature, PROTOCOL_FEATURES, type ProtocolFeature } from "../../protocols/features";
19
+ import { previewProtocolPlan, type ProtocolPlanRequest } from "../../protocols/plan-snapshot";
20
+ import { buildProtocolProviderSummary } from "../../protocols/provider-summary";
21
+ import { protocolPolicyRevision, resolveApiSurfaceSettings, resolveProtocolSettings } from "../../protocols/settings";
22
+ import type { OcxConfig } from "../../types";
23
+ import type { ManagementContext } from "./context";
24
+ import { readManagementJsonBodyOr } from "./body";
25
+ import {
26
+ applyProtocolSettingsPatch,
27
+ parseProtocolSettingsPatch,
28
+ restoreProtocolSettings,
29
+ snapshotProtocolSettings,
30
+ } from "./protocol-settings-patch";
31
+
32
+ export const PROTOCOL_PLAN_LIMITS = { modelLength: 200, features: 24 } as const;
33
+ export const PROTOCOL_PROVIDER_QUERY_LIMIT = 200;
34
+
35
+ const PLAN_BODY_KEYS = new Set(["model", "inbound", "features"]);
36
+ const INVALID_BODY = Symbol("invalid-body");
37
+
38
+ type ParsedPlanBody = { ok: true; request: ProtocolPlanRequest } | { ok: false; code: string; message: string };
39
+
40
+ function invalid(code: string, message: string): ParsedPlanBody {
41
+ return { ok: false, code, message };
42
+ }
43
+
44
+ /** Validate a plan request body. Messages name the field, never echo its value. */
45
+ export function parseProtocolPlanBody(body: unknown): ParsedPlanBody {
46
+ if (!body || typeof body !== "object" || Array.isArray(body)) return invalid("invalid_body", "body must be a JSON object");
47
+ const record = body as Record<string, unknown>;
48
+ for (const key of Object.keys(record)) {
49
+ if (!PLAN_BODY_KEYS.has(key)) return invalid("unknown_field", "body accepts only model, inbound and features");
50
+ }
51
+ const model = typeof record.model === "string" ? record.model.trim() : "";
52
+ if (!model || model.length > PROTOCOL_PLAN_LIMITS.modelLength || /[\u0000-\u001f\u007f]/.test(model)) {
53
+ return invalid("invalid_model", `model must be a non-empty string of at most ${PROTOCOL_PLAN_LIMITS.modelLength} characters`);
54
+ }
55
+ if (!isProtocol(record.inbound)) return invalid("invalid_inbound", "inbound must be responses, chat or messages");
56
+ let features: ProtocolFeature[] = [];
57
+ if (record.features !== undefined) {
58
+ if (!Array.isArray(record.features) || record.features.length > PROTOCOL_PLAN_LIMITS.features) {
59
+ return invalid("invalid_features", `features must be an array of at most ${PROTOCOL_PLAN_LIMITS.features} entries`);
60
+ }
61
+ if (!record.features.every(isProtocolFeature)) return invalid("invalid_features", "features contains an unknown feature");
62
+ features = [...new Set(record.features)];
63
+ }
64
+ return { ok: true, request: { model, inbound: record.inbound, features } };
65
+ }
66
+
67
+ function protocolInfo(config: OcxConfig) {
68
+ return {
69
+ schemaVersion: 1,
70
+ contractVersion: PROTOCOL_CONTRACT_VERSION,
71
+ policyRevision: protocolPolicyRevision(config),
72
+ surfaces: resolveApiSurfaceSettings(config),
73
+ settings: resolveProtocolSettings(config),
74
+ features: PROTOCOL_FEATURES,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * `GET /api/protocols?provider=<name>`: the usual body plus the provider's wire summary. The
80
+ * name is bounded and must name a configured provider; neither error echoes it back.
81
+ */
82
+ function protocolInfoForProvider(ctx: ManagementContext, values: string[]): Response {
83
+ const { req, config } = ctx;
84
+ const name = values.length === 1 ? values[0]!.trim() : "";
85
+ if (!name || name.length > PROTOCOL_PROVIDER_QUERY_LIMIT || /[\u0000-\u001f\u007f]/.test(name)) {
86
+ const message = `provider must be one non-empty name of at most ${PROTOCOL_PROVIDER_QUERY_LIMIT} characters`;
87
+ return jsonResponse({ error: { code: "invalid_provider", message } }, 400, req, config);
88
+ }
89
+ const provider = buildProtocolProviderSummary(config, name);
90
+ if (!provider) return jsonResponse({ error: { code: "unknown_provider", message: "no provider with that name" } }, 404, req, config);
91
+ return jsonResponse({ ...protocolInfo(config), provider }, 200, req, config);
92
+ }
93
+
94
+ /** Only SQLITE_BUSY is contention worth retrying; any other lock failure repeats forever. */
95
+ function isConfigLockContention(error: unknown): boolean {
96
+ if (!error || typeof error !== "object") return false;
97
+ if ((error as { code?: unknown }).code !== "CONFIG_MUTATION_LOCK_UNAVAILABLE") return false;
98
+ return (error as { cause?: { code?: unknown } }).cause?.code === "SQLITE_BUSY";
99
+ }
100
+
101
+ async function patchProtocolSettings(ctx: ManagementContext): Promise<Response> {
102
+ const { req, config } = ctx;
103
+ const body = await readManagementJsonBodyOr(req, INVALID_BODY);
104
+ const parsed = body === INVALID_BODY
105
+ ? { ok: false as const, code: "invalid_json", message: "body must be valid JSON" }
106
+ : parseProtocolSettingsPatch(body);
107
+ if (!parsed.ok) return jsonResponse({ error: { code: parsed.code, message: parsed.message } }, 400, req, config);
108
+
109
+ const snapshot = snapshotProtocolSettings(config);
110
+ const applied = applyProtocolSettingsPatch(config, parsed.patch);
111
+ if (!applied.ok) {
112
+ restoreProtocolSettings(config, snapshot);
113
+ return jsonResponse({ error: { code: applied.code, message: applied.message } }, 400, req, config);
114
+ }
115
+ // `deps.` first: route tests with an in-memory fixture must never write the real config.
116
+ const persist = ctx.deps.saveConfigPreservingClaudeCode
117
+ ?? (await import("../../config")).saveConfigPreservingClaudeCode;
118
+ try {
119
+ persist(config);
120
+ } catch (error) {
121
+ // Undo in memory too: a live config that serves a state the file does not hold would
122
+ // reopen (or keep closed) the surface only until the next restart.
123
+ restoreProtocolSettings(config, snapshot);
124
+ return isConfigLockContention(error)
125
+ ? jsonResponse({ error: { code: "config_busy", message: "Another process is saving the configuration. Try again in a moment." } }, 409, req, config)
126
+ : jsonResponse({ error: { code: "write_failed", message: "The configuration could not be saved." } }, 500, req, config);
127
+ }
128
+ // Closing Messages also turned the Claude integration off; prune its agent definitions the
129
+ // way the Claude page toggle does.
130
+ if (applied.claudeCodeChanged) await ctx.syncClaudeAgentDefsBestEffort?.();
131
+ return jsonResponse(protocolInfo(config), 200, req, config);
132
+ }
133
+
134
+ export async function handleProtocolRoutes(ctx: ManagementContext): Promise<Response | null> {
135
+ const { url, req, config } = ctx;
136
+
137
+ if (url.pathname === "/api/protocols") {
138
+ if (req.method !== "GET") return null;
139
+ if (!url.searchParams.has("provider")) return jsonResponse(protocolInfo(config), 200, req, config);
140
+ return protocolInfoForProvider(ctx, url.searchParams.getAll("provider"));
141
+ }
142
+
143
+ if (url.pathname === "/api/protocols/settings") {
144
+ if (req.method !== "PATCH") return null;
145
+ return patchProtocolSettings(ctx);
146
+ }
147
+
148
+ if (url.pathname === "/api/protocols/plan") {
149
+ if (req.method !== "POST") return null;
150
+ const body = await readManagementJsonBodyOr(req, INVALID_BODY);
151
+ const parsed = body === INVALID_BODY ? invalid("invalid_json", "body must be valid JSON") : parseProtocolPlanBody(body);
152
+ if (!parsed.ok) {
153
+ return jsonResponse({ error: { code: parsed.code, message: parsed.message } }, 400, req, config);
154
+ }
155
+ return jsonResponse(previewProtocolPlan(config, parsed.request), 200, req, config);
156
+ }
157
+
158
+ return null;
159
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Validation and in-memory application of `PATCH /api/protocols/settings`.
3
+ *
4
+ * Pure with respect to I/O: the route persists through `saveConfigPreservingClaudeCode` and
5
+ * restores the snapshot taken here when the save fails, so a refused write never leaves the
6
+ * live config serving a state the file does not hold.
7
+ *
8
+ * The one asymmetric rule is the Messages surface. Closing it writes
9
+ * `apiSurfaces.messages.enabled = false` AND `claudeCode.enabled = false` in the same save:
10
+ * a binary older than `apiSurfaces` reads only `claudeCode.enabled`, so a rollback after a
11
+ * close must still find the endpoint closed. Opening writes only the explicit surface value;
12
+ * an older binary then keeps reading `claudeCode.enabled`, which errs closed.
13
+ */
14
+ import { commitClaudeCodeBlock } from "../../claude/claude-code-block";
15
+ import type { ProtocolRolloutSettings, UnrepresentablePolicy } from "../../protocols/settings";
16
+ import type { OcxConfig } from "../../types";
17
+
18
+ export interface ProtocolSettingsPatch {
19
+ messagesEnabled?: boolean;
20
+ unrepresentable?: UnrepresentablePolicy;
21
+ rollout?: Partial<ProtocolRolloutSettings>;
22
+ }
23
+
24
+ export type ParsedProtocolSettingsPatch =
25
+ | { ok: true; patch: ProtocolSettingsPatch }
26
+ | { ok: false; code: string; message: string };
27
+
28
+ const PATCH_KEYS = new Set(["messagesEnabled", "unrepresentable", "rollout"]);
29
+ export const PROTOCOL_ROLLOUT_KEYS = [
30
+ "nativeChatCombos",
31
+ "managedMessagesNative",
32
+ "managedMessagesNativeOAuth",
33
+ "directEncoders",
34
+ "shadowPlan",
35
+ ] as const satisfies readonly (keyof ProtocolRolloutSettings)[];
36
+ const ROLLOUT_KEYS = new Set<string>(PROTOCOL_ROLLOUT_KEYS);
37
+
38
+ type Rec = Record<string, unknown>;
39
+ function isRec(value: unknown): value is Rec {
40
+ return !!value && typeof value === "object" && !Array.isArray(value);
41
+ }
42
+
43
+ function invalid(code: string, message: string): ParsedProtocolSettingsPatch {
44
+ return { ok: false, code, message };
45
+ }
46
+
47
+ /** Strict: unknown keys and wrong types are refused; messages name the field, never the value. */
48
+ export function parseProtocolSettingsPatch(body: unknown): ParsedProtocolSettingsPatch {
49
+ if (!isRec(body)) return invalid("invalid_body", "body must be a JSON object");
50
+ const keys = Object.keys(body);
51
+ if (keys.length === 0) return invalid("empty_body", "body must set messagesEnabled, unrepresentable or rollout");
52
+ for (const key of keys) {
53
+ if (!PATCH_KEYS.has(key)) return invalid("unknown_field", "body accepts only messagesEnabled, unrepresentable and rollout");
54
+ }
55
+ const patch: ProtocolSettingsPatch = {};
56
+ if (body.messagesEnabled !== undefined) {
57
+ if (typeof body.messagesEnabled !== "boolean") return invalid("invalid_messages_enabled", "messagesEnabled must be a boolean");
58
+ patch.messagesEnabled = body.messagesEnabled;
59
+ }
60
+ if (body.unrepresentable !== undefined) {
61
+ if (body.unrepresentable !== "legacy" && body.unrepresentable !== "reject") {
62
+ return invalid("invalid_unrepresentable", "unrepresentable must be \"legacy\" or \"reject\"");
63
+ }
64
+ patch.unrepresentable = body.unrepresentable;
65
+ }
66
+ if (body.rollout !== undefined) {
67
+ if (!isRec(body.rollout)) return invalid("invalid_rollout", "rollout must be an object");
68
+ const rollout: Partial<ProtocolRolloutSettings> = {};
69
+ for (const [key, value] of Object.entries(body.rollout)) {
70
+ if (!ROLLOUT_KEYS.has(key)) return invalid("unknown_rollout_field", `rollout accepts only ${PROTOCOL_ROLLOUT_KEYS.join(", ")}`);
71
+ if (typeof value !== "boolean") return invalid("invalid_rollout", "rollout values must be booleans");
72
+ rollout[key as keyof ProtocolRolloutSettings] = value;
73
+ }
74
+ patch.rollout = rollout;
75
+ }
76
+ return { ok: true, patch };
77
+ }
78
+
79
+ /** The config subtrees a patch may touch, captured so a failed save can be undone. */
80
+ export interface ProtocolSettingsSnapshot {
81
+ apiSurfaces: unknown;
82
+ protocols: unknown;
83
+ claudeCode: unknown;
84
+ }
85
+
86
+ export function snapshotProtocolSettings(config: OcxConfig): ProtocolSettingsSnapshot {
87
+ return {
88
+ apiSurfaces: structuredClone(config.apiSurfaces),
89
+ protocols: structuredClone(config.protocols),
90
+ claudeCode: structuredClone(config.claudeCode),
91
+ };
92
+ }
93
+
94
+ export function restoreProtocolSettings(config: OcxConfig, snapshot: ProtocolSettingsSnapshot): void {
95
+ const target = config as unknown as Rec;
96
+ for (const key of ["apiSurfaces", "protocols", "claudeCode"] as const) {
97
+ if (snapshot[key] === undefined) delete target[key];
98
+ else target[key] = snapshot[key];
99
+ }
100
+ }
101
+
102
+ export type ApplyProtocolSettingsResult =
103
+ | { ok: true; claudeCodeChanged: boolean }
104
+ | { ok: false; code: string; message: string };
105
+
106
+ /**
107
+ * Apply a validated patch to the live config. Checks that depend on the merged state run
108
+ * before anything is written, so a refusal leaves the config untouched.
109
+ */
110
+ export function applyProtocolSettingsPatch(config: OcxConfig, patch: ProtocolSettingsPatch): ApplyProtocolSettingsResult {
111
+ const protocols: Rec = isRec(config.protocols) ? { ...config.protocols } : {};
112
+ if (patch.rollout) {
113
+ const rollout: Rec = { ...(isRec(protocols.rollout) ? protocols.rollout : {}), ...patch.rollout };
114
+ // The OAuth extension is meaningless without the key-auth lane it extends; the resolver
115
+ // would silently read it as off, so refuse turning it on alone instead of storing a switch
116
+ // that does nothing. Turning the key-auth lane off leaves the extension inert, not wrong.
117
+ if (patch.rollout.managedMessagesNativeOAuth === true && rollout.managedMessagesNative !== true) {
118
+ return {
119
+ ok: false,
120
+ code: "rollout_dependency",
121
+ message: "rollout.managedMessagesNativeOAuth requires rollout.managedMessagesNative",
122
+ };
123
+ }
124
+ protocols.rollout = rollout;
125
+ }
126
+ if (patch.unrepresentable !== undefined) protocols.unrepresentable = patch.unrepresentable;
127
+ if (patch.unrepresentable !== undefined || patch.rollout) {
128
+ config.protocols = protocols as OcxConfig["protocols"];
129
+ }
130
+
131
+ let claudeCodeChanged = false;
132
+ if (patch.messagesEnabled !== undefined) {
133
+ // A malformed block is replaced, not merged: the explicit value is what the operator asked for.
134
+ const surfaces: Rec = isRec(config.apiSurfaces) ? { ...config.apiSurfaces } : {};
135
+ const messages: Rec = isRec(surfaces.messages) ? { ...surfaces.messages } : {};
136
+ messages.enabled = patch.messagesEnabled;
137
+ surfaces.messages = messages;
138
+ config.apiSurfaces = surfaces as OcxConfig["apiSurfaces"];
139
+ if (!patch.messagesEnabled && config.claudeCode?.enabled !== false) {
140
+ commitClaudeCodeBlock(config, { ...(config.claudeCode ?? {}), enabled: false });
141
+ claudeCodeChanged = true;
142
+ }
143
+ }
144
+ return { ok: true, claudeCodeChanged };
145
+ }
@@ -0,0 +1,61 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import { withConfigMutationLockSync } from "../../config/mutation-lock";
3
+ import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
4
+ import { ConfigWritePublishedError } from "../../config/persist-unlocked";
5
+
6
+ /**
7
+ * Persistence rebases nested records and arrays in place before writing. Keep their
8
+ * identities and descriptors so a failed write also restores references held by routing.
9
+ * Only plain config containers are traversed; opaque runtime values and accessors are
10
+ * retained as descriptors, never cloned or invoked while taking the snapshot.
11
+ */
12
+ function captureConfigGraphRollback(config: OcxConfig): () => void {
13
+ const snapshots = new Map<object, PropertyDescriptorMap>();
14
+ const visit = (value: unknown): void => {
15
+ if (value === null || typeof value !== "object" || snapshots.has(value)) return;
16
+ const prototype = Object.getPrototypeOf(value);
17
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return;
18
+ const descriptors = Object.getOwnPropertyDescriptors(value);
19
+ snapshots.set(value, descriptors);
20
+ for (const key of Reflect.ownKeys(descriptors)) {
21
+ const descriptor = descriptors[key as keyof typeof descriptors]!;
22
+ if ("value" in descriptor) visit(descriptor.value);
23
+ }
24
+ };
25
+ visit(config);
26
+ const restoreProvenance = captureConfigTopLevelRollback(config, []);
27
+ return () => {
28
+ for (const [value, descriptors] of snapshots) {
29
+ const keys = Reflect.ownKeys(value);
30
+ const originalKeys = Reflect.ownKeys(descriptors);
31
+ // Re-defining a deleted provider appends it, changing route tie-breaking.
32
+ // Rebuild record properties in snapshot order; array indices/length retain
33
+ // their descriptor-based restoration and every container keeps its identity.
34
+ const reordered = !Array.isArray(value)
35
+ && (keys.length !== originalKeys.length || keys.some((key, index) => key !== originalKeys[index]));
36
+ for (const key of keys) {
37
+ if (reordered || !Object.hasOwn(descriptors, key)) Reflect.deleteProperty(value, key);
38
+ }
39
+ Object.defineProperties(value, descriptors);
40
+ }
41
+ restoreProvenance();
42
+ };
43
+ }
44
+
45
+ /** No await may separate mutation, persistence, and rollback inside this lock. */
46
+ export function commitProviderPatch(
47
+ config: OcxConfig,
48
+ mutate: () => void,
49
+ save: (config: OcxConfig) => void,
50
+ ): void {
51
+ withConfigMutationLockSync(() => {
52
+ const rollback = captureConfigGraphRollback(config);
53
+ try {
54
+ mutate();
55
+ save(config);
56
+ } catch (error) {
57
+ if (!(error instanceof ConfigWritePublishedError)) rollback();
58
+ throw error;
59
+ }
60
+ });
61
+ }
@@ -36,6 +36,7 @@ import {
36
36
  submitManualLoginCode,
37
37
  upsertOAuthProvider,
38
38
  } from "../../oauth";
39
+ import { commitProviderPatch } from "./provider-patch-transaction";
39
40
  import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
40
41
  import { canonicalAutoReviewModelKey, mergeModelPinnedEfforts, modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError } from "../../config/provider-validation";
41
42
  import { replaceProviderAccountSet } from "../../oauth/store";
@@ -73,6 +74,7 @@ import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaRepo
73
74
  import { getCachedProviderRoutingQuota } from "../../providers/quota-routing-cache";
74
75
  import { PROVIDER_QUOTA_MAX_AGE_MS, type ProviderRoutingQuota } from "../../providers/quota-types";
75
76
  import { cachedProviderQuotaIsExhausted } from "../../combos/resolve";
77
+ import { resolveJevDecision } from "../../combos/jev";
76
78
  import { clearKeyCooldowns, forgetApiKeyRotationCursor } from "../../providers/key-failover";
77
79
  import { providerRequestPacingStatus } from "../../providers/request-pacing";
78
80
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
@@ -1423,9 +1425,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1423
1425
  if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
1424
1426
  return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
1425
1427
  }
1426
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
1427
- config.providers.openai = { ...provider, codexAccountMode: mode };
1428
- save(config);
1428
+ commitProviderPatch(config, () => {
1429
+ config.providers.openai = { ...provider, codexAccountMode: mode };
1430
+ }, deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode);
1429
1431
  reconcileLiveStateStores();
1430
1432
  (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
1431
1433
  (deps.clearThreadAccountMap ?? clearThreadAccountMap)();
@@ -1450,9 +1452,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1450
1452
  if (config.providers[name]!.disabled) {
1451
1453
  return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
1452
1454
  }
1453
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
1454
- config.defaultProvider = name;
1455
- save(config);
1455
+ commitProviderPatch(config, () => {
1456
+ config.defaultProvider = name;
1457
+ }, deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode);
1456
1458
  reconcileLiveStateStores();
1457
1459
  return jsonResponse({ success: true, name, defaultProvider: name });
1458
1460
  }
@@ -1557,19 +1559,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1557
1559
  const validation = validateConfigCandidate({ ...config, providers: { ...config.providers, [name]: candidate } });
1558
1560
  if (!validation.ok) { replayError = validation.error; return; }
1559
1561
  }
1560
- const previous = Object.getOwnPropertyDescriptor(config.providers, name);
1561
- const rollback = pinsTouched ? captureConfigTopLevelRollback(config, []) : undefined;
1562
- try {
1562
+ commitProviderPatch(config, () => {
1563
1563
  config.providers[name] = candidate;
1564
- (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config);
1565
- } catch (error) {
1566
- if (rollback) {
1567
- if (previous) Object.defineProperty(config.providers, name, previous);
1568
- else delete config.providers[name];
1569
- rollback();
1570
- }
1571
- throw error;
1572
- }
1564
+ }, deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode);
1573
1565
  });
1574
1566
  if (replayError !== undefined) return jsonResponse({ error: replayError }, 409);
1575
1567
  reconcileLiveStateStores();
@@ -1611,6 +1603,35 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1611
1603
  message: "Passthrough provider is configured (forwards your Codex login; no upstream /models).",
1612
1604
  });
1613
1605
  }
1606
+ if (name === "jev" && providerMatchesRegistryTransport(name, prov)) {
1607
+ const probe = { targetKey: "jev/probe", effort: null } as const;
1608
+ const decision = await resolveJevDecision({
1609
+ body: { input: "Verify the configured TypeSafe JEV decision service." },
1610
+ candidates: [{
1611
+ key: probe.targetKey,
1612
+ provider: "jev",
1613
+ model: "jev-latest",
1614
+ reasoningEfforts: [],
1615
+ }],
1616
+ fallback: probe,
1617
+ config,
1618
+ signal: req.signal,
1619
+ });
1620
+ if (decision.gate === "apply") {
1621
+ return jsonResponse({
1622
+ ok: true,
1623
+ latencyMs: decision.latencyMs,
1624
+ message: "Connected. TypeSafe JEV answered a decision probe.",
1625
+ });
1626
+ }
1627
+ return jsonResponse({
1628
+ ok: false,
1629
+ latencyMs: decision.latencyMs,
1630
+ error: decision.gate === "missing_key"
1631
+ ? "TypeSafe JEV API key is not configured"
1632
+ : `TypeSafe JEV decision probe failed (${decision.gate})`,
1633
+ });
1634
+ }
1614
1635
  if (prov.liveModels === false) {
1615
1636
  // A static catalog has no live discovery endpoint to test. This is neither
1616
1637
  // positive connectivity evidence nor an outage, and it must stay before
@@ -337,6 +337,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
337
337
  // server/management/quota-reset-routes
338
338
  { method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false, mechanism: "negated-guard" },
339
339
  // server/management/workflow-budget-routes
340
+ { method: "GET", path: "/api/protocols", module: "server/management/protocol-routes", mutates: false },
341
+ { method: "POST", path: "/api/protocols/plan", module: "server/management/protocol-routes", mutates: false },
342
+ { method: "PATCH", path: "/api/protocols/settings", module: "server/management/protocol-routes", mutates: true },
340
343
  { method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
341
344
  { method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
342
345
  // server/management/request-history-routes
@@ -363,6 +366,15 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
363
366
  { method: "POST", path: "/api/storage/codex-logs/protect", module: "server/management/storage-log-guard-routes", mutates: true },
364
367
  { method: "POST", path: "/api/storage/codex-logs/repair", module: "server/management/storage-log-guard-routes", mutates: true },
365
368
  { method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true },
369
+ // server/management/link-routes
370
+ { method: "GET", path: "/api/link/status", module: "server/management/link-routes", mutates: false },
371
+ { method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard pairing surface and are withheld from admin-token and Tailscale identity sessions." } },
372
+ { method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive pairing consent flow." } },
373
+ { method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the paired dashboard session that saw the fingerprint." } },
374
+ { method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client." } },
375
+ { method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires the paired dashboard session." } },
376
+ { method: "DELETE", path: "/api/link/{id}", module: "server/management/link-routes", mutates: true, mechanism: "regex" },
377
+ { method: "POST", path: "/api/link/issue", module: "server/management/link-routes", mutates: true },
366
378
  // server/management/remote-workspace-routes
367
379
  { method: "GET", path: "/api/remote-workspace", module: "server/management/remote-workspace-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The first Remote Workspace slice exposes Hub status through the authenticated dashboard; a distinct CLI Hub-status verb is still owed and must not be confused with the Executor-local status command.", owner: "remote-workspace-cli-followup", ownerDoc: "docs-site/src/content/docs/reference/management-api.md" } },
368
380
  { method: "GET", path: "/api/remote-workspace/runtimes", module: "server/management/remote-workspace-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The first Remote Workspace slice exposes Hub runtime availability through the authenticated dashboard; a distinct CLI Hub-status verb is still owed.", owner: "remote-workspace-cli-followup", ownerDoc: "docs-site/src/content/docs/reference/management-api.md" } },
@@ -24,6 +24,7 @@
24
24
  * gate accepts only after matching origin and the per-session CSRF token.
25
25
  */
26
26
  import { jsonResponse } from "../auth-cors";
27
+ import { verifyLocalDesktopSnapshotBody } from "../local-desktop-snapshot-auth";
27
28
  import { agentDrivenMarkers } from "../../cli/agent-driven";
28
29
  import type { ManagementContext } from "./context";
29
30
 
@@ -102,8 +103,8 @@ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Respo
102
103
  if (req.headers.has("origin")) {
103
104
  return Response.json({ error: "desktop snapshot does not accept browser-origin requests" }, { status: 403 });
104
105
  }
105
- if (ctx.principal !== "admin-token") {
106
- return jsonResponse({ error: "desktop snapshot requires admin token" }, 403, req, ctx.config);
106
+ if (ctx.principal !== "admin-token" && ctx.principal !== "local-desktop-snapshot-capability") {
107
+ return jsonResponse({ error: "desktop snapshot requires admin token or snapshot capability" }, 403, req, ctx.config);
107
108
  }
108
109
  if (req.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") {
109
110
  return jsonResponse({ error: "invalid desktop snapshot" }, 400, req, ctx.config);
@@ -127,7 +128,11 @@ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Respo
127
128
  bytes.set(part.value, used);
128
129
  used += part.value.length;
129
130
  }
130
- const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used));
131
+ const body = bytes.subarray(0, used);
132
+ if (ctx.principal === "local-desktop-snapshot-capability" && !verifyLocalDesktopSnapshotBody(req, body)) {
133
+ return jsonResponse({ error: "invalid desktop snapshot capability body" }, 403, req, ctx.config);
134
+ }
135
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(body);
131
136
  const { desktopBadgeStore } = await import("../../update/desktop-badge");
132
137
  if (!desktopBadgeStore.put(JSON.parse(decoded))) {
133
138
  return jsonResponse({ error: "invalid desktop snapshot" }, 400, req, ctx.config);
@@ -26,7 +26,7 @@ import { selectEagerPath } from "../../lib/bun-stream-caps";
26
26
  import { reportedBunRuntimeSource } from "../../lib/bun-runtime";
27
27
  import { getActiveTurnCount, isDraining } from "../lifecycle";
28
28
  import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
29
- import { responseStateMetrics } from "../../responses/state";
29
+ import { inspectResponseSpillStorage, responseStateMetrics, type ResponseSpillDirInspection } from "../../responses/state";
30
30
  import { appOwnedBytesSnapshot } from "../../lib/app-owned-memory";
31
31
  import { readWindowsReplaceRetryCounters } from "../../lib/windows-atomic-replace";
32
32
  import {
@@ -48,6 +48,18 @@ import type { ManagementContext } from "./context";
48
48
  import { acceptSystemRestart } from "./system-restart";
49
49
 
50
50
  const ENDPOINT_SAMPLE_LIMIT = 60;
51
+ // The spill report reads the snapshot and walks the spill directory synchronously,
52
+ // so a polled memory endpoint reuses one result per window instead of rescanning.
53
+ const RESPONSE_SPILL_REPORT_TTL_MS = 30_000;
54
+ let responseSpillReport: { at: number; value: ResponseSpillDirInspection } | null = null;
55
+
56
+ function cachedResponseSpillReport(): ResponseSpillDirInspection {
57
+ const now = Date.now();
58
+ if (!responseSpillReport || now - responseSpillReport.at >= RESPONSE_SPILL_REPORT_TTL_MS) {
59
+ responseSpillReport = { at: now, value: inspectResponseSpillStorage() };
60
+ }
61
+ return responseSpillReport.value;
62
+ }
51
63
 
52
64
  export async function handleSystemRoutes(ctx: ManagementContext): Promise<Response | null> {
53
65
  const { req, url, config, version } = ctx;
@@ -121,6 +133,11 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
121
133
  observedMetric: observed.observedMetric,
122
134
  jscHeap,
123
135
  responseState: responseStateMetrics(),
136
+ // Dry-run spill-directory counters: owned vs orphan-candidate bytes, same
137
+ // predicate the startup reclaim uses. Sibling of responseState, not a field
138
+ // in it — that block's key count is pinned. Still scalar-only. Cached for
139
+ // RESPONSE_SPILL_REPORT_TTL_MS.
140
+ responseSpill: cachedResponseSpillReport(),
124
141
  appOwnedBytes: appOwnedBytesSnapshot(),
125
142
  inspectionCounters: getInspectionCounters(),
126
143
  streamMode,