mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user