@bitkyc08/opencodex 2.63.0 → 2.64.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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-CpuDF3ci.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-B4uEIa1O.js} +1 -1
  10. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  11. package/gui/dist/assets/index-SggB6t3z.js +86 -0
  12. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-BqrsSrIR.js +0 -50
  259. package/gui/dist/assets/index-C6SJrh0N.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -32,6 +32,7 @@ import {
32
32
  REPLAY_REFUSAL_CLIENT_HEADERS,
33
33
  REPLAY_REFUSED_STATUS,
34
34
  retainReplayRefusal,
35
+ UpstreamRetryEvidenceError,
35
36
  type UpstreamSendRecovery,
36
37
  UPSTREAM_RESET_REPLAY_REFUSED_CODE,
37
38
  } from "../lib/upstream-retry";
@@ -48,6 +49,7 @@ import {
48
49
  transientRetryPolicyFor,
49
50
  } from "../providers/key-failover";
50
51
  import { fastPolicyForModel } from "../providers/service-tier";
52
+ import { stampApiKeyAccountLabel } from "../providers/label";
51
53
  import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection";
52
54
  import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate-limit";
53
55
  import type { OcxProviderTransport } from "../providers/xai-transport";
@@ -68,12 +70,16 @@ import {
68
70
  } from "./request-log";
69
71
  import { jsonCompletionSse, nativeChatSse, structuredError, usageFromChat } from "./chat-native-sse";
70
72
  import { registerTurn, unregisterTurn } from "./lifecycle";
73
+ import { attachRequestSpendTracker } from "./responses/request-spend";
74
+ import { workflowRefusalResponse } from "./workflow-refusal";
71
75
 
72
76
  type Rec = Record<string, unknown>;
73
77
 
74
78
  const MAX_NATIVE_CHAT_JSON_BYTES = 32 * 1024 * 1024;
75
79
  const MAX_NATIVE_CHAT_ERROR_BYTES = 64 * 1024;
76
80
 
81
+ class NativeChatSpendRefusal extends Error {}
82
+
77
83
  const chatEffortSnapshots = new WeakMap<Rec, {
78
84
  inputModel: string;
79
85
  providerName: string;
@@ -262,6 +268,8 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
262
268
  const proactiveKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, route.provider);
263
269
  if (proactiveKeyProvider) route.provider = proactiveKeyProvider;
264
270
  let activeProvider: OcxProviderConfig = route.provider;
271
+ stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
272
+ const spendTracker = attachRequestSpendTracker(req, logCtx);
265
273
  let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider);
266
274
  let activeRequest: AdapterRequest;
267
275
  let retainedRequestBytes = 0;
@@ -339,6 +347,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
339
347
  throw new Error("Provider key selection is no longer available for native Chat");
340
348
  }
341
349
  activeProvider = current;
350
+ stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
342
351
  activeAdapter = createOpenAIChatAdapter(current);
343
352
  activeRequest.releaseBodyObservation?.();
344
353
  releaseRetainedRequest();
@@ -353,6 +362,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
353
362
  const encoding = new Headers(init.headers).get("accept-encoding");
354
363
  if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding);
355
364
  if (init.signal?.aborted) throw init.signal.reason;
365
+ if (!spendTracker.charge()) throw new NativeChatSpendRefusal();
356
366
  noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
357
367
  // A reselected provider transport is still a physical send: the connection policy
358
368
  // and manual-redirect ownership wrap the selected implementation (#4992).
@@ -432,6 +442,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
432
442
  if (!transientSendAvailable()) break;
433
443
  try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
434
444
  activeProvider = rotated;
445
+ stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
435
446
  activeAdapter = createOpenAIChatAdapter(activeProvider);
436
447
  releaseRetainedRequest();
437
448
  activeRequest = buildActiveRequest();
@@ -443,6 +454,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
443
454
  cleanupAbort();
444
455
  upstream.abort();
445
456
  if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled");
457
+ const sendError = error instanceof UpstreamRetryEvidenceError ? error.cause : error;
458
+ if (sendError instanceof NativeChatSpendRefusal) {
459
+ const refusal = workflowRefusalResponse("workflow-spend-exhausted", logCtx);
460
+ finishLog(429);
461
+ return refusal;
462
+ }
446
463
  if (isTranslatorBudgetExceededError(error)) {
447
464
  return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit");
448
465
  }
@@ -740,9 +740,10 @@ async function handleClaudeMessagesWithBudget(
740
740
  );
741
741
  if (claudeConversationId) logCtx.conversationId = claudeConversationId;
742
742
  }
743
- // A fast row blocks passthrough, unlike the chat case: this path forwards to Anthropic's
744
- // own API, whose wire has no service_tier field and whose FastWire kind has an empty
745
- // adapter set by design, so the tier would be silently dropped.
743
+ // A fast row blocks passthrough, unlike the chat case: native passthrough forwards the
744
+ // caller's body with the caller's credential and never runs the Anthropic adapter, so the
745
+ // proxy-owned `speed` + beta (anthropic-speed wire) and its usage.speed observation would be
746
+ // silently skipped. Translation reaches the adapter, which owns both.
746
747
  if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) {
747
748
  return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
748
749
  }
@@ -6,6 +6,7 @@ const DIRECT_LOCAL_HTTP_TIMEOUT_MS = 10_000;
6
6
  type DirectLocalHttpIo = {
7
7
  timeoutMs?: number;
8
8
  connect?: (hostname: string, port: number) => Socket;
9
+ scheduleDeadline?: (onTimeout: () => void, timeoutMs: number) => () => void;
9
10
  };
10
11
 
11
12
  function abortReason(signal: AbortSignal): Error {
@@ -270,18 +271,22 @@ export async function directLocalHttpFetch(
270
271
  return await new Promise<Response>((resolve, reject) => {
271
272
  let socket: Socket | undefined;
272
273
  let settled = false;
274
+ let closed = false;
275
+ let completed = false;
276
+ let outcomeError: Error | undefined;
277
+ let cancelDeadline: (() => void) | undefined;
273
278
  let receivedBytes = 0;
274
279
  let responseBytes = Buffer.allocUnsafe(4 * 1024);
275
280
  let framing: ResponseFraming = { kind: "head", searchFrom: 0 };
276
- const cleanup = () => {
281
+ const disableSocketTimeout = () => { socket?.setTimeout(0); };
282
+ const settleAfterClose = (deadlineError?: Error) => {
283
+ if (!settled || (!closed && !deadlineError) || completed) return;
284
+ completed = true;
285
+ // Framing may finish before close, so the caller's deadline owns teardown too.
277
286
  signal?.removeEventListener("abort", onAbort);
278
- socket?.setTimeout(0);
279
- };
280
- const finish = (error?: Error) => {
281
- if (settled) return;
282
- settled = true;
283
- cleanup();
284
- try { socket?.destroy(); } catch { /* ignore */ }
287
+ cancelDeadline?.();
288
+ cancelDeadline = undefined;
289
+ const error = outcomeError ?? deadlineError;
285
290
  if (error) {
286
291
  reject(error);
287
292
  return;
@@ -292,9 +297,28 @@ export async function directLocalHttpFetch(
292
297
  reject(parseError instanceof Error ? parseError : new Error(String(parseError)));
293
298
  }
294
299
  };
300
+ const finish = (error?: Error) => {
301
+ if (settled) return;
302
+ settled = true;
303
+ outcomeError = error;
304
+ disableSocketTimeout();
305
+ try { socket?.destroy(); } catch (destroyError) {
306
+ settleAfterClose(error ?? (destroyError instanceof Error ? destroyError : new Error(String(destroyError))));
307
+ return;
308
+ }
309
+ settleAfterClose();
310
+ };
295
311
  const onAbort = () => {
296
312
  const error = signal ? abortReason(signal) : new Error("direct local HTTP request aborted");
313
+ if (settled) outcomeError ??= error;
314
+ else finish(error);
315
+ if (error.name === "TimeoutError") settleAfterClose(error);
316
+ };
317
+ const onDeadline = () => {
318
+ const error = new Error("direct local HTTP request timed out");
319
+ error.name = "TimeoutError";
297
320
  finish(error);
321
+ settleAfterClose(error);
298
322
  };
299
323
 
300
324
  socket = (io.connect ?? ((host, selectedPort) => net.createConnection({
@@ -302,17 +326,9 @@ export async function directLocalHttpFetch(
302
326
  port: selectedPort,
303
327
  autoSelectFamily: true,
304
328
  })))(hostname, port);
305
- socket.setTimeout(timeoutMs, () => {
306
- const error = new Error("direct local HTTP request timed out");
307
- error.name = "TimeoutError";
308
- finish(error);
309
- });
310
- signal?.addEventListener("abort", onAbort, { once: true });
311
- if (signal?.aborted) {
312
- onAbort();
313
- return;
314
- }
329
+ socket.setTimeout(timeoutMs, onDeadline);
315
330
  socket.on("connect", () => {
331
+ if (settled) return;
316
332
  try { socket?.write(requestBytes); } catch (error) {
317
333
  finish(error instanceof Error ? error : new Error(String(error)));
318
334
  }
@@ -342,6 +358,16 @@ export async function directLocalHttpFetch(
342
358
  });
343
359
  socket.once("end", () => finish());
344
360
  socket.once("error", error => finish(error));
345
- socket.once("close", () => finish());
361
+ socket.once("close", () => {
362
+ closed = true;
363
+ finish();
364
+ settleAfterClose();
365
+ });
366
+ cancelDeadline = (io.scheduleDeadline ?? ((callback, delayMs) => {
367
+ const timer = setTimeout(callback, delayMs);
368
+ return () => clearTimeout(timer);
369
+ }))(onDeadline, timeoutMs);
370
+ signal?.addEventListener("abort", onAbort, { once: true });
371
+ if (signal?.aborted) onAbort();
346
372
  });
347
373
  }
@@ -239,10 +239,8 @@ function findPairingGrant(
239
239
  state: GuiSessionState,
240
240
  ): [string, GuiPairingGrantRecord] | null {
241
241
  const digest = pairingGrantDigest(grant);
242
- for (const [candidate, record] of state.pairingGrants) {
243
- if (equalSecret(candidate, digest)) return [candidate, record];
244
- }
245
- return null;
242
+ const record = state.pairingGrants.get(digest);
243
+ return record ? [digest, record] : null;
246
244
  }
247
245
 
248
246
  function consumeGrantRateSlot(state: GuiSessionState, now: number): void {
@@ -341,19 +339,12 @@ export function consumeGuiPairingGrant(
341
339
  tailscaleUser: null,
342
340
  browserOrigin,
343
341
  };
344
- const sourceRecord = attemptContext
345
- ? pairingSourceAttempts.get(state)?.get(pairingSourceKey(context))
346
- : undefined;
347
- if (sourceRecord && sourceRecord.windowStartedAt + PAIRING_SOURCE_WINDOW_MS > now
348
- && sourceRecord.failures >= PAIRING_SOURCE_FAILURE_LIMIT) {
349
- return {
350
- allowed: false,
351
- retryAfterSeconds: Math.max(1, Math.ceil((sourceRecord.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now) / 1000)),
352
- reason: "source",
353
- };
354
- }
342
+ // Source throttling is only a cost bound for invalid guesses. Look up the grant
343
+ // first so callers sharing a proxy address cannot lock out a valid redemption.
355
344
  const found = findPairingGrant(grant, state);
356
345
  if (!found) {
346
+ // Cross-origin browser requests must not create limiter state as a side effect.
347
+ if (!isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null;
357
348
  const source = recordSourceFailure(state, context, now);
358
349
  return attemptContext && !source.allowed ? source : null;
359
350
  }
@@ -0,0 +1,53 @@
1
+ import {
2
+ createRuntimePackageTreeIntegrityGuard,
3
+ type PackageTreeIntegrityGuard,
4
+ } from "../../lib/package-tree-integrity";
5
+ import { acceptSystemRestart } from "../management/system-restart";
6
+ import { inspectNativeCodexOwnership } from "../../integrations/native/ownership-preflight";
7
+ import { detectInstall } from "../../update/index";
8
+ import type { StartServerDeps } from "./startup-warnings";
9
+
10
+ // Production guard wiring for the package-tree integrity fence. Tests inject
11
+ // deps.packageTreeIntegrity and never reach this path; everything else is the
12
+ // same default the inline construction used to build.
13
+ export function createPackageTreeIntegrityGuardForServer(
14
+ deps: StartServerDeps,
15
+ serviceHomeOwned: () => boolean = deps.packageTreeServiceHomeOwned
16
+ ?? (() => inspectNativeCodexOwnership().ownership === "owned"),
17
+ isServiceChild: () => boolean = deps.packageTreeServiceChild ?? (() => process.env.OCX_SERVICE === "1"),
18
+ ): PackageTreeIntegrityGuard {
19
+ if (deps.packageTreeIntegrity) {
20
+ return deps.packageTreeIntegrity;
21
+ }
22
+ const acceptPackageTreeRestart = deps.acceptSystemRestart ?? acceptSystemRestart;
23
+ let vetoAcceptedRestart: (() => void) | undefined;
24
+ const guard = createRuntimePackageTreeIntegrityGuard(
25
+ deps.packageTreeInstaller ?? detectInstall(),
26
+ deps.observePackageTree,
27
+ undefined,
28
+ {
29
+ ...deps.packageTreeIntegrityOptions,
30
+ onReplaced: () => {
31
+ // An out-of-band install replaced the package under this live process. Serve
32
+ // the 503 for the triggering request, then let the standard drain-and-restart
33
+ // path bring the new tree up instead of refusing traffic until a manual
34
+ // restart. acceptSystemRestart is idempotent and supervisor-aware.
35
+ const beforeScheduledDrain = () => !isServiceChild() || serviceHomeOwned();
36
+ if (!beforeScheduledDrain()) throw new Error("service home ownership changed");
37
+ acceptPackageTreeRestart(undefined, {
38
+ onAccepted: veto => { vetoAcceptedRestart = veto; },
39
+ beforeScheduledDrain,
40
+ });
41
+ },
42
+ },
43
+ );
44
+ return {
45
+ status: () => guard.status(),
46
+ installedVersion: () => guard.installedVersion?.(),
47
+ dispose: () => {
48
+ guard.dispose();
49
+ vetoAcceptedRestart?.();
50
+ vetoAcceptedRestart = undefined;
51
+ },
52
+ };
53
+ }
@@ -168,6 +168,7 @@ import {
168
168
  } from "../../lib/local-management-attestation";
169
169
  import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../../lib/system-restart-contract";
170
170
  import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../../lib/local-provider-reload-contract";
171
+ import { LOCAL_ASIDE_SYNC_CAPABILITY_VERSION } from "../../lib/local-aside-sync-contract";
171
172
  import {
172
173
  GUI_PAIR_BROWSER_ORIGIN_HEADER,
173
174
  GUI_PAIR_CAPABILITY_VERSION,
@@ -350,20 +351,32 @@ export function createServeOptions(ctx: ServeOptionsContext) {
350
351
  || readyzPath !== undefined
351
352
  || url.pathname.startsWith("/v1/")
352
353
  )) {
353
- const message = "OpenCodex package files changed while this proxy was running; restart OpenCodex before retrying.";
354
- const response = url.pathname === "/healthz" || readyzPath !== undefined
354
+ const message = "OpenCodex package files changed while this proxy was running; it restarts on its own, or run 'ocx restart' ('ocx service restart' for a background service).";
355
+ const fencedPort = ctx.boundPort ?? requestServer.port ?? listenPort;
356
+ const fencedHealth = url.pathname === "/healthz";
357
+ const response = fencedHealth || readyzPath !== undefined
355
358
  ? jsonResponse({
356
359
  status: "restart_required",
357
360
  service: "opencodex",
358
361
  version: VERSION,
359
362
  uptime: process.uptime(),
360
363
  pid: process.pid,
361
- port: ctx.boundPort ?? requestServer.port ?? listenPort,
364
+ port: fencedPort,
365
+ // Identity stays attestable while readiness is fenced (#5496): the CLI can only
366
+ // restart or stop what it can prove it owns, and an unverified 503 body is not proof.
367
+ // installedVersion is what an in-place respawn will run from the replaced tree.
368
+ ...(fencedHealth ? {
369
+ restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
370
+ installedVersion: packageTreeIntegrity.installedVersion?.(),
371
+ } : {}),
362
372
  error: { code: "package_tree_changed", message },
363
373
  }, 503, req, policy)
364
374
  : packageTreeChangedResponse(req, policy, message);
365
375
  const headers = new Headers(response.headers);
366
376
  headers.set("Retry-After", "5");
377
+ const challenge = fencedHealth ? req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER) : null;
378
+ const proof = challenge ? createLocalAttestationProof(localAttestationSecret, challenge, process.pid, fencedPort) : null;
379
+ if (proof) headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof);
367
380
  return new Response(response.body, { status: 503, headers });
368
381
  }
369
382
 
@@ -565,6 +578,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
565
578
  port: healthPort,
566
579
  restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
567
580
  providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION,
581
+ asideSyncCapability: LOCAL_ASIDE_SYNC_CAPABILITY_VERSION,
568
582
  guiPairCapability: GUI_PAIR_CAPABILITY_VERSION,
569
583
  }, 200, req, policy);
570
584
  const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
@@ -6,6 +6,11 @@ import {
6
6
  type OwnershipInspection,
7
7
  } from "../../integrations/native/ownership-preflight";
8
8
  import { registerCodexQuotaAutoRefreshWorker } from "../../codex/quota-auto-refresh";
9
+ import type {
10
+ ObservePackageTree,
11
+ PackageTreeIntegrityOptions,
12
+ PackageTreeRuntimeInstall,
13
+ } from "../../lib/package-tree-integrity";
9
14
  import {
10
15
  consumeForInspection,
11
16
  relaySseWithHeartbeat,
@@ -141,6 +146,18 @@ export interface StartServerDeps {
141
146
  readinessGate?: ReadinessGate;
142
147
  /** Test-only package-tree observation; production captures package.json identity at boot. */
143
148
  packageTreeIntegrity?: PackageTreeIntegrityGuard;
149
+ /** Test-only default-guard options; production observes the installed package manifest. */
150
+ packageTreeIntegrityOptions?: PackageTreeIntegrityOptions;
151
+ /** Test-only installed-package identity; production detects the current install. */
152
+ packageTreeInstaller?: PackageTreeRuntimeInstall;
153
+ /** Test-only manifest observer; production stats the installed package.json. */
154
+ observePackageTree?: ObservePackageTree;
155
+ /** Test-only restart acceptor; production uses the normal drain-and-restart path. */
156
+ acceptSystemRestart?: typeof import("../management/system-restart").acceptSystemRestart;
157
+ /** Test-only: whether this process is a service child, for the package-tree restart. */
158
+ packageTreeServiceChild?: () => boolean;
159
+ /** Test-only: whether this service child still owns its service home. */
160
+ packageTreeServiceHomeOwned?: () => boolean;
144
161
  /** Test-only seam for observing quota-worker registration ownership. */
145
162
  registerCodexQuotaAutoRefreshWorker?: typeof registerCodexQuotaAutoRefreshWorker;
146
163
  }
@@ -78,6 +78,7 @@ export {
78
78
  import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile, serveSessionBootstrap } from "./gui-static";
79
79
  export { resolveGuiFilePath, rootFallbackPayload } from "./gui-static";
80
80
  export { resolveAdapter } from "./adapter-resolve";
81
+ export { noteExplicitShutdownRequested } from "./management/system-restart";
81
82
  import { formatErrorResponse, type ResponsesTerminalStatus } from "../bridge";
82
83
  import {
83
84
  drainAndShutdown,
@@ -193,13 +194,9 @@ import {
193
194
  createLocalAttestationSecret,
194
195
  } from "../lib/local-management-attestation";
195
196
  import { createReadinessGate, type ReadinessGate } from "./readiness";
196
- import {
197
- createRuntimePackageTreeIntegrityGuard,
198
- type PackageTreeIntegrityGuard,
199
- } from "../lib/package-tree-integrity";
200
- import { detectInstall } from "../update/index";
201
197
  import { createServeOptions, type ServerIngress } from "./index/serve-options";
202
198
  import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle";
199
+ import { createPackageTreeIntegrityGuardForServer } from "./index/package-tree-guard";
203
200
  import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings";
204
201
  import { acquireSpendLedgerServerLifecycle, recordFailedStartRollback, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle";
205
202
  export { waitForFailedStartRollback } from "./index/spend-ledger-lifecycle";
@@ -255,10 +252,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
255
252
  const resolveServiceHomes = deps.resolveServiceHomes ?? currentServiceHomes;
256
253
  let startupOwnershipHomes: ReturnType<typeof currentServiceHomes> | null = null;
257
254
  let startupOwnershipStatePaths: readonly string[] | null = null;
258
- // #2923: both synchronous startup ownership decisions keep their fresh,
259
- // race-sensitive targeted task query. Only the expensive fallback listing is
260
- // shared, and only while that targeted result stays byte-for-byte unchanged.
261
- // Runtime ownership retries below intentionally omit this startup-local memo.
255
+ // #2923: retain a successful fallback listing only within the first startup
256
+ // ownership decision. A targeted query's bytes are not a Task Scheduler state
257
+ // generation, so the later race-sensitive decision must take a fresh listing.
258
+ // Runtime ownership retries below intentionally omit this startup-local memo too.
262
259
  const startupWindowsTaskListingCache = createWindowsTaskListingCache();
263
260
  try {
264
261
  const homes = resolveServiceHomes();
@@ -320,12 +317,13 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
320
317
  const listenPort = port ?? config.port ?? 10100;
321
318
  setCorsOrigin(listenPort);
322
319
 
323
- // Canonicalize an explicit "localhost" bind to IPv4 so it matches the injected base_url (which
320
+ // Canonicalize an explicit "localhost" bind (including its fully-qualified spelling) to IPv4
321
+ // so it matches the injected base_url (which
324
322
  // resolves localhost→127.0.0.1): on Windows `localhost` resolves ::1-first, but the injected URL
325
323
  // is 127.0.0.1, so binding literal "localhost" would reintroduce the F4 refusal. Wildcards
326
324
  // (0.0.0.0/::) and specific hosts are left untouched so intentional exposure is preserved.
327
325
  const configuredHost = config.hostname?.trim();
328
- const bindHost = !configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
326
+ const bindHost = !configuredHost || /^localhost\.?$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
329
327
 
330
328
  // Unauthenticated loopback listener (#1102). Off unless explicitly enabled.
331
329
  // A port-less enabled entry is the companion form: same port as the public listener, on
@@ -515,16 +513,14 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
515
513
  // still unreadable to a browser dashboard -- which made exposing it pointless.
516
514
  return withCors(workflowDecisionRefusalResponse(workflow, undefined, refusalLog), req, policy);
517
515
  }
518
- const releaseWorkflow = (): void => { if (workflow?.admitted) workflow.lease.release(); };
516
+ if (workflow?.admitted) lease.attach(workflow.lease);
519
517
  let response: Response;
520
518
  try {
521
519
  response = await runAdmittedBodyWork(req, policy, config.maxInboundBodyBytes, () => work(lease), refusalLog);
522
520
  } catch (error) {
523
- releaseWorkflow();
524
521
  lease.release();
525
522
  throw error;
526
523
  }
527
- releaseWorkflow();
528
524
  if (!lease.isTransferred()) {
529
525
  lease.release();
530
526
  }
@@ -537,8 +533,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
537
533
  // passes it in, and transitions it after the post-startup sync settles. When
538
534
  // no gate is supplied (tests, ad-hoc starts) a fresh pending gate is created.
539
535
  const readinessGate = deps.readinessGate ?? createReadinessGate();
540
- const packageTreeIntegrity = deps.packageTreeIntegrity
541
- ?? createRuntimePackageTreeIntegrityGuard(detectInstall());
536
+ const packageTreeIntegrity = createPackageTreeIntegrityGuardForServer(deps);
542
537
  // Actual bound port, filled in after Bun.serve binds so /readyz reports the
543
538
  // real ephemeral port for startServer(0). /healthz keeps its existing port
544
539
  // field (the requested listenPort) byte-for-byte.
@@ -554,7 +549,6 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
554
549
  deps,
555
550
  startupOwnershipHomes,
556
551
  startupOwnershipStatePaths,
557
- startupWindowsTaskListingCache,
558
552
  );
559
553
  const preparedNativeMainLifecycle = nativeOwnership.ownership !== "foreign"
560
554
  && startupOwnershipHomes !== null
@@ -757,6 +751,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
757
751
  value: async (closeActiveConnections?: boolean): Promise<void> => {
758
752
  remoteWorkspaceStopping = true;
759
753
  liveCallBindings.clear();
754
+ // Disarm the package-tree restart timer before listener teardown: a queued
755
+ // replacement callback must not call acceptSystemRestart() after stop() has
756
+ // begun, or it would schedule a drain-and-restart on a stopped server.
757
+ packageTreeIntegrity.dispose();
760
758
  // The orchestration lives in `runListenerShutdown` so its two competing properties —
761
759
  // cleanup completes, failure propagates — are testable without a live socket.
762
760
  await runListenerShutdown(
@@ -35,6 +35,7 @@ export const MAX_ACTIVE_SESSION_LANES = 64;
35
35
  export const SESSION_LANE_ID_BYTES = 32;
36
36
  const turnGate = createAdmissionGate("active_turns", MAX_ACTIVE_TURNS);
37
37
  export interface ActiveTurnLease extends AdmissionLease {
38
+ attach(lease: AdmissionLease): void;
38
39
  bindAbortController(ac: AbortController): void;
39
40
  beginCodexAccountSelection(): CodexAccountSelectionAdmission;
40
41
  isTransferred(): boolean;
@@ -190,10 +191,15 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
190
191
  }
191
192
  }
192
193
  const controllers = new Set<AbortController>();
194
+ const attachedLeases = new Set<AdmissionLease>();
193
195
  let active = true;
194
196
  let transferred = false;
195
197
  let nativeMainClaimed = false;
196
198
  const lease: ActiveTurnLease = {
199
+ attach(attachedLease) {
200
+ if (!active) attachedLease.release();
201
+ else attachedLeases.add(attachedLease);
202
+ },
197
203
  bindAbortController(ac) {
198
204
  knownTurnControllers.add(ac);
199
205
  if (!active) {
@@ -238,6 +244,8 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
238
244
  if (activeTurns.get(controller) === lease) activeTurns.delete(controller);
239
245
  }
240
246
  controllers.clear();
247
+ for (const attachedLease of attachedLeases) attachedLease.release();
248
+ attachedLeases.clear();
241
249
  nativeMainTurns.delete(lease);
242
250
  if (opaqueSessionLaneId) {
243
251
  const currentRefCount = activeSessionLaneRefCounts.get(opaqueSessionLaneId);
@@ -27,7 +27,7 @@ import { codexCompatibleUrl } from "../codex/context-compat";
27
27
  * - `GET /v1/realtime?model=` — RealtimeV2 standalone (no intent)
28
28
  * - `GET /v1/live?model=` — Frameless standalone
29
29
  */
30
- import { appendFileSync } from "node:fs";
30
+ import { closeSync, fchmodSync, fstatSync, openSync, statSync, writeSync } from "node:fs";
31
31
  import { formatErrorResponse } from "../bridge";
32
32
  import {
33
33
  CodexAccountCooldownError,
@@ -42,6 +42,7 @@ import {
42
42
  import { formatCodexProviderForLog } from "../codex/routing";
43
43
  import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
44
44
  import { sidecarEnter } from "../lib/sidecar-tracker";
45
+ import { hardenSecretPath } from "../lib/windows-secret-acl";
45
46
  import type { OcxConfig } from "../types";
46
47
  import { resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
47
48
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "./auth-cors";
@@ -103,9 +104,58 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
103
104
  * JSONL record: direction, frame kind, byte length, and whether the payload contains U+FFFD.
104
105
  * Privacy: no frame content is written, including excerpts around replacement characters.
105
106
  * For binary frames, U+FFFD may also be introduced by UTF-8 decoding; the flag alone does not
106
- * identify the source of corruption. Disabled entirely when the env var is unset.
107
+ * identify the source of corruption. The log is created with owner-only permissions and is
108
+ * disabled entirely when the env var is unset.
107
109
  */
108
110
  export const LIVE_FRAME_LOG_ENV = "OCX_LIVE_FRAME_LOG";
111
+ /**
112
+ * Append one JSONL record with owner-only permissions. `appendFileSync`'s `mode` only applies
113
+ * when it creates the file, so an existing permissive log would stay readable by other local
114
+ * users. Open for append, harden the target, and verify that the path still names
115
+ * the opened file before writing. A failed harden or identity check writes nothing. On Windows
116
+ * the hardened file's identity is remembered, so a replaced file is hardened again but an
117
+ * unchanged one does not spawn icacls for every frame.
118
+ */
119
+ /** Windows ACL hardening spawns icacls; remember the file already hardened so frames do not. */
120
+ let hardenedWindowsFrameLog: { path: string; dev: bigint; ino: bigint } | undefined;
121
+
122
+ export function appendOwnerOnly(
123
+ path: string,
124
+ line: string,
125
+ harden: (fd: number, path: string) => void = hardenLogDescriptor,
126
+ platform: NodeJS.Platform = process.platform,
127
+ ): void {
128
+ const fd = openSync(path, "a", 0o600);
129
+ try {
130
+ const before = fstatSync(fd, { bigint: true });
131
+ const memo = hardenedWindowsFrameLog;
132
+ const alreadyHardened = platform === "win32" && memo !== undefined && memo.path === path
133
+ && before.ino !== 0n && memo.dev === before.dev && memo.ino === before.ino;
134
+ if (!alreadyHardened) {
135
+ hardenedWindowsFrameLog = undefined;
136
+ harden(fd, path);
137
+ }
138
+ const opened = fstatSync(fd, { bigint: true });
139
+ const named = statSync(path, { bigint: true });
140
+ if (!opened.isFile() || !named.isFile() || opened.ino === 0n || named.ino === 0n
141
+ || opened.dev !== named.dev || opened.ino !== named.ino) {
142
+ throw new Error("Frame log path changed during hardening.");
143
+ }
144
+ if (platform === "win32") hardenedWindowsFrameLog = { path, dev: opened.dev, ino: opened.ino };
145
+ writeSync(fd, line);
146
+ } finally {
147
+ closeSync(fd);
148
+ }
149
+ }
150
+
151
+ function hardenLogDescriptor(fd: number, path: string): void {
152
+ if (process.platform === "win32") {
153
+ hardenSecretPath(path, { required: true });
154
+ return;
155
+ }
156
+ fchmodSync(fd, 0o600);
157
+ }
158
+
109
159
  export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void {
110
160
  const logPath = process.env[LIVE_FRAME_LOG_ENV];
111
161
  if (!logPath) return;
@@ -134,7 +184,7 @@ export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void {
134
184
  bytes,
135
185
  fffd,
136
186
  };
137
- appendFileSync(logPath, `${JSON.stringify(record)}\n`);
187
+ appendOwnerOnly(logPath, `${JSON.stringify(record)}\n`);
138
188
  } catch {
139
189
  // Frame forensics must never break the relay.
140
190
  }
@@ -168,7 +218,7 @@ export function logLiveSidebandStage(
168
218
  const record: Record<string, unknown> = { ts: new Date().toISOString(), stage };
169
219
  if (detail?.status !== undefined) record.status = detail.status;
170
220
  if (detail?.code !== undefined) record.code = detail.code;
171
- appendFileSync(logPath, JSON.stringify(record) + "\n");
221
+ appendOwnerOnly(logPath, JSON.stringify(record) + "\n");
172
222
  } catch {
173
223
  // Diagnostics must never break the relay.
174
224
  }