콘텐츠로 이동

크로스 플랫폼

플랫폼 차이는 분기가 아니라 서브클래스입니다. 팩토리가 한 번 고르고 나면, 어느 호출부도 다시 process.platform을 묻지 않습니다. 이 페이지는 각 서브클래스가 무엇을 위한 것인지 설명합니다.

관심사 win32 macOS / Linux
명령 해석 WindowsLauncherwhere와 PATHEXT PosixLauncher (which)
트리 종료 WindowsProcessTree (taskkill /F /T) PosixProcessTree (그룹 시그널)
분리된 자손 가두기 JobSpawner (Job Object) 필요 없음 — detached와 그룹 시그널로 충분
짧은 명령 실행 cmd.exe를 실행 파일로 두고, argv는 원소로 execFile을 직접
커맨드라인 읽기 Get-CimInstance Win32_Process ps -o args=
죽은 부모 감지 signal-0 프로브 (ppid가 고정됨) ppid 비교 (reparenting)

CLI가 자신에게 있다고 믿는 터미널

섹션 제목: “CLI가 자신에게 있다고 믿는 터미널”

적용 대상: 모든 플랫폼.

이것은 플랫폼 차이가 아니지만, 형태가 같기에 그 옆에 놓입니다. 우리가 고르지 않은 환경이 우리가 파싱해야 하는 출력을 오염시킨다는 형태 말입니다.

능력 있는 터미널에 붙어 있다고 믿는 CLI는 출력을 ANSI 이스케이프 시퀀스로 장식합니다. 그것들이 프로토콜과 같은 스트림에 실리므로, JSON 한 줄이 \x1b[1 q{"type":"system"…처럼 도착해 파싱에 실패합니다 — 로그인 셸을 통해 에이전트를 실행하던 다른 도구들이 겪은 바로 그 고장입니다.

ClaudeAdapter는 모든 spawn에 바닥을 깔아 둡니다.

변수 이유
TERM dumb 하중을 지는 것. 장식할 만한 터미널이 없다고 CLI에 알립니다.
CI true 비대화식 동작을 요청합니다 — 여기에는 터미널에 그려진 프롬프트에 답할 존재가 없습니다.
CLAUDECODE undefined 설정이 아니라 제거. 이것은 “당신은 Claude Code 안에서 실행 중”임을 표시하는데, 그런 부모로부터 이를 물려받으면 중첩 실행이 아닌데도 CLI가 중첩 실행처럼 굴게 됩니다.

호출자 자신의 env는 이 바닥 위에 병합됩니다. 호출자가 우리가 모르는 무언가를 알고 있을 수 있기 때문입니다 — 그리고 자격 증명 제거는 그 둘 위에 마지막으로 적용됩니다.


적용 대상: 모든 플랫폼.

IDE가 spawn한 백엔드는 최소한의 PATH를 물려받습니다. 사용자의 셸이 구성한 PATH가 아니라 데스크톱 세션이 갖고 있던 PATH입니다. nvm, volta, homebrew 같은 것들은 모두 셸 초기화가 추가하는 디렉터리에 살기 때문에, 사용자가 분명히 설치해 둔 CLI가 맨 which에는 그냥 보이지 않습니다.

증상은, 사용자가 터미널에서는 멀쩡히 실행하는 CLI에 대해 spawn이 ENOENT로 실패하는 것입니다.

AugmentedPath는 잘 알려진 위치들을, 실제로 존재하는 것만 걸러 앞에 붙입니다.

import { AugmentedPath } from 'activecli';
const env = AugmentedPath.shared.toEnv();
// 이제 env.PATH는 ~/.claude/local, ~/.local/bin, /opt/homebrew/bin, … 순으로 시작합니다

이 라이브러리의 모든 spawn이 여기를 거칩니다 — 세션도, CommandRunner도, 로그인 흐름도 마찬가지입니다 — 그래서 하나가 찾은 CLI는 나머지 전부도 찾습니다.

가장 중요한 단 하나의 항목은 ~/.claude/local입니다. claude migrate-installer가 바이너리를 두는 곳이 거기이고, 공식 설치 프로그램은 사용자의 셸 alias를 그곳으로 향하게 합니다 — 그런데 GUI가 spawn한 백엔드는 그 alias를 결코 읽지 않습니다. 이 항목이 없으면 아주 흔한 설치 형태가 ENOENT로 spawn됩니다.

nvm은 찾아다녀서는 발견되지 않습니다

섹션 제목: “nvm은 찾아다녀서는 발견되지 않습니다”

nvm은 고정된 위치에 설치되지 않습니다. 셸에서 PATH를 다시 쓰는 방식으로 버전을 전환하는데, GUI가 spawn한 프로세스는 그것을 결코 보지 못합니다. 현재 버전이 어디 있는지 알아낼 유일하게 믿을 만한 방법은 nvm 자신에게 묻는 것입니다.

Terminal window
bash -c "source '$NVM_DIR/nvm.sh' --no-use 2>/dev/null && nvm which current"

NvmProbe가 정확히 그 일을 3초 타임아웃으로 수행하고, nvm이 지목한 node 바이너리의 디렉터리를 반환합니다. 서브셸 비용이 들기 때문에 AugmentedPath는 결과를 캐시하며, 그 비용을 어댑터마다가 아니라 프로세스당 한 번만 치르도록 AugmentedPath.shared가 존재합니다.

NVM_DIR 값은 환경에서 오고 bash 명령에 보간되므로, 신뢰할 수 없는 입력으로 취급됩니다. 경로를 resolve하고, 스크립트의 존재를 확인하며, 작은따옴표를 이스케이프합니다.


Windows는 하나의 환경이 아닙니다

섹션 제목: “Windows는 하나의 환경이 아닙니다”

이 페이지의 나머지는 win32에 관한 것입니다. 각 절은 서로 다른 고장이고, 그것들은 겹쳐서 쌓입니다.

PATHEXT: where가 결코 실행되지 않을 파일을 지목합니다

섹션 제목: “PATHEXT: where가 결코 실행되지 않을 파일을 지목합니다”

증상: 해석된 경로가 cmd.exe라면 결코 실행하지 않을 파일을 가리키고, 그래서 그로부터 파생되는 모든 것 — 설치 방식 감지, 업데이트 대상 — 이 실제로 spawn되는 바이너리와 어긋납니다.

where claude는 PATH 순서대로 모든 매치를 나열하며, 여기에는 npm이 claude.cmd 옆에 함께 배포하는 확장자 없는 셸 스크립트도 포함됩니다.

C:\Users\me\AppData\Roaming\npm\claude <- MSYS/bash 래퍼. cmd.exe는 이것을 실행하지 않습니다.
C:\Users\me\AppData\Roaming\npm\claude.cmd <- 실제로 실행되는

cmd.exe는 맨 claude를 PATHEXT(.COM;.EXE;.BAT;.CMD;…)를 통해 해석하므로, 첫 줄은 틀린 답입니다. WindowsLauncher.pick()은 확장자가 PATHEXT에 있는 첫 줄을 취합니다. where가 이미 PATH 디렉터리 순서로 매치를 출력하므로, 그 줄은 cmd.exe가 가장 먼저 닿는 디렉터리에 속합니다.

new WindowsLauncher().pick(whereOutput);
// 'C:\Users\me\AppData\Roaming\npm\claude.cmd'

PATHEXT가 설정되어 있지 않으면 cmd.exe 기본값인 .COM;.EXE;.BAT;.CMD를 사용합니다. PATHEXT 확장자를 가진 줄이 하나도 없으면 첫 줄을 반환합니다 — 확장자 없는 맨 실행 파일이라도 아무것도 없는 것보다는 낫습니다.

shell: true는 해결책이 아니라 인젝션 표면입니다

섹션 제목: “shell: true는 해결책이 아니라 인젝션 표면입니다”

증상: C:\Program Files\ 아래의 런처가 반으로 찢어지고, &, |, <, >를 담은 인자가 의미를 바꿉니다.

우리가 구동하는 CLI들은 런처 래퍼(.cmd, .ps1)로 배포되는데, execFile은 셸 없이 .cmd를 실행하지 못하고 ENOENT로 실패합니다. 뻔한 해결책이 shell: true이고, 그것은 틀렸습니다. 그렇게 하면 커맨드라인 전체가 문자열로 cmd.exe에 넘어가고, cmd.exe는 그것을 공백으로 쪼개고 그 안의 메타문자를 확장합니다.

올바른 구성은 cmd.exe를 실행 파일로 두고 — .exe이므로 Node의 배치 파일 캐럿 하드닝도 발동하지 않습니다 — 진짜 런처와 그 인자들을 별개의 argv 원소로 주는 것입니다.

new WindowsCommandLine().toArgv('C:\\Program Files\\nodejs\\npm.cmd', ['view', 'pkg']);
// ['/d', '/s', '/c', 'C:\Program Files\nodejs\npm.cmd', 'view', 'pkg']

그러면 Node가 각 원소를 큰따옴표로 감싸므로 공백 문제가 해결됩니다. CommandRunnershell: false(Node가 또 하나의 셸을 중첩하지 않고 파일을 직접 spawn)와 windowsVerbatimArguments: false(verbatim 모드는 인자를 날것 그대로 넘깁니다)도 함께 설정합니다.

플래그 목적
/d AutoRun을 건너뜁니다
/s 인용을 예측 가능하게 유지합니다
/c 실행하고 종료합니다

shellPath()ComSpec을 읽으므로, cmd.exe를 옮겨 둔 호스트도 존중됩니다.

인용은 &를 보호하지 못하며, CI가 그것을 증명했습니다

섹션 제목: “인용은 &를 보호하지 못하며, CI가 그것을 증명했습니다”

직관적인 믿음 — Node의 CommandLineToArgvW 인용이 & | < >를 리터럴로 만든다는 것 — 은 틀렸고, Windows 러너가 검사하기 전까지 이 라이브러리의 주석에 그렇게 적혀 있었습니다. 실측 결과입니다.

인자가 전달된 방식 자식 프로세스가 받은 것
cmd.exe 없이 직접 a&b|c<d>e — 온전함
cmd.exe를 거쳐, 표준 인용 a, 그리고 'b' is not recognized as an internal or external command
cmd.exe를 거쳐, 캐럿 이스케이프 a&b|c<d>e — 온전함

cmd.exe는 인자 파싱이 시작되기 전에 자신의 명령 구분자를 훑고, 따옴표 안에서도 그렇게 합니다 — &에서 명령을 끝내고 나머지를 실행하려 듭니다. 그래서 toArgv()는 그것들을 캐럿으로 이스케이프합니다.

new WindowsCommandLine().escape('a&b|c<d>e'); // 'a^&b^|c^<d^>e'

이것이 지키는 페이로드는 mcp add-json이고, 그 JSON은 이 문자들로 가득합니다. 이 이스케이프가 없었다면 Windows 사용자의 MCP 설정이 기록될 때 조용히 잘려 나갔을 것입니다 — macOS 스위트가 초록불이라는 사실이 존재하지 않는다고 암시하는, 바로 그런 종류의 결함입니다.

%VAR%는 따옴표 안에서도 확장됩니다

섹션 제목: “%VAR%는 따옴표 안에서도 확장됩니다”

증상: 인자가 변형된 채로 — 지목된 변수가 존재하지 않으면 비워진 채로 — 런처에 도달하고, 그 사실을 어디서도 보고하지 않습니다.

이것은 Node의 인용이 보호해 주지 못하는 유일한 것입니다. cmd.exe는 Node가 각 인자를 감싼 큰따옴표 안에서도 %VAR%를 확장합니다.

안전한 이스케이프가 없으므로 CommandRunner는 거부합니다.

await new CommandRunner().run('npm', ['view', '%API_KEY%']);
// throws: "Cannot run this command on Windows: argument #3 contains a '%' character,
// which cmd.exe expands as an environment variable (even inside quotes)
// and would corrupt the value. Remove the '%' from that value and try again."

시끄럽게 실패하는 것만이 정직한 선택지입니다. 인자가 조용히 다시 쓰인 명령을 실행하는 것은 아예 실행하지 않는 것보다 나쁩니다. 에러는 위치를 알려주되 값을 결코 그대로 내보이지 않습니다. 인자가 토큰이나 키를 실어 나를 수 있기 때문입니다.

캐럿도 여기서는 도움이 되지 않습니다. cmd.exe는 캐럿과 무관하게 %VAR%를 확장합니다.

Job Object: taskkill /T가 보지 못하는 고아들

섹션 제목: “Job Object: taskkill /T가 보지 못하는 고아들”

증상: 세션을 멈춘 뒤에도 워커 프로세스가 계속 실행되고 — 계속 과금되며 — 이후의 어떤 호스트에게도 보이지 않습니다.

win32에는 프로세스 그룹이 없습니다. taskkill /F /T살아 있는 부모-자식 트리를 훑지만, git-bash(MSYS)는 곧바로 종료되는 fork 헬퍼 아래에서 워커를 띄우기 때문에 워커가 cmd → cli → bash 트리에서 떨어져 나오고 종종 ppid 1로 reparent됩니다. Windows는 결코 reaper로 reparent하지 않으므로, 그 워커들은 모든 트리 종료에서 살아남아 머리 없는 고아가 됩니다.

Job Object는 커널이 제공하는 프로세스 그룹의 등가물입니다. PPID가 어떻게 다시 쓰이든 자손은 job 안에 남습니다.

graph TD
Host["Host process"] --> PS["powershell wrapper<br>holds the job handle"]
subgraph Job["Windows Job Object with KILL_ON_JOB_CLOSE"]
C["cmd.exe"] --> CLI["claude"]
CLI --> Bash["git-bash"]
Bash -.->|"fork helper exits, worker detaches"| W["worker with ppid 1"]
end
PS --> C

래퍼(win-job-wrapper.ps1)는 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE로 job을 만들고, 그 안에서 CLI를 실행한 뒤, CLI의 수명 동안 job 핸들을 붙들고 있습니다. 호스트가 래퍼를 죽이거나 — 혹은 호스트 자신이 죽으면 — 마지막 핸들이 닫히고 커널이 분리된 워커까지 포함한 트리 전체를 무너뜨립니다.

계약상 중요한 세부가 셋 있습니다.

세부 이유
커맨드라인은 ACTIVECLI_JOB_CMDLINE으로 전달됩니다 래퍼 자신의 argv 밖에 두어, 아무것도 그것을 다시 인용하지 않게 합니다
argv[0]은 래퍼가 무시하는 불투명한 태그입니다 호스트의 프로세스 레지스트리가 그 태그로 이 래퍼를 찾아 죽일 수 있도록 실어 나를 뿐입니다
래퍼는 stdout에서 침묵해야 합니다 호스트는 자식의 stdout을 CLI의 스트림 프로토콜로 읽습니다. 진짜 에러만 stderr로 갑니다

래퍼 안의 모든 실패는 치명적이지 않습니다 — job 생성이나 할당이 실패해도 CLI는 여전히 실행되며, job 이전의 동작으로 격하될 뿐입니다. job이 없다는 것은 고아 문제의 틈을 다시 열 뿐, 사용자를 막지는 않습니다.

측면 POSIX win32
메커니즘 process.kill(-pid, signal) taskkill /F /T /PID <pid>
요구사항 CLI가 detached로 spawn되어 그룹을 이끌어야 함 없음. 살아 있는 트리를 훑습니다
도달 범위 reparenting과 무관하게 모든 자손 여전히 트리에 붙어 있는 것만
폴백 ESRCH일 때 proc.kill() — detached가 아닌 자식은 우리가 아는 트리의 전부입니다 taskkill이 없을 때 proc.kill()
시그널 존중 아니요 — 시그널은 POSIX의 개념입니다

둘 다 AbstractProcessTree.kill()이 지키며, 이미 reap된 프로세스는 거부합니다. 어느 메커니즘도 생존 여부를 인지하지 못하고, 재사용된 pid라면 무관한 트리를 무너뜨리는 일이 됩니다.


WSL: 양쪽 모두에서 spawn을 깨뜨리는 경로

섹션 제목: “WSL: 양쪽 모두에서 spawn을 깨뜨리는 경로”

증상: UNC paths are not supported이거나, 바이너리가 없는 것처럼 보이지만 사실은 디렉터리가 없는 ENOENT.

Windows에서 실행되는 IDE는 프로젝트 루트를 WSL UNC 경로로 넘깁니다 — \\wsl.localhost\Ubuntu\home\user\proj, 혹은 옛 형식인 \\wsl$\NixOS\.... 그 경로는 spawn cwd로서 양쪽 방향 모두에서 실패합니다.

호스트가 실행되는 곳 벌어지는 일
Windows 네이티브 cmd.exe가 UNC cwd를 그대로 거부하고(UNC paths are not supported), 그러면 에이전트는 bash 대신 PowerShell 도구를 고릅니다
distro 내부 — Linux 호스트이며, IDE가 띄운 백엔드가 실제로 실행되는 곳 IDE는 여전히 프로젝트 루트를 Windows 형식으로 넘깁니다. 그 위치는 distro 안에 존재하지 않으므로 spawn이 ENOENT로 실패합니다 — 없는 것은 cwd인데도 바이너리가 없는 것처럼 읽힙니다

둘 다 자식에게 toWslPath()를 대신 넘기면 해결되며, isSpawnableAsCwd()false를 반환하는 것이 호출자로 하여금 그것을 집어 들게 합니다.

const path = new PathParser().parse('\\\\wsl.localhost\\Ubuntu\\home\\user\\proj');
path.isSpawnableAsCwd(); // false
path.toWslPath(); // '/home/user/proj'

ClaudeAdapter는 그 답에 따라, 실패할 것이 뻔한 값을 넘기는 대신 spawn에서 cwd를 아예 빼는 방식으로 대응합니다.

모든 UNC 경로가 WSL 경로인 것은 아닙니다. \\server\share\filenull로 파싱됩니다.

distro 안에서의 Windows 드라이브 경로

섹션 제목: “distro 안에서의 Windows 드라이브 경로”

Linux 자식 프로세스는 C:\Users\foo를 쓸 수 없습니다. WSL distro 안에서 Windows 드라이브는 /mnt/<letter> 아래에 마운트됩니다.

parser.parse('C:\\Users\\foo').toWslPath(); // '/mnt/c/Users/foo'
parser.parse('D:\\Projects').toWslPath(); // '/mnt/d/Projects'
parser.parse('C:\\').toWslPath(); // '/mnt/c'
parser.parse('C:Users\\foo').toWslPath(); // '/mnt/c/Users/foo' — 구분자 없는 형태

드라이브 문자는 소문자로 바뀝니다. 마운트 지점이 /mnt/C가 아니라 /mnt/c이기 때문입니다. 끝의 슬래시는 제거되는데, /mnt/c/는 마운트 지점 자체와는 다른 위치로 읽히기 때문입니다.