fix(plugin): жёсткий разбор пути к серверу и обёртка запуска в лаунчере

- Test-Path -PathType Leaf: путь-каталог в KOMPAS_MCP_EXE больше не проходит
  проверку молча — раньше падение случалось на голом & $exe сырым
  CommandNotFoundException в обход диагностической обёртки.
- запуск сервера (& $exe @args) обёрнут в try/catch: сбой самого запуска
  (битый бинарь, недостающая зависимость, отказ в доступе) теперь тоже даёт
  чистое сообщение 'kompas-mcp launcher: ...' и код 1, а не сырой трейс;
  ненулевой код возврата уже запущенного сервера по-прежнему пробрасывается как есть.
- тесты: три новых сценария (каталог вместо файла, ноль байт в stdout на
  пути отказа, повреждённый exe) + первый тест переписан на побайтовую
  проверку stdout через Start-Process без фильтрации пустых строк.
This commit is contained in:
2026-07-31 02:39:37 +03:00
parent 0ed211fe16
commit 2fb212bb14
2 changed files with 99 additions and 5 deletions
+16 -3
View File
@@ -17,7 +17,9 @@ try {
$exe = Resolve-KompasMcpExecutable -Lock $lock
}
if (-not (Test-Path -LiteralPath $exe)) {
# -PathType Leaf: без него путь к существующему каталогу молча проходит проверку,
# а падает уже голый вызов "& $exe" сырым CommandNotFoundException в обход catch ниже.
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) {
throw "не найден сервер: $exe"
}
}
@@ -26,6 +28,17 @@ catch {
exit 1
}
& $exe @args
$serverExit = $LASTEXITCODE
try {
# Сплаттинг @args во внешний процесс: пустые строки-аргументы теряются (особенность
# PowerShell) — для MCP-клиента безвредно, он серверу аргументов не передаёт.
& $exe @args
$serverExit = $LASTEXITCODE
}
catch {
# Сбой самого запуска (битый бинарь, недостающая зависимость, отказ в доступе) —
# не путать с ненулевым кодом возврата уже запущенного сервера, это не exception.
[Console]::Error.WriteLine("kompas-mcp launcher: не удалось запустить сервер: $($_.Exception.Message)")
exit 1
}
exit $serverExit
+83 -2
View File
@@ -1,13 +1,42 @@
BeforeAll {
$script:Launcher = Join-Path $PSScriptRoot '..' '..' 'plugin' 'scripts' 'launch-kompas-mcp.ps1'
# Запускает лаунчер через Start-Process с раздельным перехватом stdout/stderr во
# временные файлы — в отличие от захвата пайплайном (`& powershell ...`), это даёт
# точный побайтовый результат каждого потока, а не строки, склеенные PowerShell.
function Invoke-Launcher {
param([string[]] $LauncherArgs = @())
$stdOut = [System.IO.Path]::GetTempFileName()
$stdErr = [System.IO.Path]::GetTempFileName()
$psArgs = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $script:Launcher) + $LauncherArgs
$proc = Start-Process -FilePath 'powershell' -ArgumentList $psArgs -NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $stdOut -RedirectStandardError $stdErr
return [pscustomobject]@{
ExitCode = $proc.ExitCode
StdOutPath = $stdOut
StdErrPath = $stdErr
}
}
function Remove-LauncherResult {
param($Result)
Remove-Item -LiteralPath $Result.StdOutPath, $Result.StdErrPath -ErrorAction SilentlyContinue
}
}
Describe 'launch-kompas-mcp.ps1' {
It 'не сорит в stdout: наружу идёт только вывод сервера' {
$env:KOMPAS_MCP_EXE = $env:ComSpec
try {
$out = & powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script:Launcher /c echo PONG
($out | Where-Object { $_.Trim().Length -gt 0 }) -join "`n" | Should -Be 'PONG'
$result = Invoke-Launcher -LauncherArgs @('/c', 'echo', 'PONG')
try {
# Побайтовое (посимвольное) сравнение — без фильтрации пустых строк,
# чтобы лишний символ/строка в stdout не проскочила незамеченной.
(Get-Content -LiteralPath $result.StdOutPath -Raw) | Should -Be "PONG`r`n"
} finally { Remove-LauncherResult $result }
} finally { Remove-Item Env:\KOMPAS_MCP_EXE -ErrorAction SilentlyContinue }
}
@@ -32,4 +61,56 @@ Describe 'launch-kompas-mcp.ps1' {
($stderr | Out-String) | Should -Match 'kompas-mcp launcher'
} finally { Remove-Item Env:\KOMPAS_MCP_EXE -ErrorAction SilentlyContinue }
}
It 'падает с кодом 1 и диагностическим сообщением (не сырым трейсом), когда KOMPAS_MCP_EXE указывает на каталог' {
$dir = Join-Path ([System.IO.Path]::GetTempPath()) ('kompasmcp_dir_' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$env:KOMPAS_MCP_EXE = $dir
try {
$result = Invoke-Launcher
try {
$result.ExitCode | Should -Be 1
$stderrText = Get-Content -LiteralPath $result.StdErrPath -Raw
$stderrText | Should -Match 'kompas-mcp launcher'
$stderrText | Should -Not -Match 'CommandNotFoundException'
} finally { Remove-LauncherResult $result }
} finally {
Remove-Item Env:\KOMPAS_MCP_EXE -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'на пути отказа не пишет в stdout ни одного байта' {
$dir = Join-Path ([System.IO.Path]::GetTempPath()) ('kompasmcp_dir_' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$env:KOMPAS_MCP_EXE = $dir
try {
$result = Invoke-Launcher
try {
$result.ExitCode | Should -Be 1
# Длина файла в байтах, а не строковый вывод — один лишний байт в stdout
# ломает JSON-RPC на всю MCP-сессию, это самый критичный инвариант лаунчера.
(Get-Item -LiteralPath $result.StdOutPath).Length | Should -Be 0
} finally { Remove-LauncherResult $result }
} finally {
Remove-Item Env:\KOMPAS_MCP_EXE -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'падает с кодом 1 и диагностическим сообщением, когда KOMPAS_MCP_EXE указывает на повреждённый exe' {
$badExe = Join-Path ([System.IO.Path]::GetTempPath()) ('kompasmcp_bad_' + [guid]::NewGuid().ToString('N') + '.exe')
Set-Content -LiteralPath $badExe -Value 'это не исполняемый файл' -Encoding ascii
$env:KOMPAS_MCP_EXE = $badExe
try {
$result = Invoke-Launcher
try {
$result.ExitCode | Should -Be 1
(Get-Content -LiteralPath $result.StdErrPath -Raw) | Should -Match 'kompas-mcp launcher'
} finally { Remove-LauncherResult $result }
} finally {
Remove-Item Env:\KOMPAS_MCP_EXE -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $badExe -ErrorAction SilentlyContinue
}
}
}