Skip to content

Commit 8342bfe

Browse files
Eric Wheelerweiz3630
authored andcommitted
feat: add ZDOTDIR handling for zsh shell integration
Creates a temporary ZDOTDIR to handle zsh shell integration properly while preserving user's zsh configuration. This ensures VSCode shell integration works correctly with zsh without modifying the user's existing setup. - Add terminalZdotdir setting (disabled by default) - Create temporary directory with proper security (sticky bit) - Add automatic cleanup on terminal close - Add translations for all supported languages User confirmed fixes: Fixes: RooCodeInc#2205 Fixes: RooCodeInc#2129 Signed-off-by: Eric Wheeler <[email protected]>
1 parent bfd2a33 commit 8342bfe

31 files changed

+315
-0
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
379379
terminalZshOhMy,
380380
terminalZshP10k,
381381
terminalPowershellCounter,
382+
terminalZdotdir,
382383
}) => {
383384
setSoundEnabled(soundEnabled ?? false)
384385
Terminal.setShellIntegrationTimeout(
@@ -389,6 +390,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
389390
Terminal.setTerminalZshOhMy(terminalZshOhMy ?? false)
390391
Terminal.setTerminalZshP10k(terminalZshP10k ?? false)
391392
Terminal.setPowershellCounter(terminalPowershellCounter ?? false)
393+
Terminal.setTerminalZdotdir(terminalZdotdir ?? false)
392394
},
393395
)
394396

@@ -1300,6 +1302,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
13001302
terminalZshClearEolMark,
13011303
terminalZshOhMy,
13021304
terminalZshP10k,
1305+
terminalZdotdir,
13031306
fuzzyMatchThreshold,
13041307
mcpEnabled,
13051308
enableMcpServerCreation,
@@ -1372,6 +1375,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
13721375
terminalZshClearEolMark: terminalZshClearEolMark ?? true,
13731376
terminalZshOhMy: terminalZshOhMy ?? false,
13741377
terminalZshP10k: terminalZshP10k ?? false,
1378+
terminalZdotdir: terminalZdotdir ?? false,
13751379
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
13761380
mcpEnabled: mcpEnabled ?? true,
13771381
enableMcpServerCreation: enableMcpServerCreation ?? true,
@@ -1475,6 +1479,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
14751479
terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true,
14761480
terminalZshOhMy: stateValues.terminalZshOhMy ?? false,
14771481
terminalZshP10k: stateValues.terminalZshP10k ?? false,
1482+
terminalZdotdir: stateValues.terminalZdotdir ?? false,
14781483
mode: stateValues.mode ?? defaultModeSlug,
14791484
language: stateValues.language ?? formatLanguage(vscode.env.language),
14801485
mcpEnabled: stateValues.mcpEnabled ?? true,

src/core/webview/webviewMessageHandler.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,13 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
784784
Terminal.setTerminalZshP10k(message.bool)
785785
}
786786
break
787+
case "terminalZdotdir":
788+
await updateGlobalState("terminalZdotdir", message.bool)
789+
await provider.postStateToWebview()
790+
if (message.bool !== undefined) {
791+
Terminal.setTerminalZdotdir(message.bool)
792+
}
793+
break
787794
case "mode":
788795
await provider.handleModeSwitch(message.text as Mode)
789796
break

src/exports/roo-code.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ type GlobalSettings = {
288288
terminalZshClearEolMark?: boolean | undefined
289289
terminalZshOhMy?: boolean | undefined
290290
terminalZshP10k?: boolean | undefined
291+
terminalZdotdir?: boolean | undefined
291292
rateLimitSeconds?: number | undefined
292293
diffEnabled?: boolean | undefined
293294
fuzzyMatchThreshold?: number | undefined

src/exports/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ type GlobalSettings = {
291291
terminalZshClearEolMark?: boolean | undefined
292292
terminalZshOhMy?: boolean | undefined
293293
terminalZshP10k?: boolean | undefined
294+
terminalZdotdir?: boolean | undefined
294295
rateLimitSeconds?: number | undefined
295296
diffEnabled?: boolean | undefined
296297
fuzzyMatchThreshold?: number | undefined

src/integrations/terminal/Terminal.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import * as vscode from "vscode"
22
import pWaitFor from "p-wait-for"
33
import { ExitCodeDetails, mergePromise, TerminalProcess, TerminalProcessResultPromise } from "./TerminalProcess"
44
import { truncateOutput, applyRunLengthEncoding } from "../misc/extract-text"
5+
// Import TerminalRegistry here to avoid circular dependencies
6+
const { TerminalRegistry } = require("./TerminalRegistry")
57

68
export const TERMINAL_SHELL_INTEGRATION_TIMEOUT = 5000
79

@@ -12,6 +14,7 @@ export class Terminal {
1214
private static terminalZshClearEolMark: boolean = true
1315
private static terminalZshOhMy: boolean = false
1416
private static terminalZshP10k: boolean = false
17+
private static terminalZdotdir: boolean = false
1518

1619
public terminal: vscode.Terminal
1720
public busy: boolean
@@ -185,10 +188,16 @@ export class Terminal {
185188
// Wait for shell integration before executing the command
186189
pWaitFor(() => this.terminal.shellIntegration !== undefined, { timeout: Terminal.shellIntegrationTimeout })
187190
.then(() => {
191+
// Clean up temporary directory if shell integration is available, zsh did its job:
192+
TerminalRegistry.zshCleanupTmpDir(this.id)
193+
194+
// Run the command in the terminal
188195
process.run(command)
189196
})
190197
.catch(() => {
191198
console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`)
199+
// Clean up temporary directory if shell integration is not available
200+
TerminalRegistry.zshCleanupTmpDir(this.id)
192201
process.emit(
193202
"no_shell_integration",
194203
`Shell integration initialization sequence '\\x1b]633;A' was not received within ${Terminal.shellIntegrationTimeout / 1000}s. Shell integration has been disabled for this terminal instance. Increase the timeout in the settings if necessary.`,
@@ -348,4 +357,20 @@ export class Terminal {
348357
public static compressTerminalOutput(input: string, lineLimit: number): string {
349358
return truncateOutput(applyRunLengthEncoding(input), lineLimit)
350359
}
360+
361+
/**
362+
* Sets whether to enable ZDOTDIR handling for zsh
363+
* @param enabled Whether to enable ZDOTDIR handling
364+
*/
365+
public static setTerminalZdotdir(enabled: boolean): void {
366+
Terminal.terminalZdotdir = enabled
367+
}
368+
369+
/**
370+
* Gets whether ZDOTDIR handling is enabled
371+
* @returns Whether ZDOTDIR handling is enabled
372+
*/
373+
public static getTerminalZdotdir(): boolean {
374+
return Terminal.terminalZdotdir
375+
}
351376
}

src/integrations/terminal/TerminalRegistry.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as vscode from "vscode"
2+
import * as path from "path"
23
import { arePathsEqual } from "../../utils/path"
34
import { Terminal } from "./Terminal"
45
import { TerminalProcess } from "./TerminalProcess"
@@ -9,6 +10,7 @@ export class TerminalRegistry {
910
private static terminals: Terminal[] = []
1011
private static nextTerminalId = 1
1112
private static disposables: vscode.Disposable[] = []
13+
private static terminalTmpDirs: Map<number, string> = new Map()
1214
private static isInitialized = false
1315

1416
static initialize() {
@@ -17,6 +19,18 @@ export class TerminalRegistry {
1719
}
1820
this.isInitialized = true
1921

22+
// Register handler for terminal close events to clean up temporary directories
23+
const closeDisposable = vscode.window.onDidCloseTerminal((terminal) => {
24+
const terminalInfo = this.getTerminalByVSCETerminal(terminal)
25+
if (terminalInfo) {
26+
// Clean up temporary directory if it exists
27+
if (this.terminalTmpDirs.has(terminalInfo.id)) {
28+
this.zshCleanupTmpDir(terminalInfo.id)
29+
}
30+
}
31+
})
32+
this.disposables.push(closeDisposable)
33+
2034
try {
2135
// onDidStartTerminalShellExecution
2236
const startDisposable = vscode.window.onDidStartTerminalShellExecution?.(
@@ -141,6 +155,11 @@ export class TerminalRegistry {
141155
env.PROMPT_EOL_MARK = ""
142156
}
143157

158+
// Handle ZDOTDIR for zsh if enabled
159+
if (Terminal.getTerminalZdotdir()) {
160+
env.ZDOTDIR = this.zshInitTmpDir(env)
161+
}
162+
144163
const terminal = vscode.window.createTerminal({
145164
cwd,
146165
name: "Shenma",
@@ -151,6 +170,13 @@ export class TerminalRegistry {
151170
const cwdString = cwd.toString()
152171
const newTerminal = new Terminal(this.nextTerminalId++, terminal, cwdString)
153172

173+
if (Terminal.getTerminalZdotdir()) {
174+
this.terminalTmpDirs.set(newTerminal.id, env.ZDOTDIR)
175+
console.info(
176+
`[TerminalRegistry] Stored temporary directory path for terminal ${newTerminal.id}: ${env.ZDOTDIR}`,
177+
)
178+
}
179+
154180
this.terminals.push(newTerminal)
155181
return newTerminal
156182
}
@@ -191,6 +217,8 @@ export class TerminalRegistry {
191217
}
192218

193219
static removeTerminal(id: number) {
220+
this.zshCleanupTmpDir(id)
221+
194222
this.terminals = this.terminals.filter((t) => t.id !== id)
195223
}
196224

@@ -279,10 +307,156 @@ export class TerminalRegistry {
279307
}
280308

281309
static cleanup() {
310+
// Clean up all temporary directories
311+
this.terminalTmpDirs.forEach((_, terminalId) => {
312+
this.zshCleanupTmpDir(terminalId)
313+
})
314+
this.terminalTmpDirs.clear()
315+
282316
this.disposables.forEach((disposable) => disposable.dispose())
283317
this.disposables = []
284318
}
285319

320+
/**
321+
* Gets the path to the shell integration script for a given shell type
322+
* @param shell The shell type
323+
* @returns The path to the shell integration script
324+
*/
325+
private static getShellIntegrationPath(shell: "bash" | "pwsh" | "zsh" | "fish"): string {
326+
let filename: string
327+
328+
switch (shell) {
329+
case "bash":
330+
filename = "shellIntegration-bash.sh"
331+
break
332+
case "pwsh":
333+
filename = "shellIntegration.ps1"
334+
break
335+
case "zsh":
336+
filename = "shellIntegration-rc.zsh"
337+
break
338+
case "fish":
339+
filename = "shellIntegration.fish"
340+
break
341+
default:
342+
throw new Error(`Invalid shell type: ${shell}`)
343+
}
344+
345+
// This is the same path used by the CLI command
346+
return path.join(
347+
vscode.env.appRoot,
348+
"out",
349+
"vs",
350+
"workbench",
351+
"contrib",
352+
"terminal",
353+
"common",
354+
"scripts",
355+
filename,
356+
)
357+
}
358+
359+
/**
360+
* Initialize a temporary directory for ZDOTDIR
361+
* @param env The environment variables object to modify
362+
* @returns The path to the temporary directory
363+
*/
364+
private static zshInitTmpDir(env: Record<string, string>): string {
365+
// Create a temporary directory with the sticky bit set for security
366+
const os = require("os")
367+
const path = require("path")
368+
const tmpDir = path.join(os.tmpdir(), `roo-zdotdir-${Math.random().toString(36).substring(2, 15)}`)
369+
console.info(`[TerminalRegistry] Creating temporary directory for ZDOTDIR: ${tmpDir}`)
370+
371+
// Save original ZDOTDIR as ROO_ZDOTDIR
372+
if (process.env.ZDOTDIR) {
373+
env.ROO_ZDOTDIR = process.env.ZDOTDIR
374+
}
375+
376+
// Create the temporary directory
377+
vscode.workspace.fs
378+
.createDirectory(vscode.Uri.file(tmpDir))
379+
.then(() => {
380+
console.info(`[TerminalRegistry] Created temporary directory for ZDOTDIR at ${tmpDir}`)
381+
382+
// Create .zshrc in the temporary directory
383+
const zshrcPath = `${tmpDir}/.zshrc`
384+
385+
// Get the path to the shell integration script
386+
const shellIntegrationPath = this.getShellIntegrationPath("zsh")
387+
388+
const zshrcContent = `
389+
source "${shellIntegrationPath}"
390+
ZDOTDIR=\${ROO_ZDOTDIR:-$HOME}
391+
unset ROO_ZDOTDIR
392+
[ -f "$ZDOTDIR/.zshenv" ] && source "$ZDOTDIR/.zshenv"
393+
[ -f "$ZDOTDIR/.zprofile" ] && source "$ZDOTDIR/.zprofile"
394+
[ -f "$ZDOTDIR/.zshrc" ] && source "$ZDOTDIR/.zshrc"
395+
[ -f "$ZDOTDIR/.zlogin" ] && source "$ZDOTDIR/.zlogin"
396+
[ "$ZDOTDIR" = "$HOME" ] && unset ZDOTDIR
397+
`
398+
console.info(`[TerminalRegistry] Creating .zshrc file at ${zshrcPath} with content:\n${zshrcContent}`)
399+
vscode.workspace.fs.writeFile(vscode.Uri.file(zshrcPath), Buffer.from(zshrcContent)).then(
400+
// Success handler
401+
() => {
402+
console.info(`[TerminalRegistry] Successfully created .zshrc file at ${zshrcPath}`)
403+
},
404+
// Error handler
405+
(error: Error) => {
406+
console.error(`[TerminalRegistry] Error creating .zshrc file at ${zshrcPath}: ${error}`)
407+
},
408+
)
409+
})
410+
.then(undefined, (error: Error) => {
411+
console.error(`[TerminalRegistry] Error creating temporary directory at ${tmpDir}: ${error}`)
412+
})
413+
414+
return tmpDir
415+
}
416+
417+
/**
418+
* Clean up a temporary directory used for ZDOTDIR
419+
*/
420+
private static zshCleanupTmpDir(terminalId: number): boolean {
421+
const tmpDir = this.terminalTmpDirs.get(terminalId)
422+
if (!tmpDir) {
423+
return false
424+
}
425+
426+
const logPrefix = `[TerminalRegistry] Cleaning up temporary directory for terminal ${terminalId}`
427+
console.info(`${logPrefix}: ${tmpDir}`)
428+
429+
try {
430+
// Use fs to remove the directory and its contents
431+
const fs = require("fs")
432+
const path = require("path")
433+
434+
// Remove .zshrc file
435+
const zshrcPath = path.join(tmpDir, ".zshrc")
436+
if (fs.existsSync(zshrcPath)) {
437+
console.info(`${logPrefix}: Removing .zshrc file at ${zshrcPath}`)
438+
fs.unlinkSync(zshrcPath)
439+
}
440+
441+
// Remove the directory
442+
if (fs.existsSync(tmpDir)) {
443+
console.info(`${logPrefix}: Removing directory at ${tmpDir}`)
444+
fs.rmdirSync(tmpDir)
445+
}
446+
447+
// Remove it from the map
448+
this.terminalTmpDirs.delete(terminalId)
449+
console.info(`${logPrefix}: Removed terminal ${terminalId} from temporary directory map`)
450+
451+
return true
452+
} catch (error: unknown) {
453+
console.error(
454+
`[TerminalRegistry] Error cleaning up temporary directory ${tmpDir}: ${error instanceof Error ? error.message : String(error)}`,
455+
)
456+
return false
457+
}
458+
}
459+
286460
/**
287461
* Releases all terminals associated with a task
288462
* @param taskId The task ID

src/integrations/terminal/__tests__/TerminalProcessExec.bash.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ jest.mock("vscode", () => {
1111
const eventHandlers = {
1212
startTerminalShellExecution: null,
1313
endTerminalShellExecution: null,
14+
closeTerminal: null,
1415
}
1516

1617
return {
@@ -29,6 +30,10 @@ jest.mock("vscode", () => {
2930
eventHandlers.endTerminalShellExecution = handler
3031
return { dispose: jest.fn() }
3132
}),
33+
onDidCloseTerminal: jest.fn().mockImplementation((handler) => {
34+
eventHandlers.closeTerminal = handler
35+
return { dispose: jest.fn() }
36+
}),
3237
},
3338
ThemeIcon: class ThemeIcon {
3439
constructor(id: string) {

src/integrations/terminal/__tests__/TerminalProcessExec.cmd.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jest.mock("vscode", () => {
1616
const eventHandlers = {
1717
startTerminalShellExecution: null,
1818
endTerminalShellExecution: null,
19+
closeTerminal: null,
1920
}
2021

2122
return {
@@ -34,6 +35,10 @@ jest.mock("vscode", () => {
3435
eventHandlers.endTerminalShellExecution = handler
3536
return { dispose: jest.fn() }
3637
}),
38+
onDidCloseTerminal: jest.fn().mockImplementation((handler) => {
39+
eventHandlers.closeTerminal = handler
40+
return { dispose: jest.fn() }
41+
}),
3742
},
3843
ThemeIcon: class ThemeIcon {
3944
constructor(id: string) {

0 commit comments

Comments
 (0)