Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/codex/native-main-lock-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,16 @@ export function assertStableLockFile(path: string, handle: StableLockFile): void
export async function hardenStableLockFile(
path: string,
platform: NodeJS.Platform = process.platform,
options: { retryTimedOutOnce?: boolean } = {},
): Promise<void> {
if (platform === "win32") {
// Best-effort here: POSIX modes are not authoritative on NTFS, and the
// required ACL hardening below is what actually decides.
try { chmodSync(path, 0o600); } catch { /* ACL below is authoritative. */ }
await hardenSecretPathAsync(path, { required: true });
await hardenSecretPathAsync(path, {
required: true,
retryTimedOutOnce: options.retryTimedOutOnce,
});
return;
}
// On POSIX the mode IS the mechanism, so a failure may not be swallowed.
Expand Down
20 changes: 17 additions & 3 deletions src/codex/native-main-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface OwnerEntry {
timer?: ReturnType<typeof setTimeout>;
drive?: Promise<void>;
activeOperations: Set<Promise<unknown>>;
aclTimeoutRetryUsed: boolean;
closing: boolean;
}

Expand Down Expand Up @@ -149,8 +150,13 @@ async function prepareOwnerDatabase(entry: OwnerEntry): Promise<void> {
// here still exercises the host's branch, and the production default — the
// thing that actually hardens the owner's lock file — stays unproved. An
// audit replaced this fallback with a no-op and 91 tests stayed green.
await (entry.options.hardenPath
?? ((target: string) => hardenStableLockFile(target, entry.options.platform)))(entry.lockPath);
if (entry.options.hardenPath) {
await entry.options.hardenPath(entry.lockPath);
} else {
await hardenStableLockFile(entry.lockPath, entry.options.platform, {
retryTimedOutOnce: entry.aclTimeoutRetryUsed,
});
}
assertStableLockFile(entry.lockPath, file);
entry.file = file;
file = undefined;
Expand Down Expand Up @@ -196,6 +202,13 @@ async function drive(entry: OwnerEntry, generation: number): Promise<void> {
scheduleRetry(entry, generation);
return;
}
if (errorCode(error) === "ETIMEDOUT" && !entry.aclTimeoutRetryUsed) {
entry.aclTimeoutRetryUsed = true;
// A timeout is neither ownership contention nor a permanent ACL denial.
// Stay fail-closed in `acquiring` and spend exactly one fresh-budget retry.
scheduleRetry(entry, generation);
return;
}
publish(entry, { status: "unavailable", homeId: entry.context.homeId, reason: "lock-unavailable" });
}
}
Expand All @@ -222,6 +235,7 @@ function entryFor(context: NativeProfileContext, options: NativeMainOwnerOptions
snapshot: { status: "acquiring", homeId: context.homeId },
listeners: new Set(),
activeOperations: new Set(),
aclTimeoutRetryUsed: false,
prepared: false,
closing: false,
};
Expand All @@ -239,7 +253,7 @@ export function retainNativeMainOwner(
throw new NativeProfileError("NATIVE_MAIN_OWNER_BUSY", "Native-main ownership is closing.", 503, true);
}
entry.refs += 1;
if (!entry.drive && !entry.database && !entry.timer) {
if (!entry.drive && !entry.database && !entry.timer && entry.snapshot.status !== "unavailable") {
const generation = entry.generation;
entry.drive = drive(entry, generation).finally(() => {
if (entry.generation === generation) entry.drive = undefined;
Expand Down
88 changes: 72 additions & 16 deletions src/lib/windows-secret-acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ import { env, platform } from "node:process";

const hardenedDirectories = new Map<string, HardenedIdentity>();
const hardenedPaths = new Map<string, HardenedIdentity>();
/** Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them. */
const timedOutPaths = new Set<string>();
/**
* Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them.
* `false` means one explicitly authorized recovery attempt remains; `true` means
* that attempt was consumed. Ordinary callers never consume it.
*/
const timedOutPaths = new Map<string, boolean>();

/**
* The memo value: `object:freshness` for a file a harden was actually attributed
Expand Down Expand Up @@ -212,6 +216,12 @@ export interface HardenOptions {
* Must NOT be a parent directory — directory ACLs are not authoritative for new files.
*/
timeoutMemoKey?: string;
/**
* Consume the one recovery attempt for a previously timed-out memo key.
* Only a caller that owns its own single-flight and bounded retry policy should
* set this. It never clears or bypasses an already-consumed timeout memo.
*/
retryTimedOutOnce?: boolean;
}

/**
Expand Down Expand Up @@ -524,6 +534,50 @@ function isTimeoutError(error: unknown): boolean {
&& String((error as NodeJS.ErrnoException).code) === "ETIMEDOUT";
}

/** Preserve only the bounded machine-readable cause on a sanitized public error. */
function sanitizedAclError(diagnostics: string, cause: unknown): NodeJS.ErrnoException {
const error = new Error(diagnostics) as NodeJS.ErrnoException;
const code = cause && typeof cause === "object" && "code" in cause
? String((cause as { code?: unknown }).code)
: "";
if (code === "ETIMEDOUT" || code === "EICACLS" || code === "EACCES" || code === "EPERM") {
error.code = code;
}
return error;
}

function previousTimeoutError(retryConsumed: boolean): NodeJS.ErrnoException {
if (retryConsumed) {
const error = new Error(
"ACL hardening skipped — the previous timeout recovery was already consumed",
) as NodeJS.ErrnoException;
error.code = "EACLRETRYEXHAUSTED";
return error;
}
return sanitizedAclError(
"ACL hardening skipped — previous attempt timed out",
Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }),
);
}

/** Consume, but never reset, the single explicit recovery attempt for this key. */
function timeoutMemoErrorIfBlocked(
memoKey: string,
opts: HardenOptions,
): NodeJS.ErrnoException | null {
const retryConsumed = timedOutPaths.get(memoKey);
if (retryConsumed === undefined) return null;
if (opts.retryTimedOutOnce && retryConsumed === false) {
timedOutPaths.set(memoKey, true);
return null;
}
return previousTimeoutError(retryConsumed);
}

function recordTimeout(memoKey: string): void {
if (!timedOutPaths.has(memoKey)) timedOutPaths.set(memoKey, false);
}

/**
* Diagnostic-only post-timeout probe (never promotes to ok:true — a clean /findsid
* does not prove inheritance was disabled or the user grant ran; only a fully
Expand Down Expand Up @@ -588,10 +642,10 @@ function hardenEntry(
if (effectivePlatform() !== "win32") return { ok: true };
if (memoSatisfied(cache, targetPath)) return { ok: true };
const memoKey = timeoutMemoKey(targetPath, opts);
if (timedOutPaths.has(memoKey)) {
const diagnostics = "ACL hardening skipped — previous attempt timed out";
if (opts.required) throw new Error(diagnostics);
return { ok: false, diagnostics };
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
if (timeoutMemoError) {
if (opts.required) throw timeoutMemoError;
return { ok: false, diagnostics: timeoutMemoError.message };
}

const deadline = nowFn() + resolveHardenDeadlineMs();
Expand All @@ -606,6 +660,7 @@ function hardenEntry(
if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
}
timedOutPaths.delete(memoKey);
return { ok: true };
} catch (err) {
// A substitution is not a transient icacls stall; do not spend the retry on it.
Expand All @@ -617,14 +672,14 @@ function hardenEntry(

const diagnostics = sanitizeDiagnostics(lastErr);
if (isTimeoutError(lastErr)) {
timedOutPaths.add(memoKey);
recordTimeout(memoKey);
const state = describeAclStateAfterTimeout(targetPath, deadline);
const annotated = `${diagnostics}; ${state}`;
if (opts.required) throw new Error(annotated);
if (opts.required) throw sanitizedAclError(annotated, lastErr);
console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
return { ok: false, diagnostics: annotated };
}
if (opts.required) throw new Error(diagnostics);
if (opts.required) throw sanitizedAclError(diagnostics, lastErr);
return { ok: false, diagnostics };
}

Expand All @@ -639,10 +694,10 @@ async function hardenEntryAsync(
if (effectivePlatform() !== "win32") return { ok: true };
if (memoSatisfied(cache, targetPath)) return { ok: true };
const memoKey = timeoutMemoKey(targetPath, opts);
if (timedOutPaths.has(memoKey)) {
const diagnostics = "ACL hardening skipped — previous attempt timed out";
if (opts.required) throw new Error(diagnostics);
return { ok: false, diagnostics };
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
if (timeoutMemoError) {
if (opts.required) throw timeoutMemoError;
return { ok: false, diagnostics: timeoutMemoError.message };
}

const deadline = nowFn() + resolveHardenDeadlineMs();
Expand All @@ -656,6 +711,7 @@ async function hardenEntryAsync(
if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
}
timedOutPaths.delete(memoKey);
return { ok: true };
} catch (err) {
if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err;
Expand All @@ -666,14 +722,14 @@ async function hardenEntryAsync(

const diagnostics = sanitizeDiagnostics(lastErr);
if (isTimeoutError(lastErr)) {
timedOutPaths.add(memoKey);
recordTimeout(memoKey);
const state = await describeAclStateAfterTimeoutAsync(targetPath, deadline);
const annotated = `${diagnostics}; ${state}`;
if (opts.required) throw new Error(annotated);
if (opts.required) throw sanitizedAclError(annotated, lastErr);
console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
return { ok: false, diagnostics: annotated };
}
if (opts.required) throw new Error(diagnostics);
if (opts.required) throw sanitizedAclError(diagnostics, lastErr);
return { ok: false, diagnostics };
}

Expand Down
78 changes: 78 additions & 0 deletions tests/native-main-owner-lifetime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,84 @@ function isContended(event: Event): boolean {
}

describe("native-main process owner lease", () => {
test("one coded ACL timeout retries once before ownership becomes held", async () => {
const f = fixture("acl-timeout-recovery");
let attempts = 0;
const trace: string[] = [];
const owner = retainNativeMainOwner(f.manager.context, {
retryMs: 10,
hardenPath: async () => {
attempts += 1;
if (attempts === 1) throw Object.assign(new Error("transient"), { code: "ETIMEDOUT" });
},
});
const unsubscribe = owner.subscribe(snapshot => { trace.push(snapshot.status); });
try {
await waitUntil(() => owner.snapshot().status === "held" ? true : null);
expect(attempts).toBe(2);
expect(trace).toEqual(["acquiring", "held"]);
} finally {
unsubscribe();
await owner.release();
}
});

test("a second coded ACL timeout becomes terminal and a release cancels a pending retry", async () => {
const f = fixture("acl-timeout-terminal");
let attempts = 0;
const trace: string[] = [];
const owner = retainNativeMainOwner(f.manager.context, {
retryMs: 10,
hardenPath: async () => {
attempts += 1;
throw Object.assign(new Error("transient"), { code: "ETIMEDOUT" });
},
});
const unsubscribe = owner.subscribe(snapshot => { trace.push(snapshot.status); });
try {
await waitUntil(() => owner.snapshot().status === "unavailable" ? true : null);
await Bun.sleep(50);
expect(attempts).toBe(2);
expect(trace).toEqual(["acquiring", "unavailable"]);
} finally {
unsubscribe();
await owner.release();
}

const f2 = fixture("acl-timeout-release");
let releasedAttempts = 0;
const releasing = retainNativeMainOwner(f2.manager.context, {
retryMs: 100,
hardenPath: async () => {
releasedAttempts += 1;
throw Object.assign(new Error("transient"), { code: "ETIMEDOUT" });
},
});
await waitUntil(() => releasedAttempts === 1 ? true : null);
await releasing.release();
await Bun.sleep(150);
expect(releasedAttempts).toBe(1);
});

test("an ETIMEDOUT-looking message without the code remains a permanent failure", async () => {
const f = fixture("acl-timeout-message-only");
let attempts = 0;
const owner = retainNativeMainOwner(f.manager.context, {
retryMs: 10,
hardenPath: async () => {
attempts += 1;
throw new Error("ETIMEDOUT in untrusted prose");
},
});
try {
await waitUntil(() => owner.snapshot().status === "unavailable" ? true : null);
await Bun.sleep(50);
expect(attempts).toBe(1);
} finally {
await owner.release();
}
});

test("same-process references retain one owner and do not deadlock the transaction lock", async () => {
const f = fixture();
const first = retainNativeMainOwner(f.manager.context, { retryMs: 10, hardenPath: async () => {} });
Expand Down
Loading
Loading