mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* fix(settings): make WSL skill commands pasteable (#7795) * Fix WSL skill commands so PowerShell 7 pastes match PowerShell 5.1 argv - Encode the WSL login-shell script as base64 and decode/eval it inside the sh -c invocation, avoiding raw nested quotes at the paste boundary - Scope $PSNativeCommandArgumentPassing = 'Legacy' to the invocation so PS 5.1 and PS 7 both hand wsl.exe the same escaped argv - Extract powershell-native-argument.ts as the shared quoting module and reuse it from ssh-remote-powershell.ts * test(runtime): stub getRepo in mobile-tab startup cwd test Main's #7892 made listMobileSessionTabs validate selectors via this.store?.getRepo; the mock store here only defined getWorkspaceSession, so the merged CI build threw 'getRepo is not a function'. Return null (wt-1 is a worktree id, not a repo). Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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\\"'))
|
||||
}
|
||||
Reference in New Issue
Block a user