From 7f6dd948952c298e9b7195320ef682cad3e4771c Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 14 Aug 2026 08:27:58 +0000 Subject: [PATCH] fix(windows): preserve localized profile paths --- src/codex/user-identity.ts | 35 +++++- src/lib/windows-text.ts | 106 ++++++++++++++++++ src/service-manager-probe.ts | 55 ++++----- ...ex-service-manager-probe-hardening.test.ts | 39 +++++++ tests/windows-popup-fix.test.ts | 12 +- tests/windows-text-decoding.test.ts | 31 +++++ 6 files changed, 241 insertions(+), 37 deletions(-) create mode 100644 src/lib/windows-text.ts create mode 100644 tests/windows-text-decoding.test.ts diff --git a/src/codex/user-identity.ts b/src/codex/user-identity.ts index eb6d317000..0488e96f12 100644 --- a/src/codex/user-identity.ts +++ b/src/codex/user-identity.ts @@ -53,6 +53,15 @@ function refuse(message: string, cause?: unknown): never { } function windowsIdentityPowerShellCommand(expression: string): string[] { + // PowerShell 5.1 can encode redirected native/host output with the active + // Windows code page. Base64 contains ASCII only, while the payload is + // explicitly UTF-16LE, so Korean and Western profile paths arrive unchanged. + const deterministicOutput = [ + "$ErrorActionPreference = 'Stop'", + `$ocxValue = [string](${expression})`, + "$ocxBytes = [System.Text.Encoding]::Unicode.GetBytes($ocxValue)", + "[Console]::Out.Write([Convert]::ToBase64String($ocxBytes))", + ].join("; "); return [ resolveTrustedWindowsPowerShellExe(), "-NoLogo", @@ -61,7 +70,7 @@ function windowsIdentityPowerShellCommand(expression: string): string[] { "-WindowStyle", "Hidden", "-Command", - expression, + deterministicOutput, ]; } @@ -93,6 +102,28 @@ export function windowsIdentityPowerShellSpawnOptionsForTests(): ReturnType< return windowsIdentityPowerShellSpawnOptions(); } +function decodeWindowsIdentityPowerShellOutput(output: Uint8Array): string { + let encoded: string; + try { + encoded = new TextDecoder("utf-8", { fatal: true }).decode(output).trim(); + } catch (cause) { + refuse("Windows effective-account lookup returned a malformed value.", cause); + } + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) { + refuse("Windows effective-account lookup returned a malformed value."); + } + const bytes = Buffer.from(encoded, "base64"); + if (bytes.length % 2 !== 0 || bytes.toString("base64") !== encoded) { + refuse("Windows effective-account lookup returned a malformed value."); + } + return bytes.toString("utf16le").trim(); +} + +/** Test-only decode seam for the deterministic PowerShell output contract. */ +export function decodeWindowsIdentityPowerShellOutputForTests(output: Uint8Array): string { + return decodeWindowsIdentityPowerShellOutput(output); +} + function powershellValue(expression: string): string { let command: string[]; try { @@ -113,7 +144,7 @@ function powershellValue(expression: string): string { } if (result.exitedDueToTimeout) refuse("Windows effective-account lookup timed out."); if (result.exitCode !== 0) refuse("Windows effective-account lookup failed."); - const value = new TextDecoder().decode(result.stdout).trim(); + const value = decodeWindowsIdentityPowerShellOutput(result.stdout ?? Buffer.alloc(0)); if (!value) refuse("Windows effective-account lookup returned an empty value."); return value; } diff --git a/src/lib/windows-text.ts b/src/lib/windows-text.ts new file mode 100644 index 0000000000..8e26657986 --- /dev/null +++ b/src/lib/windows-text.ts @@ -0,0 +1,106 @@ +/** + * Decode bounded text emitted by Windows system tools. + * + * [Decision Log] + * - Purpose: preserve non-ASCII paths when a Windows tool writes the active + * legacy code page instead of UTF-8. + * - Existing constraint: generated service assets may be UTF-16, while + * redirected `schtasks` output follows the Windows locale on affected hosts. + * - Alternatives considered: replacement-character heuristics and a new + * iconv dependency. The former can reinterpret valid text; the latter widens + * the install/security surface for two small, already-supported codecs. + * - Choice: recognize UTF-16 first, accept only strict UTF-8 next, then use the + * locale-appropriate WHATWG decoder (CP949 through `euc-kr`, or Windows-1252 + * only for locales that actually use that family). Unknown/unsupported + * locales fail back to the old replacement-preserving UTF-8 result instead + * of guessing another code page or throwing in diagnostics. + * - Impact: decoding stays dependency-free and bounded, but this deliberately + * does not guess arbitrary OEM code pages that the runtime cannot identify. + */ + +function trimWindowsText(value: string): string { + return value.replace(/^\uFEFF/, "").trim(); +} + +function currentWindowsLocale(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().locale; + } catch { + return "en-US"; + } +} + +function decodeStrict(buffer: Uint8Array, encoding: string): string | null { + try { + return trimWindowsText(new TextDecoder(encoding, { fatal: true }).decode(buffer)); + } catch { + return null; + } +} + +function decodeUtf16Be(buffer: Uint8Array): string { + const payloadLength = buffer.length - 2; + const swapped = Buffer.alloc(payloadLength - (payloadLength % 2)); + for (let i = 2; i + 1 < buffer.length; i += 2) { + swapped[i - 2] = buffer[i + 1]!; + swapped[i - 1] = buffer[i]!; + } + return trimWindowsText(swapped.toString("utf16le")); +} + +/** + * CP949 is exposed by the Encoding Standard under the `euc-kr` label. Keep the + * Western fallback deliberately narrow: treating CP932, CP1250, or CP1251 + * bytes as Windows-1252 can fabricate a different valid-looking filesystem + * path, which is worse than the previous replacement-character refusal. + */ +function legacyEncodingForLocale(locale: string): "euc-kr" | "windows-1252" | null { + const language = locale.trim().split(/[-_]/, 1)[0]?.toLowerCase(); + if (language === "ko") return "euc-kr"; + if (language && WINDOWS_1252_LANGUAGES.has(language)) return "windows-1252"; + return null; +} + +const WINDOWS_1252_LANGUAGES = new Set([ + "af", "br", "ca", "co", "cy", "da", "de", "en", "es", "eu", "fi", "fo", "fr", + "ga", "gd", "gl", "id", "is", "it", "lb", "ms", "nl", "no", "oc", "pt", "sq", + "sv", "sw", +]); + +export interface WindowsTextDecodeOptions { + /** Test seam and explicit locale override; production uses the active Intl locale. */ + readonly locale?: string; +} + +export function decodeWindowsTextBytes( + buffer: Uint8Array, + options: WindowsTextDecodeOptions = {}, +): string { + if (buffer.length === 0) return ""; + + const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe; + const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff; + const looksUtf16Le = buffer.length >= 4 + && buffer[1] === 0x00 + && buffer[3] === 0x00 + && buffer[0] !== 0x00; + + if (bomUtf16Le || looksUtf16Le) { + return trimWindowsText(Buffer.from(buffer).toString("utf16le")); + } + if (bomUtf16Be) return decodeUtf16Be(buffer); + + const utf8 = decodeStrict(buffer, "utf-8"); + if (utf8 !== null) return utf8; + + const locale = options.locale ?? currentWindowsLocale(); + const legacyEncoding = legacyEncodingForLocale(locale); + if (legacyEncoding !== null) { + const legacy = decodeStrict(buffer, legacyEncoding); + if (legacy !== null) return legacy; + } + + // Preserve the previous fail-soft behavior when the runtime lacks a codec or + // the bytes are malformed even for the selected Windows code page. + return trimWindowsText(new TextDecoder("utf-8").decode(buffer)); +} diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index cc447b644f..ca760dc165 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -26,6 +26,7 @@ import { resolveTrustedWindowsSchtasksExe, resolveTrustedWindowsSystemDirectory, } from "./lib/windows-elevation"; +import { decodeWindowsTextBytes } from "./lib/windows-text"; import { WINSW_SERVICE_ID } from "./lib/winsw"; /** Short: this runs inside admission, and a slow answer is the same as none. */ @@ -119,6 +120,8 @@ export interface ProbeDeps { readonly configDir?: string; /** Test seam for WinSW SCM status. Production uses bounded trusted `sc.exe query`. */ readonly winswStatus?: () => "started" | "stopped" | "nonexistent" | "unknown"; + /** Test seam for redirected Windows legacy-codepage output. */ + readonly windowsLocale?: string; } const LABEL = "com.opencodex.proxy"; @@ -338,29 +341,6 @@ function windowsConfigDirPath(deps: { home: string; configDir?: string }): strin return join(deps.home, ".opencodex"); } -/** Decode an on-disk Windows text asset (task XML, VBS), which is UTF-16LE (often BOM-prefixed). */ -function decodeWindowsText(buffer: Buffer): string { - if (buffer.length === 0) return ""; - const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe; - const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff; - const looksUtf16Le = buffer.length >= 4 - && buffer[1] === 0x00 - && buffer[3] === 0x00 - && buffer[0] !== 0x00; - if (bomUtf16Le || looksUtf16Le) { - return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim(); - } - if (bomUtf16Be) { - const swapped = Buffer.alloc(buffer.length - 2); - for (let i = 2; i + 1 < buffer.length; i += 2) { - swapped[i - 2] = buffer[i + 1]!; - swapped[i - 1] = buffer[i]!; - } - return swapped.toString("utf16le").trim(); - } - return buffer.toString("utf8").replace(/^\uFEFF/, "").trim(); -} - /** Decode the XML entities emitted by the service-definition writers. */ function decodeXmlEntities(value: string): string { return value @@ -478,7 +458,9 @@ const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i; * other nonzero responses use a bounded full listing as the locale-neutral * fallback, and only a successful list without our task proves absence. */ -function probeWindowsTaskRegistration(deps: Required>): { +function probeWindowsTaskRegistration( + deps: Required> & Pick, +): { registered: "present" | "absent" | "unknown"; registeredXml: string; } { @@ -492,13 +474,14 @@ function probeWindowsTaskRegistration(deps: Required>) const queried = deps.runRaw(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]); if (queried.spawnFailed || queried.timedOut) return { registered: "unknown", registeredXml: "" }; if (queried.status === 0) { - const registeredXml = decodeWindowsText(queried.stdout) || decodeWindowsText(queried.stderr); + const registeredXml = decodeWindowsTextBytes(queried.stdout, { locale: deps.windowsLocale }) + || decodeWindowsTextBytes(queried.stderr, { locale: deps.windowsLocale }); return registeredXml ? { registered: "present", registeredXml } : { registered: "unknown", registeredXml: "" }; } - const queryText = `${decodeWindowsText(queried.stdout)}\n${decodeWindowsText(queried.stderr)}`; + const queryText = `${decodeWindowsTextBytes(queried.stdout, { locale: deps.windowsLocale })}\n${decodeWindowsTextBytes(queried.stderr, { locale: deps.windowsLocale })}`; if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND_EN.test(queryText)) { return { registered: "absent", registeredXml: "" }; } @@ -507,7 +490,8 @@ function probeWindowsTaskRegistration(deps: Required>) if (listed.spawnFailed || listed.timedOut || listed.status !== 0) { return { registered: "unknown", registeredXml: "" }; } - const listing = decodeWindowsText(listed.stdout) || decodeWindowsText(listed.stderr); + const listing = decodeWindowsTextBytes(listed.stdout, { locale: deps.windowsLocale }) + || decodeWindowsTextBytes(listed.stderr, { locale: deps.windowsLocale }); return windowsTaskListContains(listing, windowsTaskName()) ? { registered: "unknown", registeredXml: "" } : { registered: "absent", registeredXml: "" }; @@ -545,7 +529,8 @@ function probeWinswRegistration( } function inspectWindows( - deps: Required> & Pick, + deps: Required> + & Pick, ): ServiceManagerInstallation { const configDir = windowsConfigDirPath(deps); const taskXmlPath = join(configDir, "opencodex-service-task.xml"); @@ -557,7 +542,7 @@ function inspectWindows( let xml = ""; if (task !== "absent") { try { - xml = decodeWindowsText(readFileSync(taskXmlPath)); + xml = decodeWindowsTextBytes(readFileSync(taskXmlPath), { locale: deps.windowsLocale }); } catch (error) { return unknown(`the scheduled-task XML exists but could not be read: ${String(error)}`); } @@ -659,7 +644,7 @@ function homesEqual( * generated service-asset directory. */ function walkWindowsChain( - deps: Required> & Pick, + deps: Required> & Pick, xml: string, definitionPath: string, ): ServiceManagerInstallation { @@ -683,7 +668,7 @@ function walkWindowsChain( } let launcherBody: string; try { - launcherBody = decodeWindowsText(readFileSync(launcherPath)); + launcherBody = decodeWindowsTextBytes(readFileSync(launcherPath), { locale: deps.windowsLocale }); } catch (error) { return unknown(`the scheduled-task launcher could not be read: ${String(error)}`); } @@ -702,7 +687,7 @@ function walkWindowsChain( } let wrapperBody: string; try { - wrapperBody = decodeWindowsText(readFileSync(wrapperPath)); + wrapperBody = decodeWindowsTextBytes(readFileSync(wrapperPath), { locale: deps.windowsLocale }); } catch (error) { return unknown(`the launcher wrapper could not be read: ${String(error)}`); } @@ -734,7 +719,8 @@ function walkWindowsChain( * this read-only ownership probe. */ function walkWinswChain( - deps: Required> & Pick, + deps: Required> + & Pick, ): ServiceManagerInstallation { const configDir = windowsConfigDirPath(deps); const exePath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.exe`); @@ -753,7 +739,7 @@ function walkWinswChain( let body: string; try { - body = decodeWindowsText(readFileSync(xmlPath)); + body = decodeWindowsTextBytes(readFileSync(xmlPath), { locale: deps.windowsLocale }); } catch (error) { return unknown(`the WinSW XML could not be read: ${String(error)}`); } @@ -801,6 +787,7 @@ export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): Service home, configDir: deps.configDir, winswStatus: deps.winswStatus, + windowsLocale: deps.windowsLocale, }); } return unknown(`no service manager probe for platform ${platform}`); diff --git a/tests/codex-service-manager-probe-hardening.test.ts b/tests/codex-service-manager-probe-hardening.test.ts index 620e404337..1f260eb6af 100644 --- a/tests/codex-service-manager-probe-hardening.test.ts +++ b/tests/codex-service-manager-probe-hardening.test.ts @@ -46,6 +46,16 @@ function raw( }; } +function cp949KoreanFixture(value: string): Buffer { + const parts = value.split("한글"); + if (parts.length !== 2) throw new Error("fixture must contain exactly one Korean marker"); + return Buffer.concat([ + Buffer.from(parts[0]!, "ascii"), + Buffer.from([0xc7, 0xd1, 0xb1, 0xdb]), + Buffer.from(parts[1]!, "ascii"), + ]); +} + function schedulerXml(launcherPath: string): string { const escaped = launcherPath.replace(/&/g, "&").replace(/"/g, """); return [ @@ -128,6 +138,35 @@ function taskAbsentRunner(calls: Array<{ file: string; args: readonly string[] } } describe("Windows ownership probe hardening regressions", () => { + test("registered CP949 task XML preserves a Korean profile path", () => { + const koreanConfigDir = join(home, "한글", ".opencodex"); + const codexHome = join(home, "한글", ".codex"); + const chain = writeSchedulerChain(koreanConfigDir, codexHome, koreanConfigDir); + const runRaw: RawProbeRunner = (file, args) => { + if (file.toLowerCase().endsWith("sc.exe")) return raw(1, "", "1060"); + if (args.includes("/xml")) { + return { + ...raw(0), + stdout: cp949KoreanFixture(schedulerXml(chain.launcher)), + }; + } + return raw(1, "", "unexpected query"); + }; + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir: koreanConfigDir, + runRaw, + windowsLocale: "ko-KR", + }); + + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].registration).toBe("present"); + expect(result.claims[0].homes).toEqual({ codexHome, opencodexHome: koreanConfigDir }); + }); + test("ownership inspects the effective current OPENCODEX_HOME without an injected configDir", () => { const currentCodexHome = "C:\\current\\.codex"; const foreignCodexHome = "C:\\foreign\\.codex"; diff --git a/tests/windows-popup-fix.test.ts b/tests/windows-popup-fix.test.ts index 79cb3a01be..145368da94 100644 --- a/tests/windows-popup-fix.test.ts +++ b/tests/windows-popup-fix.test.ts @@ -12,6 +12,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { readProcessStartMsBatch } from "../src/codex/app-server-processes"; import { + decodeWindowsIdentityPowerShellOutputForTests, resolveEffectiveUserIdentity, windowsIdentityPowerShellCommandForTests, windowsIdentityPowerShellSpawnOptionsForTests, @@ -38,7 +39,9 @@ describe("Windows identity lookup popup fix (#1278)", () => { expect(command[windowStyle + 1]).toBe("Hidden"); expect(command[command.length - 2]).toBe("-Command"); expect(command[command.length - 1]) - .toBe("[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value"); + .toContain("[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value"); + expect(command[command.length - 1]).toContain("ToBase64String"); + expect(command[command.length - 1]).toContain("Encoding]::Unicode"); }); test("spawn options are hidden and bounded", () => { @@ -50,6 +53,13 @@ describe("Windows identity lookup popup fix (#1278)", () => { expect(options.timeout).toBe(8_000); }); + test("decodes non-ASCII known-folder values from the ASCII-safe envelope", () => { + const path = "C:\\Users\\한글\\AppData\\Local"; + const envelope = Buffer.from(path, "utf16le").toString("base64"); + expect(decodeWindowsIdentityPowerShellOutputForTests(Buffer.from(`${envelope}\r\n`, "ascii"))) + .toBe(path); + }); + test("the hidden trusted lookup resolves the real token on Windows", () => { if (process.platform !== "win32") return; const identity = resolveEffectiveUserIdentity(); diff --git a/tests/windows-text-decoding.test.ts b/tests/windows-text-decoding.test.ts new file mode 100644 index 0000000000..58da9e58c7 --- /dev/null +++ b/tests/windows-text-decoding.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; + +import { decodeWindowsTextBytes } from "../src/lib/windows-text"; + +describe("Windows system text decoding (#1573)", () => { + test("preserves strict UTF-8 before considering a legacy code page", () => { + const path = "C:\\Users\\한글\\.opencodex"; + expect(decodeWindowsTextBytes(Buffer.from(path, "utf8"), { locale: "ko-KR" })).toBe(path); + }); + + test("decodes CP949 Korean profile paths under a Korean Windows locale", () => { + const cp949 = Buffer.from("433a5c55736572735cc7d1b1db", "hex"); + expect(decodeWindowsTextBytes(cp949, { locale: "ko-KR" })).toBe("C:\\Users\\한글"); + }); + + test("decodes Windows-1252 Western profile paths without Korean reinterpretation", () => { + const windows1252 = Buffer.from("433a5c55736572735c4af67267", "hex"); + expect(decodeWindowsTextBytes(windows1252, { locale: "de-DE" })).toBe("C:\\Users\\Jörg"); + }); + + test("does not guess Windows-1252 for an unsupported legacy-codepage locale", () => { + const cp932 = Buffer.from([0x82, 0xa0]); + expect(decodeWindowsTextBytes(cp932, { locale: "ja-JP" })).toContain("\uFFFD"); + }); + + test("preserves UTF-16LE task XML", () => { + const xml = 'C:\\Users\\한글'; + const bytes = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xml, "utf16le")]); + expect(decodeWindowsTextBytes(bytes, { locale: "ko-KR" })).toBe(xml); + }); +});