@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.
- package/bin/ocx.mjs +127 -16
- package/gui/dist/assets/index-6HZDkIpX.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -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/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- 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/dispatch.ts +54 -18
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +423 -32
- package/src/cli/integrations.ts +24 -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/cli/uninstall-plan.ts +86 -0
- package/src/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +131 -12
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +96 -10
- 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/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -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/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/routing.ts +68 -16
- package/src/codex/shim.ts +204 -18
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +155 -67
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +107 -16
- package/src/lib/bounded-subprocess.ts +36 -0
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -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/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +354 -29
- package/src/responses/state.ts +775 -8
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- package/src/server/management/integration-routes.ts +36 -5
- 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 +72 -13
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/startup-action-control.ts +3 -2
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +749 -53
- 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 +39 -6
- package/src/update/job.ts +10 -11
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-CowztZdo.js +0 -112
- 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,57 @@ 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 {
|
|
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
|
|
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);
|
|
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
|
|
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
|
|