• Home
  • About
    • lahuman photo

      lahuman

      열심히 사는 아저씨

    • Learn More
    • Facebook
    • LinkedIn
    • Github
  • Posts
    • All Posts
    • All Tags
  • Projects

cokacremote 톺아보기 2부 — AI가 명령을 실행한다는 것

23 Aug 2026

Reading time ~10 minutes

1부에서는 ChatGPT의 요청이 어디로 들어오는지 따라가 봤습니다.

대략 이런 흐름이었습니다.

flowchart TD; A[ChatGPT] --> B[POST /mcp]; B --> C[Express]; C --> D[MCP Server]; D --> E[Tool];

그런데 여기까지 보고 나니 바로 다음이 궁금했습니다.

ChatGPT가 npm test를 실행해달라고 하면 실제 컴퓨터에서는 무슨 일이 일어날까?

AI가 명령을 “실행한다”고 하면 뭔가 거창해 보입니다.

그런데 결국 어딘가에서는 OS 프로세스 하나가 만들어져야 합니다.

그래서 exec_command부터 ProcessManager까지 내려가 봤습니다.

분석 기준은 1부와 같습니다.

repository: https://github.com/lahuman/cokacremote
commit: 1bb0f767c4249e8536bcee98341808bebcbbebaa
date: 2026-08-21

exec_command부터 다시 봤습니다.

1부 마지막에 exec_command가 등록되는 곳까지 확인했습니다.

파일은 src/exec-tools.ts입니다.

server.registerTool(
  "exec_command",
  {
    title: "Execute command",
    description:
      "Run an unrestricted shell command on the host...",

설명부터 꽤 셉니다.

unrestricted shell command라고 되어 있습니다.

코드도 별도의 명령 allowlist를 두거나 특정 명령만 허용하지 않습니다.

MCP 서버 프로세스가 가진 권한으로 shell command를 실행합니다.

입력값은 이렇습니다.

cmd
workdir
shell
login
env
stdin
timeoutMs
yieldTimeMs
maxOutputBytes

보통 제가 ChatGPT에게

이 프로젝트 테스트 돌려줘.

라고 말하면 Tool 호출 쪽에서는 대략 이런 정보가 넘어갑니다.

cmd: npm test
workdir: /workspace/project

물론 실제 Tool 호출 인자는 상황마다 달라질 수 있습니다.

cmd만 받는 건 아닙니다. 작업 디렉터리, shell, 환경변수, timeout 같은 실행 조건도 같이 받을 수 있습니다.

Handler 안으로 들어가 봤습니다.

exec_command의 실제 Handler를 보면 길지 않습니다.

const cwd = fileService.resolve(".", workdir);
const executable = shell || config.defaultShell;
const sessionId = processManager.start({
  executable,
  args: [login ? "-lc" : "-c", cmd],
  commandForDisplay: cmd,
  cwd,
  env,
  timeoutMs,
  stdin,
});

여기서 궁금했던 건 이겁니다.

cmd를 바로 실행하는 건가?

아니었습니다.

먼저 어떤 shell을 쓸지 정합니다.

const executable = shell || config.defaultShell;

별도로 shell을 주지 않으면 config.defaultShell을 사용합니다.

1부에서 봤던 기본값은 /bin/bash입니다.

그 다음 인자로

args: [login ? "-lc" : "-c", cmd]

를 넘깁니다.

기본값인 login: true라면 실제 모양은 대략 이렇습니다.

/bin/bash -lc "npm test"

login: false라면

/bin/bash -c "npm test"

가 됩니다.

exec_command가 npm test를 직접 해석하는 건 아닙니다.

shell에게 그대로 넘깁니다.

실제 프로세스는 ProcessManager가 만듭니다.

여기서 processManager.start()로 넘어갑니다.

파일은 src/process-manager.ts입니다.

처음 부분에서 바로 Node.js의 spawn()이 보입니다.

import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";

그리고 start() 안에서 실제로 호출합니다.

const child = spawn(request.executable, request.args, {
  cwd: request.cwd,
  env: { ...process.env, ...request.env },
  stdio: "pipe",
  detached: process.platform !== "win32",
  windowsHide: true,
});

찾았습니다.

실제로 OS 프로세스를 만드는 곳은 여기였습니다.

flowchart TD; A[exec_command] --> B[ProcessManager.start]; B --> C[node child_process spawn]; C --> D[shell]; D --> E[npm test];

ChatGPT가 직접 명령을 실행하는 것도 아니고

MCP SDK가 대신 실행하는 것도 아닙니다.

cokacremote의 ProcessManager가 Node.js child_process.spawn()을 호출하고,

Node.js가 실제 OS 프로세스를 만듭니다.

workdir는 어디에 적용될까?

workdir도 자주 쓰는 값입니다.

제가 지금 작업 중인 Moon 저장소를 예로 들면

/workspace/moon

같은 경로를 workdir로 넘깁니다.

exec_command에서는 먼저 FileService로 경로를 resolve합니다.

const cwd = fileService.resolve(".", workdir);

그 뒤 spawn()에 그대로 들어갑니다.

cwd: request.cwd,

그래서

cmd: git status
workdir: /workspace/moon

라고 호출하면 실제로는 해당 디렉터리에서 shell이 실행됩니다.

명령 앞에 cd /workspace/moon &&을 붙이는 방식은 아니었습니다.

Node.js 프로세스 생성 옵션의 cwd를 사용합니다.

환경변수도 그대로 합칩니다.

env도 비슷합니다.

env: { ...process.env, ...request.env },

기존 MCP 서버 프로세스의 환경변수를 먼저 복사하고,

Tool 호출에서 받은 env를 뒤에 덮어씁니다.

테스트 코드에서도 이걸 실제로 확인합니다.

const completed = await callOk("exec_command", {
  cmd: "printf '%s\\n' \"$E2E_VALUE\"; pwd; printf 'stderr-ok' >&2; exit 7",
  workdir: testRoot,
  env: { E2E_VALUE: "env-ok" },
  yieldTimeMs: 3000,
});

그리고 결과에서

env-ok

가 출력되는지 확인합니다.

이 테스트 하나로 cwd, 환경변수, stderr, exit code를 같이 확인합니다.

그런데 spawn()하고 바로 끝나지는 않습니다.

프로세스를 만들었으니 이제 출력을 받아야 합니다.

여기서 stdio: "pipe"가 중요합니다.

stdio: "pipe",

stdin, stdout, stderr를 pipe로 연결합니다.

그리고 ProcessManager가 이벤트를 붙입니다.

child.stdout.on("data", (data: Buffer | string) => {
  this.#appendOutput(managed, "stdout", Buffer.from(data));
});

child.stderr.on("data", (data: Buffer | string) => {
  this.#appendOutput(managed, "stderr", Buffer.from(data));
});

명령 실행 중 출력이 생기면 여기로 들어옵니다.

그림으로 보면 이렇습니다.

flowchart LR; A[child process] -->|stdout| B[ProcessManager]; A -->|stderr| B; B --> C[output chunks]; C --> D[read_process];

ChatGPT가 터미널 화면을 직접 보고 있는 건 아닙니다.

ProcessManager가 stdout과 stderr를 받아서 보관하고,

Tool 결과로 돌려줍니다.

stdout과 stderr는 따로 보관합니다.

프로세스 정보를 담는 ManagedProcess를 보면 이런 값들이 있습니다.

interface ManagedProcess {
  sessionId: string;
  child: ChildProcessWithoutNullStreams;
  command: string;
  cwd: string;
  startedAt: number;
  endedAt: number | undefined;
  exitCode: number | null | undefined;
  signal: NodeJS.Signals | null | undefined;
  error: string | undefined;
  timedOut: boolean;
  chunks: OutputChunk[];
  // ...
}

출력은 OutputChunk로 관리합니다.

interface OutputChunk {
  seq: number;
  stream: ProcessOutputStream;
  data: Buffer;
}

여기서 stream 값은 둘 중 하나입니다.

"stdout" | "stderr"

그래서 읽을 때도 따로 조립할 수 있습니다.

const stdout = Buffer.concat(
  selected.filter((chunk) => chunk.stream === "stdout").map((chunk) => chunk.data),
).toString("utf8");

const stderr = Buffer.concat(
  selected.filter((chunk) => chunk.stream === "stderr").map((chunk) => chunk.data),
).toString("utf8");

그리고 순서대로 합친 output도 따로 만듭니다.

const output = Buffer.concat(selected.map((chunk) => chunk.data)).toString("utf8");

실제로 테스트에서도

printf stdout
printf stderr >&2

를 실행해서 둘이 따로 들어오는지 확인합니다.

테스트 결과도 직접 돌려봤는데 정상적으로 통과했습니다.

sessionId는 왜 필요할까?

여기서 sessionId가 나옵니다.

모든 명령이 바로 끝나지는 않습니다.

예를 들어

npm test

정도는 금방 끝날 수도 있지만,

npm run dev

처럼 계속 떠 있는 프로세스도 있습니다.

ProcessManager.start()는 프로세스를 만든 다음 UUID를 하나 생성합니다.

const sessionId = randomUUID();

그리고 내부 Map에 저장합니다.

this.#processes.set(sessionId, managed);

프로세스를 PID만으로 관리하지 않고 MCP Tool에서 다시 찾을 수 있는 sessionId도 붙입니다.

예를 들면 처음 실행 결과가 이런 상태일 수 있습니다.

sessionId: 1234...
running: true
completed: false

ChatGPT는 이 sessionId로 나중에 같은 프로세스를 다시 확인할 수 있습니다.

yieldTimeMs가 재미있습니다.

exec_command에는 yieldTimeMs라는 값이 있습니다.

기본값은 10초입니다.

yieldTimeMs: z
  .number()
  .int()
  .min(0)
  .max(30_000)
  .default(10_000)

프로세스를 시작한 다음 이 값을 사용합니다.

await processManager.waitForExit(sessionId, yieldTimeMs);

쉽게 말하면

이 정도 시간까지는 끝나는지 한번 기다려보자.

입니다.

10초 안에 명령이 끝나면 결과를 바로 돌려줄 수 있습니다.

안 끝나면 프로세스를 죽이는 게 아닙니다.

그대로 실행시킨 채 현재 상태를 반환합니다.

여기서 헷갈리기 쉽습니다.

yieldTimeMs는 timeout이 아닙니다.

flowchart TD; A[exec_command] --> B[process start]; B --> C{yieldTime 안에 종료?}; C -->|YES| D[stdout stderr exitCode 반환]; C -->|NO| E[sessionId + running 반환]; E --> F[프로세스는 계속 실행];

처음에는 저도 yieldTimeMs가 실행 제한 시간인가 싶었습니다.

아니었습니다.

실행 제한은 별도로 timeoutMs가 있습니다.

오래 걸리면 read_process로 다시 봅니다.

프로세스가 계속 실행 중이면 read_process를 호출할 수 있습니다.

src/exec-tools.ts에 별도 Tool로 등록되어 있습니다.

server.registerTool(
  "read_process",
  {
    title: "Read process output",
    description:
      "Poll a managed process for output and terminal state...",

필요한 값은 sessionId입니다.

sessionId
afterSeq
waitMs
maxOutputBytes

내부에서는 결국 이것을 호출합니다.

processManager.read(sessionId, {
  afterSeq,
  waitMs,
  maxOutputBytes,
})

그래서 흐름은 이렇게 됩니다.

exec_command
   ↓
running: true
sessionId: xxx
   ↓
read_process(sessionId)
   ↓
새 stdout / stderr 확인
   ↓
아직 running이면 다시 read_process

이게 1부에서 봤던 stateless 구조와 연결됩니다.

MCP HTTP 요청은 각각 독립적이지만,

ProcessManager는 createServices()에서 만들어져 HTTP 요청 바깥에서 계속 살아 있습니다.

그래서 다음 MCP 요청이 들어와도 같은 sessionId를 찾을 수 있습니다.

이 부분이 생각보다 재미있었습니다.

출력도 한 번에 다 보내지는 않습니다.

명령 출력이 엄청 많으면 어떻게 될까요?

ProcessManager는 출력을 chunk로 나눠 저장합니다.

현재 코드의 chunk 크기는

const OUTPUT_CHUNK_BYTES = 16 * 1024;

16KB입니다.

각 chunk에는 순번이 붙습니다.

managed.chunks.push({
  seq: managed.nextSeq,
  stream,
  data,
});
managed.nextSeq += 1;

그리고 read_process 결과에는 nextSeq가 들어갑니다.

다음 호출에서

afterSeq: 이전 nextSeq

를 보내면 이미 읽은 출력은 빼고 그 이후 출력부터 받습니다.

로그가 긴 작업에서는 이게 꽤 유용합니다.

매번 처음부터 전체 로그를 다시 보내면 토큰도 낭비되고 같은 내용을 계속 읽어야 합니다.

테스트에서도 20,000자의 출력을 만들고 maxOutputBytes: 16384로 제한한 뒤 read_process를 반복 호출해서 전체 출력이 복원되는지 확인합니다.

한글이나 이모지가 중간에 잘리면?

코드를 보다 의외의 부분도 하나 찾았습니다.

UTF-8 처리입니다.

출력을 무조건 16KB에서 딱 잘라버리면 한글이나 이모지의 byte 중간이 끊어질 수 있습니다.

그러면 다음처럼 깨진 문자가 나올 수 있습니다.

�

그래서 splitOutputChunks()에서는 UTF-8 sequence를 확인해서 문자 중간을 피합니다.

테스트도 꽤 노골적입니다.

const expected = `${"a".repeat(16 * 1024 - 1)}😀B`;

딱 chunk 경계에 이모지를 걸쳐놓습니다.

그리고 두 번 나눠 읽은 결과를 합쳐

expect(first.output + second.output).toBe(expected);
expect(first.output + second.output).not.toContain("�");

를 확인합니다.

이 테스트도 직접 실행했고 통과했습니다.

사소해 보이지만 터미널 출력을 다루다 보면 필요한 처리입니다.

write_stdin도 있습니다.

프로세스가 입력을 기다리면 어떻게 해야 할까요?

예를 들어 어떤 프로그램이 실행 중에 값을 입력받는 경우입니다.

그때 사용하는 Tool이 write_stdin입니다.

server.registerTool(
  "write_stdin",
  {
    title: "Write to process stdin",

내부에서는 ProcessManager.write()를 호출합니다.

await processManager.write(sessionId, chars, closeStdin);

실제 write 코드는 우리가 예상하는 그대로입니다.

managed.child.stdin.write(input, (error) => {
  if (error) {
    reject(error);
  } else {
    resolve();
  }
});

closeStdin: true이면 입력 후 stdin도 닫습니다.

managed.child.stdin.end();

테스트에서는 입력을 기다리는 Node 프로세스를 하나 띄우고

interactive-ok

를 write_stdin으로 보내서 그대로 stdout으로 나오는지 확인합니다.

ChatGPT는 명령만 시작하는 게 아니라 실행 중인 프로세스에 입력도 보낼 수 있습니다.

timeout은 별도로 동작합니다.

아까 yieldTimeMs와 timeout은 다르다고 했습니다.

실제 timeout 코드는 ProcessManager.start()에 있습니다.

if (timeoutMs > 0) {
  managed.timeoutHandle = setTimeout(() => {
    managed.timedOut = true;
    managed.error ??= `Process exceeded timeout of ${timeoutMs} ms`;
    this.#signal(managed, "SIGTERM");

시간을 넘기면 먼저 SIGTERM을 보냅니다.

그런데 프로세스가 종료되지 않을 수도 있습니다.

그래서 5초 뒤에도 살아 있으면

if (this.#isRunning(managed)) {
  this.#signal(managed, "SIGKILL");
}

SIGKILL까지 보냅니다.

테스트에서는

sleep 10

을 실행하고 timeout을 50ms로 줍니다.

결과에서

timedOut: true

가 되는지 확인합니다.

이것도 실제 테스트를 돌려서 통과하는 것을 확인했습니다.

프로세스를 직접 종료할 수도 있습니다.

계속 실행 중인 개발 서버를 이제 그만 끄고 싶을 수도 있습니다.

그때 terminate_process를 사용합니다.

server.registerTool(
  "terminate_process",

지원하는 signal은 세 가지입니다.

SIGINT
SIGTERM
SIGKILL

기본값은 SIGTERM입니다.

여기서 하나 더 봤습니다.

Linux나 macOS 계열에서 프로세스를 시작할 때

detached: process.platform !== "win32",

로 실행합니다.

그리고 종료할 때는

process.kill(-pid, signal);

처럼 PID 앞에 음수를 붙입니다.

Unix 계열에서 이렇게 호출하면 해당 프로세스 그룹에 signal을 보낼 수 있습니다.

shell이 child process를 또 만들었을 때 shell만 죽고 자식이 남는 일을 줄이려는 구조입니다.

Windows에서는 다르게 처리합니다.

managed.child.kill(signal);

제가 사용하는 환경은 Mac에서 Docker로 실행하고 있으니 실제 cokacremote 프로세스가 보는 OS는 Docker 컨테이너 쪽 Linux입니다.

따라서 이 환경에서는 Unix 쪽 로직을 탑니다.

list_processes로 현재 작업도 볼 수 있습니다.

관리 중인 프로세스 목록을 보는 Tool도 있습니다.

server.registerTool(
  "list_processes",

결과에는 이런 값이 들어갑니다.

sessionId
pid
command
cwd
running
startedAt
endedAt
exitCode

그래서 ChatGPT가 예전에 실행한 명령 중 아직 살아 있는 프로세스가 있는지 확인할 수 있습니다.

integration test에서도 무한 실행되는 Node 프로세스를 하나 만들고

cmd: "node -e \"setInterval(() => {}, 1000)\""

list_processes에서 running: true인지 확인합니다.

그 다음 terminate_process로 SIGTERM을 보내고,

마지막 read_process에서

running: false
completed: true
signal: SIGTERM

이 되는 것까지 확인합니다.

끝난 프로세스도 바로 사라지지는 않습니다.

프로세스가 끝나도 sessionId 정보를 바로 지우지는 않습니다.

ProcessManager에는 retention 시간이 있습니다.

기본값은

processRetentionMs: 60 * 60 * 1000

1시간입니다.

끝난 프로세스는 일정 시간 동안 남아 있어서 나중에 결과를 다시 읽을 수 있습니다.

그리고 prune()에서 오래된 프로세스를 정리합니다.

if (managed.endedAt !== undefined && managed.endedAt < cutoff) {
  this.#processes.delete(sessionId);
}

프로세스 개수에도 제한이 있습니다.

기본 maxProcesses는 128입니다.

출력을 계속 무한정 메모리에 들고 있는 것도 아닙니다.

기본 최대 retained output은 4MB입니다.

한도를 넘어가면 오래된 chunk부터 제거하고

managed.droppedOutputBytes += removed.data.length;

얼마나 버렸는지도 기록합니다.

제가 쓰는 환경에서는 어디서 실행될까?

이제 제가 쓰는 환경에 넣어보면 더 이해하기 쉽습니다.

제 환경은 이렇습니다.

flowchart TD; A[ChatGPT] --> B[nginx proxy]; B --> C[Mac]; C --> D[Docker]; D --> E[cokacremote MCP]; E --> F[workspace];

여기서 제가 ChatGPT에게

/workspace/moon에서 git status 확인해줘.

라고 하면 실제 흐름은 대략 이렇게 됩니다.

flowchart TD; A[ChatGPT] --> B[exec_command]; B --> C[workdir /workspace/moon]; C --> D[ProcessManager.start]; D --> E[spawn /bin/bash -lc]; E --> F[git status]; F --> G[Docker container process]; G --> H[stdout / stderr]; H --> I[ChatGPT];

여기서 하나 구분해야 합니다.

제 Mac에 명령이 바로 생기는 것은 아닙니다.

제가 구성한 환경에서는 cokacremote가 Docker 컨테이너 안에서 동작하고,

/workspace가 컨테이너에 연결되어 있습니다.

그래서 실제 spawn()도 컨테이너 안에서 일어납니다.

다만 /workspace/moon이 Mac의 실제 프로젝트와 volume으로 연결되어 있으니 파일 변경은 Mac 쪽 프로젝트에도 그대로 보입니다.

여기까지가 cokacremote의 명령 실행 구조와 제가 붙인 Docker 구성의 경계입니다.

실제 테스트도 돌려봤습니다.

소스만 보고 끝내기는 아쉬워서 관련 테스트도 직접 돌렸습니다.

실행한 테스트는

test/process-manager.test.ts
test/mcp.integration.test.ts

두 파일입니다.

결과는

Test Files  2 passed (2)
Tests       10 passed (10)

였습니다.

여기에는

stdout / stderr 수집
interactive stdin
process timeout
UTF-8 chunk 처리
MCP exec_command
read_process
stateless MCP 요청 간 process 유지

같은 내용이 포함되어 있습니다.

이번 글의 핵심 흐름은 코드만 보고 추측한 게 아니라 테스트까지 확인한 내용입니다.

여기까지 보고 나니

처음 궁금했던 건 단순했습니다.

AI가 npm test를 실행한다는 게 정확히 뭐지?

코드를 따라가 보니 이렇게 흘렀습니다.

flowchart TD; A[ChatGPT] --> B[MCP exec_command]; B --> C[ProcessManager]; C --> D[Node.js spawn]; D --> E[Shell]; E --> F[실제 명령]; F --> G[stdout stderr exitCode]; G --> H[ProcessManager]; H --> I[ChatGPT];

명령이 빨리 끝나면 결과를 바로 돌려줍니다.

오래 걸리면 sessionId를 돌려주고 계속 실행합니다.

출력은 read_process로 이어서 읽고,

입력이 필요하면 write_stdin,

목록은 list_processes,

종료하려면 terminate_process를 사용합니다.

막연하게 “AI가 터미널을 조작한다”고 생각했는데, 까보니 훨씬 평범했습니다.

결국 shell process를 만들고 stdin/stdout/stderr를 연결해서 상태를 저장합니다.

이걸 MCP Tool로 감싸서 ChatGPT가 쓸 수 있게 만든 겁니다.

그럼 다음은 파일 수정입니다.

명령 실행은 그렇다 치고,

AI가 코드를 수정할 때는 왜 파일 전체를 다시 쓰지 않고 apply_patch를 자주 사용할까?

cokacremote 소스를 보면 apply_patch는 그냥 이름만 patch인 Tool이 아니었습니다.

실제로 git apply를 사용합니다.

거기에 --unsafe-paths도 들어갑니다.

3부에서는 이 부분을 제대로 까보겠습니다.


시리즈

  • Codex 토큰을 다 썼다. 그래서 웹을 써봤습니다.
  • cokacremote 톺아보기 1부 — MCP Server는 어떻게 시작될까?
  • cokacremote 톺아보기 2부 — AI가 명령을 실행한다는 것
  • cokacremote 톺아보기 3부 — AI는 왜 apply_patch를 사용할까?

참고

  • cokacremote
  • 분석 기준 commit: 1bb0f767c4249e8536bcee98341808bebcbbebaa


aichatgptmcpcokacremote Share Tweet +1