diff --git a/src/cli/orchestration-mutation-recovery.ts b/src/cli/orchestration-mutation-recovery.ts index 3777b04755e..d5d80326c49 100644 --- a/src/cli/orchestration-mutation-recovery.ts +++ b/src/cli/orchestration-mutation-recovery.ts @@ -1,3 +1,4 @@ +import { quotePosixShellDoubleQuotedApostrophe } from '../shared/shell-argument-quoting' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' import { recoverableOrchestrationArgs, @@ -208,7 +209,7 @@ function shellQuote(value: string): string { if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { return value } - return `'${value.replaceAll("'", `'"'"'`)}'` + return quotePosixShellDoubleQuotedApostrophe(value) } function stripUnsafeRetryAdvice(message: string, requestId: string): string { diff --git a/src/cli/shell-command-quote.ts b/src/cli/shell-command-quote.ts index c5dff7f62a3..6ad5e3d73f2 100644 --- a/src/cli/shell-command-quote.ts +++ b/src/cli/shell-command-quote.ts @@ -1,3 +1,5 @@ +import { quotePosixShell } from '../shared/shell-argument-quoting' + export function quoteCliCommandArgument(value: string): string { if (/^[a-zA-Z0-9._:/@-]+$/.test(value)) { return value @@ -5,5 +7,5 @@ export function quoteCliCommandArgument(value: string): string { if (process.platform === 'win32') { return `"${value.replace(/"/g, '\\"')}"` } - return `'${value.replaceAll("'", "'\\''")}'` + return quotePosixShell(value) } diff --git a/src/main/agent-hooks/posix-hook-command.ts b/src/main/agent-hooks/posix-hook-command.ts index 8a9f6dc7bf4..b51cc90632a 100644 --- a/src/main/agent-hooks/posix-hook-command.ts +++ b/src/main/agent-hooks/posix-hook-command.ts @@ -1,9 +1,6 @@ +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' -export function quotePosixShellString(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} - // Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call. export function wrapPosixHookCommand( scriptPath: string, @@ -12,15 +9,15 @@ export function wrapPosixHookCommand( options: { fallbackStdout?: string; requiredEnvVar?: string } = {} ): string { // Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path. - const quoted = quotePosixShellString(scriptPath) + const quoted = quotePosixShell(scriptPath) const envPrefix = Object.entries(env) - .map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`) + .map(([key, value]) => `${key}=${quotePosixShell(value)}`) .join(' ') const invocation = envPrefix ? `${envPrefix} /bin/sh ${quoted}` : `/bin/sh ${quoted}` const fallback = options.fallbackStdout === undefined ? POSIX_HOOK_STDIN_DRAIN_COMMAND - : `printf '%s\\n' ${quotePosixShellString(options.fallbackStdout)}; ${POSIX_HOOK_STDIN_DRAIN_COMMAND}` + : `printf '%s\\n' ${quotePosixShell(options.fallbackStdout)}; ${POSIX_HOOK_STDIN_DRAIN_COMMAND}` // Why: default form avoids Grok rejecting unset vars or splicing values into shell quotes at load // time; the child shell checks the current pane env before spawning the managed script. const guards = [ diff --git a/src/main/antigravity/antigravity-browser-helper.ts b/src/main/antigravity/antigravity-browser-helper.ts index 62ae8822b5c..ecf5f6febeb 100644 --- a/src/main/antigravity/antigravity-browser-helper.ts +++ b/src/main/antigravity/antigravity-browser-helper.ts @@ -2,6 +2,7 @@ // one-line program run by its own executable in Node mode, which prints the link on error output // instead: the link is captured either way, and no browser opens on the host. +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' import { runProcess } from '@orca/process-host' export const ANTIGRAVITY_BROWSER_MARKER = '__ORCA_ANTIGRAVITY_AUTH_URL__' @@ -13,14 +14,15 @@ const HELPER_SOURCE = const PREFLIGHT_URL = 'https://example.invalid/orca-antigravity-browser-preflight' // Python splits BROWSER with shlex. -const quote = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'` // Node mode is set in the command itself: the provider supervisor strips ELECTRON_RUN_AS_NODE from // the server, which keeps it out of the agent's own tools. const executable = process.execPath.replaceAll('\\', '\\\\').replaceAll(':', '\\072') -const shell = `exec /usr/bin/env ELECTRON_RUN_AS_NODE=1 "$(printf %b ${quote(executable)})" -e ${quote(HELPER_SOURCE)} -- "$1"` +const shell = `exec /usr/bin/env ELECTRON_RUN_AS_NODE=1 "$(printf %b ${quotePosixShellDoubleQuotedApostrophe(executable)})" -e ${quotePosixShellDoubleQuotedApostrophe(HELPER_SOURCE)} -- "$1"` const BROWSER_ARGV = ['/bin/sh', '-c', shell, '--', '%s'] -export const ANTIGRAVITY_BROWSER_COMMAND = BROWSER_ARGV.map(quote).join(' ') +export const ANTIGRAVITY_BROWSER_COMMAND = BROWSER_ARGV.map( + quotePosixShellDoubleQuotedApostrophe +).join(' ') const usable = !/[\r\n\0]|%s/.test(process.execPath) /** The exact template the server's Python browser controller executes. */ diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index 071ca59bf48..846f6bb1e9e 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -27,7 +27,7 @@ import { ANTIGRAVITY_PRE_TOOL_USE_DECISION, type AntigravityEvent } from './hook-events' -import { quotePosixShellString } from '../agent-hooks/posix-hook-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { getManagedScript, getWindowsWrapperScript } from './hook-script' import { buildInstalledConfig, @@ -64,7 +64,7 @@ function getPosixManagedCommand(scriptPath: string, event: AntigravityEvent): st event.eventName === 'PreToolUse' ? { fallbackStdout: ANTIGRAVITY_PRE_TOOL_USE_DECISION } : {} ) // ACP hosts tokenize the command into argv; the shell must be the executable, not `if`. - return `/bin/sh -c ${quotePosixShellString(command)}` + return `/bin/sh -c ${quotePosixShell(command)}` } function getManagedCommand(scriptPath: string, event: AntigravityEvent): string { diff --git a/src/main/cli/appimage-stable-launcher.ts b/src/main/cli/appimage-stable-launcher.ts index 9494479abd8..e74797fadfb 100644 --- a/src/main/cli/appimage-stable-launcher.ts +++ b/src/main/cli/appimage-stable-launcher.ts @@ -16,7 +16,7 @@ import { } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' -import { quoteShell } from './cli-install-path-format' +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' const LAUNCHER_DIRECTORY_NAME = 'launcher' const LIVE_ENDPOINT_NAME = 'live' @@ -226,7 +226,7 @@ function buildStableLauncherScript(cacheRootPath: string): string { return `#!/usr/bin/env bash ${LAUNCHER_MARKER} shopt -s execfail -launcher_dir=${quoteShell(launcherDirectory)} +launcher_dir=${quotePosixShellDoubleQuotedApostrophe(launcherDirectory)} deadline=$((SECONDS + ${LAUNCHER_WAIT_SECONDS})) while (( SECONDS <= deadline )); do launcher="$launcher_dir/${INSTALLED_ENDPOINT_NAME}" diff --git a/src/main/cli/cli-command-filesystem-transaction.ts b/src/main/cli/cli-command-filesystem-transaction.ts index 6b7261dfefd..ab89d0bb55e 100644 --- a/src/main/cli/cli-command-filesystem-transaction.ts +++ b/src/main/cli/cli-command-filesystem-transaction.ts @@ -4,7 +4,7 @@ import { basename, dirname, join } from 'node:path' import type { CliInstallStatus } from '../../shared/cli-install-types' import { isPermissionError } from '../win32-utils' import { isMissingError } from './cli-install-errors' -import { quoteShell } from './cli-install-path-format' +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' export type EntryIdentity = { dev: bigint @@ -171,10 +171,12 @@ export function buildMacPrivilegedSymlinkReadableCommand(args: { commandPath: string expected: EntryIdentity }): string { - const commandPath = quoteShell(args.commandPath) - const changed = quoteShell(`The command at ${args.commandPath} changed while Orca inspected it.`) + const commandPath = quotePosixShellDoubleQuotedApostrophe(args.commandPath) + const changed = quotePosixShellDoubleQuotedApostrophe( + `The command at ${args.commandPath} changed while Orca inspected it.` + ) return ( - `if [ -L ${commandPath} ] && [ "$(/usr/bin/stat -f '%d:%i' ${commandPath})" = ${quoteShell(`${args.expected.dev}:${args.expected.ino}`)} ]; ` + + `if [ -L ${commandPath} ] && [ "$(/usr/bin/stat -f '%d:%i' ${commandPath})" = ${quotePosixShellDoubleQuotedApostrophe(`${args.expected.dev}:${args.expected.ino}`)} ]; ` + `then /bin/chmod -h 755 ${commandPath}; else echo ${changed} >&2; exit 73; fi` ) } @@ -194,43 +196,45 @@ export function buildMacPrivilegedSymlinkTransaction( const heldPath = join(transactionDirectory, basename(args.commandPath)) const publishDirectory = join(transactionDirectory, 'publish') const publishPath = join(publishDirectory, basename(args.commandPath)) - const recoveryMessage = quoteShell(`The displaced entry is preserved at ${heldPath}.`) + const recoveryMessage = quotePosixShellDoubleQuotedApostrophe( + `The displaced entry is preserved at ${heldPath}.` + ) const restore = - `/bin/ln -P ${quoteShell(heldPath)} ${quoteShell(commandDirectory)} && ` + - `/bin/rm ${quoteShell(heldPath)} && /bin/rmdir ${quoteShell(transactionDirectory)}` + `/bin/ln -P ${quotePosixShellDoubleQuotedApostrophe(heldPath)} ${quotePosixShellDoubleQuotedApostrophe(commandDirectory)} && ` + + `/bin/rm ${quotePosixShellDoubleQuotedApostrophe(heldPath)} && /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)}` const restoreOrPreserve = `if ${restore}; then :; else echo ${recoveryMessage} >&2; exit 74; fi` const { fileSha256, rawSymlinkTarget, snapshot } = args.inspected const fileMismatch = fileSha256 - ? ` || [ "$(/usr/bin/shasum -a 256 ${quoteShell(heldPath)} | /usr/bin/awk '{print $1}')" != ${quoteShell(fileSha256)} ]` + ? ` || [ "$(/usr/bin/shasum -a 256 ${quotePosixShellDoubleQuotedApostrophe(heldPath)} | /usr/bin/awk '{print $1}')" != ${quotePosixShellDoubleQuotedApostrophe(fileSha256)} ]` : '' const symlinkMismatch = rawSymlinkTarget - ? ` || [ "$(/usr/bin/readlink -n ${quoteShell(heldPath)}; /usr/bin/printf x)" != ${quoteShell(`${rawSymlinkTarget}x`)} ]` + ? ` || [ "$(/usr/bin/readlink -n ${quotePosixShellDoubleQuotedApostrophe(heldPath)}; /usr/bin/printf x)" != ${quotePosixShellDoubleQuotedApostrophe(`${rawSymlinkTarget}x`)} ]` : '' const rejectCaptured = snapshot - ? `if [ "$captured" -eq 1 ] && { [ "$(/usr/bin/stat -f '%d:%i' ${quoteShell(heldPath)})" != ${quoteShell(`${snapshot.identity.dev}:${snapshot.identity.ino}`)} ]${fileMismatch}${symlinkMismatch}; }; then ${restoreOrPreserve}; exit 73; fi` + ? `if [ "$captured" -eq 1 ] && { [ "$(/usr/bin/stat -f '%d:%i' ${quotePosixShellDoubleQuotedApostrophe(heldPath)})" != ${quotePosixShellDoubleQuotedApostrophe(`${snapshot.identity.dev}:${snapshot.identity.ino}`)} ]${fileMismatch}${symlinkMismatch}; }; then ${restoreOrPreserve}; exit 73; fi` : `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; exit 73; fi` // Why: macOS applies the umask to symlinks too, so the script publishes under 022 and only the // transaction directories are created private. const capture = - `umask 022; /bin/mkdir -p ${quoteShell(commandDirectory)} || exit $?; ` + - `/bin/mkdir -m 700 ${quoteShell(transactionDirectory)} || exit $?; captured=0; ` + - `if [ -e ${quoteShell(args.commandPath)} ] || [ -L ${quoteShell(args.commandPath)} ]; then ` + - `/bin/mv ${quoteShell(args.commandPath)} ${quoteShell(heldPath)} && captured=1 || exit $?; fi; ` + + `umask 022; /bin/mkdir -p ${quotePosixShellDoubleQuotedApostrophe(commandDirectory)} || exit $?; ` + + `/bin/mkdir -m 700 ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)} || exit $?; captured=0; ` + + `if [ -e ${quotePosixShellDoubleQuotedApostrophe(args.commandPath)} ] || [ -L ${quotePosixShellDoubleQuotedApostrophe(args.commandPath)} ]; then ` + + `/bin/mv ${quotePosixShellDoubleQuotedApostrophe(args.commandPath)} ${quotePosixShellDoubleQuotedApostrophe(heldPath)} && captured=1 || exit $?; fi; ` + `${rejectCaptured}; ` if (args.action === 'remove') { - return `${capture}if [ "$captured" -eq 1 ]; then /bin/rm ${quoteShell(heldPath)}; fi; /bin/rmdir ${quoteShell(transactionDirectory)}` + return `${capture}if [ "$captured" -eq 1 ]; then /bin/rm ${quotePosixShellDoubleQuotedApostrophe(heldPath)}; fi; /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)}` } const rollback = - `/bin/rm -f ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)} 2>/dev/null || :; ` + - `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; else /bin/rmdir ${quoteShell(transactionDirectory)}; fi; exit 73` + `/bin/rm -f ${quotePosixShellDoubleQuotedApostrophe(publishPath)}; /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(publishDirectory)} 2>/dev/null || :; ` + + `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; else /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)}; fi; exit 73` return ( - `${capture}if /bin/mkdir -m 700 ${quoteShell(publishDirectory)} && ` + - `/bin/ln -s ${quoteShell(args.launcherPath)} ${quoteShell(publishPath)} && ` + - `/bin/ln -P ${quoteShell(publishPath)} ${quoteShell(commandDirectory)}; then ` + - `/bin/rm ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)}; ` + - `if [ "$captured" -eq 1 ]; then /bin/rm ${quoteShell(heldPath)}; fi; ` + - `/bin/rmdir ${quoteShell(transactionDirectory)}; else ${rollback}; fi` + `${capture}if /bin/mkdir -m 700 ${quotePosixShellDoubleQuotedApostrophe(publishDirectory)} && ` + + `/bin/ln -s ${quotePosixShellDoubleQuotedApostrophe(args.launcherPath)} ${quotePosixShellDoubleQuotedApostrophe(publishPath)} && ` + + `/bin/ln -P ${quotePosixShellDoubleQuotedApostrophe(publishPath)} ${quotePosixShellDoubleQuotedApostrophe(commandDirectory)}; then ` + + `/bin/rm ${quotePosixShellDoubleQuotedApostrophe(publishPath)}; /bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(publishDirectory)}; ` + + `if [ "$captured" -eq 1 ]; then /bin/rm ${quotePosixShellDoubleQuotedApostrophe(heldPath)}; fi; ` + + `/bin/rmdir ${quotePosixShellDoubleQuotedApostrophe(transactionDirectory)}; else ${rollback}; fi` ) } diff --git a/src/main/cli/cli-dev-launcher.ts b/src/main/cli/cli-dev-launcher.ts index e7b0f75c4b5..839cc8f2eb4 100644 --- a/src/main/cli/cli-dev-launcher.ts +++ b/src/main/cli/cli-dev-launcher.ts @@ -2,11 +2,8 @@ import { existsSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { dirname, isAbsolute, join } from 'node:path' import { DEV_COMMAND_NAME, DEV_LAUNCHER_DIR } from './cli-install-constants' -import { - escapeWindowsBatchValue, - isAbsoluteForPlatform, - quoteShell -} from './cli-install-path-format' +import { escapeWindowsBatchValue, isAbsoluteForPlatform } from './cli-install-path-format' +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' export async function ensureDevLauncher(args: { platform: NodeJS.Platform @@ -58,16 +55,16 @@ export function buildUnixCliLauncher( if (runtime === 'node') { return `#!/usr/bin/env sh set -eu -export ORCA_USER_DATA_PATH=${quoteShell(userDataPath)} +export ORCA_USER_DATA_PATH=${quotePosixShellDoubleQuotedApostrophe(userDataPath)} unset NODE_OPTIONS NODE_REPL_EXTERNAL_MODULE ELECTRON_RUN_AS_NODE -exec ${quoteShell(execPathValue)} ${quoteShell(cliEntryPath)} "$@" +exec ${quotePosixShellDoubleQuotedApostrophe(execPathValue)} ${quotePosixShellDoubleQuotedApostrophe(cliEntryPath)} "$@" ` } return `#!/usr/bin/env bash set -euo pipefail -ELECTRON=${quoteShell(execPathValue)} -CLI=${quoteShell(cliEntryPath)} -export ORCA_USER_DATA_PATH=${quoteShell(userDataPath)} +ELECTRON=${quotePosixShellDoubleQuotedApostrophe(execPathValue)} +CLI=${quotePosixShellDoubleQuotedApostrophe(cliEntryPath)} +export ORCA_USER_DATA_PATH=${quotePosixShellDoubleQuotedApostrophe(userDataPath)} if [ -z "\${ORCA_APP_EXECUTABLE:-}" ]; then export ORCA_APP_EXECUTABLE="$ELECTRON" export ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1 diff --git a/src/main/cli/cli-install-path-format.ts b/src/main/cli/cli-install-path-format.ts index 1192594c270..6af5ca168d3 100644 --- a/src/main/cli/cli-install-path-format.ts +++ b/src/main/cli/cli-install-path-format.ts @@ -73,10 +73,6 @@ export function escapeWindowsBatchValue(value: string): string { return value.replaceAll('"', '""') } -export function quoteShell(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'` -} - export function isAbsoluteForPlatform(platform: NodeJS.Platform, value: string): boolean { if (platform === 'win32') { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') diff --git a/src/main/cli/legacy-appimage-cli-wrapper.ts b/src/main/cli/legacy-appimage-cli-wrapper.ts index 1e22b2dc577..8af84bc1588 100644 --- a/src/main/cli/legacy-appimage-cli-wrapper.ts +++ b/src/main/cli/legacy-appimage-cli-wrapper.ts @@ -1,4 +1,4 @@ -import { quoteShell } from './cli-install-path-format' +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' const APPIMAGE_CLI_SCRIPT = [ '(async()=>{', @@ -25,7 +25,7 @@ export function extractLegacyAppImageCliWrapperTarget(content: string): string | export function buildLegacyAppImageCliWrapper(appImagePath: string): string { return `#!/usr/bin/env bash set -euo pipefail -APPIMAGE=${quoteShell(appImagePath)} +APPIMAGE=${quotePosixShellDoubleQuotedApostrophe(appImagePath)} if [ ! -f "$APPIMAGE" ]; then echo "Orca AppImage not found at $APPIMAGE" >&2 echo "If you moved the AppImage, re-run CLI registration from Orca Settings." >&2 @@ -36,7 +36,7 @@ export ORCA_NODE_REPL_EXTERNAL_MODULE="\${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS unset NODE_REPL_EXTERNAL_MODULE # Why: AppImage mount paths change on each launch; $APPDIR is the runtime mount. -ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} -- "$@" +ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quotePosixShellDoubleQuotedApostrophe(APPIMAGE_CLI_SCRIPT)} -- "$@" ` } @@ -45,5 +45,5 @@ function unquoteShell(value: string): string | null { return null } const decoded = value.slice(1, -1).split(`'"'"'`).join("'") - return quoteShell(decoded) === value ? decoded : null + return quotePosixShellDoubleQuotedApostrophe(decoded) === value ? decoded : null } diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index ac23081a2ce..7260d3de100 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -16,7 +16,7 @@ import { pruneAppImageExtractedRoots } from './appimage-extraction-pruning' import { resolveGenerationStableLauncherPath } from './appimage-generation-stable-launcher' import { withAppImageRegistrationLock } from './appimage-registration-lock' import { getBundledLauncherPath } from './bundled-cli-launcher-path' -import { quoteShell } from './cli-install-path-format' +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite // our own file idempotently but never clobber a user's own ~/.local/bin/orca. @@ -80,7 +80,7 @@ export async function installLinuxBareOrcaDispatcher( /** Bare-`orca` script that execs the one Linux CLI launcher. */ export function buildBareOrcaCliScript(launcherPath: string): string { - return `#!/usr/bin/env bash\nexec ${quoteShell(launcherPath)} "$@"\n` + return `#!/usr/bin/env bash\nexec ${quotePosixShellDoubleQuotedApostrophe(launcherPath)} "$@"\n` } /** diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index 7697bac01ac..21874d6a847 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -22,7 +22,7 @@ import { } from './appimage-stable-launcher' import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' -import { quoteShell } from './cli-install-path-format' +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' const SHIM_DIR_NAME = 'linux-orca-cli-shim' @@ -175,12 +175,12 @@ function buildAppImageLiveLauncherScript( launcherPath: string, fence: AppImageRuntimeFence ): string { - const runtimePid = quoteShell(String(fence.pid)) - const runtimeStartTime = quoteShell(fence.startTime) - const runtimeRoot = quoteShell(fence.runtimeRoot) - const expectedRuntimeIdentity = quoteShell(fence.runtimeIdentity) - const expectedLauncherIdentity = quoteShell(fence.launcherIdentity) - const quotedLauncherPath = quoteShell(launcherPath) + const runtimePid = quotePosixShellDoubleQuotedApostrophe(String(fence.pid)) + const runtimeStartTime = quotePosixShellDoubleQuotedApostrophe(fence.startTime) + const runtimeRoot = quotePosixShellDoubleQuotedApostrophe(fence.runtimeRoot) + const expectedRuntimeIdentity = quotePosixShellDoubleQuotedApostrophe(fence.runtimeIdentity) + const expectedLauncherIdentity = quotePosixShellDoubleQuotedApostrophe(fence.launcherIdentity) + const quotedLauncherPath = quotePosixShellDoubleQuotedApostrophe(launcherPath) return `#!/usr/bin/env bash runtime_pid=${runtimePid} runtime_start_time=${runtimeStartTime} diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index b827542a17a..6345cd3a301 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -11,9 +11,9 @@ import { getPosixDirname, getWslBridgeMarker, getWslLauncherMarker, - parseManagedLauncherTarget, - quoteShell + parseManagedLauncherTarget } from './wsl-cli-scripts' +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' import { buildWslCliInstallCommand } from './wsl-cli-registration-command' import { buildWslCliStatus, @@ -242,7 +242,10 @@ export class WslCliInstaller { // startup reconciliation as opt-in proof, silently undoing this removal. await this.run( this.distro as string, - ['set -eu', buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))].join('\n') + [ + 'set -eu', + buildManagedLegacyRemoveCommand(quotePosixShellDoubleQuotedApostrophe(legacyCommandPath)) + ].join('\n') ) return status } diff --git a/src/main/cli/wsl-cli-registration-command.ts b/src/main/cli/wsl-cli-registration-command.ts index 53d39eaec86..4158031653b 100644 --- a/src/main/cli/wsl-cli-registration-command.ts +++ b/src/main/cli/wsl-cli-registration-command.ts @@ -8,9 +8,9 @@ import { getBridgePathFromCommandPath, getPosixDirname, getWslBridgeMarker, - getWslLauncherMarker, - quoteShell + getWslLauncherMarker } from './wsl-cli-scripts' +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' const MANAGED_MARKER = getWslLauncherMarker() const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() @@ -25,12 +25,13 @@ export function buildWslCliInstallCommand( // Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`, // and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option. 'set -eu', - `mkdir -p ${quoteShell(status.pathDirectory as string)}`, - `mkdir -p ${quoteShell(getPosixDirname(bridgePath))}`, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: WSL status sets pathDirectory to dirname(commandPath) whenever commandPath is set, and the caller requires commandPath. + `mkdir -p ${quotePosixShellDoubleQuotedApostrophe(status.pathDirectory as string)}`, + `mkdir -p ${quotePosixShellDoubleQuotedApostrophe(getPosixDirname(bridgePath))}`, buildRegistrationLockPrelude(status.commandPath), - `command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`, - `bridge_path=${quoteShell(bridgePath)}`, - `legacy_command_path=${quoteShell(legacyCommandPath)}`, + `command_tmp=${quotePosixShellDoubleQuotedApostrophe(`${status.commandPath}.tmp`)}.$$`, + `bridge_path=${quotePosixShellDoubleQuotedApostrophe(bridgePath)}`, + `legacy_command_path=${quotePosixShellDoubleQuotedApostrophe(legacyCommandPath)}`, 'bridge_tmp="${bridge_path}.tmp.$$"', 'bridge_backup="${bridge_tmp}.backup"', 'bridge_had_original=0', @@ -40,7 +41,7 @@ export function buildWslCliInstallCommand( ' result=$?', ' set +e', ' if [ "$committed" -ne 1 ]; then', - ` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(bridgePath)}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(bridgePath)}; fi`, + ` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quotePosixShellDoubleQuotedApostrophe(bridgePath)}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quotePosixShellDoubleQuotedApostrophe(bridgePath)}; fi`, ' fi', ' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"', ' exit "$result"', @@ -58,10 +59,10 @@ export function buildWslCliInstallCommand( 'chmod 644 "$bridge_tmp"', buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER), - `if [ -f ${quoteShell(bridgePath)} ]; then cp -p ${quoteShell(bridgePath)} "$bridge_backup"; bridge_had_original=1; fi`, - `mv -f "$bridge_tmp" ${quoteShell(bridgePath)}`, + `if [ -f ${quotePosixShellDoubleQuotedApostrophe(bridgePath)} ]; then cp -p ${quotePosixShellDoubleQuotedApostrophe(bridgePath)} "$bridge_backup"; bridge_had_original=1; fi`, + `mv -f "$bridge_tmp" ${quotePosixShellDoubleQuotedApostrophe(bridgePath)}`, 'bridge_touched=1', - `mv -f "$command_tmp" ${quoteShell(status.commandPath)}`, + `mv -f "$command_tmp" ${quotePosixShellDoubleQuotedApostrophe(status.commandPath)}`, 'committed=1', 'rm -f "$bridge_backup"', buildManagedLegacyRemoveCommand('"$legacy_command_path"'), diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 3f4e4156433..44da53fbad9 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -1,3 +1,4 @@ +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' const MANAGED_MARKER = '# Orca managed WSL CLI launcher' @@ -16,7 +17,11 @@ 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) + return buildLauncher( + windowsLauncherPath, + quotePosixShellDoubleQuotedApostrophe(bridgePath), + FIND_INTEROP_POWERSHELL + ) } /** Launcher that finds its bridge beside itself and PowerShell by Windows path, independent of guest PATH. */ @@ -27,7 +32,7 @@ export function buildColocatedWslLauncher( return buildLauncher( windowsLauncherPath, '"$(dirname -- "$0")/orca-wsl-bridge.ps1"', - `ORCA_POWERSHELL=$(wslpath -u ${quoteShell(windowsPowerShellPath)}) + `ORCA_POWERSHELL=$(wslpath -u ${quotePosixShellDoubleQuotedApostrophe(windowsPowerShellPath)}) if [ ! -x "$ORCA_POWERSHELL" ]; then echo "Orca WSL CLI requires Windows interop and access to $ORCA_POWERSHELL." >&2 exit 1 @@ -45,7 +50,7 @@ function buildLauncher( set -euo pipefail ${MANAGED_MARKER} # ORCA_WIN_LAUNCHER_B64=${encodedTarget} -ORCA_WIN_LAUNCHER=${quoteShell(windowsLauncherPath)} +ORCA_WIN_LAUNCHER=${quotePosixShellDoubleQuotedApostrophe(windowsLauncherPath)} ORCA_BRIDGE_PS1=${bridgePathExpression} ${resolvePowerShell} # Why: a shell can outlive a deleted worktree; keep explicit CLI selectors and @@ -200,8 +205,8 @@ export function getBridgePathFromCommandPath(commandPath: string): string { } export function buildSafeReplaceGuard(path: string, managedMarker: string): string { - const quotedPath = quoteShell(path) - const quotedMarker = quoteShell(managedMarker) + const quotedPath = quotePosixShellDoubleQuotedApostrophe(path) + const quotedMarker = quotePosixShellDoubleQuotedApostrophe(managedMarker) return [ `if [ -L ${quotedPath} ]; then`, ' echo "__ORCA_CONFLICT__"', @@ -218,8 +223,8 @@ export function buildRegistrationLockPrelude(commandPath: string): string { // 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`)}`, + `if command -v flock >/dev/null 2>&1 && mkdir -p ${quotePosixShellDoubleQuotedApostrophe(lockDir)} 2>/dev/null; then`, + ` exec 9>${quotePosixShellDoubleQuotedApostrophe(`${lockDir}/.orca-wsl-cli.lock`)}`, ' flock -x -w 30 9', 'fi' ].join('\n') @@ -228,7 +233,7 @@ export function buildRegistrationLockPrelude(commandPath: string): string { 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` + return `if [ ! -L ${quotedLegacyCommandPath} ] && [ -f ${quotedLegacyCommandPath} ] && grep -Fq ${quotePosixShellDoubleQuotedApostrophe(MANAGED_MARKER)} ${quotedLegacyCommandPath}; then rm -f ${quotedLegacyCommandPath}; fi` } export function buildSafeRemoveCommand(commandPath: string, legacyCommandPath?: string): string { @@ -240,10 +245,12 @@ export function buildSafeRemoveCommand(commandPath: string, legacyCommandPath?: buildRegistrationLockPrelude(commandPath), buildSafeReplaceGuard(commandPath, MANAGED_MARKER), buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER), - `rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`, + `rm -f ${quotePosixShellDoubleQuotedApostrophe(commandPath)} ${quotePosixShellDoubleQuotedApostrophe(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))] : []) + ...(legacyCommandPath + ? [buildManagedLegacyRemoveCommand(quotePosixShellDoubleQuotedApostrophe(legacyCommandPath))] + : []) ].join('\n') } @@ -272,7 +279,3 @@ export function getWslLauncherMarker(): string { export function getWslBridgeMarker(): string { return BRIDGE_MANAGED_MARKER } - -export function quoteShell(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'` -} diff --git a/src/main/cli/wsl-cli-status.ts b/src/main/cli/wsl-cli-status.ts index e8e9a0519ca..62f33dbd2ee 100644 --- a/src/main/cli/wsl-cli-status.ts +++ b/src/main/cli/wsl-cli-status.ts @@ -1,5 +1,6 @@ import type { CliInstallStatus } from '../../shared/cli-install-types' -import { getBridgePathFromCommandPath, getPosixDirname, quoteShell } from './wsl-cli-scripts' +import { getBridgePathFromCommandPath, getPosixDirname } from './wsl-cli-scripts' +import { quotePosixShellDoubleQuotedApostrophe } from '../../shared/shell-argument-quoting' const WSL_COMMAND_NAME = 'orca-ide' @@ -47,14 +48,14 @@ export async function readWslCliCommandFile( const output = await run( distro, [ - `if [ -L ${quoteShell(commandPath)} ]; then`, + `if [ -L ${quotePosixShellDoubleQuotedApostrophe(commandPath)} ]; then`, ' printf __ORCA_NOT_FILE__', - `elif [ ! -e ${quoteShell(commandPath)} ]; then`, + `elif [ ! -e ${quotePosixShellDoubleQuotedApostrophe(commandPath)} ]; then`, ' printf __ORCA_MISSING__', - `elif [ ! -f ${quoteShell(commandPath)} ]; then`, + `elif [ ! -f ${quotePosixShellDoubleQuotedApostrophe(commandPath)} ]; then`, ' printf __ORCA_NOT_FILE__', 'else', - ` cat ${quoteShell(commandPath)}`, + ` cat ${quotePosixShellDoubleQuotedApostrophe(commandPath)}`, 'fi' ].join('\n'), { loginPath: 'none' } @@ -208,7 +209,7 @@ async function readPathConfigured( try { const output = await run( distro, - `case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`, + `case ":$PATH:" in *:${quotePosixShellDoubleQuotedApostrophe(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`, { loginPath: 'preferred' } ) return output.trim() === 'yes' diff --git a/src/main/codex/codex-hook-definition.ts b/src/main/codex/codex-hook-definition.ts index d3ced21c96f..9f44b948b49 100644 --- a/src/main/codex/codex-hook-definition.ts +++ b/src/main/codex/codex-hook-definition.ts @@ -1,3 +1,4 @@ +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { join } from 'node:path' import { buildManagedCommandHook, @@ -138,7 +139,7 @@ export function getCodexManagedHookInstallMaterial(): CodexManagedHookInstallMat } export function wrapReadablePosixHookCommand(scriptPath: string): string { - const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'` + const quoted = quotePosixShell(scriptPath) // Why: WSL hooks are written from Windows over UNC where the exec bit is unreliable; a missing script must still own stdin. return `if [ -f ${quoted} ] && [ -r ${quoted} ]; then /bin/sh ${quoted}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` } diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index 5b5d41cdd26..c07909a64d4 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -1,3 +1,4 @@ +import { quotePosixShellUnlessPlain } from '../shared/shell-argument-quoting' import { spawn, type ChildProcess } from 'node:child_process' import { spawnProcess } from '@orca/process-host' import { existsSync } from 'node:fs' @@ -43,13 +44,6 @@ export type ExternalEditorLaunchSpec = | ExternalEditorExecutableLaunchSpec | ExternalEditorShellLaunchSpec -function escapePosixPathForShell(pathValue: string): string { - if (/^[a-zA-Z0-9_./@:-]+$/.test(pathValue)) { - return pathValue - } - return `'${pathValue.replace(/'/g, "'\\''")}'` -} - function escapeWindowsPathForShell(pathValue: string): string { return /^[a-zA-Z0-9_./@:\\-]+$/.test(pathValue) ? pathValue : `"${pathValue}"` } @@ -57,7 +51,7 @@ function escapeWindowsPathForShell(pathValue: string): string { function escapePathForShell(pathValue: string, platform: NodeJS.Platform): string { return platform === 'win32' ? escapeWindowsPathForShell(pathValue) - : escapePosixPathForShell(pathValue) + : quotePosixShellUnlessPlain(pathValue) } function isWindowsExecutablePath(command: string): boolean { diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts index 2197f8e9c4f..d97b3bc49c0 100644 --- a/src/main/ipc/preflight-command-exec.ts +++ b/src/main/ipc/preflight-command-exec.ts @@ -1,3 +1,4 @@ +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { execFile } from 'node:child_process' import path from 'node:path' import { homedir } from 'node:os' @@ -20,10 +21,6 @@ const WSL_COMMAND_PATH_SENTINEL = '__ORCA_PREFLIGHT_COMMAND_PATH__' export type PreflightCommandResult = { stdout: string; stderr: string } -export function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - async function withPreflightTimeout( command: string, commandPromise: Promise, @@ -226,7 +223,7 @@ export async function isCommandAvailable( return (await findRunnableLocalCommand(command)).status === 'available' } try { - await execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`) + await execCommandInWslOrThrow(wslTarget, `${quotePosixShell(command)} --version`) return true } catch { return false diff --git a/src/main/ipc/preflight-provider-command-selection.test.ts b/src/main/ipc/preflight-provider-command-selection.test.ts index a51ac4a886c..f7b1fcd5376 100644 --- a/src/main/ipc/preflight-provider-command-selection.test.ts +++ b/src/main/ipc/preflight-provider-command-selection.test.ts @@ -9,9 +9,9 @@ import { execFileCaptureToTermination } from '../git/command-runner/exec-file-ca import { beginLocalCommandSelection, resolveSelectedLocalCommand } from './command-path-resolver' import { execLocalPreflightCommandOrThrow, - findRunnableLocalCommand, - shellQuote + findRunnableLocalCommand } from './preflight-command-exec' +import { quotePosixShell } from '../../shared/shell-argument-quoting' vi.mock('../../shared/system-cli-install-dirs', () => ({ getSystemCliInstallDirectories: (_platform: NodeJS.Platform, home: string) => [ @@ -32,7 +32,7 @@ async function fixtureCli( const binary = path.join(dir, cli) await writeFile( binary, - `#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(`${binary}-calls`)}\n${body}`, + `#!/bin/sh\nprintf '%s\\n' "$*" >> ${quotePosixShell(`${binary}-calls`)}\n${body}`, { mode: 0o755 } ) return binary diff --git a/src/main/ipc/preflight-runnable-local-cli.test.ts b/src/main/ipc/preflight-runnable-local-cli.test.ts index 5f960f0cee7..64ea0d31398 100644 --- a/src/main/ipc/preflight-runnable-local-cli.test.ts +++ b/src/main/ipc/preflight-runnable-local-cli.test.ts @@ -7,9 +7,9 @@ import { execLocalPreflightCommandOrThrow, findRunnableLocalCommand, isCommandAvailable, - isCommandOnPath, - shellQuote + isCommandOnPath } from './preflight-command-exec' +import { quotePosixShell } from '../../shared/shell-argument-quoting' const COMMAND = 'orca-22975-gh' const BROKEN_SHIM = 'echo broken-shim >&2\nexit 126\n' @@ -20,7 +20,7 @@ async function cliInDirectory(label: string, body = 'echo gh-version-fixture\n') await mkdir(dir, { recursive: true }) await writeFile( path.join(dir, COMMAND), - `#!/bin/sh\nprintf '%s\\n' "$@" >> ${shellQuote(path.join(dir, 'argv.txt'))}\n${body}`, + `#!/bin/sh\nprintf '%s\\n' "$@" >> ${quotePosixShell(path.join(dir, 'argv.txt'))}\n${body}`, { mode: 0o755 } ) return dir @@ -188,8 +188,11 @@ describe.skipIf(process.platform === 'win32')( it('recovers a working copy after a real launcher timeout', async () => { const marker = path.join(root, 'descendant-terminated') - const childScript = `trap ${shellQuote(`printf terminated > ${shellQuote(marker)}; exit 0`)} TERM\n/bin/sleep 30 &\nwait\n` - const hung = await cliInDirectory('hung', `/bin/sh -c ${shellQuote(childScript)} &\nwait\n`) + const childScript = `trap ${quotePosixShell(`printf terminated > ${quotePosixShell(marker)}; exit 0`)} TERM\n/bin/sleep 30 &\nwait\n` + const hung = await cliInDirectory( + 'hung', + `/bin/sh -c ${quotePosixShell(childScript)} &\nwait\n` + ) const good = await cliInDirectory('good') vi.stubEnv('PATH', [hung, good].join(path.delimiter)) diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index a0e843b43b1..214ec0a10e9 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -1,3 +1,4 @@ +import { quotePosixShell } from '../../shared/shell-argument-quoting' import path from 'node:path' import { buildPosixFallbackPathPrelude } from '../../shared/posix-version-manager-bin-dirs' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' @@ -19,7 +20,7 @@ export async function detectWslCommandsOnPath( return new Set() } - const commandList = uniqueCommands.map(shellQuote).join(' ') + const commandList = uniqueCommands.map(quotePosixShell).join(' ') const lookupScript = buildPosixCommandPathLookupScript( { kind: 'shell-variable', name: 'cmd' }, // Skip Windows mounts DURING the walk, not after it: WSL appends the @@ -65,10 +66,6 @@ export async function detectWslCommandsOnPath( } } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - function parseWslDetectedCommands(stdout: string): Set { const found = new Set() for (const rawLine of stdout.split(/\r?\n/)) { diff --git a/src/main/linux-package-install-command.test.ts b/src/main/linux-package-install-command.test.ts index c76c062bbce..5cc86cef8cd 100644 --- a/src/main/linux-package-install-command.test.ts +++ b/src/main/linux-package-install-command.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { quotePosixShellDoubleQuotedApostrophe } from '../shared/shell-argument-quoting' const { statSyncMock } = vi.hoisted(() => ({ statSyncMock: vi.fn() })) @@ -34,38 +35,36 @@ afterEach(() => { vi.unstubAllEnvs() }) -describe('quoteForPosixShell', () => { - it('wraps plain values in single quotes', async () => { - const { quoteForPosixShell } = await loadCommandModule() - expect(quoteForPosixShell('/tmp/orca.deb')).toBe("'/tmp/orca.deb'") +describe('quotePosixShellDoubleQuotedApostrophe', () => { + it('wraps plain values in single quotes', () => { + expect(quotePosixShellDoubleQuotedApostrophe('/tmp/orca.deb')).toBe("'/tmp/orca.deb'") }) - it('quotes spaces', async () => { - const { quoteForPosixShell } = await loadCommandModule() - expect(quoteForPosixShell('/tmp/Orca Setup.deb')).toBe("'/tmp/Orca Setup.deb'") + it('quotes spaces', () => { + expect(quotePosixShellDoubleQuotedApostrophe('/tmp/Orca Setup.deb')).toBe( + "'/tmp/Orca Setup.deb'" + ) }) - it('neutralizes expansion, command substitution, separators, and double quotes', async () => { - const { quoteForPosixShell } = await loadCommandModule() - expect(quoteForPosixShell('/tmp/$HOME.deb')).toBe("'/tmp/$HOME.deb'") - expect(quoteForPosixShell('/tmp/`id`.deb')).toBe("'/tmp/`id`.deb'") - expect(quoteForPosixShell('/tmp/a;rm -rf ~.deb')).toBe("'/tmp/a;rm -rf ~.deb'") - expect(quoteForPosixShell('/tmp/"quoted".deb')).toBe('\'/tmp/"quoted".deb\'') + it('neutralizes expansion, command substitution, separators, and double quotes', () => { + expect(quotePosixShellDoubleQuotedApostrophe('/tmp/$HOME.deb')).toBe("'/tmp/$HOME.deb'") + expect(quotePosixShellDoubleQuotedApostrophe('/tmp/`id`.deb')).toBe("'/tmp/`id`.deb'") + expect(quotePosixShellDoubleQuotedApostrophe('/tmp/a;rm -rf ~.deb')).toBe( + "'/tmp/a;rm -rf ~.deb'" + ) + expect(quotePosixShellDoubleQuotedApostrophe('/tmp/"quoted".deb')).toBe('\'/tmp/"quoted".deb\'') }) - it('closes and reopens the quote around a literal single quote', async () => { - const { quoteForPosixShell } = await loadCommandModule() - expect(quoteForPosixShell("/tmp/it's.deb")).toBe(`'/tmp/it'"'"'s.deb'`) + it('closes and reopens the quote around a literal single quote', () => { + expect(quotePosixShellDoubleQuotedApostrophe("/tmp/it's.deb")).toBe(`'/tmp/it'"'"'s.deb'`) }) - it('escapes every single quote in a value', async () => { - const { quoteForPosixShell } = await loadCommandModule() - expect(quoteForPosixShell("'; rm -rf /; '")).toBe(`''"'"'; rm -rf /; '"'"''`) + it('escapes every single quote in a value', () => { + expect(quotePosixShellDoubleQuotedApostrophe("'; rm -rf /; '")).toBe(`''"'"'; rm -rf /; '"'"''`) }) - it('quotes an empty value', async () => { - const { quoteForPosixShell } = await loadCommandModule() - expect(quoteForPosixShell('')).toBe("''") + it('quotes an empty value', () => { + expect(quotePosixShellDoubleQuotedApostrophe('')).toBe("''") }) }) diff --git a/src/main/linux-package-install-command.ts b/src/main/linux-package-install-command.ts index 728ffcd1d92..b9c9191b2de 100644 --- a/src/main/linux-package-install-command.ts +++ b/src/main/linux-package-install-command.ts @@ -1,3 +1,4 @@ +import { quotePosixShellDoubleQuotedApostrophe } from '../shared/shell-argument-quoting' import { statSync } from 'node:fs' import path from 'node:path' import type { LinuxRootPackageType } from '../shared/update-status-types' @@ -26,11 +27,6 @@ export type LinuxPackageInstallCommandResult = | { ok: true; command: string } | { ok: false; reason: 'no-sudo' | 'no-package-manager' | 'invalid-package-path' } -/** POSIX single-quoting: the only metacharacter left is `'`, closed and re-opened around a literal. */ -export function quoteForPosixShell(value: string): string { - return `'${value.split("'").join(`'"'"'`)}'` -} - /** * Resolves an executable strictly from the trusted system directories. A symlink inside those * directories is fine — its target is what `statSync` checks — but nothing outside them is consulted @@ -86,7 +82,12 @@ export function buildLinuxPackageInstallCommand( continue } // No -y/--noconfirm: the user must see and confirm the privileged transaction. - const tokens = [sudoPath, managerPath, ...candidate.args, quoteForPosixShell(packagePath)] + const tokens = [ + sudoPath, + managerPath, + ...candidate.args, + quotePosixShellDoubleQuotedApostrophe(packagePath) + ] return { ok: true, command: tokens.join(' ') } } return { ok: false, reason: 'no-package-manager' } diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index 56730c6be9e..fc5a27f52f0 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -27,9 +27,9 @@ import { execCommandInWslOrThrow, execLocalPreflightCommandOrThrow, findRunnableLocalCommand, - isCommandAvailable, - shellQuote + isCommandAvailable } from '../ipc/preflight-command-exec' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { detectRemoteWindowsTerminalCapabilities, type RemoteWindowsTerminalCapabilities @@ -220,7 +220,7 @@ export async function refreshShellPathAndDetectAgents( async function isGhAuthenticated(probe: CommandRuntime): Promise { try { await (probe.wslTarget - ? execCommandInWslOrThrow(probe.wslTarget, `${shellQuote('gh')} auth status`) + ? execCommandInWslOrThrow(probe.wslTarget, `${quotePosixShell('gh')} auth status`) : execLocalPreflightCommandOrThrow(probe.binary ?? 'gh', ['auth', 'status'])) // Why: for plain-text `gh auth status`, exit 0 means gh did not detect any // authentication issues for the checked hosts/accounts. @@ -241,7 +241,7 @@ async function isGhAuthenticated(probe: CommandRuntime): Promise { async function isGlabAuthenticated(probe: CommandRuntime): Promise { try { await (probe.wslTarget - ? execCommandInWslOrThrow(probe.wslTarget, `${shellQuote('glab')} auth status`) + ? execCommandInWslOrThrow(probe.wslTarget, `${quotePosixShell('glab')} auth status`) : execLocalPreflightCommandOrThrow(probe.binary ?? 'glab', ['auth', 'status'])) return true } catch (error) { diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 8892a275cf6..7a5649dbc73 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -33,7 +33,7 @@ import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' -import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' const RPC_TIMEOUT_MS = 10_000 @@ -53,9 +53,9 @@ function buildWslCodexCommand( } const setupCommands = [ ...getHiddenRateLimitWslCwdSetupCommands(), - `export CODEX_HOME=${quoteHiddenRateLimitShellValue(wslInfo.linuxPath)}` + `export CODEX_HOME=${quotePosixShell(wslInfo.linuxPath)}` ].join(' && ') - const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')} <&3 >&4 3<&- 4>&-` + const execSuffix = `${args.map(quotePosixShell).join(' ')} <&3 >&4 3<&- 4>&-` const loginShellCommand = buildWslLoginShellCommand( [setupCommands, `exec codex ${execSuffix}`].join(' && ') ) diff --git a/src/main/rate-limits/hidden-rate-limit-shell.ts b/src/main/rate-limits/hidden-rate-limit-shell.ts deleted file mode 100644 index 261f4519926..00000000000 --- a/src/main/rate-limits/hidden-rate-limit-shell.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function quoteHiddenRateLimitShellValue(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} diff --git a/src/main/runtime/orchestration/setup-completion-signal.ts b/src/main/runtime/orchestration/setup-completion-signal.ts index 15c74d2294e..255423dd534 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.ts @@ -1,3 +1,4 @@ +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { resolveSetupRunnerCommand, type SetupRunnerCommandPlatform, @@ -42,7 +43,7 @@ export function buildObservedSetupCommand( `printf '\\n${completionPrefix(completionToken)}%s\\n' "$status"`, 'exit "$status"' ].join('; ') - return { command: `bash -lc ${quotePosixArg(script)}` } + return { command: `bash -lc ${quotePosixShell(script)}` } } export function createSetupCompletionScanner( @@ -78,7 +79,3 @@ export function createSetupCompletionScanner( function completionPrefix(completionToken: string): string { return `${SETUP_COMPLETION_PREFIX}${completionToken}:` } - -function quotePosixArg(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} diff --git a/src/main/ssh/ssh-remote-directory-browse.ts b/src/main/ssh/ssh-remote-directory-browse.ts index ba3748ca8f5..8abc6997334 100644 --- a/src/main/ssh/ssh-remote-directory-browse.ts +++ b/src/main/ssh/ssh-remote-directory-browse.ts @@ -1,3 +1,4 @@ +import { quotePosixShell } from '../../shared/shell-argument-quoting' import type { SshConnectionManager } from './connection/ssh-connection-manager' import type { SshExecOptions } from './connection/ssh-exec-options' import { powerShellCommand } from './ssh-remote-powershell' @@ -266,13 +267,9 @@ function shellEscape(s: string): string { return '"$HOME"' } if (s.startsWith('~/')) { - return `"$HOME"/${shellEscapeRaw(s.slice(2))}` + return `"$HOME"/${quotePosixShell(s.slice(2))}` } - return shellEscapeRaw(s) -} - -function shellEscapeRaw(s: string): string { - return `'${s.replace(/'/g, "'\\''")}'` + return quotePosixShell(s) } function powerShellPathExpression(s: string): string { diff --git a/src/renderer/src/components/editor/ipynb-kernel-setup-commands.ts b/src/renderer/src/components/editor/ipynb-kernel-setup-commands.ts index dd15a8586c5..39500c3dcba 100644 --- a/src/renderer/src/components/editor/ipynb-kernel-setup-commands.ts +++ b/src/renderer/src/components/editor/ipynb-kernel-setup-commands.ts @@ -1,10 +1,11 @@ +import { quotePosixShell } from '../../../../shared/shell-argument-quoting' import { getRendererAppPlatform } from '@/lib/renderer-app-platform' import { venvInterpreterSegments } from '../../../../shared/notebook-venv-location' import { quotePowerShellLiteral } from '../../../../shared/powershell-native-argument' /** Quotes a path for a shell; single quotes are literal in POSIX shells and PowerShell. */ function shellQuote(path: string, windows: boolean): string { - return windows ? quotePowerShellLiteral(path) : `'${path.replaceAll("'", "'\\''")}'` + return windows ? quotePowerShellLiteral(path) : quotePosixShell(path) } /** PowerShell runs a quoted program path only through `&`. */ diff --git a/src/renderer/src/components/terminal-pane/pane-helpers.ts b/src/renderer/src/components/terminal-pane/pane-helpers.ts index 0de7278022c..9104b091e59 100644 --- a/src/renderer/src/components/terminal-pane/pane-helpers.ts +++ b/src/renderer/src/components/terminal-pane/pane-helpers.ts @@ -1,3 +1,4 @@ +import { quotePosixShellUnlessPlain } from '../../../../shared/shell-argument-quoting' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { focusPanePreservingOverlays } from '@/lib/pane-manager/pane-overlay-focus' @@ -75,9 +76,5 @@ export function shellEscapePath(path: string, targetShell: 'posix' | 'windows'): return /^[a-zA-Z0-9_./@:\\-]+$/.test(path) ? path : `"${path}"` } - if (/^[a-zA-Z0-9_./@:-]+$/.test(path)) { - return path - } - - return `'${path.replace(/'/g, "'\\''")}'` + return quotePosixShellUnlessPlain(path) } diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index 2c3dcdf6f26..7a159ef554f 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -1,6 +1,7 @@ // Resume-command construction for Agent Session History rows: turns a scanned // session into the shell line that re-enters it, quoted for the target platform // and (when known) the live tab's shell. +import { quoteCmdDoubleQuoted } from './shell-argument-quoting' import { isAntigravityReferenceSession, antigravityTranscriptReferencePrompt @@ -42,7 +43,7 @@ export function buildAiVaultResumeCommand(args: { : sessionId const sessionArg = shell === 'cmd' - ? quoteWindowsCmdArg(resumeTarget) + ? quoteCmdDoubleQuoted(resumeTarget) : shell ? quoteStartupArg(resumeTarget, shell) : quoteShellArg(resumeTarget, platform) @@ -118,15 +119,15 @@ export function buildAiVaultResumeShellCommand(args: { if (platform === 'win32' && shell === 'cmd') { // Why: an interactive cmd splits the doubled quotes required by a nested // `cmd /s /c` wrapper, so queued commands must use direct cmd syntax. - return `${clearPrefix}${cwd ? `cd /d ${quoteWindowsCmdArg(cwd)} && ${resumeCommand}` : resumeCommand}` + return `${clearPrefix}${cwd ? `cd /d ${quoteCmdDoubleQuoted(cwd)} && ${resumeCommand}` : resumeCommand}` } if (!cwd) { return `${clearPrefix}${resumeCommand}` } if (platform === 'win32') { - const inner = `${clearPrefix}cd /d ${quoteWindowsCmdArg(cwd)} && ${resumeCommand}` - return `cmd /d /s /c ${quoteWindowsCmdArg(inner)}` + const inner = `${clearPrefix}cd /d ${quoteCmdDoubleQuoted(cwd)} && ${resumeCommand}` + return `cmd /d /s /c ${quoteCmdDoubleQuoted(inner)}` } return `cd ${quoteResumeArg(cwd, platform, shell)} && ${resumeCommand}` @@ -263,7 +264,7 @@ function codexHomeEnvPrefix( return '' } if (platform === 'win32') { - return `set ${quoteWindowsCmdArg(`CODEX_HOME=${codexHome}`)} && ` + return `set ${quoteCmdDoubleQuoted(`CODEX_HOME=${codexHome}`)} && ` } // fish accepts the `NAME=value cmd` prefix (3.1+), but not sh's quoting. return `CODEX_HOME=${quoteResumeArg(codexHome, platform, shell)} ` @@ -284,9 +285,5 @@ function quoteResumeArg( * `quoteStartupArg` keeps one spelling regardless of whether a caller happened * to pass a shell. */ function quoteShellArg(value: string, platform: NodeJS.Platform): string { - return platform === 'win32' ? quoteWindowsCmdArg(value) : quoteStartupArg(value, 'posix') -} - -function quoteWindowsCmdArg(value: string): string { - return `"${value.replace(/"/g, '""')}"` + return platform === 'win32' ? quoteCmdDoubleQuoted(value) : quoteStartupArg(value, 'posix') } diff --git a/src/shared/ephemeral-vm-recipe-process.ts b/src/shared/ephemeral-vm-recipe-process.ts index 1557de74ca4..ed84c4d9932 100644 --- a/src/shared/ephemeral-vm-recipe-process.ts +++ b/src/shared/ephemeral-vm-recipe-process.ts @@ -1,3 +1,4 @@ +import { quoteCmdDoubleQuoted, quotePosixShell } from './shell-argument-quoting' import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { StringDecoder } from 'node:string_decoder' import type { EphemeralVmRecipeContext } from './ephemeral-vm-recipe-runner' @@ -20,9 +21,9 @@ export function quoteShellToken(value: string): string { // Inside cmd.exe double quotes, `^` is literal; an embedded `"` is escaped // by doubling it. This token is only displayed for manual cleanup, so it // must be valid when pasted into cmd.exe. - return `"${value.replace(/"/g, '""')}"` + return quoteCmdDoubleQuoted(value) } - return `'${value.replace(/'/g, `'\\''`)}'` + return quotePosixShell(value) } export async function runRecipeCommand(args: { diff --git a/src/shared/posix-command-path-lookup.ts b/src/shared/posix-command-path-lookup.ts index ea09ca83e2f..5e5c6c598fd 100644 --- a/src/shared/posix-command-path-lookup.ts +++ b/src/shared/posix-command-path-lookup.ts @@ -1,3 +1,5 @@ +import { quotePosixShell } from './shell-argument-quoting' + export type PosixCommandPathLookupTarget = | { kind: 'literal'; value: string } | { kind: 'shell-variable'; name: string } @@ -100,14 +102,10 @@ export function buildPosixCommandPathLookupScript( function buildCommandAssignment(target: PosixCommandPathLookupTarget): string { if (target.kind === 'literal') { - return shellQuote(target.value) + return quotePosixShell(target.value) } if (!SHELL_VARIABLE_NAME_PATTERN.test(target.name)) { throw new Error(`Invalid shell variable name: ${target.name}`) } return `\${${target.name}-}` } - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 31c68e2da76..c5d1a7ba98c 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -1,3 +1,4 @@ +import { quotePosixShellUnlessPlainNoAt } from './shell-argument-quoting' import { encodePowerShellCommand } from './powershell-command-encoding' import { getPosixCodexShellLaunchPreflight } from './codex-shell-function' import { getPosixClaudeShellFunction } from './claude-shell-function' @@ -89,9 +90,9 @@ export function createSequencedSetupAgentCommands(args: { } function buildPosixSetupCommand(setupCommand: string, markerPath: string, nonce: string): string { - const marker = quotePosixArg(markerPath) - const tmp = quotePosixArg(`${markerPath}.tmp`) - const nonceValue = quotePosixArg(nonce) + const marker = quotePosixShellUnlessPlainNoAt(markerPath) + const tmp = quotePosixShellUnlessPlainNoAt(`${markerPath}.tmp`) + const nonceValue = quotePosixShellUnlessPlainNoAt(nonce) const script = [ `rm -f ${marker} ${tmp} 2>/dev/null`, @@ -102,7 +103,7 @@ function buildPosixSetupCommand(setupCommand: string, markerPath: string, nonce: 'exit "$status"' ].join('; ') - return `bash -lc ${quotePosixArg(script)}` + return `bash -lc ${quotePosixShellUnlessPlainNoAt(script)}` } function buildPosixStartupScript( @@ -111,9 +112,9 @@ function buildPosixStartupScript( nonce: string, waitTimeoutSeconds: number ): string { - const marker = quotePosixArg(markerPath) - const tmp = quotePosixArg(`${markerPath}.tmp`) - const nonceValue = quotePosixArg(nonce) + const marker = quotePosixShellUnlessPlainNoAt(markerPath) + const tmp = quotePosixShellUnlessPlainNoAt(`${markerPath}.tmp`) + const nonceValue = quotePosixShellUnlessPlainNoAt(nonce) const timeout = Math.max(1, Math.floor(waitTimeoutSeconds)) const startupSuccessCommand = buildPosixStartupSuccessCommand(startupCommand) // Why: the PTY launch path feeds this command through an interactive shell, @@ -131,7 +132,7 @@ function buildPosixStartupScript( // "Waiting for setup..." as the pane's last line forever. // Why here and not first: setup may be what puts codex on PATH, and this // `bash -lc` never reads Orca's shell wrapper, so it defines the function itself. - `if [ "$status" = "0" ]; then echo ${quotePosixArg(SETUP_COMPLETE_MESSAGE)} >&2;\n${getPosixCodexShellLaunchPreflight() + getPosixClaudeShellFunction()}if [ -n "\${${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}:-}" ]; then eval "\$${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}"; exit "$?"; else ${startupSuccessCommand}; fi; fi;`, + `if [ "$status" = "0" ]; then echo ${quotePosixShellUnlessPlainNoAt(SETUP_COMPLETE_MESSAGE)} >&2;\n${getPosixCodexShellLaunchPreflight() + getPosixClaudeShellFunction()}if [ -n "\${${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}:-}" ]; then eval "\$${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}"; exit "$?"; else ${startupSuccessCommand}; fi; fi;`, 'echo "Setup failed; skipping agent startup." >&2;', 'exit "${status:-1}";', 'fi;', @@ -152,7 +153,7 @@ function buildPosixStartupSuccessCommand(startupCommand: string): string { hasUnquotedPosixCommandSeparator(startupCommand) || hasLeadingPosixEnvAssignment(startupCommand) ) { - return `eval ${quotePosixArg(startupCommand)}; exit "$?"` + return `eval ${quotePosixShellUnlessPlainNoAt(startupCommand)}; exit "$?"` } return `exec ${startupCommand}` } @@ -297,10 +298,3 @@ function buildWindowsStartupCommand( function encodePowerShellInvocation(script: string): string { return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}` } - -function quotePosixArg(value: string): string { - if (/^[A-Za-z0-9_./:-]+$/.test(value)) { - return value - } - return `'${value.replace(/'/g, `'\\''`)}'` -} diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts index 7b1dee560fd..080b2105754 100644 --- a/src/shared/setup-runner-command.ts +++ b/src/shared/setup-runner-command.ts @@ -1,3 +1,4 @@ +import { quoteCmdDoubleQuoted, quotePosixShellUnlessPlainNoAt } from './shell-argument-quoting' import { isWindowsAbsolutePathLike } from './cross-platform-path' import { buildWindowsCmdRunnerDelayedLaunchCommand, @@ -48,14 +49,14 @@ export function resolveSetupRunnerCommand( if (isWslUncPath(runnerScriptPath)) { const linuxPath = wslUncToLinuxPath(runnerScriptPath) return { - command: `bash ${quotePosixArg(linuxPath)}`, + command: `bash ${quotePosixShellUnlessPlainNoAt(linuxPath)}`, runnerScriptPathForShell: linuxPath, shell: 'posix' } } if (runnerScriptPath.startsWith('/') && !isWindowsAbsolutePathLike(runnerScriptPath)) { return { - command: `bash ${quotePosixArg(runnerScriptPath)}`, + command: `bash ${quotePosixShellUnlessPlainNoAt(runnerScriptPath)}`, runnerScriptPathForShell: runnerScriptPath, shell: 'posix' } @@ -68,7 +69,7 @@ export function resolveSetupRunnerCommand( if (isWslExecutable(shell?.executable)) { const wslPath = nativeWindowsPathToWslShellPath(runnerScriptPath) return { - command: `bash ${quotePosixArg(wslPath)}`, + command: `bash ${quotePosixShellUnlessPlainNoAt(wslPath)}`, runnerScriptPathForShell: wslPath, shell: 'posix' } @@ -76,7 +77,7 @@ export function resolveSetupRunnerCommand( // Why: queued setup launches can outlive the process that generated them, so convert native paths before handing off to POSIX shells. const posixPath = nativeWindowsPathToPosixShellPath(runnerScriptPath) return { - command: `bash ${quotePosixArg(posixPath)}`, + command: `bash ${quotePosixShellUnlessPlainNoAt(posixPath)}`, runnerScriptPathForShell: posixPath, shell: 'posix' } @@ -90,14 +91,14 @@ export function resolveSetupRunnerCommand( command: shell?.family === 'posix' || windowsRunnerPathNeedsCmdGuard(runnerScriptPath) ? buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath) - : `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, + : `cmd.exe /c ${quoteCmdDoubleQuoted(runnerScriptPath)}`, runnerScriptPathForShell: runnerScriptPath, shell: 'windows' } } return { - command: `bash ${quotePosixArg(runnerScriptPath)}`, + command: `bash ${quotePosixShellUnlessPlainNoAt(runnerScriptPath)}`, runnerScriptPathForShell: runnerScriptPath, shell: 'posix' } @@ -119,18 +120,6 @@ export function wslUncToLinuxPath(windowsPath: string): string { return match?.[2] || '/' } -function quotePosixArg(value: string): string { - if (/^[A-Za-z0-9_./:-]+$/.test(value)) { - return value - } - - return `'${value.replace(/'/g, `'\\''`)}'` -} - -function quoteWindowsArg(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - export function nativeWindowsPathToPosixShellPath(value: string): string { const driveMatch = value.match(/^([A-Za-z]):[\\/](.*)$/) if (driveMatch) { diff --git a/src/shared/shell-argument-quoting.test.ts b/src/shared/shell-argument-quoting.test.ts index f23dde4c7c6..44898d0a75b 100644 --- a/src/shared/shell-argument-quoting.test.ts +++ b/src/shared/shell-argument-quoting.test.ts @@ -1,7 +1,110 @@ import { describe, expect, it } from 'vitest' -import { quotePosixShell } from './shell-argument-quoting' +import { + quoteCmdDoubleQuoted, + quotePosixShell, + quotePosixShellDoubleQuotedApostrophe, + quotePosixShellUnlessPlain, + quotePosixShellUnlessPlainNoAt +} from './shell-argument-quoting' -// ── quotePosixShell ────────────────────────────────────────────────────── +// Verbatim bodies of the copies this module replaced, keyed by where each one lived. Every +// former copy must produce byte-identical output to the function that now serves its callers. +const sq = (value: string): string => `'${value.replace(/'/g, "'\\''")}'` +const sqAll = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` +const plainAt = (value: string): string => (/^[a-zA-Z0-9_./@:-]+$/.test(value) ? value : sq(value)) +const plainNoAt = (value: string): string => (/^[A-Za-z0-9_./:-]+$/.test(value) ? value : sq(value)) +const dq = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'` + +const FORMER_COPIES: [string, (value: string) => string, (value: string) => string][] = [ + ['main/ssh/ssh-connection-utils shellEscape', sq, quotePosixShell], + ['shared/wsl-login-shell-command quotePosixShell', sq, quotePosixShell], + ['main/agent-hooks/posix-hook-command quotePosixShellString', sqAll, quotePosixShell], + ['main/codex/codex-hook-definition inline', sqAll, quotePosixShell], + ['main/ipc/preflight-command-exec shellQuote', sq, quotePosixShell], + ['main/ipc/preflight-wsl-agent-detection shellQuote', sq, quotePosixShell], + ['main/rate-limits/hidden-rate-limit-shell', sq, quotePosixShell], + ['main/runtime/orchestration/setup-completion-signal quotePosixArg', sq, quotePosixShell], + ['main/ssh/ssh-remote-directory-browse shellEscapeRaw', sq, quotePosixShell], + ['shared/posix-command-path-lookup shellQuote', sq, quotePosixShell], + ['renderer ipynb-kernel-setup-commands POSIX branch', sqAll, quotePosixShell], + ['shared/ephemeral-vm-recipe-process POSIX branch', sq, quotePosixShell], + ['cli/shell-command-quote POSIX tail', sqAll, quotePosixShell], + ['main/external-editor-launch escapePosixPathForShell', plainAt, quotePosixShellUnlessPlain], + ['renderer pane-helpers shellEscapePath POSIX', plainAt, quotePosixShellUnlessPlain], + ['shared/setup-agent-sequencing quotePosixArg', plainNoAt, quotePosixShellUnlessPlainNoAt], + ['shared/setup-runner-command quotePosixArg', plainNoAt, quotePosixShellUnlessPlainNoAt], + ['main/cli/cli-install-path-format quoteShell', dq, quotePosixShellDoubleQuotedApostrophe], + ['main/cli/wsl-cli-scripts quoteShell', dq, quotePosixShellDoubleQuotedApostrophe], + ['main/antigravity/antigravity-browser-helper quote', dq, quotePosixShellDoubleQuotedApostrophe], + [ + 'cli/orchestration-mutation-recovery shellQuote tail', + dq, + quotePosixShellDoubleQuotedApostrophe + ], + [ + 'main/linux-package-install-command quoteForPosixShell', + (value) => `'${value.split("'").join(`'"'"'`)}'`, + quotePosixShellDoubleQuotedApostrophe + ], + [ + 'shared/ephemeral-vm-recipe-process win32 branch', + (value) => `"${value.replace(/"/g, '""')}"`, + quoteCmdDoubleQuoted + ], + [ + 'shared/setup-runner-command quoteWindowsArg', + (value) => `"${value.replace(/"/g, '""')}"`, + quoteCmdDoubleQuoted + ], + [ + 'shared/ai-vault-resume-command quoteWindowsCmdArg', + (value) => `"${value.replace(/"/g, '""')}"`, + quoteCmdDoubleQuoted + ] +] + +const HOSTILE_INPUTS = [ + '', + "'", + "''", + "it's", + "'; rm -rf /; '", + 'plain', + 'a@b', + 'user@host:/path', + '/tmp/Orca Setup.deb', + '$HOME', + '${PATH}', + '`id`', + '$(id)', + '"quoted"', + '\\', + 'trailing\\', + 'C:\\Program Files\\Orca\\orca.exe', + '%PATH%', + '!x!', + '^&|<>()', + '~', + '~/dir', + '-n', + '--flag=value', + 'a b\tc', + 'line1\nline2', + 'crlf\r\n', + 'nul\u0000byte', + 'é☃😀', + '*?[a]{b,c}', + '#comment', + 'a;b&&c||d' +] + +describe('shell-argument-quoting matches every former copy byte for byte', () => { + describe.each(FORMER_COPIES)('%s', (_site, former, current) => { + it.each(HOSTILE_INPUTS)('%j', (input) => { + expect(current(input)).toBe(former(input)) + }) + }) +}) describe('quotePosixShell', () => { it('wraps string in single quotes', () => { @@ -20,3 +123,19 @@ describe('quotePosixShell', () => { expect(quotePosixShell('foo bar; rm -rf /')).toBe("'foo bar; rm -rf /'") }) }) + +describe('the spellings stay distinct', () => { + it('spells an apostrophe two different ways', () => { + expect(quotePosixShell("it's")).toBe(`'it'\\''s'`) + expect(quotePosixShellDoubleQuotedApostrophe("it's")).toBe(`'it'"'"'s'`) + }) + + it('leaves @ bare only in the plain-with-@ variant', () => { + expect(quotePosixShellUnlessPlain('a@b')).toBe('a@b') + expect(quotePosixShellUnlessPlainNoAt('a@b')).toBe("'a@b'") + }) + + it('doubles embedded quotes for cmd.exe', () => { + expect(quoteCmdDoubleQuoted('say "hi"')).toBe('"say ""hi"""') + }) +}) diff --git a/src/shared/shell-argument-quoting.ts b/src/shared/shell-argument-quoting.ts index 327fa6ef2f5..c34d8c45ff9 100644 --- a/src/shared/shell-argument-quoting.ts +++ b/src/shared/shell-argument-quoting.ts @@ -1,4 +1,31 @@ +// One home for shell-argument quoting. Each spelling below is a separate contract: callers embed, +// compare or parse back these exact bytes, so variants stay separate functions, never flags. +// PowerShell quoting lives in powershell-native-argument.ts; CommandLineToArgvW in @orca/process-host. + /** POSIX sh single-quoting: each `'` closes the quote, is escaped, and reopens it (`'\''`). */ export function quotePosixShell(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } + +/** As `quotePosixShell`, but a non-empty word of only `A-Za-z0-9_./@:-` stays bare. */ +export function quotePosixShellUnlessPlain(value: string): string { + return /^[a-zA-Z0-9_./@:-]+$/.test(value) ? value : quotePosixShell(value) +} + +/** As `quotePosixShellUnlessPlain`, except `@` also forces quoting. */ +export function quotePosixShellUnlessPlainNoAt(value: string): string { + return /^[A-Za-z0-9_./:-]+$/.test(value) ? value : quotePosixShell(value) +} + +/** + * POSIX sh single-quoting that spells each `'` as `'"'"'`. Installed CLI wrappers are read back + * in this spelling (legacy-appimage-cli-wrapper.ts, wsl-cli-scripts.ts), so it must not change. + */ +export function quotePosixShellDoubleQuotedApostrophe(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'` +} + +/** cmd.exe double-quoting with `"` doubled. Leaves `%`, `^` and `!` as they are. */ +export function quoteCmdDoubleQuoted(value: string): string { + return `"${value.replace(/"/g, '""')}"` +}