Skip to content
Merged
5 changes: 3 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,12 @@ async function handleStart(options: { block?: boolean } = {}) {
shutdownStartedAt = now;
console.log("\n🛑 Shutting down opencodex proxy...");
void (async () => {
let shutdownSucceeded = false;
try {
await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
shutdownSucceeded = await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
} finally {
const restored = syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit")
process.exit(restored ? 0 : 1);
process.exit(restored && shutdownSucceeded ? 0 : 1);
}
})();
};
Expand Down
21 changes: 18 additions & 3 deletions src/config/paths.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { chmodSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { hardenSecretDir } from "../lib/windows-secret-acl";
import { hardenSecretDirAsync, windowsSecretAclApplies } from "../lib/windows-secret-acl";
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";

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

export function getConfigDir(): string {
const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
Expand All @@ -34,7 +35,21 @@ export function hardenConfigDir(): void {
assertNotRealHomeUnderTest(dir);
if (!existsSync(dir)) return;
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
if (process.platform === "win32") {
hardenSecretDir(dir, { required: false });
if (windowsSecretAclApplies() && !configDirHardeningFlights.has(dir)) {
// This is an optional read-path harden. Waiting synchronously here used to stop the Bun
// event loop (including /healthz) for the full icacls timeout. Required mutation paths keep
// their own awaited/fail-closed hardening; ordinary config reads only start one soft flight.
const flight = hardenSecretDirAsync(dir, { required: false })
.then(() => undefined)
.catch(() => undefined)
.finally(() => {
if (configDirHardeningFlights.get(dir) === flight) configDirHardeningFlights.delete(dir);
});
configDirHardeningFlights.set(dir, flight);
}
}

/** Test-only: settle optional config-directory hardening without exposing it to production callers. */
export async function flushConfigDirHardeningForTests(): Promise<void> {
await Promise.all([...configDirHardeningFlights.values()]);
}
36 changes: 36 additions & 0 deletions src/lib/bounded-subprocess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export interface KillableSubprocess {
exited: Promise<number>;
kill(): unknown;
unref?(): unknown;
}

export interface BoundedSubprocessExit {
exitCode: number | null;
timedOut: boolean;
}

/** Kill at the deadline and abandon immediately; late exit/rejection remains observed. */
export function waitForSubprocessExit(
proc: KillableSubprocess,
timeoutMs: number,
): Promise<BoundedSubprocessExit> {
return new Promise(resolve => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (result: BoundedSubprocessExit): void => {
if (settled) return;
settled = true;
if (timer !== undefined) clearTimeout(timer);
resolve(result);
};
timer = setTimeout(() => {
try { proc.kill(); } catch { /* already exited */ }
try { proc.unref?.(); } catch { /* abandonment is still authoritative */ }
finish({ exitCode: null, timedOut: true });
}, Math.max(1, timeoutMs));
void proc.exited.then(
exitCode => finish({ exitCode, timedOut: false }),
() => finish({ exitCode: null, timedOut: false }),
);
});
}
72 changes: 47 additions & 25 deletions src/lib/windows-secret-acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import { existsSync, statSync } from "node:fs";
import { env, platform } from "node:process";
import { waitForSubprocessExit } from "./bounded-subprocess";
import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation";
import {
cachedCurrentWindowsIdentity,
Expand Down Expand Up @@ -216,6 +217,11 @@ export interface HardenResult {

export interface HardenOptions {
required: boolean;
/**
* Explicit total budget for this harden call. Shutdown recovery uses a reduced
* caller-owned slice instead of opening the normal 30-second window.
*/
deadlineMs?: number;
/**
* Optional timeout-memo key distinct from `targetPath` (issue #612).
* Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the
Expand Down Expand Up @@ -257,7 +263,11 @@ const HARDEN_DEADLINE_MIN_MS = 1_000;
const HARDEN_DEADLINE_MAX_MS = 60_000;

/** Resolve the total harden budget once per call (env mutation cannot change it midway). */
function resolveHardenDeadlineMs(): number {
function resolveHardenDeadlineMs(overrideMs?: number): number {
if (overrideMs !== undefined) {
if (!Number.isSafeInteger(overrideMs) || overrideMs <= 0) return 1;
return Math.min(HARDEN_DEADLINE_MAX_MS, overrideMs);
}
const raw = env["OPENCODEX_ACL_TIMEOUT_MS"]?.trim();
if (!raw) return HARDEN_DEADLINE_DEFAULT_MS;
const parsed = Number(raw);
Expand Down Expand Up @@ -328,27 +338,16 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {

/**
* Async icacls runner (#612): yields the event loop while waiting for the child.
* Timeout provenance is recorded by our timer (async Subprocess has no exitedDueToTimeout);
* we still await process exit before classifying so settlement is confirmed.
* Async Subprocess has no exitedDueToTimeout, so the shared settlement helper
* classifies the deadline and abandons a child that does not settle after kill.
*/
async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
const proc = trySpawnIcacls(args);
if (!proc) return spawnFailedResult();
let timedOutByUs = false;
const timer = setTimeout(() => {
timedOutByUs = true;
try { proc.kill(); } catch { /* already exited */ }
}, Math.max(1, timeoutMs));
let exitCode: number | null = null;
try {
exitCode = await proc.exited;
} finally {
clearTimeout(timer);
}
const stdout = proc.stdout
const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs);
const stdout = !timedOut && proc.stdout
? await new Response(proc.stdout).text().catch(() => "")
: "";
const timedOut = timedOutByUs;
return {
success: !timedOut && exitCode === 0,
exitCode: timedOut ? null : exitCode,
Expand All @@ -357,6 +356,24 @@ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Prom
};
}

function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
return new Promise(resolve => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (result: IcaclsResult): void => {
if (settled) return;
settled = true;
if (timer !== undefined) clearTimeout(timer);
resolve(result);
};
timer = setTimeout(
() => finish({ success: false, exitCode: null, timedOut: true, stdout: "" }),
Math.max(1, timeoutMs),
);
void asyncIcaclsRunner(args, timeoutMs).then(finish, () => finish(spawnFailedResult()));
});
}

let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner;
let platformOverride: string | null = null;
Expand Down Expand Up @@ -537,12 +554,14 @@ function shouldVerifyExistingAcl(): boolean {
return env["OPENCODEX_ACL_VERIFY_EXISTING"] === "1";
}

function existingAclAlreadyCompliant(targetPath: string, directory: boolean): boolean {
function existingAclAlreadyCompliant(targetPath: string, directory: boolean, deadline: number): boolean {
if (!shouldVerifyExistingAcl()) return false;
const identity = cachedCurrentWindowsIdentity();
if (!identity) return false;
try {
const result = icaclsRunner([targetPath], resolveHardenDeadlineMs());
const remaining = deadline - nowFn();
if (remaining <= 0) return false;
const result = icaclsRunner([targetPath], remaining);
return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
} catch {
return false;
Expand All @@ -552,12 +571,15 @@ function existingAclAlreadyCompliant(targetPath: string, directory: boolean): bo
async function existingAclAlreadyCompliantAsync(
targetPath: string,
directory: boolean,
deadline: number,
): Promise<boolean> {
if (!shouldVerifyExistingAcl()) return false;
const identity = cachedCurrentWindowsIdentity();
if (!identity) return false;
try {
const result = await asyncIcaclsRunner([targetPath], resolveHardenDeadlineMs());
const remaining = deadline - nowFn();
if (remaining <= 0) return false;
const result = await awaitAsyncIcaclsRunner([targetPath], remaining);
return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
} catch {
return false;
Expand Down Expand Up @@ -618,7 +640,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline:
if (remaining <= 0) {
throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" });
}
return asyncIcaclsRunner(args, remaining);
return awaitAsyncIcaclsRunner(args, remaining);
};
const runOrThrow = async (step: string, args: string[]): Promise<void> => {
const result = await run(step, args);
Expand Down Expand Up @@ -751,7 +773,7 @@ async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: n
for (const sid of BROAD_SIDS) {
const remaining = deadline - nowFn();
if (remaining <= 0) return "ACL state unverified (budget exhausted)";
const found = await asyncIcaclsRunner([targetPath, "/findsid", sid], remaining);
const found = await awaitAsyncIcaclsRunner([targetPath, "/findsid", sid], remaining);
if (!found.success) return "ACL state unverified (probe failed)";
if (found.stdout.includes(targetPath)) return "broad ACL grants still present";
}
Expand Down Expand Up @@ -788,15 +810,15 @@ function hardenEntry(
if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
if (effectivePlatform() !== "win32") return { ok: true };
if (memoSatisfied(cache, targetPath)) return { ok: true };
if (existingAclAlreadyCompliant(targetPath, directory)) return { ok: true };
const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
if (existingAclAlreadyCompliant(targetPath, directory, deadline)) return { ok: true };
const memoKey = timeoutMemoKey(targetPath, opts);
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
if (timeoutMemoError) {
if (opts.required) throw timeoutMemoError;
return { ok: false, diagnostics: timeoutMemoError.message };
}

const deadline = nowFn() + resolveHardenDeadlineMs();
let lastErr: unknown;
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains
Expand Down Expand Up @@ -841,15 +863,15 @@ async function hardenEntryAsync(
if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
if (effectivePlatform() !== "win32") return { ok: true };
if (memoSatisfied(cache, targetPath)) return { ok: true };
if (await existingAclAlreadyCompliantAsync(targetPath, directory)) return { ok: true };
const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
if (await existingAclAlreadyCompliantAsync(targetPath, directory, deadline)) return { ok: true };
const memoKey = timeoutMemoKey(targetPath, opts);
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
if (timeoutMemoError) {
if (opts.required) throw timeoutMemoError;
return { ok: false, diagnostics: timeoutMemoError.message };
}

const deadline = nowFn() + resolveHardenDeadlineMs();
let lastErr: unknown;
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt > 0 && deadline - nowFn() <= 0) break;
Expand Down
25 changes: 8 additions & 17 deletions src/lib/windows-user-principal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import { existsSync } from "node:fs";
import { win32 as windowsPath } from "node:path";
import { waitForSubprocessExit } from "./bounded-subprocess";

import {
resolveTrustedWindowsPowerShellExe,
Expand Down Expand Up @@ -143,18 +144,8 @@ async function defaultAsyncWindowsPrincipalRunner(
stderr: "ignore",
windowsHide: true,
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
try { proc.kill(); } catch { /* already exited */ }
}, Math.max(1, timeoutMs));
let exitCode: number | null = null;
try {
exitCode = await proc.exited;
} finally {
clearTimeout(timer);
}
const stdout = proc.stdout
const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs);
const stdout = !timedOut && proc.stdout
? await new Response(proc.stdout).text().catch(() => "")
: "";
return {
Expand Down Expand Up @@ -314,11 +305,11 @@ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Pr
return `*${cachedIdentity.sid}`;
})();
asyncLookupInFlight = lookup;
try {
return await lookup;
} finally {
if (asyncLookupInFlight === lookup) asyncLookupInFlight = null;
}
void lookup.then(
() => { if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; },
() => { if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; },
);
return waitForExistingLookup(lookup, timeoutMs);
}

/** Test seam: replace the sync resolver process and clear its successful cache. */
Expand Down
Loading
Loading