Skip to content

Commit 71577ac

Browse files
fix: localstack installation on ubuntu/i3 (#77)
This PR fixes two installation issues with the LocalStack CLI: 1. Global installation on Linux without desktop environment (using i3 in my case): When `pkexec` fails due to missing polkit agent (graphical authentication dialog), the installer now falls back to prompting for sudo password via VS Code input box 2. Local installation PATH availability: The LocalStack CLI binary path is now immediately added to the current VS Code process environment, eliminating the need to restart VS Code after installation This is the password prompt when using i3 (using vscode): <img width="727" height="100" alt="i3-vscode-pw" src="https://github.com/user-attachments/assets/e8249191-039c-4ce4-b8ff-909ccfbb64ae" /> This is the password prompt when using Gnome desktop env (using pkexec): ![IMG_2699](https://github.com/user-attachments/assets/3ea67e14-6594-43e0-903c-4dbadab282d9) Sorry for the literal screen shot 😁
1 parent fef3279 commit 71577ac

File tree

2 files changed

+145
-25
lines changed

2 files changed

+145
-25
lines changed

src/utils/install.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,13 @@ fi
475475
`Could not detect your shell profile. To use localstack CLI from terminal ensure ~/.local/bin is in your PATH.`,
476476
);
477477
}
478+
479+
// Update PATH for the current VSCode process so LocalStack is immediately available
480+
const localBinPath = path.join(homeDir, ".local", "bin");
481+
const currentPath = process.env.PATH || "";
482+
if (!currentPath.split(":").includes(localBinPath)) {
483+
process.env.PATH = `${localBinPath}:${currentPath}`;
484+
}
478485
} catch (err) {
479486
window.showInformationMessage(
480487
`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) {
515522
await move(`${temporaryDirname}/localstack`, LOCAL_CLI_INSTALLATION_DIRNAME);
516523
await exec(`setx PATH "%PATH%;${LOCAL_CLI_INSTALLATION_DIRNAME}"`);
517524

518-
// // Update PATH for current process (setx only updates for new processes, including vscode)
519-
// process.env.PATH = `${process.env.PATH};${CLI_INSTALLATION_DIRNAME}`;
520-
521525
window.showInformationMessage("LocalStack CLI installed for current user.");
522526
}
523527

@@ -551,17 +555,23 @@ async function installGlobalLinux(
551555
) {
552556
// Use elevated privileges to install globally on linux
553557

554-
const { cancelled } = await spawnElevatedLinux({
555-
//TODO consider loading script from a file
556-
script: `rm -rf /usr/local/localstack && mv ${temporaryDirname}/localstack /usr/local/localstack && ln -sf /usr/local/localstack/localstack /usr/local/bin/localstack`,
557-
outputChannel,
558-
outputLabel: "install",
559-
cancellationToken,
560-
});
561-
if (cancelled) {
562-
//TODO check if progress can be used instead of window
563-
window.showErrorMessage("The installation was cancelled by the user");
564-
return { cancelled: true };
558+
try {
559+
const { cancelled } = await spawnElevatedLinux({
560+
//TODO consider loading script from a file
561+
script: `rm -rf /usr/local/localstack && mv ${temporaryDirname}/localstack /usr/local/localstack && ln -sf /usr/local/localstack/localstack /usr/local/bin/localstack`,
562+
outputChannel,
563+
outputLabel: "install",
564+
cancellationToken,
565+
});
566+
if (cancelled) {
567+
//TODO check if progress can be used instead of window
568+
window.showErrorMessage("The installation was cancelled by the user");
569+
return { cancelled: true };
570+
}
571+
} catch (error) {
572+
const message = error instanceof Error ? error.message : String(error);
573+
window.showErrorMessage(`Installation failed: ${message}`);
574+
throw error;
565575
}
566576
}
567577

src/utils/prompts.ts

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import * as childProcess from "node:child_process";
12
import { appendFile, rm } from "node:fs/promises";
23
import { tmpdir } from "node:os";
34
import path from "node:path";
45

6+
import * as vscode from "vscode";
57
import type { CancellationToken, LogOutputChannel } from "vscode";
68

7-
import { spawn, SpawnError } from "./spawn.ts";
9+
import { pipeToLogOutputChannel, spawn, SpawnError } from "./spawn.ts";
810

911
export interface SpawnElevatedDarwinOptions {
1012
script: string;
@@ -50,28 +52,136 @@ export interface SpawnElevatedLinuxOptions {
5052
cancellationToken?: CancellationToken;
5153
}
5254

53-
export async function spawnElevatedLinux(
55+
async function tryPkexec(
5456
options: SpawnElevatedLinuxOptions,
55-
): Promise<{ cancelled: boolean }> {
57+
): Promise<{ success: boolean; cancelled: boolean }> {
58+
const scriptArg = JSON.stringify(options.script);
5659
try {
57-
await spawn("pkexec", ["sh", "-c", `${JSON.stringify(options.script)}`], {
60+
await spawn("pkexec", ["sh", "-c", scriptArg], {
5861
outputChannel: options.outputChannel,
5962
outputLabel: options.outputLabel,
6063
cancellationToken: options.cancellationToken,
6164
});
62-
return {
63-
cancelled: false,
64-
};
65+
return { success: true, cancelled: false };
6566
} catch (error) {
66-
if (error instanceof SpawnError && error.code === 126) {
67-
return { cancelled: true };
67+
if (error instanceof SpawnError) {
68+
// User cancelled (pkexec returns 126)
69+
if (error.code === 126) {
70+
return { success: true, cancelled: true };
71+
}
72+
// Other failures (no polkit agent, TTY error, etc.) - signal to try fallback
73+
return { success: false, cancelled: false };
6874
}
69-
70-
options.outputChannel.error(error instanceof Error ? error : String(error));
7175
throw error;
7276
}
7377
}
7478

79+
async function spawnWithSudoPassword(
80+
options: SpawnElevatedLinuxOptions,
81+
): Promise<{ cancelled: boolean }> {
82+
// Security note: This approach is less secure than pkexec because:
83+
// - Password flows through VS Code → Node.js → child process stdin (more attack surface)
84+
// - Password briefly exists in application memory (vulnerable to memory dumps)
85+
// - Potential attack vector: A malicious VS Code extension could theoretically intercept
86+
// the password by hooking into Node.js I/O streams or reading process memory
87+
// (though VS Code's extension sandbox provides some isolation)
88+
//
89+
// However, this is acceptable because:
90+
// - We only use this when pkexec is unavailable (systems without desktop environment)
91+
// - Password is masked in input, never logged, and passed via stdin only
92+
// - Password is not stored/cached and is disposed immediately
93+
// - This is a one-time installation operation, not continuous privileged access
94+
//
95+
// Alternative: complete installation failure on systems without polkit agents
96+
const password = await vscode.window.showInputBox({
97+
prompt: "Enter your sudo password",
98+
password: true,
99+
ignoreFocusOut: true,
100+
});
101+
102+
if (password === undefined) {
103+
return { cancelled: true };
104+
}
105+
106+
const outputLabel = options.outputLabel ? `[${options.outputLabel}]: ` : "";
107+
108+
return new Promise((resolve, reject) => {
109+
let stderrOutput = "";
110+
111+
const child = childProcess.spawn(
112+
"sudo",
113+
["-S", "sh", "-c", options.script],
114+
{
115+
stdio: ["pipe", "pipe", "pipe"],
116+
},
117+
);
118+
119+
// Capture stderr to detect wrong password
120+
child.stderr?.on("data", (data: Buffer) => {
121+
stderrOutput += data.toString();
122+
});
123+
124+
// Write password to stdin and close it
125+
// Note: We use stdin rather than command-line arguments because:
126+
// - Command-line args are visible in process listings (eg ps aux)
127+
// - stdin prevents the password from appearing in logs or shell history
128+
// - stdin data is not visible to other users on the system
129+
// However, the password still exists briefly in our process memory and the pipe buffer
130+
child.stdin.write(`${password}\n`);
131+
child.stdin.end();
132+
133+
// Redirect the child process stdout/stderr to VSCode's output channel for visibility
134+
pipeToLogOutputChannel(child, options.outputChannel, outputLabel);
135+
136+
const disposeCancel = options.cancellationToken?.onCancellationRequested(
137+
() => {
138+
child.kill("SIGINT");
139+
reject(new Error("Command cancelled"));
140+
},
141+
);
142+
143+
child.on("close", (code) => {
144+
disposeCancel?.dispose();
145+
if (code === 0) {
146+
resolve({ cancelled: false });
147+
} else {
148+
// Detect wrong password from stderr
149+
const isWrongPassword =
150+
stderrOutput.includes("Sorry, try again") ||
151+
stderrOutput.includes("incorrect password") ||
152+
stderrOutput.includes("Authentication failure");
153+
154+
const errorMessage = isWrongPassword
155+
? "Incorrect sudo password"
156+
: `sudo command failed with exit code ${code}`;
157+
158+
reject(new Error(errorMessage));
159+
}
160+
});
161+
162+
child.on("error", (error) => {
163+
disposeCancel?.dispose();
164+
reject(error);
165+
});
166+
});
167+
}
168+
169+
export async function spawnElevatedLinux(
170+
options: SpawnElevatedLinuxOptions,
171+
): Promise<{ cancelled: boolean }> {
172+
// Try pkexec first (works with graphical polkit agents)
173+
const pkexecResult = await tryPkexec(options);
174+
if (pkexecResult.success) {
175+
return { cancelled: pkexecResult.cancelled };
176+
}
177+
178+
// Fallback: prompt for sudo password via VS Code input
179+
options.outputChannel.info(
180+
"pkexec not available, falling back to sudo password prompt",
181+
);
182+
return spawnWithSudoPassword(options);
183+
}
184+
75185
export async function spawnElevatedWindows({
76186
script,
77187
outputChannel,

0 commit comments

Comments
 (0)