CLI 자식 프로세스 환경에 Windows 필수 변수 백필
공개 런타임 승격 체인이 psutil로 이전 프로세스 환경을 통째로 이식하는 과정에서 SystemRoot 가 유실됐고, Go 계열 CLI(agy)는 시스템 인증서 풀/홈 해석에 SystemRoot 가 필요해 agy models 가 조용히 빈 목록을 반환했다(관리자 AI 운영 화면의 'Agy가 선택 가능한 모델을 반환하지 않았습니다' 두 번째 원인). - _cli_subprocess_env(): 상속 환경에서 빠진 SystemRoot/SystemDrive/ComSpec 만 기본값으로 백필해 모든 CLI 스폰(_run_process·codex app-server·agy stream·claude 세션)에 적용. os.environ 의 Windows 대문자 정규화를 고려한 대소문자 무시 조회 - Set-CompleteProcessEnvironment: 이식본에 빠진 Windows 필수 키를 Machine 스코프 표준값으로 병합해 승격 체인 자체의 유실을 원천 보강 - 검증: 신규 2단위 RED→GREEN, engine_gateway 65 passed, app 924 passed, PS 5.1 parser OK, SystemRoot 제거 환경에서 실제 agy CLI 14모델 live 조회 확인
This commit is contained in:
parent
5afd92f8f3
commit
6369f29439
4 changed files with 73 additions and 0 deletions
|
|
@ -41,6 +41,7 @@ from app.contracts.engine_gateway import (
|
|||
)
|
||||
from engine_gateway.provider_registry import (
|
||||
ProviderError,
|
||||
_cli_subprocess_env,
|
||||
discover_capabilities,
|
||||
generate_with_provider,
|
||||
stream_with_provider,
|
||||
|
|
@ -250,6 +251,7 @@ class EngineSession:
|
|||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_cli_subprocess_env(),
|
||||
)
|
||||
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,33 @@ def _safe_process_error(stderr: bytes, fallback: str) -> str:
|
|||
return detail[-1200:]
|
||||
|
||||
|
||||
# Windows 필수 변수의 표준 기본값 — 승격 체인의 psutil 환경 이식에서 유실될 수 있다.
|
||||
_WINDOWS_ESSENTIAL_ENV_DEFAULTS = {
|
||||
"SystemRoot": r"C:\Windows",
|
||||
"SystemDrive": "C:",
|
||||
"ComSpec": r"C:\Windows\system32\cmd.exe",
|
||||
}
|
||||
|
||||
|
||||
def _cli_subprocess_env() -> dict[str, str]:
|
||||
"""CLI 자식 프로세스에 물려줄 환경.
|
||||
|
||||
공개 런타임 승격은 이전 프로세스 환경을 psutil로 통째로 이식하는데, 이 캡처에서
|
||||
SystemRoot 같은 Windows 필수 변수가 유실되면 Go 계열 CLI(agy)가 시스템 인증서
|
||||
풀·홈 디렉터리 해석에 실패하고 빈 모델 목록을 조용히 내놓는다(2026-08-18 실측).
|
||||
상속 환경에서 빠진 필수 키만 기본값으로 채운다.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
if os.name == "nt":
|
||||
# os.environ은 Windows에서 키를 대문자로 정규화하므로 대소문자 무시 조회한다.
|
||||
upper_names = {key.upper(): key for key in env}
|
||||
for name, default in _WINDOWS_ESSENTIAL_ENV_DEFAULTS.items():
|
||||
existing = upper_names.get(name.upper())
|
||||
if existing is None or not env[existing]:
|
||||
env[name] = default
|
||||
return env
|
||||
|
||||
|
||||
async def _run_process(
|
||||
args: list[str],
|
||||
*,
|
||||
|
|
@ -141,6 +168,7 @@ async def _run_process(
|
|||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=_cli_subprocess_env(),
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
|
|
@ -233,6 +261,7 @@ async def _codex_model_list(binary: str) -> dict[str, Any]:
|
|||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_cli_subprocess_env(),
|
||||
)
|
||||
if proc.stdin is None or proc.stdout is None:
|
||||
proc.kill()
|
||||
|
|
@ -681,6 +710,7 @@ async def _stream_agy(
|
|||
cwd=str(_cli_runtime_cwd()),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_cli_subprocess_env(),
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
assert proc.stderr is not None
|
||||
|
|
|
|||
|
|
@ -36,6 +36,33 @@ class _FakeAgyProcess:
|
|||
|
||||
|
||||
class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
|
||||
def test_cli_subprocess_env_backfills_windows_essentials(self):
|
||||
# 런타임 승격 체인의 psutil 환경 이식에서 SystemRoot 가 유실되면 agy(Go)가
|
||||
# 인증서 풀/홈 해석에 실패해 빈 모델 목록을 내놓는다(2026-08-18 실측).
|
||||
scrubbed = {
|
||||
"PATH": r"C:\Windows\System32",
|
||||
"USERPROFILE": r"C:\Users\encep",
|
||||
}
|
||||
with patch.dict(provider_registry.os.environ, scrubbed, clear=True):
|
||||
env = provider_registry._cli_subprocess_env()
|
||||
|
||||
self.assertEqual(env["SystemRoot"], r"C:\Windows")
|
||||
self.assertEqual(env["SystemDrive"], "C:")
|
||||
self.assertEqual(env["ComSpec"], r"C:\Windows\system32\cmd.exe")
|
||||
self.assertEqual(env["PATH"], r"C:\Windows\System32")
|
||||
|
||||
def test_cli_subprocess_env_preserves_existing_essentials(self):
|
||||
# os.environ은 Windows에서 키를 대문자로 정규화한다. 이미 값이 있으면
|
||||
# 기본 케이스 키를 덧붙이지 않고 기존값을 그대로 둔다.
|
||||
scrubbed = {
|
||||
"SYSTEMROOT": r"D:\Win",
|
||||
"PATH": "x",
|
||||
}
|
||||
with patch.dict(provider_registry.os.environ, scrubbed, clear=True):
|
||||
env = provider_registry._cli_subprocess_env()
|
||||
|
||||
self.assertEqual(env["SYSTEMROOT"], r"D:\Win")
|
||||
self.assertNotIn("SystemRoot", env)
|
||||
def setUp(self):
|
||||
provider_registry.clear_capability_cache()
|
||||
|
||||
|
|
|
|||
|
|
@ -533,6 +533,20 @@ function Save-CompleteProcessEnvironment {
|
|||
function Set-CompleteProcessEnvironment {
|
||||
param([System.Collections.IDictionary]$Environment)
|
||||
|
||||
# psutil 환경 캡색에서 SystemRoot 같은 Windows 필수 변수가 유실되면 Go 계열 CLI(agy)가
|
||||
# 시스템 인증서 풀/홈 해석에 실패해 조용히 빈 결과를 낸다(2026-08-18 실측). 이식본에
|
||||
# 빠진 필수 키는 Machine 스코프 표준값으로 되살린다.
|
||||
$windowsEssentials = @('SystemRoot', 'windir', 'SystemDrive', 'ComSpec')
|
||||
foreach ($name in $windowsEssentials) {
|
||||
$missing = -not $Environment.Contains($name) -or [string]::IsNullOrWhiteSpace([string]$Environment[$name])
|
||||
if ($missing) {
|
||||
$machineValue = [System.Environment]::GetEnvironmentVariable($name, 'Machine')
|
||||
if ($machineValue) {
|
||||
$Environment[$name] = $machineValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$current = [System.Environment]::GetEnvironmentVariables(
|
||||
[System.EnvironmentVariableTarget]::Process
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue