Fix Codex WSL project trust conflating case-distinct Linux paths (#8333)

* Fix Codex WSL project trust conflating case-distinct Linux paths

normalizeCodexProjectPathForLookup lowercased the entire Windows/UNC
path, including the case-sensitive Linux portion under \\wsl$\<distro>.
Two distinct WSL project dirs (.../Repo vs .../repo) collapsed onto one
trust key, and the mirror dedupe (codex-config-mirror) inherited it.

Preserve case for the Linux path after the case-insensitive
\\wsl$\<distro> / \\wsl.localhost\<distro> share prefix; true Windows
drive letters and normal UNC shares still case-fold as before.

Co-authored-by: Orca <help@stably.ai>

* Apply the same WSL case-fold fix to hook-trust key lookup

normalizeHookTrustKeyForLookup had the identical latent bug: on a win32
host it lowercased the whole Windows-shaped path, folding the
case-sensitive Linux tail of a \\wsl$\<distro> UNC hook path — contrary
to its own comment that WSL sources stay case-sensitive.

Extract foldWindowsCaseInsensitivePath (shared with the project-path
normalizer): fold only the case-insensitive drive/\\wsl$ share prefix,
preserve the Linux tail. Existing suffix (event:group:handler) is already
lowercase, so behavior is unchanged there.

Co-authored-by: Orca <help@stably.ai>

* Fix Codex WSL trust revocation and hook-key case folding

- Case-drifted or share-spelling-varied WSL revocations were being
  ignored on merge, letting stale "trusted" entries survive; fold
  revocation lookups fully so matching errs toward revoked.
- Hook-trust key folding was gated on process.platform === 'win32',
  so WSL/SSH-remote hook sources weren't folded when Orca itself ran
  on macOS/Linux; fold by path shape instead, via the new shared
  foldWslUncPathCaseInsensitiveParts helper (also covers /mnt drvfs
  automounts and wsl$/wsl.localhost share aliasing).

* Fix Codex WSL trust key case-folding regressions

- Don't fold case-variant `/MNT` dirs as if they were the drvfs
  automount; only literal lowercase `/mnt/<drive>` folds.
- Preserve an exact-cased trusted project entry in ~/.codex during
  runtime merge instead of letting a loosely-matched, case-drifted
  revocation clobber a user's re-granted trust on every mirror pass.
- Minor cleanup: inline foldWindowsCaseInsensitivePath and hoist the
  repeated normalizeHookTrustKeyForLookup call in findTrustBlockRanges.

* Refactor case-fold WSL trust test to assert real serializer output

Extract path variables and assert against escapeTomlString(incomingPath) instead of a hardcoded escaped string literal, so the fixture can't silently drift from the actual TOML header serialization.

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-12 00:36:51 -07:00
committed by GitHub
co-authored by Orca
parent 94f6fd4407
commit 6ee81dcfb1
7 changed files with 391 additions and 93 deletions
@@ -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',
+24 -3
View File
@@ -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[] {
+213 -72
View File
@@ -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$\<distro> 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$\<distro> 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/<drive> 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$\<distro> 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'
+23 -15
View File
@@ -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)
+9 -2
View File
@@ -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<string, number>()
@@ -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(
+33 -1
View File
@@ -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()
})
})
+15
View File
@@ -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/<drive> 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}`
}