@bitkyc08/opencodex 2.84.0 → 2.86.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 (246) hide show
  1. package/README.md +0 -4
  2. package/bin/ocx.mjs +61 -3
  3. package/gui/dist/assets/App-BjlttVLy.js +58 -0
  4. package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
  5. package/gui/dist/assets/index-BMssP4k3.js +94 -0
  6. package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/package.json +1 -2
  9. package/src/adapters/anthropic/beta-allowlist.ts +87 -8
  10. package/src/adapters/anthropic/client-identity.ts +1 -1
  11. package/src/adapters/anthropic/passthrough.ts +33 -4
  12. package/src/adapters/azure.ts +19 -1
  13. package/src/adapters/client-fingerprint.ts +24 -0
  14. package/src/adapters/coding-agent/turn.ts +22 -9
  15. package/src/adapters/cursor/arg-codec.ts +17 -5
  16. package/src/adapters/cursor/catalog.ts +8 -0
  17. package/src/adapters/cursor/effort-map.ts +3 -0
  18. package/src/adapters/cursor/freeform-args.ts +75 -0
  19. package/src/adapters/cursor/protobuf-events.ts +36 -23
  20. package/src/adapters/google-antigravity-wire.ts +22 -1
  21. package/src/adapters/google.ts +2 -2
  22. package/src/adapters/openai-chat/response-events.ts +59 -1
  23. package/src/adapters/openai-chat.ts +8 -5
  24. package/src/adapters/qoder/adapter.ts +40 -1
  25. package/src/advisor/consult.ts +220 -0
  26. package/src/advisor/context.ts +226 -0
  27. package/src/advisor/disclosure.ts +20 -0
  28. package/src/advisor/runtime.ts +305 -0
  29. package/src/advisor/settings.ts +135 -0
  30. package/src/advisor/state.ts +369 -0
  31. package/src/advisor/synthetic-tool.ts +33 -0
  32. package/src/bridge/errors.ts +5 -0
  33. package/src/chat/inbound.ts +30 -6
  34. package/src/chat/outbound.ts +5 -0
  35. package/src/claude/desktop-3p-library.ts +43 -3
  36. package/src/claude/desktop-3p.ts +22 -8
  37. package/src/claude/desktop-picker-profile.ts +11 -4
  38. package/src/claude/desktop-profile.ts +10 -1
  39. package/src/claude/desktop-remote-store-io.ts +5 -3
  40. package/src/claude/inbound.ts +121 -50
  41. package/src/claude/intercept/local-ca.ts +8 -1
  42. package/src/claude/model-default-settings.ts +154 -0
  43. package/src/claude/model-info.ts +7 -7
  44. package/src/claude/outbound.ts +13 -6
  45. package/src/cli/advisor.ts +151 -0
  46. package/src/cli/auxiliary-listener-preflight.ts +37 -0
  47. package/src/cli/capabilities-agents-routing.ts +18 -0
  48. package/src/cli/capabilities-base.ts +1 -1
  49. package/src/cli/chatgpt-command.ts +35 -14
  50. package/src/cli/claude.ts +46 -6
  51. package/src/cli/dispatch.ts +5 -1
  52. package/src/cli/doctor.ts +9 -0
  53. package/src/cli/ensure-desktop-cold-start.ts +329 -0
  54. package/src/cli/export-command.ts +1 -1
  55. package/src/cli/help.ts +2 -1
  56. package/src/cli/index.ts +13 -4
  57. package/src/cli/integration-plan-dto.ts +2 -0
  58. package/src/cli/registry.ts +15 -3
  59. package/src/cli/restart-scope.ts +3 -1
  60. package/src/clients/config-export/contracts.ts +1 -0
  61. package/src/clients/config-export/model-metadata.ts +3 -2
  62. package/src/clients/config-export/qoder.ts +93 -0
  63. package/src/clients/config-export.ts +17 -4
  64. package/src/codex/auth-api/pool-mode-gate.ts +5 -2
  65. package/src/codex/auth-context.ts +79 -5
  66. package/src/codex/autostart-health.ts +4 -0
  67. package/src/codex/catalog/build-entries.ts +11 -2
  68. package/src/codex/catalog/configured-tiers.ts +50 -0
  69. package/src/codex/catalog/effort.ts +4 -1
  70. package/src/codex/catalog/model-hints.ts +21 -3
  71. package/src/codex/catalog/native-ultrafast.ts +106 -0
  72. package/src/codex/catalog/parsing.ts +5 -0
  73. package/src/codex/catalog/provider-models.ts +4 -0
  74. package/src/codex/catalog/retained-sync.ts +2 -0
  75. package/src/codex/catalog/routed-gather.ts +22 -4
  76. package/src/codex/catalog/vendor-context.ts +82 -0
  77. package/src/codex/config-write-lock.ts +40 -2
  78. package/src/codex/convergence.ts +2 -0
  79. package/src/codex/desktop-app/types.ts +2 -0
  80. package/src/codex/desktop-app/windows.ts +29 -16
  81. package/src/codex/desktop-app-restart.ts +4 -2
  82. package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
  83. package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
  84. package/src/codex/desktop-compatibility/connection-store.ts +87 -0
  85. package/src/codex/desktop-compatibility/installed-build.ts +55 -0
  86. package/src/codex/desktop-compatibility/json-body.ts +29 -0
  87. package/src/codex/desktop-compatibility/native-identity.ts +62 -0
  88. package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
  89. package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
  90. package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
  91. package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
  92. package/src/codex/desktop-compatibility/runtime.ts +173 -0
  93. package/src/codex/desktop-compatibility/service.ts +30 -0
  94. package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
  95. package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
  96. package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
  97. package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
  98. package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
  99. package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
  100. package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
  101. package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
  102. package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
  103. package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
  104. package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
  105. package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
  106. package/src/codex/history-provider.ts +1 -1
  107. package/src/codex/inject/config-write-section.ts +4 -1
  108. package/src/codex/inject/remove.ts +18 -6
  109. package/src/codex/journal.ts +16 -1
  110. package/src/codex/main-account.ts +99 -15
  111. package/src/codex/native-main-refresh-mode.ts +217 -0
  112. package/src/codex/routing/health-store.ts +1 -1
  113. package/src/codex/routing/pool-recovery.ts +79 -0
  114. package/src/codex/shim-diagnostics.ts +6 -0
  115. package/src/codex/shim-overlay.ts +7 -2
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/live-reconcile.ts +13 -0
  118. package/src/config/load-degrade.ts +6 -1
  119. package/src/config/paths.ts +10 -3
  120. package/src/config/schema/config-schema.ts +17 -0
  121. package/src/config/schema/desktop-compatibility.ts +9 -0
  122. package/src/config/schema/leaf-validators.ts +1 -0
  123. package/src/generated/compatibility-version.json +451 -163
  124. package/src/generated/model-metadata.ts +1 -1
  125. package/src/integrations/catalog-refresh.ts +1 -1
  126. package/src/integrations/config-io.ts +20 -2
  127. package/src/integrations/journal.ts +15 -1
  128. package/src/integrations/mutation-plan.ts +1 -0
  129. package/src/integrations/registry.ts +7 -0
  130. package/src/integrations/store.ts +3 -0
  131. package/src/integrations/writer.ts +10 -1
  132. package/src/lib/advisor-activation.ts +16 -0
  133. package/src/lib/background-work.ts +65 -0
  134. package/src/lib/bounded-file-read.ts +58 -0
  135. package/src/lib/bun-path-runtime.d.mts +10 -1
  136. package/src/lib/bun-path-runtime.mjs +62 -3
  137. package/src/lib/desktop-proxy-route.ts +30 -0
  138. package/src/lib/desktop-upstream-tunnel.ts +107 -0
  139. package/src/lib/errors.ts +7 -0
  140. package/src/lib/local-internal-call-capability.ts +55 -0
  141. package/src/lib/package-tree-integrity.ts +2 -2
  142. package/src/lib/socks5-fetch.ts +48 -71
  143. package/src/lib/socks5-handshake.ts +112 -0
  144. package/src/lib/translator-budget.ts +7 -1
  145. package/src/oauth/generic-account-failover.ts +29 -5
  146. package/src/oauth/index.ts +4 -2
  147. package/src/oauth/kiro-device-login.ts +2 -1
  148. package/src/oauth/kiro.ts +7 -1
  149. package/src/protocols/codecs/chat-ir.ts +467 -0
  150. package/src/protocols/codecs/chat.ts +10 -1
  151. package/src/protocols/codecs/messages-ir.ts +500 -0
  152. package/src/protocols/features.ts +10 -2
  153. package/src/protocols/path.ts +11 -1
  154. package/src/protocols/plan-snapshot.ts +39 -16
  155. package/src/protocols/plan.ts +9 -1
  156. package/src/protocols/settings.ts +8 -0
  157. package/src/providers/antigravity-account-roster.ts +102 -0
  158. package/src/providers/antigravity-models.ts +85 -7
  159. package/src/providers/model-rename-fields.ts +2 -0
  160. package/src/providers/model-rename-migration.ts +5 -3
  161. package/src/providers/registry/entries-core.ts +7 -6
  162. package/src/providers/registry/entries-extended.ts +19 -0
  163. package/src/responses/parser.ts +30 -19
  164. package/src/responses/state.ts +32 -29
  165. package/src/routing/history/build-file.ts +92 -0
  166. package/src/routing/history/indexer.ts +248 -216
  167. package/src/routing/history/ingest.ts +238 -0
  168. package/src/routing/history/rebuild-job.ts +111 -0
  169. package/src/routing/history/rebuild-protocol.ts +15 -0
  170. package/src/routing/history/rebuild-worker.ts +34 -0
  171. package/src/server/admission-model-scope.ts +4 -1
  172. package/src/server/antigravity-direct-eligibility.ts +208 -0
  173. package/src/server/auth-cors.ts +4 -0
  174. package/src/server/chat-completions.ts +75 -9
  175. package/src/server/claude-messages.ts +110 -20
  176. package/src/server/index/desktop-compatibility-startup.ts +56 -0
  177. package/src/server/index/serve-options.ts +2 -2
  178. package/src/server/index/startup-warnings.ts +9 -0
  179. package/src/server/index/websocket-handler.ts +9 -0
  180. package/src/server/index.ts +13 -10
  181. package/src/server/inference/client-encoder-delivery.ts +6 -5
  182. package/src/server/login-shell-path.ts +146 -0
  183. package/src/server/management/advisor-routes.ts +191 -0
  184. package/src/server/management/agent-settings-routes.ts +22 -4
  185. package/src/server/management/companion-routes.ts +4 -0
  186. package/src/server/management/config-routes.ts +2 -2
  187. package/src/server/management/context.ts +4 -0
  188. package/src/server/management/desktop-compatibility-routes.ts +46 -0
  189. package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
  190. package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
  191. package/src/server/management/model-routes.ts +2 -2
  192. package/src/server/management/protocol-settings-patch.ts +1 -0
  193. package/src/server/management/route-registry.ts +11 -0
  194. package/src/server/management/sibling-guard.ts +1 -0
  195. package/src/server/management-api.ts +6 -0
  196. package/src/server/messages-antigravity-direct.ts +195 -0
  197. package/src/server/messages-native.ts +5 -1
  198. package/src/server/request-log.ts +7 -0
  199. package/src/server/responses/adapter-continuation.ts +35 -18
  200. package/src/server/responses/adapter-delivery.ts +24 -2
  201. package/src/server/responses/adapter-dispatch.ts +29 -2
  202. package/src/server/responses/advisor-plan-slot.ts +27 -0
  203. package/src/server/responses/advisor-slot.ts +336 -0
  204. package/src/server/responses/code-mode-collab-surface.ts +116 -8
  205. package/src/server/responses/core-combo-failure.ts +10 -1
  206. package/src/server/responses/core-combo.ts +21 -0
  207. package/src/server/responses/core-lifetime.ts +2 -0
  208. package/src/server/responses/core-normalize.ts +11 -0
  209. package/src/server/responses/core-options.ts +46 -0
  210. package/src/server/responses/core-replay.ts +7 -4
  211. package/src/server/responses/core.ts +1 -1
  212. package/src/server/responses/passthrough-delivery.ts +16 -10
  213. package/src/server/responses/passthrough-dispatch.ts +14 -3
  214. package/src/server/responses/request-prepare.ts +68 -5
  215. package/src/server/responses/request-transport.ts +16 -0
  216. package/src/server/responses/run-turn-execution.ts +70 -8
  217. package/src/server/responses/sidecar-execution.ts +78 -3
  218. package/src/server/responses/terminal-guard.ts +2 -1
  219. package/src/server/responses-undeclared-tool-guard.ts +30 -9
  220. package/src/server/startup-action-control.ts +16 -2
  221. package/src/server/startup-health-cache.ts +1 -0
  222. package/src/service/desktop-startup.ts +14 -0
  223. package/src/service/health-sentinel-main.ts +23 -4
  224. package/src/service/health-sentinel-probe.ts +24 -7
  225. package/src/service/logging.ts +72 -0
  226. package/src/tray/windows-tray.ps1 +43 -24
  227. package/src/types/config.ts +63 -6
  228. package/src/types/provider.ts +7 -0
  229. package/src/types/request.ts +15 -0
  230. package/src/types/tools.ts +2 -0
  231. package/src/update/async-check.ts +1 -1
  232. package/src/update/badge.ts +2 -2
  233. package/src/update/index.ts +88 -15
  234. package/src/update/job-supervised-restart.ts +107 -0
  235. package/src/update/job.ts +32 -32
  236. package/src/update/notify.ts +1 -1
  237. package/src/update/refresh-scheduler.ts +2 -2
  238. package/src/update/supervised-recovery.d.mts +27 -0
  239. package/src/update/supervised-recovery.mjs +157 -0
  240. package/src/usage/cache-diagnostic.ts +2 -0
  241. package/src/usage/expected-prices.ts +12 -0
  242. package/src/web-search/gemini-executor.ts +2 -2
  243. package/assets/sponsors/packycode.png +0 -0
  244. package/gui/dist/assets/App-CZtqiGcw.js +0 -56
  245. package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
  246. package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths";
@@ -44,9 +44,49 @@ export interface Desktop3pMetadata {
44
44
 
45
45
  export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker";
46
46
 
47
+ /** A path that no longer names the observed file was swapped; a same-path failure keeps its own errno. */
48
+ function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolean {
49
+ try {
50
+ const now = lstatSync(path, { bigint: true });
51
+ return now.isFile() && now.dev === expected.dev && now.ino === expected.ino
52
+ && now.ctimeNs === expected.ctimeNs;
53
+ } catch { return false; }
54
+ }
55
+
56
+ /** Read regular config files only; a path swap must not turn an observation into a FIFO wait. */
57
+ export function readDesktopLibraryBytes(path: string): Buffer {
58
+ const expected = lstatSync(path, { bigint: true });
59
+ if (!expected.isFile()) throw new Error("desktop_config_not_regular");
60
+ // POSIX open of a FIFO can itself block, before fstat is possible. Windows has no POSIX
61
+ // FIFO entry; retain descriptor identity validation there without unsupported open flags.
62
+ const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NONBLOCK | constants.O_NOFOLLOW);
63
+ let fd: number;
64
+ try {
65
+ fd = openSync(path, flags);
66
+ } catch (error) {
67
+ // A swap can fail at open — ELOOP on a symlink, ENXIO on a socket — before fstat can
68
+ // classify it. Re-observing the path reports every swap as the documented refusal.
69
+ if (pathStillNamesExpectedFile(path, expected)) throw error;
70
+ throw new Error("desktop_config_not_regular");
71
+ }
72
+ try {
73
+ const opened = fstatSync(fd, { bigint: true });
74
+ if (!opened.isFile() || opened.dev !== expected.dev || opened.ino !== expected.ino
75
+ || opened.ctimeNs !== expected.ctimeNs) throw new Error("desktop_config_not_regular");
76
+ return readFileSync(fd);
77
+ } finally {
78
+ closeSync(fd);
79
+ }
80
+ }
81
+
82
+ /** Text decoding stays identical to node:fs readFileSync(path, "utf8"). */
83
+ export function readDesktopLibraryText(path: string): string {
84
+ return readDesktopLibraryBytes(path).toString("utf8");
85
+ }
86
+
47
87
  export function parseMetadata(path: string): Desktop3pMetadata {
48
88
  if (!existsSync(path)) return { entries: [] };
49
- const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<Desktop3pMetadata>;
89
+ const parsed = JSON.parse(readDesktopLibraryText(path)) as Partial<Desktop3pMetadata>;
50
90
  if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array");
51
91
  return { ...parsed, entries: parsed.entries };
52
92
  }
@@ -82,7 +122,7 @@ export const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([
82
122
 
83
123
  export function readDesktopProfileForeignKeys(path: string): Record<string, unknown> {
84
124
  if (!existsSync(path)) return {};
85
- const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
125
+ const parsed = JSON.parse(readDesktopLibraryText(path)) as unknown;
86
126
  if (!isRecord(parsed)) throw new Error("Claude Desktop 3P profile is not a JSON object");
87
127
  return Object.fromEntries(
88
128
  Object.entries(parsed).filter(([key]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)),
@@ -5,12 +5,12 @@ import { applyRemoteDesktopStore, inspectRemoteDesktopCleanup, restoreRemoteDesk
5
5
  import { canonicalDirectory } from "./desktop-remote-store-io";
6
6
  import {
7
7
  resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord,
8
- isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileForeignKeys,
8
+ isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileForeignKeys, readDesktopLibraryText, readDesktopLibraryBytes,
9
9
  type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry,
10
10
  } from "./desktop-3p-library";
11
11
  export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
- import { copyFileSync, existsSync, mkdirSync, readFileSync, lstatSync, unlinkSync } from "node:fs";
13
+ import { copyFileSync, existsSync, mkdirSync, lstatSync, unlinkSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { atomicWriteFile, readConfigDiagnostics, withConfigMutationLockSync } from "../config";
16
16
  import { atomicWriteFileNoFollow } from "../config/atomic-write";
@@ -20,6 +20,7 @@ import {
20
20
  reconcileDesktopProfile,
21
21
  renderDesktopProfile,
22
22
  validDateAlias,
23
+ legacyDesktopProfileWireAlias,
23
24
  type DesktopProfileModel,
24
25
  } from "./desktop-profile";
25
26
  import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog";
@@ -223,7 +224,7 @@ function collectDesktop3pModels(
223
224
  ...(model.supports1m ? { supports1m: true, prefer1m: true } : {}),
224
225
  });
225
226
  }
226
- // Hash aliases from both pre-profile generations are compatibility-only and can
227
+ // Previous profile IDs and both hash generations are compatibility-only and can
227
228
  // collide. Bind them in stable route order so changing a family default or
228
229
  // rendered ordering can never silently rebind an old Desktop id.
229
230
  for (const model of [...rendered].sort((a, b) => a.route.localeCompare(b.route))) {
@@ -231,7 +232,8 @@ function collectDesktop3pModels(
231
232
  const providerEnd = model.route.indexOf("/");
232
233
  const provider = model.route.slice(0, providerEnd);
233
234
  const id = model.route.slice(providerEnd + 1);
234
- for (const legacy of [desktop3pAlias(provider, id), legacyDesktop3pAlias(provider, id)]) {
235
+ const storedAlias = reconciled.assignments[model.route]!.alias;
236
+ for (const legacy of [legacyDesktopProfileWireAlias(storedAlias), desktop3pAlias(provider, id), legacyDesktop3pAlias(provider, id)]) {
235
237
  if (realAnthropicIds.has(legacy)) {
236
238
  console.warn(`[opencodex] Claude Desktop legacy alias ${legacy} for ${model.route} conflicts with a real Anthropic model; ignoring the alias`);
237
239
  continue;
@@ -360,6 +362,11 @@ export function isKnownDesktop3pModelId(id: string): boolean {
360
362
  return desktop3pRegistry.has(id) || desktop3pRealAnthropicIds.has(id);
361
363
  }
362
364
 
365
+ /** Real catalog IDs retain identity even when their spelling resembles an alias. */
366
+ export function isRealDesktop3pModelId(id: string): boolean {
367
+ return desktop3pRealAnthropicIds.has(id);
368
+ }
369
+
363
370
  /** Only missing IDs in the emitted Desktop namespaces are managed-alias errors. */
364
371
  export function isUnresolvedDesktop3pAlias(id: string): boolean {
365
372
  if (isKnownDesktop3pModelId(id)) return false;
@@ -369,7 +376,7 @@ export function isUnresolvedDesktop3pAlias(id: string): boolean {
369
376
  if (isKnownDesktop3pModelId(base)) return false;
370
377
  return validDateAlias(base)
371
378
  || /^claude-opus-4-(?:8-)?[a-z][a-z0-9]{2}$/.test(base)
372
- || /^claude-opus-4-8-p[0-9a-z]{3}$/.test(base);
379
+ || /^claude-(?:opus-4-8|fable-5)-p[0-9a-z]{3}$/.test(base);
373
380
  }
374
381
 
375
382
  /** Alias selected by the installed profile registry, falling back to the legacy hash shape. */
@@ -377,6 +384,13 @@ export function activeDesktop3pAlias(provider: string, modelId: string): string
377
384
  return desktop3pAliasesByRoute.get(`${provider}/${modelId}`) ?? desktop3pAlias(provider, modelId);
378
385
  }
379
386
 
387
+ /** Discovery cannot offer a skipped alias or one owned by a different route. */
388
+ export function resolvableDesktop3pAlias(provider: string, modelId: string): string | undefined {
389
+ if (provider === "anthropic" && modelId.startsWith("claude-")) return modelId;
390
+ const alias = activeDesktop3pAlias(provider, modelId);
391
+ return resolveDesktop3pAlias(alias) === `${provider}/${modelId}` ? alias : undefined;
392
+ }
393
+
380
394
  /**
381
395
  * Generate the complete Claude Desktop 3P gateway config.
382
396
  *
@@ -476,7 +490,7 @@ export function inspectDesktop3pConfigLibrary(
476
490
  let profile: Record<string, unknown>;
477
491
  let fingerprint: string;
478
492
  try {
479
- const source = readFileSync(selectedProfilePath, "utf8");
493
+ const source = readDesktopLibraryText(selectedProfilePath);
480
494
  const parsed = JSON.parse(source) as unknown;
481
495
  if (!isRecord(parsed)) return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive };
482
496
  profile = parsed;
@@ -709,7 +723,7 @@ export function writeDesktop3pConfig(
709
723
  const metadata = parseMetadata(join(inspected.libraryPath, "_meta.json"));
710
724
  if (!isOwnedDesktopGatewayEntry(metadata.entries.find(entry => entry.id === inspected.appliedId))) return refused;
711
725
  try {
712
- const selected = JSON.parse(readFileSync(inspected.selectedProfilePath, "utf8")) as Record<string, unknown>;
726
+ const selected = JSON.parse(readDesktopLibraryText(inspected.selectedProfilePath)) as Record<string, unknown>;
713
727
  mode = selected.modelDiscoveryEnabled !== true ? "static"
714
728
  : Array.isArray(selected.inferenceModels) ? "hybrid" : "discovery";
715
729
  } catch { return refused; }
@@ -825,7 +839,7 @@ function writeDesktop3pConfigWithGenerator(
825
839
  const freshMetadata = parseMetadata(metadataPath);
826
840
  if (freshMetadata.appliedId !== refreshOnly.requiredId
827
841
  || !isOwnedDesktopGatewayEntry(freshMetadata.entries.find(entry => entry.id === refreshOnly.requiredId))
828
- || createHash("sha256").update(readFileSync(configPath)).digest("hex").slice(0, 16) !== refreshOnly.expectedFingerprint) {
842
+ || createHash("sha256").update(readDesktopLibraryBytes(configPath)).digest("hex").slice(0, 16) !== refreshOnly.expectedFingerprint) {
829
843
  throw new Error();
830
844
  }
831
845
  } catch { throw new Error("desktop_refresh_only_skipped"); }
@@ -16,6 +16,7 @@ import {
16
16
  isOwnedDesktopGatewayEntry,
17
17
  parseMetadata,
18
18
  profilePath,
19
+ readDesktopLibraryText,
19
20
  resolveDesktop3pConfigLibraryPath,
20
21
  SAFE_DESKTOP_PROFILE_ID,
21
22
  type Desktop3pConfigLibraryOptions,
@@ -73,6 +74,12 @@ function readFileSnapshot(path: string): { exists: boolean; content?: string } {
73
74
  return existsSync(path) ? { exists: true, content: readFileSync(path, "utf8") } : { exists: false };
74
75
  }
75
76
 
77
+ // Desktop library paths go through the verified regular-file reader: a swapped FIFO or symlink
78
+ // must surface as a refusal, never as a blocking open. Our own picker state file keeps the plain read.
79
+ function readLibrarySnapshot(path: string): { exists: boolean; content?: string } {
80
+ return existsSync(path) ? { exists: true, content: readDesktopLibraryText(path) } : { exists: false };
81
+ }
82
+
76
83
  function restoreFile(path: string, snapshot: { exists: boolean; content?: string }): void {
77
84
  if (snapshot.exists) {
78
85
  rollbackAtomicWriteFile(path, snapshot.content ?? "");
@@ -98,7 +105,7 @@ function pickerEntry(metadata: Desktop3pMetadata): Desktop3pMetadataEntry | unde
98
105
  }
99
106
 
100
107
  function profileObject(path: string): Record<string, unknown> {
101
- const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
108
+ const parsed = JSON.parse(readDesktopLibraryText(path)) as unknown;
102
109
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
103
110
  throw new Error("picker_profile_unreadable");
104
111
  }
@@ -175,10 +182,10 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto
175
182
  const alreadySelected = selectedId === entryId;
176
183
  const priorState = readPickerState(previousStatePath);
177
184
  const previousAppliedId = alreadySelected ? priorState?.previousAppliedId ?? null : selectedId;
178
- const oldMeta = readFileSnapshot(metaPath);
179
- const oldProfile = readFileSnapshot(profile);
185
+ const oldMeta = readLibrarySnapshot(metaPath);
186
+ const oldProfile = readLibrarySnapshot(profile);
180
187
  const backupPath = `${profile}.bak`;
181
- const oldBackup = readFileSnapshot(backupPath);
188
+ const oldBackup = readLibrarySnapshot(backupPath);
182
189
  const oldState = readFileSnapshot(previousStatePath);
183
190
  const changed = !alreadySelected || !oldProfile.exists || oldProfile.content !== target;
184
191
  if (!changed) return { ok: true, changed: false, path: profile };
@@ -33,7 +33,9 @@ export interface RenderedDesktopModel extends DesktopProfileModel {
33
33
  // renderDesktopProfile() converts it to a letter-first wire id because current
34
34
  // Claude Desktop strips terminal dates before comparing model identities.
35
35
  const DATE_ALIAS = /^claude-opus-4-8-(202[6-9]\d{4}|203[0-5]\d{4})$/;
36
- const PROFILE_WIRE_PREFIX = "claude-opus-4-8-";
36
+ // Desktop 2.31226's effort fallback recognizes fable prefixes, while Opus
37
+ // requires an exact canonical id. Keep the suffix so set_model stays distinct.
38
+ const PROFILE_WIRE_PREFIX = "claude-fable-5-";
37
39
  const LEGACY_YEAR = 2026;
38
40
  const LEGACY_DAY_COUNT = 365;
39
41
  const ALIAS_FIRST_YEAR = 2026;
@@ -144,6 +146,13 @@ export function desktopProfileWireAlias(alias: string): string {
144
146
  return `${PROFILE_WIRE_PREFIX}p${slot.toString(36).padStart(3, "0")}`;
145
147
  }
146
148
 
149
+ /** Decode-only profile namespace emitted before the Desktop effort compatibility fix. */
150
+ export function legacyDesktopProfileWireAlias(alias: string): string {
151
+ return validDateAlias(alias)
152
+ ? desktopProfileWireAlias(alias).replace(PROFILE_WIRE_PREFIX, "claude-opus-4-8-")
153
+ : alias;
154
+ }
155
+
147
156
  export function parseDesktopProfile(value: unknown): DesktopProfile {
148
157
  if (!isPlainObject(value)) throw new DesktopProfileError("must be an object");
149
158
  // `appliedFingerprint`/`appliedAt` are written back by the apply route once a profile
@@ -1,8 +1,8 @@
1
- import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
1
+ import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { atomicWriteFile, getConfigDir } from "../config";
4
4
  import { hardenSecretDir } from "../lib/windows-secret-acl";
5
- import { isRecord, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
5
+ import { isRecord, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
6
6
  import { DesktopStoreError, digest } from "./desktop-remote-store-state";
7
7
 
8
8
  export const MAX_DESKTOP_METADATA_ENTRIES = 256;
@@ -43,7 +43,9 @@ export class StoreIO {
43
43
  this.seenSizes.set(path, Math.max(counted, stat.size));
44
44
  if (this.bytes > 8 * 1024 * 1024) throw new DesktopStoreError("unsafe");
45
45
  try {
46
- const bytes = readFileSync(path);
46
+ // The shared verified regular-file read keeps a swapped FIFO from parking this
47
+ // open before the post-read identity recheck below can run.
48
+ const bytes = readDesktopLibraryBytes(path);
47
49
  if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict");
48
50
  const value: unknown = JSON.parse(bytes.toString("utf8"));
49
51
  if (!isRecord(value)) throw new DesktopStoreError("unsafe");
@@ -24,7 +24,12 @@ import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator
24
24
 
25
25
 
26
26
 
27
- function imageBlockToInputImage(block: Rec): Rec | null {
27
+ /**
28
+ * The reference an Anthropic image block carries, as the Responses `image_url` string the bridge
29
+ * builds from it, or `null` for a source this route does not read. A file-backed source refuses.
30
+ * Shared with the direct Messages decoder (`src/protocols/codecs/messages-ir.ts`).
31
+ */
32
+ export function anthropicImageBlockUrl(block: Rec): string | null {
28
33
  const source = block.source;
29
34
  if (!isRec(source)) return null;
30
35
  if (source.type === "file") {
@@ -34,20 +39,25 @@ function imageBlockToInputImage(block: Rec): Rec | null {
34
39
  }
35
40
  if (source.type === "base64" && typeof source.data === "string") {
36
41
  const media = typeof source.media_type === "string" ? source.media_type : "image/png";
37
- return { type: "input_image", image_url: `data:${media};base64,${source.data}` };
42
+ return `data:${media};base64,${source.data}`;
38
43
  }
39
44
  if (source.type === "url" && typeof source.url === "string") {
40
- return { type: "input_image", image_url: source.url };
45
+ return source.url;
41
46
  }
42
47
  return null;
43
48
  }
44
49
 
45
- function documentTitle(block: Rec): string | undefined {
50
+ function imageBlockToInputImage(block: Rec): Rec | null {
51
+ const url = anthropicImageBlockUrl(block);
52
+ return url === null ? null : { type: "input_image", image_url: url };
53
+ }
54
+
55
+ export function documentTitle(block: Rec): string | undefined {
46
56
  return typeof block.title === "string" && block.title.length > 0 ? block.title : undefined;
47
57
  }
48
58
 
49
- /** An Anthropic base64 document as the Responses `input_file` block that carries its bytes. */
50
- function documentBlockToInputFile(block: Rec): Rec | null {
59
+ /** A base64 document's bytes as a `data:` URL plus its title, or `null` for any other source. */
60
+ export function anthropicDocumentData(block: Rec): { fileData: string; title?: string } | null {
51
61
  const source = block.source;
52
62
  if (!isRec(source) || source.type !== "base64") return null;
53
63
  const mediaType = typeof source.media_type === "string" && source.media_type.length > 0
@@ -55,10 +65,17 @@ function documentBlockToInputFile(block: Rec): Rec | null {
55
65
  : "application/octet-stream";
56
66
  if (typeof source.data !== "string" || source.data.length === 0) return null;
57
67
  const title = documentTitle(block);
68
+ return { fileData: `data:${mediaType};base64,${source.data}`, ...(title !== undefined ? { title } : {}) };
69
+ }
70
+
71
+ /** An Anthropic base64 document as the Responses `input_file` block that carries its bytes. */
72
+ function documentBlockToInputFile(block: Rec): Rec | null {
73
+ const document = anthropicDocumentData(block);
74
+ if (!document) return null;
58
75
  return {
59
76
  type: "input_file",
60
- file_data: `data:${mediaType};base64,${source.data}`,
61
- ...(title !== undefined ? { filename: title } : {}),
77
+ file_data: document.fileData,
78
+ ...(document.title !== undefined ? { filename: document.title } : {}),
62
79
  };
63
80
  }
64
81
 
@@ -145,7 +162,7 @@ const NO_ELISION: SkillElisionContext = { callIds: new Set(), names: [] };
145
162
  * `Base directory for this skill: <dir>/<skill-name>`. Stub that block when the
146
163
  * directory basename matches a blocked skill.
147
164
  */
148
- function maybeElideSkillText(text: string, names: readonly string[]): string {
165
+ export function maybeElideSkillText(text: string, names: readonly string[]): string {
149
166
  if (names.length === 0 || text.length < SKILL_ELISION_MIN_CHARS) return text;
150
167
  if (!text.startsWith(SKILL_TEXT_MARKER)) return text;
151
168
  const pathStart = SKILL_TEXT_MARKER.length;
@@ -162,14 +179,14 @@ function maybeElideSkillText(text: string, names: readonly string[]): string {
162
179
  + "(claudeCode.blockedSkills). The skill is loaded; answer from general knowledge instead of citing the bundle.";
163
180
  }
164
181
 
165
- function skillElisionStub(callId: string): string {
182
+ export function skillElisionStub(callId: string): string {
166
183
  return "[opencodex] Skill document bundle elided for routed models (claudeCode.blockedSkills). "
167
184
  + `The skill loaded, but its reference documents were removed to save context (call ${callId}). `
168
185
  + "Answer from general knowledge instead of citing the bundle.";
169
186
  }
170
187
 
171
188
  /** Collect Skill-tool call ids whose input names a blocked skill. */
172
- function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly string[]): Set<string> {
189
+ export function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly string[]): Set<string> {
173
190
  const ids = new Set<string>();
174
191
  if (blocked.length === 0) return ids;
175
192
  const needles = blocked.map(name => name.toLowerCase()).filter(name => name.length > 0);
@@ -222,7 +239,7 @@ export function anthropicBodyElidesBlockedSkill(body: unknown, cc?: Pick<OcxClau
222
239
  * prefix and rotating the Desktop `prompt_cache_key` fallback below on every turn
223
240
  * (#4148).
224
241
  */
225
- function systemMessageText(content: unknown): string {
242
+ export function systemMessageText(content: unknown): string {
226
243
  if (typeof content === "string") return content;
227
244
  if (!Array.isArray(content)) return "";
228
245
  const parts: string[] = [];
@@ -410,19 +427,10 @@ function translateAnthropicRequest(
410
427
  cc: OcxClaudeCodeConfig | undefined,
411
428
  budget: TranslatorBudget,
412
429
  ): ClaudeInboundTranslation {
413
- if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object");
414
- if (typeof raw.model !== "string" || raw.model.length === 0) {
415
- throw new AnthropicRequestError("model is required");
416
- }
417
- if (!Array.isArray(raw.messages) || raw.messages.length === 0) {
418
- throw new AnthropicRequestError("messages must be a non-empty array");
419
- }
430
+ assertAnthropicRequestShape(raw);
420
431
 
421
432
  const input: Rec[] = [];
422
433
  const nativeReasoningReplay = new Map<string, string>();
423
- const systemParts: string[] = [];
424
- const topLevelSystem = systemToInstructions(raw.system);
425
- if (topLevelSystem !== undefined) systemParts.push(topLevelSystem);
426
434
  const blockedNames = effectiveBlockedSkillNames(cc);
427
435
  const elide: SkillElisionContext = {
428
436
  callIds: blockedSkillCallIds(raw.messages, blockedNames),
@@ -451,36 +459,99 @@ function translateAnthropicRequest(
451
459
  stream: raw.stream === true,
452
460
  };
453
461
 
462
+ const system = anthropicSystemLayout(raw.system, cc);
463
+ if (system.instructions !== undefined) body.instructions = system.instructions;
464
+ if (system.dynamicNotice !== undefined) {
465
+ input.push({
466
+ type: "message",
467
+ role: "user",
468
+ content: [{ type: "input_text", text: system.dynamicNotice }],
469
+ });
470
+ }
471
+
472
+ const tools = toolsToResponses(raw.tools);
473
+ if (tools) body.tools = tools;
474
+ const cacheKeySource = applyAnthropicSettings(raw, body, system.cacheSystem, tools);
475
+ return { body, cacheKeySource, nativeReasoningReplay };
476
+ }
477
+
478
+ function assertAnthropicRequestShape(raw: unknown): asserts raw is Rec & { model: string; messages: unknown[] } {
479
+ if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object");
480
+ if (typeof raw.model !== "string" || raw.model.length === 0) {
481
+ throw new AnthropicRequestError("model is required");
482
+ }
483
+ if (!Array.isArray(raw.messages) || raw.messages.length === 0) {
484
+ throw new AnthropicRequestError("messages must be a non-empty array");
485
+ }
486
+ }
487
+
488
+ /** Where the top-level `system` lands on the bridge body, and what the cache-cohort key hashes. */
489
+ export interface AnthropicSystemLayout {
490
+ /** The Responses `instructions`, when there are any. */
491
+ instructions?: string;
492
+ /** A dynamic notice `stabilizePromptCache` moved out of the system text into a trailing user turn. */
493
+ dynamicNotice?: string;
494
+ cacheSystem: string | string[];
495
+ }
496
+
497
+ /** The system text placement `claudeCode.stabilizePromptCache` decides; shared with the direct decoder. */
498
+ export function anthropicSystemLayout(system: unknown, cc: OcxClaudeCodeConfig | undefined): AnthropicSystemLayout {
499
+ const systemParts: string[] = [];
500
+ const topLevelSystem = systemToInstructions(system);
501
+ if (topLevelSystem !== undefined) systemParts.push(topLevelSystem);
454
502
  const joinedSystem = systemParts.length > 0 ? systemParts.join("\n\n") : "";
455
503
  const stabilizePromptCache = cc?.stabilizePromptCache === true;
456
504
  // Desktop fallback hashes raw systemParts unless the caller opted into
457
505
  // harness cleanup. Opt-in then hashes the same string as body.instructions.
458
- let cacheSystem: string | string[] = systemParts;
459
- if (joinedSystem) {
460
- if (stabilizePromptCache) {
461
- // Claude Code appends growing <total_tokens>N tokens left</total_tokens>
462
- // footers (and occasional TaskCreate nudges) into system text. That churn
463
- // breaks Muse/Go prefix cache on the Responses instructions prefix even
464
- // when tools stay stable. Relocation is caller-opted, not inferred from
465
- // a matching suffix or metadata.user_id.
466
- const stabilized = stabilizeClaudeInstructionsForPromptCache(joinedSystem);
467
- if (stabilized.instructions) body.instructions = stabilized.instructions;
468
- // Token footers are dropped; append nothing if no other notice remains.
469
- if (stabilized.dynamicNotice) {
470
- input.push({
471
- type: "message",
472
- role: "user",
473
- content: [{ type: "input_text", text: stabilized.dynamicNotice }],
474
- });
475
- }
476
- cacheSystem = stabilized.instructions;
477
- } else {
478
- body.instructions = joinedSystem;
479
- }
480
- }
506
+ if (!joinedSystem) return { cacheSystem: systemParts };
507
+ if (!stabilizePromptCache) return { instructions: joinedSystem, cacheSystem: systemParts };
508
+ // Claude Code appends growing <total_tokens>N tokens left</total_tokens>
509
+ // footers (and occasional TaskCreate nudges) into system text. That churn
510
+ // breaks Muse/Go prefix cache on the Responses instructions prefix even
511
+ // when tools stay stable. Relocation is caller-opted, not inferred from
512
+ // a matching suffix or metadata.user_id.
513
+ const stabilized = stabilizeClaudeInstructionsForPromptCache(joinedSystem);
514
+ return {
515
+ ...(stabilized.instructions ? { instructions: stabilized.instructions } : {}),
516
+ // Token footers are dropped; append nothing if no other notice remains.
517
+ ...(stabilized.dynamicNotice ? { dynamicNotice: stabilized.dynamicNotice } : {}),
518
+ cacheSystem: stabilized.instructions,
519
+ };
520
+ }
481
521
 
482
- const tools = toolsToResponses(raw.tools);
483
- if (tools) body.tools = tools;
522
+ /**
523
+ * The request settings of a Messages body as a Responses body with no conversation: `input` is
524
+ * empty and there are no `instructions` or `tools`, but the prompt-cache key is still derived
525
+ * from the system text and translated tool catalog exactly as the bridge derives it. The
526
+ * Antigravity direct lane hands this to the Responses core while the conversation is decoded
527
+ * straight into the IR (`src/protocols/codecs/messages-ir.ts`); both bodies are completed by
528
+ * `applyAnthropicSettings`. It validates the same request shape, but a conversation error the
529
+ * bridge would report first is not looked for here: a caller that gets a throw must translate
530
+ * the full body to learn the bridge's answer.
531
+ */
532
+ export function anthropicSettingsTranslation(
533
+ raw: unknown,
534
+ cc?: OcxClaudeCodeConfig,
535
+ ): { body: Rec; cacheKeySource: ClaudeCacheKeySource } {
536
+ assertAnthropicRequestShape(raw);
537
+ const body: Rec = {
538
+ model: resolveInboundModel(raw.model, cc),
539
+ input: [],
540
+ store: false,
541
+ stream: raw.stream === true,
542
+ };
543
+ const system = anthropicSystemLayout(raw.system, cc);
544
+ const cacheKeySource = applyAnthropicSettings(raw, body, system.cacheSystem, toolsToResponses(raw.tools));
545
+ return { body, cacheKeySource };
546
+ }
547
+
548
+ /** Everything after the conversation and tool catalog: choice, limits, format, cache key, reasoning. */
549
+ function applyAnthropicSettings(
550
+ raw: Rec,
551
+ body: Rec,
552
+ cacheSystem: string | string[],
553
+ tools: Rec[] | undefined,
554
+ ): ClaudeCacheKeySource {
484
555
  toolChoiceToResponses(raw.tool_choice, body);
485
556
 
486
557
  if (typeof raw.max_tokens === "number") body.max_output_tokens = raw.max_tokens;
@@ -505,7 +576,7 @@ function translateAnthropicRequest(
505
576
  // per-session key with a bounded length/charset.
506
577
  body.prompt_cache_key = userIdHash.slice(0, 32);
507
578
  cacheKeySource = "metadata";
508
- } else if (systemParts.length > 0) {
579
+ } else if (typeof cacheSystem === "string" || cacheSystem.length > 0) {
509
580
  // Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the
510
581
  // ChatGPT/OpenAI backends reported cached_tokens:0 on every turn. Fall back to a
511
582
  // cache-cohort hash (devlog 260712 B4 + Pro review 012): fingerprint what the
@@ -526,7 +597,7 @@ function translateAnthropicRequest(
526
597
  version: 2,
527
598
  model: body.model,
528
599
  system: cacheSystem,
529
- tools: Array.isArray(body.tools) ? body.tools : [],
600
+ tools: tools ?? [],
530
601
  }))
531
602
  .digest("hex").slice(0, 32);
532
603
  cacheKeySource = "system";
@@ -556,7 +627,7 @@ function translateAnthropicRequest(
556
627
  body.include = ["reasoning.encrypted_content"];
557
628
  }
558
629
 
559
- return { body, cacheKeySource, nativeReasoningReplay };
630
+ return cacheKeySource;
560
631
  }
561
632
 
562
633
  /**
@@ -197,7 +197,9 @@ export interface AuthorityOptions {
197
197
  permittedDnsNames?: readonly string[];
198
198
  /** With permittedDnsNames: also exclude every IP address (default true). */
199
199
  excludeAllIpAddresses?: boolean;
200
- }
200
+ /** Restrict this authority to TLS server authentication; legacy callers keep their existing scope. */
201
+ serverAuthOnly?: boolean;
202
+ }
201
203
 
202
204
  export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa {
203
205
  const validityDays = options.validityDays ?? CA_VALIDITY_DAYS;
@@ -217,6 +219,7 @@ export function createCertificateAuthority(options: AuthorityOptions): LocalInte
217
219
  // keyCertSign | cRLSign
218
220
  extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)),
219
221
  extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))),
222
+ ...(options.serverAuthOnly ? [extension(OID.extendedKeyUsage, true, sequence(objectIdentifier(OID.serverAuth)))] : []),
220
223
  ...(options.permittedDnsNames?.length
221
224
  ? [extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames, options.excludeAllIpAddresses !== false))]
222
225
  : []),
@@ -234,6 +237,10 @@ export function createLocalInterceptCa(): LocalInterceptCa {
234
237
  return createCertificateAuthority({ commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME });
235
238
  }
236
239
 
240
+ export function isServerAuthOnlyCertificate(certificate: X509Certificate): boolean {
241
+ return certificate.keyUsage?.length === 1 && certificate.keyUsage[0] === OID.serverAuth;
242
+ }
243
+
237
244
  /**
238
245
  * Test hook for trust-boundary suites: mint a self-signed authority carrying an arbitrary list of
239
246
  * DER-encoded Extension items so adversarial profiles still bear a valid signature. Production