Skip to content

Commit 400a6a3

Browse files
committed
fix(windows): move response spill ACL work off event loop
1 parent 1031b6f commit 400a6a3

6 files changed

Lines changed: 540 additions & 32 deletions

File tree

src/config/paths.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { chmodSync, existsSync } from "node:fs";
22
import { homedir } from "node:os";
33
import { join, resolve } from "node:path";
4-
import { hardenSecretDir } from "../lib/windows-secret-acl";
4+
import { hardenSecretDirAsync, windowsSecretAclApplies } from "../lib/windows-secret-acl";
55
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
66

77
/**
@@ -14,6 +14,7 @@ export function expandUserPath(raw: string): string {
1414
return raw;
1515
}
1616
let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null;
17+
const configDirHardeningFlights = new Map<string, Promise<void>>();
1718

1819
export function getConfigDir(): string {
1920
const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
@@ -34,7 +35,21 @@ export function hardenConfigDir(): void {
3435
assertNotRealHomeUnderTest(dir);
3536
if (!existsSync(dir)) return;
3637
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
37-
if (process.platform === "win32") {
38-
hardenSecretDir(dir, { required: false });
38+
if (windowsSecretAclApplies() && !configDirHardeningFlights.has(dir)) {
39+
// This is an optional read-path harden. Waiting synchronously here used to stop the Bun
40+
// event loop (including /healthz) for the full icacls timeout. Required mutation paths keep
41+
// their own awaited/fail-closed hardening; ordinary config reads only start one soft flight.
42+
const flight = hardenSecretDirAsync(dir, { required: false })
43+
.then(() => undefined)
44+
.catch(() => undefined)
45+
.finally(() => {
46+
if (configDirHardeningFlights.get(dir) === flight) configDirHardeningFlights.delete(dir);
47+
});
48+
configDirHardeningFlights.set(dir, flight);
3949
}
4050
}
51+
52+
/** Test-only: settle optional config-directory hardening without exposing it to production callers. */
53+
export async function flushConfigDirHardeningForTests(): Promise<void> {
54+
await Promise.all([...configDirHardeningFlights.values()]);
55+
}

src/responses/spill-store.ts

Lines changed: 161 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@ import {
1717
import { createHash, randomBytes } from "node:crypto";
1818
import { join } from "node:path";
1919
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";
2129
import { isValidProviderContinuationOwner } from "./provider-continuation";
2230
import type { OcxProviderContinuationState } from "../types";
2331

@@ -189,6 +197,20 @@ function harden(path: string, mode: number): void {
189197
}
190198
}
191199

200+
async function hardenAsync(path: string, mode: number, retryTimedOutOnce = false): Promise<void> {
201+
try {
202+
chmodSync(path, mode);
203+
} catch {
204+
if (!windowsSecretAclApplies()) throw new Error("Response spill permission hardening failed");
205+
}
206+
if (windowsSecretAclApplies()) {
207+
const result = mode === 0o700
208+
? await hardenSecretDirAsync(path, { required: true, retryTimedOutOnce })
209+
: await hardenSecretPathAsync(path, { required: true, retryTimedOutOnce });
210+
if (!result.ok) throw new Error("Response spill permission hardening failed");
211+
}
212+
}
213+
192214
function writeAll(fd: number, bytes: Uint8Array): void {
193215
if (spillIoForTest?.write) spillIoForTest.write(fd, bytes);
194216
else {
@@ -261,6 +283,78 @@ function publishNoReplace(tempPath: string, destinationPath: string): void {
261283
record("publish");
262284
}
263285

286+
async function publishNoReplaceAsync(
287+
tempPath: string,
288+
destinationPath: string,
289+
retryTimedOutOnce: boolean,
290+
): Promise<void> {
291+
try {
292+
if (spillIoForTest?.link) spillIoForTest.link(tempPath, destinationPath);
293+
else linkSync(tempPath, destinationPath);
294+
} catch (error) {
295+
if (isErrno(error, "EEXIST")) throw error;
296+
if (!canUseExclusiveCopyFallback(error)) throw error;
297+
let copied = false;
298+
try {
299+
if (spillIoForTest?.copyFileExcl) spillIoForTest.copyFileExcl(tempPath, destinationPath);
300+
else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL);
301+
copied = true;
302+
await hardenAsync(destinationPath, 0o600, retryTimedOutOnce);
303+
const copyFd = openSync(destinationPath, "r");
304+
try {
305+
if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
306+
else fsyncSync(copyFd);
307+
} finally {
308+
closeSync(copyFd);
309+
}
310+
} catch (copyError) {
311+
if (copied) {
312+
try { unlink(destinationPath); } catch { /* startup GC reclaims an incomplete publication */ }
313+
}
314+
throw copyError;
315+
}
316+
}
317+
record("publish");
318+
}
319+
320+
function serializedSpill(
321+
responseId: string,
322+
state: Omit<ResponseSpillPayload, "version" | "responseId">,
323+
): {
324+
bytes: Buffer;
325+
digest: string;
326+
idDigest: string;
327+
contentDigest: string;
328+
} {
329+
const payload: ResponseSpillPayload = {
330+
version: 1,
331+
responseId,
332+
createdAt: state.createdAt,
333+
...(state.clientThreadId ? { clientThreadId: state.clientThreadId } : {}),
334+
items: state.items,
335+
...(state.providerOutputStart !== undefined ? { providerOutputStart: state.providerOutputStart } : {}),
336+
...(state.providers ? { providers: state.providers } : {}),
337+
};
338+
const serialized = JSON.stringify(payload);
339+
if (serialized === undefined) throw new Error("Response spill serialization failed");
340+
const bytes = Buffer.from(serialized, "utf8");
341+
const digest = sha256(bytes);
342+
return {
343+
bytes,
344+
digest,
345+
idDigest: sha256(responseId).slice(0, 12),
346+
contentDigest: digest.slice(0, 24),
347+
};
348+
}
349+
350+
function responseSpillWriteError(cause: unknown): NodeJS.ErrnoException {
351+
const error = new Error("Response spill write failed", { cause }) as NodeJS.ErrnoException;
352+
if (cause && typeof cause === "object" && "code" in cause) {
353+
error.code = String((cause as { code?: unknown }).code);
354+
}
355+
return error;
356+
}
357+
264358
function validSpillRef(ref: ResponseSpillRef): boolean {
265359
return ref.version === 1
266360
&& OWNED_SPILL_NAME.test(ref.fileName)
@@ -306,21 +400,7 @@ export function writeResponseSpillDurably(
306400
let tempPath: string | null = null;
307401
let fd: number | null = null;
308402
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);
403+
const { bytes, digest, idDigest, contentDigest } = serializedSpill(responseId, state);
324404
const dir = responseSpillDirectory();
325405
mkdirSync(dir, { recursive: true, mode: 0o700 });
326406
harden(dir, 0o700);
@@ -352,14 +432,77 @@ export function writeResponseSpillDurably(
352432
}
353433
}
354434
throw new Error("Response spill publication retries exhausted");
355-
} catch {
435+
} catch (cause) {
436+
if (fd !== null) {
437+
try { closeSync(fd); } catch { /* best effort */ }
438+
}
439+
if (tempPath) {
440+
try { unlinkEphemeral(tempPath); } catch { /* best effort */ }
441+
}
442+
throw responseSpillWriteError(cause);
443+
}
444+
}
445+
446+
/**
447+
* Windows runtime counterpart of `writeResponseSpillDurably`.
448+
*
449+
* The filesystem publication contract stays identical, but required NTFS ACL subprocesses are
450+
* awaited through Bun.spawn instead of Bun.spawnSync. State ownership and serialization remain in
451+
* `state.ts`; callers must compare the resident generation again before installing the returned
452+
* reference because another response can replace it while ACL hardening is pending.
453+
*/
454+
export async function writeResponseSpillDurablyAsync(
455+
responseId: string,
456+
state: Omit<ResponseSpillPayload, "version" | "responseId">,
457+
options: { retryTimedOutOnce?: boolean } = {},
458+
): Promise<ResponseSpillRef> {
459+
let tempPath: string | null = null;
460+
let fd: number | null = null;
461+
try {
462+
const { bytes, digest, idDigest, contentDigest } = serializedSpill(responseId, state);
463+
const dir = responseSpillDirectory();
464+
mkdirSync(dir, { recursive: true, mode: 0o700 });
465+
await hardenAsync(dir, 0o700, options.retryTimedOutOnce === true);
466+
467+
tempPath = join(dir, `.response-spill.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
468+
fd = openSync(tempPath, "wx", 0o600);
469+
writeAll(fd, bytes);
470+
fsyncFile(fd);
471+
closeFile(fd);
472+
fd = null;
473+
await hardenAsync(tempPath, 0o600, options.retryTimedOutOnce === true);
474+
record("harden");
475+
const publishTempPath = tempPath;
476+
477+
for (let attempt = 0; attempt < RESPONSE_SPILL_PUBLISH_RETRIES; attempt++) {
478+
spillGeneration += 1;
479+
const fileName = `${sanitizeResponseId(responseId)}.${idDigest}.${contentDigest}.${spillGeneration}.${bytes.byteLength}.spill.json`;
480+
if (!OWNED_SPILL_NAME.test(fileName)) throw new Error("Response spill name allocation failed");
481+
const destinationPath = join(dir, fileName);
482+
try {
483+
await publishNoReplaceAsync(
484+
publishTempPath,
485+
destinationPath,
486+
options.retryTimedOutOnce === true,
487+
);
488+
fsyncDirectoryBestEffort(dir);
489+
unlinkEphemeral(publishTempPath);
490+
tempPath = null;
491+
return { version: 1, fileName, digest, payloadBytes: bytes.byteLength };
492+
} catch (error) {
493+
if (isErrno(error, "EEXIST")) continue;
494+
throw error;
495+
}
496+
}
497+
throw new Error("Response spill publication retries exhausted");
498+
} catch (cause) {
356499
if (fd !== null) {
357500
try { closeSync(fd); } catch { /* best effort */ }
358501
}
359502
if (tempPath) {
360503
try { unlinkEphemeral(tempPath); } catch { /* best effort */ }
361504
}
362-
throw new Error("Response spill write failed");
505+
throw responseSpillWriteError(cause);
363506
}
364507
}
365508

0 commit comments

Comments
 (0)