@bitkyc08/opencodex 2.37.0 → 2.38.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.
- package/bin/ocx.mjs +69 -10
- package/gui/dist/assets/{index-CowztZdo.js → index-C14iCj_Q.js} +13 -13
- package/gui/dist/assets/index-D7PIz7_g.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
- package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
- package/gui/dist/provider-icons/openclaw.svg +54 -0
- package/gui/dist/provider-icons/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/package.json +1 -1
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/cli/capabilities.ts +14 -0
- package/src/cli/codex-cli-update.ts +96 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +3 -2
- package/src/cli/launcher-context.ts +53 -2
- package/src/cli/opencode.ts +126 -33
- package/src/cli/registry.ts +16 -10
- package/src/cli/system-command.ts +6 -1
- package/src/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +10 -4
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +20 -1
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/cli-install-provenance.ts +795 -0
- package/src/codex/convergence.ts +4 -3
- package/src/codex/credential-mutation-epoch.ts +11 -0
- package/src/codex/main-account.ts +2 -0
- package/src/codex/model-entitlements.ts +430 -27
- package/src/codex/native-profile-manager.ts +4 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/shim.ts +204 -18
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +81 -45
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +25 -9
- package/src/lib/bounded-subprocess.ts +36 -0
- package/src/lib/strict-semver.ts +47 -0
- package/src/lib/windows-elevation.ts +32 -1
- package/src/lib/windows-secret-acl.ts +47 -25
- package/src/lib/windows-service-mutation-lock.ts +133 -0
- package/src/lib/windows-user-principal.ts +15 -17
- package/src/responses/spill-store.ts +334 -29
- package/src/responses/state.ts +488 -7
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- package/src/server/management/model-rows.ts +11 -2
- package/src/server/management/provider-routes.ts +4 -0
- package/src/server/management/system-restart.ts +5 -5
- package/src/server/management-api.ts +7 -2
- package/src/server/startup-action-control.ts +3 -2
- package/src/service.ts +594 -33
- package/src/sidecar/candidates.ts +1 -1
- package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/update/index.ts +3 -2
- package/src/update/job.ts +10 -11
- 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 {
|
|
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
|
|
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 (
|
|
266
|
+
if (!windowsSecretAclApplies()) throw new Error("Response spill permission hardening failed");
|
|
183
267
|
}
|
|
184
|
-
if (
|
|
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
|
-
?
|
|
187
|
-
:
|
|
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,94 @@ function unlinkEphemeral(path: string): void {
|
|
|
231
321
|
unlink(path, true);
|
|
232
322
|
}
|
|
233
323
|
|
|
234
|
-
function
|
|
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 {
|
|
375
|
+
try {
|
|
376
|
+
if (spillIoForTest?.link) spillIoForTest.link(tempPath, destinationPath);
|
|
377
|
+
else linkSync(tempPath, destinationPath);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (isErrno(error, "EEXIST")) throw error;
|
|
380
|
+
if (!canUseExclusiveCopyFallback(error)) throw error;
|
|
381
|
+
let copied = false;
|
|
382
|
+
try {
|
|
383
|
+
if (spillIoForTest?.copyFileExcl) spillIoForTest.copyFileExcl(tempPath, destinationPath);
|
|
384
|
+
else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL);
|
|
385
|
+
copied = true;
|
|
386
|
+
harden(destinationPath, 0o600, budget);
|
|
387
|
+
const copyFd = openSync(destinationPath, "r");
|
|
388
|
+
try {
|
|
389
|
+
if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
|
|
390
|
+
else fsyncSync(copyFd);
|
|
391
|
+
} finally {
|
|
392
|
+
closeSync(copyFd);
|
|
393
|
+
}
|
|
394
|
+
} catch (copyError) {
|
|
395
|
+
if (copied) {
|
|
396
|
+
try { unlink(destinationPath); } catch { /* startup GC reclaims an incomplete publication */ }
|
|
397
|
+
}
|
|
398
|
+
throw copyError;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
record("publish");
|
|
402
|
+
}
|
|
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);
|
|
235
412
|
try {
|
|
236
413
|
if (spillIoForTest?.link) spillIoForTest.link(tempPath, destinationPath);
|
|
237
414
|
else linkSync(tempPath, destinationPath);
|
|
@@ -243,7 +420,8 @@ function publishNoReplace(tempPath: string, destinationPath: string): void {
|
|
|
243
420
|
if (spillIoForTest?.copyFileExcl) spillIoForTest.copyFileExcl(tempPath, destinationPath);
|
|
244
421
|
else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL);
|
|
245
422
|
copied = true;
|
|
246
|
-
|
|
423
|
+
await hardenAsync(destinationPath, 0o600, budget, retryTimedOutOnce);
|
|
424
|
+
throwIfPublicationSuperseded(publicationControl);
|
|
247
425
|
const copyFd = openSync(destinationPath, "r");
|
|
248
426
|
try {
|
|
249
427
|
if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
|
|
@@ -258,9 +436,48 @@ function publishNoReplace(tempPath: string, destinationPath: string): void {
|
|
|
258
436
|
throw copyError;
|
|
259
437
|
}
|
|
260
438
|
}
|
|
439
|
+
throwIfPublicationSuperseded(publicationControl);
|
|
261
440
|
record("publish");
|
|
262
441
|
}
|
|
263
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
|
+
function responseSpillWriteError(cause: unknown): NodeJS.ErrnoException {
|
|
474
|
+
const error = new Error("Response spill write failed", { cause }) as NodeJS.ErrnoException;
|
|
475
|
+
if (cause && typeof cause === "object" && "code" in cause) {
|
|
476
|
+
error.code = String((cause as { code?: unknown }).code);
|
|
477
|
+
}
|
|
478
|
+
return error;
|
|
479
|
+
}
|
|
480
|
+
|
|
264
481
|
function validSpillRef(ref: ResponseSpillRef): boolean {
|
|
265
482
|
return ref.version === 1
|
|
266
483
|
&& OWNED_SPILL_NAME.test(ref.fileName)
|
|
@@ -302,28 +519,16 @@ function validPayload(value: unknown, responseId: string): value is ResponseSpil
|
|
|
302
519
|
export function writeResponseSpillDurably(
|
|
303
520
|
responseId: string,
|
|
304
521
|
state: Omit<ResponseSpillPayload, "version" | "responseId">,
|
|
522
|
+
options: ResponseSpillWriteOptions = {},
|
|
305
523
|
): ResponseSpillRef {
|
|
306
524
|
let tempPath: string | null = null;
|
|
307
525
|
let fd: number | null = null;
|
|
308
526
|
try {
|
|
309
|
-
const
|
|
310
|
-
|
|
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);
|
|
527
|
+
const aclBudget = spillAclBudget(options.aclBudgetMs);
|
|
528
|
+
const { bytes, digest, idDigest, contentDigest } = serializedSpill(responseId, state);
|
|
324
529
|
const dir = responseSpillDirectory();
|
|
325
530
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
326
|
-
harden(dir, 0o700);
|
|
531
|
+
harden(dir, 0o700, aclBudget);
|
|
327
532
|
|
|
328
533
|
tempPath = join(dir, `.response-spill.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
329
534
|
fd = openSync(tempPath, "wx", 0o600);
|
|
@@ -331,7 +536,7 @@ export function writeResponseSpillDurably(
|
|
|
331
536
|
fsyncFile(fd);
|
|
332
537
|
closeFile(fd);
|
|
333
538
|
fd = null;
|
|
334
|
-
harden(tempPath, 0o600);
|
|
539
|
+
harden(tempPath, 0o600, aclBudget);
|
|
335
540
|
record("harden");
|
|
336
541
|
const publishTempPath = tempPath;
|
|
337
542
|
|
|
@@ -341,7 +546,7 @@ export function writeResponseSpillDurably(
|
|
|
341
546
|
if (!OWNED_SPILL_NAME.test(fileName)) throw new Error("Response spill name allocation failed");
|
|
342
547
|
const destinationPath = join(dir, fileName);
|
|
343
548
|
try {
|
|
344
|
-
publishNoReplace(publishTempPath, destinationPath);
|
|
549
|
+
publishNoReplace(publishTempPath, destinationPath, aclBudget);
|
|
345
550
|
fsyncDirectoryBestEffort(dir);
|
|
346
551
|
unlinkEphemeral(publishTempPath);
|
|
347
552
|
tempPath = null;
|
|
@@ -352,14 +557,114 @@ export function writeResponseSpillDurably(
|
|
|
352
557
|
}
|
|
353
558
|
}
|
|
354
559
|
throw new Error("Response spill publication retries exhausted");
|
|
355
|
-
} catch {
|
|
560
|
+
} catch (cause) {
|
|
356
561
|
if (fd !== null) {
|
|
357
562
|
try { closeSync(fd); } catch { /* best effort */ }
|
|
358
563
|
}
|
|
359
564
|
if (tempPath) {
|
|
360
565
|
try { unlinkEphemeral(tempPath); } catch { /* best effort */ }
|
|
361
566
|
}
|
|
362
|
-
throw
|
|
567
|
+
throw responseSpillWriteError(cause);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Windows runtime counterpart of `writeResponseSpillDurably`.
|
|
573
|
+
*
|
|
574
|
+
* The filesystem publication contract stays identical, but required NTFS ACL subprocesses are
|
|
575
|
+
* awaited through Bun.spawn instead of Bun.spawnSync. State ownership and serialization remain in
|
|
576
|
+
* `state.ts`; callers must compare the resident generation again before installing the returned
|
|
577
|
+
* reference because another response can replace it while ACL hardening is pending.
|
|
578
|
+
*/
|
|
579
|
+
export async function writeResponseSpillDurablyAsync(
|
|
580
|
+
responseId: string,
|
|
581
|
+
state: Omit<ResponseSpillPayload, "version" | "responseId">,
|
|
582
|
+
options: ResponseSpillWriteOptions & { aclBudgetMs: number },
|
|
583
|
+
): Promise<ResponseSpillRef> {
|
|
584
|
+
const publicationControl = options.publicationControl;
|
|
585
|
+
const aclBudget = spillAclBudget(options.aclBudgetMs);
|
|
586
|
+
if (!aclBudget) throw new Error("Response spill async ACL budget is required");
|
|
587
|
+
let tempPath: string | null = null;
|
|
588
|
+
let fd: number | null = null;
|
|
589
|
+
try {
|
|
590
|
+
throwIfPublicationSuperseded(publicationControl);
|
|
591
|
+
const { bytes, digest, idDigest, contentDigest } = serializedSpill(responseId, state);
|
|
592
|
+
const dir = responseSpillDirectory();
|
|
593
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
594
|
+
await hardenAsync(dir, 0o700, aclBudget, options.retryTimedOutOnce === true);
|
|
595
|
+
throwIfPublicationSuperseded(publicationControl);
|
|
596
|
+
|
|
597
|
+
tempPath = join(dir, `.response-spill.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
598
|
+
fd = openSync(tempPath, "wx", 0o600);
|
|
599
|
+
if (publicationControl) publicationControl.tempPath = tempPath;
|
|
600
|
+
writeAll(fd, bytes);
|
|
601
|
+
fsyncFile(fd);
|
|
602
|
+
closeFile(fd);
|
|
603
|
+
fd = null;
|
|
604
|
+
await hardenAsync(tempPath, 0o600, aclBudget, options.retryTimedOutOnce === true);
|
|
605
|
+
throwIfPublicationSuperseded(publicationControl);
|
|
606
|
+
record("harden");
|
|
607
|
+
const publishTempPath = tempPath;
|
|
608
|
+
|
|
609
|
+
for (let attempt = 0; attempt < RESPONSE_SPILL_PUBLISH_RETRIES; attempt++) {
|
|
610
|
+
throwIfPublicationSuperseded(publicationControl);
|
|
611
|
+
spillGeneration += 1;
|
|
612
|
+
const fileName = `${sanitizeResponseId(responseId)}.${idDigest}.${contentDigest}.${spillGeneration}.${bytes.byteLength}.spill.json`;
|
|
613
|
+
if (!OWNED_SPILL_NAME.test(fileName)) throw new Error("Response spill name allocation failed");
|
|
614
|
+
const destinationPath = join(dir, fileName);
|
|
615
|
+
if (publicationControl) publicationControl.destinationPath = destinationPath;
|
|
616
|
+
try {
|
|
617
|
+
throwIfPublicationSuperseded(publicationControl);
|
|
618
|
+
await publishNoReplaceAsync(
|
|
619
|
+
publishTempPath,
|
|
620
|
+
destinationPath,
|
|
621
|
+
aclBudget,
|
|
622
|
+
options.retryTimedOutOnce === true,
|
|
623
|
+
publicationControl,
|
|
624
|
+
);
|
|
625
|
+
throwIfPublicationSuperseded(publicationControl);
|
|
626
|
+
fsyncDirectoryBestEffort(dir);
|
|
627
|
+
unlinkEphemeral(publishTempPath);
|
|
628
|
+
tempPath = null;
|
|
629
|
+
if (publicationControl) {
|
|
630
|
+
publicationControl.tempPath = null;
|
|
631
|
+
publicationControl.destinationPath = null;
|
|
632
|
+
}
|
|
633
|
+
return { version: 1, fileName, digest, payloadBytes: bytes.byteLength };
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (publicationControl?.superseded) throw error;
|
|
636
|
+
if (publicationControl) publicationControl.destinationPath = null;
|
|
637
|
+
if (isErrno(error, "EEXIST")) continue;
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
throw new Error("Response spill publication retries exhausted");
|
|
642
|
+
} catch (cause) {
|
|
643
|
+
if (fd !== null) {
|
|
644
|
+
try { closeSync(fd); } catch { /* best effort */ }
|
|
645
|
+
}
|
|
646
|
+
if (tempPath) {
|
|
647
|
+
try {
|
|
648
|
+
unlinkEphemeral(tempPath);
|
|
649
|
+
if (publicationControl?.tempPath === tempPath) publicationControl.tempPath = null;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (isErrno(error, "ENOENT") && publicationControl?.tempPath === tempPath) {
|
|
652
|
+
publicationControl.tempPath = null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (publicationControl) {
|
|
657
|
+
const destinationPath = publicationControl.destinationPath;
|
|
658
|
+
if (destinationPath) {
|
|
659
|
+
try {
|
|
660
|
+
unlink(destinationPath);
|
|
661
|
+
publicationControl.destinationPath = null;
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (isErrno(error, "ENOENT")) publicationControl.destinationPath = null;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
throw responseSpillWriteError(cause);
|
|
363
668
|
}
|
|
364
669
|
}
|
|
365
670
|
|