diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index 9a531bf45bf..2c0edf63e0b 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -28,7 +28,9 @@ on: - 'config/patches/node-pty*' # The deploy runs over this SSH transport. - 'src/main/ssh/connection/**' - - 'src/main/ssh/ssh-connection-utils.ts' + - 'src/main/ssh/ssh-reconnect-error-classification.ts' + - 'src/main/ssh/ssh-posix-command-wrapper.ts' + - 'src/shared/shell-argument-quoting.ts' - '!src/**/*.test.ts' - 'src/main/ssh/hostile-host-test-support/ssh-hostile-hosts.docker.test.ts' - '.github/workflows/ssh-hostile-hosts.yml' diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 0bd5172460c..82fcb984f55 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -44,7 +44,9 @@ on: - 'src/main/ipc/ssh/ssh-host-server-*.ts' # The deploy runs over this SSH transport. - 'src/main/ssh/connection/**' - - 'src/main/ssh/ssh-connection-utils.ts' + - 'src/main/ssh/ssh-reconnect-error-classification.ts' + - 'src/main/ssh/ssh-posix-command-wrapper.ts' + - 'src/shared/shell-argument-quoting.ts' - '!src/**/*.test.ts' - 'src/main/managed-server/host-state/orcad-windows-host-lane.test.ts' - 'tests/e2e/ssh-orcad-auto-convert.spec.ts' diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index eea6662952b..a5115154bdc 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -5734,7 +5734,7 @@ "src/main/ssh/connection/ssh-connection-store-orcad-ownership.test.ts": 293, "src/main/ssh/connection/ssh-connection-store.test.ts": 337, "src/main/ssh/connection/ssh-connection-system-transport.test.ts": 206, - "src/main/ssh/ssh-connection-utils.test.ts": 244, + "src/main/ssh/connection/ssh-connect-config.test.ts": 244, "src/main/ssh/connection/ssh-connection.test.ts": 1226, "src/main/ssh/ssh-control-socket.test.ts": 50, "src/main/ssh/ssh-credential-decline.test.ts": 252, diff --git a/config/scripts/orcad-e2e-change-scope.test.mjs b/config/scripts/orcad-e2e-change-scope.test.mjs index c2f1849ff2f..3bc4963b7b9 100644 --- a/config/scripts/orcad-e2e-change-scope.test.mjs +++ b/config/scripts/orcad-e2e-change-scope.test.mjs @@ -163,11 +163,21 @@ it('re-includes the real WSL distro root after excluding unit tests', () => { describe.each([ [ 'hostileHosts', - ['src/main/ssh/connection/ssh-connection.ts', 'src/main/ssh/ssh-connection-utils.ts'] + [ + 'src/main/ssh/connection/ssh-connection.ts', + 'src/main/ssh/connection/ssh-connect-config.ts', + 'src/main/ssh/ssh-posix-command-wrapper.ts', + 'src/shared/shell-argument-quoting.ts' + ] ], [ 'windowsHosts', - ['src/main/ssh/connection/ssh-connection.ts', 'src/main/ssh/ssh-connection-utils.ts'] + [ + 'src/main/ssh/connection/ssh-connection.ts', + 'src/main/ssh/connection/ssh-connect-config.ts', + 'src/main/ssh/ssh-posix-command-wrapper.ts', + 'src/shared/shell-argument-quoting.ts' + ] ], ['wsl', ['src/main/orcad/orcad-wsl-host-login-environment.ts']] ])('%s remote transport routing', (lane, sources) => { diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 321e5543067..65fdeb85af9 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -288,7 +288,7 @@ export const PR_E2E_SOURCE_ROUTES = [ ], matches: (file) => isProductSource(file) && - /^(?:config\/scripts\/(?:verify-wsl-e2e-participation|verify-playwright-participation)\.mjs$|src\/main\/(?:wsl[/-]|pty\/.*wsl|providers\/wsl)|src\/shared\/(?:wsl-|windows-terminal-shell)|src\/renderer\/src\/.*(?:terminal-paste|pty-paste)|tests\/e2e\/(?:golden-tab-bar-agent-launch\.spec|terminal-windows-shell-paste-ownership\.spec|terminal-inline-images-windows\.spec|helpers\/(?:wsl-golden-stub-agent|golden-stub-agent))|\.github\/(?:actions\/setup-wsl-test-runtime\/|workflows\/windows-wsl-e2e\.yml))/.test( + /^(?:config\/scripts\/(?:verify-wsl-e2e-participation|verify-playwright-participation)\.mjs$|src\/main\/(?:wsl[/-]|pty\/.*wsl|providers\/wsl)|src\/shared\/(?:wsl-|windows-terminal-shell|shell-argument-quoting)|src\/renderer\/src\/.*(?:terminal-paste|pty-paste)|tests\/e2e\/(?:golden-tab-bar-agent-launch\.spec|terminal-windows-shell-paste-ownership\.spec|terminal-inline-images-windows\.spec|helpers\/(?:wsl-golden-stub-agent|golden-stub-agent))|\.github\/(?:actions\/setup-wsl-test-runtime\/|workflows\/windows-wsl-e2e\.yml))/.test( file ) }, diff --git a/config/scripts/wsl-e2e-lane-contract.test.mjs b/config/scripts/wsl-e2e-lane-contract.test.mjs index f10f8284b3f..df9984661eb 100644 --- a/config/scripts/wsl-e2e-lane-contract.test.mjs +++ b/config/scripts/wsl-e2e-lane-contract.test.mjs @@ -13,6 +13,7 @@ describe('real WSL terminal lane', () => { 'src/main/wsl/wsl-runner.ts', 'src/main/pty/wsl-orca-env.ts', 'src/shared/wsl-login-shell-command.ts', + 'src/shared/shell-argument-quoting.ts', 'src/shared/windows-terminal-shell.ts', 'tests/e2e/helpers/wsl-golden-stub-agent.ts', 'tests/e2e/golden-tab-bar-agent-launch.spec.ts', diff --git a/config/scripts/wsl-git-shell-benchmark.mjs b/config/scripts/wsl-git-shell-benchmark.mjs index 2ae1f09876d..857229d4441 100644 --- a/config/scripts/wsl-git-shell-benchmark.mjs +++ b/config/scripts/wsl-git-shell-benchmark.mjs @@ -128,9 +128,10 @@ async function main() { assertRepoPath(options.mountedRepo, '/mnt/') const distro = await resolveDistro(options.distro) const jiti = createJiti(import.meta.url) - const { buildWslLoginShellCommand, quotePosixShell } = await jiti.import( + const { buildWslLoginShellCommand } = await jiti.import( '../../src/shared/wsl-login-shell-command.ts' ) + const { quotePosixShell } = await jiti.import('../../src/shared/shell-argument-quoting.ts') const loginProbe = buildWslLoginShellCommand( `printf '\\n__ORCA_PATH__%s\\n__ORCA_GIT__%s\\n__ORCA_HOME__%s\\n' "$PATH" "$(command -v git)" "$HOME"` diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 85442fa9703..2d64a29aade 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -7,11 +7,8 @@ import { printResult } from '../format' import { RuntimeClientError } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' -import { - buildWslExecArgs, - buildWslLoginShellCommand, - quotePosixShell -} from '../../shared/wsl-login-shell-command' +import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { getVersionManagerBinPaths, resolveCliCommand, diff --git a/src/main/automations/precheck-runner.ts b/src/main/automations/precheck-runner.ts index 5437f9fa5e4..3ab6ab224ec 100644 --- a/src/main/automations/precheck-runner.ts +++ b/src/main/automations/precheck-runner.ts @@ -3,7 +3,7 @@ import type { ClientChannel } from 'ssh2' import type { AutomationPrecheck, AutomationPrecheckResult } from '../../shared/automations-types' import { MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS } from '../../shared/automation-precheck' import { getSshConnectionManager } from '../ssh/targets/ssh-target-registry' -import { shellEscape } from '../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { admitSelfInitiatedTreeKill } from '../own-chromium-tree-kill-guard' import { errorMessage } from '../../shared/error-message' @@ -263,7 +263,7 @@ async function runSshPrecheck( return failedPrecheckResult(precheck, startedAt, 'SSH target is not connected.') } try { - const remoteCommand = `cd ${shellEscape(target.cwd)} && ${precheck.command}` + const remoteCommand = `cd ${quotePosixShell(target.cwd)} && ${precheck.command}` const channel = await connection.exec(remoteCommand) return await runSshChannelPrecheck({ precheck, channel, startedAt }) } catch (error) { diff --git a/src/main/claude-accounts/claude-command-process.ts b/src/main/claude-accounts/claude-command-process.ts index 21e75a3434b..2c621918c9e 100644 --- a/src/main/claude-accounts/claude-command-process.ts +++ b/src/main/claude-accounts/claude-command-process.ts @@ -4,11 +4,8 @@ import { buildWindowsHostInteractiveLoginSpawn, type WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' -import { - buildWslExecArgs, - buildWslLoginShellCommand, - quotePosixShell -} from '../../shared/wsl-login-shell-command' +import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { resolveClaudeCommand } from '../codex-cli/command' import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' import { buildWindowsCommandInvocation } from './windows-command-invocation' diff --git a/src/main/codex-accounts/codex-managed-home-lifecycle.ts b/src/main/codex-accounts/codex-managed-home-lifecycle.ts index c4790869a99..f1c67628b17 100644 --- a/src/main/codex-accounts/codex-managed-home-lifecycle.ts +++ b/src/main/codex-accounts/codex-managed-home-lifecycle.ts @@ -5,7 +5,7 @@ import { WINDOWS_RM_MAX_RETRIES, WINDOWS_RM_RETRY_DELAY_MS } from '../../shared/windows-transient-lock-removal' -import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { runWslProcess } from '../wsl/wsl-runner' diff --git a/src/main/codex-accounts/codex-managed-home-path.ts b/src/main/codex-accounts/codex-managed-home-path.ts index 37df7d27b17..0d98dd9ccb1 100644 --- a/src/main/codex-accounts/codex-managed-home-path.ts +++ b/src/main/codex-accounts/codex-managed-home-path.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { parseWslUncPath } from '../../shared/wsl-paths' import type { CodexManagedAccount } from '../../shared/managed-account-types' import { getSystemCodexHomePath } from '../codex/codex-home-paths' diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-shell-commands.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-shell-commands.ts index 3ed3100ee24..06dd18b0d59 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-shell-commands.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-shell-commands.ts @@ -1,4 +1,4 @@ -import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { WSL_CODEX_SESSION_BRIDGE_SCRIPT } from '../codex/wsl-codex-session-bridge-script' export const RETIRED_SESSION_BRIDGE_COMMAND = `bash -c ${quotePosixShell(WSL_CODEX_SESSION_BRIDGE_SCRIPT)} bash "$legacy_home/sessions" "$target_home/sessions" "$session_link_manifest" "$session_stage_root"` diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts index ad1f5e915a7..6dd9bdc834a 100644 --- a/src/main/codex-accounts/wsl-codex-command.ts +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -2,9 +2,9 @@ import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-pa import { buildWslCapturedLoginShellCommand, buildWslExecArgs, - buildWslLoginShellCommand, - quotePosixShell + buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000 export const WSL_CODEX_NOT_FOUND_MESSAGE = 'Codex CLI not found in the WSL login-shell PATH.' diff --git a/src/main/codex/wsl-codex-session-bridge-script.ts b/src/main/codex/wsl-codex-session-bridge-script.ts index 77f52e3dd4e..f2c05836e85 100644 --- a/src/main/codex/wsl-codex-session-bridge-script.ts +++ b/src/main/codex/wsl-codex-session-bridge-script.ts @@ -1,4 +1,4 @@ -import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' export const WSL_SESSION_BRIDGE_TIMEOUT_MS = 30_000 diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts index eebf7f10ebd..9be36f40a89 100644 --- a/src/main/git/command-runner/wsl-command-resolution.ts +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -3,9 +3,9 @@ import { buildWslCapturedLoginShellCommand, buildWslExecArgs, buildWslLoginShellCommand, - quotePosixShell, type WslCapturedLoginShellCommand } from '../../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../../shared/git-output-locale' import type { WslGitReadEnvironment } from '../wsl-git-read-environment' import { diff --git a/src/main/git/git-network-safety-real-git.test.ts b/src/main/git/git-network-safety-real-git.test.ts index 8bea61f7bcf..baf7ad9bae6 100644 --- a/src/main/git/git-network-safety-real-git.test.ts +++ b/src/main/git/git-network-safety-real-git.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' -import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { gitExecFileAsync, gitSpawnAfterWindowsEnvironmentReady } from './runner' let root = '' diff --git a/src/main/git/wsl-process-group-termination.ts b/src/main/git/wsl-process-group-termination.ts index e1b715657bc..2f030877673 100644 --- a/src/main/git/wsl-process-group-termination.ts +++ b/src/main/git/wsl-process-group-termination.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import type { ProcessTerminationBarrier } from '@orca/process-host/process-spec' -import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { runWslProcess } from '../wsl/wsl-runner' const GUEST_TERMINATION_ATTEMPTS = 40 diff --git a/src/main/ipc/ssh/ssh-connection-state-callbacks.ts b/src/main/ipc/ssh/ssh-connection-state-callbacks.ts index 487a9d25c3e..558dfe1e99e 100644 --- a/src/main/ipc/ssh/ssh-connection-state-callbacks.ts +++ b/src/main/ipc/ssh/ssh-connection-state-callbacks.ts @@ -1,4 +1,4 @@ -import type { SshConnectionCallbacks } from '../../ssh/ssh-connection-utils' +import type { SshConnectionCallbacks } from '../../ssh/connection/ssh-connection-callbacks' import type { SshConnectionState } from '../../../shared/ssh-types' import { isSshHostCensusInFlight } from '../../ssh/connection/ssh-connection-attribution' import { diff --git a/src/main/ipc/ssh/ssh-credential-cancel.test.ts b/src/main/ipc/ssh/ssh-credential-cancel.test.ts index 4b08d744374..0ec89526fba 100644 --- a/src/main/ipc/ssh/ssh-credential-cancel.test.ts +++ b/src/main/ipc/ssh/ssh-credential-cancel.test.ts @@ -14,7 +14,7 @@ vi.mock('../../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../../ssh/port-forwarding/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -import type { SshCredentialKind } from '../../ssh/ssh-connection-utils' +import type { SshCredentialKind } from '../../ssh/connection/ssh-connection-callbacks' import type { SshTarget } from '../../../shared/ssh-types' import { isSshCredentialDeclined } from '../../ssh/ssh-credential-decline' import { createSshIpcHarness } from './ssh-ipc-test-harness' diff --git a/src/main/ipc/ssh/ssh-host-server-connect.ts b/src/main/ipc/ssh/ssh-host-server-connect.ts index 8f698ff6ce0..373cf703908 100644 --- a/src/main/ipc/ssh/ssh-host-server-connect.ts +++ b/src/main/ipc/ssh/ssh-host-server-connect.ts @@ -17,7 +17,7 @@ import { import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' import { broadcastSshState, getPublicSshState } from './ssh-renderer-broadcast' -import { isAuthError } from '../../ssh/ssh-connection-utils' +import { isAuthError } from '../../ssh/ssh-reconnect-error-classification' export async function decideHostServer(target: SshTarget): Promise { // Why lazy: the managed-server graph (deploy, migration, tunnel) loads only when a host connects. diff --git a/src/main/ipc/ssh/ssh-passphrase.test.ts b/src/main/ipc/ssh/ssh-passphrase.test.ts index af3f5783faf..cf94bfa657a 100644 --- a/src/main/ipc/ssh/ssh-passphrase.test.ts +++ b/src/main/ipc/ssh/ssh-passphrase.test.ts @@ -1,7 +1,7 @@ import { ipcMain, type BrowserWindow } from 'electron' import { beforeEach, describe, expect, it, vi } from 'vitest' import { registerCredentialHandler, requestCredential } from './ssh-passphrase' -import { SSH_CREDENTIAL_TIMEOUT_MS } from '../../ssh/ssh-connection-utils' +import { SSH_CREDENTIAL_TIMEOUT_MS } from '../../ssh/connection/ssh-connection-timing' import { isSshCredentialDeclined, resetSshCredentialDeclinesForTests diff --git a/src/main/ipc/ssh/ssh-passphrase.ts b/src/main/ipc/ssh/ssh-passphrase.ts index 738d1d5c753..cda53283b3c 100644 --- a/src/main/ipc/ssh/ssh-passphrase.ts +++ b/src/main/ipc/ssh/ssh-passphrase.ts @@ -1,6 +1,7 @@ import { ipcMain, type BrowserWindow } from 'electron' import { randomUUID } from 'node:crypto' -import { SSH_CREDENTIAL_TIMEOUT_MS, type SshCredentialKind } from '../../ssh/ssh-connection-utils' +import { SSH_CREDENTIAL_TIMEOUT_MS } from '../../ssh/connection/ssh-connection-timing' +import type { SshCredentialKind } from '../../ssh/connection/ssh-connection-callbacks' import { recordSshCredentialDeclined } from '../../ssh/ssh-credential-decline' const pendingRequests = new Map< string, diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index 346f9e057fd..65491b3fd26 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -1,6 +1,7 @@ import { runProcess } from '@orca/process-host' import { lstat, readFile } from 'node:fs/promises' -import { buildWslExecArgs, quotePosixShell } from '../shared/wsl-login-shell-command' +import { buildWslExecArgs } from '../shared/wsl-login-shell-command' +import { quotePosixShell } from '../shared/shell-argument-quoting' import { removeHostTree } from './host-tree-removal' import { toLinuxPath } from './wsl' import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' diff --git a/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts b/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts index 4edbef24b43..e51b23107d3 100644 --- a/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts +++ b/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts @@ -15,8 +15,8 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ( ...(await importOriginal()), execCommand: vi.fn() })) -vi.mock('../../ssh/ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` +vi.mock('../../../shared/shell-argument-quoting', () => ({ + quotePosixShell: (s: string) => `'${s}'` })) vi.mock('../install/remote-install-lock', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/main/managed-server/activation/orcad-activation-fence-scope.ts b/src/main/managed-server/activation/orcad-activation-fence-scope.ts index 910b4c6adbd..0513c10c822 100644 --- a/src/main/managed-server/activation/orcad-activation-fence-scope.ts +++ b/src/main/managed-server/activation/orcad-activation-fence-scope.ts @@ -8,7 +8,7 @@ * launching, mutating state, or deleting the successor's fence and journal. */ import { AsyncLocalStorage } from 'node:async_hooks' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { isSshCommandExitError } from '../../ssh/remote-exec/remote-exec-command' export const ORCAD_FENCE_OWNER_FILENAME = '.orca-fence-owner' @@ -39,8 +39,10 @@ export class OrcadFenceLostError extends Error { /** A POSIX test that succeeds only while the lock still carries this run's token. */ export function posixOrcadFenceOwnedTest(fence: OrcadFence): string { - const owner = shellEscape(`${fence.lockDir.replace(/\/+$/u, '')}/${ORCAD_FENCE_OWNER_FILENAME}`) - return `[ "$(cat ${owner} 2>/dev/null)" = ${shellEscape(fence.token)} ]` + const owner = quotePosixShell( + `${fence.lockDir.replace(/\/+$/u, '')}/${ORCAD_FENCE_OWNER_FILENAME}` + ) + return `[ "$(cat ${owner} 2>/dev/null)" = ${quotePosixShell(fence.token)} ]` } export function posixOrcadFenceGuard(fence: OrcadFence): string { diff --git a/src/main/managed-server/activation/orcad-activation-lock.ts b/src/main/managed-server/activation/orcad-activation-lock.ts index 7be50609818..47f63e91500 100644 --- a/src/main/managed-server/activation/orcad-activation-lock.ts +++ b/src/main/managed-server/activation/orcad-activation-lock.ts @@ -18,7 +18,7 @@ import { orphanInstallLockCommand, probeInstallLockExistsCommand } from '../install/remote-install-lock-commands' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { ORCAD_FENCE_OWNER_FILENAME, posixOrcadFenceOwnedTest, @@ -273,9 +273,9 @@ async function releaseActivationFence( * that stalls after its token check can then never delete a successor's journal or lock. */ function posixReleaseFenceCommand(fence: OrcadFence, journal: string, lockRoot: string): string { - const lock = shellEscape(fence.lockDir) - const journalPath = shellEscape(journal) - const stamp = shellEscape(journalFenceStamp(fence.token)) + const lock = quotePosixShell(fence.lockDir) + const journalPath = quotePosixShell(journal) + const stamp = quotePosixShell(journalFenceStamp(fence.token)) return [ `${posixOrcadFenceOwnedTest(fence)} || { echo SUPERSEDED; exit 0; };`, `ja=${journalPath}.release.$$;`, @@ -283,9 +283,9 @@ function posixReleaseFenceCommand(fence: OrcadFence, journal: string, lockRoot: `grep -qF ${stamp} "$ja" || mv -n "$ja" ${journalPath} 2>/dev/null; rm -f "$ja"; fi;`, `la=${lock}.released.$$;`, `if mv ${lock} "$la" 2>/dev/null; then`, - `if [ "$(cat "$la"/${ORCAD_FENCE_OWNER_FILENAME} 2>/dev/null)" = ${shellEscape(fence.token)} ]; then rm -rf "$la";`, + `if [ "$(cat "$la"/${ORCAD_FENCE_OWNER_FILENAME} 2>/dev/null)" = ${quotePosixShell(fence.token)} ]; then rm -rf "$la";`, `else mv -n "$la" ${lock} 2>/dev/null; fi; fi;`, - `rmdir ${shellEscape(lockRoot)} 2>/dev/null; echo RELEASED` + `rmdir ${quotePosixShell(lockRoot)} 2>/dev/null; echo RELEASED` ].join(' ') } diff --git a/src/main/managed-server/host-state/orcad-machine-claim-command.ts b/src/main/managed-server/host-state/orcad-machine-claim-command.ts index 6c6058f4a5b..1c3c267b368 100644 --- a/src/main/managed-server/host-state/orcad-machine-claim-command.ts +++ b/src/main/managed-server/host-state/orcad-machine-claim-command.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { RELAY_REMOTE_DIR } from '../../ssh/remote-exec/remote-frame-protocol' import { ORCAD_MACHINE_CLAIM_FILENAME, @@ -23,7 +23,7 @@ export function orcadMachineClaimCommand( machineKey: string ): string { const remoteDir = (...parts: string[]): string => - shellEscape(joinRemotePath(host, loginHome, RELAY_REMOTE_DIR, ...parts)) + quotePosixShell(joinRemotePath(host, loginHome, RELAY_REMOTE_DIR, ...parts)) const attempt = randomUUID() const base = remoteDir() const claim = remoteDir(ORCAD_MACHINE_CLAIM_FILENAME) @@ -35,7 +35,7 @@ export function orcadMachineClaimCommand( const arbiterClaim = remoteDir(ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME, ORCAD_MACHINE_CLAIM_FILENAME) // `mv` into an existing arbiter nests the stage inside it instead of replacing it. const nestedStage = remoteDir(ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME, stageName) - const body = shellEscape( + const body = quotePosixShell( JSON.stringify({ schemaVersion: ORCAD_MACHINE_CLAIM_SCHEMA_VERSION, machineKey diff --git a/src/main/managed-server/host-state/orcad-machine-home.ts b/src/main/managed-server/host-state/orcad-machine-home.ts index 1404fd54221..1b3b85c919a 100644 --- a/src/main/managed-server/host-state/orcad-machine-home.ts +++ b/src/main/managed-server/host-state/orcad-machine-home.ts @@ -11,7 +11,7 @@ * can see, so a store shared across machines could be collected under another machine's server. */ import { createHash } from 'node:crypto' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { RELAY_REMOTE_DIR } from '../../ssh/remote-exec/remote-frame-protocol' import { ORCAD_ACTIVATION_FILENAME } from '../activation/orcad-activation-record' import { parseDarwinPlatformUuid } from '../../persistence/profile-state/profile-state-access-identity' @@ -101,10 +101,10 @@ export type OrcadMachineOwnershipUnresolvedCause = 'stopped-install' | 'unattrib * in one exec. Identity sources match `profile-state-access-identity.ts`, so the two agree. */ export function orcadMachineHomeProbeCommand(host: RemoteHostPlatform): string { - const claim = `"$HOME"/${shellEscape(`${RELAY_REMOTE_DIR}/${ORCAD_MACHINE_CLAIM_FILENAME}`)}` - const access = `"$HOME"/${shellEscape(ACCESS_ROOT)}` - const homes = `"$HOME"/${shellEscape(`${RELAY_REMOTE_DIR}/${ORCAD_MACHINE_HOMES_DIRNAME}`)}` - const activation = `"$HOME"/${shellEscape(`${RELAY_REMOTE_DIR}/${ORCAD_ACTIVATION_FILENAME}`)}` + const claim = `"$HOME"/${quotePosixShell(`${RELAY_REMOTE_DIR}/${ORCAD_MACHINE_CLAIM_FILENAME}`)}` + const access = `"$HOME"/${quotePosixShell(ACCESS_ROOT)}` + const homes = `"$HOME"/${quotePosixShell(`${RELAY_REMOTE_DIR}/${ORCAD_MACHINE_HOMES_DIRNAME}`)}` + const activation = `"$HOME"/${quotePosixShell(`${RELAY_REMOTE_DIR}/${ORCAD_ACTIVATION_FILENAME}`)}` return [ `printf '%s\\n' "$HOME"; echo ${SECTION};`, host.os === 'darwin' diff --git a/src/main/managed-server/host-state/orcad-state-mutation-timeout.test.ts b/src/main/managed-server/host-state/orcad-state-mutation-timeout.test.ts index 9af2a828766..f3c0bd895f3 100644 --- a/src/main/managed-server/host-state/orcad-state-mutation-timeout.test.ts +++ b/src/main/managed-server/host-state/orcad-state-mutation-timeout.test.ts @@ -14,7 +14,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ( ...(await importOriginal()), execCommand: vi.fn() })) -vi.mock('../../ssh/ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('../../../shared/shell-argument-quoting', () => ({ + quotePosixShell: (s: string) => `'${s}'` +})) vi.mock('../install/remote-install-lock', async (importOriginal) => ({ ...(await importOriginal()), acquireInstallLock: vi.fn() diff --git a/src/main/managed-server/host-state/orcad-state-snapshot-shell.test.ts b/src/main/managed-server/host-state/orcad-state-snapshot-shell.test.ts index d7f568aca2c..f45d69a9c29 100644 --- a/src/main/managed-server/host-state/orcad-state-snapshot-shell.test.ts +++ b/src/main/managed-server/host-state/orcad-state-snapshot-shell.test.ts @@ -21,7 +21,7 @@ import { } from './orcad-state-snapshot' import { posixProcessGroupCommand } from './orcad-state-mutation-owner-record' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' const posix = getRemoteHostPlatform('linux-x64') @@ -207,8 +207,8 @@ describe.skipIf(process.platform === 'win32')('snapshot commands on a real shell it('keeps the lock while a killed shell’s child still runs, and frees it once the group is gone', async () => { const lock = join(remoteBase, 'orcad-state-mutation.lock') const childReceipt = join(base, 'child-pid') - const childScript = `echo $$ > ${shellEscape(childReceipt)}; exec sleep 30` - const workload = `sh -c ${shellEscape(childScript)} & wait "$!"; echo DONE` + const childScript = `echo $$ > ${quotePosixShell(childReceipt)}; exec sleep 30` + const workload = `sh -c ${quotePosixShell(childScript)} & wait "$!"; echo DONE` const isLive = (pid: number): boolean => { try { process.kill(pid, 0) diff --git a/src/main/managed-server/host-state/orcad-state-snapshot.ts b/src/main/managed-server/host-state/orcad-state-snapshot.ts index 92ee7a5b0ad..ea39f35b274 100644 --- a/src/main/managed-server/host-state/orcad-state-snapshot.ts +++ b/src/main/managed-server/host-state/orcad-state-snapshot.ts @@ -23,7 +23,7 @@ import { posixOrcadFenceOwnedTest, type OrcadFence } from '../activation/orcad-activation-fence-scope' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { isWindowsRemoteHost, joinRemotePath, @@ -84,12 +84,12 @@ export function serializedStateMutationCommand( // The holder's fence; null (a call outside any fence's run) refreshes no fence at all. owned: OrcadFence | null = currentOrcadFence() ): string { - const lock = shellEscape(`${baseDir}/${ORCAD_STATE_MUTATION_LOCK_DIRNAME}`) + const lock = quotePosixShell(`${baseDir}/${ORCAD_STATE_MUTATION_LOCK_DIRNAME}`) const busy = `echo ${ORCAD_STATE_MUTATION_BUSY}; exit 0;` const staleMinutes = Math.max(1, Math.ceil((3 * heartbeatSeconds) / 60)) const guarded = [ `lock=${lock};`, - `mkdir -p ${shellEscape(baseDir)} 2>/dev/null;`, + `mkdir -p ${quotePosixShell(baseDir)} 2>/dev/null;`, 'if ! mkdir "$lock" 2>/dev/null; then', 'holder=$(cat "$lock/pid" 2>/dev/null); group=$(cat "$lock/pgid" 2>/dev/null);', // No pid yet: no work began, and the pid write is exclusive, so a late writer backs off. @@ -111,7 +111,7 @@ export function serializedStateMutationCommand( // Only a fence this run still owns: a superseded or foreign one ages toward takeover. `beat_fence() { touch -c -m "$lock" 2>/dev/null; ${ owned - ? `${posixOrcadFenceOwnedTest(owned)} && touch -c -m ${shellEscape(owned.lockDir)} 2>/dev/null;` + ? `${posixOrcadFenceOwnedTest(owned)} && touch -c -m ${quotePosixShell(owned.lockDir)} 2>/dev/null;` : ':;' } };`, 'beat_fence;', @@ -119,13 +119,13 @@ export function serializedStateMutationCommand( `trap 'kill "$beat" 2>/dev/null; rm -rf "$lock"' EXIT;`, script ].join(' ') - const run = `sh -c ${shellEscape(guarded)}` + const run = `sh -c ${quotePosixShell(guarded)}` return [ // Outermost: a superseded fence holder never takes the mutation lock or touches state. ...(owned ? [posixOrcadFenceGuard(owned)] : []), // Its own process group, so the lock can name every process the mutation started. 'orca_state_group() { if command -v setsid >/dev/null 2>&1; then ORCA_STATE_MUTATION_GROUP=1 setsid "$@";', - `elif command -v perl >/dev/null 2>&1; then ORCA_STATE_MUTATION_GROUP=1 perl -e ${shellEscape('setpgrp(0, 0); exec { $ARGV[0] } @ARGV or exit 127')} "$@";`, + `elif command -v perl >/dev/null 2>&1; then ORCA_STATE_MUTATION_GROUP=1 perl -e ${quotePosixShell('setpgrp(0, 0); exec { $ARGV[0] } @ARGV or exit 127')} "$@";`, 'else "$@"; fi; };', // Why timeout inside the group: KILL then reaches tar and rm, not only the shell. `if command -v timeout >/dev/null 2>&1; then orca_state_group timeout -s KILL ${ORCAD_STATE_MUTATION_DEADLINE_SECONDS} ${run}; else orca_state_group ${run}; fi;`, @@ -166,15 +166,15 @@ export function captureOrcadStateSnapshotCommand( if (windows) { return windows } - const root = shellEscape(userDataDir) - const dir = shellEscape(snapshotDir) - const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const root = quotePosixShell(userDataDir) + const dir = quotePosixShell(snapshotDir) + const archive = quotePosixShell(joinRemotePath(host, snapshotDir, 'state.tar')) const memberTests = ORCAD_SNAPSHOT_MEMBERS.map( // Why the accumulated name is NOT quoted: `$members` is re-split by the shell before it // reaches tar, so a quoted name arrives as a literal `'profiles'` that tar cannot stat. // `assertPlainMemberName` is what makes leaving them bare safe. (member) => { - const path = `${root}/${shellEscape(member)}` + const path = `${root}/${quotePosixShell(member)}` return ( `if [ -e ${path} ] || [ -L ${path} ]; then ` + `${noSymlinkedStateCommand(path)} || { echo FAILED; exit 0; }; ` + @@ -217,7 +217,7 @@ export function probeOrcadStateSnapshotCommand( if (windows) { return windows } - const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const archive = quotePosixShell(joinRemotePath(host, snapshotDir, 'state.tar')) return `test -f ${archive} && echo PRESENT || echo ABSENT` } @@ -254,16 +254,18 @@ export function restoreOrcadStateSnapshotCommand( if (windows) { return windows } - const root = shellEscape(userDataDir) - const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) - const stage = shellEscape(joinRemotePath(host, userDataDir, ORCAD_STATE_RESTORE_STAGE_DIRNAME)) + const root = quotePosixShell(userDataDir) + const archive = quotePosixShell(joinRemotePath(host, snapshotDir, 'state.tar')) + const stage = quotePosixShell( + joinRemotePath(host, userDataDir, ORCAD_STATE_RESTORE_STAGE_DIRNAME) + ) const removals = removeMembersCommand(root) const replacements = ORCAD_SNAPSHOT_MEMBERS.map((member) => { - const name = shellEscape(member) + const name = quotePosixShell(member) return `if [ -e ${stage}/${name} ]; then mv ${stage}/${name} ${root}/${name}; fi` }).join(' && ') const stagedMemberChecks = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `[ -e ${stage}/${shellEscape(member)} ]` + (member) => `[ -e ${stage}/${quotePosixShell(member)} ]` ).join(' || ') return serializedStateMutationCommand( baseDir, @@ -291,7 +293,7 @@ export function clearOrcadStateSnapshotMembersCommand( if (windows) { return windows } - const root = shellEscape(userDataDir) + const root = quotePosixShell(userDataDir) return serializedStateMutationCommand( baseDir, `test -d ${root} || mkdir -p ${root}; if ${removeMembersCommand(root)}; then echo RESTORED; else echo FAILED; fi` @@ -299,7 +301,7 @@ export function clearOrcadStateSnapshotMembersCommand( } function removeMembersCommand(root: string): string { - return ORCAD_SNAPSHOT_MEMBERS.map((member) => `rm -rf ${root}/${shellEscape(member)}`).join( + return ORCAD_SNAPSHOT_MEMBERS.map((member) => `rm -rf ${root}/${quotePosixShell(member)}`).join( ' && ' ) } @@ -325,11 +327,11 @@ export function compareOrcadStateSnapshotCommand( if (windows) { return windows } - const root = shellEscape(userDataDir) - const dir = shellEscape(snapshotDir) - const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const root = quotePosixShell(userDataDir) + const dir = quotePosixShell(snapshotDir) + const archive = quotePosixShell(joinRemotePath(host, snapshotDir, 'state.tar')) const comparisons = ORCAD_SNAPSHOT_MEMBERS.map((member) => { - const name = shellEscape(member) + const name = quotePosixShell(member) return [ `if [ -e ${root}/${name} ] || [ -L ${root}/${name} ]; then`, `${noSymlinkedStateCommand(`${root}/${name}`)} || { echo UNKNOWN; exit 0; };`, @@ -370,8 +372,10 @@ export function newestStateMtimeCommand( if (windows) { return windows } - const root = shellEscape(userDataDir) - const paths = ORCAD_SNAPSHOT_MEMBERS.map((member) => `${root}/${shellEscape(member)}`).join(' ') + const root = quotePosixShell(userDataDir) + const paths = ORCAD_SNAPSHOT_MEMBERS.map((member) => `${root}/${quotePosixShell(member)}`).join( + ' ' + ) return [ `newest=$(find ${paths} -type f -exec stat -c %Y {} + 2>/dev/null ||`, `find ${paths} -type f -exec stat -f %m {} + 2>/dev/null);`, diff --git a/src/main/managed-server/install/orcad-remote-build-hash.ts b/src/main/managed-server/install/orcad-remote-build-hash.ts index 312ee003fcb..4f9dcb6a9ca 100644 --- a/src/main/managed-server/install/orcad-remote-build-hash.ts +++ b/src/main/managed-server/install/orcad-remote-build-hash.ts @@ -1,6 +1,6 @@ /** The installed entry bytes, hashed as the server reports them for activation and rollback. */ import { ORCAD_LAUNCHER_FILENAME } from '../../../shared/orcad-artifacts' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' import { orcadWindowsSlotOpCommand } from './orcad-remote-windows-node' import { ORCAD_BUILD_HASH_MARKER as BUILD_HASH_MARKER } from '../host-state/orcad-windows-host-script' @@ -34,12 +34,12 @@ export function remoteOrcadBuildHashCommand( joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME) ]) } - const path = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) + const path = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) // Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first. return [ `[ -f ${path} ] && [ -r ${path} ] || exit 1;`, `orca_hash=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum ${path} | awk '{print $1}';`, `elif command -v shasum >/dev/null 2>&1; then shasum -a 256 ${path} | awk '{print $1}'; fi);`, - `case "$orca_hash" in [0-9a-fA-F][0-9a-fA-F]*) printf '%s %.16s\\n' ${shellEscape(BUILD_HASH_MARKER)} "$orca_hash";; esac` + `case "$orca_hash" in [0-9a-fA-F][0-9a-fA-F]*) printf '%s %.16s\\n' ${quotePosixShell(BUILD_HASH_MARKER)} "$orca_hash";; esac` ].join(' ') } diff --git a/src/main/managed-server/install/orcad-remote-context.ts b/src/main/managed-server/install/orcad-remote-context.ts index 9d5b318de48..2471cea1473 100644 --- a/src/main/managed-server/install/orcad-remote-context.ts +++ b/src/main/managed-server/install/orcad-remote-context.ts @@ -22,7 +22,7 @@ import { import { orcadMachineClaimCommand } from '../host-state/orcad-machine-claim-command' import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' import { readRemoteHomeCommand } from '../../ssh/ssh-remote-commands' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { RELAY_REMOTE_DIR } from '../../ssh/remote-exec/remote-frame-protocol' import { isWindowsRemoteHost, @@ -162,7 +162,7 @@ export async function resolveOrcadMachineHome( } } const orcadHome = orcadMachineHomeDir(host, loginHome, decision.machineKey) - await execOrcadRemote(remote, `umask 077; mkdir -p ${shellEscape(orcadHome)}`) + await execOrcadRemote(remote, `umask 077; mkdir -p ${quotePosixShell(orcadHome)}`) return { loginHome, orcadHome, diff --git a/src/main/managed-server/install/orcad-remote-decommission.test.ts b/src/main/managed-server/install/orcad-remote-decommission.test.ts index 873b24cd9f7..f13b9a5e50e 100644 --- a/src/main/managed-server/install/orcad-remote-decommission.test.ts +++ b/src/main/managed-server/install/orcad-remote-decommission.test.ts @@ -7,7 +7,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ( ...(await importOriginal()), execCommand: vi.fn() })) -vi.mock('../../ssh/ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('../../../shared/shell-argument-quoting', () => ({ + quotePosixShell: (s: string) => `'${s}'` +})) vi.mock('./remote-install-lock', async (importOriginal) => ({ ...(await importOriginal()), acquireInstallLock: vi.fn() diff --git a/src/main/managed-server/install/orcad-remote-gc.test.ts b/src/main/managed-server/install/orcad-remote-gc.test.ts index 4be62080738..2d9441f10c0 100644 --- a/src/main/managed-server/install/orcad-remote-gc.test.ts +++ b/src/main/managed-server/install/orcad-remote-gc.test.ts @@ -5,7 +5,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ( ...(await importOriginal()), execCommand: vi.fn() })) -vi.mock('../../ssh/ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('../../../shared/shell-argument-quoting', () => ({ + quotePosixShell: (s: string) => `'${s}'` +})) vi.mock('./remote-install-gc-claim', () => ({ isRelayGcClaimOwned: vi.fn().mockResolvedValue(true), releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), diff --git a/src/main/managed-server/install/orcad-remote-install.ts b/src/main/managed-server/install/orcad-remote-install.ts index 260e2c45ad4..0316086b9e6 100644 --- a/src/main/managed-server/install/orcad-remote-install.ts +++ b/src/main/managed-server/install/orcad-remote-install.ts @@ -8,7 +8,7 @@ import { execCommand, isUnconfirmedSshCommandTermination } from '../../ssh/remote-exec/remote-exec-command' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import type { OrcadHostTransport } from '../../ssh/orcad-host-transport' import { joinRemotePath, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' import { ORCAD_INSTALL_MODEL } from './remote-install-model' @@ -135,14 +135,14 @@ function executablePermissionsCommand( .map((artifact) => joinRemotePath(host, directory, ...artifact.split('/'))) const browsers = new Set( (['glibc', 'musl'] as const).map((libc) => - shellEscape( + quotePosixShell( joinRemotePath(host, directory, orcadAgentBrowserNativeName(host.os, host.arch, libc)) ) ) ) // SFTP drops executable modes; missing optional browser binaries remain a supported install. return ( - `chmod 755 ${required.map(shellEscape).join(' ')} && ` + + `chmod 755 ${required.map(quotePosixShell).join(' ')} && ` + `for executable in ${[...browsers].join(' ')}; do ` + 'if [ -f "$executable" ]; then chmod 755 "$executable" || exit $?; fi; done' ) diff --git a/src/main/managed-server/install/orcad-remote-launch.ts b/src/main/managed-server/install/orcad-remote-launch.ts index 42f6e8ccef8..d1df90a70f6 100644 --- a/src/main/managed-server/install/orcad-remote-launch.ts +++ b/src/main/managed-server/install/orcad-remote-launch.ts @@ -17,7 +17,7 @@ import { ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME } from '../../../shared/orcad-artifacts' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { isWindowsRemoteHost, joinRemotePath, @@ -99,13 +99,15 @@ export function orcadLaunchCommand( hostEnv: readonly OrcadLaunchEnvEntry[] = [] ): string { assertPosixHost(host) - const dir = shellEscape(spec.remoteInstallDir) - const readiness = shellEscape( + const dir = quotePosixShell(spec.remoteInstallDir) + const readiness = quotePosixShell( joinRemotePath(host, spec.remoteInstallDir, ORCAD_READINESS_FILENAME) ) - const log = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LOG_FILENAME)) - const pidFile = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME)) - const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) + const log = quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LOG_FILENAME)) + const pidFile = quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME)) + const entry = quotePosixShell( + joinRemotePath(host, spec.remoteInstallDir, ORCAD_LAUNCHER_FILENAME) + ) const baseDir = remoteDirname(spec.remoteInstallDir.replace(/\/+$/u, ''), host) // Only `~/.orca-remote` itself; never tighten an unrelated parent a custom slot path names. const privateDirs = [ @@ -120,7 +122,7 @@ export function orcadLaunchCommand( // launch creates must be owner-only from birth, not only the ones after the exec. 'umask 077 &&', // Best effort: directories and files an earlier build left 0755/0644 keep their old modes. - `{ chmod 700 ${privateDirs.map(shellEscape).join(' ')} 2>/dev/null || :; } &&`, + `{ chmod 700 ${privateDirs.map(quotePosixShell).join(' ')} 2>/dev/null || :; } &&`, `cd ${dir} &&`, `${selectOrcadSlotRuntimeCommand(host, spec.remoteInstallDir, spec.nodePath)} &&`, // Why truncate: a re-launch into a dir that already holds a previous readiness line would @@ -130,18 +132,18 @@ export function orcadLaunchCommand( `chmod 600 ${readiness} &&`, `{ chmod 600 ${pidFile} ${log} 2>/dev/null || :; } &&`, // A stop request the previous process never consumed must not stop this one. - `rm -f ${shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME))} &&`, + `rm -f ${quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME))} &&`, ...(detach === 'setsid' ? [`rm -f ${pidFile} &&`] : []), - `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, - `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, - ...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${shellEscape(value)}`), + `ORCA_VERSION=${quotePosixShell(spec.fullVersion)}`, + `ORCA_USER_DATA=${quotePosixShell(spec.userDataDir)}`, + ...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${quotePosixShell(value)}`), ...(detach === 'setsid' ? setsidLaunchTail(entry, spec, readiness, log, pidFile, hostEnv) : [ // Keep $! equal to the runtime PID rather than a waiting shell's PID. // Older clients identify the live PID by orcad.js; the pinned launcher loads the body in-place. `exec nohup "$orcad_runtime" ${entry}`, - `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, + `--json --bind ${quotePosixShell(spec.bindHost)} --port ${String(spec.port)}`, `> ${readiness} 2>> ${log} < /dev/null &`, `echo $! > ${pidFile} &&`, ...(recordSession @@ -168,12 +170,12 @@ function setsidLaunchTail( return [ // Only a setsid (WSL host) launch marks itself; SSH keeps its byte-for-byte command. `${ORCAD_WSL_HOST_ENV}=1`, - ...hostEnv.map(([name, value]) => `${assertEnvName(name)}=${shellEscape(value)}`), + ...hostEnv.map(([name, value]) => `${assertEnvName(name)}=${quotePosixShell(value)}`), 'sh -c \'orcad_detach() { if setsid -f true >/dev/null 2>&1; then setsid -f "$@"; ' + 'else setsid "$@" & fi; }; orcad_detach "$@"\' orcad-detach', `sh -c 'echo $$ > "$1" && shift && exec "$@"' orcad-launch ${pidFile}`, `"$orcad_runtime" ${entry}`, - `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, + `--json --bind ${quotePosixShell(spec.bindHost)} --port ${String(spec.port)}`, `> ${readiness} 2>> ${log} < /dev/null &&`, `orcad_wait=0; while [ ! -s ${pidFile} ] && [ "$orcad_wait" -lt ${ORCAD_SETSID_PID_WAIT_TRIES} ];`, 'do sleep 0.05; orcad_wait=$((orcad_wait + 1)); done;', @@ -193,7 +195,9 @@ export function readOrcadReadinessCommand( remoteInstallDir: string ): string { assertPosixHost(host) - const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) + const readiness = quotePosixShell( + joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME) + ) // One byte over the cap is enough to tell an oversized payload from a full one. return `head -c ${ORCAD_READINESS_MAX_BYTES + 1} ${readiness} 2>/dev/null || true` } @@ -215,10 +219,14 @@ export function orcadLivenessProbeCommand( // Windows: PID plus process creation time (PIDs are reused); anything unanswerable is UNKNOWN, never DEAD. return orcadWindowsSlotOpCommand(host, remoteInstallDir, 'liveness', [remoteInstallDir]) } - const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) - const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) - const launcher = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) - const server = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_SERVER_ENTRY_FILENAME)) + const pidFile = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + const readiness = quotePosixShell( + joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME) + ) + const launcher = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) + const server = quotePosixShell( + joinRemotePath(host, remoteInstallDir, ORCAD_SERVER_ENTRY_FILENAME) + ) return [ posixProcessAliveShellFunction({ refuseUnverifiable: true }), // A PID is no identity once reused: a process whose command line does not run this slot's diff --git a/src/main/managed-server/install/orcad-remote-log-tail.ts b/src/main/managed-server/install/orcad-remote-log-tail.ts index e4b4d336af8..e03a2f4ab4c 100644 --- a/src/main/managed-server/install/orcad-remote-log-tail.ts +++ b/src/main/managed-server/install/orcad-remote-log-tail.ts @@ -4,7 +4,7 @@ * telemetry. Best-effort: a host that can't answer leaves the error as it was. */ import { redactString } from '../../observability/redactor' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { ORCAD_LOG_FILENAME } from './orcad-remote-host-support' import { execCommand } from '../../ssh/remote-exec/remote-exec-command' import { @@ -32,7 +32,7 @@ export function orcadLogTailCommand(host: RemoteHostPlatform, slotDir: string): String(ORCAD_LOG_TAIL_MAX_BYTES) ]) } - return `tail -c ${ORCAD_LOG_TAIL_MAX_BYTES} ${shellEscape(log)} 2>/dev/null || true` + return `tail -c ${ORCAD_LOG_TAIL_MAX_BYTES} ${quotePosixShell(log)} 2>/dev/null || true` } /** The last lines of what the host printed, redacted; null when the log is empty or absent. */ diff --git a/src/main/managed-server/install/orcad-remote-node-runtime-recorded-probe.ts b/src/main/managed-server/install/orcad-remote-node-runtime-recorded-probe.ts index 5a8877b4205..1cbc590c51c 100644 --- a/src/main/managed-server/install/orcad-remote-node-runtime-recorded-probe.ts +++ b/src/main/managed-server/install/orcad-remote-node-runtime-recorded-probe.ts @@ -5,7 +5,7 @@ * forces the full hash again (verify on mismatch). */ import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../../shared/node-runtime-pin' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { joinRemotePath, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' import { assertPosixOrcadHost } from './orcad-remote-host-support' import { @@ -25,14 +25,14 @@ export function recordedNodeRuntimeProbeCommand( executableSha256: string ): string { assertPosixOrcadHost(host) - const executable = shellEscape(posixNodeRuntimeExecutable(host, runtimeDir)) - const verified = shellEscape( + const executable = quotePosixShell(posixNodeRuntimeExecutable(host, runtimeDir)) + const verified = quotePosixShell( joinRemotePath(host, runtimeDir, REMOTE_NODE_RUNTIME_VERIFIED_MARKER) ) - const record = shellEscape( + const record = quotePosixShell( joinRemotePath(host, runtimeDir, REMOTE_NODE_RUNTIME_HASHED_STAT_FILENAME) ) - const pin = shellEscape(executableSha256) + const pin = quotePosixShell(executableSha256) // GNU/busybox `stat -c`, else BSD `stat -f`; an empty signature never matches and is never kept. const signature = `{ stat -L -c '%d:%i:%s:%y:%z' ${executable} || ` + diff --git a/src/main/managed-server/install/orcad-remote-node-runtime.ts b/src/main/managed-server/install/orcad-remote-node-runtime.ts index b06cbf5a818..56ed98b399d 100644 --- a/src/main/managed-server/install/orcad-remote-node-runtime.ts +++ b/src/main/managed-server/install/orcad-remote-node-runtime.ts @@ -23,7 +23,7 @@ import { orcadNodeRuntimeExecutable } from '../../../shared/orcad-artifacts' import type { OrcadHostTransport } from '../../ssh/orcad-host-transport' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { execCommand, isUnconfirmedSshCommandTermination @@ -119,11 +119,11 @@ export function probeRemoteNodeRuntimeCommand( if (isWindowsRemoteHost(host)) { return windowsNodeRuntimeProbeCommand(runtimeDir, target) } - const executable = shellEscape(posixNodeRuntimeExecutable(host, runtimeDir)) - const verified = shellEscape(joinRemotePath(host, runtimeDir, VERIFIED_MARKER)) + const executable = quotePosixShell(posixNodeRuntimeExecutable(host, runtimeDir)) + const verified = quotePosixShell(joinRemotePath(host, runtimeDir, VERIFIED_MARKER)) return ( `if [ -f ${verified} ] && [ -x ${executable} ] && ` + - `[ "$(${remoteSha256Command(executable)})" = ${shellEscape(pinnedNodeRuntimeAsset(target).executableSha256)} ]; ` + + `[ "$(${remoteSha256Command(executable)})" = ${quotePosixShell(pinnedNodeRuntimeAsset(target).executableSha256)} ]; ` + `then echo ${REMOTE_NODE_RUNTIME_READY}; else echo ${REMOTE_NODE_RUNTIME_MISSING}; fi` ) } @@ -142,13 +142,13 @@ export function remoteNodeRuntimePresentCommand( if (isWindowsRemoteHost(host)) { return windowsNodeRuntimePresentCommand(runtimeDir, run) } - const executable = shellEscape( + const executable = quotePosixShell( joinRemotePath(host, runtimeDir, ...ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE.split('/')) ) - const verified = shellEscape(joinRemotePath(host, runtimeDir, VERIFIED_MARKER)) + const verified = quotePosixShell(joinRemotePath(host, runtimeDir, VERIFIED_MARKER)) const ready = run ? `{ orca_rt_out=$(${executable} --version 2>&1); orca_rt_status=$?; ` + - `if [ "$orca_rt_out" = ${shellEscape(`v${NODE_RUNTIME_PIN.version}`)} ]; then echo ${REMOTE_NODE_RUNTIME_READY}; ` + + `if [ "$orca_rt_out" = ${quotePosixShell(`v${NODE_RUNTIME_PIN.version}`)} ]; then echo ${REMOTE_NODE_RUNTIME_READY}; ` + `else echo ${REMOTE_NODE_RUNTIME_SELFTEST_FAILED}; echo "${REMOTE_NODE_RUNTIME_EXIT_PREFIX}$orca_rt_status"; ` + `printf '%s\\n' "$orca_rt_out" | head -c 4000; fi; }` : `echo ${REMOTE_NODE_RUNTIME_READY}` @@ -175,21 +175,23 @@ export function promoteRemoteNodeRuntimeCommand( assertPosixOrcadHost(host) const asset = pinnedNodeRuntimeAsset(args.target) const member = nodeRuntimeExecutablePath(args.target, asset.archive) - const stage = shellEscape(args.stageDir) - const extracted = shellEscape(joinRemotePath(host, args.stageDir, ...member.split('/'))) - const binDir = shellEscape(joinRemotePath(host, args.runtimeDir, 'bin')) - const temporary = shellEscape(joinRemotePath(host, args.runtimeDir, 'bin', `node.${args.token}`)) - const executable = shellEscape(posixNodeRuntimeExecutable(host, args.runtimeDir)) - const verified = shellEscape(joinRemotePath(host, args.runtimeDir, VERIFIED_MARKER)) + const stage = quotePosixShell(args.stageDir) + const extracted = quotePosixShell(joinRemotePath(host, args.stageDir, ...member.split('/'))) + const binDir = quotePosixShell(joinRemotePath(host, args.runtimeDir, 'bin')) + const temporary = quotePosixShell( + joinRemotePath(host, args.runtimeDir, 'bin', `node.${args.token}`) + ) + const executable = quotePosixShell(posixNodeRuntimeExecutable(host, args.runtimeDir)) + const verified = quotePosixShell(joinRemotePath(host, args.runtimeDir, VERIFIED_MARKER)) return [ `cd ${stage} || exit 1`, - `tar -xzf ${shellEscape(joinRemotePath(host, args.stageDir, args.archive))} ${shellEscape(member)} || { echo ORCA_NODE_RUNTIME_EXTRACT_FAILED; exit 1; }`, - `[ "$(${remoteSha256Command(extracted)})" = ${shellEscape(asset.executableSha256)} ] || { echo ORCA_NODE_RUNTIME_HASH_MISMATCH; exit 1; }`, + `tar -xzf ${quotePosixShell(joinRemotePath(host, args.stageDir, args.archive))} ${quotePosixShell(member)} || { echo ORCA_NODE_RUNTIME_EXTRACT_FAILED; exit 1; }`, + `[ "$(${remoteSha256Command(extracted)})" = ${quotePosixShell(asset.executableSha256)} ] || { echo ORCA_NODE_RUNTIME_HASH_MISMATCH; exit 1; }`, `chmod 755 ${extracted}`, // Why run it: executing is the only reliable check for noexec mounts and a wrong libc. // Exit 0 on refusal so the caller receives the loader's words to classify, not a bare exit 1. `{ orca_rt_out=$(${extracted} --version 2>&1); orca_rt_status=$?; ` + - `[ "$orca_rt_out" = ${shellEscape(`v${NODE_RUNTIME_PIN.version}`)} ] || ` + + `[ "$orca_rt_out" = ${quotePosixShell(`v${NODE_RUNTIME_PIN.version}`)} ] || ` + `{ echo ${REMOTE_NODE_RUNTIME_SELFTEST_FAILED}; echo "${REMOTE_NODE_RUNTIME_EXIT_PREFIX}$orca_rt_status"; ` + `printf '%s\\n' "$orca_rt_out" | head -c 4000; exit 0; }; }`, `mkdir -p ${binDir}`, @@ -224,9 +226,9 @@ export function installNodeRuntimeFromHostArchiveCommand( args: { runtimeDir: string; archive: string; target: NodeRuntimeTarget; token: string } ): string { const stageDir = nodeRuntimeStageDir(host, args.runtimeDir, args.token) - const stage = shellEscape(stageDir) + const stage = quotePosixShell(stageDir) const promote = promoteRemoteNodeRuntimeCommand(host, { ...args, stageDir }) - const copy = `cp -- "$1" ${shellEscape(joinRemotePath(host, stageDir, args.archive))}` + const copy = `cp -- "$1" ${quotePosixShell(joinRemotePath(host, stageDir, args.archive))}` // Subshell: promote's `exit 1` must still reach the stage cleanup. return `(umask 077 && mkdir -p ${stage} && ${copy} && ${promote}); status=$?; rm -rf ${stage}; exit $status` } @@ -283,7 +285,7 @@ export async function ensureRemoteOrcadNodeRuntime(options: { } const cleanupStage = windows ? windowsNodeRuntimeStageCleanupCommand(stageDir) - : `rm -rf ${shellEscape(stageDir)}` + : `rm -rf ${quotePosixShell(stageDir)}` let localStage: string | undefined let stageUnconfirmed = false let hostRemovedStage = false @@ -296,7 +298,7 @@ export async function ensureRemoteOrcadNodeRuntime(options: { copyFile(archivePath, join(uploadDir, archive)) ) if (!windows) { - await exec(`mkdir -p ${shellEscape(stageDir)}`, { signal }) + await exec(`mkdir -p ${quotePosixShell(stageDir)}`, { signal }) } await remoteStep(() => uploadRelayDirectory(conn, uploadDir, stageDir, host, { signal })) let promoteRan = false diff --git a/src/main/managed-server/install/orcad-remote-preflight.ts b/src/main/managed-server/install/orcad-remote-preflight.ts index 2386779569c..e3bbe0eb95c 100644 --- a/src/main/managed-server/install/orcad-remote-preflight.ts +++ b/src/main/managed-server/install/orcad-remote-preflight.ts @@ -16,7 +16,7 @@ import { readWindowsOrcadSlotEntry } from './orcad-remote-launch-windows' import { ORCAD_WINDOWS_RUNTIME_MARKER } from '../host-state/orcad-windows-host-script' import { orcadNodeSlotRuntimeCommand, selectOrcadSlotEntryCommand } from './orcad-remote-runtime' import { execCommand } from '../../ssh/remote-exec/remote-exec-command' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { isWindowsRemoteHost, joinRemotePath, @@ -31,13 +31,15 @@ export function orcadProfilePreflightCommand( ): string { assertPosixOrcadHost(host) // Why no host-Node fallback: a candidate this client installed is always a Node slot. - const marker = shellEscape(joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME)) + const marker = quotePosixShell( + joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME) + ) const launch = [ 'ORCA_BACKGROUND_LAUNCH=1', '"$orcad_runtime"', '"$orcad_entry"', ORCAD_PROFILE_PREFLIGHT_FLAG, - shellEscape(nonce) + quotePosixShell(nonce) ].join(' ') return `[ -e ${marker} ] || exit 78; ${orcadNodeSlotRuntimeCommand(host, directory)}${selectOrcadSlotEntryCommand(host, directory)}; ${launch}` } diff --git a/src/main/managed-server/install/orcad-remote-process-control.ts b/src/main/managed-server/install/orcad-remote-process-control.ts index 173ca1c2fbe..ca9dd6f66c2 100644 --- a/src/main/managed-server/install/orcad-remote-process-control.ts +++ b/src/main/managed-server/install/orcad-remote-process-control.ts @@ -4,7 +4,7 @@ * SIGTERM starts one bounded durable shutdown. If it outlasts this wait, preserve the * current owner; SIGKILL would skip flushing state and releasing the instance lock. */ -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { isWindowsRemoteHost, joinRemotePath, @@ -47,9 +47,11 @@ export function stopOrcadCommand( options.justLaunched === true ? '1' : '0' ]) } - const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) - const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) - const requestFile = shellEscape( + const pidFile = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + const readiness = quotePosixShell( + joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME) + ) + const requestFile = quotePosixShell( joinRemotePath(host, remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME) ) const readRuntimePid = [ @@ -69,7 +71,7 @@ export function stopOrcadCommand( ? [] : [ `runtime_answer=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + - `"$orcad_runtime" -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, + `"$orcad_runtime" -e ${quotePosixShell(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, '[ "$pid" = "${runtime_answer%%:*}" ] || { echo UNKNOWN; exit 0; };', 'stop_mode=${runtime_answer#*:};' ]), diff --git a/src/main/managed-server/install/orcad-remote-readiness-wait.ts b/src/main/managed-server/install/orcad-remote-readiness-wait.ts index 2c5a2dfe8c0..f185f18cbca 100644 --- a/src/main/managed-server/install/orcad-remote-readiness-wait.ts +++ b/src/main/managed-server/install/orcad-remote-readiness-wait.ts @@ -6,7 +6,7 @@ * (docs/reference/windows-edr-posture.md). One exec now waits host-side for a complete line, an * oversized file, or its own bounded deadline. */ -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { isWindowsRemoteHost, joinRemotePath, @@ -33,7 +33,7 @@ function posixReadinessWaitCommand( remoteInstallDir: string, waitSeconds: number ): string { - const file = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) + const file = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) const read = `head -c ${ORCAD_READINESS_MAX_BYTES + 1} ${file} 2>/dev/null` return [ // Fractional sleep is not POSIX; probe it once so polling stays fine-grained where it can. diff --git a/src/main/managed-server/install/orcad-remote-record-file.ts b/src/main/managed-server/install/orcad-remote-record-file.ts index 35c61db4924..3fc16b5fe3d 100644 --- a/src/main/managed-server/install/orcad-remote-record-file.ts +++ b/src/main/managed-server/install/orcad-remote-record-file.ts @@ -5,7 +5,7 @@ * absent one, and treating it as absent is how a client deploys over a live install. */ import { randomUUID } from 'node:crypto' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { removeRemoteFileCommand } from '../../ssh/ssh-remote-commands' import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' import { isUnconfirmedSshCommandTermination } from '../../ssh/remote-exec/remote-exec-command' @@ -41,7 +41,7 @@ export async function readBoundedOrcadRemoteRecord( if (isWindowsRemoteHost(target.host)) { return readWindowsRecord(target, path, maxBytes) } - const file = shellEscape(path) + const file = quotePosixShell(path) // Why markers: an empty stdout must never be mistaken for "no record" when the read failed. const output = await execOrcadRemote( target, @@ -104,8 +104,8 @@ export async function writeAtomicOrcadRemoteRecord( } await execOrcadRemote( target, - `umask 077; printf %s ${shellEscape(contents)} > ${shellEscape(partialPath)} && ` + - `mv -f ${shellEscape(partialPath)} ${shellEscape(path)}` + `umask 077; printf %s ${quotePosixShell(contents)} > ${quotePosixShell(partialPath)} && ` + + `mv -f ${quotePosixShell(partialPath)} ${quotePosixShell(path)}` ) } catch (error) { // Why keep the partial on an unconfirmed termination: the write may still be running there. diff --git a/src/main/managed-server/install/orcad-remote-rollback.test.ts b/src/main/managed-server/install/orcad-remote-rollback.test.ts index 19a5fbd4a1d..a9558a0f23f 100644 --- a/src/main/managed-server/install/orcad-remote-rollback.test.ts +++ b/src/main/managed-server/install/orcad-remote-rollback.test.ts @@ -8,7 +8,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ( ...(await importOriginal()), execCommand: vi.fn() })) -vi.mock('../../ssh/ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('../../../shared/shell-argument-quoting', () => ({ + quotePosixShell: (s: string) => `'${s}'` +})) vi.mock('./remote-install-transfers', () => ({ writeRelayFile: vi.fn().mockResolvedValue(undefined), uploadRelayDirectory: vi.fn().mockResolvedValue(undefined) diff --git a/src/main/managed-server/install/orcad-remote-runtime-control.test.ts b/src/main/managed-server/install/orcad-remote-runtime-control.test.ts index 69623e1d6e3..63feb7c6b8b 100644 --- a/src/main/managed-server/install/orcad-remote-runtime-control.test.ts +++ b/src/main/managed-server/install/orcad-remote-runtime-control.test.ts @@ -12,7 +12,7 @@ import { execCommand } from '../../ssh/remote-exec/remote-exec-command' import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' import type { OrcadLaunchSpec } from './orcad-remote-launch' -import { createSshOperationAbortError } from '../../ssh/ssh-connection-utils' +import { createSshOperationAbortError } from '../../ssh/connection/ssh-exec-options' const host = getRemoteHostPlatform('linux-arm64') const spec: OrcadLaunchSpec = { diff --git a/src/main/managed-server/install/orcad-remote-runtime.test.ts b/src/main/managed-server/install/orcad-remote-runtime.test.ts index fe5f6c866f5..11c4a90522b 100644 --- a/src/main/managed-server/install/orcad-remote-runtime.test.ts +++ b/src/main/managed-server/install/orcad-remote-runtime.test.ts @@ -6,7 +6,7 @@ import { runProcessSync } from '@orca/process-host' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { stopOrcadCommand } from './orcad-remote-process-control' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' const directories: string[] = [] const host = getRemoteHostPlatform('linux-x64') @@ -41,12 +41,12 @@ function nodeSlot(options: { marker?: string; runtime?: boolean } = {}): string /** Frozen copy of the Bun-era selector that shipped clients still run (design D7.1 R5). */ function bunEraSelectorCommand(directory: string, legacyNodePath: string): string { - const runtime = shellEscape(join(directory, 'bun-runtime')) - const target = shellEscape(join(directory, '.build-target')) + const runtime = quotePosixShell(join(directory, 'bun-runtime')) + const target = quotePosixShell(join(directory, '.build-target')) return ( `if [ -e ${target} ] || [ -e ${runtime} ]; then ` + `[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` + - `else orcad_runtime=${shellEscape(legacyNodePath)}; fi` + `else orcad_runtime=${quotePosixShell(legacyNodePath)}; fi` ) } diff --git a/src/main/managed-server/install/orcad-remote-runtime.ts b/src/main/managed-server/install/orcad-remote-runtime.ts index b51303be02a..85fb82db72f 100644 --- a/src/main/managed-server/install/orcad-remote-runtime.ts +++ b/src/main/managed-server/install/orcad-remote-runtime.ts @@ -9,7 +9,7 @@ import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { joinRemotePath, remoteDirname, @@ -19,8 +19,8 @@ import { /** New slots start the server directly; rollback still accepts older single-entry slots. */ export function selectOrcadSlotEntryCommand(host: RemoteHostPlatform, directory: string): string { assertPosixOrcadHost(host) - const server = shellEscape(joinRemotePath(host, directory, ORCAD_SERVER_ENTRY_FILENAME)) - const launcher = shellEscape(joinRemotePath(host, directory, ORCAD_LAUNCHER_FILENAME)) + const server = quotePosixShell(joinRemotePath(host, directory, ORCAD_SERVER_ENTRY_FILENAME)) + const launcher = quotePosixShell(joinRemotePath(host, directory, ORCAD_LAUNCHER_FILENAME)) return `if [ -f ${server} ]; then orcad_entry=${server}; else orcad_entry=${launcher}; fi` } @@ -30,10 +30,10 @@ export function selectOrcadSlotEntryCommand(host: RemoteHostPlatform, directory: */ export function orcadNodeSlotRuntimeCommand(host: RemoteHostPlatform, directory: string): string { assertPosixOrcadHost(host) - const nodeMarker = shellEscape( + const nodeMarker = quotePosixShell( joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME) ) - const nodeRuntimePrefix = shellEscape( + const nodeRuntimePrefix = quotePosixShell( joinRemotePath( host, remoteDirname(directory.replace(/\/+$/, ''), host), @@ -41,7 +41,7 @@ export function orcadNodeSlotRuntimeCommand(host: RemoteHostPlatform, directory: ORCAD_NODE_RUNTIME_DIR_PREFIX ) ) - const nodeExecutable = shellEscape(`/${ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE}`) + const nodeExecutable = quotePosixShell(`/${ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE}`) return ( `orcad_node_sha=$(cat ${nodeMarker}) || exit 78; ` + // Why validate: the digest becomes a path segment, so only a bare sha256 may reach it. @@ -63,15 +63,15 @@ export function selectOrcadSlotRuntimeCommand( legacyNodePath: string ): string { assertPosixOrcadHost(host) - const nodeMarker = shellEscape( + const nodeMarker = quotePosixShell( joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME) ) - const runtime = shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))) - const target = shellEscape(joinRemotePath(host, directory, ORCAD_BUILD_TARGET_FILENAME)) + const runtime = quotePosixShell(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))) + const target = quotePosixShell(joinRemotePath(host, directory, ORCAD_BUILD_TARGET_FILENAME)) return ( `if [ -e ${nodeMarker} ]; then ${orcadNodeSlotRuntimeCommand(host, directory)}` + `elif [ -e ${target} ] || [ -e ${runtime} ]; then ` + `[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` + - `else orcad_runtime=${shellEscape(legacyNodePath)}; fi` + `else orcad_runtime=${quotePosixShell(legacyNodePath)}; fi` ) } diff --git a/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts b/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts index 38fc026c1e6..5c5c098fa14 100644 --- a/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts @@ -37,7 +37,7 @@ import { parseOrcadStopOutcome, stopOrcadCommand } from './orcad-remote-process-control' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { orcadReadinessWaitCommand, parseOrcadReadinessWaitOutput @@ -402,7 +402,7 @@ describe('liveness and stop commands, run for real', () => { // The prior client recognizes only orcad.js when deciding whether this PID was reused. const legacyIdentity = sh( `args=$(ps -o args= -p ${recordedPid}); ` + - `case "$args" in *${shellEscape(join(versionDir, 'orcad.js'))}*) echo LIVE;; *) echo DEAD;; esac` + `case "$args" in *${quotePosixShell(join(versionDir, 'orcad.js'))}*) echo LIVE;; *) echo DEAD;; esac` ).trim() expect(legacyIdentity).toBe('LIVE') expect(stopTestRuntime()).toBe('stopped') @@ -521,7 +521,7 @@ describe('liveness and stop commands, run for real', () => { const matchingPs = 'ps() { [ "$#" = 4 ] && [ "$1" = -o ] && [ "$2" = args= ] && ' + '[ "$3" = -p ] && [ "$4" = 4242 ] || return 1; ' + - `printf 'node %s\\n' ${shellEscape(join(versionDir, 'orcad.js'))}; };` + `printf 'node %s\\n' ${quotePosixShell(join(versionDir, 'orcad.js'))}; };` const deniedKill = 'kill() { if [ "$LC_ALL" = C ]; then echo "kill: Operation not permitted" >&2; ' + 'else echo "kill: Vorgang nicht zulässig" >&2; fi; return 1; };' @@ -637,7 +637,7 @@ describe('host-side readiness wait, run for real', () => { writeFileSync(readiness(), line.slice(0, 10)) // The writer finishes after the wait starts; the shell must pick it up mid-wait. sh( - `(sleep 1; printf '%s\\n' ${shellEscape(line.trimEnd().slice(10))} >> ${shellEscape(readiness())}) >/dev/null 2>&1 &` + `(sleep 1; printf '%s\\n' ${quotePosixShell(line.trimEnd().slice(10))} >> ${quotePosixShell(readiness())}) >/dev/null 2>&1 &` ) expect( parseOrcadReadinessWaitOutput(host, sh(orcadReadinessWaitCommand(host, versionDir, 10))) diff --git a/src/main/managed-server/install/remote-install-gc-claim.ts b/src/main/managed-server/install/remote-install-gc-claim.ts index d51bcd71a5e..a407826f60c 100644 --- a/src/main/managed-server/install/remote-install-gc-claim.ts +++ b/src/main/managed-server/install/remote-install-gc-claim.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import type { RemoteInstallTransport } from './remote-install-transport' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { execHostCommand } from '../../ssh/remote-exec/remote-host-exec' import { isUnconfirmedSshCommandTermination } from '../../ssh/remote-exec/remote-exec-command' import { @@ -100,7 +100,7 @@ async function writeRelayGcClaimOwner( ? powerShellCommand( `Set-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Value ${powerShellLiteral(token)} -NoNewline -ErrorAction Stop` ) - : `printf %s ${shellEscape(token)} > ${shellEscape(ownerPath)}` + : `printf %s ${quotePosixShell(token)} > ${quotePosixShell(ownerPath)}` try { await execHostCommand(conn, host, command, { signal }) return token @@ -126,7 +126,7 @@ export async function isRelayGcClaimOwned( ? powerShellCommand( `if ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` ) - : `test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" = ${shellEscape(token)} && echo OWNED || echo LOST` + : `test "$(cat ${quotePosixShell(ownerPath)} 2>/dev/null)" = ${quotePosixShell(token)} && echo OWNED || echo LOST` const output = await execHostCommand(conn, host, command).catch((error) => { if (isUnconfirmedSshCommandTermination(error)) { throw error @@ -155,10 +155,10 @@ export async function releaseRelayGcClaim( "if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } }" ) : [ - `if ! test -e ${shellEscape(claimPath)}; then echo RELEASED;`, - `elif test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" != ${shellEscape(token)}; then echo LOST;`, + `if ! test -e ${quotePosixShell(claimPath)}; then echo RELEASED;`, + `elif test "$(cat ${quotePosixShell(ownerPath)} 2>/dev/null)" != ${quotePosixShell(token)}; then echo LOST;`, `else ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`, - `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi` + `if test -e ${quotePosixShell(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi` ].join(' ') const output = await execHostCommand(conn, host, command).catch((error) => { if (isUnconfirmedSshCommandTermination(error)) { diff --git a/src/main/managed-server/install/remote-install-gc-listing.test-fixture.ts b/src/main/managed-server/install/remote-install-gc-listing.test-fixture.ts index 41cd2109c42..815aec8eb76 100644 --- a/src/main/managed-server/install/remote-install-gc-listing.test-fixture.ts +++ b/src/main/managed-server/install/remote-install-gc-listing.test-fixture.ts @@ -6,7 +6,7 @@ import { PassThrough } from 'node:stream' import type { ClientChannel } from 'ssh2' import { runProcess, spawnProcess } from '@orca/process-host' import type { SshConnection } from '../../ssh/connection/ssh-connection' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { execCommand } from '../../ssh/remote-exec/remote-exec-command' export const SSH_EXEC_OUTPUT_CAP_CHARS = 1024 * 1024 @@ -119,7 +119,7 @@ export function createRelayGcListingFixture(): { symlinkSync(join(root, validNames[0]), join(root, 'relay-0.1.0+eee'), 'dir') return { root, - findCommand: `find ${shellEscape(root)} -mindepth 1 -maxdepth 1 -type d -name 'relay-*' -print`, + findCommand: `find ${quotePosixShell(root)} -mindepth 1 -maxdepth 1 -type d -name 'relay-*' -print`, validNames, fillValidEntryBoundary: () => { for (let index = 0; index < 63; index += 1) { diff --git a/src/main/managed-server/install/remote-install-lock-commands.ts b/src/main/managed-server/install/remote-install-lock-commands.ts index ba017807350..324f98ccf10 100644 --- a/src/main/managed-server/install/remote-install-lock-commands.ts +++ b/src/main/managed-server/install/remote-install-lock-commands.ts @@ -1,4 +1,4 @@ -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { powerShellCommand } from '../../ssh/ssh-remote-powershell' import { quotePowerShellLiteral as powerShellLiteral } from '../../../shared/powershell-native-argument' import { isWindowsRemoteHost, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' @@ -11,7 +11,7 @@ export function acquireInstallLockParentCommand( remoteRelayDir: string ): string { if (!isWindowsRemoteHost(host)) { - return `mkdir -p ${shellEscape(remoteRelayDir)}` + return `mkdir -p ${quotePosixShell(remoteRelayDir)}` } return powerShellCommand( `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(remoteRelayDir)}` @@ -28,7 +28,7 @@ export function tryCreateInstallLockCommand( ): string { if (!isWindowsRemoteHost(host)) { return [ - `if mkdir ${shellEscape(lockDir)} 2>/dev/null; then`, + `if mkdir ${quotePosixShell(lockDir)} 2>/dev/null; then`, posixCurrentBootIdentityAssignment(host, 'current_boot_id'), posixWriteBootIdentity(lockDir, 'current_boot_id'), ...posixWriteOwner(lockDir, owner), @@ -60,7 +60,7 @@ export function tryCreateInstallLockCommand( export function probeInstallLockExistsCommand(host: RemoteHostPlatform, lockPath: string): string { if (!isWindowsRemoteHost(host)) { - return `test -e ${shellEscape(lockPath)} && echo LOCKED || echo OPEN` + return `test -e ${quotePosixShell(lockPath)} && echo LOCKED || echo OPEN` } // Why: one prerelease briefly wrote file locks; accept both shapes so those // hosts remain recoverable after upgrading to directory-plus-owner locks. @@ -89,7 +89,7 @@ export function lockAgeSecondsCommand(host: RemoteHostPlatform, lockDir: string) */ export function orphanInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string { if (!isWindowsRemoteHost(host)) { - return `touch -m -t 200001010000 ${shellEscape(lockDir)}` + return `touch -m -t 200001010000 ${quotePosixShell(lockDir)}` } return powerShellCommand( `(Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop).LastWriteTimeUtc = ` + @@ -118,8 +118,8 @@ function posixStealInstallLockCommand( owner?: InstallLockOwnerFile, exitedOwner?: exitedOwnerSteal.InstallLockExitedOwner ): string { - const escapedLockDir = shellEscape(lockDir) - const escapedStealLockPrefix = shellEscape(`${lockDir}.steal`) + const escapedLockDir = quotePosixShell(lockDir) + const escapedStealLockPrefix = quotePosixShell(`${lockDir}.steal`) return [ posixCurrentBootIdentityAssignment(host, 'current_boot_id'), `${posixReadBootIdentity(lockDir, 'recorded_boot_id')}`, @@ -248,7 +248,7 @@ function posixCurrentBootIdentityAssignment( } function posixReadBootIdentity(lockDir: string, variableName: string): string { - const markerPath = shellEscape(`${lockDir}/${INSTALL_LOCK_BOOT_ID_NAME}`) + const markerPath = quotePosixShell(`${lockDir}/${INSTALL_LOCK_BOOT_ID_NAME}`) return [ `${variableName}=$(head -c 128 ${markerPath} 2>/dev/null | tr -d '\\r\\n') || ${variableName}=;`, `if ! printf '%s\\n' "$${variableName}" | grep -Eq '^(linux:[0-9a-fA-F-]{1,64}:[0-9]{1,32}|darwin:[0-9]{1,32})$'; then ${variableName}=; fi;` @@ -256,8 +256,8 @@ function posixReadBootIdentity(lockDir: string, variableName: string): string { } function posixWriteBootIdentity(lockDir: string, variableName: string): string { - const markerPath = shellEscape(`${lockDir}/${INSTALL_LOCK_BOOT_ID_NAME}`) - const markerTempPrefix = shellEscape(`${lockDir}/${INSTALL_LOCK_BOOT_ID_NAME}.tmp`) + const markerPath = quotePosixShell(`${lockDir}/${INSTALL_LOCK_BOOT_ID_NAME}`) + const markerTempPrefix = quotePosixShell(`${lockDir}/${INSTALL_LOCK_BOOT_ID_NAME}.tmp`) return [ `if [ -n "$${variableName}" ]; then`, `boot_marker_tmp=${markerTempPrefix}.$$;`, @@ -305,12 +305,12 @@ function posixLockAgeSecondsAssignment(lockDir: string): string { } function posixLockIdentityAssignment(lockDir: string, variableName: string): string { - const escapedLockDir = shellEscape(lockDir) + const escapedLockDir = quotePosixShell(lockDir) return `${variableName}=$(stat -c %Y:%i ${escapedLockDir} 2>/dev/null || stat -f %m:%i ${escapedLockDir} 2>/dev/null)` } function posixLockMtimeSecondsAssignment(lockDir: string, variableName: string): string { - const escapedLockDir = shellEscape(lockDir) + const escapedLockDir = quotePosixShell(lockDir) return `${variableName}=$(stat -c %Y ${escapedLockDir} 2>/dev/null || stat -f %m ${escapedLockDir} 2>/dev/null)` } @@ -319,7 +319,7 @@ function posixWriteOwner(lockDir: string, owner?: InstallLockOwnerFile): string[ return [] } const ownerPath = `${lockDir.replace(/\/+$/u, '')}/${owner.fileName}` - return [`printf %s ${shellEscape(owner.token)} > ${shellEscape(ownerPath)};`] + return [`printf %s ${quotePosixShell(owner.token)} > ${quotePosixShell(ownerPath)};`] } function windowsWriteOwner(owner?: InstallLockOwnerFile): string { diff --git a/src/main/managed-server/install/remote-install-lock-exited-owner.ts b/src/main/managed-server/install/remote-install-lock-exited-owner.ts index 5c372eba674..107043dcd07 100644 --- a/src/main/managed-server/install/remote-install-lock-exited-owner.ts +++ b/src/main/managed-server/install/remote-install-lock-exited-owner.ts @@ -2,7 +2,7 @@ * The steal's exited-holder condition, as each host's command reads it: `*Check` before the steal * claim, `*ClaimCheck` inside it, and `*Release` where the claim is cleaned up. */ -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import type { InstallLockOwnerFile } from './remote-install-lock-commands' import { quotePowerShellLiteral as powerShellLiteral } from '../../../shared/powershell-native-argument' import { @@ -30,9 +30,9 @@ export function posixCheck( if (!exitedOwner) { return `${variableName}=0;` } - const ownerPath = shellEscape(`${lockDir.replace(/\/+$/u, '')}/${exitedOwner.fileName}`) + const ownerPath = quotePosixShell(`${lockDir.replace(/\/+$/u, '')}/${exitedOwner.fileName}`) return ( - `${variableName}=0; if [ "$(cat ${ownerPath} 2>/dev/null)" = ${shellEscape(exitedOwner.token)} ] && ` + + `${variableName}=0; if [ "$(cat ${ownerPath} 2>/dev/null)" = ${quotePosixShell(exitedOwner.token)} ] && ` + `[ "\${${ageVariable}:-0}" -gt ${exitedOwner.quietSeconds} ] 2>/dev/null; then ${variableName}=1; fi;` ) } @@ -67,13 +67,13 @@ function posixMutationAdmission( if (!exitedOwner?.mutationLock) { return '' } - const ownerPath = shellEscape(`${lockDir.replace(/\/+$/u, '')}/${exitedOwner.fileName}`) + const ownerPath = quotePosixShell(`${lockDir.replace(/\/+$/u, '')}/${exitedOwner.fileName}`) return [ 'if [ "$current_exited" = 1 ]; then current_exited=0;', - `if mkdir ${shellEscape(exitedOwner.mutationLock)} 2>/dev/null; then held_mutation=${shellEscape(exitedOwner.mutationLock)};`, + `if mkdir ${quotePosixShell(exitedOwner.mutationLock)} 2>/dev/null; then held_mutation=${quotePosixShell(exitedOwner.mutationLock)};`, `owns_mutation=1; ${posixStateMutationPidRecord('"$held_mutation"', 'owns_mutation=0;')}`, posixStateMutationGroupRecord('"$held_mutation"'), - `[ "$owns_mutation" = 1 ] && [ "$(cat ${ownerPath} 2>/dev/null)" = ${shellEscape(exitedOwner.token)} ] &&`, + `[ "$owns_mutation" = 1 ] && [ "$(cat ${ownerPath} 2>/dev/null)" = ${quotePosixShell(exitedOwner.token)} ] &&`, `${identityAssignment(lockDir, 'again_key')} && [ "$again_key" = "$lock_key" ] && current_exited=1;`, 'fi; fi;' ].join(' ') diff --git a/src/main/managed-server/install/remote-install-transfers-exec-fallback.test.ts b/src/main/managed-server/install/remote-install-transfers-exec-fallback.test.ts index e032bcc6d7f..e4e743ae85d 100644 --- a/src/main/managed-server/install/remote-install-transfers-exec-fallback.test.ts +++ b/src/main/managed-server/install/remote-install-transfers-exec-fallback.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshConnection } from '../../ssh/connection/ssh-connection' -import { createSshOperationAbortError } from '../../ssh/ssh-connection-utils' +import { createSshOperationAbortError } from '../../ssh/connection/ssh-exec-options' import { uploadDirectoryViaExecStdin, writeStringViaExecStdin diff --git a/src/main/managed-server/install/remote-install-transport.ts b/src/main/managed-server/install/remote-install-transport.ts index 7269b36f99a..469fdf80bc1 100644 --- a/src/main/managed-server/install/remote-install-transport.ts +++ b/src/main/managed-server/install/remote-install-transport.ts @@ -1,6 +1,6 @@ import type { Duplex, Readable, Writable } from 'node:stream' import type { SshRemoteFileOptions } from '../../ssh/connection/ssh-connection-file-transfers' -import type { SshExecOptions } from '../../ssh/ssh-connection-utils' +import type { SshExecOptions } from '../../ssh/connection/ssh-exec-options' type RemoteFileWriteOptions = SshRemoteFileOptions & { signal?: AbortSignal } diff --git a/src/main/managed-server/install/remote-node-runtime-store-gc.ts b/src/main/managed-server/install/remote-node-runtime-store-gc.ts index f1725ffb30a..4aa5f2056b4 100644 --- a/src/main/managed-server/install/remote-node-runtime-store-gc.ts +++ b/src/main/managed-server/install/remote-node-runtime-store-gc.ts @@ -27,7 +27,7 @@ import { remoteNodeRuntimeStoreDir, tryWithRuntimeStoreLock } from './remote-node-runtime-store-lock' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { execCommand, isUnconfirmedSshCommandTermination @@ -145,7 +145,7 @@ export function sweepStaleRuntimeStagesCommand( return windowsSweepStaleRuntimeStagesCommand(storeDir, staleMinutes, SWEPT_STAGE) } return [ - `for s in ${shellEscape(storeDir)}/${RUNTIME_STORE_STAGE_PREFIX}*; do`, + `for s in ${quotePosixShell(storeDir)}/${RUNTIME_STORE_STAGE_PREFIX}*; do`, ' [ -d "$s" ] && [ ! -L "$s" ] || continue', ` recent=$(find "$s" -mmin -${staleMinutes} -print 2>/dev/null) || continue`, ' [ -z "$recent" ] || continue', diff --git a/src/main/managed-server/install/remote-node-runtime-store-inventory.ts b/src/main/managed-server/install/remote-node-runtime-store-inventory.ts index c3c0ad47688..697297e3ff2 100644 --- a/src/main/managed-server/install/remote-node-runtime-store-inventory.ts +++ b/src/main/managed-server/install/remote-node-runtime-store-inventory.ts @@ -7,7 +7,7 @@ import { ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_RUNTIMES_DIRNAME } from '../../../shared/orcad-artifacts' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { RELAY_REMOTE_DIR } from '../../ssh/remote-exec/remote-frame-protocol' import { joinRemotePath, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' @@ -48,7 +48,7 @@ export function runtimeStoreInventoryCommand(host: RemoteHostPlatform, remoteHom const refPrefix = RUNTIME_REF_NODE_PREFIX return [ - `root=${shellEscape(root)}`, + `root=${quotePosixShell(root)}`, `rt="$root"/${ORCAD_RUNTIMES_DIRNAME}`, `[ -d "$rt" ] || { printf '%s\\n' ${INVENTORY_OK}; exit 0; }`, `[ -r "$root" ] && [ -x "$root" ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, diff --git a/src/main/managed-server/install/remote-node-runtime-store-lock.ts b/src/main/managed-server/install/remote-node-runtime-store-lock.ts index 81beda6dc43..a7eb74d0763 100644 --- a/src/main/managed-server/install/remote-node-runtime-store-lock.ts +++ b/src/main/managed-server/install/remote-node-runtime-store-lock.ts @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto' import { ORCAD_RUNTIMES_DIRNAME } from '../../../shared/orcad-artifacts' import type { RemoteInstallTransport } from './remote-install-transport' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { execHostCommand } from '../../ssh/remote-exec/remote-host-exec' import { isUnconfirmedSshCommandTermination } from '../../ssh/remote-exec/remote-exec-command' import { acquireInstallLock, INSTALL_LOCK_STALE_SECONDS } from './remote-install-lock' @@ -50,8 +50,8 @@ function posixReleaseOwnedLockCommand( lock: string, owner: InstallLockOwnerFile ): string { - const ownerPath = shellEscape(joinRemotePath(host, lock, owner.fileName)) - return `[ "$(cat ${ownerPath} 2>/dev/null)" = ${shellEscape(owner.token)} ] && rm -rf ${shellEscape(lock)}` + const ownerPath = quotePosixShell(joinRemotePath(host, lock, owner.fileName)) + return `[ "$(cat ${ownerPath} 2>/dev/null)" = ${quotePosixShell(owner.token)} ] && rm -rf ${quotePosixShell(lock)}` } function heldLock( diff --git a/src/main/managed-server/install/remote-versioned-install.test.ts b/src/main/managed-server/install/remote-versioned-install.test.ts index 3f09fc05cb7..80f244b897a 100644 --- a/src/main/managed-server/install/remote-versioned-install.test.ts +++ b/src/main/managed-server/install/remote-versioned-install.test.ts @@ -11,8 +11,8 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ( execCommand: vi.fn() })) -vi.mock('../../ssh/ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` +vi.mock('../../../shared/shell-argument-quoting', () => ({ + quotePosixShell: (s: string) => `'${s}'` })) import { existsSync, readFileSync } from 'node:fs' diff --git a/src/main/managed-server/lifecycle/orcad-managed-remote-stop.ts b/src/main/managed-server/lifecycle/orcad-managed-remote-stop.ts index 70d58af0952..f57681521af 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-remote-stop.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-remote-stop.ts @@ -6,7 +6,7 @@ * nonce). The slot's own `orcad.js` then writes the request and proves exit on the host. */ import { randomUUID } from 'node:crypto' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { selectOrcadSlotRuntimeCommand } from '../install/orcad-remote-runtime' import { parseOrcadReadinessWaitOutput, @@ -129,7 +129,7 @@ async function runSlotCommand( : await execOrcadRemote( options, `${selectOrcadSlotRuntimeCommand(options.host, slotDir, options.nodePath)} && ` + - `"$orcad_runtime" ${shellEscape(entry)} ${flag} ${shellEscape(JSON.stringify(request))}` + `"$orcad_runtime" ${quotePosixShell(entry)} ${flag} ${quotePosixShell(JSON.stringify(request))}` ) const line = output .trim() diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-manager.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-manager.ts index 38705e7817c..59d9a80cb90 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-tunnel-manager.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel-manager.ts @@ -9,7 +9,7 @@ import { import type { SshTarget } from '../../../shared/ssh-types' import { getManagedOrcadFenceEnvironmentId } from '../../../shared/managed-orcad-ssh-owner' import type { SshConnection } from '../../ssh/connection/ssh-connection' -import { isAuthError } from '../../ssh/ssh-connection-utils' +import { isAuthError } from '../../ssh/ssh-reconnect-error-classification' import type { SshConnectionManager } from '../../ssh/connection/ssh-connection-manager' import { SshPortForwardManager } from '../../ssh/port-forwarding/ssh-port-forward' import { OrcadManagedTunnelTransportProvider } from './orcad-managed-tunnel-transport' diff --git a/src/main/managed-server/lifecycle/orcad-managed-wake.test.ts b/src/main/managed-server/lifecycle/orcad-managed-wake.test.ts index 0d03f0aaa76..8f70be98729 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-wake.test.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-wake.test.ts @@ -6,7 +6,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ( ...(await importOriginal()), execCommand: vi.fn() })) -vi.mock('../../ssh/ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('../../../shared/shell-argument-quoting', () => ({ + quotePosixShell: (s: string) => `'${s}'` +})) vi.mock('../install/remote-install-lock', async (importOriginal) => ({ ...(await importOriginal()), acquireInstallLock: vi.fn() diff --git a/src/main/managed-server/lifecycle/orcad-stdio-bridge-script.ts b/src/main/managed-server/lifecycle/orcad-stdio-bridge-script.ts index 5e6baa259a3..36c2212f731 100644 --- a/src/main/managed-server/lifecycle/orcad-stdio-bridge-script.ts +++ b/src/main/managed-server/lifecycle/orcad-stdio-bridge-script.ts @@ -13,7 +13,7 @@ import { ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE, ORCAD_RUNTIMES_DIRNAME } from '../../../shared/orcad-artifacts' -import { shellEscape } from '../../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { RELAY_REMOTE_DIR } from '../../ssh/remote-exec/remote-frame-protocol' import { ORCAD_MACHINE_HOMES_DIRNAME } from '../host-state/orcad-machine-home' @@ -87,10 +87,10 @@ export function orcadPosixStdioBridgeCommand(port: number): string { const executable = `/${ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE}` const machineStores = `${RELAY_REMOTE_DIR}/${ORCAD_MACHINE_HOMES_DIRNAME}/` return [ - `for runtime in "$HOME"/${shellEscape(store)}*${shellEscape(executable)}`, - `"$HOME"/${shellEscape(machineStores)}*/${shellEscape(store)}*${shellEscape(executable)}; do`, + `for runtime in "$HOME"/${quotePosixShell(store)}*${quotePosixShell(executable)}`, + `"$HOME"/${quotePosixShell(machineStores)}*/${quotePosixShell(store)}*${quotePosixShell(executable)}; do`, `if [ -x "$runtime" ] && "$runtime" -e 0 >/dev/null 2>&1; then`, - `exec "$runtime" -e ${shellEscape(script)} ${String(port)}; fi;`, - `done; echo ${shellEscape(ORCAD_STDIO_BRIDGE_NO_NODE)}; exit 127` + `exec "$runtime" -e ${quotePosixShell(script)} ${String(port)}; fi;`, + `done; echo ${quotePosixShell(ORCAD_STDIO_BRIDGE_NO_NODE)}; exit 127` ].join(' ') } diff --git a/src/main/orcad/orcad-bundled-runtime.integration.test.ts b/src/main/orcad/orcad-bundled-runtime.integration.test.ts index fa794db736d..95cb9f7a232 100644 --- a/src/main/orcad/orcad-bundled-runtime.integration.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.integration.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess, spawnProcess } from '@orca/process-host' import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import { orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts' -import { shellEscape } from '../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' const TARGET = 'darwin-arm64' const SHA = NODE_RUNTIME_ASSETS[TARGET].executableSha256 @@ -69,7 +69,7 @@ beforeEach(async () => { await mkdir(join(runtime, '..'), { recursive: true }) await writeFile( runtime, - `#!/bin/sh\nORCA_TEST_HANDOFF_CHILD=1 exec ${shellEscape(process.execPath)} "$@"\n` + `#!/bin/sh\nORCA_TEST_HANDOFF_CHILD=1 exec ${quotePosixShell(process.execPath)} "$@"\n` ) await chmod(runtime, 0o700) }) diff --git a/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts index f5523e89c87..74703652b69 100644 --- a/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts +++ b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts @@ -35,7 +35,7 @@ import { type RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-session-id' -import { shellEscape } from '../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { materializeOrcadArtifact } from './orcad-artifact-materializer' import { hostServerTarget, @@ -201,7 +201,10 @@ describe.skipIf(skip || process.platform === 'win32')('packaged plain-Node struc 'utf8' ) ) - writeFileSync(fakeCli, `#!/bin/sh\nexec ${shellEscape(runtime)} ${shellEscape(script)} "$@"\n`) + writeFileSync( + fakeCli, + `#!/bin/sh\nexec ${quotePosixShell(runtime)} ${quotePosixShell(script)} "$@"\n` + ) chmodSync(fakeCli, 0o700) const sessionId = randomUUID() const scenarioPath = join(root, 'scenario.json') diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 0444294a1c5..742282ce2c9 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -3,9 +3,9 @@ import { isWindowsGitBashShellPath } from '../git-bash' import { parseWslPath, toLinuxPath, toWindowsWslPath } from '../wsl' import { buildWslExecArgs, - buildWslInteractiveLoginShellCommand, - quotePosixShell + buildWslInteractiveLoginShellCommand } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { getBashWrapperLaunchArgs } from './local-pty-shell-ready' import { ensureShellReadyWrappersAt } from './local-pty-shell-ready-wrapper-generation' import { diff --git a/src/main/ripgrep/bundled-ripgrep-path.ts b/src/main/ripgrep/bundled-ripgrep-path.ts index e52a83d9076..4e69bfaa97f 100644 --- a/src/main/ripgrep/bundled-ripgrep-path.ts +++ b/src/main/ripgrep/bundled-ripgrep-path.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { existsSync, readFileSync, realpathSync } from 'node:fs' import { join } from 'node:path' import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' -import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { BUNDLED_RIPGREP_PACKAGE_BIN_DIR, BUNDLED_RIPGREP_RESOURCE_DIR, diff --git a/src/main/skills/skill-wsl-provider-detection.ts b/src/main/skills/skill-wsl-provider-detection.ts index 164529971c7..bd1ad6850f6 100644 --- a/src/main/skills/skill-wsl-provider-detection.ts +++ b/src/main/skills/skill-wsl-provider-detection.ts @@ -4,7 +4,7 @@ import { KNOWN_TUI_AGENT_DETECTION_COMMANDS, resolveDetectedTuiAgentIds } from '../../shared/tui-agent-detection-commands' -import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { runWslProcess } from '../wsl/wsl-runner' const PROVIDER_DETECTION_COMMANDS = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter((command) => diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/connection/ssh-connect-config.test.ts similarity index 72% rename from src/main/ssh/ssh-connection-utils.test.ts rename to src/main/ssh/connection/ssh-connect-config.test.ts index 760eccdfba7..ce629d58394 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/connection/ssh-connect-config.test.ts @@ -19,234 +19,12 @@ vi.mock('fs', () => ({ readFileSync: (...args: unknown[]) => mockReadFileSync(...args) })) -import { - isTransientError, - isSystemSshFallbackError, - isGssapiSystemSshFallbackCandidate, - isAuthError, - isAgentFallbackError, - shellEscape, - buildConnectConfig -} from './ssh-connection-utils' -import { findDefaultKeyFile, resolveAgentSocket } from './ssh-auth-resolution' -import { resolveEffectiveProxy } from './ssh-proxy-command' -import { walkInitialAuthLadder } from './connection/ssh-connection-test-fixtures' -import type { SshTarget } from '../../shared/ssh-types' -import type { SshResolvedConfig } from './config/ssh-g-config-resolution' - -// ── isTransientError ───────────────────────────────────────────────── - -describe('isTransientError', () => { - it('returns true for ETIMEDOUT code', () => { - const err = new Error('timed out') as NodeJS.ErrnoException - err.code = 'ETIMEDOUT' - expect(isTransientError(err)).toBe(true) - }) - - it('returns true for ECONNREFUSED code', () => { - const err = new Error('refused') as NodeJS.ErrnoException - err.code = 'ECONNREFUSED' - expect(isTransientError(err)).toBe(true) - }) - - it('returns true for ECONNRESET code', () => { - const err = new Error('reset') as NodeJS.ErrnoException - err.code = 'ECONNRESET' - expect(isTransientError(err)).toBe(true) - }) - - it('returns true for EHOSTUNREACH code', () => { - const err = new Error('host unreachable') as NodeJS.ErrnoException - err.code = 'EHOSTUNREACH' - expect(isTransientError(err)).toBe(true) - }) - - it('returns true for ENETUNREACH code', () => { - const err = new Error('net unreachable') as NodeJS.ErrnoException - err.code = 'ENETUNREACH' - expect(isTransientError(err)).toBe(true) - }) - - it('returns true for EAI_AGAIN code', () => { - const err = new Error('dns') as NodeJS.ErrnoException - err.code = 'EAI_AGAIN' - expect(isTransientError(err)).toBe(true) - }) - - it('returns true for ETIMEDOUT in message (no code)', () => { - expect(isTransientError(new Error('connect ETIMEDOUT 1.2.3.4:22'))).toBe(true) - }) - - it('returns true for ECONNREFUSED in message', () => { - expect(isTransientError(new Error('connect ECONNREFUSED 1.2.3.4:22'))).toBe(true) - }) - - it('returns true for ECONNRESET in message', () => { - expect(isTransientError(new Error('read ECONNRESET'))).toBe(true) - }) - - it('returns true for the bounded SSH authentication watchdog', () => { - const timeout = Object.assign(new Error('Timed out while waiting for SSH authentication'), { - level: 'client-timeout' - }) - - expect(isTransientError(timeout)).toBe(true) - expect(isTransientError(new Error('Timed out while waiting for SSH authentication'))).toBe(true) - }) - - it('returns false for auth errors', () => { - expect(isTransientError(new Error('All configured authentication methods failed'))).toBe(false) - }) - - it('returns false for generic errors', () => { - expect(isTransientError(new Error('something went wrong'))).toBe(false) - }) -}) - -// ── isSystemSshFallbackError ───────────────────────────────────────── - -describe('isSystemSshFallbackError', () => { - it('returns true for local reachability errors that system ssh may bypass', () => { - const hostErr = new Error('host unreachable') as NodeJS.ErrnoException - hostErr.code = 'EHOSTUNREACH' - const netErr = new Error('net unreachable') as NodeJS.ErrnoException - netErr.code = 'ENETUNREACH' - - expect(isSystemSshFallbackError(hostErr)).toBe(true) - expect(isSystemSshFallbackError(netErr)).toBe(true) - }) - - it('returns false for transient errors that should keep the normal retry path', () => { - const refused = new Error('refused') as NodeJS.ErrnoException - refused.code = 'ECONNREFUSED' - - expect(isSystemSshFallbackError(refused)).toBe(false) - expect(isSystemSshFallbackError(new Error('connect ETIMEDOUT 1.2.3.4:22'))).toBe(false) - }) -}) - -// ── isGssapiSystemSshFallbackCandidate ─────────────────────────────── - -describe('isGssapiSystemSshFallbackCandidate', () => { - const authErr = new Error('All configured authentication methods failed') - - it('returns true for auth failures when resolved config enables GSSAPI', () => { - expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: true })).toBe( - true - ) - }) - - it('returns true for passphrase failures so Kerberos SSO runs before prompting', () => { - const passphraseErr = new Error('Encrypted private OpenSSH key detected, but no passphrase') - expect( - isGssapiSystemSshFallbackCandidate(passphraseErr, {}, { gssapiAuthentication: true }) - ).toBe(true) - }) - - it('returns false when the target already tried system ssh proactively', () => { - expect( - isGssapiSystemSshFallbackCandidate( - authErr, - { gssapiAuthentication: true }, - { gssapiAuthentication: true } - ) - ).toBe(false) - }) - - it('returns false without GSSAPI in the resolved config', () => { - expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: false })).toBe( - false - ) - expect(isGssapiSystemSshFallbackCandidate(authErr, {}, null)).toBe(false) - }) - - it('returns false for network errors so retry semantics stay unchanged', () => { - const netErr = new Error('connect ETIMEDOUT 1.2.3.4:22') - expect(isGssapiSystemSshFallbackCandidate(netErr, {}, { gssapiAuthentication: true })).toBe( - false - ) - }) -}) - -// ── isAuthError ────────────────────────────────────────────────────── - -describe('isAuthError', () => { - it('returns true for "All configured authentication methods failed"', () => { - expect(isAuthError(new Error('All configured authentication methods failed'))).toBe(true) - }) - - it('returns true for "Authentication failed"', () => { - expect(isAuthError(new Error('Authentication failed'))).toBe(true) - }) - - it('returns true for client-authentication level', () => { - const err = new Error('auth') as Error & { level: string } - err.level = 'client-authentication' - expect(isAuthError(err)).toBe(true) - }) - - it('returns true for server auth-attempt exhaustion', () => { - expect(isAuthError(new Error('Received disconnect: Too many authentication failures'))).toBe( - true - ) - }) - - it.each([ - 'Permission denied (publickey).', - 'Permission denied (publickey,password).', - 'Permission denied, please try again.' - ])('detects OpenSSH credential rejection: %s', (message) => { - expect(isAuthError(new Error(message))).toBe(true) - }) - - it('returns false for transient errors', () => { - expect(isAuthError(new Error('connect ETIMEDOUT'))).toBe(false) - }) - - it('does not classify a local filesystem permission failure as authentication', () => { - expect(isAuthError(new Error('Permission denied (os error 13)'))).toBe(false) - }) -}) - -// ── isAgentFallbackError ──────────────────────────────────────────── - -describe('isAgentFallbackError', () => { - it('returns true for ssh2 agent-level failures', () => { - const err = new Error('Failed to connect to agent') as Error & { level: string } - err.level = 'agent' - expect(isAgentFallbackError(err)).toBe(true) - }) - - it('returns true when agent auth exhausts the server auth attempt limit', () => { - expect( - isAgentFallbackError(new Error('Received disconnect: Too many authentication failures')) - ).toBe(true) - }) - - it('keeps unrelated transport errors out of agent fallback handling', () => { - expect(isAgentFallbackError(new Error('connect ECONNRESET'))).toBe(false) - }) -}) - -// ── shellEscape ────────────────────────────────────────────────────── - -describe('shellEscape', () => { - it('wraps string in single quotes', () => { - expect(shellEscape('hello')).toBe("'hello'") - }) - - it('escapes embedded single quotes', () => { - expect(shellEscape("it's")).toBe("'it'\\''s'") - }) - - it('handles empty string', () => { - expect(shellEscape('')).toBe("''") - }) - - it('handles special characters', () => { - expect(shellEscape('foo bar; rm -rf /')).toBe("'foo bar; rm -rf /'") - }) -}) +import { buildConnectConfig } from './ssh-connect-config' +import { findDefaultKeyFile, resolveAgentSocket } from '../ssh-auth-resolution' +import { resolveEffectiveProxy } from '../ssh-proxy-command' +import { walkInitialAuthLadder } from './ssh-connection-test-fixtures' +import type { SshTarget } from '../../../shared/ssh-types' +import type { SshResolvedConfig } from '../config/ssh-g-config-resolution' // ── findDefaultKeyFile ─────────────────────────────────────────────── diff --git a/src/main/ssh/connection/ssh-connect-config.ts b/src/main/ssh/connection/ssh-connect-config.ts new file mode 100644 index 00000000000..ca32d8f7b16 --- /dev/null +++ b/src/main/ssh/connection/ssh-connect-config.ts @@ -0,0 +1,126 @@ +import type { ConnectConfig } from 'ssh2' +import type { SshTarget } from '../../../shared/ssh-types' +import type { SshResolvedConfig } from '../config/ssh-g-config-resolution' +import { + findEncryptedPrivateKeyPath, + isUnencryptedPrivateKey, + resolveAgentConfigValue, + resolveAgentSocket, + resolvePrivateKeys, + resolveUnencryptedExplicitPrivateKeys +} from '../ssh-auth-resolution' +import { configurePrivateKeyAuthentication } from '../ssh-private-key-authentication' +import { isOpenSshConfigBackedTarget } from '../system-transport/system-ssh-args' +import { CONNECT_TIMEOUT_MS } from './ssh-connection-timing' + +export type BuildConnectConfigOptions = { + includeAgent?: boolean + includePrivateKey?: boolean + /** No agent and no key: only the host's keyboard-interactive challenge (and a cached password). */ + keyboardInteractiveOnly?: boolean +} + +// Why: ssh2 tries privateKey before agent, but parses encrypted privateKey +// values before any agent auth can run. Keep unencrypted explicit keys first +// while deferring encrypted keys until the post-agent passphrase path. +export function buildConnectConfig( + target: SshTarget, + resolved: SshResolvedConfig | null, + options: BuildConnectConfigOptions = {} +): ConnectConfig { + const effectiveHost = resolveEffectiveHost(target, resolved) + const effectivePort = resolveEffectivePort(target, resolved) + const effectiveUser = + isOpenSshConfigBackedTarget(target) && resolved + ? (resolved.user ?? target.username) + : target.username || resolved?.user || '' + + const config: Record = { + host: effectiveHost, + port: effectivePort, + username: effectiveUser, + readyTimeout: CONNECT_TIMEOUT_MS, + keepaliveInterval: 15_000, + // Why: lets servers deliver keyboard-interactive challenges (RFC 4256, + // commonly MFA after a partial password success). SshConnection answers + // them through the credential prompt callback. + tryKeyboard: true + } + + const shouldIncludeAgent = !options.keyboardInteractiveOnly && (options.includeAgent ?? true) + const agentSocket = shouldIncludeAgent ? resolveAgentSocket(target, resolved) : undefined + const agent = agentSocket ? resolveAgentConfigValue(agentSocket, target, resolved) : undefined + + if (agent) { + config.agent = agent + } + + if (agent && resolved?.forwardAgent) { + config.agentForward = true + } + + const keys = options.keyboardInteractiveOnly + ? [] + : (options.includePrivateKey ?? !agent) + ? resolvePrivateKeys(target, resolved) + : resolveUnencryptedExplicitPrivateKeys(target, resolved) + let deferralDecision: boolean | undefined + configurePrivateKeyAuthentication( + config as ConnectConfig, + keys, + findEncryptedPrivateKeyPath(keys), + { + // Why: SshConnection retries with the deferred keys only after an attempt that carried none, + // so only that attempt holds the host's password challenge back for them. An encrypted key + // would swap that dialog for a passphrase one, and a file that is no usable private key + // aborts the retry before any auth, so hold it back only if no deferred key needs input. + // One key only: each rejected key spends the host's MaxAuthTries, so several could exhaust + // the retry before it reaches the challenge this attempt skipped. + // Decided lazily so the deferred files are read only once the agent has failed, as the retry + // would read them anyway, never on an agent login that needs no file. + deferKeyboardInteractive: + keys.length === 0 && !options.keyboardInteractiveOnly + ? () => { + if (deferralDecision === undefined) { + const deferredKeys = resolvePrivateKeys(target, resolved) + deferralDecision = + deferredKeys.length === 1 && isUnencryptedPrivateKey(deferredKeys[0].contents) + } + return deferralDecision + } + : undefined + } + ) + + return config as ConnectConfig +} + +function resolveEffectiveHost(target: SshTarget, resolved: SshResolvedConfig | null): string { + if (isOpenSshConfigBackedTarget(target) && resolved?.hostname) { + return resolved.hostname + } + if (shouldUseResolvedEndpoint(target, resolved)) { + return resolved!.hostname + } + return target.host || resolved?.hostname || target.label +} + +function resolveEffectivePort(target: SshTarget, resolved: SshResolvedConfig | null): number { + if (isOpenSshConfigBackedTarget(target) && resolved) { + return resolved.port || target.port || 22 + } + // Why: imported config aliases store 22 as the schema default even when an + // included/wildcard OpenSSH rule later resolves a different effective Port. + if (target.configHost && target.port === 22 && resolved?.port) { + return resolved.port + } + return target.port || resolved?.port || 22 +} + +function shouldUseResolvedEndpoint(target: SshTarget, resolved: SshResolvedConfig | null): boolean { + if (!target.configHost || !resolved?.hostname) { + return false + } + const host = target.host.trim() + return host === '' || host === target.configHost || host === target.label +} diff --git a/src/main/ssh/connection/ssh-connection-callbacks.ts b/src/main/ssh/connection/ssh-connection-callbacks.ts new file mode 100644 index 00000000000..79618f43658 --- /dev/null +++ b/src/main/ssh/connection/ssh-connection-callbacks.ts @@ -0,0 +1,16 @@ +import type { SshConnectionState } from '../../../shared/ssh-types' + +export type SshCredentialKind = 'passphrase' | 'password' | 'keyboard-interactive' + +export type SshConnectionCallbacks = { + onStateChange: (targetId: string, state: SshConnectionState) => void + // Why: echo carries the server's RFC 4256 echo flag for keyboard-interactive + // prompts so the UI knows whether the typed response may be shown. + onCredentialRequest?: ( + targetId: string, + kind: SshCredentialKind, + detail: string, + echo?: boolean, + signal?: AbortSignal + ) => Promise +} diff --git a/src/main/ssh/connection/ssh-connection-manager.test.ts b/src/main/ssh/connection/ssh-connection-manager.test.ts index 536ddfc9fb7..d2bdb40ce3a 100644 --- a/src/main/ssh/connection/ssh-connection-manager.test.ts +++ b/src/main/ssh/connection/ssh-connection-manager.test.ts @@ -54,7 +54,7 @@ import { recordSshSignInAwaited, resetSshCredentialDeclinesForTests } from '../ssh-credential-decline' -import { isAuthError } from '../ssh-connection-utils' +import { isAuthError } from '../ssh-reconnect-error-classification' import { recordSshUserDisconnected, resetSshUserDisconnectsForTests } from '../ssh-user-disconnect' const target = { diff --git a/src/main/ssh/connection/ssh-connection-manager.ts b/src/main/ssh/connection/ssh-connection-manager.ts index bc0b35d9fe5..898b9199cc5 100644 --- a/src/main/ssh/connection/ssh-connection-manager.ts +++ b/src/main/ssh/connection/ssh-connection-manager.ts @@ -1,6 +1,6 @@ import type { SshTarget, SshConnectionState } from '../../../shared/ssh-types' import { SshConnection, type SshCachedCredentials } from './ssh-connection' -import type { SshConnectionCallbacks } from '../ssh-connection-utils' +import type { SshConnectionCallbacks } from './ssh-connection-callbacks' import { recordSshConnectionOpened, recordSshConnectionReused } from './ssh-connection-attribution' import { createSshCredentialDeclinedError, diff --git a/src/main/ssh/connection/ssh-connection-reconnect-handshake-loss.test.ts b/src/main/ssh/connection/ssh-connection-reconnect-handshake-loss.test.ts index 5d9bdcf331a..c4b1a26c348 100644 --- a/src/main/ssh/connection/ssh-connection-reconnect-handshake-loss.test.ts +++ b/src/main/ssh/connection/ssh-connection-reconnect-handshake-loss.test.ts @@ -7,7 +7,7 @@ import { import { clientInstances, emitSshEvent, ssh2Mock } from '../__tests__/ssh-connection-test-client' import { createCallbacks, createTarget } from './ssh-connection-test-fixtures' import { SshConnection } from './ssh-connection' -import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from '../ssh-connection-utils' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-timing' vi.mock('ssh2', async () => (await import('../__tests__/ssh-connection-test-client')).createSsh2Module() diff --git a/src/main/ssh/connection/ssh-connection-reconnect-ladder.test.ts b/src/main/ssh/connection/ssh-connection-reconnect-ladder.test.ts index 38efab06f1a..ade6e7ba11f 100644 --- a/src/main/ssh/connection/ssh-connection-reconnect-ladder.test.ts +++ b/src/main/ssh/connection/ssh-connection-reconnect-ladder.test.ts @@ -20,7 +20,7 @@ import { } from './ssh-connection-test-fixtures' import { SshConnection } from './ssh-connection' import { resolveWithSshG } from '../config/ssh-g-config-resolution' -import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from '../ssh-connection-utils' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-timing' import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../../shared/ssh-types' vi.mock('ssh2', async () => diff --git a/src/main/ssh/connection/ssh-connection-system-transport.test.ts b/src/main/ssh/connection/ssh-connection-system-transport.test.ts index bb4f841957b..2d47ed8e459 100644 --- a/src/main/ssh/connection/ssh-connection-system-transport.test.ts +++ b/src/main/ssh/connection/ssh-connection-system-transport.test.ts @@ -22,7 +22,7 @@ import { SshConnection } from './ssh-connection' import { shouldUseSystemSshTransport } from '../ssh-transport-selection' import { resolveWithSshG } from '../config/ssh-g-config-resolution' import { writeFileViaSystemSsh } from '../system-transport/system-ssh-file-transfer' -import { CONNECT_TIMEOUT_MS } from '../ssh-connection-utils' +import { CONNECT_TIMEOUT_MS } from './ssh-connection-timing' import type { SshTarget } from '../../../shared/ssh-types' import { createOpenSshPrivateKeyFixture, diff --git a/src/main/ssh/connection/ssh-connection-test-fixtures.ts b/src/main/ssh/connection/ssh-connection-test-fixtures.ts index 3eb40fc0318..f52badec2e7 100644 --- a/src/main/ssh/connection/ssh-connection-test-fixtures.ts +++ b/src/main/ssh/connection/ssh-connection-test-fixtures.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events' import { vi } from 'vitest' import type { Mock } from 'vitest' import type { AnyAuthMethod, AuthenticationType } from 'ssh2' -import type { SshConnectionCallbacks } from '../ssh-connection-utils' +import type { SshConnectionCallbacks } from './ssh-connection-callbacks' import type { SshResolvedConfig } from '../config/ssh-g-config-resolution' import type { SshTarget } from '../../../shared/ssh-types' diff --git a/src/main/ssh/connection/ssh-connection-timing.ts b/src/main/ssh/connection/ssh-connection-timing.ts new file mode 100644 index 00000000000..1a90723c72f --- /dev/null +++ b/src/main/ssh/connection/ssh-connection-timing.ts @@ -0,0 +1,9 @@ +export const INITIAL_RETRY_ATTEMPTS = 5 +export const INITIAL_RETRY_DELAY_MS = 2000 +export const RECONNECT_BACKOFF_MS = [1000, 2000, 5000, 5000, 10000, 10000, 10000, 30000, 30000] +export const CONNECT_TIMEOUT_MS = 30_000 +export const SSH_CREDENTIAL_TIMEOUT_MS = 120_000 + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/main/ssh/connection/ssh-connection-wake-retry.test.ts b/src/main/ssh/connection/ssh-connection-wake-retry.test.ts index ded760113a4..18c54166fa5 100644 --- a/src/main/ssh/connection/ssh-connection-wake-retry.test.ts +++ b/src/main/ssh/connection/ssh-connection-wake-retry.test.ts @@ -16,7 +16,7 @@ import { import { SshConnection } from './ssh-connection' import { resolveWithSshG } from '../config/ssh-g-config-resolution' import { SshConnectionManager } from './ssh-connection-manager' -import { RECONNECT_BACKOFF_MS } from '../ssh-connection-utils' +import { RECONNECT_BACKOFF_MS } from './ssh-connection-timing' import { recordSshUserDisconnected, resetSshUserDisconnectsForTests } from '../ssh-user-disconnect' vi.mock('ssh2', async () => diff --git a/src/main/ssh/connection/ssh-connection.ts b/src/main/ssh/connection/ssh-connection.ts index 87072c1b74c..bf1a0a5808a 100644 --- a/src/main/ssh/connection/ssh-connection.ts +++ b/src/main/ssh/connection/ssh-connection.ts @@ -34,20 +34,22 @@ import { RECONNECT_BACKOFF_MS, CONNECT_TIMEOUT_MS, SSH_CREDENTIAL_TIMEOUT_MS, + sleep +} from './ssh-connection-timing' +import { isTransientError, isAuthError, isAgentFallbackError, isSystemSshFallbackError, isGssapiSystemSshFallbackCandidate, isPassphraseError, - sleep, - buildConnectConfig, - wrapRemoteCommandForPosixShell, - createSshOperationAbortError, - type SshExecOptions, - type SshConnectionCallbacks, - type SshCredentialKind -} from '../ssh-connection-utils' + isDefiniteSystemSshHostFailure, + isTransientReconnectError +} from '../ssh-reconnect-error-classification' +import { buildConnectConfig } from './ssh-connect-config' +import { wrapRemoteCommandForPosixShell } from '../ssh-posix-command-wrapper' +import { createSshOperationAbortError, type SshExecOptions } from './ssh-exec-options' +import type { SshConnectionCallbacks, SshCredentialKind } from './ssh-connection-callbacks' import { collectKeyboardInteractiveResponses } from '../ssh-keyboard-interactive' import { resolveEffectiveProxy, spawnProxyCommand } from '../ssh-proxy-command' import { @@ -78,10 +80,6 @@ import { createCancelledConnectAttemptError, isCancelledConnectAttemptError } from '../ssh-connect-attempt-cancellation' -import { - isDefiniteSystemSshHostFailure, - isTransientReconnectError -} from '../ssh-reconnect-error-classification' import { SshReconnectLadder } from '../ssh-reconnect-ladder' import { mayUserSshConfigClaimAlias } from '../config/ssh-config-alias-claim' import { diff --git a/src/main/ssh/connection/ssh-exec-options.ts b/src/main/ssh/connection/ssh-exec-options.ts new file mode 100644 index 00000000000..61cbea6a63a --- /dev/null +++ b/src/main/ssh/connection/ssh-exec-options.ts @@ -0,0 +1,10 @@ +export type SshExecOptions = { + wrapCommand?: boolean + signal?: AbortSignal +} + +export function createSshOperationAbortError(): Error & { name: string } { + const error = new Error('SSH operation was cancelled') + error.name = 'AbortError' + return error +} diff --git a/src/main/ssh/hostile-host-test-support/ssh-hostile-host-managed-orcad.ts b/src/main/ssh/hostile-host-test-support/ssh-hostile-host-managed-orcad.ts index 70e071e8723..5b7f515fe19 100644 --- a/src/main/ssh/hostile-host-test-support/ssh-hostile-host-managed-orcad.ts +++ b/src/main/ssh/hostile-host-test-support/ssh-hostile-host-managed-orcad.ts @@ -21,7 +21,7 @@ import { } from '../../managed-server/install/orcad-remote-launch' import { execOrcadRemote } from '../../managed-server/install/orcad-remote-runtime-control' import { decommissionRemoteOrcad } from '../../managed-server/install/orcad-remote-decommission' -import { shellEscape } from '../ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { forbiddenToolActions, type HostileHostCellCore, @@ -62,7 +62,7 @@ export async function proveManagedOrcadCell( } const toolLog = await hostExec( target, - `cat ${shellEscape(target.forbiddenToolLog)} 2>/dev/null || true` + `cat ${quotePosixShell(target.forbiddenToolLog)} 2>/dev/null || true` ) expect(forbiddenToolActions(toolLog)).toEqual([]) } finally { diff --git a/src/main/ssh/legacy-relay/legacy-relay-endpoints.ts b/src/main/ssh/legacy-relay/legacy-relay-endpoints.ts index 1d9c10978ff..96263f12bfa 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-endpoints.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-endpoints.ts @@ -6,7 +6,7 @@ * anything. Another desktop's relay hashes another target id, so it is never listed here. */ import type { SshConnection } from '../connection/ssh-connection' -import { shellEscape } from '../ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { RELAY_REMOTE_DIR } from '../remote-exec/remote-frame-protocol' import { SHORT_RELAY_SOCKET_DIR_PREFIX } from '../relay-socket-path-limit' import { execCommand } from '../remote-exec/remote-exec-command' @@ -51,9 +51,9 @@ export type LegacyRelayEndpointListing = | { kind: 'unverifiable' } export function legacyRelayEndpointListCommand(remoteHome: string, sockName: string): string { - const name = shellEscape(sockName) + const name = quotePosixShell(sockName) return [ - `base=${shellEscape(`${remoteHome}/${RELAY_REMOTE_DIR}`)}`, + `base=${quotePosixShell(`${remoteHome}/${RELAY_REMOTE_DIR}`)}`, `short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`, `for sock in "$base"/relay-*/${name} "$short_base"/relay-*/${name}; do`, ' [ -S "$sock" ] && printf \'%s\\n\' "$sock"', @@ -81,9 +81,9 @@ export function posixLegacyRelayConnectCommand( } const relayDir = `${remoteHome}/${RELAY_REMOTE_DIR}/${versionDir}` return ( - `cd ${shellEscape(relayDir)} && ${shellEscape(nodePath)} relay.js --connect ` + - `--sock-path ${shellEscape(sockPath)} ` + - `--credential-file ${shellEscape(`${relayDir}/${sockName}.credential`)}` + `cd ${quotePosixShell(relayDir)} && ${quotePosixShell(nodePath)} relay.js --connect ` + + `--sock-path ${quotePosixShell(sockPath)} ` + + `--credential-file ${quotePosixShell(`${relayDir}/${sockName}.credential`)}` ) } diff --git a/src/main/ssh/legacy-relay/ssh-host-relay-endpoint-census.ts b/src/main/ssh/legacy-relay/ssh-host-relay-endpoint-census.ts index 8ffd098c12b..1b4d28a6d59 100644 --- a/src/main/ssh/legacy-relay/ssh-host-relay-endpoint-census.ts +++ b/src/main/ssh/legacy-relay/ssh-host-relay-endpoint-census.ts @@ -7,7 +7,7 @@ * that failed, ran out of room, or an endpoint the probe could not classify is `unverifiable`. */ import type { SshConnection } from '../connection/ssh-connection' -import { shellEscape } from '../ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { RELAY_REMOTE_DIR } from '../remote-exec/remote-frame-protocol' import { SHORT_RELAY_SOCKET_DIR_PREFIX } from '../relay-socket-path-limit' import { execCommand } from '../remote-exec/remote-exec-command' @@ -33,7 +33,7 @@ const MAX_CENSUS_ENDPOINTS = 32 */ export function hostRelayEndpointListCommand(remoteHome: string): string { return [ - `base=${shellEscape(`${remoteHome}/${RELAY_REMOTE_DIR}`)}`, + `base=${quotePosixShell(`${remoteHome}/${RELAY_REMOTE_DIR}`)}`, `short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`, 'for sock in "$base"/relay-*/relay*.sock "$short_base"/relay-*/relay*.sock; do', ' [ -S "$sock" ] && printf \'%s\\n\' "$sock"', diff --git a/src/main/ssh/legacy-relay/ssh-relay-endpoint-incumbent.ts b/src/main/ssh/legacy-relay/ssh-relay-endpoint-incumbent.ts index eb920301af5..e0f70a045ac 100644 --- a/src/main/ssh/legacy-relay/ssh-relay-endpoint-incumbent.ts +++ b/src/main/ssh/legacy-relay/ssh-relay-endpoint-incumbent.ts @@ -22,7 +22,7 @@ */ import { RELAY_LSOF_PROBE_JS } from '@orca/process-host/posix-lsof-probe' import type { SshConnection } from '../connection/ssh-connection' -import { shellEscape } from '../ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { RELAY_CHILD_COUNT_VAR, RELAY_UNRECOGNIZED_CHILD_COUNT_VAR, @@ -106,15 +106,15 @@ export class RelayProbeCleanupUnconfirmedError extends Error { * explicit "could not tell" value; nothing is inferred from a missing tool. */ export function relayEndpointIncumbentProbeCommand(nodePath: string, sockPath: string): string { - const sock = shellEscape(sockPath) - const node = shellEscape(nodePath) + const sock = quotePosixShell(sockPath) + const node = quotePosixShell(nodePath) return [ `sock=${sock}`, `node=${node}`, - `printf '%s\\n' ${shellEscape(PROBE_BEGIN)}`, + `printf '%s\\n' ${quotePosixShell(PROBE_BEGIN)}`, 'if [ -S "$sock" ]; then', " printf 'PRESENT=yes\\n'", - ` listen=$("$node" -e ${shellEscape(RELAY_CONNECT_PROBE_JS)} "$sock" 2>/dev/null) || listen=unknown`, + ` listen=$("$node" -e ${quotePosixShell(RELAY_CONNECT_PROBE_JS)} "$sock" 2>/dev/null) || listen=unknown`, ' [ -n "$listen" ] || listen=unknown', 'else', " printf 'PRESENT=no\\n'", @@ -122,19 +122,19 @@ export function relayEndpointIncumbentProbeCommand(nodePath: string, sockPath: s 'fi', 'printf \'LISTEN=%s\\n\' "$listen"', 'dir=$(dirname "$sock")', - `fs_type=$("$node" -e ${shellEscape(ENDPOINT_FILESYSTEM_PROBE_JS)} "$dir" 2>/dev/null) || fs_type=`, + `fs_type=$("$node" -e ${quotePosixShell(ENDPOINT_FILESYSTEM_PROBE_JS)} "$dir" 2>/dev/null) || fs_type=`, 'if [ -n "$fs_type" ]; then :', // BSD stat has no filesystem type; macOS names it in `mount` against df's mount point. 'elif [ "$(uname -s 2>/dev/null)" = Darwin ]; then', ` mp=$(df -P "$dir" 2>/dev/null | awk 'NR==2{print $NF}')`, - ` fs_type="darwin-mount $(mount 2>/dev/null | awk -v mp="$mp" ${shellEscape(DARWIN_MOUNT_TYPE_AWK)})"`, + ` fs_type="darwin-mount $(mount 2>/dev/null | awk -v mp="$mp" ${quotePosixShell(DARWIN_MOUNT_TYPE_AWK)})"`, 'else', ' fs_type="$(uname -s 2>/dev/null) $(stat -f -c %t "$dir" 2>/dev/null) $(stat -f -c %t / 2>/dev/null)"', 'fi', 'printf \'FS_TYPE=%s\\n\' "$fs_type"', 'if command -v lsof >/dev/null 2>&1; then', // Why -a: lsof ORs selectors without it and reports unrelated unix-socket holders (#8762). - ` lsof_result=$("$node" -e ${shellEscape(RELAY_LSOF_PROBE_JS)} "$sock" 2>/dev/null) || lsof_result=unavailable`, + ` lsof_result=$("$node" -e ${quotePosixShell(RELAY_LSOF_PROBE_JS)} "$sock" 2>/dev/null) || lsof_result=unavailable`, ' case "$lsof_result" in', ' cleanup-unconfirmed*)', " printf 'PROBE_CLEANUP=unconfirmed\\n'", @@ -159,7 +159,7 @@ export function relayEndpointIncumbentProbeCommand(nodePath: string, sockPath: s 'else', " printf 'HOLDERS_SOURCE=unavailable\\n'", 'fi', - `printf '%s\\n' ${shellEscape(PROBE_END)}` + `printf '%s\\n' ${quotePosixShell(PROBE_END)}` ].join('\n') } diff --git a/src/main/ssh/legacy-relay/ssh-relay-endpoint-pty-count.ts b/src/main/ssh/legacy-relay/ssh-relay-endpoint-pty-count.ts index 26f148b7991..05d64a2680b 100644 --- a/src/main/ssh/legacy-relay/ssh-relay-endpoint-pty-count.ts +++ b/src/main/ssh/legacy-relay/ssh-relay-endpoint-pty-count.ts @@ -8,7 +8,7 @@ */ import type { SshConnection } from '../connection/ssh-connection' import { SshChannelMultiplexer } from '../ssh-channel-multiplexer' -import { shellEscape } from '../ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { SHORT_RELAY_SOCKET_DIR_PREFIX } from '../relay-socket-path-limit' import { waitForSentinel } from './ssh-relay-sentinel' @@ -41,8 +41,8 @@ export function relayBridgeConnectCommand(nodePath: string, sockPath: string): s return null } return ( - `cd ${shellEscape(relayDir)} && ${shellEscape(nodePath)} relay.js --connect ` + - `--sock-path ${shellEscape(sockPath)} --credential-file ${shellEscape(`${sockPath}.credential`)}` + `cd ${quotePosixShell(relayDir)} && ${quotePosixShell(nodePath)} relay.js --connect ` + + `--sock-path ${quotePosixShell(sockPath)} --credential-file ${quotePosixShell(`${sockPath}.credential`)}` ) } diff --git a/src/main/ssh/legacy-relay/ssh-relay-sentinel.ts b/src/main/ssh/legacy-relay/ssh-relay-sentinel.ts index 8d5ef3969c2..f1a1d2c0f6e 100644 --- a/src/main/ssh/legacy-relay/ssh-relay-sentinel.ts +++ b/src/main/ssh/legacy-relay/ssh-relay-sentinel.ts @@ -1,5 +1,5 @@ import type { ClientChannel } from 'ssh2' -import { createSshOperationAbortError } from '../ssh-connection-utils' +import { createSshOperationAbortError } from '../connection/ssh-exec-options' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../remote-exec/remote-frame-protocol' import type { MultiplexerTransport } from '../ssh-channel-multiplexer' import { buildRelayHandshakeRefusalError } from './ssh-relay-handshake-mismatch' diff --git a/src/main/ssh/orcad-exited-own-lock.ts b/src/main/ssh/orcad-exited-own-lock.ts index d28d8ae55b5..02af5207f11 100644 --- a/src/main/ssh/orcad-exited-own-lock.ts +++ b/src/main/ssh/orcad-exited-own-lock.ts @@ -29,7 +29,7 @@ import { ORCAD_STATE_MUTATION_LOCK_DIRNAME } from '../managed-server/host-state/orcad-state-snapshot-members' import { CMD_EXE_COMMAND_LINE_MAX_CHARS } from '../providers/windows-shell-args' -import { shellEscape } from './ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import type { InstallLockExitedOwnerProof } from '../managed-server/install/remote-install-lock' import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform' @@ -126,11 +126,11 @@ function exitedOwnLockCheckCommand( const quiet = (path: string): string => `[ -n "$(find ${path} -maxdepth 0 -mmin +${ORCAD_EXITED_OWN_LOCK_QUIET_SECONDS / 60} 2>/dev/null)" ]` return [ - `${posixOrcadFenceOwnedTest(lock)} && ${quiet(shellEscape(lock.lockDir))} || exit 0;`, + `${posixOrcadFenceOwnedTest(lock)} && ${quiet(quotePosixShell(lock.lockDir))} || exit 0;`, ...(mutationLock ? [ // Any mutation lock refuses, as the steal does: it can only take an absent one. - `[ -e ${shellEscape(mutationLock)} ] && exit 0;` + `[ -e ${quotePosixShell(mutationLock)} ] && exit 0;` ] : []), 'echo EXITED_OWNER' diff --git a/src/main/ssh/orcad-initial-activation-admission.ts b/src/main/ssh/orcad-initial-activation-admission.ts index 2464f542e6f..518446406c8 100644 --- a/src/main/ssh/orcad-initial-activation-admission.ts +++ b/src/main/ssh/orcad-initial-activation-admission.ts @@ -7,7 +7,7 @@ */ import { ORCAD_LOCK_FILE_NAME } from '../orcad/orcad-instance-lock' import { PRIMARY_RUNTIME_METADATA_FILE } from '../../shared/runtime-bootstrap' -import { shellEscape } from './ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { selectOrcadSlotRuntimeCommand } from '../managed-server/install/orcad-remote-runtime' import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { orcadWindowsSlotOpCommand } from '../managed-server/install/orcad-remote-windows-node' @@ -65,7 +65,7 @@ export function initialOrcadActivationAdmissionCommand( // The subshell turns the selector's `exit 78` into silence, which parses as unverifiable. return ( `(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, legacyNodePath)}; ` + - `"$orcad_runtime" -e ${shellEscape(script)} ${shellEscape(JSON.stringify(owners))})` + `"$orcad_runtime" -e ${quotePosixShell(script)} ${quotePosixShell(JSON.stringify(owners))})` ) } diff --git a/src/main/ssh/orcad-launch-ssh-session.ts b/src/main/ssh/orcad-launch-ssh-session.ts index 91cfe42a20d..64def148fd7 100644 --- a/src/main/ssh/orcad-launch-ssh-session.ts +++ b/src/main/ssh/orcad-launch-ssh-session.ts @@ -11,7 +11,7 @@ * spawned by the daemon, which outlives orcad restarts and keeps the attribution it was spawned * with, so a launch that finds a live daemon carries the earlier session forward. */ -import { shellEscape } from './ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { ORCAD_PID_FILENAME } from '../managed-server/install/orcad-remote-host-support' @@ -54,7 +54,7 @@ export function captureOrcadLaunchSshSessionCommand(): string { } function recordPath(host: RemoteHostPlatform, userDataDir: string): string { - return shellEscape(joinRemotePath(host, userDataDir, ORCAD_LAUNCH_SSH_SESSION_FILENAME)) + return quotePosixShell(joinRemotePath(host, userDataDir, ORCAD_LAUNCH_SSH_SESSION_FILENAME)) } /** @@ -64,7 +64,7 @@ function recordPath(host: RemoteHostPlatform, userDataDir: string): string { * name an unrelated process. */ function liveDaemonShellFunction(host: RemoteHostPlatform, userDataDir: string): string { - const daemonDir = shellEscape(joinRemotePath(host, userDataDir, 'daemon')) + const daemonDir = quotePosixShell(joinRemotePath(host, userDataDir, 'daemon')) return ( `orcad_daemon_live() { orcad_ddir=${daemonDir}; for orcad_df in "$orcad_ddir"/daemon-v*.pid; do ` + '[ -f "$orcad_df" ] || continue; ' + @@ -88,7 +88,7 @@ export function writeOrcadLaunchSshSessionCommand( const file = recordPath(host, userDataDir) return ( // Why mkdir: on a first deploy the detached server may not have created its data root yet. - `{ orcad_bg=$!; ( umask 077; mkdir -p ${shellEscape(userDataDir)} ); ` + + `{ orcad_bg=$!; ( umask 077; mkdir -p ${quotePosixShell(userDataDir)} ); ` + `${liveDaemonShellFunction(host, userDataDir)} ` + 'orcad_daemon_live; orcad_dl=$?; ' + 'if [ "$orcad_dl" = 0 ]; then ' + @@ -108,7 +108,7 @@ export function orcadLaunchSshSessionProbeCommand( userDataDir: string ): string { const file = recordPath(host, userDataDir) - const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + const pidFile = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) return [ 'orcad_mac=$(sw_vers -productVersion 2>/dev/null) || { echo NOT_APPLICABLE; exit 0; };', 'orcad_major=${orcad_mac%%.*};', diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index b9531315686..6cb47d97c8b 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -10,7 +10,7 @@ vi.mock('./remote-exec/remote-exec-command', async (importOriginal) => ({ ...(await importOriginal()), execCommand: vi.fn() })) -vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +vi.mock('../../shared/shell-argument-quoting', () => ({ quotePosixShell: (s: string) => `'${s}'` })) vi.mock('../managed-server/install/remote-install-lock', () => ({ acquireInstallLock: vi.fn().mockResolvedValue(undefined), isRelayInstallLockStale: vi.fn().mockResolvedValue(false), diff --git a/src/main/ssh/relay-daemon-service-children.ts b/src/main/ssh/relay-daemon-service-children.ts index 36b687da28d..b34682dd2c7 100644 --- a/src/main/ssh/relay-daemon-service-children.ts +++ b/src/main/ssh/relay-daemon-service-children.ts @@ -16,7 +16,7 @@ * never evidence that it holds nothing. */ import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from './relay-install-artifacts' -import { shellEscape } from './ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' /** Shell variable set to the daemon's direct-child count, or `unknown`. */ export const RELAY_CHILD_COUNT_VAR = 'kids' @@ -33,7 +33,7 @@ export const RELAY_UNRECOGNIZED_CHILD_COUNT_VAR = 'unrecognized_kids' */ function serviceChildArgvPatterns(): string { return RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.map( - (filename) => `*${shellEscape(`/${filename}`)}` + (filename) => `*${quotePosixShell(`/${filename}`)}` ).join('|') } diff --git a/src/main/ssh/remote-exec/remote-exec-command.ts b/src/main/ssh/remote-exec/remote-exec-command.ts index 5b5b51d3723..1dd82546008 100644 --- a/src/main/ssh/remote-exec/remote-exec-command.ts +++ b/src/main/ssh/remote-exec/remote-exec-command.ts @@ -3,7 +3,7 @@ import type { RemoteCommandChannel, RemoteInstallTransport } from '../../managed-server/install/remote-install-transport' -import { createSshOperationAbortError, type SshExecOptions } from '../ssh-connection-utils' +import { createSshOperationAbortError, type SshExecOptions } from '../connection/ssh-exec-options' import { redactRelayInstallMarkerError, redactRelayInstallMarkerTokens diff --git a/src/main/ssh/ssh-channel-open.ts b/src/main/ssh/ssh-channel-open.ts index 4ee86522277..c6cca66d3fd 100644 --- a/src/main/ssh/ssh-channel-open.ts +++ b/src/main/ssh/ssh-channel-open.ts @@ -1,5 +1,6 @@ import { trackSshConnectionChannelLifetime } from './connection/ssh-connection-channel-lifetime' -import { CONNECT_TIMEOUT_MS, createSshOperationAbortError } from './ssh-connection-utils' +import { CONNECT_TIMEOUT_MS } from './connection/ssh-connection-timing' +import { createSshOperationAbortError } from './connection/ssh-exec-options' import { isSshSessionLimitError } from './ssh-session-limit-error' // Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. diff --git a/src/main/ssh/ssh-connect-attempt-cancellation.test.ts b/src/main/ssh/ssh-connect-attempt-cancellation.test.ts index b98b54762ad..c2d40ae0c72 100644 --- a/src/main/ssh/ssh-connect-attempt-cancellation.test.ts +++ b/src/main/ssh/ssh-connect-attempt-cancellation.test.ts @@ -3,8 +3,7 @@ import { createCancelledConnectAttemptError, isCancelledConnectAttemptError } from './ssh-connect-attempt-cancellation' -import { isAuthError } from './ssh-connection-utils' -import { isTransientReconnectError } from './ssh-reconnect-error-classification' +import { isAuthError, isTransientReconnectError } from './ssh-reconnect-error-classification' describe('createCancelledConnectAttemptError', () => { it('is recognisable by identity, not just by message text', () => { diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts deleted file mode 100644 index 6598fee3c65..00000000000 --- a/src/main/ssh/ssh-connection-utils.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { ConnectConfig } from 'ssh2' -import type { SshTarget, SshConnectionState } from '../../shared/ssh-types' -import type { SshResolvedConfig } from './config/ssh-g-config-resolution' -import { - findEncryptedPrivateKeyPath, - isUnencryptedPrivateKey, - resolveAgentConfigValue, - resolveAgentSocket, - resolvePrivateKeys, - resolveUnencryptedExplicitPrivateKeys -} from './ssh-auth-resolution' -import { configurePrivateKeyAuthentication } from './ssh-private-key-authentication' -import { isOpenSshConfigBackedTarget } from './system-transport/system-ssh-args' - -export type SshCredentialKind = 'passphrase' | 'password' | 'keyboard-interactive' - -export type SshConnectionCallbacks = { - onStateChange: (targetId: string, state: SshConnectionState) => void - // Why: echo carries the server's RFC 4256 echo flag for keyboard-interactive - // prompts so the UI knows whether the typed response may be shown. - onCredentialRequest?: ( - targetId: string, - kind: SshCredentialKind, - detail: string, - echo?: boolean, - signal?: AbortSignal - ) => Promise -} - -export function isPassphraseError(err: Error): boolean { - const msg = err.message.toLowerCase() - return msg.includes('passphrase') || msg.includes('encrypted key') || msg.includes('bad decrypt') -} - -export const INITIAL_RETRY_ATTEMPTS = 5 -export const INITIAL_RETRY_DELAY_MS = 2000 -export const RECONNECT_BACKOFF_MS = [1000, 2000, 5000, 5000, 10000, 10000, 10000, 30000, 30000] -export const CONNECT_TIMEOUT_MS = 30_000 -export const SSH_CREDENTIAL_TIMEOUT_MS = 120_000 - -const TRANSIENT_ERROR_CODES = new Set([ - 'ETIMEDOUT', - 'ECONNREFUSED', - 'ECONNRESET', - 'EHOSTUNREACH', - 'ENETUNREACH', - 'EAI_AGAIN' -]) - -function sshErrorLevel(err: Error): unknown { - return 'level' in err ? err.level : undefined -} - -export function isAuthError(err: Error): boolean { - const msg = err.message.toLowerCase() - return ( - msg.includes('all configured authentication methods failed') || - msg.includes('authentication failed') || - msg.includes('too many authentication failures') || - /permission denied(?:, please try again\.?| \([^)]*(?:publickey|password|keyboard-interactive|gssapi|hostbased)[^)]*\))/.test( - msg - ) || - sshErrorLevel(err) === 'client-authentication' - ) -} - -export function isAgentFallbackError(err: Error): boolean { - return isAuthError(err) || sshErrorLevel(err) === 'agent' -} - -export function isTransientError(err: Error): boolean { - if ( - sshErrorLevel(err) === 'client-timeout' || - err.message === 'Timed out while waiting for SSH authentication' - ) { - return true - } - const code = 'code' in err && typeof err.code === 'string' ? err.code : undefined - if (code && TRANSIENT_ERROR_CODES.has(code)) { - return true - } - if (err.message.includes('ETIMEDOUT')) { - return true - } - if (err.message.includes('ECONNREFUSED')) { - return true - } - if (err.message.includes('ECONNRESET')) { - return true - } - return false -} - -const SYSTEM_SSH_FALLBACK_ERROR_CODES = new Set(['EHOSTUNREACH', 'ENETUNREACH']) - -export function isSystemSshFallbackError(err: Error): boolean { - const code = (err as NodeJS.ErrnoException).code - if (code && SYSTEM_SSH_FALLBACK_ERROR_CODES.has(code)) { - return true - } - return err.message.includes('EHOSTUNREACH') || err.message.includes('ENETUNREACH') -} - -// Why: ssh2 has no gssapi-with-mic support. When the effective OpenSSH config -// enables GSSAPIAuthentication (often a distro-wide /etc/ssh default), a -// Kerberos ticket can still authenticate through the system ssh binary after -// key/agent auth fails — but only auth-shaped failures qualify, so network -// errors keep their existing retry semantics. -export function isGssapiSystemSshFallbackCandidate( - err: Error, - target: Pick, - resolved: Pick | null -): boolean { - // Why: targets with an explicit per-host flag already tried system ssh - // proactively during this attempt; probing again cannot succeed. - if (target.gssapiAuthentication === true) { - return false - } - return (isAuthError(err) || isPassphraseError(err)) && resolved?.gssapiAuthentication === true -} - -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -export function shellEscape(s: string): string { - return `'${s.replace(/'/g, "'\\''")}'` -} - -const REMOTE_COMMAND_CHUNK_MAX_BYTES = 1_024 -const REMOTE_COMMAND_PRINTF_ESCAPED_BYTES = new Set([0x21, 0x27, 0x5c]) - -function encodeRemoteCommandForPrintf(command: string): string[] { - const chunks: string[] = [] - let chunk = '' - let chunkBytes = 0 - for (const character of command) { - const codePoint = character.codePointAt(0)! - const isSafePrintableAscii = - codePoint >= 0x20 && codePoint <= 0x7e && !REMOTE_COMMAND_PRINTF_ESCAPED_BYTES.has(codePoint) - const encodedCharacter = - codePoint > 0x7f || isSafePrintableAscii - ? character - : `\\0${codePoint.toString(8).padStart(3, '0')}` - const encodedBytes = codePoint > 0x7f ? Buffer.byteLength(character) : encodedCharacter.length - if (chunkBytes + encodedBytes > REMOTE_COMMAND_CHUNK_MAX_BYTES) { - chunks.push(chunk) - chunk = '' - chunkBytes = 0 - } - chunk += encodedCharacter - chunkBytes += encodedBytes - } - chunks.push(chunk) - return chunks -} - -/** Wrap a POSIX snippet into one line that non-POSIX SSH login shells can forward. */ -export function wrapRemoteCommandForPosixShell(command: string): string { - // Why: csh/tcsh split multiline SSH exec strings before /bin/sh sees them. - // POSIX printf rebuilds bounded argument chunks without consuming relay stdin. - const encodedChunks = encodeRemoteCommandForPrintf(command) - const decodeAndRun = - 'decoded=$(printf %b "$@" && printf _) || exit $?; ' + - 'decoded=${decoded%_}; exec /bin/sh -c "$decoded"' - const chunkArguments = encodedChunks.map(shellEscape).join(' ') - return `exec /bin/sh -c ${shellEscape(decodeAndRun)} orca-command ${chunkArguments}` -} - -export type SshExecOptions = { - wrapCommand?: boolean - signal?: AbortSignal -} - -export function createSshOperationAbortError(): Error & { name: string } { - const error = new Error('SSH operation was cancelled') as Error & { name: string } - error.name = 'AbortError' - return error -} - -export type BuildConnectConfigOptions = { - includeAgent?: boolean - includePrivateKey?: boolean - /** No agent and no key: only the host's keyboard-interactive challenge (and a cached password). */ - keyboardInteractiveOnly?: boolean -} - -// Why: ssh2 tries privateKey before agent, but parses encrypted privateKey -// values before any agent auth can run. Keep unencrypted explicit keys first -// while deferring encrypted keys until the post-agent passphrase path. -export function buildConnectConfig( - target: SshTarget, - resolved: SshResolvedConfig | null, - options: BuildConnectConfigOptions = {} -): ConnectConfig { - const effectiveHost = resolveEffectiveHost(target, resolved) - const effectivePort = resolveEffectivePort(target, resolved) - const effectiveUser = - isOpenSshConfigBackedTarget(target) && resolved - ? (resolved.user ?? target.username) - : target.username || resolved?.user || '' - - const config: Record = { - host: effectiveHost, - port: effectivePort, - username: effectiveUser, - readyTimeout: CONNECT_TIMEOUT_MS, - keepaliveInterval: 15_000, - // Why: lets servers deliver keyboard-interactive challenges (RFC 4256, - // commonly MFA after a partial password success). SshConnection answers - // them through the credential prompt callback. - tryKeyboard: true - } - - const shouldIncludeAgent = !options.keyboardInteractiveOnly && (options.includeAgent ?? true) - const agentSocket = shouldIncludeAgent ? resolveAgentSocket(target, resolved) : undefined - const agent = agentSocket ? resolveAgentConfigValue(agentSocket, target, resolved) : undefined - - if (agent) { - config.agent = agent - } - - if (agent && resolved?.forwardAgent) { - config.agentForward = true - } - - const keys = options.keyboardInteractiveOnly - ? [] - : (options.includePrivateKey ?? !agent) - ? resolvePrivateKeys(target, resolved) - : resolveUnencryptedExplicitPrivateKeys(target, resolved) - let deferralDecision: boolean | undefined - configurePrivateKeyAuthentication( - config as ConnectConfig, - keys, - findEncryptedPrivateKeyPath(keys), - { - // Why: SshConnection retries with the deferred keys only after an attempt that carried none, - // so only that attempt holds the host's password challenge back for them. An encrypted key - // would swap that dialog for a passphrase one, and a file that is no usable private key - // aborts the retry before any auth, so hold it back only if no deferred key needs input. - // One key only: each rejected key spends the host's MaxAuthTries, so several could exhaust - // the retry before it reaches the challenge this attempt skipped. - // Decided lazily so the deferred files are read only once the agent has failed, as the retry - // would read them anyway, never on an agent login that needs no file. - deferKeyboardInteractive: - keys.length === 0 && !options.keyboardInteractiveOnly - ? () => { - if (deferralDecision === undefined) { - const deferredKeys = resolvePrivateKeys(target, resolved) - deferralDecision = - deferredKeys.length === 1 && isUnencryptedPrivateKey(deferredKeys[0].contents) - } - return deferralDecision - } - : undefined - } - ) - - return config as ConnectConfig -} - -function resolveEffectiveHost(target: SshTarget, resolved: SshResolvedConfig | null): string { - if (isOpenSshConfigBackedTarget(target) && resolved?.hostname) { - return resolved.hostname - } - if (shouldUseResolvedEndpoint(target, resolved)) { - return resolved!.hostname - } - return target.host || resolved?.hostname || target.label -} - -function resolveEffectivePort(target: SshTarget, resolved: SshResolvedConfig | null): number { - if (isOpenSshConfigBackedTarget(target) && resolved) { - return resolved.port || target.port || 22 - } - // Why: imported config aliases store 22 as the schema default even when an - // included/wildcard OpenSSH rule later resolves a different effective Port. - if (target.configHost && target.port === 22 && resolved?.port) { - return resolved.port - } - return target.port || resolved?.port || 22 -} - -function shouldUseResolvedEndpoint(target: SshTarget, resolved: SshResolvedConfig | null): boolean { - if (!target.configHost || !resolved?.hostname) { - return false - } - const host = target.host.trim() - return host === '' || host === target.configHost || host === target.label -} diff --git a/src/main/ssh/ssh-credential-prompt-login-grace-wire.test.ts b/src/main/ssh/ssh-credential-prompt-login-grace-wire.test.ts index 511eb01e90f..ded05582e8e 100644 --- a/src/main/ssh/ssh-credential-prompt-login-grace-wire.test.ts +++ b/src/main/ssh/ssh-credential-prompt-login-grace-wire.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { Server, utils, type Connection } from 'ssh2' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SshConnection } from './connection/ssh-connection' -import { INITIAL_RETRY_DELAY_MS } from './ssh-connection-utils' +import { INITIAL_RETRY_DELAY_MS } from './connection/ssh-connection-timing' import { createCallbacks, createResolvedConfig, diff --git a/src/main/ssh/ssh-exec-rc-stdout-noise.test.ts b/src/main/ssh/ssh-exec-rc-stdout-noise.test.ts index 12cea22fc19..6b97728d9c6 100644 --- a/src/main/ssh/ssh-exec-rc-stdout-noise.test.ts +++ b/src/main/ssh/ssh-exec-rc-stdout-noise.test.ts @@ -6,7 +6,8 @@ import { join } from 'node:path' import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' -import { wrapRemoteCommandForPosixShell, type SshExecOptions } from './ssh-connection-utils' +import { wrapRemoteCommandForPosixShell } from './ssh-posix-command-wrapper' +import type { SshExecOptions } from './connection/ssh-exec-options' import { execCommand, isSshCommandExitError } from './remote-exec/remote-exec-command' import { readRemoteHomeCommand } from './ssh-remote-commands' import type { SshConnection } from './connection/ssh-connection' diff --git a/src/main/ssh/ssh-exec-stdin-file-transfer.ts b/src/main/ssh/ssh-exec-stdin-file-transfer.ts index d9d750353c4..91be5113e74 100644 --- a/src/main/ssh/ssh-exec-stdin-file-transfer.ts +++ b/src/main/ssh/ssh-exec-stdin-file-transfer.ts @@ -12,7 +12,7 @@ import { lstat, open } from 'node:fs/promises' import { posix } from 'node:path' import { Readable } from 'node:stream' import type { RemoteInstallTransport } from '../managed-server/install/remote-install-transport' -import { shellEscape } from './ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { execCommand } from './remote-exec/remote-exec-command' import { RELAY_DEPLOY_TIMEOUT_MS } from '../managed-server/install/remote-deploy-timing' import type { RemoteHostPlatform } from './ssh-remote-platform' @@ -33,14 +33,14 @@ export function makeExecStdinWriteFileCommand( throw new Error(`Invalid upload size: ${byteLength}`) } const part = `${remotePath}.orca-part-${options.token}` - const escapedPart = shellEscape(part) + const escapedPart = quotePosixShell(part) const steps = [ - `mkdir -p ${shellEscape(posix.dirname(remotePath))}`, + `mkdir -p ${quotePosixShell(posix.dirname(remotePath))}`, makePosixWriteFileCommand(part), // Why tr: BSD `wc -c` pads its count with spaces. `[ "$(wc -c < ${escapedPart} | tr -d ' ')" = ${byteLength} ]`, ...(options.executable ? [`chmod 755 ${escapedPart}`] : []), - `mv -f ${escapedPart} ${shellEscape(remotePath)}`, + `mv -f ${escapedPart} ${quotePosixShell(remotePath)}`, `echo ${EXEC_STDIN_WRITE_DONE}` ] return `{ ${steps.join(' && ')}; } || { rm -f ${escapedPart}; exit 1; }` @@ -121,7 +121,7 @@ export async function uploadDirectoryViaExecStdin( options?: ExecStdinOptions ): Promise { const plan = await collectLocalUploadPlan(localDir, remoteDir, host, options?.signal) - await execCommand(conn, `mkdir -p ${plan.directories.map(shellEscape).join(' ')}`, { + await execCommand(conn, `mkdir -p ${plan.directories.map(quotePosixShell).join(' ')}`, { signal: options?.signal }) for (const file of plan.files) { diff --git a/src/main/ssh/ssh-file-transfer-abort.ts b/src/main/ssh/ssh-file-transfer-abort.ts index 9e2e817d9ed..719253a88d8 100644 --- a/src/main/ssh/ssh-file-transfer-abort.ts +++ b/src/main/ssh/ssh-file-transfer-abort.ts @@ -1,4 +1,4 @@ -import { createSshOperationAbortError } from './ssh-connection-utils' +import { createSshOperationAbortError } from './connection/ssh-exec-options' export function createLinkedSshFileTransferSignal(signals: readonly AbortSignal[]): { signal: AbortSignal diff --git a/src/main/ssh/ssh-keyboard-interactive.ts b/src/main/ssh/ssh-keyboard-interactive.ts index 052a8eaf86e..a1a88eea1ce 100644 --- a/src/main/ssh/ssh-keyboard-interactive.ts +++ b/src/main/ssh/ssh-keyboard-interactive.ts @@ -1,5 +1,5 @@ import type { Prompt } from 'ssh2' -import type { SshConnectionCallbacks } from './ssh-connection-utils' +import type { SshConnectionCallbacks } from './connection/ssh-connection-callbacks' // Why: servers commonly reuse keyboard-interactive (RFC 4256) to collect the // login password itself before issuing MFA challenges. Password-looking diff --git a/src/main/ssh/ssh-login-shell-command.ts b/src/main/ssh/ssh-login-shell-command.ts index 8047faca893..384097fec67 100644 --- a/src/main/ssh/ssh-login-shell-command.ts +++ b/src/main/ssh/ssh-login-shell-command.ts @@ -1,4 +1,4 @@ -import { shellEscape } from './ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { buildAnyShellStdoutFence } from '../../shared/posix-stdout-fence' const COMMAND_ONLY_SHELLS = new Set(['sh', 'dash', 'csh', 'tcsh']) @@ -17,7 +17,7 @@ export function buildSshLoginShellCommand(shell: string, command: string): SshLo // Why inside the login shell: both sshd's shell and this one read startup files that can print. const fence = buildAnyShellStdoutFence(command, 'SSH_LOGIN') return { - command: `${shellEscape(shell)} ${mode} ${shellEscape(fence.command)}`, + command: `${quotePosixShell(shell)} ${mode} ${quotePosixShell(fence.command)}`, readStdout: fence.readStdout } } diff --git a/src/main/ssh/ssh-multi-factor-authentication.test.ts b/src/main/ssh/ssh-multi-factor-authentication.test.ts index cab4c2d4c95..a0f97e030d5 100644 --- a/src/main/ssh/ssh-multi-factor-authentication.test.ts +++ b/src/main/ssh/ssh-multi-factor-authentication.test.ts @@ -19,7 +19,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './config/ssh-g-config-resolution' -import { buildConnectConfig, type BuildConnectConfigOptions } from './ssh-connection-utils' +import { buildConnectConfig, type BuildConnectConfigOptions } from './connection/ssh-connect-config' vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal() diff --git a/src/main/ssh/ssh-multi-key-authentication.test.ts b/src/main/ssh/ssh-multi-key-authentication.test.ts index ce04b6aa844..8ac4fc4afce 100644 --- a/src/main/ssh/ssh-multi-key-authentication.test.ts +++ b/src/main/ssh/ssh-multi-key-authentication.test.ts @@ -22,7 +22,7 @@ vi.mock('fs', () => ({ readFileSync: (...args: unknown[]) => mockReadFileSync(...args) })) -import { buildConnectConfig } from './ssh-connection-utils' +import { buildConnectConfig } from './connection/ssh-connect-config' import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' import { buildSshArgs } from './system-transport/system-ssh-args' import { walkInitialAuthLadder } from './connection/ssh-connection-test-fixtures' diff --git a/src/main/ssh/ssh-posix-command-wrapper.test.ts b/src/main/ssh/ssh-posix-command-wrapper.test.ts index 1497dba4d40..0551cee1fe0 100644 --- a/src/main/ssh/ssh-posix-command-wrapper.test.ts +++ b/src/main/ssh/ssh-posix-command-wrapper.test.ts @@ -1,7 +1,8 @@ import { existsSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' -import { shellEscape, wrapRemoteCommandForPosixShell } from './ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' +import { wrapRemoteCommandForPosixShell } from './ssh-posix-command-wrapper' describe('wrapRemoteCommandForPosixShell', () => { it('emits one physical line without requiring a remote decoder binary', () => { @@ -30,7 +31,7 @@ describe('wrapRemoteCommandForPosixShell', () => { 'reconstructs multiline UTF-8 and shell metacharacters exactly', () => { const expected = "line one\nline 'two' \"$`\\ ! ☃\n" - const command = `printf '%s' ${shellEscape(expected)}` + const command = `printf '%s' ${quotePosixShell(expected)}` const result = runThroughShell('/bin/sh', command) expect(result.status).toBe(0) @@ -51,7 +52,7 @@ describe('wrapRemoteCommandForPosixShell', () => { 'keeps every login-shell word bounded for long metacharacter-heavy commands', () => { const expected = '$"`'.repeat(2_000) - const command = `printf '%s' ${shellEscape(expected)}` + const command = `printf '%s' ${quotePosixShell(expected)}` const result = runThroughShell('/bin/tcsh', command) expect(result.status).toBe(0) @@ -76,7 +77,7 @@ describe('wrapRemoteCommandForPosixShell', () => { `survives the real ${shell} parser`, () => { const expected = "first\nsecond '$`\\n\\\\ ! ☃\n" - const command = `printf '%s' ${shellEscape(expected)}` + const command = `printf '%s' ${quotePosixShell(expected)}` const result = runThroughShell(shell, command) expect(result.status).toBe(0) expect(result.stderr).toBe('') diff --git a/src/main/ssh/ssh-posix-command-wrapper.ts b/src/main/ssh/ssh-posix-command-wrapper.ts new file mode 100644 index 00000000000..c49d378ad75 --- /dev/null +++ b/src/main/ssh/ssh-posix-command-wrapper.ts @@ -0,0 +1,41 @@ +import { quotePosixShell } from '../../shared/shell-argument-quoting' + +const REMOTE_COMMAND_CHUNK_MAX_BYTES = 1_024 +const REMOTE_COMMAND_PRINTF_ESCAPED_BYTES = new Set([0x21, 0x27, 0x5c]) + +function encodeRemoteCommandForPrintf(command: string): string[] { + const chunks: string[] = [] + let chunk = '' + let chunkBytes = 0 + for (const character of command) { + const codePoint = character.codePointAt(0)! + const isSafePrintableAscii = + codePoint >= 0x20 && codePoint <= 0x7e && !REMOTE_COMMAND_PRINTF_ESCAPED_BYTES.has(codePoint) + const encodedCharacter = + codePoint > 0x7f || isSafePrintableAscii + ? character + : `\\0${codePoint.toString(8).padStart(3, '0')}` + const encodedBytes = codePoint > 0x7f ? Buffer.byteLength(character) : encodedCharacter.length + if (chunkBytes + encodedBytes > REMOTE_COMMAND_CHUNK_MAX_BYTES) { + chunks.push(chunk) + chunk = '' + chunkBytes = 0 + } + chunk += encodedCharacter + chunkBytes += encodedBytes + } + chunks.push(chunk) + return chunks +} + +/** Wrap a POSIX snippet into one line that non-POSIX SSH login shells can forward. */ +export function wrapRemoteCommandForPosixShell(command: string): string { + // Why: csh/tcsh split multiline SSH exec strings before /bin/sh sees them. + // POSIX printf rebuilds bounded argument chunks without consuming relay stdin. + const encodedChunks = encodeRemoteCommandForPrintf(command) + const decodeAndRun = + 'decoded=$(printf %b "$@" && printf _) || exit $?; ' + + 'decoded=${decoded%_}; exec /bin/sh -c "$decoded"' + const chunkArguments = encodedChunks.map(quotePosixShell).join(' ') + return `exec /bin/sh -c ${quotePosixShell(decodeAndRun)} orca-command ${chunkArguments}` +} diff --git a/src/main/ssh/ssh-proxy-command.ts b/src/main/ssh/ssh-proxy-command.ts index 8b60a1ad5c7..9e7eef38843 100644 --- a/src/main/ssh/ssh-proxy-command.ts +++ b/src/main/ssh/ssh-proxy-command.ts @@ -4,7 +4,7 @@ import { Duplex } from 'node:stream' import type { Socket as NetSocket } from 'node:net' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './config/ssh-g-config-resolution' -import { shellEscape } from './ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { isOpenSshConfigBackedTarget } from './system-transport/system-ssh-args' // Why: ProxyJump and jumpHost are syntactic sugar for ProxyCommand. @@ -108,7 +108,7 @@ export function spawnProxyCommand( args: jumpHostSpawnArgs(proxy.jumpHost, host, port) }) : (() => { - const escape = process.platform === 'win32' ? cmdEscape : shellEscape + const escape = process.platform === 'win32' ? cmdEscape : quotePosixShell const expanded = proxy.command .replace(/%h/g, escape(host)) .replace(/%p/g, escape(String(port))) diff --git a/src/main/ssh/ssh-reconnect-error-classification.test.ts b/src/main/ssh/ssh-reconnect-error-classification.test.ts index 0840da68a2b..46d15287049 100644 --- a/src/main/ssh/ssh-reconnect-error-classification.test.ts +++ b/src/main/ssh/ssh-reconnect-error-classification.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect } from 'vitest' -import { isTransientError } from './ssh-connection-utils' -import { HostKeyVerificationError } from './ssh-host-key-decision' import { + isTransientError, isDefiniteSystemSshHostFailure, - isTransientReconnectError + isTransientReconnectError, + isSystemSshFallbackError, + isGssapiSystemSshFallbackCandidate, + isAuthError, + isAgentFallbackError } from './ssh-reconnect-error-classification' +import { HostKeyVerificationError } from './ssh-host-key-decision' describe('isTransientReconnectError', () => { it('treats the system SSH connect timeout as recoverable', () => { @@ -123,3 +127,197 @@ describe('isTransientReconnectError', () => { expect(isTransientReconnectError(err)).toBe(false) }) }) + +// ── isTransientError ───────────────────────────────────────────────── + +describe('isTransientError', () => { + it('returns true for ETIMEDOUT code', () => { + const err = new Error('timed out') as NodeJS.ErrnoException + err.code = 'ETIMEDOUT' + expect(isTransientError(err)).toBe(true) + }) + + it('returns true for ECONNREFUSED code', () => { + const err = new Error('refused') as NodeJS.ErrnoException + err.code = 'ECONNREFUSED' + expect(isTransientError(err)).toBe(true) + }) + + it('returns true for ECONNRESET code', () => { + const err = new Error('reset') as NodeJS.ErrnoException + err.code = 'ECONNRESET' + expect(isTransientError(err)).toBe(true) + }) + + it('returns true for EHOSTUNREACH code', () => { + const err = new Error('host unreachable') as NodeJS.ErrnoException + err.code = 'EHOSTUNREACH' + expect(isTransientError(err)).toBe(true) + }) + + it('returns true for ENETUNREACH code', () => { + const err = new Error('net unreachable') as NodeJS.ErrnoException + err.code = 'ENETUNREACH' + expect(isTransientError(err)).toBe(true) + }) + + it('returns true for EAI_AGAIN code', () => { + const err = new Error('dns') as NodeJS.ErrnoException + err.code = 'EAI_AGAIN' + expect(isTransientError(err)).toBe(true) + }) + + it('returns true for ETIMEDOUT in message (no code)', () => { + expect(isTransientError(new Error('connect ETIMEDOUT 1.2.3.4:22'))).toBe(true) + }) + + it('returns true for ECONNREFUSED in message', () => { + expect(isTransientError(new Error('connect ECONNREFUSED 1.2.3.4:22'))).toBe(true) + }) + + it('returns true for ECONNRESET in message', () => { + expect(isTransientError(new Error('read ECONNRESET'))).toBe(true) + }) + + it('returns true for the bounded SSH authentication watchdog', () => { + const timeout = Object.assign(new Error('Timed out while waiting for SSH authentication'), { + level: 'client-timeout' + }) + + expect(isTransientError(timeout)).toBe(true) + expect(isTransientError(new Error('Timed out while waiting for SSH authentication'))).toBe(true) + }) + + it('returns false for auth errors', () => { + expect(isTransientError(new Error('All configured authentication methods failed'))).toBe(false) + }) + + it('returns false for generic errors', () => { + expect(isTransientError(new Error('something went wrong'))).toBe(false) + }) +}) + +// ── isSystemSshFallbackError ───────────────────────────────────────── + +describe('isSystemSshFallbackError', () => { + it('returns true for local reachability errors that system ssh may bypass', () => { + const hostErr = new Error('host unreachable') as NodeJS.ErrnoException + hostErr.code = 'EHOSTUNREACH' + const netErr = new Error('net unreachable') as NodeJS.ErrnoException + netErr.code = 'ENETUNREACH' + + expect(isSystemSshFallbackError(hostErr)).toBe(true) + expect(isSystemSshFallbackError(netErr)).toBe(true) + }) + + it('returns false for transient errors that should keep the normal retry path', () => { + const refused = new Error('refused') as NodeJS.ErrnoException + refused.code = 'ECONNREFUSED' + + expect(isSystemSshFallbackError(refused)).toBe(false) + expect(isSystemSshFallbackError(new Error('connect ETIMEDOUT 1.2.3.4:22'))).toBe(false) + }) +}) + +// ── isGssapiSystemSshFallbackCandidate ─────────────────────────────── + +describe('isGssapiSystemSshFallbackCandidate', () => { + const authErr = new Error('All configured authentication methods failed') + + it('returns true for auth failures when resolved config enables GSSAPI', () => { + expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: true })).toBe( + true + ) + }) + + it('returns true for passphrase failures so Kerberos SSO runs before prompting', () => { + const passphraseErr = new Error('Encrypted private OpenSSH key detected, but no passphrase') + expect( + isGssapiSystemSshFallbackCandidate(passphraseErr, {}, { gssapiAuthentication: true }) + ).toBe(true) + }) + + it('returns false when the target already tried system ssh proactively', () => { + expect( + isGssapiSystemSshFallbackCandidate( + authErr, + { gssapiAuthentication: true }, + { gssapiAuthentication: true } + ) + ).toBe(false) + }) + + it('returns false without GSSAPI in the resolved config', () => { + expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: false })).toBe( + false + ) + expect(isGssapiSystemSshFallbackCandidate(authErr, {}, null)).toBe(false) + }) + + it('returns false for network errors so retry semantics stay unchanged', () => { + const netErr = new Error('connect ETIMEDOUT 1.2.3.4:22') + expect(isGssapiSystemSshFallbackCandidate(netErr, {}, { gssapiAuthentication: true })).toBe( + false + ) + }) +}) + +// ── isAuthError ────────────────────────────────────────────────────── + +describe('isAuthError', () => { + it('returns true for "All configured authentication methods failed"', () => { + expect(isAuthError(new Error('All configured authentication methods failed'))).toBe(true) + }) + + it('returns true for "Authentication failed"', () => { + expect(isAuthError(new Error('Authentication failed'))).toBe(true) + }) + + it('returns true for client-authentication level', () => { + const err = new Error('auth') as Error & { level: string } + err.level = 'client-authentication' + expect(isAuthError(err)).toBe(true) + }) + + it('returns true for server auth-attempt exhaustion', () => { + expect(isAuthError(new Error('Received disconnect: Too many authentication failures'))).toBe( + true + ) + }) + + it.each([ + 'Permission denied (publickey).', + 'Permission denied (publickey,password).', + 'Permission denied, please try again.' + ])('detects OpenSSH credential rejection: %s', (message) => { + expect(isAuthError(new Error(message))).toBe(true) + }) + + it('returns false for transient errors', () => { + expect(isAuthError(new Error('connect ETIMEDOUT'))).toBe(false) + }) + + it('does not classify a local filesystem permission failure as authentication', () => { + expect(isAuthError(new Error('Permission denied (os error 13)'))).toBe(false) + }) +}) + +// ── isAgentFallbackError ──────────────────────────────────────────── + +describe('isAgentFallbackError', () => { + it('returns true for ssh2 agent-level failures', () => { + const err = new Error('Failed to connect to agent') as Error & { level: string } + err.level = 'agent' + expect(isAgentFallbackError(err)).toBe(true) + }) + + it('returns true when agent auth exhausts the server auth attempt limit', () => { + expect( + isAgentFallbackError(new Error('Received disconnect: Too many authentication failures')) + ).toBe(true) + }) + + it('keeps unrelated transport errors out of agent fallback handling', () => { + expect(isAgentFallbackError(new Error('connect ECONNRESET'))).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-reconnect-error-classification.ts b/src/main/ssh/ssh-reconnect-error-classification.ts index c5a81b721c4..41fda0176a0 100644 --- a/src/main/ssh/ssh-reconnect-error-classification.ts +++ b/src/main/ssh/ssh-reconnect-error-classification.ts @@ -1,6 +1,93 @@ -import { isAuthError, isPassphraseError, isTransientError } from './ssh-connection-utils' +import type { SshTarget } from '../../shared/ssh-types' +import type { SshResolvedConfig } from './config/ssh-g-config-resolution' import { isHostKeyVerificationError } from './ssh-host-key-decision' +export function isPassphraseError(err: Error): boolean { + const msg = err.message.toLowerCase() + return msg.includes('passphrase') || msg.includes('encrypted key') || msg.includes('bad decrypt') +} + +const TRANSIENT_ERROR_CODES = new Set([ + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'EAI_AGAIN' +]) + +function sshErrorLevel(err: Error): unknown { + return 'level' in err ? err.level : undefined +} + +export function isAuthError(err: Error): boolean { + const msg = err.message.toLowerCase() + return ( + msg.includes('all configured authentication methods failed') || + msg.includes('authentication failed') || + msg.includes('too many authentication failures') || + /permission denied(?:, please try again\.?| \([^)]*(?:publickey|password|keyboard-interactive|gssapi|hostbased)[^)]*\))/.test( + msg + ) || + sshErrorLevel(err) === 'client-authentication' + ) +} + +export function isAgentFallbackError(err: Error): boolean { + return isAuthError(err) || sshErrorLevel(err) === 'agent' +} + +export function isTransientError(err: Error): boolean { + if ( + sshErrorLevel(err) === 'client-timeout' || + err.message === 'Timed out while waiting for SSH authentication' + ) { + return true + } + const code = 'code' in err && typeof err.code === 'string' ? err.code : undefined + if (code && TRANSIENT_ERROR_CODES.has(code)) { + return true + } + if (err.message.includes('ETIMEDOUT')) { + return true + } + if (err.message.includes('ECONNREFUSED')) { + return true + } + if (err.message.includes('ECONNRESET')) { + return true + } + return false +} + +const SYSTEM_SSH_FALLBACK_ERROR_CODES = new Set(['EHOSTUNREACH', 'ENETUNREACH']) + +export function isSystemSshFallbackError(err: Error): boolean { + const code = 'code' in err && typeof err.code === 'string' ? err.code : undefined + if (code && SYSTEM_SSH_FALLBACK_ERROR_CODES.has(code)) { + return true + } + return err.message.includes('EHOSTUNREACH') || err.message.includes('ENETUNREACH') +} + +// Why: ssh2 has no gssapi-with-mic support. When the effective OpenSSH config +// enables GSSAPIAuthentication (often a distro-wide /etc/ssh default), a +// Kerberos ticket can still authenticate through the system ssh binary after +// key/agent auth fails — but only auth-shaped failures qualify, so network +// errors keep their existing retry semantics. +export function isGssapiSystemSshFallbackCandidate( + err: Error, + target: Pick, + resolved: Pick | null +): boolean { + // Why: targets with an explicit per-host flag already tried system ssh + // proactively during this attempt; probing again cannot succeed. + if (target.gssapiAuthentication === true) { + return false + } + return (isAuthError(err) || isPassphraseError(err)) && resolved?.gssapiAuthentication === true +} + const DEFINITE_HOST_FAILURE_FRAGMENTS = [ 'no route to host', 'network is unreachable', diff --git a/src/main/ssh/ssh-reconnect-ladder.test.ts b/src/main/ssh/ssh-reconnect-ladder.test.ts index 8010ebd96a5..31edb1fc099 100644 --- a/src/main/ssh/ssh-reconnect-ladder.test.ts +++ b/src/main/ssh/ssh-reconnect-ladder.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' -import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-utils' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './connection/ssh-connection-timing' import { FLAP_DELAY_CAP_MS, RELAY_REESTABLISH_BUDGET_MS, diff --git a/src/main/ssh/ssh-reconnect-ladder.ts b/src/main/ssh/ssh-reconnect-ladder.ts index bf03154be0e..8d0d07e17d6 100644 --- a/src/main/ssh/ssh-reconnect-ladder.ts +++ b/src/main/ssh/ssh-reconnect-ladder.ts @@ -1,5 +1,5 @@ import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' -import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-utils' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './connection/ssh-connection-timing' // A connection that held this long is treated as healthy, so the next drop restarts the delay ladder. export const STABLE_CONNECTION_MS = 60_000 diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 38b2bd62f9a..6ccff9b9eb3 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -6,7 +6,7 @@ import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform' import { powerShellCommand } from './ssh-remote-powershell' import { quotePowerShellLiteral as powerShellLiteral } from '../../shared/powershell-native-argument' -import { shellEscape } from './ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' export function readRemoteHomeCommand(host: RemoteHostPlatform): string { if (!isWindowsRemoteHost(host)) { @@ -17,7 +17,7 @@ export function readRemoteHomeCommand(host: RemoteHostPlatform): string { export function removeRemoteFileCommand(host: RemoteHostPlatform, remotePath: string): string { if (!isWindowsRemoteHost(host)) { - return `rm -f ${shellEscape(remotePath)} 2>/dev/null; true` + return `rm -f ${quotePosixShell(remotePath)} 2>/dev/null; true` } return powerShellCommand( `Remove-Item -LiteralPath ${powerShellLiteral(remotePath)} -Force -ErrorAction SilentlyContinue` @@ -26,7 +26,7 @@ export function removeRemoteFileCommand(host: RemoteHostPlatform, remotePath: st export function removeRemoteTreeCommand(host: RemoteHostPlatform, remotePath: string): string { if (!isWindowsRemoteHost(host)) { - return `rm -rf ${shellEscape(remotePath)}` + return `rm -rf ${quotePosixShell(remotePath)}` } return powerShellCommand( `Remove-Item -LiteralPath ${powerShellLiteral(remotePath)} -Recurse -Force -ErrorAction SilentlyContinue` @@ -39,7 +39,7 @@ export function moveRemoteTreeCommand( destinationPath: string ): string { if (!isWindowsRemoteHost(host)) { - return `mv ${shellEscape(sourcePath)} ${shellEscape(destinationPath)} 2>&1 && echo MOVED || echo BUSY` + return `mv ${quotePosixShell(sourcePath)} ${quotePosixShell(destinationPath)} 2>&1 && echo MOVED || echo BUSY` } return powerShellCommand( [ @@ -62,12 +62,12 @@ export function restoreRemoteTreeCommand( `if (-not (Test-Path -LiteralPath ${powerShellLiteral(destination)})) { Move-Item -LiteralPath ${powerShellLiteral(source)} -Destination ${powerShellLiteral(destination)} -ErrorAction Stop; 'MOVED' } else { 'BUSY' }` ) } - return `if [ ! -e ${shellEscape(destination)} ] && [ ! -L ${shellEscape(destination)} ]; then mv ${shellEscape(source)} ${shellEscape(destination)} && echo MOVED; else echo BUSY; fi` + return `if [ ! -e ${quotePosixShell(destination)} ] && [ ! -L ${quotePosixShell(destination)} ]; then mv ${quotePosixShell(source)} ${quotePosixShell(destination)} && echo MOVED; else echo BUSY; fi` } export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath: string): string { if (!isWindowsRemoteHost(host)) { - return `touch ${shellEscape(remotePath)}` + return `touch ${quotePosixShell(remotePath)}` } return powerShellCommand( `Set-Content -LiteralPath ${powerShellLiteral(remotePath)} -Value '' -NoNewline` @@ -87,8 +87,8 @@ export function probeRemoteInstallCompleteCommand( joinRemotePath(host, remoteInstallDir, filename) ) if (!isWindowsRemoteHost(host)) { - const fileTests = required.map((path) => `&& test -f ${shellEscape(path)} `).join('') - return `test -d ${shellEscape(remoteInstallDir)} ${fileTests}&& echo OK || echo MISSING` + const fileTests = required.map((path) => `&& test -f ${quotePosixShell(path)} `).join('') + return `test -d ${quotePosixShell(remoteInstallDir)} ${fileTests}&& echo OK || echo MISSING` } return powerShellCommand( [ @@ -119,7 +119,7 @@ export function listRemoteInstallBaseDirsCommand( if (!isWindowsRemoteHost(host)) { const statusPrefix = '__ORCA_RELAY_GC_FIND_STATUS__' return [ - `base=${shellEscape(baseDir)}; [ -d "$base" ] || exit 0;`, + `base=${quotePosixShell(baseDir)}; [ -d "$base" ] || exit 0;`, `{ find "$base" -mindepth 1 -maxdepth 1 -type d -name '${model.dirPrefix}-*' -print; status=$?; printf '\n${statusPrefix}%s\n' "$status"; } |`, String.raw`awk 'BEGIN { count=0; status=-1 } /^${statusPrefix}[0-9]+$/ { status=substr($0, ${statusPrefix.length + 1}); next } { name=$0; sub(/^.*\//, "", name); if (name ~ /${namePattern}/ && count < ${MAX_REMOTE_INSTALL_GC_LISTING_ENTRIES}) { entries[count++]=name } } END { if (status != 0) exit 1; for (i=0; i>' : '>' const noclobber = !options?.append && options?.exclusive ? 'set -C; ' : '' - return `${noclobber}cat ${redirection} ${shellEscape(remotePath)}` + return `${noclobber}cat ${redirection} ${quotePosixShell(remotePath)}` } function makeWindowsReadFileCommand(remotePath: string): string { diff --git a/src/main/ssh/system-transport/system-ssh-file-transfer.ts b/src/main/ssh/system-transport/system-ssh-file-transfer.ts index 4c6ea1e16e2..9051297ed38 100644 --- a/src/main/ssh/system-transport/system-ssh-file-transfer.ts +++ b/src/main/ssh/system-transport/system-ssh-file-transfer.ts @@ -4,7 +4,8 @@ import { lstat, opendir } from 'node:fs/promises' import { join as pathJoin } from 'node:path' import { pipeline } from 'node:stream/promises' import type { SshTarget } from '../../../shared/ssh-types' -import { shellEscape, wrapRemoteCommandForPosixShell } from '../ssh-connection-utils' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' +import { wrapRemoteCommandForPosixShell } from '../ssh-posix-command-wrapper' import { findSystemSsh } from './system-ssh-binary' import { buildSshArgs, @@ -65,7 +66,7 @@ export async function uploadDirectoryViaSystemSsh( stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) - const remoteCommand = `mkdir -p ${shellEscape(remoteDir)} && tar -xzf - -C ${shellEscape(remoteDir)}` + const remoteCommand = `mkdir -p ${quotePosixShell(remoteDir)} && tar -xzf - -C ${quotePosixShell(remoteDir)}` const sshExtract = spawn( sshPath, [...buildSshArgs(target, options), wrapRemoteCommandForPosixShell(remoteCommand)], diff --git a/src/main/windows/windows-msys-job.win32.test.ts b/src/main/windows/windows-msys-job.win32.test.ts index f29c7bd8d0f..fe7f9f8e363 100644 --- a/src/main/windows/windows-msys-job.win32.test.ts +++ b/src/main/windows/windows-msys-job.win32.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { resolveGitBashPath } from '../git-bash' -import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { listPtyJobProcessIds, terminatePtyJob } from './windows-pty-job' const describeOnWindows = process.platform === 'win32' ? describe : describe.skip diff --git a/src/main/wsl-bash-command.ts b/src/main/wsl-bash-command.ts index 06e358b2a0a..62dfc1f6e12 100644 --- a/src/main/wsl-bash-command.ts +++ b/src/main/wsl-bash-command.ts @@ -1,6 +1,6 @@ // Why re-export rather than redefine: POSIX single-quote escaping had four // byte-identical copies in this tree. One of them is enough. -import { quotePosixShell } from '../shared/wsl-login-shell-command' +import { quotePosixShell } from '../shared/shell-argument-quoting' export { quotePosixShell as quoteBashString } diff --git a/src/main/wsl/wsl-command-loop-pool.ts b/src/main/wsl/wsl-command-loop-pool.ts index f5b9d750500..f59fb24ed4b 100644 --- a/src/main/wsl/wsl-command-loop-pool.ts +++ b/src/main/wsl/wsl-command-loop-pool.ts @@ -1,6 +1,6 @@ import type { PipedChildProcess } from '@orca/process-host/process-spec' import type { RemoteCommandChannel } from '../managed-server/install/remote-install-transport' -import { createSshOperationAbortError } from '../ssh/ssh-connection-utils' +import { createSshOperationAbortError } from '../ssh/connection/ssh-exec-options' import { startWslCommandLoop, wslCommandLoopMarker, type WslCommandLoop } from './wsl-command-loop' export type WslCommandLoopPool = { diff --git a/src/main/wsl/wsl-directory-upload.ts b/src/main/wsl/wsl-directory-upload.ts index f8829e9c46a..fc141a17395 100644 --- a/src/main/wsl/wsl-directory-upload.ts +++ b/src/main/wsl/wsl-directory-upload.ts @@ -10,7 +10,7 @@ import { open } from 'node:fs/promises' import { Readable } from 'node:stream' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import type { RemoteInstallTransport } from '../managed-server/install/remote-install-transport' -import { shellEscape } from '../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { execCommand } from '../ssh/remote-exec/remote-exec-command' import { RELAY_DEPLOY_TIMEOUT_MS } from '../managed-server/install/remote-deploy-timing' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' @@ -39,7 +39,7 @@ export function wslGuestSourcePath(localDir: string, distro: string): string | n // Success paths never `exit`: execCommand's stdout fence must still print its end marker. function stageFromGuestPathCommand(source: string, stage: string): string { return [ - `source=${shellEscape(source)}; stage=${shellEscape(stage)}`, + `source=${quotePosixShell(source)}; stage=${quotePosixShell(stage)}`, 'if [ -d "$source" ]; then', ' { mkdir -p "$stage" && cp -R "$source/." "$stage/"; } || { rm -rf "$stage"; false; }', 'else', @@ -50,7 +50,7 @@ function stageFromGuestPathCommand(source: string, stage: string): string { function stageFromTarCommand(stage: string): string { return [ - `stage=${shellEscape(stage)}`, + `stage=${quotePosixShell(stage)}`, // `-o`: files belong to whoever extracts them, not to the archive's uid 0. '{ mkdir -p "$stage" && tar -x -o -f - -C "$stage"; } || { rm -rf "$stage"; false; }' ].join('\n') @@ -59,7 +59,7 @@ function stageFromTarCommand(stage: string): string { /** Reads the digest manifest from stdin; the stage is removed whatever the outcome. */ function verifyAndPromoteCommand(stage: string, dest: string, hasFiles: boolean): string { return [ - `stage=${shellEscape(stage)}; dest=${shellEscape(dest)}`, + `stage=${quotePosixShell(stage)}; dest=${quotePosixShell(dest)}`, `trap 'rm -rf "$stage"' EXIT`, // GNU sha256sum rejects an empty manifest. ...(hasFiles diff --git a/src/main/wsl/wsl-orcad-deploy.ts b/src/main/wsl/wsl-orcad-deploy.ts index f68200f0f1b..603d5d02910 100644 --- a/src/main/wsl/wsl-orcad-deploy.ts +++ b/src/main/wsl/wsl-orcad-deploy.ts @@ -27,7 +27,7 @@ import { wakeAlreadyActiveOrcad } from '../ssh/orcad-already-active-wake' import { resolveOrcadRemoteContext } from '../managed-server/install/orcad-remote-context' import { deployOrcad } from '../ssh/orcad-remote-deploy' import { ORCAD_PID_FILENAME } from '../managed-server/install/orcad-remote-host-support' -import { shellEscape } from '../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { execCommand } from '../ssh/remote-exec/remote-exec-command' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { readLocalFullVersion } from '../managed-server/install/remote-versioned-install' @@ -221,7 +221,7 @@ async function launchOverTransport( ORCAD_PID_FILENAME ) const pid = Number.parseInt( - await execCommand(conn, `cat ${shellEscape(pidFile)} 2>/dev/null || true`, { signal }), + await execCommand(conn, `cat ${quotePosixShell(pidFile)} 2>/dev/null || true`, { signal }), 10 ) return { diff --git a/src/main/wsl/wsl-orcad-host-transport.ts b/src/main/wsl/wsl-orcad-host-transport.ts index 5c6ea5927a7..575d45997f4 100644 --- a/src/main/wsl/wsl-orcad-host-transport.ts +++ b/src/main/wsl/wsl-orcad-host-transport.ts @@ -5,7 +5,10 @@ import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../shared/windows-command-line-budget' import type { OrcadHostTransport } from '../ssh/orcad-host-transport' import type { RemoteCommandChannel } from '../managed-server/install/remote-install-transport' -import { createSshOperationAbortError, type SshExecOptions } from '../ssh/ssh-connection-utils' +import { + createSshOperationAbortError, + type SshExecOptions +} from '../ssh/connection/ssh-exec-options' import { writeStringViaExecStdin } from '../ssh/ssh-exec-stdin-file-transfer' import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' import { wrapWslCommandProcess } from './wsl-command-channel' diff --git a/src/main/wsl/wsl-orcad-instance-adoption.ts b/src/main/wsl/wsl-orcad-instance-adoption.ts index 050c60a66d0..2c8438604ba 100644 --- a/src/main/wsl/wsl-orcad-instance-adoption.ts +++ b/src/main/wsl/wsl-orcad-instance-adoption.ts @@ -34,7 +34,7 @@ import { remoteInstallDirName, remoteInstallVersionDirRegex } from '../managed-server/install/remote-install-model' -import { shellEscape } from '../ssh/ssh-connection-utils' +import { quotePosixShell } from '../../shared/shell-argument-quoting' import { joinRemotePath } from '../ssh/ssh-remote-platform' const LOCK_RECORD_MAX_BYTES = 64 * 1024 @@ -71,7 +71,7 @@ async function readLiveLockHolder(slot: OrcadSlotOptions): Promise { const pidFile = joinRemotePath(slot.host, orcadSlotDir(slot, version), ORCAD_PID_FILENAME) - const raw = await execOrcadRemote(slot, `cat ${shellEscape(pidFile)} 2>/dev/null || true`) + const raw = await execOrcadRemote(slot, `cat ${quotePosixShell(pidFile)} 2>/dev/null || true`) const pid = Number.parseInt(raw.trim(), 10) return Number.isSafeInteger(pid) && pid > 0 ? pid : null } @@ -173,7 +173,7 @@ export async function adoptUnrecordedWslOrcad( ) await execOrcadRemote( slot, - `(umask 077 && printf '%s\\n' ${String(holder.pid)} > ${shellEscape(pidFile)})` + `(umask 077 && printf '%s\\n' ${String(holder.pid)} > ${quotePosixShell(pidFile)})` ) const next = adoptedRecord(current, holder.version, now()) if (next !== current) { diff --git a/src/shared/git-ssh-policy-env.test.ts b/src/shared/git-ssh-policy-env.test.ts index f6218c1db6c..ad743360d35 100644 --- a/src/shared/git-ssh-policy-env.test.ts +++ b/src/shared/git-ssh-policy-env.test.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { runProcess } from '@orca/process-host' -import { quotePosixShell } from './wsl-login-shell-command' +import { quotePosixShell } from './shell-argument-quoting' import { describe, expect, it } from 'vitest' import { buildGitSshPolicyEnv, parseGitSshConfig } from './git-ssh-policy-env' diff --git a/src/shared/git-ssh-policy-env.ts b/src/shared/git-ssh-policy-env.ts index 3abd9376d97..6395e11fadc 100644 --- a/src/shared/git-ssh-policy-env.ts +++ b/src/shared/git-ssh-policy-env.ts @@ -1,5 +1,5 @@ import { isWindowsAbsolutePathLike } from './cross-platform-path' -import { quotePosixShell } from './wsl-login-shell-command' +import { quotePosixShell } from './shell-argument-quoting' export type GitSshPolicyMode = | 'default' diff --git a/src/shared/shell-argument-quoting.test.ts b/src/shared/shell-argument-quoting.test.ts new file mode 100644 index 00000000000..f23dde4c7c6 --- /dev/null +++ b/src/shared/shell-argument-quoting.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { quotePosixShell } from './shell-argument-quoting' + +// ── quotePosixShell ────────────────────────────────────────────────────── + +describe('quotePosixShell', () => { + it('wraps string in single quotes', () => { + expect(quotePosixShell('hello')).toBe("'hello'") + }) + + it('escapes embedded single quotes', () => { + expect(quotePosixShell("it's")).toBe("'it'\\''s'") + }) + + it('handles empty string', () => { + expect(quotePosixShell('')).toBe("''") + }) + + it('handles special characters', () => { + expect(quotePosixShell('foo bar; rm -rf /')).toBe("'foo bar; rm -rf /'") + }) +}) diff --git a/src/shared/shell-argument-quoting.ts b/src/shared/shell-argument-quoting.ts new file mode 100644 index 00000000000..327fa6ef2f5 --- /dev/null +++ b/src/shared/shell-argument-quoting.ts @@ -0,0 +1,4 @@ +/** POSIX sh single-quoting: each `'` closes the quote, is escaped, and reopens it (`'\''`). */ +export function quotePosixShell(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 0a9b3fb13af..552b3708aad 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -7,9 +7,9 @@ import { buildWslCapturedLoginShellCommand, buildWslExecArgs, buildWslInteractiveLoginShellCommand, - buildWslLoginShellCommand, - quotePosixShell + buildWslLoginShellCommand } from './wsl-login-shell-command' +import { quotePosixShell } from './shell-argument-quoting' const WSL_TEST_COMMAND_TIMEOUT_MS = 10_000 let wslShAvailable: boolean | null = null diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index 9058ea12305..8958a6c16b7 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -1,9 +1,6 @@ import { getFishClaudeShellFunction } from './claude-shell-function' import { buildPosixStdoutFence, type PosixStdoutFence } from './posix-stdout-fence' - -export function quotePosixShell(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} +import { quotePosixShell } from './shell-argument-quoting' /** * Build `wsl.exe` argv that hands a POSIX script to the guest byte-for-byte. diff --git a/tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts b/tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts index a892c16f2d7..9d1d4c6dd24 100644 --- a/tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts +++ b/tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts @@ -8,7 +8,7 @@ import { expect, test } from './helpers/orca-app' import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' import { launchPairedWebClient } from './helpers/paired-electron-client' import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const MIN_EXHAUSTED_ACK_BYTES = 400 * 1024 const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-headless-stalled-stream-')) diff --git a/tests/e2e/helpers/docker-ssh-host-target.ts b/tests/e2e/helpers/docker-ssh-host-target.ts index aa57bf6ebf3..9a6a9b65de0 100644 --- a/tests/e2e/helpers/docker-ssh-host-target.ts +++ b/tests/e2e/helpers/docker-ssh-host-target.ts @@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { getDockerSshHostImage } from './docker-ssh-host-image' -import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../src/shared/shell-argument-quoting' import type { TestInfo } from '@stablyai/playwright-test' diff --git a/tests/e2e/helpers/host-created-terminal-retention-oracle.ts b/tests/e2e/helpers/host-created-terminal-retention-oracle.ts index d647fd96d3c..fae33400d7c 100644 --- a/tests/e2e/helpers/host-created-terminal-retention-oracle.ts +++ b/tests/e2e/helpers/host-created-terminal-retention-oracle.ts @@ -15,7 +15,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { expect, type Page } from '@stablyai/playwright-test' -import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../src/shared/shell-argument-quoting' export type RuntimeRpcCall = (method: string, params: unknown) => Promise diff --git a/tests/e2e/helpers/legacy-relay-docker-host.ts b/tests/e2e/helpers/legacy-relay-docker-host.ts index 8ee011153b2..0d1244031b4 100644 --- a/tests/e2e/helpers/legacy-relay-docker-host.ts +++ b/tests/e2e/helpers/legacy-relay-docker-host.ts @@ -7,7 +7,7 @@ import { execFileSync } from 'node:child_process' import { cpSync, mkdtempSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../src/shared/shell-argument-quoting' import { relaySocketNameForInstanceId } from '../../../src/main/ssh/legacy-relay/ssh-relay-instance-id' import { SshChannelMultiplexer } from '../../../src/main/ssh/ssh-channel-multiplexer' import { diff --git a/tests/e2e/helpers/paired-terminal-parking-fixture.ts b/tests/e2e/helpers/paired-terminal-parking-fixture.ts index 3c986c4ad75..134604250fc 100644 --- a/tests/e2e/helpers/paired-terminal-parking-fixture.ts +++ b/tests/e2e/helpers/paired-terminal-parking-fixture.ts @@ -1,7 +1,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../src/shared/shell-argument-quoting' const FILL_ROWS = 6_000 const FLOOD_ROWS = 4_000 diff --git a/tests/e2e/helpers/remote-session-bulk-open-fixture.ts b/tests/e2e/helpers/remote-session-bulk-open-fixture.ts index 8109d1de497..f9f2a51635e 100644 --- a/tests/e2e/helpers/remote-session-bulk-open-fixture.ts +++ b/tests/e2e/helpers/remote-session-bulk-open-fixture.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../src/shared/shell-argument-quoting' function fixtureCommand(fixturePath: string, marker: string): string { const command = [process.execPath, fixturePath, marker] diff --git a/tests/e2e/helpers/ssh-remote-only-browser-fixture.ts b/tests/e2e/helpers/ssh-remote-only-browser-fixture.ts index e03746440f8..063c8456dac 100644 --- a/tests/e2e/helpers/ssh-remote-only-browser-fixture.ts +++ b/tests/e2e/helpers/ssh-remote-only-browser-fixture.ts @@ -4,7 +4,7 @@ import { writeDockerSshHostTargetFile, type DockerSshHostTarget } from './docker-ssh-host-target' -import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../src/shared/shell-argument-quoting' /** * An HTTP fixture that exists only inside the SSH container, on a hostname only the diff --git a/tests/e2e/helpers/startup-exec-readiness-oracle.ts b/tests/e2e/helpers/startup-exec-readiness-oracle.ts index 3bb43862825..bad4e227cf6 100644 --- a/tests/e2e/helpers/startup-exec-readiness-oracle.ts +++ b/tests/e2e/helpers/startup-exec-readiness-oracle.ts @@ -10,7 +10,7 @@ import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface- import { expect } from './orca-app' import { getTerminalContent, waitForActivePanePtyId } from './terminal' import { readFreshTerminalInventory } from './terminal-inventory-observation' -import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../src/shared/shell-argument-quoting' const RECOVERY_DEADLINE_MS = 8_000 diff --git a/tests/e2e/helpers/terminal-kitty-file-image-proof.ts b/tests/e2e/helpers/terminal-kitty-file-image-proof.ts index 7edd3e8e653..febdc51d754 100644 --- a/tests/e2e/helpers/terminal-kitty-file-image-proof.ts +++ b/tests/e2e/helpers/terminal-kitty-file-image-proof.ts @@ -1,6 +1,6 @@ import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test' import { PNG } from 'pngjs' -import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../src/shared/shell-argument-quoting' import { nodeTerminalCommand } from '../terminal-node-command' import { execInTerminal, waitForTerminalOutput } from './terminal' import { readInlineImageState } from './terminal-inline-image-proof' diff --git a/tests/e2e/helpers/terminal-workspace-root-link.ts b/tests/e2e/helpers/terminal-workspace-root-link.ts index c59478f0e5b..d1eb9bbf94b 100644 --- a/tests/e2e/helpers/terminal-workspace-root-link.ts +++ b/tests/e2e/helpers/terminal-workspace-root-link.ts @@ -1,7 +1,7 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect } from './orca-app' import { focusActiveTerminalInput, getTerminalContent } from './terminal' -import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../../src/shared/shell-argument-quoting' export async function openTerminalWorkspaceRootLink( page: Page, diff --git a/tests/e2e/host-parked-pane-remote-viewer.spec.ts b/tests/e2e/host-parked-pane-remote-viewer.spec.ts index de722116b12..d25e79fd3a2 100644 --- a/tests/e2e/host-parked-pane-remote-viewer.spec.ts +++ b/tests/e2e/host-parked-pane-remote-viewer.spec.ts @@ -46,7 +46,7 @@ import { } from './helpers/paired-electron-client' import { focusActiveTerminalInput } from './helpers/terminal' import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const PARK_DELAY_MS = 30_000 const PAINT_BUDGET_MS = 20_000 diff --git a/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts b/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts index 707c9c9946f..b37be4edfaa 100644 --- a/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts +++ b/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts @@ -39,7 +39,7 @@ import { type PairedElectronClient } from './helpers/paired-electron-client' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' /** What a user would accept for "my terminal is back" after reopening the app. */ const RESTORE_BUDGET_MS = 60_000 diff --git a/tests/e2e/paired-remote-terminal-host-quit-reconnect-input.spec.ts b/tests/e2e/paired-remote-terminal-host-quit-reconnect-input.spec.ts index cb02e03d678..71083d373b3 100644 --- a/tests/e2e/paired-remote-terminal-host-quit-reconnect-input.spec.ts +++ b/tests/e2e/paired-remote-terminal-host-quit-reconnect-input.spec.ts @@ -30,7 +30,7 @@ import { holdWindowGraphPublication, releaseWindowGraphPublication } from './helpers/hold-window-graph-publication' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-host-quit-input-')) const fixturePath = path.join(scratch, 'paired-host-quit-terminal.mjs') diff --git a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts index 9e1c1de712c..0d78b0c39fa 100644 --- a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts +++ b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts @@ -16,7 +16,7 @@ import { } from './helpers/paired-electron-client' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { waitForTabParked } from './helpers/terminal-hidden-parking' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const PARK_DELAY_MS = 2_000 const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-host-restart-background-')) diff --git a/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts b/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts index 86126bc054b..135346c60aa 100644 --- a/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts +++ b/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts @@ -25,7 +25,7 @@ import { readPairedPaneContent, waitForPairedPaneMarker } from './helpers/paired-host-terminal' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const LINE_COUNT = 300 const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-initial-scrollback-')) diff --git a/tests/e2e/paired-remote-terminal-lossy-initial-snapshot.spec.ts b/tests/e2e/paired-remote-terminal-lossy-initial-snapshot.spec.ts index 48cb5c18d2e..5c03e8367a5 100644 --- a/tests/e2e/paired-remote-terminal-lossy-initial-snapshot.spec.ts +++ b/tests/e2e/paired-remote-terminal-lossy-initial-snapshot.spec.ts @@ -9,7 +9,7 @@ import { createRuntimeDesktopPairingOffer, launchPairedElectronClient } from './helpers/paired-electron-client' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-lossy-snapshot-')) const fixturePath = path.join(scratch, 'lossy-snapshot-terminal.mjs') diff --git a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts index 1cb35438189..395f06dbf56 100644 --- a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts +++ b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts @@ -17,7 +17,7 @@ import { } from './helpers/paired-electron-client' import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' import { readFreshTerminalInventory } from './helpers/terminal-inventory-observation' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-materialize-')) const fixturePath = path.join(scratch, 'materialize-terminal.mjs') diff --git a/tests/e2e/paired-remote-terminal-network-outage.spec.ts b/tests/e2e/paired-remote-terminal-network-outage.spec.ts index caa33e1c313..5e08a39a85f 100644 --- a/tests/e2e/paired-remote-terminal-network-outage.spec.ts +++ b/tests/e2e/paired-remote-terminal-network-outage.spec.ts @@ -14,7 +14,7 @@ import path from 'node:path' import type { Page } from '@stablyai/playwright-test' import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' import { decodePairingOffer, encodePairingOffer } from '../../src/shared/pairing' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' import { expect, test } from './helpers/orca-app' import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' import { cleanupE2EDaemons } from './helpers/electron-process-shutdown' diff --git a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts index f6963a28e1b..237deec0399 100644 --- a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts @@ -37,7 +37,7 @@ import { } from './helpers/paired-electron-client' import { focusActiveTerminalInput } from './helpers/terminal' import { waitForTabParked } from './helpers/terminal-hidden-parking' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const PARK_DELAY_MS = 2_000 const LIVE_PAINT_BUDGET_MS = 12_000 diff --git a/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts index b91818bff36..cb95555ec3d 100644 --- a/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts @@ -55,7 +55,7 @@ import { import { focusActiveTerminalInput } from './helpers/terminal' import { waitForTabParked } from './helpers/terminal-hidden-parking' import { readPersistedProfileState } from './helpers/persisted-profile-state' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const PARK_DELAY_MS = 2_000 const PAINT_BUDGET_MS = 30_000 diff --git a/tests/e2e/paired-remote-terminal-parked-scrollback-survives.spec.ts b/tests/e2e/paired-remote-terminal-parked-scrollback-survives.spec.ts index cc8e62de335..9b4f3a1face 100644 --- a/tests/e2e/paired-remote-terminal-parked-scrollback-survives.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-scrollback-survives.spec.ts @@ -49,7 +49,7 @@ import { } from './helpers/paired-host-terminal' import { focusActiveTerminalInput } from './helpers/terminal' import { waitForTabParked } from './helpers/terminal-hidden-parking' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const PARK_DELAY_MS = 2_000 const PAINT_BUDGET_MS = 30_000 diff --git a/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts index 81335f1b991..f328df2b710 100644 --- a/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts +++ b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts @@ -14,7 +14,7 @@ import { launchPairedWebClient } from './helpers/paired-electron-client' import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-probe-gap-')) const fixturePath = path.join(scratch, 'probe-gap-terminal.mjs') diff --git a/tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts b/tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts index aeed931b7e8..f726868137a 100644 --- a/tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts +++ b/tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts @@ -11,7 +11,7 @@ import { launchPairedWebClient } from './helpers/paired-electron-client' import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const RETENTION_PARK_DELAY_MS = 100 const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-truncated-tail-')) diff --git a/tests/e2e/remote-agent-session-focus-authority.spec.ts b/tests/e2e/remote-agent-session-focus-authority.spec.ts index a1594f34fa3..9a097849dbc 100644 --- a/tests/e2e/remote-agent-session-focus-authority.spec.ts +++ b/tests/e2e/remote-agent-session-focus-authority.spec.ts @@ -9,7 +9,7 @@ import { } from './helpers/paired-electron-client' import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' import type { RuntimeTerminalSummary } from '../../src/shared/runtime-types' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' type ClientMirror = { activeTabId: string | null diff --git a/tests/e2e/repo-registration-async.spec.ts b/tests/e2e/repo-registration-async.spec.ts index be590844ef6..8e0c8a65e49 100644 --- a/tests/e2e/repo-registration-async.spec.ts +++ b/tests/e2e/repo-registration-async.spec.ts @@ -89,8 +89,9 @@ test('real WSL registration preserves literal paths and fences login output', as }, testInfo) => { const distro = process.env.ORCA_REAL_WSL_DISTRO test.skip(process.platform !== 'win32' || !distro, 'Requires a real Windows WSL distro') - const { buildWslExecArgs, buildWslCapturedLoginShellCommand, quotePosixShell } = + const { buildWslExecArgs, buildWslCapturedLoginShellCommand } = await import('../../src/shared/wsl-login-shell-command') + const { quotePosixShell } = await import('../../src/shared/shell-argument-quoting') const root = `/tmp/or60risk/run-${Date.now()}` const repoPath = `${root}/repo-$literal space` const uncPath = `\\\\wsl.localhost\\${distro}${repoPath.replaceAll('/', '\\')}` diff --git a/tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts b/tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts index a8c3b76032b..b7530b0706e 100644 --- a/tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts +++ b/tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts @@ -13,7 +13,7 @@ import { type DockerSshHostTarget } from './helpers/docker-ssh-host-target' import { resolveSshBrowserNetworkExecutionRoute } from '../../src/main/browser/ssh-browser-network-execution-route' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' const runDocker = process.env.ORCA_RUN_DOCKER_SSH_BROWSER_E2E === '1' const executionHost = { diff --git a/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts b/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts index 88de5b66bda..5d76c5a4d6b 100644 --- a/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts +++ b/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts @@ -18,7 +18,7 @@ import { } from './helpers/browser-split-guest-probes' import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' import { getWorktreeHostIdentity } from '../../src/shared/worktree/host-qualified-identity' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' skipUnlessManagedDockerHost() test.skip(process.platform === 'win32', 'Owned POSIX host collision fixture') diff --git a/tests/e2e/ssh-orcad-open-in-owner.spec.ts b/tests/e2e/ssh-orcad-open-in-owner.spec.ts index f446020d7f3..54035e43a6a 100644 --- a/tests/e2e/ssh-orcad-open-in-owner.spec.ts +++ b/tests/e2e/ssh-orcad-open-in-owner.spec.ts @@ -19,7 +19,7 @@ import { import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' import { folderWorkspaceKey } from '../../src/shared/workspace-scope' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' skipUnlessManagedDockerHost() test.skip(process.platform === 'win32', 'Needs a POSIX recorder') diff --git a/tests/e2e/ssh-orcad-terminal-root-owner.spec.ts b/tests/e2e/ssh-orcad-terminal-root-owner.spec.ts index ea2e36b4f29..45adc94dcba 100644 --- a/tests/e2e/ssh-orcad-terminal-root-owner.spec.ts +++ b/tests/e2e/ssh-orcad-terminal-root-owner.spec.ts @@ -13,7 +13,7 @@ import { openFileExplorer } from './helpers/file-explorer' import { openTerminalWorkspaceRootLink } from './helpers/terminal-workspace-root-link' import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' import { getWorktreeHostIdentity } from '../../src/shared/worktree/host-qualified-identity' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' import { focusActiveTerminalInput, diff --git a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts index c051ae0a20d..9d71b6bbdb4 100644 --- a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts +++ b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts @@ -14,7 +14,7 @@ import { } from './helpers/docker-ssh-host-target' import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' import { ORCAD_TEMPLATE, skipUnlessManagedDockerHost } from './helpers/orcad-managed-workspace' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' /** A recipe VM's SSH root, served by a managed orcad the first connect deploys. */ skipUnlessManagedDockerHost() diff --git a/tests/e2e/ssh-orcad-workspace-tools.spec.ts b/tests/e2e/ssh-orcad-workspace-tools.spec.ts index bbb10fce8d5..0911b65b9e3 100644 --- a/tests/e2e/ssh-orcad-workspace-tools.spec.ts +++ b/tests/e2e/ssh-orcad-workspace-tools.spec.ts @@ -17,7 +17,7 @@ import { typeInTerminal, withManagedHost } from './helpers/orcad-managed-workspace' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' skipUnlessManagedDockerHost() test.skip(process.platform === 'win32', 'The Docker SSH host uses POSIX tooling') diff --git a/tests/e2e/terminal-node-command.ts b/tests/e2e/terminal-node-command.ts index 26dfd5c9688..345c893635b 100644 --- a/tests/e2e/terminal-node-command.ts +++ b/tests/e2e/terminal-node-command.ts @@ -1,5 +1,5 @@ import { quotePowerShellLiteral } from '../../src/shared/powershell-native-argument' -import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' function quoteTerminalArg(value: string): string { return process.platform === 'win32' ? quotePowerShellLiteral(value) : quotePosixShell(value)