diff --git a/src/utils/install.ts b/src/utils/install.ts index 7acfaaf..a7c58a8 100644 --- a/src/utils/install.ts +++ b/src/utils/install.ts @@ -475,6 +475,13 @@ fi `Could not detect your shell profile. To use localstack CLI from terminal ensure ~/.local/bin is in your PATH.`, ); } + + // Update PATH for the current VSCode process so LocalStack is immediately available + const localBinPath = path.join(homeDir, ".local", "bin"); + const currentPath = process.env.PATH || ""; + if (!currentPath.split(":").includes(localBinPath)) { + process.env.PATH = `${localBinPath}:${currentPath}`; + } } catch (err) { window.showInformationMessage( `Could not update your shell profile. To use localstack CLI from terminal ensure ~/.local/bin is in your PATH.`, @@ -515,9 +522,6 @@ async function installLocalWindows(temporaryDirname: string) { await move(`${temporaryDirname}/localstack`, LOCAL_CLI_INSTALLATION_DIRNAME); await exec(`setx PATH "%PATH%;${LOCAL_CLI_INSTALLATION_DIRNAME}"`); - // // Update PATH for current process (setx only updates for new processes, including vscode) - // process.env.PATH = `${process.env.PATH};${CLI_INSTALLATION_DIRNAME}`; - window.showInformationMessage("LocalStack CLI installed for current user."); } @@ -551,17 +555,23 @@ async function installGlobalLinux( ) { // Use elevated privileges to install globally on linux - const { cancelled } = await spawnElevatedLinux({ - //TODO consider loading script from a file - script: `rm -rf /usr/local/localstack && mv ${temporaryDirname}/localstack /usr/local/localstack && ln -sf /usr/local/localstack/localstack /usr/local/bin/localstack`, - outputChannel, - outputLabel: "install", - cancellationToken, - }); - if (cancelled) { - //TODO check if progress can be used instead of window - window.showErrorMessage("The installation was cancelled by the user"); - return { cancelled: true }; + try { + const { cancelled } = await spawnElevatedLinux({ + //TODO consider loading script from a file + script: `rm -rf /usr/local/localstack && mv ${temporaryDirname}/localstack /usr/local/localstack && ln -sf /usr/local/localstack/localstack /usr/local/bin/localstack`, + outputChannel, + outputLabel: "install", + cancellationToken, + }); + if (cancelled) { + //TODO check if progress can be used instead of window + window.showErrorMessage("The installation was cancelled by the user"); + return { cancelled: true }; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + window.showErrorMessage(`Installation failed: ${message}`); + throw error; } } diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts index b4e26c4..6b9ea48 100644 --- a/src/utils/prompts.ts +++ b/src/utils/prompts.ts @@ -1,10 +1,12 @@ +import * as childProcess from "node:child_process"; import { appendFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import * as vscode from "vscode"; import type { CancellationToken, LogOutputChannel } from "vscode"; -import { spawn, SpawnError } from "./spawn.ts"; +import { pipeToLogOutputChannel, spawn, SpawnError } from "./spawn.ts"; export interface SpawnElevatedDarwinOptions { script: string; @@ -50,28 +52,136 @@ export interface SpawnElevatedLinuxOptions { cancellationToken?: CancellationToken; } -export async function spawnElevatedLinux( +async function tryPkexec( options: SpawnElevatedLinuxOptions, -): Promise<{ cancelled: boolean }> { +): Promise<{ success: boolean; cancelled: boolean }> { + const scriptArg = JSON.stringify(options.script); try { - await spawn("pkexec", ["sh", "-c", `${JSON.stringify(options.script)}`], { + await spawn("pkexec", ["sh", "-c", scriptArg], { outputChannel: options.outputChannel, outputLabel: options.outputLabel, cancellationToken: options.cancellationToken, }); - return { - cancelled: false, - }; + return { success: true, cancelled: false }; } catch (error) { - if (error instanceof SpawnError && error.code === 126) { - return { cancelled: true }; + if (error instanceof SpawnError) { + // User cancelled (pkexec returns 126) + if (error.code === 126) { + return { success: true, cancelled: true }; + } + // Other failures (no polkit agent, TTY error, etc.) - signal to try fallback + return { success: false, cancelled: false }; } - - options.outputChannel.error(error instanceof Error ? error : String(error)); throw error; } } +async function spawnWithSudoPassword( + options: SpawnElevatedLinuxOptions, +): Promise<{ cancelled: boolean }> { + // Security note: This approach is less secure than pkexec because: + // - Password flows through VS Code → Node.js → child process stdin (more attack surface) + // - Password briefly exists in application memory (vulnerable to memory dumps) + // - Potential attack vector: A malicious VS Code extension could theoretically intercept + // the password by hooking into Node.js I/O streams or reading process memory + // (though VS Code's extension sandbox provides some isolation) + // + // However, this is acceptable because: + // - We only use this when pkexec is unavailable (systems without desktop environment) + // - Password is masked in input, never logged, and passed via stdin only + // - Password is not stored/cached and is disposed immediately + // - This is a one-time installation operation, not continuous privileged access + // + // Alternative: complete installation failure on systems without polkit agents + const password = await vscode.window.showInputBox({ + prompt: "Enter your sudo password", + password: true, + ignoreFocusOut: true, + }); + + if (password === undefined) { + return { cancelled: true }; + } + + const outputLabel = options.outputLabel ? `[${options.outputLabel}]: ` : ""; + + return new Promise((resolve, reject) => { + let stderrOutput = ""; + + const child = childProcess.spawn( + "sudo", + ["-S", "sh", "-c", options.script], + { + stdio: ["pipe", "pipe", "pipe"], + }, + ); + + // Capture stderr to detect wrong password + child.stderr?.on("data", (data: Buffer) => { + stderrOutput += data.toString(); + }); + + // Write password to stdin and close it + // Note: We use stdin rather than command-line arguments because: + // - Command-line args are visible in process listings (eg ps aux) + // - stdin prevents the password from appearing in logs or shell history + // - stdin data is not visible to other users on the system + // However, the password still exists briefly in our process memory and the pipe buffer + child.stdin.write(`${password}\n`); + child.stdin.end(); + + // Redirect the child process stdout/stderr to VSCode's output channel for visibility + pipeToLogOutputChannel(child, options.outputChannel, outputLabel); + + const disposeCancel = options.cancellationToken?.onCancellationRequested( + () => { + child.kill("SIGINT"); + reject(new Error("Command cancelled")); + }, + ); + + child.on("close", (code) => { + disposeCancel?.dispose(); + if (code === 0) { + resolve({ cancelled: false }); + } else { + // Detect wrong password from stderr + const isWrongPassword = + stderrOutput.includes("Sorry, try again") || + stderrOutput.includes("incorrect password") || + stderrOutput.includes("Authentication failure"); + + const errorMessage = isWrongPassword + ? "Incorrect sudo password" + : `sudo command failed with exit code ${code}`; + + reject(new Error(errorMessage)); + } + }); + + child.on("error", (error) => { + disposeCancel?.dispose(); + reject(error); + }); + }); +} + +export async function spawnElevatedLinux( + options: SpawnElevatedLinuxOptions, +): Promise<{ cancelled: boolean }> { + // Try pkexec first (works with graphical polkit agents) + const pkexecResult = await tryPkexec(options); + if (pkexecResult.success) { + return { cancelled: pkexecResult.cancelled }; + } + + // Fallback: prompt for sudo password via VS Code input + options.outputChannel.info( + "pkexec not available, falling back to sudo password prompt", + ); + return spawnWithSudoPassword(options); +} + export async function spawnElevatedWindows({ script, outputChannel,