Skip to content

Commit 14e340f

Browse files
committed
Fix Linux terminal launch shell quoting (#39) and add macOS E2E test (#38)
- Use -EncodedCommand (Base64 UTF-16LE) instead of -Command with shell quoting - Move -WorkingDirectory to Set-Location inside the encoded command - Fix caseFix to skip empty PSModulePath entries (Join-Path failure) - Add GitHub Actions workflow for macOS Terminal.app E2E testing
1 parent e730e4a commit 14e340f

2 files changed

Lines changed: 184 additions & 11 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
name: macOS Terminal E2E Test
2+
3+
on:
4+
workflow_dispatch: # Manual trigger only
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
test-macos-terminal:
11+
runs-on: macos-14
12+
timeout-minutes: 10
13+
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Setup .NET
18+
uses: actions/setup-dotnet@v4
19+
with:
20+
dotnet-version: |
21+
8.0.x
22+
9.0.x
23+
24+
- name: Install PowerShell
25+
run: brew install powershell/tap/powershell
26+
27+
- name: Build and setup module
28+
run: |
29+
dotnet build PowerShell.MCP -c Release --no-incremental
30+
dotnet publish PowerShell.MCP.Proxy -c Release -r osx-arm64 --self-contained
31+
32+
MODULE_PATH="$HOME/.local/share/powershell/Modules/PowerShell.MCP"
33+
mkdir -p "$MODULE_PATH/bin/osx-arm64"
34+
cp PowerShell.MCP/bin/Release/net8.0/PowerShell.MCP.dll "$MODULE_PATH/"
35+
cp PowerShell.MCP/bin/Release/net8.0/Ude.NetStandard.dll "$MODULE_PATH/"
36+
cp Staging/PowerShell.MCP.psd1 "$MODULE_PATH/"
37+
cp Staging/PowerShell.MCP.psm1 "$MODULE_PATH/"
38+
cp PowerShell.MCP.Proxy/bin/Release/net9.0/osx-arm64/publish/PowerShell.MCP.Proxy "$MODULE_PATH/bin/osx-arm64/"
39+
chmod +x "$MODULE_PATH/bin/osx-arm64/PowerShell.MCP.Proxy"
40+
41+
echo "Module files:"
42+
ls -laR "$MODULE_PATH/"
43+
44+
- name: Test Terminal.app launch and invoke_expression (Issue #38)
45+
shell: pwsh
46+
timeout-minutes: 5
47+
run: |
48+
$ErrorActionPreference = "Stop"
49+
50+
$proxyPath = Get-MCPProxyPath
51+
Write-Host "Proxy path: $proxyPath"
52+
53+
# Start Proxy process
54+
$psi = [System.Diagnostics.ProcessStartInfo]::new()
55+
$psi.FileName = $proxyPath
56+
$psi.RedirectStandardInput = $true
57+
$psi.RedirectStandardOutput = $true
58+
$psi.RedirectStandardError = $true
59+
$psi.UseShellExecute = $false
60+
$psi.CreateNoWindow = $true
61+
62+
$process = [System.Diagnostics.Process]::Start($psi)
63+
Write-Host "Proxy started with PID: $($process.Id)"
64+
65+
function Send-JsonRpc {
66+
param([string]$Json, [int]$TimeoutMs = 30000)
67+
Write-Host "Sending: $($Json.Substring(0, [Math]::Min(120, $Json.Length)))..."
68+
$process.StandardInput.WriteLine($Json)
69+
$process.StandardInput.Flush()
70+
$task = $process.StandardOutput.ReadLineAsync()
71+
if ($task.Wait($TimeoutMs)) {
72+
return $task.Result
73+
} else {
74+
throw "Timeout waiting for response after ${TimeoutMs}ms"
75+
}
76+
}
77+
78+
try {
79+
# 1. Initialize
80+
Write-Host "`n=== Step 1: Initialize ===" -ForegroundColor Cyan
81+
$response = Send-JsonRpc '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
82+
Write-Host "OK: $($response.Substring(0, [Math]::Min(100, $response.Length)))..."
83+
84+
$process.StandardInput.WriteLine('{"jsonrpc":"2.0","method":"notifications/initialized"}')
85+
$process.StandardInput.Flush()
86+
Start-Sleep -Seconds 1
87+
88+
# 2. Start console via Terminal.app
89+
Write-Host "`n=== Step 2: start_powershell_console (Terminal.app) ===" -ForegroundColor Cyan
90+
$response = Send-JsonRpc '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"start_powershell_console","arguments":{"reason":"issue38 test","banner":"Issue #38 E2E Test"}}}' 60000
91+
Write-Host "Response: $($response.Substring(0, [Math]::Min(200, $response.Length)))..."
92+
93+
if ($response -match '"error"') {
94+
Write-Host "ERROR in start_powershell_console response:" -ForegroundColor Red
95+
Write-Host $response
96+
throw "start_powershell_console failed"
97+
}
98+
Write-Host "Terminal.app console started" -ForegroundColor Green
99+
100+
# Take screenshot after console start
101+
screencapture -x /tmp/screenshot-after-start.png 2>$null
102+
Write-Host "Screenshot saved: /tmp/screenshot-after-start.png"
103+
104+
# 3. Quick command - should execute without manual Enter
105+
Write-Host "`n=== Step 3: invoke_expression (quick) ===" -ForegroundColor Cyan
106+
$response = Send-JsonRpc '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Write-Host TEST-QUICK -ForegroundColor Green"}}}' 30000
107+
Write-Host "Response: $($response.Substring(0, [Math]::Min(300, $response.Length)))..."
108+
109+
if ($response -match 'TEST-QUICK') {
110+
Write-Host "PASS: Quick command executed without manual Enter" -ForegroundColor Green
111+
} else {
112+
Write-Host "WARN: TEST-QUICK not in response (may be first-call redirect)" -ForegroundColor Yellow
113+
}
114+
115+
# 4. Delayed command - the main #38 scenario
116+
Write-Host "`n=== Step 4: invoke_expression after 5s delay ===" -ForegroundColor Cyan
117+
Start-Sleep -Seconds 5
118+
$response = Send-JsonRpc '{"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Get-Date -Format yyyy-MM-dd"}}}' 30000
119+
Write-Host "Response: $($response.Substring(0, [Math]::Min(300, $response.Length)))..."
120+
121+
$today = Get-Date -Format "yyyy-MM-dd"
122+
if ($response -match $today) {
123+
Write-Host "PASS: Delayed command returned correct date ($today)" -ForegroundColor Green
124+
} else {
125+
Write-Host "FAIL: Expected date $today not found in response" -ForegroundColor Red
126+
throw "Issue #38 regression: delayed command did not execute automatically"
127+
}
128+
129+
# 5. Long-running command
130+
Write-Host "`n=== Step 5: Long-running command (3s sleep) ===" -ForegroundColor Cyan
131+
$response = Send-JsonRpc '{"jsonrpc":"2.0","id":30,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Start-Sleep -Seconds 3; Write-Host LONG-DONE"}}}' 60000
132+
Write-Host "Response: $($response.Substring(0, [Math]::Min(300, $response.Length)))..."
133+
134+
if ($response -match 'LONG-DONE') {
135+
Write-Host "PASS: Long-running command completed" -ForegroundColor Green
136+
} else {
137+
throw "Long-running command did not return expected output"
138+
}
139+
140+
# 6. Command after long-running
141+
Write-Host "`n=== Step 6: Command immediately after long-running ===" -ForegroundColor Cyan
142+
$response = Send-JsonRpc '{"jsonrpc":"2.0","id":40,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Write-Host AFTER-LONG"}}}' 30000
143+
Write-Host "Response: $($response.Substring(0, [Math]::Min(300, $response.Length)))..."
144+
145+
if ($response -match 'AFTER-LONG') {
146+
Write-Host "PASS: Post-long command executed" -ForegroundColor Green
147+
} else {
148+
throw "Post-long command did not execute"
149+
}
150+
151+
# Take final screenshot
152+
screencapture -x /tmp/screenshot-final.png 2>$null
153+
154+
Write-Host "`n========================================" -ForegroundColor Green
155+
Write-Host "ALL TESTS PASSED - Issue #38 not reproduced" -ForegroundColor Green
156+
Write-Host "========================================" -ForegroundColor Green
157+
158+
} finally {
159+
if (-not $process.HasExited) { $process.Kill() }
160+
$process.Dispose()
161+
}
162+
163+
- name: Upload screenshots
164+
if: always()
165+
uses: actions/upload-artifact@v4
166+
with:
167+
name: macos-terminal-screenshots
168+
path: /tmp/screenshot-*.png
169+
if-no-files-found: ignore

PowerShell.MCP.Proxy/Services/PowerShellProcessManager.cs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -420,29 +420,33 @@ private static bool TryLaunchTerminal(string terminal, string agentId, string? s
420420
CreateNoWindow = true
421421
};
422422

423-
// Build command with optional startup commands (pre-built Write-Host statements, with '' escaping for shell)
423+
// Build initialization command and encode to Base64 to avoid shell quoting issues
424424
// Set global variables with proxy PID and agent ID before importing module
425425
var proxyPid = Process.GetCurrentProcess().Id;
426426
// Fix module directory case sensitivity on Linux: Install-PSResource may create lowercase 'powershell.mcp'
427-
var caseFix = "foreach ($p in ($env:PSModulePath -split [IO.Path]::PathSeparator)) { $lc = Join-Path $p ''powershell.mcp''; $uc = Join-Path $p ''PowerShell.MCP''; if ((Test-Path $lc) -and -not (Test-Path $uc)) { Rename-Item $lc $uc; break } }; ";
427+
var caseFix = "foreach ($p in ($env:PSModulePath -split [IO.Path]::PathSeparator)) { if ([string]::IsNullOrWhiteSpace($p)) { continue }; $lc = Join-Path $p 'powershell.mcp'; $uc = Join-Path $p 'PowerShell.MCP'; if ((Test-Path $lc) -and -not (Test-Path $uc)) { Rename-Item $lc $uc; break } }; ";
428+
429+
// Set working directory via Set-Location inside the command to avoid shell quoting issues
430+
var setLocation = string.IsNullOrEmpty(startLocation)
431+
? "Set-Location ~; "
432+
: $"Set-Location -LiteralPath '{startLocation.Replace("'", "''")}'; ";
433+
428434
string initCommand;
429435
if (!string.IsNullOrEmpty(startupCommands))
430436
{
431-
// Double single quotes for shell string context
432-
var escaped = startupCommands.Replace("'", "''");
433-
initCommand = $"$global:PowerShellMCPProxyPid = {proxyPid}; $global:PowerShellMCPAgentId = ''{agentId}''; {caseFix}Import-Module PowerShell.MCP -Force; Remove-Module PSReadLine -ErrorAction SilentlyContinue; {escaped}";
437+
initCommand = $"{setLocation}$global:PowerShellMCPProxyPid = {proxyPid}; $global:PowerShellMCPAgentId = '{agentId}'; {caseFix}Import-Module PowerShell.MCP -Force; Remove-Module PSReadLine -ErrorAction SilentlyContinue; {startupCommands}";
434438
}
435439
else
436440
{
437-
initCommand = $"$global:PowerShellMCPProxyPid = {proxyPid}; $global:PowerShellMCPAgentId = ''{agentId}''; {caseFix}Import-Module PowerShell.MCP -Force; Remove-Module PSReadLine -ErrorAction SilentlyContinue";
441+
initCommand = $"{setLocation}$global:PowerShellMCPProxyPid = {proxyPid}; $global:PowerShellMCPAgentId = '{agentId}'; {caseFix}Import-Module PowerShell.MCP -Force; Remove-Module PSReadLine -ErrorAction SilentlyContinue";
438442
}
439443

440-
// Resolve working directory
441-
var workingDir = string.IsNullOrEmpty(startLocation) ? "~" : startLocation.Replace("'", "'\\''");
444+
// Encode command to Base64 (UTF-16LE) to bypass shell quoting/expansion issues
445+
var encodedCommand = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(initCommand));
442446

443-
// Command to launch pwsh with proper initialization via login shell
447+
// Command to launch pwsh with encoded initialization via login shell
444448
// exec replaces the shell with pwsh to keep the process tree clean
445-
var pwshCommand = $"exec pwsh -NoExit -WorkingDirectory '{workingDir}' -Command ''{initCommand}''";
449+
var pwshCommand = $"exec pwsh -NoExit -EncodedCommand {encodedCommand}";
446450

447451
// setsid <terminal> ... <shell> -l -c '<pwshCommand>'
448452
psi.ArgumentList.Add(terminal);
@@ -519,7 +523,7 @@ private static void LaunchPwshDirectly(string agentId, string? startupCommands,
519523
{
520524
var proxyPid = Process.GetCurrentProcess().Id;
521525
// Fix module directory case sensitivity on Linux: Install-PSResource may create lowercase 'powershell.mcp'
522-
var caseFix = "foreach ($p in ($env:PSModulePath -split [IO.Path]::PathSeparator)) { $lc = Join-Path $p 'powershell.mcp'; $uc = Join-Path $p 'PowerShell.MCP'; if ((Test-Path $lc) -and -not (Test-Path $uc)) { Rename-Item $lc $uc; break } }; ";
526+
var caseFix = "foreach ($p in ($env:PSModulePath -split [IO.Path]::PathSeparator)) { if ([string]::IsNullOrWhiteSpace($p)) { continue }; $lc = Join-Path $p 'powershell.mcp'; $uc = Join-Path $p 'PowerShell.MCP'; if ((Test-Path $lc) -and -not (Test-Path $uc)) { Rename-Item $lc $uc; break } }; ";
523527
var initCommand = string.IsNullOrEmpty(startupCommands)
524528
? $"$global:PowerShellMCPProxyPid = {proxyPid}; $global:PowerShellMCPAgentId = '{agentId}'; {caseFix}Import-Module PowerShell.MCP -Force"
525529
: $"$global:PowerShellMCPProxyPid = {proxyPid}; $global:PowerShellMCPAgentId = '{agentId}'; {caseFix}Import-Module PowerShell.MCP -Force; {startupCommands}";

0 commit comments

Comments
 (0)