Skip to content

Commit 28b96ac

Browse files
committed
fix(terminal): harden shell detection, WSL support, and error recovery (#321) (#333)
Replace the fragile SHELL_ALLOWLIST in shell.ts with delegation to vscode.env.shell (stable since VS Code 1.37), which internally resolves terminal.integrated.defaultProfile.platform - including PowerShell version deduction, WSL detection, and per-platform system fallback. Connect the Zoo Code terminal profile override to both execution paths: the VS Code integrated terminal (shellPath/shellArgs in createTerminal) and the execa inline fallback (shell resolution + profile env + WSL args). Shell Detection (shell.ts + Terminal.ts) - getShell(): profile override -> vscode.env.shell -> os.userInfo().shell -> COMSPEC/SHELL -> platform default; WSL paths canonicalized to WSL_EXE_PATH regardless of casing or slash direction - isActiveShellPowerShell(): replace source-string heuristic with actual PATH lookup (pwsh.exe / pwsh), fixing false positives on macOS/Linux where PowerShell is the VS Code default but pwsh is not installed - Shell identity methods: isCmdExe, isPowerShell, isFish, isActiveShellCmdExe, isActiveShellPowerShell, isActiveShellFish WSL Inline Execution (ExecaTerminalProcess.ts) - Two-tier WSL detection: tier 1 - getShell() authoritative; tier 2 - getConfiguredWslProfileArgs() trusted-scope VS Code config for distro - 3-tier path conversion: drive-letter -> /mnt/drive/ -> WSL UNC \\wsl$\Distro\ -> wsl.exe wslpath fallback - Respect profile override shellArgs before falling back to VS Code default WSL args, preventing distro mismatch when Zoo Code overrides the active WSL profile - Propagate profile env (sanitized, blocking ZDOTDIR/LD_PRELOAD/etc.) to the WSL execa invocation - Direct wsl.exe spawn (execa file + args array) to avoid cmd.exe nested-quoting; --cd omitted with warning when wslpath fails Terminal Process Error Recovery (TerminalProcess.ts + ExecaTerminalProcess.ts) - SIGTERM instead of SIGKILL for process.kill on Windows compatibility - Real exit code captured from execa subprocess; abort path preserves signal info instead of reporting exitCode:0 - Bounded Ctrl+C retry loop for processes that trap SIGINT (#266) - Guard against overlapping abort retry loops via aborting flag - completionTimeout safety net for lost shell_execution_complete (VS Code bug 237208); timeout cleared in finally to prevent unhandled Promise rejection - TextDecoder with stream:true + final flush to avoid U+FFFD on multi-byte UTF-8 split across chunk boundaries - WeakRef deref guarded in all event handlers against GC'd terminals - C-marker search regression: restore commandOutputStarted boolean so OSC 633;C arriving after the first chunk is detected - isHot cleared on stream timeout to prevent task loop stall - Multiline scripts wrapped for single shell execution: PowerShell .{ }, fish begin/end, POSIX { } Profile Override (Terminal.ts + BaseTerminal.ts) - getConfiguredProfiles() / getConfiguredDefaultProfileName(): read only defaultValue + globalValue, workspace settings intentionally excluded - getConfiguredWslProfileArgs(): detect WSL by source field or name pattern; normalize args (string -> [string]) - getProfileShell(): resolve profile to shellPath/shellArgs/env; sanitize env (block ZDOTDIR, LD_PRELOAD, DYLD_INSERT_LIBRARIES, BASH_ENV, etc.; only string/null values) - Skip ZDOTDIR injection when profile override is active (VS Code's injector handles it; two ZDOTDIRs fight per issue 96295) Execute Command Tool (ExecuteCommandTool.ts + Task.ts) - canRetryShellIntegrationError(): type guard for commandSubmitted:false (shell startup race -> safe to retry via execa); commandSubmitted:true (already submitted -> warn, do not retry) - Show shell_integration_warning when falling back to execa - cmd.exe fast-path: force execa provider (cmd.exe cannot emit OSC 633;C per VS Code issue 164646) - Reset hasAskedForCommandOutput on ask error for future retries - 5s safety-net timeout for onCompleted promise - Add abort guard to ask() pWaitFor to prevent deadlock on cancel - Add abort/abandoned guard to userMessageContentReady pWaitFor Tests (6 test files) - shell.spec.ts: vscode.env.shell passthrough, WSL canonicalization (case + slashes), fallback chain, profile override - TerminalProfile.spec.ts: profile resolution, shell identity methods, WSL args, ZDOTDIR guard, createTerminal integration, isFish - TerminalProcess.spec.ts: C-marker regression, abort retry loop, multiline wrapping, hot timer, commandSubmitted branches - ExecaTerminalProcess.spec.ts: WSL args merging, path conversion, profile env, abort behavior - Terminal.spec.ts: profile-based constructor, reuseKey - terminal-profile.test.ts (E2E): profile override lifecycle, transient shell_integration_warning acceptance Fixes #321 and all 14 code-review defects from PR #333
1 parent 8893d0b commit 28b96ac

12 files changed

Lines changed: 1017 additions & 170 deletions

File tree

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ suite("Terminal Profile", function () {
126126
await sleep(100)
127127
})
128128

129-
test("executes command through profile override without shell integration warning", async function () {
129+
test("executes command through profile override (shell startup race may emit transient warning)", async function () {
130130
const api = globalThis.api
131131
const messages: ClineMessage[] = []
132132

@@ -155,7 +155,12 @@ suite("Terminal Profile", function () {
155155
const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning")
156156
const gotError = messages.some((m) => m.type === "say" && m.say === "error")
157157

158-
assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with a valid profile")
158+
// shell_integration_warning is expected when shell integration has a
159+
// startup race (commandSubmitted: false → execa fallback). This is
160+
// environment-dependent: common in WSL, rare on native Linux/macOS.
161+
if (gotWarning) {
162+
console.info("shell_integration_warning fired — shell startup race occurred, execa fallback used")
163+
}
159164
assert.strictEqual(
160165
gotError,
161166
false,

src/core/task/Task.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1300,7 +1300,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13001300
// Wait for askResponse to be set
13011301
await pWaitFor(
13021302
() => {
1303-
if (this.askResponse !== undefined || this.lastMessageTs !== askTs) {
1303+
if (this.askResponse !== undefined || this.lastMessageTs !== askTs || this.abort) {
13041304
return true
13051305
}
13061306

src/core/tools/ExecuteCommandTool.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,11 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
141141
task.supersedePendingAsk()
142142

143143
if (canRetryShellIntegrationError(error)) {
144-
// Silent retry via execa — shell startup race, command was not submitted.
144+
// Shell integration not available — command was not submitted to the
145+
// VS Code terminal. Show warning so the user knows the terminal mode
146+
// changed, then fall back to execa.
147+
await task.say("shell_integration_warning")
148+
145149
const status: CommandExecutionStatus = { executionId, status: "fallback" }
146150
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
147151

@@ -356,7 +360,10 @@ export async function executeCommandInTerminal(
356360
process.continue()
357361
}
358362
} catch (_error) {
359-
// Silently handle ask errors (e.g., "Current ask promise was ignored")
363+
// Silently handle ask errors (e.g., "Current ask promise was ignored").
364+
// Reset the flag so a future ask can be triggered if the command is
365+
// still producing output.
366+
hasAskedForCommandOutput = false
360367
}
361368
},
362369
onCompleted: async (output: string | undefined) => {
@@ -493,7 +500,10 @@ export async function executeCommandInTerminal(
493500
// This ensures persistedResult is set before we try to use it, fixing the race
494501
// condition where exitDetails is set (sync) before the async onCompleted finishes.
495502
if (exitDetails && onCompletedPromise) {
496-
await onCompletedPromise
503+
await Promise.race([
504+
onCompletedPromise,
505+
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
506+
])
497507
}
498508

499509
if (message) {

src/integrations/terminal/ExecaTerminalProcess.ts

Lines changed: 117 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ import type { RooTerminal } from "./types"
66
import { BaseTerminal } from "./BaseTerminal"
77
import { BaseTerminalProcess } from "./BaseTerminalProcess"
88
import { getShell, WSL_EXE_PATH } from "../../utils/shell"
9+
import { Terminal } from "./Terminal"
910

1011
// Matches \\wsl$\Distro\... and \\wsl.localhost\Distro\...
1112
const WSL_UNC_PREFIX = /^\/\/wsl(?:\$|\.localhost)\/([^\/]+)\/?(.*)$/i
1213

13-
async function convertWindowsPathToWsl(windowsPath: string): Promise<string | null> {
14+
async function convertWindowsPathToWsl(
15+
windowsPath: string,
16+
profileArgs?: string[],
17+
): Promise<string | null> {
1418
const forward = windowsPath.replace(/\\/g, "/")
1519

1620
// Already a POSIX/WSL path — no Windows-to-WSL conversion needed.
@@ -35,9 +39,12 @@ async function convertWindowsPathToWsl(windowsPath: string): Promise<string | nu
3539
return subPath.startsWith("/") ? subPath : `/${subPath}`
3640
}
3741

38-
// Tier 3: Arbitrary UNC → wslpath fallback
42+
// Tier 3: Arbitrary UNC → wslpath fallback.
43+
// Pass the configured WSL distro args so wslpath runs in the correct
44+
// distro — the system default may have different mount points.
3945
try {
40-
const { stdout } = await execa(WSL_EXE_PATH, ["wslpath", windowsPath], {
46+
const wslpathArgs = [...(profileArgs ?? []), "wslpath", windowsPath]
47+
const { stdout } = await execa(WSL_EXE_PATH, wslpathArgs, {
4148
timeout: 5_000,
4249
stdin: "ignore",
4350
})
@@ -62,7 +69,11 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
6269
this.terminalRef = new WeakRef(terminal)
6370

6471
this.once("completed", () => {
65-
this.terminal.busy = false
72+
try {
73+
this.terminal.busy = false
74+
} catch {
75+
// Terminal has been garbage collected — nothing to clean up.
76+
}
6677
})
6778
}
6879

@@ -85,27 +96,52 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
8596
const execaShellPath = BaseTerminal.getExecaShellPath()
8697
const resolvedShell = execaShellPath || getShell()
8798

99+
// Resolve the profile shell for env propagation. Even in the execa fallback
100+
// path, profile-specific environment variables (locale, PATH, etc.) should be
101+
// honored. getShell() already handles the profile shell path; here we need the
102+
// env portion of the profile definition.
103+
const profileShell = execaShellPath ? undefined : Terminal.getProfileShell()
104+
88105
// WSL detection only applies when the user has NOT set an explicit execa shell.
89106
const isWslShell = execaShellPath ? false : resolvedShell === WSL_EXE_PATH
90107

91108
if (isWslShell) {
92109
// Spawn wsl.exe directly (not through cmd.exe) to avoid nested-quoting issues.
93110
// execa(file, args, options) passes args as an array — no shell interpretation.
111+
//
112+
// WSL detection is two-tier:
113+
// Tier 1 — getShell() via getProfileShell() (authoritative):
114+
// decides whether we enter the WSL path at all
115+
// Tier 2 — profile override args, falling back to VS Code default:
116+
// supplements with user-configured profile args (e.g. distro selection).
117+
// When a Zoo Code terminalProfile override is active, use its shellArgs
118+
// directly. Otherwise fall back to the VS Code default WSL profile args.
119+
const profileArgs = profileShell?.shellArgs?.length
120+
? profileShell.shellArgs
121+
: (Terminal.getConfiguredWslProfileArgs() ?? [])
94122
const windowsCwd = this.terminal.getCurrentWorkingDirectory()
95-
const wslCwd = await convertWindowsPathToWsl(windowsCwd)
123+
const wslCwd = await convertWindowsPathToWsl(windowsCwd, profileArgs)
96124

97-
const wslArgs = ["--", "bash", "-c", command]
125+
const wslArgs: string[] = [...profileArgs]
98126

99127
if (wslCwd) {
100-
wslArgs.unshift("--cd", wslCwd)
128+
wslArgs.push("--cd", wslCwd)
129+
} else {
130+
console.warn(
131+
`[ExecaTerminalProcess] Could not convert Windows path to WSL: "${windowsCwd}". ` +
132+
`Command will run in WSL home directory instead of expected CWD.`,
133+
)
101134
}
102135

136+
wslArgs.push("--", "bash", "-c", command)
137+
103138
this.subprocess = execa(WSL_EXE_PATH, wslArgs, {
104139
cwd: undefined,
105140
all: true,
106141
stdin: "ignore",
107142
env: {
108143
...process.env,
144+
...profileShell?.env,
109145
LANG: "en_US.UTF-8",
110146
LC_ALL: "en_US.UTF-8",
111147
},
@@ -118,6 +154,10 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
118154
stdin: "ignore",
119155
env: {
120156
...process.env,
157+
// Profile-specific environment variables (e.g. locale, custom PATH).
158+
// Already sanitized by getProfileShell() — dangerous keys like
159+
// ZDOTDIR, LD_PRELOAD are filtered.
160+
...profileShell?.env,
121161
LANG: "en_US.UTF-8",
122162
LC_ALL: "en_US.UTF-8",
123163
},
@@ -147,10 +187,23 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
147187

148188
const rawStream = this.subprocess.iterable({ from: "all", preserveNewlines: true })
149189

150-
// Wrap the stream to ensure all chunks are strings (execa can return Uint8Array)
190+
const decoder = new TextDecoder()
191+
192+
// Wrap the stream to ensure all chunks are strings.
193+
// A single TextDecoder with { stream: true } handles multi-byte UTF-8
194+
// characters split across chunk boundaries — a fresh decoder per chunk
195+
// would produce U+FFFD replacement characters for partial sequences.
151196
const stream = (async function* () {
152197
for await (const chunk of rawStream) {
153-
yield typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
198+
yield typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
199+
}
200+
// Flush any remaining bytes buffered in the decoder.
201+
// Without this call, an incomplete multi-byte UTF-8
202+
// sequence at the very end of the stream would be
203+
// silently dropped.
204+
const final = decoder.decode()
205+
if (final) {
206+
yield final
154207
}
155208
})()
156209

@@ -182,7 +235,11 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
182235
timeoutId = setTimeout(() => {
183236
try {
184237
this.subprocess?.kill("SIGKILL")
185-
} catch (e) {}
238+
} catch (e) {
239+
console.warn(
240+
`[ExecaTerminalProcess#run] kill timeout subprocess error: ${e instanceof Error ? e.message : String(e)}`,
241+
)
242+
}
186243

187244
resolve()
188245
}, 5_000)
@@ -201,11 +258,49 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
201258
}
202259
}
203260

204-
this.emit("shell_execution_complete", { exitCode: 0 })
261+
// Capture real exit code from the execa subprocess.
262+
// execa v9's iterable() does not throw on process failure — the
263+
// for-await loop exits cleanly regardless of exit code. Await the
264+
// subprocess promise (already settled by now — the stream has
265+
// ended) to get the actual exitCode and signalName.
266+
let emitExitCode: number | undefined
267+
let emitSignal: string | undefined
268+
269+
// Always attempt to read the real exit code. If the subprocess already
270+
// settled (stream ended), this gives us the actual result. If it hasn't
271+
// settled yet (abort killed it mid-stream), proceed to the abort signal path.
272+
try {
273+
const result = await this.subprocess
274+
emitExitCode = result.exitCode
275+
emitSignal = result.signal
276+
} catch (error) {
277+
if (error instanceof ExecaError) {
278+
emitExitCode = error.exitCode
279+
emitSignal = error.signal
280+
} else {
281+
// Unexpected error — re-throw to outer catch
282+
throw error
283+
}
284+
}
285+
286+
if (this.aborted) {
287+
// Subprocess was signalled but may have already exited normally
288+
// before the abort flag was set. Preserve the real exit code and
289+
// signal if available. Only default to SIGKILL when the process
290+
// was killed mid-flight (no exit code available).
291+
if (emitSignal === undefined && emitExitCode === undefined) {
292+
emitSignal = "SIGKILL"
293+
}
294+
}
295+
296+
this.emit("shell_execution_complete", {
297+
exitCode: emitExitCode,
298+
signalName: emitSignal,
299+
})
205300
} catch (error) {
206301
if (error instanceof ExecaError) {
207302
console.error(`[ExecaTerminalProcess#run] shell execution error: ${error.message}`)
208-
this.emit("shell_execution_complete", { exitCode: error.exitCode ?? 0, signalName: error.signal })
303+
this.emit("shell_execution_complete", { exitCode: error.exitCode, signalName: error.signal })
209304
} else {
210305
console.error(
211306
`[ExecaTerminalProcess#run] shell execution error: ${error instanceof Error ? error.message : String(error)}`,
@@ -216,7 +311,13 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
216311
this.subprocess = undefined
217312
}
218313

219-
this.terminal.setActiveStream(undefined)
314+
try {
315+
this.terminal.setActiveStream(undefined)
316+
this.terminal.running = false
317+
} catch {
318+
// Terminal has been garbage collected — nothing to clean up.
319+
}
320+
220321
this.emitRemainingBufferIfListening()
221322
this.stopHotTimer()
222323
this.emit("completed", this.fullOutput)
@@ -249,7 +350,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
249350
// Kill the stored PID (which should be the actual command after our update)
250351
if (this.pid) {
251352
try {
252-
process.kill(this.pid, "SIGKILL")
353+
process.kill(this.pid, "SIGTERM")
253354
} catch (e) {
254355
console.warn(
255356
`[ExecaTerminalProcess#abort] Failed to kill process ${this.pid}: ${e instanceof Error ? e.message : String(e)}`,
@@ -274,10 +375,10 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
274375

275376
for (const pid of pids) {
276377
try {
277-
process.kill(pid, "SIGKILL")
378+
process.kill(pid, "SIGTERM")
278379
} catch (e) {
279380
console.warn(
280-
`[ExecaTerminalProcess#abort] Failed to send SIGKILL to child PID ${pid}: ${e instanceof Error ? e.message : String(e)}`,
381+
`[ExecaTerminalProcess#abort] Failed to send SIGTERM to child PID ${pid}: ${e instanceof Error ? e.message : String(e)}`,
281382
)
282383
}
283384
}

0 commit comments

Comments
 (0)