Skip to content

fix(debug): support shell expansion for debug adapter launch args (#17200)#17224

Open
ankitsharma101 wants to merge 7 commits intoeclipse-theia:masterfrom
ankitsharma101:fix-17200-debug-shell-expansion
Open

fix(debug): support shell expansion for debug adapter launch args (#17200)#17224
ankitsharma101 wants to merge 7 commits intoeclipse-theia:masterfrom
ankitsharma101:fix-17200-debug-shell-expansion

Conversation

@ankitsharma101
Copy link
Copy Markdown
Contributor

What it does

Fixes #17200

This PR resolves an issue where adapters like debugpy block debug execution when passed shell-expanded arguments (e.g., "args": "${command:pickArgs}").

The fix requires two steps to achieve VS Code parity:

  1. DAP Initialization: Added the supportsArgsCanBeInterpretedByShell: true capability to the client initialize payload. Without this, adapters strictly enforcing DAP capabilities reject the launch request with Invalid message: Shell expansion in "args" is not supported by the client.
  2. Terminal Execution: Updated the runInTerminal implementation in debug-session.tsx. When argsCanBeInterpretedByShell is true, Theia now joins the arguments into a single raw command string (sendText) instead of passing the array to executeCommand. This prevents Theia from automatically wrapping the variables in single quotes, allowing the underlying shell (like bash) to natively evaluate expansions (like $USER or > output.txt) before passing them to the debugger.

How to test

  1. Install the ms-python.python and ms-python.debugpy extensions in a local Theia browser environment.
  2. Create a Python file (e.g., test.py) that prints sys.argv.
  3. Create a launch.json configuration for debugpy that includes: "args": "${command:pickArgs}"
  4. Start the debug session. When prompted for arguments, test the following:
    • Standard args: hello world -> script should receive ['hello', 'world']
    • Shell variables: $USER -> script should receive your actual environment username, proving the shell expanded it before passing to Python.
    • Redirection: > output.txt -> script output should be redirected to a file, proving the shell intercepted the command.

Follow-ups

None at this time. This aligns Theia's terminal execution for debuggers with VS Code's expected behavior.

Breaking changes

  • This PR introduces breaking changes and requires careful review. If yes, the breaking changes section in the changelog has been updated.

Attribution

Review checklist

Reminder for reviewers

Copy link
Copy Markdown
Contributor

@eneufeld eneufeld left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the fix. It works in general. It is missing some points though we should fix before merging it, see inline comments.

// we join the arguments into a single raw command string and send it directly.
// This prevents Theia from automatically wrapping the variables in single quotes.
if (argsCanBeInterpretedByShell) {
const rawCommand = args.join(' ') + '\r';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should use OS.backend.EOL instead of \r.
I would also reduce the amount of comments.

// This prevents Theia from automatically wrapping the variables in single quotes.
if (argsCanBeInterpretedByShell) {
const rawCommand = args.join(' ') + '\r';
await terminal.sendText(rawCommand);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has a bug at the moment. The main issue is the missing cwd.
Check how executeCommand creates the text that is send to the terminal.
So it should be something like: use ShellCommandBuilder to construct the cd && env KEY=VAL ... prefix with proper escaping, then append the raw unquoted args, and send the whole thing via sendText with OS.backend.EOL.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also call resetCommandOutputMarker after the sendText.

Basically it is the same follow more how executeCommand is implemented.

@github-project-automation github-project-automation Bot moved this from Waiting on reviewers to Waiting on author in PR Backlog Apr 8, 2026
…on for pickArgs

- Use ShellCommandBuilder to construct cd+env+executable prefix with proper escaping
- Append remaining args raw so shell can expand variables and handle redirections
- Use OS.backend.EOL instead of \r
- Reduce verbose comments
- Fix DebuggerDescription to include variables field so command variables like pickArgs resolve correctly
- Pass variables through plugin debug registration chain (debug-ext.ts, debug-main.ts)
- Inject ShellCommandBuilder into DefaultDebugSessionFactory and PluginDebugSessionFactory
@ankitsharma101
Copy link
Copy Markdown
Contributor Author

Thank you for the detailed review! I've addressed all the feedback:

Addressed review comments:

  • Replaced \r with OS.backend.EOL
  • Reduced comments to just one concise explanation
  • Fixed the missing cwd by using ShellCommandBuilder to construct the cd && env && executable prefix with proper escaping, then appending the remaining args raw for shell expansion

Regarding resetCommandOutputMarker — I searched the codebase and couldn't find this method on TerminalWidget. Could you point me to where this should come from, or is this something that needs to be added?

Additional fix included in this PR:
While testing, I discovered that ${command:pickArgs} was being passed as a literal string rather than being resolved. The root cause was that DebuggerDescription didn't include the variables field, so provideDebuggerVariables always returned an empty map and the command variable resolver never mapped pickArgsdebugpy.pickArgs. I've fixed this by:

  • Adding variables to DebuggerDescription
  • Passing variables through the plugin debug registration chain (debug-ext.tsdebug-main.ts)
  • Calling registerDebugger with the full contribution including variables

All three test cases now pass:

  • hello world['hello', 'world']
  • $USER → actual username (shell expansion) ✅
  • > output.txt → output redirected to file ✅

Copy link
Copy Markdown
Contributor

@eneufeld eneufeld left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are nearly there, but we should use the injected builder otherwise there is no benefit in injecting it. But as it can be extended, the injection is the right choice

protected readonly workspaceService: WorkspaceService,
protected readonly debugPreferences: DebugPreferences,
protected readonly commandService: CommandService,
protected readonly shellCommandBuilder: ShellCommandBuilder,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be documented in the changelog as this is a breaking change for anybody subclassing this

await terminal.executeCommand({ cwd, args, env });

if (argsCanBeInterpretedByShell) {
const builder = new ShellCommandBuilder();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we not using the injected builder?

// `multer` handles `multipart/form-data` containing our file to upload.
multer({ dest }).single('file'),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(multer({ dest }).single('file') as any),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we just not touch this file at all?

@ankitsharma101
Copy link
Copy Markdown
Contributor Author

Thanks for the review! Here's what I've done:

  • Switched to this.shellCommandBuilder instead of creating a new instance
  • Added the breaking change and feature entries to the changelog

For node-file-upload-service.ts — I'd love to leave it untouched, but adding @theia/process to plugin-ext causes a type conflict with a duplicate @types/express-serve-static-core in the monorepo, which breaks the build in that file. The eslint-disable + as any is the minimal fix I found. Happy to explore a different approach if you have one in mind.

Also, regarding resetCommandOutputMarker from the earlier review — I couldn't find that method anywhere in the terminal package. Is it something that already exists somewhere, or would it need to be added?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Waiting on author

Development

Successfully merging this pull request may close these issues.

"${command:pickArgs}" is not supported for debug launch

2 participants