diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 1ef1f61dd0b..5022fff5cc0 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -422,6 +422,80 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig).not.toContain('trust_level = "trusted"') }) + it('applies a case-drifted WSL system revocation to the runtime trusted block', () => { + // Why: configs written before WSL tails compared case-sensitively can hold + // the revocation under drifted casing; err toward revoked, not trusted. + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ["[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']", 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + ["[projects.'\\\\wsl$\\Ubuntu\\home\\u\\repo']", 'trust_level = "untrusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).not.toContain('trust_level = "trusted"') + }) + + it('keeps runtime trust when the system config re-trusts the exact-cased WSL project', () => { + // Why: after a user re-grants trust, markCodexProjectTrusted appends an + // exact-cased trusted block beside a legacy drifted-case revocation; the + // loose revocation match must not revert that grant on every mirror pass. + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ["[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']", 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + "[projects.'\\\\wsl$\\Ubuntu\\home\\u\\repo']", + 'trust_level = "untrusted"', + '', + "[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']", + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain("[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']") + expect(runtimeConfig).toContain('trust_level = "trusted"') + expect(runtimeConfig).toContain('trust_level = "untrusted"') + }) + + it('does not let a case-distinct POSIX system revocation clobber runtime trust', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ['[projects."/home/u/Repo"]', 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + ['[projects."/home/u/repo"]', 'trust_level = "untrusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(2) + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).toContain('trust_level = "trusted"') + }) + it.each([ { name: 'drive-letter casing and separators', diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 1949a703d41..6d84ea7380f 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -17,6 +17,7 @@ import { } from './config-toml-line-scan' import { normalizeCodexProjectPathForLookup, + normalizeCodexProjectPathForRevocationLookup, parseCodexProjectHeaderPath } from './config-toml-trust' @@ -170,10 +171,20 @@ function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: .filter((section) => isRuntimeProjectTomlSection(section.header)) .map((section) => getTomlSectionHeaderKey(section.header)) ) + const systemProjectSections = deduplicateProjectTomlSections(getTomlSections(systemConfig)).filter( + (section) => isRuntimeProjectTomlSection(section.header) + ) const systemUntrustedProjectHeaders = new Set( - deduplicateProjectTomlSections(getTomlSections(systemConfig)) - .filter((section) => isRuntimeProjectTomlSection(section.header)) + systemProjectSections .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') + .map((section) => getRevocationTomlSectionHeaderKey(section.header)) + ) + // Why: an exact-cased trusted entry in ~/.codex is the user's latest explicit + // decision for that exact project; a loosely-matched (case-drifted) revocation + // must not override it, or re-granting trust would be reverted every mirror. + const systemTrustedProjectHeaders = new Set( + systemProjectSections + .filter((section) => getProjectTrustLevel(section.block) === 'trusted') .map((section) => getTomlSectionHeaderKey(section.header)) ) // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook @@ -187,7 +198,8 @@ function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: .filter( (section) => !isRuntimeProjectTomlSection(section.header) || - !systemUntrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) + !systemUntrustedProjectHeaders.has(getRevocationTomlSectionHeaderKey(section.header)) || + systemTrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) ) .map((section) => section.block) ]) @@ -277,6 +289,15 @@ function getTomlSectionHeaderKey(header: string): string { : `project:${normalizeCodexProjectPathForLookup(projectPath)}` } +// Why: configs written before WSL tails compared case-sensitively can hold a +// revocation under drifted casing; match it loosely so trust is not resurrected. +function getRevocationTomlSectionHeaderKey(header: string): string { + const projectPath = parseCodexProjectHeaderPath(header) + return projectPath === null + ? header.trim() + : `project:${normalizeCodexProjectPathForRevocationLookup(projectPath)}` +} + // Why: hook upsert already removes both quote representations, while its paired // Windows slash variants are required for Codex 0.140 and must remain distinct. function deduplicateProjectTomlSections(sections: TomlSection[]): TomlSection[] { diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 4f4dcf4861f..b1ac7452000 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -17,6 +17,8 @@ import { computeTrustedHash, escapeTomlString, getCodexCanonicalTrustPath, + normalizeCodexProjectPathForLookup, + normalizeCodexProjectPathForRevocationLookup, parseTrustKey, readHookTrustEntries, removeHookTrustEntries, @@ -961,39 +963,36 @@ describe('upsertHookTrustEntries', () => { expect(written).not.toContain(`[hooks.state.'C:\\Users\\O'Connor`) }) - it.skipIf(process.platform !== 'win32')( - 'finds a Codex-written block with lowercased username when Orca key has mixed-case username', - () => { - // Why: realpathSync.native casing can differ between what Codex wrote - // (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...). - // normalizeHookTrustKeyForLookup case-folds on Windows so the existing block is - // replaced rather than a duplicate appended. - const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json' - const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json' - const literalKey = `${lowercasePath}:session_start:0:0` - const original = [ - `[hooks.state.'${literalKey}']`, - 'enabled = true', - 'trusted_hash = "sha256:LOWERCASE"', - '' - ].join('\n') - writeFileSync(configPath, original, 'utf-8') + it('finds a Codex-written block with lowercased username when Orca key has mixed-case username', () => { + // Why: realpathSync.native casing can differ between what Codex wrote + // (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...). + // normalizeHookTrustKeyForLookup case-folds Windows-shaped paths so the + // existing block is replaced rather than a duplicate appended. + const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json' + const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json' + const literalKey = `${lowercasePath}:session_start:0:0` + const original = [ + `[hooks.state.'${literalKey}']`, + 'enabled = true', + 'trusted_hash = "sha256:LOWERCASE"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') - const entry: CodexTrustEntry = { - sourcePath: mixedCasePath, - eventLabel: 'session_start', - groupIndex: 0, - handlerIndex: 0, - command: 'echo session' - } - upsertHookTrustEntries(configPath, [entry]) - - const written = readFileSync(configPath, 'utf-8') - expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(2) - expect(written).not.toContain('sha256:LOWERCASE') - expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`) + const entry: CodexTrustEntry = { + sourcePath: mixedCasePath, + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: 'echo session' } - ) + upsertHookTrustEntries(configPath, [entry]) + + const written = readFileSync(configPath, 'utf-8') + expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(2) + expect(written).not.toContain('sha256:LOWERCASE') + expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`) + }) }) describe('upsertProjectTrustLevel', () => { @@ -1149,6 +1148,52 @@ describe('upsertProjectTrustLevel', () => { expect(updated).toContain('trust_level = "trusted"') }) + it('keeps case-distinct WSL Linux project paths as separate trust blocks', () => { + // Why: the \\wsl$\ share is case-insensitive on Windows, but the + // Linux path underneath is not — .../Repo and .../repo are two projects. + const existingPath = '\\\\wsl$\\Ubuntu\\home\\u\\Repo' + const incomingPath = '\\\\wsl$\\Ubuntu\\home\\u\\repo' + const original = [ + `[projects.'${existingPath}']`, + 'trust_level = "untrusted"', + '' + ].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, incomingPath, 'trusted', { + alreadyCanonical: true + }) + + expect(updated.match(/\[projects\./g)).toHaveLength(2) + expect(updated).toContain(`[projects.'${existingPath}']`) + // Why: serializer writes basic-string headers via escapeTomlString; assert + // that form so the fixture can't drift from real header matching. + expect(updated).toContain(`[projects."${escapeTomlString(incomingPath)}"]`) + expect(updated).toContain('trust_level = "untrusted"') + expect(updated).toContain('trust_level = "trusted"') + }) + + it('updates the same WSL project block across wsl$ and wsl.localhost spellings', () => { + // Why: the two share spellings alias the same distro filesystem, so a + // revoked project must not be re-trusted under the other spelling. + const original = [ + "[projects.'\\\\wsl$\\Ubuntu\\home\\u\\proj']", + 'trust_level = "untrusted"', + '' + ].join('\n') + + const updated = upsertProjectTrustLevelInContent( + original, + '\\\\wsl.localhost\\Ubuntu\\home\\u\\proj', + 'trusted', + { alreadyCanonical: true } + ) + + expect(updated.match(/\[projects\./g)).toHaveLength(1) + expect(updated).toContain("[projects.'\\\\wsl$\\Ubuntu\\home\\u\\proj']") + expect(updated).toContain('trust_level = "trusted"') + expect(updated).not.toContain('trust_level = "untrusted"') + }) + it('matches a literal-string POSIX project path containing a quote and backslash', () => { const projectPath = '/tmp/with"quote\\and-backslash' const original = [`[projects.'${projectPath}']`, 'trust_level = "untrusted"', ''].join('\n') @@ -1186,6 +1231,78 @@ describe('upsertProjectTrustLevel', () => { }) }) +describe('normalizeCodexProjectPathForLookup', () => { + it('dedupes drive-letter casing and separators for true Windows paths', () => { + expect(normalizeCodexProjectPathForLookup('C:\\repo')).toBe( + normalizeCodexProjectPathForLookup('c:/repo') + ) + }) + + it('keeps case-distinct WSL Linux paths distinct', () => { + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\Repo')).not.toBe( + normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\repo') + ) + }) + + it('merges separator and distro-casing variants of the same WSL path', () => { + // Why: separators and the case-insensitive \\wsl$\ share may drift, + // but the same Linux path must still resolve to one trust key. + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\proj')).toBe( + normalizeCodexProjectPathForLookup('//WSL$/ubuntu/home/u/proj') + ) + }) + + it('treats wsl.localhost like the wsl$ share for the case-sensitive tail', () => { + expect( + normalizeCodexProjectPathForLookup('\\\\wsl.localhost\\Ubuntu\\home\\u\\Repo') + ).not.toBe(normalizeCodexProjectPathForLookup('\\\\wsl.localhost\\Ubuntu\\home\\u\\repo')) + expect(normalizeCodexProjectPathForLookup('\\\\WSL.LOCALHOST\\Ubuntu\\home\\u\\proj')).toBe( + normalizeCodexProjectPathForLookup('//wsl.localhost/ubuntu/home/u/proj') + ) + }) + + it('folds the wsl$ and wsl.localhost spellings of the same path to one key', () => { + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\Proj')).toBe( + normalizeCodexProjectPathForLookup('\\\\wsl.localhost\\Ubuntu\\home\\u\\Proj') + ) + }) + + it('folds drvfs automount tails case-insensitively like the native drive path', () => { + // Why: /mnt/ is NTFS through drvfs, case-insensitive like C:\ itself. + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\mnt\\c\\Users\\Bob\\Repo')).toBe( + normalizeCodexProjectPathForLookup('//wsl.localhost/ubuntu/mnt/c/users/bob/repo') + ) + // /mnt/wsl is tmpfs, not a drvfs drive mount — its tail stays case-sensitive. + expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\mnt\\wsl\\Repo')).not.toBe( + normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\mnt\\wsl\\repo') + ) + }) + + it('still case-folds normal UNC shares', () => { + expect(normalizeCodexProjectPathForLookup('\\\\server\\share\\Proj')).toBe( + normalizeCodexProjectPathForLookup('//SERVER/share/proj') + ) + }) + + it('leaves POSIX paths untouched', () => { + expect(normalizeCodexProjectPathForLookup('/home/u/Repo')).toBe('/home/u/Repo') + }) +}) + +describe('normalizeCodexProjectPathForRevocationLookup', () => { + it('folds WSL tails fully so drifted-case legacy revocations still match', () => { + expect( + normalizeCodexProjectPathForRevocationLookup('\\\\wsl$\\Ubuntu\\home\\u\\Repo') + ).toBe(normalizeCodexProjectPathForRevocationLookup('//wsl.localhost/ubuntu/home/u/repo')) + }) + + it('keeps POSIX paths case-sensitive', () => { + expect(normalizeCodexProjectPathForRevocationLookup('/home/u/Repo')).not.toBe( + normalizeCodexProjectPathForRevocationLookup('/home/u/repo') + ) + }) +}) + describe('removeHookTrustEntries', () => { it('is a no-op (creates no file) when the config does not exist', () => { removeHookTrustEntries(configPath, ['/x/hooks.json:pre_tool_use:0:0']) @@ -1494,54 +1611,78 @@ describe('readHookTrustEntries', () => { }) }) - it.skipIf(process.platform !== 'win32')( - 'supports case-insensitive lookups for Windows hook trust keys read from config', - () => { - // Why: Codex and realpathSync.native can disagree on user-path casing; - // status checks still need Map.get(computeTrustKey(...)) to find the row. - const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0' - const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0' - const original = [ - `[hooks.state.'${rawKey}']`, + it('supports case-insensitive lookups for Windows hook trust keys read from config', () => { + // Why: Codex and realpathSync.native can disagree on user-path casing; + // status checks still need Map.get(computeTrustKey(...)) to find the row. + const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0' + const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0' + const original = [ + `[hooks.state.'${rawKey}']`, + 'enabled = true', + 'trusted_hash = "sha256:CASE"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + + expect(result.get(lookupKey)).toEqual({ trustedHash: 'sha256:CASE', enabled: true }) + }) + + it('keeps POSIX-shaped hook trust paths case-sensitive', () => { + const upperKey = '/windows/d/Repo/hooks.json:session_start:0:0' + const lowerKey = '/windows/d/repo/hooks.json:session_start:0:0' + writeFileSync( + configPath, + [ + `[hooks.state."${upperKey}"]`, 'enabled = true', - 'trusted_hash = "sha256:CASE"', + 'trusted_hash = "sha256:UPPER"', + '', + `[hooks.state."${lowerKey}"]`, + 'enabled = true', + 'trusted_hash = "sha256:LOWER"', '' - ].join('\n') - writeFileSync(configPath, original, 'utf-8') + ].join('\n'), + 'utf-8' + ) - const result = readHookTrustEntries(configPath) + const result = readHookTrustEntries(configPath) - expect(result.get(lookupKey)).toEqual({ trustedHash: 'sha256:CASE', enabled: true }) - } - ) + expect(result.get(upperKey)?.trustedHash).toBe('sha256:UPPER') + expect(result.get(lowerKey)?.trustedHash).toBe('sha256:LOWER') + expect(result.size).toBe(2) + }) - it.skipIf(process.platform !== 'win32')( - 'keeps WSL hook trust paths case-sensitive on a Windows host', - () => { - const upperKey = '/windows/d/Repo/hooks.json:session_start:0:0' - const lowerKey = '/windows/d/repo/hooks.json:session_start:0:0' - writeFileSync( - configPath, - [ - `[hooks.state."${upperKey}"]`, - 'enabled = true', - 'trusted_hash = "sha256:UPPER"', - '', - `[hooks.state."${lowerKey}"]`, - 'enabled = true', - 'trusted_hash = "sha256:LOWER"', - '' - ].join('\n'), - 'utf-8' - ) + it('keeps case-distinct WSL UNC hook paths distinct', () => { + // Why: the \\wsl$\ share is case-insensitive, but the Linux tail + // is not — folding the whole path would merge two distinct hook sources. + const upperKey = '\\\\wsl$\\Ubuntu\\home\\u\\Repo\\hooks.json:session_start:0:0' + const lowerKey = '\\\\wsl$\\Ubuntu\\home\\u\\repo\\hooks.json:session_start:0:0' + writeFileSync( + configPath, + [ + `[hooks.state.'${upperKey}']`, + 'enabled = true', + 'trusted_hash = "sha256:UPPER"', + '', + `[hooks.state.'${lowerKey}']`, + 'enabled = true', + 'trusted_hash = "sha256:LOWER"', + '' + ].join('\n'), + 'utf-8' + ) - const result = readHookTrustEntries(configPath) + const result = readHookTrustEntries(configPath) - expect(result.get(upperKey)?.trustedHash).toBe('sha256:UPPER') - expect(result.get(lowerKey)?.trustedHash).toBe('sha256:LOWER') - expect(result.size).toBe(2) - } - ) + // Same share, different-cased distro/separators still fold to one key. + expect(result.get('//WSL$/ubuntu/home/u/Repo/hooks.json:session_start:0:0')?.trustedHash).toBe( + 'sha256:UPPER' + ) + expect(result.get(lowerKey)?.trustedHash).toBe('sha256:LOWER') + expect(result.size).toBe(2) + }) it('reads entries from a CRLF-terminated config', () => { const key = '/x/hooks.json:pre_tool_use:0:0' diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 02cd0372837..eede0efea32 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -10,6 +10,7 @@ import { import { dirname, join } from 'node:path' import { createHash, randomUUID } from 'node:crypto' import { copyFileWithWindowsRetry, renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths' import { createTomlLineScanState, isTomlStructuralLine, @@ -211,9 +212,20 @@ function usesWindowsPathSeparators(sourcePath: string): boolean { // Why: Codex and Orca can disagree on quote style, separators, and casing for // the same Windows project, including when the caller targets a remote host. export function normalizeCodexProjectPathForLookup(projectPath: string): string { - return usesWindowsPathSeparators(projectPath) - ? normalizeWindowsPathSeparators(projectPath).toLowerCase() - : projectPath + if (!usesWindowsPathSeparators(projectPath)) { + return projectPath + } + // Why: the Linux path under a WSL share is case-sensitive, so folding it would + // conflate distinct dirs (e.g. .../Repo vs .../repo) onto one trust key. + const slashedPath = normalizeWindowsPathSeparators(projectPath) + return foldWslUncPathCaseInsensitiveParts(slashedPath) ?? slashedPath.toLowerCase() +} + +// Why: trust revocations recorded before WSL tails compared case-sensitively +// can carry drifted casing; fold fully so matching errs toward revoked. +export function normalizeCodexProjectPathForRevocationLookup(projectPath: string): string { + const normalized = normalizeCodexProjectPathForLookup(projectPath) + return usesWindowsPathSeparators(projectPath) ? normalized.toLowerCase() : normalized } export function parseTrustKey(key: string): { @@ -526,18 +538,17 @@ type TrustBlockRange = { // casing) must not prevent findTrustBlockRanges from matching an existing block. export function normalizeHookTrustKeyForLookup(key: string): string { const parsed = parseTrustKey(key) - const separated = parsed - ? `${normalizeWindowsPathSeparators(parsed.sourcePath)}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` - : normalizeWindowsPathSeparators(key) - // Why: Windows-native paths are case-insensitive, but WSL and SSH trust - // sources remain case-sensitive even when Orca's host process is Windows. - const caseInsensitiveWindowsPath = - process.platform === 'win32' && usesWindowsPathSeparators(parsed?.sourcePath ?? key) - return caseInsensitiveWindowsPath ? separated.toLowerCase() : separated + // Why: fold by path shape, not host platform — hook sources on WSL and SSH + // Windows remotes need the same folding when Orca runs on macOS or Linux. + const foldedPath = normalizeCodexProjectPathForLookup(parsed ? parsed.sourcePath : key) + return parsed + ? `${foldedPath}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` + : foldedPath } function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { const ranges: TrustBlockRange[] = [] + const normalizedKey = normalizeHookTrustKeyForLookup(key) let cursor = 0 let scanState = createTomlLineScanState() while (cursor < content.length) { @@ -547,10 +558,7 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { const line = rawLine.replace(/\r$/, '') const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1 const headerKey = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null - if ( - headerKey !== null && - normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key) - ) { + if (headerKey !== null && normalizeHookTrustKeyForLookup(headerKey) === normalizedKey) { const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd const after = content.slice(headerLineEnd) const nextHeaderRel = findNextTableHeader(after) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 97142a4a242..0ad81dede26 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -30,6 +30,7 @@ import { computeTrustedHash, escapeTomlString, getCodexCanonicalTrustPath, + normalizeCodexProjectPathForLookup, normalizeHookTrustKeyForLookup, parseTrustKey, readHookTrustEntries, @@ -1010,6 +1011,12 @@ function getWslHookReconciliationAction(args: { return 'reinstall' } +// Why: fold only the Windows-case-insensitive portion — a full lowercase would +// let case-distinct WSL runtime homes share one reconciliation generation slot. +function getWslReconciliationKey(runtimeHomePath: string): string { + return normalizeCodexProjectPathForLookup(runtimeHomePath) +} + export class CodexHookService { private readonly wslReconciliationGeneration = new Map() @@ -1017,7 +1024,7 @@ export class CodexHookService { if (!runtimeHomePath) { return 0 } - const key = process.platform === 'win32' ? runtimeHomePath.toLowerCase() : runtimeHomePath + const key = getWslReconciliationKey(runtimeHomePath) const generation = (this.wslReconciliationGeneration.get(key) ?? 0) + 1 this.wslReconciliationGeneration.set(key, generation) return generation @@ -1033,7 +1040,7 @@ export class CodexHookService { if (!runtimeHomePath) { return } - const key = process.platform === 'win32' ? runtimeHomePath.toLowerCase() : runtimeHomePath + const key = getWslReconciliationKey(runtimeHomePath) const resolvedPlan = settlement.status === 'resolved' ? createCodexWslRuntimeHookInstallPlan( diff --git a/src/shared/wsl-paths.test.ts b/src/shared/wsl-paths.test.ts index 6bf2631928e..498e3c02860 100644 --- a/src/shared/wsl-paths.test.ts +++ b/src/shared/wsl-paths.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isWslUncPath, parseWslUncPath } from './wsl-paths' +import { foldWslUncPathCaseInsensitiveParts, isWslUncPath, parseWslUncPath } from './wsl-paths' describe('wsl path helpers', () => { it('parses modern and legacy WSL UNC paths without platform checks', () => { @@ -18,3 +18,35 @@ describe('wsl path helpers', () => { expect(isWslUncPath('/home/jin/repo')).toBe(false) }) }) + +describe('foldWslUncPathCaseInsensitiveParts', () => { + it('folds share spelling, distro casing, and separators but not the Linux tail', () => { + expect(foldWslUncPathCaseInsensitiveParts('\\\\WSL$\\Ubuntu\\home\\jin\\Repo')).toBe( + '//wsl.localhost/ubuntu/home/jin/Repo' + ) + expect(foldWslUncPathCaseInsensitiveParts('//wsl.localhost/UBUNTU/home/jin/Repo')).toBe( + '//wsl.localhost/ubuntu/home/jin/Repo' + ) + }) + + it('folds drvfs automount tails but not other /mnt entries', () => { + expect(foldWslUncPathCaseInsensitiveParts('\\\\wsl$\\Ubuntu\\mnt\\C\\Users\\Jin')).toBe( + '//wsl.localhost/ubuntu/mnt/c/users/jin' + ) + expect(foldWslUncPathCaseInsensitiveParts('\\\\wsl$\\Ubuntu\\mnt\\wsl\\Data')).toBe( + '//wsl.localhost/ubuntu/mnt/wsl/Data' + ) + }) + + it('does not treat a case-variant /MNT dir as the drvfs automount', () => { + expect(foldWslUncPathCaseInsensitiveParts('\\\\wsl$\\Ubuntu\\MNT\\c\\Repo')).toBe( + '//wsl.localhost/ubuntu/MNT/c/Repo' + ) + }) + + it('returns null for non-WSL paths', () => { + expect(foldWslUncPathCaseInsensitiveParts('C:\\Users\\jin')).toBeNull() + expect(foldWslUncPathCaseInsensitiveParts('//server/share/x')).toBeNull() + expect(foldWslUncPathCaseInsensitiveParts('/home/jin')).toBeNull() + }) +}) diff --git a/src/shared/wsl-paths.ts b/src/shared/wsl-paths.ts index 98ec1212f21..3db38712251 100644 --- a/src/shared/wsl-paths.ts +++ b/src/shared/wsl-paths.ts @@ -19,3 +19,18 @@ export function parseWslUncPath(path: string): WslUncPathInfo | null { export function isWslUncPath(path: string): boolean { return parseWslUncPath(path) !== null } + +// Why: Windows folds the share (\\wsl$ aliases \\wsl.localhost), the distro, and +// drvfs /mnt/ tails case-insensitively; the rest of the Linux path is not. +export function foldWslUncPathCaseInsensitiveParts(path: string): string | null { + const parsed = parseWslUncPath(path) + if (!parsed) { + return null + } + // Why: the drvfs automount is literally lowercase /mnt — a case-variant like + // /MNT is an ordinary case-sensitive Linux dir and must not be folded. + const linuxPath = /^\/mnt\/[a-zA-Z](?:\/|$)/.test(parsed.linuxPath) + ? parsed.linuxPath.toLowerCase() + : parsed.linuxPath + return `//wsl.localhost/${parsed.distro.toLowerCase()}${linuxPath === '/' ? '' : linuxPath}` +}