From 6369f29439fab3c3998d44f8ee78d01b7a63eb66 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Tue, 18 Aug 2026 13:25:08 +0900 Subject: [PATCH] =?UTF-8?q?CLI=20=EC=9E=90=EC=8B=9D=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=84=B8=EC=8A=A4=20=ED=99=98=EA=B2=BD=EC=97=90=20Windows=20?= =?UTF-8?q?=ED=95=84=EC=88=98=20=EB=B3=80=EC=88=98=20=EB=B0=B1=ED=95=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 런타임 승격 체인이 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 조회 확인 --- apps/api/engine_gateway/gateway.py | 2 ++ apps/api/engine_gateway/provider_registry.py | 30 +++++++++++++++++++ .../engine_gateway/test_provider_registry.py | 27 +++++++++++++++++ scripts/start-public-runtime.ps1 | 14 +++++++++ 4 files changed, 73 insertions(+) diff --git a/apps/api/engine_gateway/gateway.py b/apps/api/engine_gateway/gateway.py index e29c7b7..f9d931c 100644 --- a/apps/api/engine_gateway/gateway.py +++ b/apps/api/engine_gateway/gateway.py @@ -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()) diff --git a/apps/api/engine_gateway/provider_registry.py b/apps/api/engine_gateway/provider_registry.py index 92c91f1..432e18b 100644 --- a/apps/api/engine_gateway/provider_registry.py +++ b/apps/api/engine_gateway/provider_registry.py @@ -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 diff --git a/apps/api/engine_gateway/test_provider_registry.py b/apps/api/engine_gateway/test_provider_registry.py index a2c30c6..eeb7df3 100644 --- a/apps/api/engine_gateway/test_provider_registry.py +++ b/apps/api/engine_gateway/test_provider_registry.py @@ -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() diff --git a/scripts/start-public-runtime.ps1 b/scripts/start-public-runtime.ps1 index e6c3efe..c3ec44f 100644 --- a/scripts/start-public-runtime.ps1 +++ b/scripts/start-public-runtime.ps1 @@ -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 )