Files
orca/src/main/cli/wsl-cli-scripts.ts
T
OrcaWinandOrca Worker 58d1ff3b6a Provide the Orca CLI automatically in managed WSL terminals (#22761)
* Provide the Orca CLI automatically in managed WSL terminals

* Simplify managed WSL CLI provisioning

Never block a shell on CLI availability, keep the shared WSL login-shell
builder unchanged, provision from PTY env assembly only, drop the error
variable and command probing, and reuse the existing WSLENV helper.

* Scope the managed WSL CLI to WSL terminals

Provision only for WSL panes and publish the directory through
addOrcaWslInteropEnv, so daemon terminals keep inherited WSLENV entries and
non-WSL builds never see the variable. Write the bridge with a UTF-8 BOM so
Windows PowerShell 5.1 keeps non-ASCII user-data paths, give the dev bridge the
dev launcher's app-launch env, and drop the unused skill-setup wiring and
runtime capability.

* Tighten the managed WSL CLI bridge and setup

Launch the bridge child exactly like the registered bridge (no hidden
window or output relay; verified through WSL with Node and Electron), give
the dev bridge the dev launcher's NODE_OPTIONS stash, clear the guest-only
directory before starting Windows processes, collapse setup into one
function, warn once, and guard WSL env routing with tests.

* Harden managed WSL CLI quoting and inheritance

PowerShell also ends single-quoted strings at typographic quotes, so a
user-data path such as O'Brien with a curly apostrophe broke the managed
bridge. Fix the shared quotePowerShellLiteral and reuse it. Drop an inherited
ORCA_WSL_CLI_DIR on the daemon path, remove the unreachable PATH dedupe, and
cover failed setup with a stale caller value.

* Cover the managed WSL CLI in zsh and on POSIX CI

Add a live zsh case that reaches a real prompt, a POSIX test that runs the
PATH restore snippet in bash and zsh under set -u, and a null result for
unwritable user data. Say what a failed write actually costs, and document
per-spawn write logging and older-daemon behaviour.

* Keep system bashrc out of the PATH restore test

CI runners make bash -c read /etc/bash.bashrc, which fails under set -u.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-25 21:55:21 -07:00

279 lines
10 KiB
TypeScript

import { quotePowerShellLiteral } from '../../shared/powershell-native-argument'
const MANAGED_MARKER = '# Orca managed WSL CLI launcher'
const BRIDGE_MANAGED_MARKER = '# Orca managed WSL CLI PowerShell bridge'
const FIND_INTEROP_POWERSHELL = `if command -v powershell.exe >/dev/null 2>&1; then
ORCA_POWERSHELL=powershell.exe
elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then
ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe
else
echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2
exit 1
fi`
export function buildWslLauncher(
windowsLauncherPath: string,
bridgePath = '${XDG_DATA_HOME:-$HOME/.local/share}/orca/orca-wsl-bridge.ps1'
): string {
return buildLauncher(windowsLauncherPath, quoteShell(bridgePath), FIND_INTEROP_POWERSHELL)
}
/** Launcher that finds its bridge beside itself and PowerShell by Windows path, independent of guest PATH. */
export function buildColocatedWslLauncher(
windowsLauncherPath: string,
windowsPowerShellPath: string
): string {
return buildLauncher(
windowsLauncherPath,
'"$(dirname -- "$0")/orca-wsl-bridge.ps1"',
`ORCA_POWERSHELL=$(wslpath -u ${quoteShell(windowsPowerShellPath)})
if [ ! -x "$ORCA_POWERSHELL" ]; then
echo "Orca WSL CLI requires Windows interop and access to $ORCA_POWERSHELL." >&2
exit 1
fi`
)
}
function buildLauncher(
windowsLauncherPath: string,
bridgePathExpression: string,
resolvePowerShell: string
): string {
const encodedTarget = Buffer.from(windowsLauncherPath, 'utf8').toString('base64')
return `#!/usr/bin/env bash
set -euo pipefail
${MANAGED_MARKER}
# ORCA_WIN_LAUNCHER_B64=${encodedTarget}
ORCA_WIN_LAUNCHER=${quoteShell(windowsLauncherPath)}
ORCA_BRIDGE_PS1=${bridgePathExpression}
${resolvePowerShell}
# Why: a shell can outlive a deleted worktree; keep explicit CLI selectors and
# help usable, and repair cwd before any WSL interop tool tries to resolve it.
ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || {
ORCA_WSL_CWD=/
cd /
}
ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1")
ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD")
if [ -n "\${WSL_DISTRO_NAME:-}" ]; then
set -- -WslDistro "$WSL_DISTRO_NAME" "$@"
fi
exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"
`
}
/** `app` pins the bridge to one Orca instance; the guest-registered bridge omits it. */
export function buildWslBridgeScript(app?: {
userDataPath: string
cliEntryPath?: string
}): string {
const setAppEnv = app
? [
`$env:ORCA_USER_DATA_PATH = ${quotePowerShellLiteral(app.userDataPath)}`,
// Why: WSLENV /p maps this guest-only dir back; an app the CLI starts must not inherit it.
'Remove-Item Env:ORCA_WSL_CLI_DIR -ErrorAction SilentlyContinue',
...(app.cliEntryPath ? buildDevCliEnv(app.cliEntryPath) : [])
]
: []
// Why the BOM: PowerShell 5.1 reads BOM-less scripts as ANSI, garbling non-ASCII embedded paths.
return `${app ? '\uFEFF' : ''}${BRIDGE_MANAGED_MARKER}
function ConvertTo-NativeCommandLineArgument {
param([AllowEmptyString()][string]$Value)
if ($Value.Length -gt 0 -and $Value -notmatch '[\\s"]') {
return $Value
}
$Quoted = [System.Text.StringBuilder]::new()
[void]$Quoted.Append([char]'"')
[int]$BackslashCount = 0
foreach ($Character in $Value.ToCharArray()) {
if ($Character -eq [char]'\\') {
$BackslashCount += 1
continue
}
if ($Character -eq [char]'"') {
[void]$Quoted.Append([char]'\\', $BackslashCount * 2 + 1)
[void]$Quoted.Append([char]'"')
} else {
[void]$Quoted.Append([char]'\\', $BackslashCount)
[void]$Quoted.Append($Character)
}
$BackslashCount = 0
}
[void]$Quoted.Append([char]'\\', $BackslashCount * 2)
[void]$Quoted.Append([char]'"')
return $Quoted.ToString()
}
$exitCode = 0
try {
# Why: a param block prefix-binds forwarded flags such as --for in PowerShell 5.1.
if ($args.Count -lt 1) {
throw 'Invalid Orca WSL CLI bridge invocation.'
}
[string]$OrcaLauncher = $args[0]
[string]$WslCwd = ''
[string]$WslDistro = ''
[int]$ForwardArgStart = 1
if ($args.Count -ge 2 -and $args[1] -eq '-WslCwd') {
if ($args.Count -lt 3) {
throw 'Invalid Orca WSL CLI bridge invocation.'
}
$WslCwd = $args[2]
$ForwardArgStart = 3
}
if ($ForwardArgStart -eq 3 -and $args.Count -ge 4 -and $args[3] -eq '-WslDistro') {
if ($args.Count -lt 5) {
throw 'Invalid Orca WSL CLI bridge invocation.'
}
$WslDistro = $args[4]
$ForwardArgStart = 5
}
[string[]]$ForwardArgs = @()
if ($args.Count -gt $ForwardArgStart) {
$ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)])
}
if ([string]::IsNullOrEmpty($WslCwd)) {
Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue
} else {
$env:ORCA_CLI_CWD = $WslCwd
}
# Do not let an inherited Windows environment choose the caller's account location.
if ([string]::IsNullOrEmpty($WslDistro)) {
Remove-Item Env:ORCA_CLI_WSL_DISTRO -ErrorAction SilentlyContinue
} else {
$env:ORCA_CLI_WSL_DISTRO = $WslDistro
}
$LauncherDirectory = Split-Path -Parent $OrcaLauncher
Push-Location -LiteralPath $LauncherDirectory
# Why: Windows PowerShell 5.1 cannot losslessly splat strings to native argv.
$StartInfo = [System.Diagnostics.ProcessStartInfo]::new()
$StartInfo.FileName = $OrcaLauncher
${bridgeLines(setAppEnv)} $StartInfo.Arguments = (($ForwardArgs | ForEach-Object {
ConvertTo-NativeCommandLineArgument $_
}) -join ' ')
$StartInfo.UseShellExecute = $false
# Why (#16463): Push-Location moves the PowerShell provider location, not the
# Win32 current directory, and an empty WorkingDirectory with UseShellExecute
# disabled means "inherit the caller's". Launched from a WSL shell that is the
# user's worktree on the 9P share, so without this the app stands in a
# directory Linux can delete -- after which every CreateProcessW it makes
# fails ERROR_PATH_NOT_FOUND, reported as: spawn wsl.exe ENOENT.
$StartInfo.WorkingDirectory = $LauncherDirectory
$Process = [System.Diagnostics.Process]::Start($StartInfo)
if ($null -eq $Process) {
throw 'Unable to start the Orca Windows CLI launcher.'
}
$Process.WaitForExit()
$exitCode = $Process.ExitCode
$Process.Dispose()
} catch {
Write-Error $_
$exitCode = 1
}
exit $exitCode
`
}
/** Runs the dev CLI directly (its .cmd launcher adds a cmd.exe quoting boundary) with that launcher's env. */
function buildDevCliEnv(cliEntryPath: string): string[] {
return [
"$env:ELECTRON_RUN_AS_NODE = '1'",
"if (-not $env:ORCA_APP_EXECUTABLE) { $env:ORCA_APP_EXECUTABLE = $OrcaLauncher; $env:ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1' }",
'$env:ORCA_NODE_OPTIONS = $env:NODE_OPTIONS',
'$env:ORCA_NODE_REPL_EXTERNAL_MODULE = $env:NODE_REPL_EXTERNAL_MODULE',
'Remove-Item Env:NODE_OPTIONS, Env:NODE_REPL_EXTERNAL_MODULE -ErrorAction SilentlyContinue',
`$ForwardArgs = @(${quotePowerShellLiteral(cliEntryPath)}) + $ForwardArgs`
]
}
function bridgeLines(lines: readonly string[]): string {
return lines.map((line) => ` ${line}\n`).join('')
}
export function getBridgePathFromCommandPath(commandPath: string): string {
// Why: both the current Linux command and the legacy pre-rename command
// share one WSL bridge under ~/.local/share/orca.
return `${commandPath.replace(/\/\.local\/bin\/(?:orca|orca-ide)$/, '/.local/share/orca')}/orca-wsl-bridge.ps1`
}
export function buildSafeReplaceGuard(path: string, managedMarker: string): string {
const quotedPath = quoteShell(path)
const quotedMarker = quoteShell(managedMarker)
return [
`if [ -L ${quotedPath} ]; then`,
' echo "__ORCA_CONFLICT__"',
' exit 23',
`elif [ -e ${quotedPath} ] && { [ ! -f ${quotedPath} ] || ! grep -Fq ${quotedMarker} ${quotedPath}; }; then`,
' echo "__ORCA_CONFLICT__"',
' exit 23',
'fi'
].join('\n')
}
export function buildRegistrationLockPrelude(commandPath: string): string {
const lockDir = getPosixDirname(getBridgePathFromCommandPath(commandPath))
// Why: the per-distro queue only serializes one Orca process; flock covers
// a second install (e.g. stable + nightly) mutating the same distro files.
return [
`if command -v flock >/dev/null 2>&1 && mkdir -p ${quoteShell(lockDir)} 2>/dev/null; then`,
` exec 9>${quoteShell(`${lockDir}/.orca-wsl-cli.lock`)}`,
' flock -x -w 30 9',
'fi'
].join('\n')
}
export function buildManagedLegacyRemoveCommand(quotedLegacyCommandPath: string): string {
// Why: remove only the Orca-managed pre-rename wrapper; user-owned `orca`
// commands and symlinks must survive.
return `if [ ! -L ${quotedLegacyCommandPath} ] && [ -f ${quotedLegacyCommandPath} ] && grep -Fq ${quoteShell(MANAGED_MARKER)} ${quotedLegacyCommandPath}; then rm -f ${quotedLegacyCommandPath}; fi`
}
export function buildSafeRemoveCommand(commandPath: string, legacyCommandPath?: string): string {
const bridgePath = getBridgePathFromCommandPath(commandPath)
return [
// Why -eu not -euo pipefail: this script runs via runWslProcess's `sh -s`,
// and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option.
'set -eu',
buildRegistrationLockPrelude(commandPath),
buildSafeReplaceGuard(commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
`rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`,
// Why: leaving a managed legacy `orca` behind lets startup reconciliation
// re-adopt it as opt-in proof and silently undo this removal.
...(legacyCommandPath ? [buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))] : [])
].join('\n')
}
export function parseManagedLauncherTarget(content: string): string | null {
const encoded = content.match(/^# ORCA_WIN_LAUNCHER_B64=([A-Za-z0-9+/=]+)$/m)?.[1]
if (encoded) {
try {
return Buffer.from(encoded, 'base64').toString('utf8')
} catch {
return null
}
}
const legacyTarget = content.match(/^ORCA_WIN_LAUNCHER='((?:[^']|'"'"')*)'$/m)?.[1]
return legacyTarget ? legacyTarget.replaceAll(`'"'"'`, "'") : null
}
export function getPosixDirname(path: string): string {
return path.slice(0, path.lastIndexOf('/')) || '/'
}
export function getWslLauncherMarker(): string {
return MANAGED_MARKER
}
export function getWslBridgeMarker(): string {
return BRIDGE_MANAGED_MARKER
}
export function quoteShell(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`
}