diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 6d84ea7380f..e6ca4c0b834 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -171,9 +171,9 @@ 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 systemProjectSections = deduplicateProjectTomlSections( + getTomlSections(systemConfig) + ).filter((section) => isRuntimeProjectTomlSection(section.header)) const systemUntrustedProjectHeaders = new Set( systemProjectSections .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index 1fa525c2e4a..a2c397dd132 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -90,10 +90,7 @@ function linkSystemCodexResource( removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) return } - if ( - entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && - !systemResourceIsRegularFile(sourcePath) - ) { + if (entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && !systemResourceIsRegularFile(sourcePath)) { removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) console.warn('[codex-home] Ignoring non-file system Codex resource:', entryName) return diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index b1ac7452000..9f8efbea403 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -1153,11 +1153,7 @@ describe('upsertProjectTrustLevel', () => { // 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 original = [`[projects.'${existingPath}']`, 'trust_level = "untrusted"', ''].join('\n') const updated = upsertProjectTrustLevelInContent(original, incomingPath, 'trusted', { alreadyCanonical: true @@ -1253,9 +1249,9 @@ describe('normalizeCodexProjectPathForLookup', () => { }) 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\\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') ) @@ -1291,9 +1287,9 @@ describe('normalizeCodexProjectPathForLookup', () => { 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')) + expect(normalizeCodexProjectPathForRevocationLookup('\\\\wsl$\\Ubuntu\\home\\u\\Repo')).toBe( + normalizeCodexProjectPathForRevocationLookup('//wsl.localhost/ubuntu/home/u/repo') + ) }) it('keeps POSIX paths case-sensitive', () => { diff --git a/src/main/ssh/ssh-remote-powershell.ts b/src/main/ssh/ssh-remote-powershell.ts index d20f0e2969f..cbc3b4faddc 100644 --- a/src/main/ssh/ssh-remote-powershell.ts +++ b/src/main/ssh/ssh-remote-powershell.ts @@ -1,14 +1,8 @@ import { encodePowerShellCommand } from '../../shared/powershell-command-encoding' - -export function powerShellLiteral(value: string): string { - return `'${value.replace(/'/g, "''")}'` -} - -// Why: Windows PowerShell 5.1 does not preserve embedded double quotes when -// passing args to native executables, so pre-escape them for Win32 argv parsing. -export function powerShellNativeArg(value: string): string { - return powerShellLiteral(value.replace(/(\\*)"/g, '$1$1\\"')) -} +export { + quotePowerShellLiteral as powerShellLiteral, + quotePowerShellNativeArgument as powerShellNativeArg +} from '../../shared/powershell-native-argument' export function powerShellCommand(script: string): string { return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}` diff --git a/src/renderer/src/components/settings/CliSection.test.tsx b/src/renderer/src/components/settings/CliSection.test.tsx index db46bd69b79..2c34b299d8b 100644 --- a/src/renderer/src/components/settings/CliSection.test.tsx +++ b/src/renderer/src/components/settings/CliSection.test.tsx @@ -89,10 +89,12 @@ describe('CliSection project runtime defaults', () => { sourceKinds: ['global'] }) ) - expect(capturedPanel.props?.command).toContain("wsl.exe -d 'Ubuntu' -- sh -c") - expect(capturedPanel.props?.command).toContain('npx skills add') - expect(capturedPanel.props?.installedCommand).toContain("wsl.exe -d 'Ubuntu' -- sh -c") - expect(capturedPanel.props?.installedCommand).toContain('npx skills update orca-cli --global') + expect(capturedPanel.props?.command).toMatch( + /^& \{ \$PSNativeCommandArgumentPassing = 'Legacy'; wsl\.exe -d 'Ubuntu' -- sh -c 'eval \\"`printf %s [A-Za-z0-9+/=]+ \| base64 -d`\\"'/ + ) + expect(capturedPanel.props?.installedCommand).toMatch( + /^& \{ \$PSNativeCommandArgumentPassing = 'Legacy'; wsl\.exe -d 'Ubuntu' -- sh -c 'eval \\"`printf %s [A-Za-z0-9+/=]+ \| base64 -d`\\"'/ + ) expect(getWslInstallStatus).toHaveBeenCalledWith({ distro: 'Ubuntu' }) expect(getWslInstallStatus).toHaveBeenCalledTimes(2) }) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx index 64b7bb3dd1c..2c6cccb43d2 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -1,6 +1,11 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { buildAgentFeatureSkillInstallCommand } from '../../../../shared/agent-feature-install-commands' +import { buildWslLoginShellCommand } from '../../../../shared/wsl-login-shell-command' import { buildSkillCommandForRuntime, buildSkillInstallCommandForRuntime, @@ -8,31 +13,111 @@ import { getSkillDiscoveryTargetForRuntime } from './CliSkillRuntimeSetup' +function decodeWslLoginShellScript(command: string): string { + const encoded = /-- sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec( + command + )?.[1] + expect(encoded).toBeDefined() + return Buffer.from(encoded!, 'base64').toString('utf8') +} + +function getWslOuterShellScript(command: string): string { + const script = /-- sh -c '([^']+)' \} # Runs:/.exec(command)?.[1] + expect(script).toBeDefined() + // Simulate PowerShell 5.1's native argv boundary consuming quote escapes. + return script!.replaceAll('\\"', '"') +} + describe('CliSkillRuntimeSetup runtime helpers', () => { - it('wraps WSL skill installs in the selected distro login shell', () => { - const command = buildSkillInstallCommandForRuntime('npx skills add orchestration --global', { + it('wraps WSL skill installs as a directly runnable selected-distro command', () => { + const skillCommand = 'npx skills add orchestration --global' + const command = buildSkillInstallCommandForRuntime(skillCommand, { runtime: 'wsl', wslDistro: 'Ubuntu', label: 'WSL Ubuntu' }) + const encoded = Buffer.from(buildWslLoginShellCommand(skillCommand), 'utf8').toString('base64') - expect(command).toContain("wsl.exe -d 'Ubuntu' -- sh -c") - expect(command).toContain('getent passwd') - expect(command).toContain('npx skills add orchestration --global') + expect(command).toBe( + `& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe -d 'Ubuntu' -- sh -c 'eval \\"\`printf %s ${encoded} | base64 -d\`\\"' } # Runs: ${skillCommand}` + ) + expect(decodeWslLoginShellScript(command)).toContain( + 'exec "$_orca_wsl_shell" -ilc \'npx skills add orchestration --global\'' + ) }) - it('wraps WSL skill updates with the same selected distro login shell', () => { + it('wraps WSL skill updates as a directly runnable selected-distro command', () => { const command = buildSkillCommandForRuntime('npx skills update orchestration --global', { runtime: 'wsl', wslDistro: 'Fedora Remix', label: 'WSL Fedora Remix' }) - expect(command).toContain("wsl.exe -d 'Fedora Remix' -- sh -c") - expect(command).toContain('getent passwd') - expect(command).toContain('npx skills update orchestration --global') + expect(decodeWslLoginShellScript(command)).toContain( + 'exec "$_orca_wsl_shell" -ilc \'npx skills update orchestration --global\'' + ) }) + it('scopes the PS5-compatible argv mode when pasted into PowerShell 7', () => { + const command = buildSkillCommandForRuntime('npx skills update orchestration --global', { + runtime: 'wsl', + label: 'WSL' + }) + + expect(command).toMatch( + /^& \{ \$PSNativeCommandArgumentPassing = 'Legacy'; wsl\.exe -- sh -c 'eval \\"`printf/ + ) + expect(command).toContain('`\\"\' } # Runs: npx skills update orchestration --global') + }) + + it.skipIf(process.platform === 'win32')( + 'runs skill commands with npx from the configured WSL login-shell PATH', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wsl-skill-command-')) + const tools = join(root, 'tools') + const npxBin = join(root, 'npx-bin') + const loginShell = join(root, 'zsh') + mkdirSync(tools) + mkdirSync(npxBin) + writeFileSync( + join(tools, 'getent'), + '#!/bin/sh\nprintf \'%s\\n\' "user:x:1000:1000::/home/user:$ORCA_TEST_LOGIN_SHELL"\n' + ) + writeFileSync( + loginShell, + '#!/bin/sh\nexport PATH="$ORCA_TEST_NPX_BIN:/usr/bin:/bin"\nexec /bin/sh -c "$2"\n' + ) + writeFileSync( + join(npxBin, 'npx'), + '#!/bin/sh\nread -r input\nprintf \'%s:%s\' "$*" "$input"\n' + ) + chmodSync(join(tools, 'getent'), 0o755) + chmodSync(loginShell, 0o755) + chmodSync(join(npxBin, 'npx'), 0o755) + + try { + const wrapped = buildSkillCommandForRuntime('npx skills update orchestration --global', { + runtime: 'wsl', + label: 'WSL' + }) + expect( + execFileSync('/bin/sh', ['-c', getWslOuterShellScript(wrapped)], { + encoding: 'utf8', + input: 'terminal-input\n', + env: { + ...process.env, + PATH: `${tools}:/usr/bin:/bin`, + ORCA_TEST_LOGIN_SHELL: loginShell, + ORCA_TEST_NPX_BIN: npxBin + } + }) + ).toBe('skills update orchestration --global:terminal-input') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + it('reinstalls Windows-host skill updates through the add path', () => { expect( buildSkillCommandForRuntime( diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 895caa1b80a..469bb17acd7 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -4,9 +4,10 @@ import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' import { - buildWslLoginShellCommand, - escapeWslShCommandForWindows -} from '../../../../shared/wsl-login-shell-command' + quotePowerShellLiteral, + quotePowerShellNativeArgument +} from '../../../../shared/powershell-native-argument' +import { buildWslLoginShellCommand } from '../../../../shared/wsl-login-shell-command' import { buildAgentFeatureSkillInstallCommand } from '../../../../shared/agent-feature-install-commands' import { toast } from 'sonner' import type { CliInstallStatus } from '../../../../shared/cli-install-types' @@ -56,8 +57,13 @@ export function getSelectedAgentRuntime( return { runtime: 'host', label: getHostRuntimeLabel() } } -function quotePowerShellSingle(value: string): string { - return `'${value.replaceAll("'", "''")}'` +function encodeWslLoginShellScript(command: string): string { + const bytes = new TextEncoder().encode(buildWslLoginShellCommand(command)) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) } export function getWslCliDistroRequest( @@ -84,10 +90,17 @@ export function buildSkillCommandForRuntime( } const distroArg = resolvedRuntime.wslDistro?.trim() - ? ` -d ${quotePowerShellSingle(resolvedRuntime.wslDistro.trim())}` + ? ` -d ${quotePowerShellLiteral(resolvedRuntime.wslDistro.trim())}` : '' - const wslCommand = escapeWslShCommandForWindows(buildWslLoginShellCommand(normalizedCommand)) - return `wsl.exe${distroArg} -- sh -c ${quotePowerShellSingle(wslCommand)}` + // Why: encoding preserves the user's configured login-shell PATH while + // avoiding raw multiline and nested quotes at the copy/paste boundary. + const encodedScript = encodeWslLoginShellScript(normalizedCommand) + const visibleCommand = normalizedCommand.replace(/[\r\n]+/g, ' ') + const shellScript = `eval "\`printf %s ${encodedScript} | base64 -d\`"` + const wslCommand = `wsl.exe${distroArg} -- sh -c ${quotePowerShellNativeArgument(shellScript)}` + // Why: scope Legacy argv parsing to this invocation so Windows PowerShell + // 5.1 and PowerShell 7 pass the same embedded quotes to wsl.exe. + return `& { $PSNativeCommandArgumentPassing = 'Legacy'; ${wslCommand} } # Runs: ${visibleCommand}` } function normalizeWindowsSkillUpdateCommand( diff --git a/src/shared/powershell-native-argument.test.ts b/src/shared/powershell-native-argument.test.ts new file mode 100644 index 00000000000..276413f4fac --- /dev/null +++ b/src/shared/powershell-native-argument.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { quotePowerShellLiteral, quotePowerShellNativeArgument } from './powershell-native-argument' + +describe('PowerShell native argument quoting', () => { + it('escapes literals for PowerShell source parsing', () => { + expect(quotePowerShellLiteral("WSL 'Preview'")).toBe("'WSL ''Preview'''") + }) + + it('pre-escapes embedded quotes for Windows native argv parsing', () => { + expect(quotePowerShellNativeArgument('eval "decoded"')).toBe(String.raw`'eval \"decoded\"'`) + expect(quotePowerShellNativeArgument(String.raw`before\"after`)).toBe( + String.raw`'before\\\"after'` + ) + }) +}) diff --git a/src/shared/powershell-native-argument.ts b/src/shared/powershell-native-argument.ts new file mode 100644 index 00000000000..6376ab5d842 --- /dev/null +++ b/src/shared/powershell-native-argument.ts @@ -0,0 +1,9 @@ +export function quotePowerShellLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +export function quotePowerShellNativeArgument(value: string): string { + // Why: Windows PowerShell 5.1 drops unescaped embedded quotes when it + // constructs argv for native executables such as wsl.exe. + return quotePowerShellLiteral(value.replace(/(\\*)"/g, '$1$1\\"')) +}