Files
orca/src/shared/claude-shell-function.ts
T
OrcaWinandOrca Worker 5df4fef917 fix(windows): codex and claude get a prompt's double quotes intact in PowerShell (#30424)
* fix(windows): codex and claude get a prompt's double quotes in Windows PowerShell

Windows PowerShell 5.1 (and pwsh before 7.3) does not escape an embedded
`"` when it builds a native program's command line, so every quote in a
launch prompt was dropped, and quotes could split the prompt into extra
arguments. Orca's PowerShell codex and claude wrappers now escape their
arguments at runtime for the PowerShell that is actually running, through
one shared __OrcaNativeArgs function. pwsh 7.3+ already escapes, so it
gets the text untouched.

* fix(windows): escape agent args for .cmd shims under pwsh 7.3+ too

pwsh 7.3+ defaults to the Windows argument mode, which still passes
.cmd/.bat targets the Legacy way, so a codex or claude .cmd shim kept
dropping quotes there. The function now takes the program it escapes
for and names the modes that escape by themselves, and anchors the 5.1
trailing-backslash fix at the true end of the text (`$` also matched
before a final newline). Tests now run the real codex wrapper with a
quoted prompt, cover pwsh's Legacy mode and the newline case, and pin
the claude wrapper's call.

* fix(windows): strip comment lines from the PowerShell bootstrap payload

The shared escaping function pushed the encoded bootstrap to 27,324 of
the 28,000-character budget, leaving almost no room for a startup
command to ride in -EncodedCommand, so short commands fell back to
stdin (the path #7236 broke). Comment lines document the source but
only spend that budget in the payload; dropping them frees 3,732
encoded characters, more headroom than main had.

* fix(windows): quote agent args with "" where cmd.exe or Windows PowerShell reads them

Escaping embedded quotes as \" let pwsh 7.3+'s .cmd quoting expose a
prompt's `&` to cmd.exe, which then ran the rest as a command. Where
cmd.exe reads the line, or Windows PowerShell counts quotes naively,
the function now quotes the argument itself and writes each quote as
"", as process-host's windows-command-line.ts already does. That also
delivers text 5.1 used to strip, so its special cases go away.

The codex and claude wrappers now come as one fragment with the
function they call, the bootstrap test pins the startup-command budget
instead of restating the strip, and the cwd-restore comment leaves the
payload too.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-10-18 17:29:54 -07:00

121 lines
7.0 KiB
TypeScript

/**
* `claude` re-reads the which-account file on every launch, so a switch reaches open terminals
* (superset's wrapper rule). Defined only in a pane Orca routed (pointer env set) where `claude` is
* a real executable. A missing or empty file is System default, which restores the user's own value
* Orca's replaced. The user's own CLAUDE_CONFIG_DIR (ORCA_CLAUDE_USER_CONFIG_DIR, e.g. an rc export)
* follows the selection like System default's folder does; any other value set in the pane wins.
* Prints nothing: an account folder with no login is created, and Claude's own first run signs in
* there. The shell's Anthropic auth passes through untouched, as on System default. A pointer that names no absolute folder (never written by Orca) runs nothing, since any
* fallback would be another account.
*/
export function getPosixClaudeShellFunction(): string {
return `__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)"
if [[ -n "\${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "\${__orca_claude_binary:-}" && -x "\${__orca_claude_binary}" ]]; then
function claude {
local __orca_claude_home __orca_claude_pointer="\${ORCA_CLAUDE_PROFILE_POINTER:-}"
# Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn.
case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="\${HOME:-}/\${__orca_claude_pointer#??}" ;; esac
__orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)"
if [ -n "\${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "\${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ] &&
{ [ -z "\${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ] || [ "\${CLAUDE_CONFIG_DIR%/}" != "\${ORCA_CLAUDE_USER_CONFIG_DIR%/}" ]; }; then
command claude "$@"; return
fi
# Why: Orca's value replaced the user's own at spawn, so System default restores theirs.
if [ -z "$__orca_claude_home" ] && [ -n "\${CLAUDE_CONFIG_DIR:-}" ] && [ -n "\${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then
( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return
fi
if [ -z "$__orca_claude_home" ]; then
( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return
fi
case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac
# Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private.
[ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null
( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" )
}
fi
unset __orca_claude_binary
`
}
/** Leading newline: the codex fragment it follows ends without one. */
export function getFishClaudeShellFunction(): string {
return `
set -l __orca_claude_type (type -t claude 2>/dev/null)
if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file
function claude
# Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn.
set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER")
set -l profile (cat "$pointer" 2>/dev/null)
set -l own (string trim -r -c / -- "$ORCA_CLAUDE_USER_CONFIG_DIR")
set -l current (string trim -r -c / -- "$CLAUDE_CONFIG_DIR")
if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR"; and begin; test -z "$own"; or test "$current" != "$own"; end
command claude $argv
return $status
end
if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR"
env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv
return $status
end
if test -z "$profile"
env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv
return $status
end
if not string match -qr '^(/|[A-Za-z]:)' -- "$profile"
return 1
end
test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null
env CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv
end
end
set -e __orca_claude_type
`
}
export function getPowerShellClaudeShellFunction(): string {
return `$orcaClaudeCommand = Get-Command claude -ErrorAction SilentlyContinue | Select-Object -First 1
if ($env:ORCA_CLAUDE_PROFILE_POINTER -and $orcaClaudeCommand -and
$orcaClaudeCommand.CommandType -in @("Application", "ExternalScript")) {
function Global:claude {
$names = @('CLAUDE_CONFIG_DIR', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR')
$saved = @{}
foreach ($name in $names) { $saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') }
try {
$orcaClaudeHome = ''
if ($env:ORCA_CLAUDE_PROFILE_POINTER -and (Test-Path -LiteralPath $env:ORCA_CLAUDE_PROFILE_POINTER -PathType Leaf)) {
$orcaClaudeHome = [IO.File]::ReadAllText($env:ORCA_CLAUDE_PROFILE_POINTER).TrimEnd()
}
# Why the separator swap: Windows names one folder with either separator.
$own = ("$env:ORCA_CLAUDE_USER_CONFIG_DIR" -replace '/', '\\').TrimEnd('\\')
if ($env:CLAUDE_CONFIG_DIR -and $env:CLAUDE_CONFIG_DIR -ne $env:ORCA_CLAUDE_INJECTED_CONFIG_DIR -and
(-not $own -or ("$env:CLAUDE_CONFIG_DIR" -replace '/', '\\').TrimEnd('\\') -ne $own)) {
# A value set in this pane, other than the user's own, wins.
} elseif (-not $orcaClaudeHome) {
Remove-Item Env:ORCA_CLAUDE_INJECTED_CONFIG_DIR -ErrorAction SilentlyContinue
if ($env:CLAUDE_CONFIG_DIR -and $env:ORCA_CLAUDE_USER_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR = $env:ORCA_CLAUDE_USER_CONFIG_DIR }
else { Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue }
} elseif (-not [IO.Path]::IsPathRooted($orcaClaudeHome)) {
$global:LASTEXITCODE = 1
return
} else {
if (-not [IO.Directory]::Exists($orcaClaudeHome)) { $null = New-Item -ItemType Directory -Path $orcaClaudeHome -Force -ErrorAction SilentlyContinue }
$env:CLAUDE_CONFIG_DIR = $orcaClaudeHome
$env:ORCA_CLAUDE_INJECTED_CONFIG_DIR = $orcaClaudeHome
}
$binary = Get-Command claude -CommandType Application,ExternalScript -ErrorAction Stop | Select-Object -First 1
$claudeArgs = @(__OrcaEscapeNativeArgs $binary.Source $args)
if ($MyInvocation.ExpectingInput) { $input | & $binary.Source @claudeArgs } else { & $binary.Source @claudeArgs }
$global:LASTEXITCODE = $LASTEXITCODE
} catch { $global:LASTEXITCODE = 1; Write-Error $_ -ErrorAction Continue }
finally {
# Why Remove-Item: on .NET 9+ a $null value (passed as "") creates the variable empty instead of deleting it.
foreach ($name in $names) {
if ($null -eq $saved[$name]) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue }
else { [Environment]::SetEnvironmentVariable($name, $saved[$name], 'Process') }
}
}
}
}
Remove-Variable orcaClaudeCommand -ErrorAction SilentlyContinue
`
}