@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
@@ -44,10 +44,19 @@ export interface RaycastDetectDeps {
44
44
  */
45
45
  const RAYCAST_DEFAULTS_DOMAIN = "com.raycast.macos.v1";
46
46
  const RAYCAST_SUBSCRIPTION_KEY = "subscriptions_active";
47
+ const DEFAULTS_PATH = "/usr/bin/defaults";
48
+ const DEFAULTS_TIMEOUT_MS = 2_000;
47
49
 
48
- export function realRaycastDetectDeps(): RaycastDetectDeps {
50
+ interface RealRaycastDetectRuntime {
51
+ platform?: string;
52
+ spawnSync?: typeof Bun.spawnSync;
53
+ }
54
+
55
+ export function realRaycastDetectDeps(runtime: RealRaycastDetectRuntime = {}): RaycastDetectDeps {
56
+ const platform = runtime.platform ?? process.platform;
57
+ const spawnSync = runtime.spawnSync ?? Bun.spawnSync;
49
58
  return {
50
- platform: process.platform,
59
+ platform,
51
60
  homedir: homedir(),
52
61
  env: process.env,
53
62
  exists: path => {
@@ -59,9 +68,15 @@ export function realRaycastDetectDeps(): RaycastDetectDeps {
59
68
  },
60
69
  readDefault: (domain, key) => {
61
70
  // `defaults` is macOS-only; elsewhere the plan is simply unknown.
62
- if (process.platform !== "darwin") return null;
71
+ if (platform !== "darwin") return null;
63
72
  try {
64
- const result = Bun.spawnSync(["defaults", "read", domain, key], { stdout: "pipe", stderr: "pipe" });
73
+ const result = spawnSync([DEFAULTS_PATH, "read", domain, key], {
74
+ stdout: "pipe",
75
+ stderr: "pipe",
76
+ timeout: DEFAULTS_TIMEOUT_MS,
77
+ });
78
+ // A timed-out or signal-killed probe reports exitCode === null; that
79
+ // and any non-zero exit mean the preference was not read.
65
80
  if (result.exitCode !== 0) return null;
66
81
  return result.stdout.toString().trim();
67
82
  } catch {
@@ -177,7 +177,17 @@ export function quoteTomlKey(key: string): string {
177
177
  function tomlScalar(value: unknown): string {
178
178
  if (typeof value === "string") return tomlString(value);
179
179
  if (typeof value === "boolean") return value ? "true" : "false";
180
- if (typeof value === "number" && Number.isFinite(value)) return String(value);
180
+ if (typeof value === "number" && Number.isFinite(value)) {
181
+ // Bun.TOML.parse returns TOML integers as JavaScript numbers. Values outside
182
+ // the safe range may already have been rounded, so writing them back would
183
+ // silently alter a user-owned config rather than merely reformatting it.
184
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
185
+ throw new UnserializableValueError(
186
+ "TOML cannot safely rewrite an integer outside JavaScript's safe range",
187
+ );
188
+ }
189
+ return String(value);
190
+ }
181
191
  /*
182
192
  * Arrays of ANY scalar, not just strings. The string-only check was written
183
193
  * against our own builder output; a user's config legitimately holds
@@ -15,6 +15,8 @@ export interface BoundedBodyOptions {
15
15
  * Reader cancellation and lock release still run. Defaults to false.
16
16
  */
17
17
  fatalUtf8?: boolean;
18
+ /** Report UTF-8 validity without rejecting malformed bodies. */
19
+ reportUtf8Validity?: boolean;
18
20
  /**
19
21
  * Byte ceiling for retained body data. Defaults to BOUNDED_BODY_MAX_BYTES (64 KiB),
20
22
  * which suits error bodies; callers materializing whole success payloads (e.g. a
@@ -44,6 +46,8 @@ export interface BoundedBodyResult {
44
46
  oversized: boolean;
45
47
  /** False means callers should use a status-only fallback, not `text`. */
46
48
  displaySafe: boolean;
49
+ /** Present when reportUtf8Validity was requested and the retained body reached EOF. */
50
+ utf8Valid?: boolean;
47
51
  }
48
52
 
49
53
  export interface BoundedBytesOptions {
@@ -238,6 +242,14 @@ function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean, timedOut = fa
238
242
  }
239
243
  }
240
244
 
245
+ function decodeUtf8WithValidity(bytes: Uint8Array): { text: string; utf8Valid: boolean } {
246
+ try {
247
+ return { text: decodeUtf8([bytes], true), utf8Valid: true };
248
+ } catch {
249
+ return { text: decodeUtf8([bytes], false), utf8Valid: false };
250
+ }
251
+ }
252
+
241
253
  /**
242
254
  * Consume the original response body under strict memory and time bounds.
243
255
  *
@@ -326,6 +338,24 @@ export async function readBoundedResponseBody(
326
338
 
327
339
  const { value, done } = outcome as ReadableStreamReadResult<Uint8Array>;
328
340
  if (done) {
341
+ if (options.reportUtf8Validity) {
342
+ const bytes = retained.subarray(0, retainedBytes);
343
+ // A fatal decode that returned already proved the bytes valid; still
344
+ // honour the reporting contract instead of dropping utf8Valid.
345
+ const decoded = options.fatalUtf8 === true
346
+ ? { text: decodeUtf8([bytes], true), utf8Valid: true }
347
+ : decodeUtf8WithValidity(bytes);
348
+ return {
349
+ text: decoded.text,
350
+ truncated: false,
351
+ timedOut: false,
352
+ totalTimedOut: false,
353
+ inactivityTimedOut: false,
354
+ oversized: false,
355
+ displaySafe: true,
356
+ utf8Valid: decoded.utf8Valid,
357
+ };
358
+ }
329
359
  return {
330
360
  text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true),
331
361
  truncated: false,
@@ -150,7 +150,9 @@ function safeStringify(value: unknown): string {
150
150
 
151
151
  let benignSuppressed = 0;
152
152
  let benignLastLoggedAt = 0;
153
+ let benignLastOrigin: string | undefined;
153
154
  const BENIGN_LOG_INTERVAL_MS = 5 * 60_000;
155
+ const MAX_BENIGN_ORIGIN_BYTES = 1024;
154
156
 
155
157
  /**
156
158
  * Bun raises an off-path `unhandledRejection: TypeError: null is not an object` (native-only stack)
@@ -176,17 +178,53 @@ export function isBenignAbortTeardown(err: unknown): boolean {
176
178
  const lockedStreamTeardown = err.message === "Invalid state: ReadableStream is locked"
177
179
  && (err as { code?: unknown }).code === "ERR_INVALID_STATE";
178
180
  if (!bareNullTeardown && !lockedStreamTeardown) return false;
179
- const stack = err.stack ?? "";
180
- // Native-only: no JS source frame. A real app TypeError would carry a `(file:line:col)` frame.
181
- return !/\((?!native:)[^)]*:\d+:\d+\)/.test(stack);
181
+ // Native-only: no JS source frame, parenthesized or not. Hidden JSC source fields
182
+ // (sourceURL/line/column) do not decide this: Bun can attach them to errors raised from
183
+ // its own builtin frames, and the benign summary still records them through diagnose().
184
+ return !hasJsSourceFrame(err.stack ?? "");
185
+ }
186
+
187
+ /**
188
+ * True when a stack line is an `at …` frame ending in `line:col` (optionally inside
189
+ * parentheses) whose location is not a Bun builtin (`native:`). Covers `at fn (/abs/x.ts:1:2)`,
190
+ * `at /abs/x.ts:1:2`, `at async fn (file:///x.ts:1:2)` and Windows drive paths.
191
+ */
192
+ function hasJsSourceFrame(stack: string): boolean {
193
+ return stack.split(/\r?\n/).some(raw => {
194
+ const frame = raw.trim();
195
+ return frame.startsWith("at ")
196
+ && /:\d+:\d+\)?$/.test(frame)
197
+ && !/[(\s]native:\d+:\d+\)?$/.test(frame);
198
+ });
199
+ }
200
+
201
+ /**
202
+ * The JSC hidden throw site (`sourceURL:line:col`), when the error carries one. Best-effort:
203
+ * this runs inside the process crash handler, so an accessor that throws yields no site.
204
+ */
205
+ function hiddenThrowSite(err: unknown): string | undefined {
206
+ try {
207
+ if (!err || typeof err !== "object") return undefined;
208
+ const e = err as Record<string, unknown>;
209
+ if (typeof e.sourceURL !== "string" || !e.sourceURL) return undefined;
210
+ const site = `${e.sourceURL}:${String(e.line ?? e.originalLine ?? "")}:${String(e.column ?? e.originalColumn ?? "")}`;
211
+ return truncateRetainedUtf8(site, MAX_BENIGN_ORIGIN_BYTES);
212
+ } catch {
213
+ return undefined;
214
+ }
182
215
  }
183
216
 
184
217
  function record(kind: string, err: unknown, promise?: unknown): void {
185
218
  if (kind === "unhandledRejection" && isBenignAbortTeardown(err)) {
186
219
  benignSuppressed++;
187
220
  const now = Date.now();
188
- if (now - benignLastLoggedAt < BENIGN_LOG_INTERVAL_MS) return; // fold repeats silently
221
+ // A throw site JSC recorded on hidden fields is new information when it differs from
222
+ // the last one logged, so it is written even inside the fold window; repeats still fold.
223
+ const origin = hiddenThrowSite(err);
224
+ const novelOrigin = origin !== undefined && origin !== benignLastOrigin;
225
+ if (!novelOrigin && now - benignLastLoggedAt < BENIGN_LOG_INTERVAL_MS) return; // fold repeats silently
189
226
  benignLastLoggedAt = now;
227
+ if (origin !== undefined) benignLastOrigin = origin;
190
228
  const summary = `\n[${new Date(now).toISOString()}] benign-abort-teardown x${benignSuppressed}`
191
229
  + ` (Bun fetch-body abort; proxy unaffected)${diagnose(err)}${diagnosePromise(promise)}${breadcrumb()}\n`;
192
230
  benignSuppressed = 0;
@@ -306,6 +344,16 @@ export function resetCrashRingForTests(): void {
306
344
  fetchRingBytes = 0;
307
345
  }
308
346
 
347
+ export function recordCrashForTests(kind: string, err: unknown): void {
348
+ record(kind, err);
349
+ }
350
+
351
+ export function resetBenignFoldForTests(): void {
352
+ benignSuppressed = 0;
353
+ benignLastLoggedAt = 0;
354
+ benignLastOrigin = undefined;
355
+ }
356
+
309
357
  /** Render the recent fetch ring (pending first) for the crash breadcrumb. */
310
358
  function recentFetches(): string {
311
359
  try {
@@ -0,0 +1,41 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { isLocalAttestationSecret } from "./local-management-attestation";
3
+
4
+ export const LOCAL_ASIDE_SYNC_METHOD = "POST";
5
+ export const LOCAL_ASIDE_SYNC_PATH = "/api/client-integrations/aside/sync";
6
+ export const LOCAL_ASIDE_SYNC_CAPABILITY_VERSION = "v1";
7
+ export const LOCAL_ASIDE_SYNC_EXPECTED_PID_HEADER = "x-opencodex-aside-sync-expected-pid";
8
+ export const LOCAL_ASIDE_SYNC_NONCE_HEADER = "x-opencodex-aside-sync-nonce";
9
+ export const LOCAL_ASIDE_SYNC_EXPIRES_AT_HEADER = "x-opencodex-aside-sync-expires-at";
10
+ export const LOCAL_ASIDE_SYNC_CAPABILITY_HEADER = "x-opencodex-aside-sync-capability";
11
+ export const LOCAL_ASIDE_SYNC_CAPABILITY_TTL_MS = 10_000;
12
+
13
+ const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
14
+
15
+ export function parseExpectedLocalAsideSyncPid(value: string | null): number | null {
16
+ if (!value || !/^[1-9]\d*$/.test(value)) return null;
17
+ const pid = Number(value);
18
+ return Number.isSafeInteger(pid) ? pid : null;
19
+ }
20
+
21
+ function payload(nonce: string, method: string, path: string, pid: number, port: number, expiresAt: number): string | null {
22
+ if (!BASE64URL_256.test(nonce) || method !== LOCAL_ASIDE_SYNC_METHOD || path !== LOCAL_ASIDE_SYNC_PATH) return null;
23
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isInteger(port) || port <= 0 || port > 65535) return null;
24
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null;
25
+ return `opencodex-local-aside-sync-v1\n${nonce}\n${method}\n${path}\n${pid}\n${port}\n${expiresAt}`;
26
+ }
27
+
28
+ export function createLocalAsideSyncCapability(secret: string, nonce: string, method: string, path: string, pid: number, port: number, expiresAt: number): string | null {
29
+ if (!isLocalAttestationSecret(secret)) return null;
30
+ const value = payload(nonce, method, path, pid, port, expiresAt);
31
+ return value ? createHmac("sha256", secret).update(value).digest("base64url") : null;
32
+ }
33
+
34
+ export function verifyLocalAsideSyncCapability(secret: string, nonce: string | null, method: string, path: string, pid: number, port: number, expiresAt: number, capability: string | null, now = Date.now()): boolean {
35
+ if (!nonce || !capability || !BASE64URL_256.test(capability) || expiresAt <= now || expiresAt > now + LOCAL_ASIDE_SYNC_CAPABILITY_TTL_MS) return false;
36
+ const expected = createLocalAsideSyncCapability(secret, nonce, method, path, pid, port, expiresAt);
37
+ if (!expected) return false;
38
+ const expectedBytes = Buffer.from(expected);
39
+ const actualBytes = Buffer.from(capability);
40
+ return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
41
+ }
@@ -1,4 +1,4 @@
1
- import { statSync } from "node:fs";
1
+ import { readFileSync, statSync } from "node:fs";
2
2
  import { isStandaloneBinary } from "./standalone";
3
3
 
4
4
  export interface PackageTreeObservation {
@@ -14,13 +14,66 @@ export type PackageTreeIntegrityStatus =
14
14
 
15
15
  export interface PackageTreeIntegrityGuard {
16
16
  status(): PackageTreeIntegrityStatus;
17
+ /**
18
+ * Version recorded in the package manifest that is on disk NOW, once the replacement has
19
+ * settled. A fenced proxy reports it so `ocx restart` compares the CLI with the files an
20
+ * in-place respawn would run, not with the version this process booted from.
21
+ *
22
+ * A readable manifest is not an install-completion signal: npm can write package.json while
23
+ * it is still extracting the rest of the tree. So this stays undefined until the guard's own
24
+ * stability debounce has seen the same replacement identity for the full interval, and again
25
+ * whenever the tree has moved since. Undefined also covers an unreadable or malformed manifest.
26
+ */
27
+ installedVersion?(): string | undefined;
28
+ /**
29
+ * Permanently disarms the guard: cancels any pending restart timer and
30
+ * invalidates queued callbacks. Called from `server.stop()` so a still-queued
31
+ * replacement callback cannot schedule a drain-and-restart after shutdown
32
+ * has already begun.
33
+ */
34
+ dispose(): void;
17
35
  }
18
36
 
19
- type ObservePackageTree = () => PackageTreeObservation | null;
20
- type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source";
37
+ export interface PackageTreeIntegrityOptions {
38
+ /**
39
+ * Called once when a replaced package tree persists past `replacedRestartDelayMs`
40
+ * of sustained failure. The intended handler is the graceful drain-and-restart
41
+ * acceptor: an out-of-band install (npm/bun/pnpm global upgrade under a live
42
+ * proxy) then self-heals instead of serving 503s until someone restarts by hand.
43
+ * Only `package_tree_replaced` counts — an unreadable manifest resets the timer,
44
+ * so an install still mid-write does not trigger a restart on partial state.
45
+ */
46
+ onReplaced?: () => void;
47
+ /** Sustained-replacement delay before `onReplaced` fires. 0 fires on first detection. */
48
+ replacedRestartDelayMs?: number;
49
+ /**
50
+ * Test seam; production uses an unref'd timer. May return a cancellation
51
+ * function; when it does, `resetRestartTimer` cancels the pending callback
52
+ * instead of leaving it queued behind a generation check.
53
+ */
54
+ schedule?: (callback: () => void, delayMs: number) => (() => void) | void;
55
+ /** Test seam for `installedVersion()`; production reads the package manifest. */
56
+ readInstalledVersion?: () => string | undefined;
57
+ }
58
+
59
+ export type ObservePackageTree = () => PackageTreeObservation | null;
60
+ export type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source";
21
61
 
22
62
  const packageManifestUrl = new URL("../../package.json", import.meta.url);
23
63
 
64
+ const INSTALLED_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
65
+
66
+ function readInstalledManifestVersion(): string | undefined {
67
+ try {
68
+ const version = (JSON.parse(readFileSync(packageManifestUrl, "utf8")) as { version?: unknown }).version;
69
+ return typeof version === "string" && version.length <= 64 && INSTALLED_VERSION_PATTERN.test(version)
70
+ ? version
71
+ : undefined;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
24
77
  function observePackageManifest(): PackageTreeObservation | null {
25
78
  try {
26
79
  const stat = statSync(packageManifestUrl, { bigint: true });
@@ -70,17 +123,133 @@ const PACKAGE_TREE_RECHECK_MS = 1_000;
70
123
  export function createPackageTreeIntegrityGuard(
71
124
  observe: ObservePackageTree = observePackageManifest,
72
125
  now: () => number = Date.now,
126
+ options: PackageTreeIntegrityOptions = {},
73
127
  ): PackageTreeIntegrityGuard {
74
128
  const boot = observe();
75
129
  let lastOkAt: number | null = null;
130
+ let notified = false;
131
+ let timerGeneration = 0;
132
+ let timerScheduled = false;
133
+ let cancelScheduled: (() => void) | null = null;
134
+ let waitingForReadableTree = false;
135
+ let replacementCandidate: PackageTreeObservation | null = null;
136
+ /** The replacement identity that survived a full stability interval (see installedVersion). */
137
+ let settledReplacement: PackageTreeObservation | null = null;
138
+ const restartDelayMs = options.replacedRestartDelayMs ?? 5_000;
139
+ const readInstalledVersion = options.readInstalledVersion ?? readInstalledManifestVersion;
140
+ const schedule = options.schedule ?? ((callback, delayMs) => {
141
+ const timer = setTimeout(callback, delayMs);
142
+ timer.unref?.();
143
+ return () => clearTimeout(timer);
144
+ });
145
+
146
+ const resetRestartTimer = (): void => {
147
+ timerGeneration += 1;
148
+ timerScheduled = false;
149
+ const cancel = cancelScheduled;
150
+ cancelScheduled = null;
151
+ cancel?.();
152
+ };
153
+
154
+ const armRestartTimer = (delayMs = restartDelayMs): void => {
155
+ if (!options.onReplaced || notified || timerScheduled) return;
156
+ timerScheduled = true;
157
+ const generation = timerGeneration;
158
+ const verifyAndNotify = () => {
159
+ if (generation !== timerGeneration || notified) return;
160
+ timerScheduled = false;
161
+ const current = observe();
162
+ if (boot === null || current === null) {
163
+ // A package manager may replace package.json before the rest of the tree.
164
+ // Wait for a readable tree, then require a fresh full debounce interval.
165
+ resetRestartTimer();
166
+ waitingForReadableTree = true;
167
+ armRestartTimer(PACKAGE_TREE_RECHECK_MS);
168
+ return;
169
+ }
170
+ if (sameObservation(boot, current)) {
171
+ resetRestartTimer();
172
+ waitingForReadableTree = false;
173
+ replacementCandidate = null;
174
+ return;
175
+ }
176
+ if (waitingForReadableTree) {
177
+ waitingForReadableTree = false;
178
+ replacementCandidate = current;
179
+ resetRestartTimer();
180
+ armRestartTimer();
181
+ return;
182
+ }
183
+ if (replacementCandidate === null || !sameObservation(replacementCandidate, current)) {
184
+ replacementCandidate = current;
185
+ resetRestartTimer();
186
+ armRestartTimer();
187
+ return;
188
+ }
189
+ settledReplacement = current;
190
+ try {
191
+ options.onReplaced?.();
192
+ notified = true;
193
+ } catch {
194
+ // A failed restart admission must not leave the proxy fenced forever.
195
+ // Re-observe after the normal debounce and try again if replacement persists.
196
+ armRestartTimer(Math.max(PACKAGE_TREE_RECHECK_MS, restartDelayMs));
197
+ }
198
+ };
199
+ if (delayMs === 0) {
200
+ // Defer like the scheduled path: verifyAndNotify can arm the next timer, and a
201
+ // synchronous verify inside this frame would re-enter armRestartTimer while this
202
+ // arm is still running.
203
+ queueMicrotask(verifyAndNotify);
204
+ } else {
205
+ // The seam may run the callback synchronously; defer the work so
206
+ // cancelScheduled ownership is settled before verifyAndNotify can
207
+ // re-enter armRestartTimer.
208
+ const cancel = schedule(() => {
209
+ queueMicrotask(verifyAndNotify);
210
+ }, delayMs);
211
+ if (generation === timerGeneration && timerScheduled && typeof cancel === "function") {
212
+ cancelScheduled = cancel;
213
+ }
214
+ }
215
+ };
216
+
76
217
  return {
218
+ installedVersion: () => {
219
+ if (settledReplacement === null) return undefined;
220
+ const current = observe();
221
+ if (current === null || !sameObservation(settledReplacement, current)) return undefined;
222
+ return readInstalledVersion();
223
+ },
224
+ dispose(): void {
225
+ resetRestartTimer();
226
+ notified = true;
227
+ },
77
228
  status(): PackageTreeIntegrityStatus {
78
229
  const at = now();
79
230
  if (lastOkAt !== null && at - lastOkAt < PACKAGE_TREE_RECHECK_MS) return { ok: true };
80
231
  const current = observe();
81
- if (boot === null || current === null) return { ok: false, reason: "package_tree_unreadable" };
82
- if (!sameObservation(boot, current)) return { ok: false, reason: "package_tree_replaced" };
232
+ if (boot === null || current === null) {
233
+ const wasWatchingReplacement = timerScheduled;
234
+ resetRestartTimer();
235
+ if (wasWatchingReplacement && boot !== null) {
236
+ waitingForReadableTree = true;
237
+ armRestartTimer(PACKAGE_TREE_RECHECK_MS);
238
+ }
239
+ return { ok: false, reason: "package_tree_unreadable" };
240
+ }
241
+ if (!sameObservation(boot, current)) {
242
+ if (replacementCandidate === null || !sameObservation(replacementCandidate, current)) {
243
+ replacementCandidate = current;
244
+ resetRestartTimer();
245
+ }
246
+ armRestartTimer();
247
+ return { ok: false, reason: "package_tree_replaced" };
248
+ }
83
249
  lastOkAt = at;
250
+ resetRestartTimer();
251
+ waitingForReadableTree = false;
252
+ replacementCandidate = null;
84
253
  return { ok: true };
85
254
  },
86
255
  };
@@ -96,7 +265,10 @@ export function createRuntimePackageTreeIntegrityGuard(
96
265
  installer: PackageTreeRuntimeInstall,
97
266
  observe: ObservePackageTree = observePackageManifest,
98
267
  now: () => number = Date.now,
99
- ): PackageTreeIntegrityGuard {
100
- if (installer === "source" || isStandaloneBinary()) return { status: () => ({ ok: true }) };
101
- return createPackageTreeIntegrityGuard(observe, now);
102
- }
268
+ options: PackageTreeIntegrityOptions = {},
269
+ ): PackageTreeIntegrityGuard {
270
+ if (installer === "source" || isStandaloneBinary()) {
271
+ return { status: () => ({ ok: true }), dispose: () => {} };
272
+ }
273
+ return createPackageTreeIntegrityGuard(observe, now, options);
274
+ }
@@ -29,6 +29,7 @@ export function noProxyMatches(
29
29
  if (!entry) continue;
30
30
  if (entry === "*") return true;
31
31
  entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!;
32
+ const domainForm = /^\*?\./.test(entry);
32
33
 
33
34
  let entryHost = entry;
34
35
  let entryPort = "";
@@ -46,11 +47,20 @@ export function noProxyMatches(
46
47
  }
47
48
  if (entryPort && entryPort !== port) continue;
48
49
  entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
49
- if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true;
50
+ if (!entryHost) continue;
51
+ if (hostname === entryHost) return true;
52
+ // A bare loopback name or an IP literal names one host: "localhost" must not send
53
+ // "anything.localhost" direct, which need not resolve to loopback. ".localhost" still does.
54
+ if (!domainForm && isExactOnlyNoProxyHost(entryHost)) continue;
55
+ if (hostname.endsWith(`.${entryHost}`)) return true;
50
56
  }
51
57
  return false;
52
58
  }
53
59
 
60
+ function isExactOnlyNoProxyHost(host: string): boolean {
61
+ return host === "localhost" || host.includes(":") || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host);
62
+ }
63
+
54
64
  export function resolveProxyRoute(
55
65
  url: URL,
56
66
  env: ProxyEnvMap = process.env,
@@ -211,6 +221,13 @@ export function configuredOutboundFetch(
211
221
  } catch {
212
222
  return base!(input, init);
213
223
  }
224
+ // A mixed inherited SOCKS/HTTP ALL_PROXY environment cannot put bare "localhost" in
225
+ // NO_PROXY: Bun would also bypass its HTTP proxy for app.localhost. Keep the name exact
226
+ // here and force native fetch direct so the opposite-case HTTP proxy cannot take over.
227
+ if (proxy && explicitProxy === undefined && (url.protocol === "http:" || url.protocol === "https:")
228
+ && normalizeProxyHostname(url.hostname) === "localhost") {
229
+ return base!(input, { ...init, proxy: false } as ProxyCapableRequestInit);
230
+ }
214
231
  if (proxy && (url.protocol === "http:" || url.protocol === "https:") && (explicitProxy !== undefined || !noProxyMatches(url))) {
215
232
  return socks5Fetch(input, init, proxy);
216
233
  }
@@ -98,6 +98,7 @@ export function deriveRequestFailureStage(facts: RequestFailureFacts): RequestFa
98
98
  const STATUS_CONFIRMED_RECOVERY_KINDS: Readonly<Partial<Record<AttemptRecoveryKind, number>>> = Object.freeze({
99
99
  "opaque-blob-rejection": 400,
100
100
  "reasoning-effort-downgrade": 400,
101
+ "anthropic-fast-downgrade": 400,
101
102
  });
102
103
 
103
104
  function refinedFourHundredCause(
@@ -229,6 +229,9 @@ const RECOVERY_KIND_CAUSE = {
229
229
  "opaque-blob-rejection": "ciphertext-refusal",
230
230
  "empty-completion": "empty-output",
231
231
  "reasoning-effort-downgrade": "parameter-rejected",
232
+ // Anthropic refused `speed: "fast"` (no usage credits, org not enabled, model outside the
233
+ // lane); the same turn succeeds once the parameter is dropped.
234
+ "anthropic-fast-downgrade": "parameter-rejected",
232
235
  } as const satisfies Record<AttemptRecoveryKind, RequestFailureCause>;
233
236
 
234
237
  export function causeForRecoveryKind(kind: AttemptRecoveryKind): RequestFailureCause {
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { closeSync, constants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getConfigDir } from "../config";
5
- import { atomicWriteFile } from "../config/atomic-write";
5
+ import { atomicWriteFile, atomicWriteFileNoFollow } from "../config/atomic-write";
6
6
 
7
7
  const MAX_SERVICE_API_TOKEN_BYTES = 4096;
8
8
 
@@ -49,6 +49,103 @@ export function readServiceApiTokenState(): ServiceApiTokenState {
49
49
  }
50
50
  }
51
51
 
52
+ /**
53
+ * Validate and tighten a reused service token without applying permissions to a
54
+ * pathname that may have been replaced since validation.
55
+ *
56
+ * The token is read off the opened descriptor — never off the path a second
57
+ * time — and once it validates, it is REPUBLISHED through the no-follow atomic
58
+ * writer rather than hardened in place. Windows ACL tooling is pathname-based,
59
+ * so an in-place harden there could still land on a substituted entry; the
60
+ * republish instead replaces whatever entry sits at the path with a freshly
61
+ * hardened owner-only file holding the same token. On return the path names
62
+ * that file, which is the contract `origin: "file"` reports. On POSIX the
63
+ * opened descriptor is also fchmod'd first, so a token-bearing inode a race
64
+ * moved aside is still tightened wherever its entry ended up.
65
+ *
66
+ *
67
+ * Return contract vs `readServiceApiTokenState`: an empty or malformed token file
68
+ * reports `unsafe` here and is never written — the path-based pre-check may still
69
+ * pass the install on loopback while this writer deliberately leaves the file
70
+ * untouched. Only `absent` permits a fresh write; anything unreadable stays as-is.
71
+ *
72
+ * Callers must run this under `withConfigMutationLockSync`: client-key rotation
73
+ * replaces the token under that lock, and a republish outside it could rename a
74
+ * stale token back over a committed rotation.
75
+ */
76
+ export function hardenReusedServiceApiToken(
77
+ validate: (token: string) => void,
78
+ ): ServiceApiTokenState {
79
+ const path = serviceApiTokenFilePath();
80
+ // O_NOFOLLOW refuses a symlinked entry and O_NONBLOCK keeps a FIFO (or other
81
+ // blocking node) from stalling the open before fstat can reject it. Windows
82
+ // omits both flags, so there the descriptor is bound to its entry by the
83
+ // lstat/fstat identity comparison below.
84
+ const flags = process.platform === "win32"
85
+ ? constants.O_RDONLY
86
+ : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
87
+ let fd: number | undefined;
88
+ try {
89
+ fd = openSync(path, flags);
90
+ } catch (error) {
91
+ const code = (error as NodeJS.ErrnoException).code;
92
+ if (code === "ENOENT") return { kind: "absent" };
93
+ if (code === "ELOOP") return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
94
+ return { kind: "unsafe", reason: "service token path could not be inspected" };
95
+ }
96
+ try {
97
+ const stat = fstatSync(fd, { bigint: true });
98
+ if (!stat.isFile() || stat.size > BigInt(MAX_SERVICE_API_TOKEN_BYTES)) {
99
+ return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
100
+ }
101
+ if (process.platform === "win32") {
102
+ let entry;
103
+ try {
104
+ entry = lstatSync(path, { bigint: true });
105
+ } catch {
106
+ return { kind: "unsafe", reason: "service token path could not be inspected" };
107
+ }
108
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.dev !== stat.dev || entry.ino !== stat.ino) {
109
+ return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
110
+ }
111
+ }
112
+ // Bound the read as well as the stat: a file that grows past the cap after
113
+ // fstat is unsafe, not something to buffer whole.
114
+ const bytes = Buffer.alloc(MAX_SERVICE_API_TOKEN_BYTES + 1);
115
+ let length = 0;
116
+ try {
117
+ while (length < bytes.length) {
118
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
119
+ if (!count) break;
120
+ length += count;
121
+ }
122
+ } catch {
123
+ return { kind: "unsafe", reason: "service token file could not be read" };
124
+ }
125
+ if (length > MAX_SERVICE_API_TOKEN_BYTES) {
126
+ return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
127
+ }
128
+ const token = bytes.subarray(0, length).toString("utf8").trim();
129
+ if (!token) return { kind: "unsafe", reason: "service token file is empty" };
130
+ validate(token);
131
+ if (process.platform !== "win32") {
132
+ // Best-effort matches the previous repair behavior: the descriptor binds
133
+ // the chmod to the regular file opened above even if its directory entry
134
+ // moved, so the validated inode is never left loose under another name.
135
+ try { fchmodSync(fd, 0o600); } catch { /* best-effort */ }
136
+ }
137
+ // The descriptor's work ends here — and must: Windows refuses to rename over
138
+ // a file this process still holds open, so the republish cannot run while it
139
+ // is held.
140
+ closeSync(fd);
141
+ fd = undefined;
142
+ atomicWriteFileNoFollow(path, `${token}\n`);
143
+ return { kind: "present", token, fingerprint: serviceApiTokenFingerprint(token) };
144
+ } finally {
145
+ if (fd !== undefined) closeSync(fd);
146
+ }
147
+ }
148
+
52
149
  export function writeServiceApiTokenFile(token: string): PersistedServiceApiToken {
53
150
  const value = token.trim();
54
151
  if (!value || /[\r\n\0]/.test(value) || Buffer.byteLength(value) > MAX_SERVICE_API_TOKEN_BYTES) {