mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
refactor(ssh): split ssh-connection-utils into concretely named modules (#30488)
* refactor(ssh): split ssh-connection-utils into concretely named modules - classifiers -> ssh-reconnect-error-classification.ts - retry/timeout constants and sleep -> connection/ssh-connection-timing.ts - buildConnectConfig -> connection/ssh-connect-config.ts - credential callbacks -> connection/ssh-connection-callbacks.ts - exec options and abort error -> connection/ssh-exec-options.ts - wrapRemoteCommandForPosixShell -> ssh-posix-command-wrapper.ts - shellEscape and shared/wsl-login-shell-command quotePosixShell (identical) -> shared/shell-argument-quoting.ts quotePosixShell No re-exports are left behind. Co-Authored-By: Claude <noreply@anthropic.com> * fix: point the WSL git benchmark and the WSL lane route at shell-argument-quoting Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<HostServerOnConnectResult> {
|
||||
// Why lazy: the managed-server graph (deploy, migration, tunnel) loads only when a host connects.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -15,8 +15,8 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => (
|
||||
...(await importOriginal<typeof ExecCommandModule>()),
|
||||
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<typeof InstallLock>()),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -14,7 +14,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => (
|
||||
...(await importOriginal<typeof ExecCommandModule>()),
|
||||
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<typeof InstallLock>()),
|
||||
acquireInstallLock: vi.fn()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);`,
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => (
|
||||
...(await importOriginal<typeof ExecCommandModule>()),
|
||||
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<typeof InstallLock>()),
|
||||
acquireInstallLock: vi.fn()
|
||||
|
||||
@@ -5,7 +5,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => (
|
||||
...(await importOriginal<typeof ExecCommandModule>()),
|
||||
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'),
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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} || ` +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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#*:};'
|
||||
]),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,7 +8,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => (
|
||||
...(await importOriginal<typeof ExecCommandModule>()),
|
||||
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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(' ')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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; }`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -6,7 +6,9 @@ vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => (
|
||||
...(await importOriginal<typeof ExecCommandModule>()),
|
||||
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<typeof InstallLock>()),
|
||||
acquireInstallLock: vi.fn()
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
+6
-228
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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
|
||||
}
|
||||
@@ -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<string | null>
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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))})`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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%%.*};',
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('./remote-exec/remote-exec-command', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ExecCommandModule>()),
|
||||
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),
|
||||
|
||||
@@ -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('|')
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string | null>
|
||||
}
|
||||
|
||||
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<SshTarget, 'gssapiAuthentication'>,
|
||||
resolved: Pick<SshResolvedConfig, 'gssapiAuthentication'> | 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<void> {
|
||||
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<string, unknown> = {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user