From a1d50db6378db59a7245a2afb031e26feef3b842 Mon Sep 17 00:00:00 2001 From: GOAT Date: Fri, 16 Oct 2026 00:04:41 +0900 Subject: [PATCH] fix(windows): resolve lock runtime known folder in process --- src/codex/user-identity.ts | 21 ++++- src/codex/windows-local-appdata.ts | 84 +++++++++++++++++ structure/codex-home.md | 2 +- structure/ops/test-sandbox-cleanup.md | 12 +++ .../codex-user-identity.test.ts | 94 +++++++++++++++++++ 5 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 src/codex/windows-local-appdata.ts diff --git a/src/codex/user-identity.ts b/src/codex/user-identity.ts index c70b8ee31c0..15fe18c7e3a 100644 --- a/src/codex/user-identity.ts +++ b/src/codex/user-identity.ts @@ -19,6 +19,7 @@ import { isAbsolute, join, resolve } from "node:path"; import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation"; import { WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal"; +import { resolveWindowsLocalAppData } from "./windows-local-appdata"; import type { ResolveCodexCoordinatorDatabasePath, @@ -209,10 +210,11 @@ export function decodeWindowsIdentityPowerShellOutputForTests(output: Uint8Array /** * Per-process memo for the Windows lookups. * - * Both values -- the effective token's SID and its known-folder local AppData -- + * Both helper values -- the effective token's SID and its known-folder local AppData -- * are fixed for the lifetime of a process: neither can change without a new logon * token, and the lookups deliberately ignore the environment, so nothing a caller - * does between two calls can alter the answer. Each call otherwise spawns a fresh + * does between two calls can alter the answer. The native x64 known-folder path + * avoids this helper entirely. Each fallback call otherwise spawns a fresh * PowerShell, roughly 150ms for the SID and 310ms for the folder, and the * coordinator asks for both on every config write and lock acquisition. That cost * pushed real multi-process injection tests past their budget while proving @@ -265,6 +267,15 @@ function resolveWindowsSid(): string { return sid.toUpperCase(); } +function windowsLocalAppData(): string { + try { + return resolveWindowsLocalAppData(() => powershellValue(windowsLocalAppDataExpression())); + } catch (cause) { + if (cause instanceof CodexUserIdentityRefusal) throw cause; + refuse("Windows LocalAppData resolution failed.", cause); + } +} + export const resolveEffectiveUserIdentity: ResolveEffectiveUserIdentity = () => { if (process.platform === "win32") { return { platform: "win32", sid: resolveWindowsSid() }; @@ -374,7 +385,7 @@ export function probeCodexCoordinatorNamespace(identity: UserIdentity): Coordina } if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID."); - const localAppData = powershellValue(windowsLocalAppDataExpression()); + const localAppData = windowsLocalAppData(); if (!isAbsolute(localAppData)) refuse("Windows LocalAppData resolution returned a relative path."); const root = resolve(localAppData, "OpenCodex", "Runtime", "v1", identity.sid.toUpperCase()); let entry; @@ -404,10 +415,10 @@ export function probeCodexCoordinatorNamespace(identity: UserIdentity): Coordina function resolveWindowsRuntimeRoot(identity: Extract): string { if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID."); - const localAppData = powershellValue(windowsLocalAppDataExpression()); + const localAppData = windowsLocalAppData(); if (!isAbsolute(localAppData)) refuse("Windows LocalAppData resolution returned a relative path."); - // The SID and known-folder values come from the effective token/.NET OS APIs, + // The SID and known-folder values come from effective-token OS APIs, // never USERPROFILE or LOCALAPPDATA. WP11 adds descriptor/reparse/ACL checks at // the stable-database open boundary where those checks can cover SQLite too. const root = resolve(localAppData, "OpenCodex", "Runtime", "v1", identity.sid.toUpperCase()); diff --git a/src/codex/windows-local-appdata.ts b/src/codex/windows-local-appdata.ts new file mode 100644 index 00000000000..552562d1605 --- /dev/null +++ b/src/codex/windows-local-appdata.ts @@ -0,0 +1,84 @@ +import { dlopen, ptr, read, type Pointer } from "bun:ffi"; +import { win32 } from "node:path"; +import { resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation"; + +// FOLDERID_LocalAppData: GUID fields Data1/Data2/Data3 are little-endian. +const LOCAL_APPDATA_ID = Buffer.from("8527b3f1ba6fcf4f9d557b8e7f157091", "hex"); +const KF_FLAG_DEFAULT_PATH = 0x400; +const MAX_PATH_CODE_UNITS = 32_768; + +export interface WindowsKnownFolderApi { + getPath(id: Uint8Array, flags: number, token: null, output: BigUint64Array): number; + readCodeUnit(address: number, byteOffset: number): number; + free(address: number): void; +} + +/** Same current-token/default-path API as the PowerShell fallback, without Add-Type. */ +export function readWindowsLocalAppData(api: WindowsKnownFolderApi): string { + const output = new BigUint64Array(1); + let address = 0; + try { + const hr = api.getPath(LOCAL_APPDATA_ID, KF_FLAG_DEFAULT_PATH, null, output); + address = Number(output[0]); + if (hr !== 0 || !Number.isSafeInteger(address) || address <= 0) { + throw new Error("Windows LocalAppData known-folder lookup failed."); + } + const units: number[] = []; + for (let index = 0; index < MAX_PATH_CODE_UNITS; index += 1) { + const unit = api.readCodeUnit(address, index * 2); + if (unit === 0) { + const path = String.fromCharCode(...units); + if (!win32.isAbsolute(path) || !/^[a-z]:\\|^\\\\/i.test(path)) { + throw new Error("Windows LocalAppData known-folder lookup returned an invalid path."); + } + return path; + } + units.push(unit); + } + throw new Error("Windows LocalAppData known-folder lookup returned an unterminated path."); + } finally { + // SHGetKnownFolderPath requires CoTaskMemFree even on a failed HRESULT. + if (address > 0 && Number.isSafeInteger(address)) api.free(address); + } +} + +let nativeLookup: (() => string) | null | undefined; + +function loadNativeLookup(): (() => string) | null { + if (nativeLookup !== undefined) return nativeLookup; + if (process.platform !== "win32" || process.arch !== "x64") return null; + try { + const systemDirectory = resolveTrustedWindowsSystemDirectory(); + const shell = dlopen(win32.join(systemDirectory, "shell32.dll"), { + SHGetKnownFolderPath: { args: ["ptr", "u32", "ptr", "ptr"], returns: "i32" }, + }); + let ole; + try { + ole = dlopen(win32.join(systemDirectory, "ole32.dll"), { + CoTaskMemFree: { args: ["ptr"], returns: "void" }, + }); + } catch (error) { + shell.close(); + throw error; + } + const api: WindowsKnownFolderApi = { + getPath: (id, flags, token, output) => shell.symbols.SHGetKnownFolderPath( + ptr(id), flags, token, ptr(output), + ), + readCodeUnit: (address, offset) => read.u16(address as Pointer, offset), + free: address => { ole.symbols.CoTaskMemFree(address as Pointer); }, + }; + nativeLookup = () => readWindowsLocalAppData(api); + } catch { + nativeLookup = null; + } + return nativeLookup; +} + +/** Unsupported FFI keeps the bounded trusted helper; an OS API failure still refuses. */ +export function resolveWindowsLocalAppData( + powershellLookup: () => string, + lookup: (() => string) | null = loadNativeLookup(), +): string { + return lookup ? lookup() : powershellLookup(); +} diff --git a/structure/codex-home.md b/structure/codex-home.md index 5d07f5c55c4..d290adc0d94 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -377,7 +377,7 @@ separate role. It is not a kernel lock, a cross-host lock, or protection against writer between byte verification and rename. A rolling mix of old writers that do not reserve takeover has the same cooperation limit. -Windows secret-file hardening resolves the effective token SID through an absolute, trusted +Effective-user roots use `src/codex/windows-local-appdata.ts` ([known-folder contract](ops/test-sandbox-cleanup.md#windows-runtime-namespace)). Windows secret-file hardening resolves the effective token SID through an absolute, trusted PowerShell path before granting the owner and removing inherited broad ACL entries. The normal path obtains System32 from `GetSystemDirectoryW`. Windows ARM64 Bun builds that cannot execute `bun:ffi` use a narrower ACL-only fallback to the fixed protected default installation path diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index daed1ef575f..a85e66fb86a 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -1,5 +1,17 @@ # Test Sandbox Cleanup +## Windows runtime namespace + +`src/codex/user-identity.ts` keys runtime namespaces on the effective SID and environment-independent +LocalAppData. `src/codex/windows-local-appdata.ts` calls `SHGetKnownFolderPath` in-process on Windows +x64, loading Shell32/Ole32 from the OS-resolved system directory. It uses `KF_FLAG_DEFAULT_PATH` +and a null token, bounds UTF-16 decoding, and frees the allocation even when the API fails. +Unsupported FFI retains the bounded trusted PowerShell helper; native API failures refuse. +`USERPROFILE` and `LOCALAPPDATA` never choose this root. Runtime canonicalization/reparse checks +and the Bun test lock's required ACL hardening remain separate admission checks. + +## Sandbox lifecycle + The batch runner's per-process home isolation and live-home/service-manager guards remain active because the preload installs them before the lock boundary. `tests/preload.ts` resolves cleanup dependencies after home/lock admission and before test cases. Teardown awaits native-main startup diff --git a/tests/codex-integration/codex-user-identity.test.ts b/tests/codex-integration/codex-user-identity.test.ts index 9635bdb5262..9128fb69af5 100644 --- a/tests/codex-integration/codex-user-identity.test.ts +++ b/tests/codex-integration/codex-user-identity.test.ts @@ -15,10 +15,104 @@ import { } from "../../src/codex/user-identity"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; +import { + readWindowsLocalAppData, + resolveWindowsLocalAppData, + type WindowsKnownFolderApi, +} from "../../src/codex/windows-local-appdata"; let codexHome = ""; let previousHome: string | undefined; +function knownFolderFixture(path = "C:\\Users\\ν•œκΈ€πŸ˜€\\AppData\\Local", hr = 0) { + const freed: number[] = []; + const api: WindowsKnownFolderApi = { + getPath(id, flags, token, output) { + expect(Buffer.from(id).toString("hex")).toBe("8527b3f1ba6fcf4f9d557b8e7f157091"); + expect(flags).toBe(0x400); + expect(token).toBeNull(); + expect(output[0]).toBe(0n); + output[0] = 4096n; + return hr; + }, + readCodeUnit(address, offset) { + expect(address).toBe(4096); + return path.charCodeAt(offset / 2) || 0; + }, + free(address) { freed.push(address); }, + }; + return { api, freed, path }; +} + +test("native known-folder resolution avoids a timed-out PowerShell identity probe", () => { + const fixture = knownFolderFixture(); + let helperCalls = 0; + const timedOutHelper = (): never => { + helperCalls += 1; + throw new Error("Windows effective-account lookup timed out."); + }; + // Toggle only native availability: the failing helper models the 30s Add-Type + // timeout without a wall-clock sleep, on every platform. + expect(() => resolveWindowsLocalAppData(timedOutHelper, null)).toThrow("timed out"); + expect(helperCalls).toBe(1); + expect(resolveWindowsLocalAppData(timedOutHelper, () => readWindowsLocalAppData(fixture.api))) + .toBe(fixture.path); + expect(helperCalls).toBe(1); + expect(fixture.freed).toEqual([4096]); +}); + +test("native known-folder failures refuse and free the OS allocation without helper fallback", () => { + for (const fixture of [knownFolderFixture(undefined, -2147467259), knownFolderFixture("relative")]) { + let fallbackCalls = 0; + expect(() => resolveWindowsLocalAppData( + () => { fallbackCalls += 1; return "C:\\forged-environment"; }, + () => readWindowsLocalAppData(fixture.api), + )).toThrow("known-folder lookup"); + expect(fixture.freed).toEqual([4096]); + expect(fallbackCalls).toBe(0); + } +}); + +test("native known-folder resolution refuses a null result and frees on decoding failure", () => { + const fixture = knownFolderFixture(); + fixture.api.getPath = () => 0; + expect(() => readWindowsLocalAppData(fixture.api)).toThrow("lookup failed"); + expect(fixture.freed).toEqual([]); + + const broken = knownFolderFixture(); + broken.api.readCodeUnit = () => { throw new Error("read failed"); }; + expect(() => readWindowsLocalAppData(broken.api)).toThrow("read failed"); + expect(broken.freed).toEqual([4096]); +}); + +test("native known-folder resolution bounds an unterminated response and frees it", () => { + const fixture = knownFolderFixture(); + let reads = 0; + fixture.api.readCodeUnit = () => { reads += 1; return 65; }; + expect(() => readWindowsLocalAppData(fixture.api)).toThrow("unterminated path"); + expect(reads).toBe(32_768); + expect(fixture.freed).toEqual([4096]); +}); + +test.if(process.platform === "win32" && process.arch === "x64")( + "Windows x64 obtains the real known folder in-process even when the helper cannot start", + () => { + const previous = { USERPROFILE: process.env.USERPROFILE, LOCALAPPDATA: process.env.LOCALAPPDATA }; + try { + process.env.USERPROFILE = "C:\\forged-known-folder-profile"; + process.env.LOCALAPPDATA = "C:\\forged-known-folder-appdata"; + const path = resolveWindowsLocalAppData(() => { throw new Error("PowerShell must not start"); }); + expect(isAbsolute(path)).toBe(true); + expect(path).not.toContain("forged-known-folder"); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }, +); + const CHILD_TIMEOUT_MS = 10_000; const userIdentityModuleUrl = pathToFileURL( repoPath("src", "codex", "user-identity.ts"),