Skip to content

Commit aa3c503

Browse files
committed
fix(terminal): harden profile settings and cleanup
1 parent 420c330 commit aa3c503

18 files changed

Lines changed: 652 additions & 82 deletions

File tree

apps/vscode-e2e/src/suite/tools/terminal-profile.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,17 @@ suite("Terminal Profile", function () {
166166
assert.ok(content.includes("zoo-profile-override-ok"), `Output file should contain marker, got: ${content}`)
167167

168168
assert.ok(vscode.window.terminals.length >= 1, "At least one VS Code terminal should exist")
169+
const profileTerminal = vscode.window.terminals.find((terminal) => {
170+
const options = terminal.creationOptions as vscode.TerminalOptions
171+
return (
172+
options.name === "Zoo Code" &&
173+
options.shellPath === "/bin/bash" &&
174+
Array.isArray(options.shellArgs) &&
175+
options.shellArgs.includes("--noprofile") &&
176+
options.shellArgs.includes("--norc")
177+
)
178+
})
179+
assert.ok(profileTerminal, "Expected a Zoo Code terminal created with the configured Bash profile")
169180
} finally {
170181
api.off(RooCodeEventName.Message, messageHandler)
171182
}

packages/types/src/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
140140
*/
141141
setActiveProfile(name: string): Promise<string | undefined>
142142
/**
143-
* Activates a VS Code terminal profile override for Zoo Code commands.
143+
* Activates a process-wide VS Code terminal profile override for Zoo Code
144+
* commands. This is intended for trusted extension integrations.
144145
* Passing undefined restores the VS Code default profile behavior and
145146
* closes idle terminals so the next command starts fresh.
146147
*/

src/core/tools/ExecuteCommandTool.ts

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization"
1515
import {
1616
ExitCodeDetails,
1717
RooTerminalCallbacks,
18+
RooTerminalProvider,
1819
RooTerminalProcess,
20+
ShellIntegrationError,
1921
ShellIntegrationErrorDetails,
2022
} from "../../integrations/terminal/types"
2123
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
@@ -26,19 +28,22 @@ import { t } from "../../i18n"
2628
import { getTaskDirectoryPath } from "../../utils/storage"
2729
import { BaseTool, ToolCallbacks } from "./BaseTool"
2830

29-
export class ShellIntegrationError extends Error {
30-
constructor(
31-
message: string,
32-
public readonly commandSubmitted: boolean,
33-
) {
34-
super(message)
35-
}
36-
}
31+
export { ShellIntegrationError } from "../../integrations/terminal/types"
3732

3833
export function canRetryShellIntegrationError(error: unknown): error is ShellIntegrationError {
3934
return error instanceof ShellIntegrationError && !error.commandSubmitted
4035
}
4136

37+
export function getTerminalProviderForExecution(terminalShellIntegrationDisabled: boolean): {
38+
terminalProvider: RooTerminalProvider
39+
isCmdExeFallback: boolean
40+
} {
41+
const isCmdExeFallback = !terminalShellIntegrationDisabled && Terminal.isActiveShellCmdExe()
42+
const terminalProvider = terminalShellIntegrationDisabled || isCmdExeFallback ? "execa" : "vscode"
43+
44+
return { terminalProvider, isCmdExeFallback }
45+
}
46+
4247
interface ExecuteCommandParams {
4348
command: string
4449
cwd?: string
@@ -224,8 +229,7 @@ export async function executeCommandInTerminal(
224229
let shellIntegrationError: ShellIntegrationError | undefined
225230
let hasAskedForCommandOutput = false
226231

227-
const isCmdExeFallback = !terminalShellIntegrationDisabled && Terminal.isActiveShellCmdExe()
228-
const terminalProvider = terminalShellIntegrationDisabled || isCmdExeFallback ? "execa" : "vscode"
232+
const { terminalProvider, isCmdExeFallback } = getTerminalProviderForExecution(terminalShellIntegrationDisabled)
229233
const provider = await task.providerRef.deref()
230234

231235
// cmd.exe can't use shell integration — tell the webview to expand the output
@@ -515,31 +519,15 @@ export async function executeCommandInTerminal(
515519
return [false, formatPersistedOutput(persistedResult, exitDetails, currentWorkingDir)]
516520
}
517521

518-
// Use inline format for small outputs (original behavior with exit status)
519-
let exitStatus: string = ""
520-
521-
if (exitDetails !== undefined) {
522-
if (exitDetails.signalName) {
523-
exitStatus = `Process terminated by signal ${exitDetails.signalName}`
524-
525-
if (exitDetails.coreDumpPossible) {
526-
exitStatus += " - core dump possible"
527-
}
528-
} else if (exitDetails.exitCode === undefined) {
529-
result += "<VSCE exit code is undefined: terminal output and command execution status is unknown.>"
530-
exitStatus = `Exit code: <undefined, notify user>`
531-
} else {
532-
if (exitDetails.exitCode !== 0) {
533-
exitStatus += "Command execution was not successful, inspect the cause and adjust as needed.\n"
534-
}
535-
536-
exitStatus += `Exit code: ${exitDetails.exitCode}`
537-
}
538-
} else {
522+
// Use inline format for small outputs (original behavior with exit status).
523+
if (exitDetails === undefined) {
539524
result += "<VSCE exitDetails == undefined: terminal output and command execution status is unknown.>"
540-
exitStatus = `Exit code: <undefined, notify user>`
525+
} else if (!exitDetails.signalName && exitDetails.exitCode === undefined) {
526+
result += "<VSCE exit code is undefined: terminal output and command execution status is unknown.>"
541527
}
542528

529+
const exitStatus = formatExitStatus(exitDetails)
530+
543531
return [
544532
false,
545533
`Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${result}`,

src/core/tools/__tests__/executeCommandTool.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Task } from "../../task/Task"
77
import { formatResponse } from "../../prompts/responses"
88
import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools"
99
import { unescapeHtmlEntities } from "../../../utils/text-normalization"
10+
import { Terminal } from "../../../integrations/terminal/Terminal"
1011

1112
// Mock dependencies
1213
vitest.mock("execa", () => ({
@@ -268,6 +269,15 @@ describe("executeCommandTool", () => {
268269

269270
expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(false)
270271
})
272+
273+
it("selects the Execa fallback provider for cmd.exe shell integration", () => {
274+
vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(true)
275+
276+
expect(executeCommandModule.getTerminalProviderForExecution(false)).toEqual({
277+
terminalProvider: "execa",
278+
isCmdExeFallback: true,
279+
})
280+
})
271281
})
272282

273283
describe("Command execution timeout configuration", () => {

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ vi.mock("../../mentions/resolveImageMentions", () => ({
172172

173173
import { resolveImageMentions } from "../../mentions/resolveImageMentions"
174174
import { Terminal } from "../../../integrations/terminal/Terminal"
175+
import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry"
175176

176177
describe("webviewMessageHandler - requestLmStudioModels", () => {
177178
beforeEach(() => {
@@ -874,32 +875,78 @@ describe("webviewMessageHandler - mcpEnabled", () => {
874875
describe("webviewMessageHandler - terminalProfile", () => {
875876
beforeEach(() => {
876877
vi.clearAllMocks()
878+
Terminal.setTerminalProfile(undefined)
877879
})
878880

879881
afterEach(() => {
882+
Terminal.setTerminalProfile(undefined)
880883
vi.restoreAllMocks()
881884
})
882885

883-
it("bridges a saved terminalProfile from updateSettings into the process-wide terminal state", async () => {
884-
const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {})
886+
it("normalizes and persists a saved terminalProfile, then closes stale idle terminals", async () => {
887+
const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {})
885888

886889
await webviewMessageHandler(mockClineProvider, {
887890
type: "updateSettings",
888-
updatedSettings: { terminalProfile: "Git Bash" },
891+
updatedSettings: { terminalProfile: " Git Bash " },
889892
})
890893

891-
expect(setTerminalProfileSpy).toHaveBeenCalledWith("Git Bash")
894+
expect(Terminal.getTerminalProfile()).toBe("Git Bash")
895+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", "Git Bash")
896+
expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1)
892897
})
893898

894-
it("clears the terminal profile when updateSettings sends undefined", async () => {
895-
const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {})
899+
it("does not close idle terminals when hydration sends the unchanged profile", async () => {
900+
Terminal.setTerminalProfile("Git Bash")
901+
const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {})
896902

897903
await webviewMessageHandler(mockClineProvider, {
898904
type: "updateSettings",
899-
updatedSettings: { terminalProfile: undefined },
905+
updatedSettings: { terminalProfile: " Git Bash " },
900906
})
901907

902-
expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined)
908+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", "Git Bash")
909+
expect(closeIdleTerminalsSpy).not.toHaveBeenCalled()
910+
})
911+
912+
it("clears the persisted profile when SettingsView sends the empty-string sentinel", async () => {
913+
Terminal.setTerminalProfile("Git Bash")
914+
const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {})
915+
916+
await webviewMessageHandler(mockClineProvider, {
917+
type: "updateSettings",
918+
updatedSettings: { terminalProfile: "" },
919+
})
920+
921+
expect(Terminal.getTerminalProfile()).toBeUndefined()
922+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", undefined)
923+
expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1)
924+
})
925+
926+
it("does not close idle terminals when the empty-string sentinel leaves the profile unset", async () => {
927+
const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {})
928+
929+
await webviewMessageHandler(mockClineProvider, {
930+
type: "updateSettings",
931+
updatedSettings: { terminalProfile: "" },
932+
})
933+
934+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", undefined)
935+
expect(closeIdleTerminalsSpy).not.toHaveBeenCalled()
936+
})
937+
938+
it("treats non-string terminalProfile values as unset", async () => {
939+
Terminal.setTerminalProfile("Git Bash")
940+
const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {})
941+
942+
await webviewMessageHandler(mockClineProvider, {
943+
type: "updateSettings",
944+
updatedSettings: { terminalProfile: 42 as any },
945+
})
946+
947+
expect(Terminal.getTerminalProfile()).toBeUndefined()
948+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("terminalProfile", undefined)
949+
expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1)
903950
})
904951
})
905952

src/core/webview/webviewMessageHandler.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -728,11 +728,16 @@ export const webviewMessageHandler = async (
728728
Terminal.setTerminalZdotdir(value as boolean)
729729
}
730730
} else if (key === "terminalProfile") {
731-
Terminal.setTerminalProfile(value as string | undefined)
732-
// Discard idle terminals so the next command gets a fresh
733-
// terminal using the new profile's shell instead of reusing
734-
// a stale one from the previous profile.
735-
TerminalRegistry.closeIdleTerminals()
731+
const previousProfile = Terminal.getTerminalProfile()
732+
Terminal.setTerminalProfile(typeof value === "string" ? value : undefined)
733+
newValue = Terminal.getTerminalProfile()
734+
735+
if (newValue !== previousProfile) {
736+
// Discard idle terminals so the next command gets a fresh
737+
// terminal using the new profile's shell instead of reusing
738+
// a stale one from the previous profile.
739+
TerminalRegistry.closeIdleTerminals()
740+
}
736741
} else if (key === "execaShellPath") {
737742
Terminal.setExecaShellPath(value as string | undefined)
738743
} else if (key === "mcpEnabled") {

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { formatLanguage } from "./shared/language"
3131
import { ContextProxy } from "./core/config/ContextProxy"
3232
import { ClineProvider } from "./core/webview/ClineProvider"
3333
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
34+
import { Terminal } from "./integrations/terminal/Terminal"
3435
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
3536
import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
3637
import { McpServerManager } from "./services/mcp/McpServerManager"
@@ -387,5 +388,6 @@ export async function deactivate() {
387388

388389
await McpServerManager.cleanup(extensionContext)
389390
TelemetryService.instance.shutdown()
391+
Terminal.setTerminalProfile(undefined)
390392
TerminalRegistry.cleanup()
391393
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import type * as vscode from "vscode"
3+
4+
import { API } from "../api"
5+
import type { ClineProvider } from "../../core/webview/ClineProvider"
6+
import { Terminal } from "../../integrations/terminal/Terminal"
7+
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
8+
9+
vi.mock("@roo-code/ipc", () => ({
10+
IpcServer: class {},
11+
}))
12+
13+
describe("API - terminal profile", () => {
14+
let api: API
15+
16+
beforeEach(() => {
17+
const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel
18+
const provider = {
19+
context: {},
20+
on: vi.fn(),
21+
} as unknown as ClineProvider
22+
23+
Terminal.setTerminalProfile(undefined)
24+
api = new API(outputChannel, provider)
25+
})
26+
27+
afterEach(() => {
28+
Terminal.setTerminalProfile(undefined)
29+
vi.restoreAllMocks()
30+
})
31+
32+
it("closes idle terminals only when the normalized profile changes", () => {
33+
const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {})
34+
35+
api.setTerminalProfile(" Git Bash ")
36+
api.setTerminalProfile("Git Bash")
37+
38+
expect(Terminal.getTerminalProfile()).toBe("Git Bash")
39+
expect(closeIdleTerminalsSpy).toHaveBeenCalledTimes(1)
40+
})
41+
})

src/extension/api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,8 +480,12 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
480480
}
481481

482482
public setTerminalProfile(name: string | undefined): void {
483+
const previousProfile = Terminal.getTerminalProfile()
483484
Terminal.setTerminalProfile(name)
484-
TerminalRegistry.closeIdleTerminals()
485+
486+
if (Terminal.getTerminalProfile() !== previousProfile) {
487+
TerminalRegistry.closeIdleTerminals()
488+
}
485489
}
486490

487491
// Provider Profile Management

src/integrations/terminal/BaseTerminal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export abstract class BaseTerminal implements RooTerminal {
4444
/**
4545
* Sets the active stream for this terminal and notifies the process
4646
* @param stream The stream to set, or undefined to clean up
47-
* @throws Error if process is undefined when a stream is provided
47+
* If no process exists when a stream is provided, logs a warning and returns.
4848
*/
4949
public setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number): void {
5050
if (stream) {

0 commit comments

Comments
 (0)