Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fdf0236
fix(windows): async ACL harden for response-state writes (#612)
Wibias Jul 28, 2026
2bbac19
docs: a dev merge is not done until dev2-go carries it too
lidge-jun Jul 29, 2026
a47aa3f
docs: harden and scope agent guidance (#583)
Wibias Jul 29, 2026
257c626
feat(videos): add Grok video bridge for non-OpenAI models (#582)
tizerluo Jul 29, 2026
20cdc0d
fix: scope Spark cooldowns to its native quota (#599)
akrock Jul 29, 2026
f492f7d
feat(gui): show reset credit expiration time (#613)
ACJF00 Jul 29, 2026
868a9c4
fix(adapters): neutralize Codex CLI 0.145 identity wording (#638)
Wibias Jul 29, 2026
c9bed7c
feat(codex): add account namespace foundation
chrisae9 Jul 26, 2026
f0867e8
feat(codex): account pause controls and bulk exhaustion (#667)
Wibias Jul 29, 2026
f7be351
docs: require needs-go-port for deferred Go ports (#672)
Wibias Jul 29, 2026
dcaede6
fix(cursor): steer Windows bridge shell away from PS 5.1 syntax loops…
Wibias Jul 29, 2026
a4fb284
fix(gui): dedupe dashboard API token prompts on concurrent 401s (#651)
Wibias Jul 29, 2026
14dde56
fix(ci): harden PR target draft conversion and issue-quality Version …
Wibias Jul 29, 2026
9d181fc
fix(catalog): accept Together top-level /models arrays (#639)
Wibias Jul 29, 2026
e6169b0
fix(cli): status/doctor use identity-verified live proxy (#642)
Wibias Jul 29, 2026
f19a73c
fix(catalog): omit bare OpenAI models without openai provider (#643)
Wibias Jul 29, 2026
2421929
merge(dev): resolve #645 conflict with account namespaces
Wibias Jul 29, 2026
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
86 changes: 85 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir } from "node:os";
import { join, resolve } from "node:path";
import * as z from "zod/v4";
import { comboConfigIssues } from "./combos/types";
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/windows-secret-acl";
import { providerDestinationConfigError } from "./lib/destination-policy";
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
import {
Expand Down Expand Up @@ -128,6 +128,90 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
}
}

/** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */
export interface AtomicWriteAsyncIO {
write: (path: string, content: string) => void | Promise<void>;
harden: (path: string) => void | Promise<void>;
rename: (source: string, destination: string) => void | Promise<void>;
truncate: (path: string) => void | Promise<void>;
unlink: (path: string) => void | Promise<void>;
}

async function renameAtomicFileAsync(source: string, destination: string): Promise<void> {
for (let attempt = 0; ; attempt += 1) {
try {
renameSync(source, destination);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
const transientWindowsError = process.platform === "win32"
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
if (!transientWindowsError || attempt >= 2) throw error;
await Bun.sleep(25 * (attempt + 1));
}
}
}

/**
* Async atomic write (#612): same temp+harden+rename and residual-temp policy as
* atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed
* by the final destination path (not the unique temp, not the parent directory).
*/
export async function atomicWriteFileAsync(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retarget this runtime fix to dev

The reviewed commit is an ordinary TypeScript runtime fix whose direct parent is ca7b104 (main), rather than a maintainer-controlled promotion from dev; retarget or rebase the change onto dev before merging, since neither the async writer nor any other changed file belongs to the scoped dev2-go line.

AGENTS.md reference: AGENTS.md:L77-L82

Useful? React with 👍 / 👎.

path: string,
content: string,
io?: AtomicWriteAsyncIO,
): Promise<void> {
const effective: AtomicWriteAsyncIO = io ?? {
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
harden: async target => {
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
if (process.platform === "win32") {
await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Obtain explicit security review before merging

This changes the Windows ACL enforcement path that protects persisted conversation/request content, including a destination-keyed timeout memo that can cause later temporary files to skip icacls, while the commit's test plan explicitly leaves security review unchecked; obtain the required explicit security review before merging this confidentiality-boundary change.

AGENTS.md reference: AGENTS.md:L83-L89

Useful? React with 👍 / 👎.

}
},
rename: renameAtomicFileAsync,
truncate: target => truncateSync(target, 0),
unlink: unlinkSync,
};
const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
let hardened = false;
try {
await effective.write(tmp, content);
await effective.harden(tmp);
hardened = true;
await effective.rename(tmp, path);
} catch (cause) {
let scrubbed = false;
try {
await effective.truncate(tmp);
scrubbed = true;
} catch (error) {
if (isMissingPathError(error)) scrubbed = true;
else {
try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
}
}
let removed = false;
try {
await effective.unlink(tmp);
removed = true;
} catch (error) {
if (isMissingPathError(error)) removed = true;
else {
try { await effective.unlink(tmp); removed = true; }
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
}
}
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
if (!removed && !hardened) {
try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
}
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
throw cause;
}
}

Comment on lines +138 to +221

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

atomicWriteFileAsync is a 45-line clone of atomicWriteFile, including the secret-residual policy.

Lines 184-219 duplicate Lines 100-135 verbatim except for await. The duplicated part is not incidental — it is the security-relevant residual-temp policy (truncate → re-write empty → double unlink → AtomicWriteSecretResidualError vs AtomicWriteResidualTempError vs re-harden). Two independent copies of that decision table means a future fix to one leaves the other silently writing an un-scrubbed plaintext temp next to the config. Extracting the policy once (sync path awaits nothing, async path awaits) keeps both callers on one audited implementation.

♻️ Sketch: one policy, two wrappers
+// Single source of truth for the temp-residual policy shared by both writers.
+async function runAtomicWrite(
+  path: string,
+  content: string,
+  io: AtomicWriteAsyncIO,
+): Promise<void> {
+  const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
+  let hardened = false;
+  try {
+    await io.write(tmp, content);
+    await io.harden(tmp);
+    hardened = true;
+    await io.rename(tmp, path);
+  } catch (cause) {
+    // …the existing scrub / unlink / classification block, once…
+  }
+}

The sync atomicWriteFile must stay synchronous for its existing callers, so if a shared implementation is not workable, at minimum add a comment on both functions stating that the residual policy is mirrored and must be changed in lockstep.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFileSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.ts` around lines 138 - 221, Extract the shared residual-temp
cleanup and error-classification policy from atomicWriteFile and
atomicWriteFileAsync into one audited helper that supports both synchronous and
asynchronous I/O. Update both wrappers to use it while preserving truncate,
empty rewrite, double unlink, re-hardening, and the existing
AtomicWriteSecretResidualError/AtomicWriteResidualTempError outcomes; keep
atomicWriteFile synchronous for current callers.

export class OpenAiTierBackupCleanupError extends Error {
constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
}
Expand Down
177 changes: 173 additions & 4 deletions src/lib/windows-secret-acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
* icacls cannot block OAuth logins or token refresh (field report: Kimi auth
* stuck behind ETIMEDOUT). Real EPERM/EACCES/exit-code failures still throw:
* availability never silently overrides confidentiality for those.
* hardenSecretPathAsync / hardenSecretDirAsync — same policy, async icacls
* runner so the event loop is not held for the child lifetime (#612).
* HardenOptions.timeoutMemoKey — optional destination-path key for the
* timeout memo (atomic writers mint unique temps; never a parent directory).
* hardenSecretDir — same contract for directories.
*/

Expand All @@ -42,6 +46,13 @@ export interface HardenResult {

export interface HardenOptions {
required: boolean;
/**
* Optional timeout-memo key distinct from `targetPath` (issue #612).
* Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the
* final destination path prevents re-stalling the event loop on every subsequent temp.
* Must NOT be a parent directory — directory ACLs are not authoritative for new files.
*/
timeoutMemoKey?: string;
}

/**
Expand Down Expand Up @@ -72,6 +83,7 @@ export interface IcaclsResult {
}

type IcaclsRunner = (args: string[], timeoutMs: number) => IcaclsResult;
type AsyncIcaclsRunner = (args: string[], timeoutMs: number) => Promise<IcaclsResult>;

function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
// Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even
Expand All @@ -91,7 +103,43 @@ 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 function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
const proc = Bun.spawn(["icacls.exe", ...args], {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
windowsHide: true,
});
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
? await new Response(proc.stdout).text().catch(() => "")
: "";
const timedOut = timedOutByUs;
return {
success: !timedOut && exitCode === 0,
exitCode: timedOut ? null : exitCode,
timedOut,
stdout,
};
}
Comment on lines +111 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Bun.spawn options windowsHide stdin ignore stdout pipe proc.exited proc.kill Bun 1.3

💡 Result:

In Bun 1.3, Bun.spawn provides robust configuration for subprocess management [1][2]. The specific options mentioned function as follows: windowsHide The windowsHide option (boolean) is specifically for Windows environments [3][4]. When set to true, it prevents the creation of a visible console window when spawning a subprocess [3][4]. stdin, stdout, and stderr configuration For standard input (stdin) and output (stdout/stderr), you can pass one of the following values to the options object to control their behavior [5][2]: - "pipe": Creates a pipe between the parent and child process, allowing you to read from or write to the stream [5][3]. - "inherit": The child process uses the parent process's corresponding stdio stream [5][3]. - "ignore" (or null): Effectively ignores the stream [5][3]. - Additionally, for stdin, you can pass specific inputs like BunFile, ArrayBufferView, Blob, or Response [5]. Process Control (proc.exited and proc.kill) When you spawn a process, Bun.spawn returns a Subprocess object [1][2]. - proc.exited: This is a promise that resolves when the subprocess exits [5]. It allows you to await the termination of the child process. - proc.kill(signal?): This method is used to terminate the subprocess [5]. You can optionally provide a signal (e.g., SIGTERM or SIGKILL); if no signal is provided, it defaults to the signal specified by the killSignal option or SIGTERM [6][7]. Note: In Bun v1.3.3, a memory leak issue associated with handling extra file descriptors in Bun.spawn was resolved [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the reported lines and the timeout bookkeeping.
sed -n '1,240p' src/lib/windows-secret-acl.ts | cat -n
printf '\n---\n'
sed -n '240,540p' src/lib/windows-secret-acl.ts | cat -n

# Find where timedOutPaths is used.
printf '\n=== timedOutPaths references ===\n'
rg -n "timedOutPaths|hardenEntryAsync|defaultAsyncIcaclsRunner|icacls" src/lib/windows-secret-acl.ts

Repository: lidge-jun/opencodex

Length of output: 27815


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file shape to confirm the subprocess type assumptions around stdout and exit handling.
ast-grep outline src/lib/windows-secret-acl.ts --view expanded

Repository: lidge-jun/opencodex

Length of output: 3121


Drain proc.stdout before awaiting exit in src/lib/windows-secret-acl.ts:111-139

defaultAsyncIcaclsRunner() waits on proc.exited first and only reads the piped stdout afterward. If icacls.exe emits enough output to fill the pipe buffer, the child blocks, proc.exited never settles, the local timer kills it, and hardenEntryAsync() records the path in timedOutPaths so later ACL hardening is skipped for the rest of the process.

Suggested fix
   let exitCode: number | null = null;
+  const stdoutPromise = proc.stdout
+    ? new Response(proc.stdout).text().catch(() => "")
+    : Promise.resolve("");
   try {
     exitCode = await proc.exited;
   } finally {
     clearTimeout(timer);
   }
-  const stdout = proc.stdout
-    ? await new Response(proc.stdout).text().catch(() => "")
-    : "";
+  const stdout = await stdoutPromise;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
const proc = Bun.spawn(["icacls.exe", ...args], {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
windowsHide: true,
});
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
? await new Response(proc.stdout).text().catch(() => "")
: "";
const timedOut = timedOutByUs;
return {
success: !timedOut && exitCode === 0,
exitCode: timedOut ? null : exitCode,
timedOut,
stdout,
};
}
async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
const proc = Bun.spawn(["icacls.exe", ...args], {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
windowsHide: true,
});
let timedOutByUs = false;
const timer = setTimeout(() => {
timedOutByUs = true;
try { proc.kill(); } catch { /* already exited */ }
}, Math.max(1, timeoutMs));
let exitCode: number | null = null;
const stdoutPromise = proc.stdout
? new Response(proc.stdout).text().catch(() => "")
: Promise.resolve("");
try {
exitCode = await proc.exited;
} finally {
clearTimeout(timer);
}
const stdout = await stdoutPromise;
const timedOut = timedOutByUs;
return {
success: !timedOut && exitCode === 0,
exitCode: timedOut ? null : exitCode,
timedOut,
stdout,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/windows-secret-acl.ts` around lines 111 - 139, Update
defaultAsyncIcaclsRunner to begin draining proc.stdout immediately after
spawning the process, before awaiting proc.exited, while preserving the existing
empty-string fallback when stdout is unavailable or unreadable. Await the
captured stdout result after process completion and retain the current timeout,
exitCode, and success semantics.


let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner;
let platformOverride: string | null = null;
let nowFn: () => number = Date.now;

Expand All @@ -100,6 +148,11 @@ export function setIcaclsRunnerForTests(runner: IcaclsRunner | null): void {
icaclsRunner = runner ?? defaultIcaclsRunner;
}

/** Test seam: replace the async icacls runner. Pass null to restore the default. */
export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): void {
asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner;
}

/** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */
export function setPlatformForTests(value: string | null): void {
platformOverride = value;
Expand Down Expand Up @@ -156,6 +209,10 @@ function currentWindowsUser(): string | undefined {
*/
const BROAD_SIDS = ["*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"] as const;

function grantAce(user: string, directory: boolean): string {
return directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
}

function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
const user = currentWindowsUser();
if (!user) {
Expand All @@ -177,8 +234,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo

// Step 1: grant current user full control BEFORE any destructive ACL change.
// If this fails, inheritance is untouched and the writer keeps inherited access.
const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
runOrThrow("/grant:r", [targetPath, "/grant:r", grant]);
runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]);

// Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE
// from step 1 survives this transition, so a later failure still leaves cleanup access.
Expand All @@ -205,6 +261,41 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
}
}

/** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */
async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise<void> {
const user = currentWindowsUser();
if (!user) {
throw new Error("Cannot determine current Windows user for ACL hardening");
}

const run = async (step: string, args: string[]): Promise<IcaclsResult> => {
const remaining = deadline - nowFn();
if (remaining <= 0) {
throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" });
}
return asyncIcaclsRunner(args, remaining);
};
const runOrThrow = async (step: string, args: string[]): Promise<void> => {
const result = await run(step, args);
if (!result.success) throw icaclsError(step, result);
};

await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]);
await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);

const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
if (!removal.success) {
if (removal.timedOut) throw icaclsError("/remove:g", removal);
for (const sid of BROAD_SIDS) {
const found = await run("/findsid", [targetPath, "/findsid", sid]);
if (!found.success) throw icaclsError("/findsid", found);
if (found.stdout.includes(targetPath)) {
throw icaclsError("/remove:g", removal);
}
}
}
}

/**
* Sanitize an error from a failed ACL operation into a safe diagnostic string.
* The raw path must not appear in the returned string (it may contain
Expand Down Expand Up @@ -254,6 +345,27 @@ function describeAclStateAfterTimeout(targetPath: string, deadline: number): str
}
}

async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: number): Promise<string> {
try {
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);
if (!found.success) return "ACL state unverified (probe failed)";
if (found.stdout.includes(targetPath)) return "broad ACL grants still present";
}
return "no broad ACL grants detected (hardening still incomplete)";
} catch {
return "ACL state unverified (probe failed)";
}
}

function timeoutMemoKey(targetPath: string, opts: HardenOptions): string {
// Destination-path memo only (issue #612). Never a parent directory — directory ACLs
// are not authoritative for newly created temps.
return opts.timeoutMemoKey ?? targetPath;
}
Comment on lines +363 to +367

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Destination-keyed memo is the correct fix, but the "must NOT be a parent directory" invariant is only a comment.

Keying timedOutPaths by opts.timeoutMemoKey (the stable destination) instead of the per-write .tmp name genuinely stops an atomic writer from re-stalling the event loop on every subsequent temp — and keeping the success cache (hardenedPaths) keyed by targetPath is right, since each fresh temp really does need its own ACL pass. The verified caller (src/config.ts Line 177 passes timeoutMemoKey: path) honours the rule.

The residual risk is that nothing enforces it. If a future caller passes a directory, one transient timeout on any file poisons ACL hardening for every file under that directory for the process lifetime — silently, since the skip path returns { ok: false } and the caller's chmod fallback keeps working. A cheap dev-mode guard would make the invariant self-defending.

🛡️ Optional guard
 function timeoutMemoKey(targetPath: string, opts: HardenOptions): string {
+  // Cheap invariant check: the memo key must be the destination, not its parent.
+  if (opts.timeoutMemoKey && targetPath.startsWith(opts.timeoutMemoKey + sep)) {
+    throw new Error("timeoutMemoKey must not be a parent directory of targetPath");
+  }
   return opts.timeoutMemoKey ?? targetPath;
 }

Also applies to: 384-385, 405-405, 427-430, 448-448

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/windows-secret-acl.ts` around lines 363 - 367, Add a development-mode
validation around timeoutMemoKey usage in timeoutMemoKey and its timedOutPaths
read/write paths, rejecting or warning when the memo key is a parent directory
rather than the destination file. Preserve targetPath-based hardenedPaths
caching and existing fallback behavior, while ensuring invalid directory keys
cannot poison timeout memoization across files.


/**
* Shared harden flow for files and directories: one total budget (env-configurable)
* covering the initial attempt, ONE timeout retry, and the diagnostic verification.
Expand All @@ -269,7 +381,8 @@ function hardenEntry(
if (!existsSync(targetPath)) return { ok: true };
if (effectivePlatform() !== "win32") return { ok: true };
if (cache.has(targetPath)) return { ok: true };
if (timedOutPaths.has(targetPath)) {
const memoKey = timeoutMemoKey(targetPath, opts);
if (timedOutPaths.has(memoKey)) {
return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" };
}

Expand All @@ -289,7 +402,7 @@ function hardenEntry(

const diagnostics = sanitizeDiagnostics(lastErr);
if (isTimeoutError(lastErr)) {
timedOutPaths.add(targetPath);
timedOutPaths.add(memoKey);
const state = describeAclStateAfterTimeout(targetPath, deadline);
const annotated = `${diagnostics}; ${state}`;
// Timeout-only soft-fail: a hung icacls must not block OAuth/token writes.
Expand All @@ -301,6 +414,47 @@ function hardenEntry(
return { ok: false, diagnostics };
}

/** Async counterpart of hardenEntry — yields while waiting on icacls (#612). */
async function hardenEntryAsync(
targetPath: string,
directory: boolean,
opts: HardenOptions,
cache: Set<string>,
): Promise<HardenResult> {
if (!existsSync(targetPath)) return { ok: true };
if (effectivePlatform() !== "win32") return { ok: true };
if (cache.has(targetPath)) return { ok: true };
const memoKey = timeoutMemoKey(targetPath, opts);
if (timedOutPaths.has(memoKey)) {
return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" };
}

const deadline = nowFn() + resolveHardenDeadlineMs();
let lastErr: unknown;
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt > 0 && deadline - nowFn() <= 0) break;
try {
await runIcaclsAsync(targetPath, directory, deadline);
cache.add(targetPath);
return { ok: true };
} catch (err) {
lastErr = err;
if (!isTimeoutError(err)) break;
}
}

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

/**
* Harden a single file path with per-user NTFS ACLs on Windows.
* On non-Windows platforms, returns ok:true immediately (caller owns chmod).
Expand All @@ -312,6 +466,14 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde
return hardenEntry(targetPath, false, opts, hardenedPaths);
}

/**
* Async harden for write paths that must not block the event loop (#612).
* Same success/timeout/error policy as hardenSecretPath.
*/
export function hardenSecretPathAsync(targetPath: string, opts: HardenOptions): Promise<HardenResult> {
return hardenEntryAsync(targetPath, false, opts, hardenedPaths);
}

/**
* Harden a directory path with per-user NTFS ACLs on Windows.
* On non-Windows platforms, returns ok:true immediately (caller owns chmod).
Expand All @@ -322,3 +484,10 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde
export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult {
return hardenEntry(targetPath, true, opts, hardenedDirectories);
}

/**
* Async directory harden (#612). Same policy as hardenSecretDir.
*/
export function hardenSecretDirAsync(targetPath: string, opts: HardenOptions): Promise<HardenResult> {
return hardenEntryAsync(targetPath, true, opts, hardenedDirectories);
}
Loading
Loading