fix(terminals): load interactive login profiles

This commit is contained in:
Federico Jaramillo Martinez
2026-07-18 20:47:07 +02:00
parent d2e2d512cd
commit 2b1507ba35
3 changed files with 104 additions and 3 deletions
+90 -1
View File
@@ -1,8 +1,26 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { RealtimeEvent, TerminalInfo } from "../../shared/apiTypes.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { TerminalService } from "./terminalService";
import { interactiveShellArgs, TerminalService } from "./terminalService";
describe("interactive shell arguments", () => {
it.each([
{ shell: "bash", expected: ["-l"] },
{ shell: "/usr/local/bin/zsh", expected: ["-l"] },
{ shell: "/opt/homebrew/bin/fish", expected: ["-l"] },
{ shell: String.raw`C:\Program Files\Git\bin\bash.exe`, expected: ["-l"] },
{ shell: "/bin/dash", expected: [] },
{ shell: "pwsh", expected: [] },
{ shell: "powershell.exe", expected: [] },
{ shell: "cmd.exe", expected: [] },
])("uses login mode only for a supported shell: $shell", ({ shell, expected }) => {
expect(interactiveShellArgs(shell)).toEqual(expected);
});
});
// TerminalService spawns a POSIX shell (/bin/bash with -lc and commands like
// printf/true/exit). The terminal feature is not supported on native Windows,
@@ -22,6 +40,47 @@ describe.skipIf(process.platform === "win32")("TerminalService command runs", ()
}
});
it("loads login-profile PATH entries in new interactive terminals", async () => {
await withBashLoginProfile(async () => {
const service = new TerminalService();
try {
const terminal = service.create({ cwd: process.cwd() });
const exit = terminalExit(service, terminal.id);
service.write(terminal.id, `${LOGIN_PROFILE_COMMAND}\nexit\n`);
expect(await exit).toContain(LOGIN_PROFILE_OUTPUT);
} finally {
service.dispose();
}
});
});
it("loads login-profile PATH entries in continued interactive terminals", async () => {
await withBashLoginProfile(async () => {
const service = new TerminalService();
try {
const run = service.runCommand({
origin: "core",
projectId: "p1",
workspaceId: "w1",
cwd: process.cwd(),
title: "Done command",
command: "true",
});
await terminalExit(service, run.terminalId);
service.continue(run.terminalId);
const exit = terminalExit(service, run.terminalId);
service.write(run.terminalId, `${LOGIN_PROFILE_COMMAND}\nexit\n`);
expect(await exit).toContain(LOGIN_PROFILE_OUTPUT);
} finally {
service.dispose();
}
});
});
describe("PI_WEB_TERMINAL propagation", () => {
let originalPiWebTerminal: string | undefined;
@@ -223,6 +282,36 @@ function requireTerminal(service: TerminalService, terminalId: string): Terminal
return terminal;
}
const LOGIN_PROFILE_COMMAND = "pi-web-test-login-profile-command";
const LOGIN_PROFILE_OUTPUT = "__PI_WEB_LOGIN_PROFILE_PATH_COMMAND__";
async function withBashLoginProfile(run: () => Promise<void>): Promise<void> {
const home = await mkdtemp(join(tmpdir(), "pi-web-terminal-home-"));
const profileBin = join(home, "profile-bin");
await mkdir(profileBin);
const commandPath = join(profileBin, LOGIN_PROFILE_COMMAND);
await writeFile(commandPath, `#!/bin/sh\nprintf '%s\\n' '${LOGIN_PROFILE_OUTPUT}'\n`);
await chmod(commandPath, 0o755);
await writeFile(join(home, ".bash_profile"), `export PATH="$HOME/profile-bin:$PATH"\n`);
const originalHome = process.env["HOME"];
const originalShell = process.env["SHELL"];
process.env["HOME"] = home;
process.env["SHELL"] = "/bin/bash";
try {
await run();
} finally {
restoreEnv("HOME", originalHome);
restoreEnv("SHELL", originalShell);
await rm(home, { recursive: true, force: true });
}
}
function restoreEnv(key: "HOME" | "SHELL", value: string | undefined): void {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
}
function terminalExit(service: TerminalService, terminalId: string): Promise<string> {
const output: string[] = [];
return new Promise((resolve, reject) => {