@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901

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 (131) hide show
  1. package/bin/ocx.mjs +127 -16
  2. package/gui/dist/assets/index-6HZDkIpX.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/baseten.svg +13 -0
  7. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  8. package/gui/dist/provider-icons/cerebras.svg +26 -0
  9. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  10. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  11. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  12. package/gui/dist/provider-icons/featherless.svg +4 -0
  13. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  14. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  15. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  16. package/gui/dist/provider-icons/kilo.svg +13 -0
  17. package/gui/dist/provider-icons/litellm.svg +1 -0
  18. package/gui/dist/provider-icons/minimax.svg +1 -0
  19. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  20. package/gui/dist/provider-icons/nebius.svg +1 -0
  21. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  22. package/gui/dist/provider-icons/nous.svg +149 -0
  23. package/gui/dist/provider-icons/novita.svg +32 -0
  24. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  25. package/gui/dist/provider-icons/openclaw.svg +54 -0
  26. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  27. package/gui/dist/provider-icons/parallel.svg +13 -0
  28. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  29. package/gui/dist/provider-icons/sambanova.svg +276 -0
  30. package/gui/dist/provider-icons/scaleway.svg +11 -0
  31. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  32. package/gui/dist/provider-icons/synthetic.svg +12 -0
  33. package/gui/dist/provider-icons/together.svg +18 -0
  34. package/gui/dist/provider-icons/umans.svg +30 -0
  35. package/gui/dist/provider-icons/venice.svg +165 -0
  36. package/gui/dist/provider-icons/vultr.svg +15 -0
  37. package/gui/dist/provider-icons/zai.svg +218 -0
  38. package/gui/dist/provider-icons/zcode.svg +219 -0
  39. package/gui/dist/provider-icons/zenmux.svg +1 -0
  40. package/package.json +1 -1
  41. package/src/adapters/cursor/live-models.ts +1 -0
  42. package/src/adapters/cursor/protobuf-request.ts +4 -1
  43. package/src/adapters/cursor/tool-definitions.ts +36 -4
  44. package/src/adapters/openai-responses.ts +72 -10
  45. package/src/bridge.ts +15 -15
  46. package/src/cli/capabilities.ts +14 -0
  47. package/src/cli/codex-cli-update.ts +96 -0
  48. package/src/cli/codex-shim-autorestore.ts +3 -0
  49. package/src/cli/dispatch.ts +54 -18
  50. package/src/cli/export-command.ts +18 -17
  51. package/src/cli/help.ts +2 -2
  52. package/src/cli/index.ts +423 -32
  53. package/src/cli/integrations.ts +24 -2
  54. package/src/cli/launcher-context.ts +53 -2
  55. package/src/cli/opencode.ts +126 -33
  56. package/src/cli/registry.ts +16 -10
  57. package/src/cli/system-command.ts +6 -1
  58. package/src/cli/uninstall-plan.ts +86 -0
  59. package/src/clients/config-export.ts +293 -28
  60. package/src/codex/account-store.ts +131 -12
  61. package/src/codex/auth-api.ts +202 -33
  62. package/src/codex/autostart-health.ts +3 -3
  63. package/src/codex/catalog/provider-fetch.ts +96 -10
  64. package/src/codex/catalog/sync.ts +4 -3
  65. package/src/codex/cli-install-provenance.ts +795 -0
  66. package/src/codex/convergence.ts +4 -3
  67. package/src/codex/credential-mutation-epoch.ts +11 -0
  68. package/src/codex/history-job.ts +10 -0
  69. package/src/codex/history-manifest.ts +35 -2
  70. package/src/codex/history-provider.ts +196 -19
  71. package/src/codex/history-worker.ts +3 -0
  72. package/src/codex/main-account.ts +2 -0
  73. package/src/codex/model-entitlements.ts +430 -27
  74. package/src/codex/native-profile-manager.ts +4 -0
  75. package/src/codex/quota-401-recovery.ts +190 -0
  76. package/src/codex/quota-recovery-timing.ts +28 -0
  77. package/src/codex/quota.ts +6 -0
  78. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  79. package/src/codex/reset-credit-recovery.ts +20 -2
  80. package/src/codex/routing.ts +68 -16
  81. package/src/codex/shim.ts +204 -18
  82. package/src/codex/subagent-model-fallback.ts +4 -1
  83. package/src/codex/user-identity.ts +2 -1
  84. package/src/config/paths.ts +18 -3
  85. package/src/config/pending-teardown-names.d.mts +8 -0
  86. package/src/config/pending-teardown-names.mjs +69 -0
  87. package/src/config/pending-teardown.ts +286 -0
  88. package/src/config.ts +23 -0
  89. package/src/generated/compatibility-version.json +155 -67
  90. package/src/integrations/journal.ts +12 -1
  91. package/src/integrations/registry.ts +112 -0
  92. package/src/integrations/state.ts +67 -5
  93. package/src/integrations/writer.ts +107 -16
  94. package/src/lib/bounded-subprocess.ts +36 -0
  95. package/src/lib/process-control.ts +30 -5
  96. package/src/lib/state-store-registrations.ts +8 -0
  97. package/src/lib/strict-semver.ts +47 -0
  98. package/src/lib/windows-elevation.ts +32 -1
  99. package/src/lib/windows-secret-acl.ts +47 -25
  100. package/src/lib/windows-service-mutation-lock.ts +133 -0
  101. package/src/lib/windows-user-principal.ts +15 -17
  102. package/src/oauth/index.ts +220 -13
  103. package/src/oauth/store.ts +220 -18
  104. package/src/responses/spill-store.ts +354 -29
  105. package/src/responses/state.ts +775 -8
  106. package/src/server/index.ts +4 -3
  107. package/src/server/lifecycle.ts +5 -1
  108. package/src/server/management/integration-routes.ts +36 -5
  109. package/src/server/management/model-rows.ts +11 -2
  110. package/src/server/management/provider-routes.ts +4 -0
  111. package/src/server/management/system-restart.ts +5 -5
  112. package/src/server/management-api.ts +72 -13
  113. package/src/server/responses/encrypted-payload.ts +18 -1
  114. package/src/server/startup-action-control.ts +3 -2
  115. package/src/server/stop-teardown.ts +84 -0
  116. package/src/service.ts +749 -53
  117. package/src/sidecar/candidates.ts +1 -1
  118. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  119. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  120. package/src/update/index.ts +39 -6
  121. package/src/update/job.ts +10 -11
  122. package/src/update/proxy-liveness-probe.d.mts +6 -0
  123. package/src/update/proxy-liveness-probe.mjs +84 -0
  124. package/src/update/stop-contract.d.mts +2 -0
  125. package/src/update/stop-contract.mjs +15 -0
  126. package/src/update/stop-decision.d.mts +10 -0
  127. package/src/update/stop-decision.mjs +34 -0
  128. package/src/vision/eligibility.ts +19 -1
  129. package/src/vision/index.ts +4 -18
  130. package/gui/dist/assets/index-CowztZdo.js +0 -112
  131. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -15,9 +15,17 @@ import {
15
15
  writeSync,
16
16
  } from "node:fs";
17
17
  import { createHash, randomBytes } from "node:crypto";
18
- import { join } from "node:path";
18
+ import { dirname, join } from "node:path";
19
19
  import { getConfigDir } from "../config";
20
- import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
20
+ import {
21
+ forgetEphemeralSecretPath,
22
+ forgetHardenedSecretPath,
23
+ hardenSecretDir,
24
+ hardenSecretDirAsync,
25
+ hardenSecretPath,
26
+ hardenSecretPathAsync,
27
+ windowsSecretAclApplies,
28
+ } from "../lib/windows-secret-acl";
21
29
  import { isValidProviderContinuationOwner } from "./provider-continuation";
22
30
  import type { OcxProviderContinuationState } from "../types";
23
31
 
@@ -110,11 +118,47 @@ export interface ResponseSpillIoForTest {
110
118
 
111
119
  let spillIoForTest: ResponseSpillIoForTest | null = null;
112
120
  let spillGeneration = 0;
121
+ let spillNowOverride: (() => number) | null = null;
122
+
123
+ interface ResponseSpillWriteOptions {
124
+ retryTimedOutOnce?: boolean;
125
+ /** Total caller-owned ACL budget shared by every harden in this publication. */
126
+ aclBudgetMs?: number;
127
+ publicationControl?: ResponseSpillPublicationControl;
128
+ }
129
+
130
+ export interface ResponseSpillPublicationControl {
131
+ superseded: boolean;
132
+ tempPath: string | null;
133
+ destinationPath: string | null;
134
+ }
135
+
136
+ interface SpillAclBudget {
137
+ deadline: number;
138
+ perCallMs: number;
139
+ }
140
+
141
+ export function createResponseSpillPublicationControl(): ResponseSpillPublicationControl {
142
+ return { superseded: false, tempPath: null, destinationPath: null };
143
+ }
144
+
145
+ export function markResponseSpillPublicationSuperseded(control: ResponseSpillPublicationControl): void {
146
+ control.superseded = true;
147
+ }
113
148
 
114
149
  export function setSpillIoForTest(io: ResponseSpillIoForTest | null): void {
115
150
  spillIoForTest = io;
116
151
  }
117
152
 
153
+ /** Test-only: inject the spill deadline clock. */
154
+ export function setResponseSpillNowForTests(now: (() => number) | null): void {
155
+ spillNowOverride = now;
156
+ }
157
+
158
+ function spillNow(): number {
159
+ return spillNowOverride?.() ?? Date.now();
160
+ }
161
+
118
162
  function record(event: "write" | "fsync" | "close" | "harden" | "publish" | "dir-fsync" | "stub-swap"): void {
119
163
  spillIoForTest?.record?.(event);
120
164
  }
@@ -175,16 +219,62 @@ function canUseExclusiveCopyFallback(error: unknown): boolean {
175
219
  .some(code => isErrno(error, code));
176
220
  }
177
221
 
178
- function harden(path: string, mode: number): void {
222
+ function spillAclBudget(totalMs: number | undefined): SpillAclBudget | undefined {
223
+ if (totalMs === undefined) return undefined;
224
+ const bounded = Math.max(1, Math.floor(totalMs));
225
+ return { deadline: spillNow() + bounded, perCallMs: Math.max(1, Math.floor(bounded / 2)) };
226
+ }
227
+
228
+ function nextSpillHardenDeadlineMs(budget: SpillAclBudget | undefined): number | undefined {
229
+ if (!budget) return undefined;
230
+ const remaining = budget.deadline - spillNow();
231
+ if (remaining <= 0) {
232
+ throw Object.assign(new Error("Response spill ACL budget exhausted"), { code: "ETIMEDOUT" });
233
+ }
234
+ return Math.min(budget.perCallMs, remaining);
235
+ }
236
+
237
+ function harden(path: string, mode: number, budget?: SpillAclBudget): void {
238
+ const aclApplies = budget ? windowsSecretAclApplies() : process.platform === "win32";
239
+ try {
240
+ chmodSync(path, mode);
241
+ } catch {
242
+ if (!aclApplies) throw new Error("Response spill permission hardening failed");
243
+ }
244
+ if (aclApplies) {
245
+ const deadlineMs = nextSpillHardenDeadlineMs(budget);
246
+ const options = {
247
+ required: true,
248
+ ...(deadlineMs !== undefined ? { deadlineMs } : {}),
249
+ };
250
+ const result = mode === 0o700
251
+ ? hardenSecretDir(path, options)
252
+ : hardenSecretPath(path, options);
253
+ if (!result.ok) throw new Error("Response spill permission hardening failed");
254
+ }
255
+ }
256
+
257
+ async function hardenAsync(
258
+ path: string,
259
+ mode: number,
260
+ budget: SpillAclBudget,
261
+ retryTimedOutOnce = false,
262
+ ): Promise<void> {
179
263
  try {
180
264
  chmodSync(path, mode);
181
265
  } catch {
182
- if (process.platform !== "win32") throw new Error("Response spill permission hardening failed");
266
+ if (!windowsSecretAclApplies()) throw new Error("Response spill permission hardening failed");
183
267
  }
184
- if (process.platform === "win32") {
268
+ if (windowsSecretAclApplies()) {
269
+ const deadlineMs = nextSpillHardenDeadlineMs(budget);
270
+ const options = {
271
+ required: true,
272
+ retryTimedOutOnce,
273
+ ...(deadlineMs !== undefined ? { deadlineMs } : {}),
274
+ };
185
275
  const result = mode === 0o700
186
- ? hardenSecretDir(path, { required: true })
187
- : hardenSecretPath(path, { required: true });
276
+ ? await hardenSecretDirAsync(path, options)
277
+ : await hardenSecretPathAsync(path, options);
188
278
  if (!result.ok) throw new Error("Response spill permission hardening failed");
189
279
  }
190
280
  }
@@ -231,7 +321,57 @@ function unlinkEphemeral(path: string): void {
231
321
  unlink(path, true);
232
322
  }
233
323
 
234
- function publishNoReplace(tempPath: string, destinationPath: string): void {
324
+ function supersededPublicationError(): NodeJS.ErrnoException {
325
+ return Object.assign(new Error("Response spill publication superseded"), { code: "ECANCELED" });
326
+ }
327
+
328
+ function throwIfPublicationSuperseded(control: ResponseSpillPublicationControl | undefined): void {
329
+ if (control?.superseded) throw supersededPublicationError();
330
+ }
331
+
332
+ function clearOwnedPath(
333
+ control: ResponseSpillPublicationControl,
334
+ key: "tempPath" | "destinationPath",
335
+ ephemeral: boolean,
336
+ ): unknown {
337
+ const path = control[key];
338
+ if (!path) return null;
339
+ try {
340
+ if (ephemeral) unlinkEphemeral(path);
341
+ else unlink(path);
342
+ control[key] = null;
343
+ return null;
344
+ } catch (error) {
345
+ if (isErrno(error, "ENOENT")) {
346
+ control[key] = null;
347
+ return null;
348
+ }
349
+ return error;
350
+ }
351
+ }
352
+
353
+ /** Claim and remove every path still owned by an abandoned async publication. */
354
+ export function cleanupSupersededResponseSpillPublication(
355
+ control: ResponseSpillPublicationControl,
356
+ ): Error | null {
357
+ control.superseded = true;
358
+ const ownedDir = control.destinationPath
359
+ ? dirname(control.destinationPath)
360
+ : control.tempPath
361
+ ? dirname(control.tempPath)
362
+ : null;
363
+ const destinationError = clearOwnedPath(control, "destinationPath", false);
364
+ const tempError = clearOwnedPath(control, "tempPath", true);
365
+ if (ownedDir) fsyncDirectoryBestEffort(ownedDir);
366
+ const cleanupError = destinationError ?? tempError;
367
+ return cleanupError ? responseSpillWriteError(cleanupError) : null;
368
+ }
369
+
370
+ function publishNoReplace(
371
+ tempPath: string,
372
+ destinationPath: string,
373
+ budget?: SpillAclBudget,
374
+ ): void {
235
375
  try {
236
376
  if (spillIoForTest?.link) spillIoForTest.link(tempPath, destinationPath);
237
377
  else linkSync(tempPath, destinationPath);
@@ -243,7 +383,7 @@ function publishNoReplace(tempPath: string, destinationPath: string): void {
243
383
  if (spillIoForTest?.copyFileExcl) spillIoForTest.copyFileExcl(tempPath, destinationPath);
244
384
  else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL);
245
385
  copied = true;
246
- harden(destinationPath, 0o600);
386
+ harden(destinationPath, 0o600, budget);
247
387
  const copyFd = openSync(destinationPath, "r");
248
388
  try {
249
389
  if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
@@ -261,6 +401,103 @@ function publishNoReplace(tempPath: string, destinationPath: string): void {
261
401
  record("publish");
262
402
  }
263
403
 
404
+ async function publishNoReplaceAsync(
405
+ tempPath: string,
406
+ destinationPath: string,
407
+ budget: SpillAclBudget,
408
+ retryTimedOutOnce: boolean,
409
+ publicationControl?: ResponseSpillPublicationControl,
410
+ ): Promise<void> {
411
+ throwIfPublicationSuperseded(publicationControl);
412
+ try {
413
+ if (spillIoForTest?.link) spillIoForTest.link(tempPath, destinationPath);
414
+ else linkSync(tempPath, destinationPath);
415
+ } catch (error) {
416
+ if (isErrno(error, "EEXIST")) throw error;
417
+ if (!canUseExclusiveCopyFallback(error)) throw error;
418
+ let copied = false;
419
+ try {
420
+ if (spillIoForTest?.copyFileExcl) spillIoForTest.copyFileExcl(tempPath, destinationPath);
421
+ else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL);
422
+ copied = true;
423
+ await hardenAsync(destinationPath, 0o600, budget, retryTimedOutOnce);
424
+ throwIfPublicationSuperseded(publicationControl);
425
+ const copyFd = openSync(destinationPath, "r");
426
+ try {
427
+ if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
428
+ else fsyncSync(copyFd);
429
+ } finally {
430
+ closeSync(copyFd);
431
+ }
432
+ } catch (copyError) {
433
+ if (copied) {
434
+ try { unlink(destinationPath); } catch { /* startup GC reclaims an incomplete publication */ }
435
+ }
436
+ throw copyError;
437
+ }
438
+ }
439
+ throwIfPublicationSuperseded(publicationControl);
440
+ record("publish");
441
+ }
442
+
443
+ function serializedSpill(
444
+ responseId: string,
445
+ state: Omit<ResponseSpillPayload, "version" | "responseId">,
446
+ ): {
447
+ bytes: Buffer;
448
+ digest: string;
449
+ idDigest: string;
450
+ contentDigest: string;
451
+ } {
452
+ const payload: ResponseSpillPayload = {
453
+ version: 1,
454
+ responseId,
455
+ createdAt: state.createdAt,
456
+ ...(state.clientThreadId ? { clientThreadId: state.clientThreadId } : {}),
457
+ items: state.items,
458
+ ...(state.providerOutputStart !== undefined ? { providerOutputStart: state.providerOutputStart } : {}),
459
+ ...(state.providers ? { providers: state.providers } : {}),
460
+ };
461
+ const serialized = JSON.stringify(payload);
462
+ if (serialized === undefined) throw new Error("Response spill serialization failed");
463
+ const bytes = Buffer.from(serialized, "utf8");
464
+ const digest = sha256(bytes);
465
+ return {
466
+ bytes,
467
+ digest,
468
+ idDigest: sha256(responseId).slice(0, 12),
469
+ contentDigest: digest.slice(0, 24),
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Exact on-disk payload size this spill WOULD occupy, measured before publication.
475
+ *
476
+ * Callers that reserve disk against a cap need the real envelope, not the resident
477
+ * measurement: the resident figure omits the `version` field the published payload
478
+ * carries, so pricing an admission by it undercounts and lets a request that sits exactly
479
+ * at the cap still exceed it. Shares `serializedSpill` rather than describing it, so the
480
+ * two cannot drift.
481
+ */
482
+ export function prospectiveResponseSpillBytes(
483
+ responseId: string,
484
+ state: Omit<ResponseSpillPayload, "version" | "responseId">,
485
+ ): number | null {
486
+ try {
487
+ return serializedSpill(responseId, state).bytes.byteLength;
488
+ } catch {
489
+ return null;
490
+ }
491
+ }
492
+
493
+ function responseSpillWriteError(cause: unknown): NodeJS.ErrnoException {
494
+ const error = new Error("Response spill write failed", { cause }) as NodeJS.ErrnoException;
495
+ if (cause && typeof cause === "object" && "code" in cause) {
496
+ error.code = String((cause as { code?: unknown }).code);
497
+ }
498
+ return error;
499
+ }
500
+
264
501
  function validSpillRef(ref: ResponseSpillRef): boolean {
265
502
  return ref.version === 1
266
503
  && OWNED_SPILL_NAME.test(ref.fileName)
@@ -302,28 +539,16 @@ function validPayload(value: unknown, responseId: string): value is ResponseSpil
302
539
  export function writeResponseSpillDurably(
303
540
  responseId: string,
304
541
  state: Omit<ResponseSpillPayload, "version" | "responseId">,
542
+ options: ResponseSpillWriteOptions = {},
305
543
  ): ResponseSpillRef {
306
544
  let tempPath: string | null = null;
307
545
  let fd: number | null = null;
308
546
  try {
309
- const payload: ResponseSpillPayload = {
310
- version: 1,
311
- responseId,
312
- createdAt: state.createdAt,
313
- ...(state.clientThreadId ? { clientThreadId: state.clientThreadId } : {}),
314
- items: state.items,
315
- ...(state.providerOutputStart !== undefined ? { providerOutputStart: state.providerOutputStart } : {}),
316
- ...(state.providers ? { providers: state.providers } : {}),
317
- };
318
- const serialized = JSON.stringify(payload);
319
- if (serialized === undefined) throw new Error("Response spill serialization failed");
320
- const bytes = Buffer.from(serialized, "utf8");
321
- const digest = sha256(bytes);
322
- const idDigest = sha256(responseId).slice(0, 12);
323
- const contentDigest = digest.slice(0, 24);
547
+ const aclBudget = spillAclBudget(options.aclBudgetMs);
548
+ const { bytes, digest, idDigest, contentDigest } = serializedSpill(responseId, state);
324
549
  const dir = responseSpillDirectory();
325
550
  mkdirSync(dir, { recursive: true, mode: 0o700 });
326
- harden(dir, 0o700);
551
+ harden(dir, 0o700, aclBudget);
327
552
 
328
553
  tempPath = join(dir, `.response-spill.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
329
554
  fd = openSync(tempPath, "wx", 0o600);
@@ -331,7 +556,7 @@ export function writeResponseSpillDurably(
331
556
  fsyncFile(fd);
332
557
  closeFile(fd);
333
558
  fd = null;
334
- harden(tempPath, 0o600);
559
+ harden(tempPath, 0o600, aclBudget);
335
560
  record("harden");
336
561
  const publishTempPath = tempPath;
337
562
 
@@ -341,7 +566,7 @@ export function writeResponseSpillDurably(
341
566
  if (!OWNED_SPILL_NAME.test(fileName)) throw new Error("Response spill name allocation failed");
342
567
  const destinationPath = join(dir, fileName);
343
568
  try {
344
- publishNoReplace(publishTempPath, destinationPath);
569
+ publishNoReplace(publishTempPath, destinationPath, aclBudget);
345
570
  fsyncDirectoryBestEffort(dir);
346
571
  unlinkEphemeral(publishTempPath);
347
572
  tempPath = null;
@@ -352,14 +577,114 @@ export function writeResponseSpillDurably(
352
577
  }
353
578
  }
354
579
  throw new Error("Response spill publication retries exhausted");
355
- } catch {
580
+ } catch (cause) {
356
581
  if (fd !== null) {
357
582
  try { closeSync(fd); } catch { /* best effort */ }
358
583
  }
359
584
  if (tempPath) {
360
585
  try { unlinkEphemeral(tempPath); } catch { /* best effort */ }
361
586
  }
362
- throw new Error("Response spill write failed");
587
+ throw responseSpillWriteError(cause);
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Windows runtime counterpart of `writeResponseSpillDurably`.
593
+ *
594
+ * The filesystem publication contract stays identical, but required NTFS ACL subprocesses are
595
+ * awaited through Bun.spawn instead of Bun.spawnSync. State ownership and serialization remain in
596
+ * `state.ts`; callers must compare the resident generation again before installing the returned
597
+ * reference because another response can replace it while ACL hardening is pending.
598
+ */
599
+ export async function writeResponseSpillDurablyAsync(
600
+ responseId: string,
601
+ state: Omit<ResponseSpillPayload, "version" | "responseId">,
602
+ options: ResponseSpillWriteOptions & { aclBudgetMs: number },
603
+ ): Promise<ResponseSpillRef> {
604
+ const publicationControl = options.publicationControl;
605
+ const aclBudget = spillAclBudget(options.aclBudgetMs);
606
+ if (!aclBudget) throw new Error("Response spill async ACL budget is required");
607
+ let tempPath: string | null = null;
608
+ let fd: number | null = null;
609
+ try {
610
+ throwIfPublicationSuperseded(publicationControl);
611
+ const { bytes, digest, idDigest, contentDigest } = serializedSpill(responseId, state);
612
+ const dir = responseSpillDirectory();
613
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
614
+ await hardenAsync(dir, 0o700, aclBudget, options.retryTimedOutOnce === true);
615
+ throwIfPublicationSuperseded(publicationControl);
616
+
617
+ tempPath = join(dir, `.response-spill.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
618
+ fd = openSync(tempPath, "wx", 0o600);
619
+ if (publicationControl) publicationControl.tempPath = tempPath;
620
+ writeAll(fd, bytes);
621
+ fsyncFile(fd);
622
+ closeFile(fd);
623
+ fd = null;
624
+ await hardenAsync(tempPath, 0o600, aclBudget, options.retryTimedOutOnce === true);
625
+ throwIfPublicationSuperseded(publicationControl);
626
+ record("harden");
627
+ const publishTempPath = tempPath;
628
+
629
+ for (let attempt = 0; attempt < RESPONSE_SPILL_PUBLISH_RETRIES; attempt++) {
630
+ throwIfPublicationSuperseded(publicationControl);
631
+ spillGeneration += 1;
632
+ const fileName = `${sanitizeResponseId(responseId)}.${idDigest}.${contentDigest}.${spillGeneration}.${bytes.byteLength}.spill.json`;
633
+ if (!OWNED_SPILL_NAME.test(fileName)) throw new Error("Response spill name allocation failed");
634
+ const destinationPath = join(dir, fileName);
635
+ if (publicationControl) publicationControl.destinationPath = destinationPath;
636
+ try {
637
+ throwIfPublicationSuperseded(publicationControl);
638
+ await publishNoReplaceAsync(
639
+ publishTempPath,
640
+ destinationPath,
641
+ aclBudget,
642
+ options.retryTimedOutOnce === true,
643
+ publicationControl,
644
+ );
645
+ throwIfPublicationSuperseded(publicationControl);
646
+ fsyncDirectoryBestEffort(dir);
647
+ unlinkEphemeral(publishTempPath);
648
+ tempPath = null;
649
+ if (publicationControl) {
650
+ publicationControl.tempPath = null;
651
+ publicationControl.destinationPath = null;
652
+ }
653
+ return { version: 1, fileName, digest, payloadBytes: bytes.byteLength };
654
+ } catch (error) {
655
+ if (publicationControl?.superseded) throw error;
656
+ if (publicationControl) publicationControl.destinationPath = null;
657
+ if (isErrno(error, "EEXIST")) continue;
658
+ throw error;
659
+ }
660
+ }
661
+ throw new Error("Response spill publication retries exhausted");
662
+ } catch (cause) {
663
+ if (fd !== null) {
664
+ try { closeSync(fd); } catch { /* best effort */ }
665
+ }
666
+ if (tempPath) {
667
+ try {
668
+ unlinkEphemeral(tempPath);
669
+ if (publicationControl?.tempPath === tempPath) publicationControl.tempPath = null;
670
+ } catch (error) {
671
+ if (isErrno(error, "ENOENT") && publicationControl?.tempPath === tempPath) {
672
+ publicationControl.tempPath = null;
673
+ }
674
+ }
675
+ }
676
+ if (publicationControl) {
677
+ const destinationPath = publicationControl.destinationPath;
678
+ if (destinationPath) {
679
+ try {
680
+ unlink(destinationPath);
681
+ publicationControl.destinationPath = null;
682
+ } catch (error) {
683
+ if (isErrno(error, "ENOENT")) publicationControl.destinationPath = null;
684
+ }
685
+ }
686
+ }
687
+ throw responseSpillWriteError(cause);
363
688
  }
364
689
  }
365
690