Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit fb720e4

Browse files
committed
fix: prevent conda auto-activation from hijacking terminal command output
When a terminal is created with conda auto-activation (or any shell initialization that runs commands), a race condition could occur where VSCode fires onDidStartTerminalShellExecution events for BOTH the conda activation AND Roo's command. The previous implementation blindly set the stream when the event fired, causing Roo to pick up the wrong stream and miss its own command's output. This fix: 1. Adds an optional eventCommand parameter to setActiveStream() 2. Passes the command from the shell execution event for verification 3. Only emits stream_available if the event command matches Roo's command 4. Uses flexible prefix matching to handle PowerShell workarounds Fixes #11148
1 parent 44fd975 commit fb720e4

4 files changed

Lines changed: 276 additions & 5 deletions

File tree

src/integrations/terminal/BaseTerminal.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,16 @@ export abstract class BaseTerminal implements RooTerminal {
4040
abstract runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise
4141

4242
/**
43-
* Sets the active stream for this terminal and notifies the process
43+
* Sets the active stream for this terminal and notifies the process.
44+
* When eventCommand is provided, the stream is only set if it matches the process's command.
45+
* This prevents conda/other auto-activation commands from interfering with Roo's command execution.
46+
*
4447
* @param stream The stream to set, or undefined to clean up
48+
* @param pid The process ID from the shell execution event
49+
* @param eventCommand The command from the shell execution event, used to verify this is Roo's command
4550
* @throws Error if process is undefined when a stream is provided
4651
*/
47-
public setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number): void {
52+
public setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number, eventCommand?: string): void {
4853
if (stream) {
4954
if (!this.process) {
5055
this.running = false
@@ -56,6 +61,18 @@ export abstract class BaseTerminal implements RooTerminal {
5661
return
5762
}
5863

64+
// If eventCommand is provided, verify it matches the process's expected command
65+
// This prevents conda/pyenv/other auto-activation commands from hijacking the stream
66+
if (eventCommand !== undefined && this.process.command) {
67+
if (!BaseTerminal.commandsMatch(this.process.command, eventCommand)) {
68+
console.warn(
69+
`[Terminal ${this.provider}/${this.id}] Ignoring shell execution for non-matching command. ` +
70+
`Expected: "${this.process.command}", Got: "${eventCommand}"`,
71+
)
72+
return
73+
}
74+
}
75+
5976
this.running = true
6077
this.streamClosed = false
6178
this.process.emit("shell_execution_started", pid)
@@ -65,6 +82,39 @@ export abstract class BaseTerminal implements RooTerminal {
6582
}
6683
}
6784

85+
/**
86+
* Checks if two commands match. Uses flexible matching to handle cases where
87+
* the executed command might be modified (e.g., PowerShell counter workaround).
88+
*
89+
* @param expectedCommand The command Roo is trying to execute
90+
* @param actualCommand The command from the shell execution event
91+
* @returns true if the commands match
92+
*/
93+
public static commandsMatch(expectedCommand: string, actualCommand: string): boolean {
94+
// Normalize commands by trimming whitespace
95+
const expected = expectedCommand.trim()
96+
const actual = actualCommand.trim()
97+
98+
// Exact match (handles both empty string case and exact matches)
99+
if (expected === actual) {
100+
return true
101+
}
102+
103+
// If expected is empty but actual is not, they don't match
104+
// (this prevents empty process command from matching any event command)
105+
if (expected.length === 0) {
106+
return false
107+
}
108+
109+
// Check if expected is a prefix of actual (handles PowerShell counter workaround
110+
// which appends extra commands like ` ; "(Roo/PS Workaround: N)" > $null`)
111+
if (actual.startsWith(expected)) {
112+
return true
113+
}
114+
115+
return false
116+
}
117+
68118
/**
69119
* Handles shell execution completion for this terminal.
70120
* @param exitDetails The exit details of the shell execution

src/integrations/terminal/TerminalRegistry.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,19 @@ export class TerminalRegistry {
5151
// Get a handle to the stream as early as possible:
5252
const stream = e.execution.read()
5353
const terminal = this.getTerminalByVSCETerminal(e.terminal)
54+
const eventCommand = e.execution?.commandLine?.value
5455

5556
console.info("[onDidStartTerminalShellExecution]", {
56-
command: e.execution?.commandLine?.value,
57+
command: eventCommand,
5758
terminalId: terminal?.id,
59+
processCommand: terminal?.process?.command,
5860
})
5961

6062
if (terminal) {
61-
terminal.setActiveStream(stream)
63+
// Pass the eventCommand to setActiveStream for verification.
64+
// This prevents conda/pyenv/other auto-activation commands from
65+
// hijacking the stream when the terminal first opens.
66+
terminal.setActiveStream(stream, undefined, eventCommand)
6267
terminal.busy = true // Mark terminal as busy when shell execution starts
6368
} else {
6469
console.error(
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// npx vitest run src/integrations/terminal/__tests__/BaseTerminal.spec.ts
2+
3+
import { BaseTerminal } from "../BaseTerminal"
4+
import type { RooTerminalProcess } from "../types"
5+
6+
// Create a concrete implementation of BaseTerminal for testing
7+
class TestTerminal extends BaseTerminal {
8+
constructor(id: number = 1, cwd: string = "/test") {
9+
super("vscode", id, cwd)
10+
}
11+
12+
isClosed(): boolean {
13+
return false
14+
}
15+
16+
runCommand(): never {
17+
throw new Error("Not implemented")
18+
}
19+
}
20+
21+
// Create a mock process for testing
22+
function createMockProcess(command: string): RooTerminalProcess {
23+
const events: Record<string, ((...args: any[]) => void)[]> = {}
24+
25+
return {
26+
command,
27+
isHot: false,
28+
run: vi.fn(),
29+
continue: vi.fn(),
30+
abort: vi.fn(),
31+
hasUnretrievedOutput: vi.fn().mockReturnValue(false),
32+
getUnretrievedOutput: vi.fn().mockReturnValue(""),
33+
trimRetrievedOutput: vi.fn(),
34+
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
35+
if (!events[event]) {
36+
events[event] = []
37+
}
38+
events[event].push(handler)
39+
}),
40+
once: vi.fn((event: string, handler: (...args: any[]) => void) => {
41+
if (!events[event]) {
42+
events[event] = []
43+
}
44+
events[event].push(handler)
45+
}),
46+
emit: vi.fn((event: string, ...args: any[]) => {
47+
const handlers = events[event] || []
48+
handlers.forEach((handler) => handler(...args))
49+
return true
50+
}),
51+
off: vi.fn(),
52+
removeListener: vi.fn(),
53+
removeAllListeners: vi.fn(),
54+
listeners: vi.fn(),
55+
rawListeners: vi.fn(),
56+
listenerCount: vi.fn(),
57+
prependListener: vi.fn(),
58+
prependOnceListener: vi.fn(),
59+
eventNames: vi.fn(),
60+
addListener: vi.fn(),
61+
setMaxListeners: vi.fn(),
62+
getMaxListeners: vi.fn(),
63+
} as unknown as RooTerminalProcess
64+
}
65+
66+
// Create a mock async iterable stream for testing
67+
async function* createMockStream(): AsyncGenerator<string> {
68+
yield "test output"
69+
}
70+
71+
describe("BaseTerminal", () => {
72+
describe("commandsMatch", () => {
73+
it("returns true for exact match", () => {
74+
expect(BaseTerminal.commandsMatch("npm test", "npm test")).toBe(true)
75+
})
76+
77+
it("returns true for exact match with whitespace trimming", () => {
78+
expect(BaseTerminal.commandsMatch(" npm test ", "npm test")).toBe(true)
79+
expect(BaseTerminal.commandsMatch("npm test", " npm test ")).toBe(true)
80+
})
81+
82+
it("returns true when actual command starts with expected command (PowerShell workaround)", () => {
83+
// PowerShell counter workaround appends extra commands
84+
const expected = "npm test"
85+
const actual = 'npm test ; "(Roo/PS Workaround: 1)" > $null'
86+
expect(BaseTerminal.commandsMatch(expected, actual)).toBe(true)
87+
})
88+
89+
it("returns true when actual command has trailing sleep command", () => {
90+
const expected = "npm test"
91+
const actual = "npm test ; start-sleep -milliseconds 50"
92+
expect(BaseTerminal.commandsMatch(expected, actual)).toBe(true)
93+
})
94+
95+
it("returns false for completely different commands", () => {
96+
expect(BaseTerminal.commandsMatch("npm test", "conda activate base")).toBe(false)
97+
})
98+
99+
it("returns false when commands are similar but not matching", () => {
100+
expect(BaseTerminal.commandsMatch("npm install", "npm run build")).toBe(false)
101+
})
102+
103+
it("returns false for reversed prefix (actual is prefix of expected)", () => {
104+
// This case should not match - the expected command should be a prefix of actual
105+
expect(BaseTerminal.commandsMatch("npm test --coverage", "npm test")).toBe(false)
106+
})
107+
108+
it("handles empty strings", () => {
109+
expect(BaseTerminal.commandsMatch("", "")).toBe(true)
110+
expect(BaseTerminal.commandsMatch("npm test", "")).toBe(false)
111+
expect(BaseTerminal.commandsMatch("", "npm test")).toBe(false)
112+
})
113+
114+
it("handles commands with special characters", () => {
115+
const expected = 'echo "hello world"'
116+
const actual = 'echo "hello world"'
117+
expect(BaseTerminal.commandsMatch(expected, actual)).toBe(true)
118+
})
119+
120+
it("handles conda activation commands correctly", () => {
121+
// Roo's command should not match conda's auto-activation
122+
expect(BaseTerminal.commandsMatch("npm test", "conda activate base")).toBe(false)
123+
expect(BaseTerminal.commandsMatch("npm test", "source activate myenv")).toBe(false)
124+
})
125+
})
126+
127+
describe("setActiveStream", () => {
128+
let terminal: TestTerminal
129+
let mockProcess: RooTerminalProcess
130+
131+
beforeEach(() => {
132+
terminal = new TestTerminal()
133+
mockProcess = createMockProcess("npm test")
134+
terminal.process = mockProcess
135+
})
136+
137+
it("sets stream when no eventCommand is provided (backwards compatibility)", () => {
138+
const stream = createMockStream()
139+
terminal.setActiveStream(stream)
140+
141+
expect(terminal.running).toBe(true)
142+
expect(mockProcess.emit).toHaveBeenCalledWith("shell_execution_started", undefined)
143+
expect(mockProcess.emit).toHaveBeenCalledWith("stream_available", stream)
144+
})
145+
146+
it("sets stream when eventCommand matches process command", () => {
147+
const stream = createMockStream()
148+
terminal.setActiveStream(stream, undefined, "npm test")
149+
150+
expect(terminal.running).toBe(true)
151+
expect(mockProcess.emit).toHaveBeenCalledWith("stream_available", stream)
152+
})
153+
154+
it("sets stream when eventCommand starts with process command (PowerShell case)", () => {
155+
const stream = createMockStream()
156+
terminal.setActiveStream(stream, undefined, 'npm test ; "(Roo/PS Workaround: 1)" > $null')
157+
158+
expect(terminal.running).toBe(true)
159+
expect(mockProcess.emit).toHaveBeenCalledWith("stream_available", stream)
160+
})
161+
162+
it("ignores stream when eventCommand does not match process command", () => {
163+
const stream = createMockStream()
164+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
165+
166+
terminal.setActiveStream(stream, undefined, "conda activate base")
167+
168+
expect(terminal.running).toBe(false)
169+
expect(mockProcess.emit).not.toHaveBeenCalledWith("stream_available", expect.anything())
170+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Ignoring shell execution"))
171+
172+
consoleSpy.mockRestore()
173+
})
174+
175+
it("accepts stream when process has no command set (backward compatibility)", () => {
176+
mockProcess.command = ""
177+
const stream = createMockStream()
178+
179+
// When process.command is empty string, the command verification is skipped
180+
// for backward compatibility. The process should accept any stream.
181+
terminal.setActiveStream(stream, undefined, "conda activate base")
182+
183+
// Since process.command is empty, no verification is done and stream is accepted
184+
expect(terminal.running).toBe(true)
185+
expect(mockProcess.emit).toHaveBeenCalledWith("stream_available", stream)
186+
})
187+
188+
it("cleans up when stream is undefined", () => {
189+
terminal.setActiveStream(undefined)
190+
191+
expect(terminal.isStreamClosed).toBe(true)
192+
})
193+
194+
it("handles missing process gracefully", () => {
195+
terminal.process = undefined
196+
const stream = createMockStream()
197+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
198+
199+
terminal.setActiveStream(stream)
200+
201+
expect(terminal.running).toBe(false)
202+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("process is undefined"))
203+
204+
consoleSpy.mockRestore()
205+
})
206+
207+
it("passes pid to shell_execution_started event", () => {
208+
const stream = createMockStream()
209+
const pid = 12345
210+
211+
terminal.setActiveStream(stream, pid, "npm test")
212+
213+
expect(mockProcess.emit).toHaveBeenCalledWith("shell_execution_started", pid)
214+
})
215+
})
216+
})

src/integrations/terminal/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export interface RooTerminal {
1212
getCurrentWorkingDirectory(): string
1313
isClosed: () => boolean
1414
runCommand: (command: string, callbacks: RooTerminalCallbacks) => RooTerminalProcessResultPromise
15-
setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number): void
15+
setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number, eventCommand?: string): void
1616
shellExecutionComplete(exitDetails: ExitCodeDetails): void
1717
getProcessesWithOutput(): RooTerminalProcess[]
1818
getUnretrievedOutput(): string

0 commit comments

Comments
 (0)