챗GPT 웹(Plus)을 자기 맥미니의 격리 샌드박스에 연결해 원격 바이브코딩 환경을 만드는, Claude Code에 통째로 붙여넣는 자립형 가이드입니다.
claude).필요한 것: ChatGPT Plus 이상 구독, Apple Silicon 맥미니, 관리자 권한. 소요 시간 약 30분~1시간.
# 챗GPT 원격 바이브코딩 샌드박스 — 맥미니 셋업 가이드 (자립형)
## 0. 받은 Claude에게
너는 지금 이 문서 하나만 가지고 어떤 사람의 맥미니에 "챗GPT 웹 → MCP 커넥터 → Docker 격리 컨테이너 → 공개 웹앱"으로 이어지는 원격 바이브코딩 샌드박스를 통째로 구축해야 한다. 이 문서 밖의 사전 지식이나 다른 서버 정보는 필요 없다 — 여기 적힌 것이 전부다.
원칙:
- **사용자(맥미니 주인)는 비개발자일 수 있다.** 앱 설치·로그인·챗GPT 웹 화면 조작처럼 GUI·계정이 필요한 단계는 사용자가 직접 해야 하니, 무엇을 어디서 클릭하는지 쉬운 말로 안내하고 기다려라.
- **그 외 나머지는 네가 직접 실행한다.** 파일 생성, 빌드, 컨테이너 기동, curl 검증 등은 네가 터미널로 직접 해라. 사용자에게 "이 명령어를 실행해보세요"라고 떠넘기지 마라.
- **각 단계 끝에는 검증 절차가 있다.** 검증을 통과한 뒤에만 다음 단계로 넘어가라. 실패하면 추측하지 말고 로그·응답을 실제로 확인해서 원인을 좁혀라.
- 아래 각 파일은 그대로 복사해서 쓰면 동작하도록 이미 검증된 구조를 각색한 것이다. 임의로 축약하거나 "더 나은 방식"으로 바꾸지 마라 — 특히 보안(비밀 경로), 영속성(bind mount, pm2 save), 프록시 순서(express.json 부착 위치) 관련 부분은 이유가 있어서 이렇게 되어 있다. 이유는 9장 함정·트러블슈팅에 설명해 놓았다.
---
## 1. 아키텍처 개요
```
[챗GPT 웹 (Plus 이상, 개발자 모드 커넥터)]
│ HTTPS · MCP Streamable HTTP
▼
[Tailscale Funnel] https://<이 맥미니의 tailnet 도메인>.ts.net
│ (경로를 건드리지 않고 그대로 전달 — 프리픽스를 벗기지 않음)
▼
[맥미니 : OrbStack/Docker 컨테이너 "gpt-sandbox", 127.0.0.1:8931]
├─ POST/GET/DELETE /<MCP_SECRET>/mcp → MCP 서버
│ 도구 6개: list_dir · read_file · write_file ·
│ run_command · deploy_app · app_status
├─ GET /health → 헬스체크 (공개, 비민감)
└─ /apps/<이름>/* → 1순위: 정적 파일(index.html 등) 서빙
2순위: .apps.json 레지스트리 조회 →
컨테이너 안 127.0.0.1:<8940~8999>로 내부 프록시
▲
└─ pm2가 상시 구동 중인 백엔드
│
▼
/workspace (호스트 bind mount: ~/gpt-sandbox/workspace)
컨테이너를 지우고 다시 만들어도 이 폴더 안 파일·pm2 상태·git 이력은 그대로 남는다.
```
왜 이런 구조인가:
- 챗GPT Plus의 넉넉한 대화 사용량으로 "코드를 쓰고 실행해보는" 노동을 떠넘기고, 실제 파일쓰기·명령실행은 맥미니 본체가 아니라 격리된 Docker 컨테이너 안에서만 일어나게 해서 사고 범위를 컨테이너 안으로 제한한다.
- 공개 노출은 Caddy·도메인 구매·포트포워딩 없이 Tailscale Funnel 하나로 끝낸다.
- 챗GPT 개발자 모드 커넥터는 Bearer 토큰 입력칸이 없다(인증 없음 또는 OAuth뿐). 그래서 유일한 방어선이 "추측 불가능한 URL 경로"이고, 이 경로가 항상 서버와 함께 움직이도록 서버 코드 안에 박아 넣었다(원본은 별도 리버스 프록시가 경로를 가려줬지만, Funnel은 경로를 건드리지 않으므로 이 구조에서는 서버 자신이 비밀 경로를 알아야 한다).
---
## 2. 사전 확인
아래 하나씩 감지하고, 없으면 안내한 대로 사용자에게 요청하거나(수동 단계) 네가 직접 설치해라.
### 2-1. macOS · CPU 아키텍처
```bash
sw_vers -productVersion
uname -m
```
- `uname -m`이 `arm64`면 Apple Silicon(정상 경로). `x86_64`(인텔 맥)면 이 가이드는 대체로 동작하지만 검증된 조합은 아니다 — 진행은 가능하다.
### 2-2. Homebrew
```bash
command -v brew || echo "Homebrew 없음"
```
- 없으면 **[사용자 수동]** 터미널에 아래를 직접 입력하도록 안내한다(관리자 비번 입력이 필요해 네가 대신 실행할 수 없다):
```
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
설치가 끝나면 터미널을 새로 열거나 안내된 `eval "$(/opt/homebrew/bin/brew shellenv)"` 줄을 실행하게 하고, `brew -v`로 확인 후 다음으로 넘어간다.
### 2-3. OrbStack 또는 Docker Desktop
```bash
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 && echo "docker 데몬 동작 중" || echo "docker 없음/미기동"
```
- 없으면 **OrbStack을 권장**한다(가볍고 개인용 무료):
```bash
brew install --cask orbstack
```
설치 후 **[사용자 수동]** OrbStack.app을 한 번 직접 열어서 초기 설정(헬퍼 설치 권한 요청)을 완료해야 `docker` 명령이 잡힌다. "로그인 시 자동 시작"이 켜져 있는지도 이때 확인한다(기본값이 켜져 있는 경우가 많음).
- 대안: `brew install --cask docker`(Docker Desktop) — 더 무겁고, 개인용도 Docker Hub 계정 로그인이 한 번 필요하다.
- 완료되면 다시 `docker info`가 정상 출력되는지 확인한다.
### 2-4. Tailscale
```bash
command -v tailscale >/dev/null 2>&1 && tailscale status >/dev/null 2>&1 && echo "tailscale 연결됨" || echo "tailscale 없음/미로그인"
```
- 없으면:
```bash
brew install --cask tailscale
```
- **[사용자 수동]** Tailscale.app을 열어 로그인(브라우저 팝업으로 구글/깃허브/이메일 중 하나 선택 — 이 맥미니를 사용자의 tailnet에 연결). 로그인 완료 후 `tailscale status`에 이 기기가 뜨는지 확인한다.
### 2-5. ChatGPT 구독 등급
- 커맨드로 확인할 수 없다. **[사용자 수동]** 확인만 요청한다: Plus / Pro / Business / Enterprise / Edu 중 하나여야 "개발자 모드" 커넥터 기능이 열린다(무료 등급은 불가).
모두 통과했으면 3장으로 넘어간다.
---
## 3. 파일 생성
작업 폴더는 `~/gpt-sandbox` 하나로 통일한다. 먼저 폴더를 만든다.
```bash
mkdir -p ~/gpt-sandbox/workspace
```
아래 5개 파일은 `~/gpt-sandbox/` 바로 아래(빌드 컨텍스트 루트)에, 나머지 2개(`AGENTS.md`, `README-앱규칙.md`)는 `~/gpt-sandbox/workspace/` 안에 그대로 저장한다. 내용은 전부 그대로 복사하면 동작한다.
### 3-1. `~/gpt-sandbox/server.js`
원본과 기능은 동일(도구 6개, `/apps` 정적서빙+프록시, `.apps.json` 레지스트리, SSE 세션 관리, CORS)하고, 딱 두 가지만 바뀌었다: ① MCP 라우트가 `/mcp`가 아니라 환경변수 `MCP_SECRET`으로 만든 `/<비밀경로>/mcp`에 마운트된다. ② `deploy_app`이 안내하는 공개 URL이 하드코딩된 도메인이 아니라 환경변수 `PUBLIC_BASE_URL`을 쓴다.
```javascript
import express from "express";
import cors from "cors";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import http from "node:http";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
const execAsync = promisify(exec);
const WORKSPACE = "/workspace";
const PORT = 8931;
// --- 필수 환경변수 검증 ---
// MCP_SECRET이 없으면 "누구나 접근 가능한 /mcp"로 뜨는 사고를 막기 위해 서버를 바로 죽인다.
const MCP_SECRET = process.env.MCP_SECRET;
if (!MCP_SECRET || MCP_SECRET.length < 16) {
console.error(
"[FATAL] MCP_SECRET 환경변수가 없거나 너무 짧습니다. .env를 확인하세요 (run.sh가 최초 1회 자동 생성합니다)."
);
process.exit(1);
}
const MCP_PATH = `/${MCP_SECRET}/mcp`;
// 공개 베이스 URL. deploy_app이 알려주는 앱 URL에 쓰인다. 미설정이어도 서버는 뜨되 경고만 남긴다
// (Tailscale Funnel을 아직 안 켰다면 처음엔 비어 있는 게 정상 — 5단계 이후 .env에 채운다).
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || "").replace(/\/+$/, "");
if (!PUBLIC_BASE_URL) {
console.warn("[WARN] PUBLIC_BASE_URL 미설정 — deploy_app이 안내하는 공개 URL이 비어 보일 수 있습니다.");
}
// 경로 안전장치: 항상 /workspace 안으로만 해석되도록 강제
function safePath(p) {
const resolved = path.resolve(WORKSPACE, p ?? ".");
if (resolved !== WORKSPACE && !resolved.startsWith(WORKSPACE + path.sep)) {
throw new Error(`경로가 작업공간(${WORKSPACE}) 밖입니다: ${p}`);
}
return resolved;
}
// 백엔드 앱 레지스트리: /workspace/.apps.json = {"이름": 포트}
// deploy_app 도구가 쓰고, /apps/<이름>/* 프록시가 읽는다.
const REGISTRY_PATH = path.join(WORKSPACE, ".apps.json");
let registryCache = null;
let registryCacheAt = 0;
async function loadRegistry() {
const now = Date.now();
if (registryCache && now - registryCacheAt < 2000) return registryCache;
try {
const raw = await fs.readFile(REGISTRY_PATH, "utf8");
registryCache = JSON.parse(raw);
} catch {
registryCache = {};
}
registryCacheAt = now;
return registryCache;
}
async function saveRegistry(reg) {
await fs.writeFile(REGISTRY_PATH, JSON.stringify(reg, null, 2), "utf8");
registryCache = reg;
registryCacheAt = Date.now();
}
// 배포 직후 앱이 실제로 포트를 열었는지 짧게 재시도하며 확인 (5xx여도 연결만 되면 OK)
function waitForPort(port, timeoutMs = 5000) {
return new Promise((resolve) => {
const deadline = Date.now() + timeoutMs;
const attempt = () => {
const req = http.get({ host: "127.0.0.1", port, path: "/", timeout: 1000 }, (res) => {
res.resume();
resolve(true);
});
req.on("error", () => {
if (Date.now() >= deadline) return resolve(false);
setTimeout(attempt, 500);
});
req.on("timeout", () => {
req.destroy();
if (Date.now() >= deadline) return resolve(false);
setTimeout(attempt, 500);
});
};
attempt();
});
}
function buildServer() {
const server = new McpServer({ name: "gpt-sandbox", version: "1.0.0" });
server.registerTool(
"list_dir",
{
title: "디렉토리 목록",
description:
"Use this to list files and folders in a directory inside the sandbox workspace. Path is relative to the workspace root.",
inputSchema: { path: z.string().optional().describe("workspace 기준 상대경로 (기본: 루트)") },
annotations: { readOnlyHint: true },
},
async ({ path: p }) => {
const dir = safePath(p);
const entries = await fs.readdir(dir, { withFileTypes: true });
const lines = entries
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => (e.isDirectory() ? "d " : "- ") + e.name);
return { content: [{ type: "text", text: lines.join("\n") || "(빈 디렉토리)" }] };
}
);
server.registerTool(
"read_file",
{
title: "파일 읽기",
description:
"Use this to read the contents of a text file inside the sandbox workspace. Path is relative to the workspace root.",
inputSchema: { path: z.string().describe("workspace 기준 상대 파일경로") },
annotations: { readOnlyHint: true },
},
async ({ path: p }) => {
const file = safePath(p);
const text = await fs.readFile(file, "utf8");
return { content: [{ type: "text", text: text.slice(0, 100000) }] };
}
);
server.registerTool(
"write_file",
{
title: "파일 쓰기",
description:
"Use this to create or overwrite a text file inside the sandbox workspace. Path is relative to the workspace root. This modifies files on disk.",
inputSchema: { path: z.string(), content: z.string() },
},
async ({ path: p, content }) => {
const file = safePath(p);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, content, "utf8");
return { content: [{ type: "text", text: `저장 완료: ${p} (${Buffer.byteLength(content)} bytes)` }] };
}
);
server.registerTool(
"run_command",
{
title: "명령 실행",
description:
"Use this to run a bash shell command inside the sandbox workspace (working directory is the workspace root). Returns stdout and stderr. Use it for building, running tests, git operations, and installing packages. This executes code.",
inputSchema: { command: z.string().describe("실행할 bash 명령") },
},
async ({ command }) => {
try {
const { stdout, stderr } = await execAsync(command, {
cwd: WORKSPACE,
timeout: 300000,
maxBuffer: 4 * 1024 * 1024,
shell: "/bin/bash",
});
const out =
[stdout && `[stdout]\n${stdout}`, stderr && `[stderr]\n${stderr}`]
.filter(Boolean)
.join("\n") || "(출력 없음)";
return { content: [{ type: "text", text: out.slice(0, 60000) }] };
} catch (err) {
const out = [
err.stdout && `[stdout]\n${err.stdout}`,
err.stderr && `[stderr]\n${err.stderr}`,
`[error] ${err.message}`,
]
.filter(Boolean)
.join("\n");
return { content: [{ type: "text", text: out.slice(0, 60000) }], isError: true };
}
}
);
server.registerTool(
"deploy_app",
{
title: "앱 배포 (pm2 등록)",
description:
"Use this to deploy or redeploy a backend app: registers it with pm2, persists it (pm2 save) so it survives container restarts, records it in the .apps.json registry so it becomes reachable at /apps/<name>/, and commits the workspace to git. Always use this instead of running pm2 manually — manual pm2 calls skip the registry and git history.",
inputSchema: {
name: z
.string()
.regex(/^[a-z0-9-]+$/)
.describe("앱 이름 (영소문자/숫자/하이픈만) — 폴더명이자 공개 URL 경로 /apps/<name>/ 이 됨"),
port: z.number().int().min(8940).max(8999).describe("바인딩할 포트 (8940~8999 범위만 허용)"),
script: z.string().describe("앱 폴더(/workspace/<name>/) 기준 상대 경로의 실행 스크립트, 예: app.py, server.js"),
interpreter: z.string().optional().describe("인터프리터 (예: python3). Node 앱이면 생략"),
},
},
async ({ name, port, script, interpreter }) => {
const appDir = safePath(name);
const scriptPath = safePath(path.join(name, script));
const dirStat = await fs.stat(appDir).catch(() => null);
if (!dirStat || !dirStat.isDirectory()) {
throw new Error(`앱 폴더가 없습니다: /workspace/${name}/`);
}
const scriptOk = await fs
.access(scriptPath)
.then(() => true)
.catch(() => false);
if (!scriptOk) {
throw new Error(`스크립트가 없습니다: ${name}/${script}`);
}
const registry = { ...(await loadRegistry()) };
const conflict = Object.entries(registry).find(([n, p]) => n !== name && p === port);
if (conflict) {
throw new Error(`포트 충돌: ${port}는 이미 '${conflict[0]}' 앱이 사용 중입니다. .apps.json을 확인하세요.`);
}
// 같은 이름의 기존 프로세스가 있으면 정리 후 재시작
await execAsync(`pm2 delete ${JSON.stringify(name)}`, {
cwd: WORKSPACE,
timeout: 30000,
shell: "/bin/bash",
}).catch(() => {});
const interpFlag = interpreter ? ` --interpreter ${JSON.stringify(interpreter)}` : "";
const startCmd = `pm2 start ${JSON.stringify(script)} --name ${JSON.stringify(name)} --cwd ${JSON.stringify(
appDir
)}${interpFlag}`;
let startOut;
try {
startOut = await execAsync(startCmd, { cwd: WORKSPACE, timeout: 60000, shell: "/bin/bash" });
} catch (err) {
throw new Error(`pm2 start 실패:\n${err.stderr || err.stdout || err.message}`);
}
let saveOk = true;
try {
await execAsync("pm2 save", { cwd: WORKSPACE, timeout: 30000, shell: "/bin/bash" });
} catch {
saveOk = false;
}
registry[name] = port;
await saveRegistry(registry);
const healthy = await waitForPort(port, 5000);
let commitLine = "git 저장소 아님 (커밋 건너뜀)";
const isRepo = await fs
.access(path.join(WORKSPACE, ".git"))
.then(() => true)
.catch(() => false);
if (isRepo) {
try {
await execAsync("git add -A", { cwd: WORKSPACE, timeout: 30000, shell: "/bin/bash" });
const { stdout: statusOut } = await execAsync("git status --porcelain", {
cwd: WORKSPACE,
timeout: 30000,
shell: "/bin/bash",
});
if (statusOut.trim()) {
await execAsync(`git commit -m ${JSON.stringify(`deploy: ${name}`)}`, {
cwd: WORKSPACE,
timeout: 30000,
shell: "/bin/bash",
});
commitLine = `git 커밋 완료 (deploy: ${name})`;
} else {
commitLine = "변경 없음 (커밋 건너뜀)";
}
} catch (err) {
commitLine = `git 커밋 실패(무시하고 배포는 진행됨): ${err.message}`;
}
}
const url = `${PUBLIC_BASE_URL || "(공개 URL 미설정 — .env의 PUBLIC_BASE_URL을 확인하세요)"}/apps/${name}/`;
const lines = [
`배포 완료: ${name} (포트 ${port})`,
`pm2 start: ${(startOut && startOut.stdout && startOut.stdout.trim()) || "(출력 없음, app_status로 확인)"}`,
`pm2 save: ${saveOk ? "성공 — 컨테이너 재시작에도 살아남음" : "실패 — 재시작 시 프로세스가 사라질 수 있음"}`,
`헬스체크(127.0.0.1:${port}): ${
healthy ? "응답함" : "5초 내 무응답 — 아직 뜨는 중일 수 있음, app_status로 로그 확인"
}`,
commitLine,
`공개 URL: ${url}`,
];
return { content: [{ type: "text", text: lines.join("\n") }], isError: !healthy };
}
);
server.registerTool(
"app_status",
{
title: "앱 상태 확인",
description:
"Use this to check the status of pm2-managed backend apps (running/stopped, restarts, uptime). Pass a name to also see its recent logs — useful when a deploy_app health check failed.",
inputSchema: { name: z.string().optional().describe("특정 앱 이름 (생략하면 전체 목록)") },
annotations: { readOnlyHint: true },
},
async ({ name }) => {
let jlist;
try {
const { stdout } = await execAsync("pm2 jlist", { cwd: WORKSPACE, timeout: 15000, shell: "/bin/bash" });
jlist = JSON.parse(stdout);
} catch (err) {
return { content: [{ type: "text", text: `pm2 jlist 실패: ${err.message}` }], isError: true };
}
const rows = jlist
.filter((p) => !name || p.name === name)
.map((p) => {
const uptimeMs = p.pm2_env && p.pm2_env.pm_uptime ? Date.now() - p.pm2_env.pm_uptime : null;
const uptimeStr = uptimeMs != null ? `${Math.round(uptimeMs / 1000)}s` : "-";
return `${p.name}\tstatus=${p.pm2_env && p.pm2_env.status}\tpid=${p.pid ?? "-"}\trestarts=${
(p.pm2_env && p.pm2_env.restart_time) ?? 0
}\tuptime=${uptimeStr}`;
});
let text = rows.length ? rows.join("\n") : "(등록된 pm2 프로세스 없음)";
if (name) {
try {
const { stdout } = await execAsync(`pm2 logs ${JSON.stringify(name)} --nostream --lines 30`, {
cwd: WORKSPACE,
timeout: 15000,
shell: "/bin/bash",
});
text += `\n\n--- 최근 로그 (${name}) ---\n${stdout.slice(-4000)}`;
} catch (err) {
text += `\n\n로그 조회 실패: ${err.message}`;
}
}
return { content: [{ type: "text", text }] };
}
);
return server;
}
const app = express();
app.use(
cors({
origin: true,
exposedHeaders: ["Mcp-Session-Id"],
allowedHeaders: ["Content-Type", "mcp-session-id", "MCP-Protocol-Version", "Authorization", "Accept"],
methods: ["GET", "POST", "DELETE", "OPTIONS"],
})
);
// 세션별 transport 저장소 (stateful Streamable HTTP)
const transports = {};
// ★ MCP 라우트는 비밀 경로에만 마운트된다. express.json()은 이 라우트에만 붙인다 —
// 전역에 걸면 아래 /apps 프록시로 가는 요청의 body 스트림을 이 미들웨어가 먼저 소비해버려서
// 백엔드로 POST가 전달되지 않는 사고가 난다 (9장 함정 참고).
app.post(MCP_PATH, express.json({ limit: "8mb" }), async (req, res) => {
const sessionId = req.headers["mcp-session-id"];
let transport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
transports[sid] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) delete transports[transport.sessionId];
};
const server = buildServer();
await server.connect(transport);
} else {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Bad Request: no valid session" },
id: null,
});
return;
}
await transport.handleRequest(req, res, req.body);
});
const handleSessionRequest = async (req, res) => {
const sessionId = req.headers["mcp-session-id"];
if (!sessionId || !transports[sessionId]) {
res.status(400).send("Invalid or missing session ID");
return;
}
await transports[sessionId].handleRequest(req, res);
};
app.get(MCP_PATH, handleSessionRequest);
app.delete(MCP_PATH, handleSessionRequest);
app.get("/health", (_req, res) => res.json({ ok: true, service: "gpt-sandbox-mcp" }));
// 정적 웹앱 공개 서빙: 컨테이너 안 /workspace 를 /apps/* 로 노출.
// 예) /workspace/netflix-now/index.html -> /apps/netflix-now/
// 디렉토리 리스팅 없음(index.html 없으면 404), dotfile(.git 등) 차단.
app.use(
"/apps",
express.static(WORKSPACE, { index: "index.html", dotfiles: "deny", fallthrough: true })
);
// 백엔드 앱 프록시: /workspace/.apps.json = {"이름": 포트} 레지스트리(위에서 정의)를 보고
// /apps/<이름>/<나머지경로> 를 컨테이너 안 127.0.0.1:<포트> 로 그대로 넘긴다.
// 정적 파일(위 express.static)에 없는 경로만 여기까지 내려온다.
app.use("/apps", async (req, res) => {
// req.url 은 /apps 프리픽스가 벗겨진 상태 (예: "/demo-api/api/items?x=1")
const m = /^\/([^/]+)(\/.*)?$/.exec(req.url);
if (!m) return res.status(404).json({ error: "not found" });
const [, name, rest] = m;
const registry = await loadRegistry();
const port = registry[name];
if (!Number.isInteger(port) || port < 8900 || port > 8999) {
return res.status(404).json({ error: `등록되지 않은 앱: ${name}` });
}
const targetPath = rest || "/";
const headers = { ...req.headers, host: `127.0.0.1:${port}` };
const proxyReq = http.request(
{ host: "127.0.0.1", port, path: targetPath, method: req.method, headers },
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
proxyRes.pipe(res);
}
);
proxyReq.on("error", (err) => {
if (!res.headersSent) {
res.status(502).json({ error: "backend unreachable", detail: err.message });
} else {
res.end();
}
});
req.pipe(proxyReq);
});
app.listen(PORT, "0.0.0.0", () => {
console.log(`gpt-sandbox MCP listening on :${PORT}`);
console.log(`MCP endpoint (secret path): ${MCP_PATH}`);
console.log(`PUBLIC_BASE_URL: ${PUBLIC_BASE_URL || "(미설정)"}`);
});
```
### 3-2. `~/gpt-sandbox/Dockerfile`
원본과 동일(각색 불필요). `node:22-bookworm`은 arm64 이미지도 제공하므로 Apple Silicon에서 에뮬레이션 없이 네이티브로 빌드·구동된다.
```dockerfile
FROM node:22-bookworm
# 샌드박스 안에서 바이브코딩에 필요한 기본 개발도구
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv git curl ca-certificates build-essential sqlite3 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g pm2
# /workspace는 host bind mount라 소유권이 container root와 달라 git이
# "dubious ownership"으로 거부한다. 이미지에 박아둬야 컨테이너 재생성 후에도 유지된다.
RUN git config --global --add safe.directory /workspace
WORKDIR /app
COPY package.json ./
RUN npm install
COPY server.js ./
COPY start.sh ./
RUN mkdir -p /workspace
EXPOSE 8931
# pm2 상태를 bind mount(/workspace) 안에 둬서 컨테이너를 재생성해도
# 챗GPT가 띄운 백엔드 프로세스를 resurrect로 되살릴 수 있게 한다.
ENV PM2_HOME=/workspace/.pm2
CMD ["/bin/bash", "/app/start.sh"]
```
### 3-3. `~/gpt-sandbox/package.json`
원본과 동일.
```json
{
"name": "gpt-sandbox-mcp",
"version": "1.0.0",
"type": "module",
"private": true,
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"zod": "^3.23.8"
}
}
```
### 3-4. `~/gpt-sandbox/run.sh`
맥용으로 각색: 경로 `$HOME/gpt-sandbox`, `sudo` 제거(OrbStack/Docker Desktop은 사용자 권한으로 도커 데몬에 접근), `.env` 최초 1회 생성(`MCP_SECRET`은 `openssl rand -hex 16`으로 자동 생성, `PUBLIC_BASE_URL`은 처음엔 빈 채로 두고 5단계에서 Funnel 도메인을 확정한 뒤 채운다), 127.0.0.1:8931 바인딩, `--restart unless-stopped`, 메모리 2g·CPU 1.5 제한 유지.
```bash
#!/usr/bin/env bash
# gpt-sandbox 빌드 + 기동 스크립트 (맥미니용)
# 사용법: cd ~/gpt-sandbox && bash run.sh
set -euo pipefail
BASE="$HOME/gpt-sandbox"
mkdir -p "$BASE/workspace"
cd "$BASE"
# --- .env 최초 생성 (이후 재실행 시 재사용) ---
# MCP_SECRET : 챗GPT 커넥터 URL에 들어갈 추측 불가능한 비밀 경로. 한 번 만든 뒤 바꾸지 않는다.
# PUBLIC_BASE_URL : Tailscale Funnel 공개 도메인(https://...ts.net). Funnel을 아직 안 켰다면
# 비워둔 채로 진행해도 된다 — 5단계(Funnel 노출)를 마친 뒤 이 파일을 열어
# 실제 주소를 채우고 `bash run.sh`를 다시 실행하면 반영된다.
if [ ! -f "$BASE/.env" ]; then
echo "[.env] 최초 실행 — MCP_SECRET을 생성합니다."
SECRET=$(openssl rand -hex 16)
cat > "$BASE/.env" <<EOF
MCP_SECRET=${SECRET}
PUBLIC_BASE_URL=
EOF
echo " 생성됨: $BASE/.env"
else
echo "[.env] 기존 파일 재사용: $BASE/.env"
fi
echo "[1/3] docker build..."
docker build -t gpt-sandbox:latest "$BASE"
echo "[2/3] restart container..."
docker rm -f gpt-sandbox >/dev/null 2>&1 || true
docker run -d --name gpt-sandbox \
--restart unless-stopped \
--memory=2g --cpus=1.5 \
-p 127.0.0.1:8931:8931 \
-v "$BASE/workspace:/workspace" \
--env-file "$BASE/.env" \
gpt-sandbox:latest
echo "[3/3] health check..."
sleep 3
curl -sf http://127.0.0.1:8931/health && echo " <- health OK" || echo "HEALTH FAILED — docker logs gpt-sandbox 로 확인하세요"
docker ps --filter name=gpt-sandbox --format "{{.Names}} {{.Status}}"
echo
echo "MCP 비밀 경로 : $(grep '^MCP_SECRET=' "$BASE/.env" | cut -d= -f2)"
PUB=$(grep '^PUBLIC_BASE_URL=' "$BASE/.env" | cut -d= -f2)
if [ -z "$PUB" ]; then
echo "공개 URL 베이스 : (아직 미설정 — 5단계에서 Funnel을 켠 뒤 .env에 채우고 재실행하세요)"
else
echo "공개 URL 베이스 : $PUB"
fi
```
맥미니 램이 8GB인 모델이라면 `--memory=2g --cpus=1.5`가 부담스러울 수 있다 — 이 경우 `--memory=1g --cpus=1.0` 정도로 낮춰도 된다.
### 3-5. `~/gpt-sandbox/start.sh`
원본과 동일.
```bash
#!/bin/bash
# PM2_HOME=/workspace/.pm2 이므로, 이전에 저장된 프로세스 목록이 있으면 되살린다.
pm2 resurrect 2>/dev/null || true
exec node /app/server.js
```
### 3-6. `~/gpt-sandbox/workspace/AGENTS.md`
챗GPT(샌드박스 안에서 일하는 쪽)를 위한 작업 지침서. 공개 URL 예시를 실제 도메인이 아닌 플레이스홀더로 바꾸고, HC 개인 인프라 전용이었던 golden-knowledge 참조 섹션은 뺐다.
```markdown
# 이 샌드박스에서 일하는 방법 (하네스)
너는 이 컨테이너의 `/workspace` 안에서만 작업하는 개발자다. 매 작업마다 아래 루프를 따른다.
## 작업 루프
1. **계획 선언** — 무엇을 만들지/고칠지 한 줄로 먼저 말한다.
2. **구현** — `write_file`로 코드를 쓴다.
3. **자체 검증 (필수, 생략 금지)** — "완료"라고 말하기 전에 반드시 확인한다:
- 백엔드가 있으면 `run_command`로 `curl -s http://127.0.0.1:<포트>/api/...` 를 실제로 호출해 응답이 맞는지 본다.
- 정적 프론트만 있으면 `list_dir`/`read_file`로 파일이 제대로 저장됐는지 확인한다.
- 검증 없이 "완료됐습니다"라고 보고하지 않는다.
4. **보고** — 공개 URL(`https://<너의-funnel-도메인>/apps/<이름>/`)과 방금 확인한 검증 결과를 함께 알려준다. (`deploy_app`을 쓰면 실제 URL을 도구가 그대로 알려주니 그걸 인용해도 된다.)
## 배포는 반드시 `deploy_app` 도구로
- pm2를 `run_command`로 직접 만지지 않는다 (`pm2 save`를 깜빡하면 서버 재시작 때 앱이 사라지는 사고가 난다).
- `deploy_app(name, port, script, interpreter?)` 하나면 pm2 등록 + save + `.apps.json` 등록 + git 커밋까지 자동으로 끝난다.
- 배포 상태나 에러 로그가 궁금하면 `app_status(name?)`.
## 금지 사항
- 기존 폴더·파일을 지우지 않는다 — 본인이 만든 것이라도 삭제 금지.
- `.apps.json`의 기존 항목을 지우거나 덮어쓰지 않는다 (deploy_app이 알아서 병합한다).
- 8940~8999 범위 밖 포트를 쓰지 않는다.
- `node_modules/`를 git에 커밋하지 않는다 (`.gitignore`에 포함되어 있는지 확인한다).
## 포트 고르는 법
새 백엔드를 만들기 전에 `read_file(".apps.json")`로 이미 쓰이는 포트를 확인하고, 비어 있는 8940~8999 번호를 고른다.
## 참고
배포 메커니즘의 세부 규칙(정적 앱 경로 규칙, fetch는 상대경로로 등)은 `README-앱규칙.md` 참고. 단 pm2 등록·save·레지스트리 등록은 `deploy_app` 도구가 자동으로 해준다 — 그 문서의 수동 pm2 절차를 따라할 필요는 없다.
```
### 3-7. `~/gpt-sandbox/workspace/README-앱규칙.md`
앱 배포 규칙. URL 예시를 플레이스홀더로 바꿨다.
````markdown
# 이 샌드박스에서 웹앱 만들기 규칙
## 정적 프론트만 있는 앱
- `/workspace/<이름>/index.html` 로 저장하면 끝.
- 공개 주소: `https://<너의-funnel-도메인>/apps/<이름>/`
- 예: `netflix-now/index.html` → `/apps/netflix-now/`
## 백엔드(API)가 있는 앱
> **참고**: 아래 2~4단계(pm2 등록·save·`.apps.json` 등록)는 `deploy_app` 도구 하나로 자동화됐다. git 커밋까지 같이 해준다 — `run_command`로 pm2를 직접 만지지 말고 `deploy_app(name, port, script, interpreter?)`를 써라. 아래는 그 도구가 내부적으로 하는 일에 대한 참고 설명이다.
1. `/workspace/<이름>/` 폴더에 백엔드 코드 작성. 포트는 **8940~8999** 중 안 쓰는 번호로 바인딩(0.0.0.0으로 listen).
2. `run_command`로 pm2에 등록해 상시 구동시킨다:
```
cd /workspace/<이름> && pm2 start app.py --interpreter python3 --name <이름>
```
(Node면 `pm2 start server.js --name <이름>`)
3. **반드시 `pm2 save`를 실행한다.** 이걸 빼먹으면 서버가 재시작될 때 프로세스가 안 살아난다.
4. `/workspace/.apps.json` 에 `{"이름": 포트}` 형태로 등록한다(기존 항목 지우지 말고 추가/병합). 이 파일이 있어야 `/apps/<이름>/...` 요청이 그 포트로 연결된다.
5. API 경로는 `/api/...` 컨벤션을 쓴다(정적 파일 이름과 안 겹치게).
6. 프론트(index.html)에서 fetch할 때는 **상대경로**로: `fetch("api/items")` 또는 `fetch("/apps/<이름>/api/items")`. 절대 `localhost:포트` 로 부르지 말 것 — 브라우저는 서버 안이 아니라 밖에 있다.
## DB
- SQLite 권장. DB 파일은 그 앱 폴더 안에 둔다 (`/workspace/<이름>/data.db`). 별도 서버 설치 필요 없음.
## 확인 방법
- `pm2 list` 로 내 프로세스가 online인지 확인.
- `pm2 logs <이름> --lines 50` 로 에러 확인.
- 문제가 생기면 `pm2 restart <이름>`.
````
### 3-8. 검증
```bash
ls -la ~/gpt-sandbox
ls -la ~/gpt-sandbox/workspace
```
7개 파일이 정확한 위치(5개는 `~/gpt-sandbox/`, 2개는 `~/gpt-sandbox/workspace/`)에 있는지 확인한다. `run.sh`, `start.sh`에 실행 권한이 없어도 `bash run.sh`로 실행하므로 문제없다.
---
## 4. 빌드·기동 + 로컬 검증
```bash
cd ~/gpt-sandbox
bash run.sh
```
첫 실행 시 `.env`가 자동 생성되고(MCP_SECRET 채워짐, PUBLIC_BASE_URL은 아직 빔), 이미지가 빌드되고, 컨테이너가 뜨고, `/health`를 자동으로 확인한다. `health OK`와 `gpt-sandbox Up ...`이 보이면 성공이다.
추가 검증:
```bash
curl -s http://localhost:8931/health
# {"ok":true,"service":"gpt-sandbox-mcp"}
docker logs gpt-sandbox --tail 20
# "gpt-sandbox MCP listening on :8931" 같은 줄이 보여야 한다
```
### 선택: 배포 이력을 위한 git 저장소 초기화
`deploy_app`은 `/workspace`가 git 저장소면 배포마다 자동으로 커밋해준다(저장소가 아니면 그냥 건너뛴다 — 없어도 동작에는 문제없다). 배포 이력을 남기고 싶다면 최초 1회 만들어둔다:
```bash
docker exec gpt-sandbox bash -c '
cd /workspace &&
git init -q &&
git config user.email "sandbox@local" &&
git config user.name "gpt-sandbox" &&
printf "node_modules/\n.pm2/\n" > .gitignore &&
git add -A &&
git commit -q -m "init"
'
```
이 단계까지 통과했으면 5장으로 넘어간다.
---
## 5. Tailscale Funnel 노출 + 공개 URL 확정
Tailscale Funnel은 tailnet 안의 기기를 `https://<기기이름>.<tailnet이름>.ts.net` 형태의 공개 HTTPS 주소로 그대로 노출해준다. 도메인 구매·Caddy·포트포워딩이 필요 없다.
```bash
# 아직 로그인 안 했다면 (GUI로 이미 로그인했다면 생략 가능)
tailscale up
# 현재 이 기기의 tailnet 도메인 확인
tailscale status
```
Funnel을 켠다(로컬 8931 포트를 백그라운드로 공개):
```bash
tailscale funnel --bg 8931
tailscale funnel status
```
`tailscale funnel status`가 공개 도메인(예: `https://<기기이름>.<tailnet이름>.ts.net`)을 보여주면 그 값을 그대로 쓴다. 만약 에러 메시지에 HTTPS 인증서 또는 Funnel 기능이 꺼져 있다는 안내와 함께 링크가 뜨면, **[사용자 수동]** 그 링크를 열어 Tailscale 관리자 콘솔에서 "HTTPS Certificates"와 "Funnel"을 켜달라고 요청한다(개인 계정은 보통 기본으로 열려 있지만, 조직 계정은 관리자 승인이 필요할 수 있다).
확정된 공개 URL을 `.env`에 채운다:
```bash
# PUBLIC_BASE_URL=https://<기기이름>.<tailnet이름>.ts.net 로 채운 뒤 저장
open -e ~/gpt-sandbox/.env # 또는 원하는 편집 방식으로
```
그리고 컨테이너를 다시 만들어 새 값을 반영한다(단순 `docker restart`로는 반영되지 않는다 — env는 컨테이너 생성 시점에 고정된다):
```bash
cd ~/gpt-sandbox && bash run.sh
```
검증:
```bash
curl -s https://<확정된 도메인>/health
# {"ok":true,"service":"gpt-sandbox-mcp"}
```
이게 통과하면 공개 URL이 확정됐다. 이 URL을 6·7장에서 그대로 쓴다.
마지막으로, 3장에서 저장해둔 `~/gpt-sandbox/workspace/AGENTS.md`와 `~/gpt-sandbox/workspace/README-앱규칙.md` 안의 플레이스홀더 `<너의-funnel-도메인>`을 방금 확정된 실제 도메인으로 전부 치환해라 — 챗GPT가 이 문서들을 읽고 공개 URL을 보고하므로, 플레이스홀더가 남아 있으면 정적 앱의 URL을 엉뚱하게 안내하게 된다.
> 대안(참고만): 이 맥미니를 tailnet에 두기 싫다면 `cloudflared tunnel --url http://localhost:8931`(임시 quick tunnel) 또는 `ngrok http 8931`로도 같은 효과를 낼 수 있다. 이 가이드는 Tailscale Funnel 기준으로 이어진다.
---
## 6. 프로토콜 검증 (initialize → tools/list, CORS)
챗GPT에 등록하기 전에, MCP 핸드셰이크가 실제로 도는지 curl로 먼저 확인한다.
### 6-1. 로컬(localhost)에서 먼저
```bash
SECRET=$(grep '^MCP_SECRET=' ~/gpt-sandbox/.env | cut -d= -f2)
BASE_URL="http://localhost:8931/${SECRET}/mcp"
# 1) initialize — 헤더+본문을 파일에 통째로 남긴다.
# ★ 여기서 -o /dev/null 로 본문을 버리면 세션이 확립되지 않아 다음 tools/list가 빈 응답으로 온다.
# (서버 버그가 아니라 curl 사용법 문제 — 본문을 반드시 소비해야 한다.)
curl -sS -i -X POST "$BASE_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' \
> /tmp/mcp-init.txt
cat /tmp/mcp-init.txt
# 2) 응답 헤더에서 세션ID를 뽑는다
SESSION_ID=$(grep -i '^mcp-session-id:' /tmp/mcp-init.txt | sed -E 's/^[^:]+:[[:space:]]*//' | tr -d '\r\n')
echo "SESSION_ID=$SESSION_ID"
# 3) tools/list — 세션 헤더를 반드시 포함해서 호출
curl -sS -X POST "$BASE_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
```
마지막 명령의 응답(SSE `data:` 줄에 감싸여 있어도 정상이다)에 `list_dir`·`read_file`·`write_file`·`run_command`·`deploy_app`·`app_status` 6개 도구 이름이 모두 보여야 한다.
### 6-2. 공개 URL로 동일하게 재확인
`BASE_URL`을 `https://<확정된 Funnel 도메인>/${SECRET}/mcp`로 바꿔서 6-1의 세 명령을 그대로 다시 실행한다. 같은 결과가 나오면 챗GPT 쪽에서도 접속 가능하다는 뜻이다.
### 6-3. CORS 확인
```bash
curl -i -X OPTIONS "https://<확정된 Funnel 도메인>/${SECRET}/mcp" \
-H "Origin: https://chatgpt.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,mcp-session-id"
```
응답 헤더에 `Access-Control-Allow-Origin`과 `Access-Control-Expose-Headers: Mcp-Session-Id`가 있어야 한다(서버의 `cors()` 미들웨어가 전역으로 이미 처리한다 — 3-1의 server.js를 그대로 썼다면 별도 설정 없이 통과한다).
여기까지 통과하면 7장으로 넘어간다.
---
## 7. 챗GPT 웹 등록 (사용자 수동)
이 단계는 챗GPT 웹 화면 조작이라 사용자가 직접 해야 한다. 아래 순서를 쉬운 말로 안내해라.
1. chatgpt.com 로그인 → **설정(Settings)** → **커넥터(Connectors)** 또는 **Apps** → **고급 설정(Advanced)** → **개발자 모드(Developer mode)** 토글을 켠다. (Plus/Pro/Business/Enterprise/Edu 등급에서만 보인다.)
2. **커넥터 추가(Add connector / Create)** → 이름(예: "gpt-sandbox")과 설명을 입력하고, MCP 서버 URL에 아래 값을 넣는다:
```
https://<확정된 Funnel 도메인>/<MCP_SECRET 값>/mcp
```
(두 값 모두 `~/gpt-sandbox/.env`에서 확인한다.)
3. **인증(Authentication)**은 **없음(No authentication)**으로 둔다 — 개발자 모드 커넥터 화면에는 Bearer 토큰을 직접 넣는 칸이 없다. 그래서 이 URL 자체가 유일한 방어선이다.
4. 저장 후, **대화(Chat)를 새로 시작할 때마다** 이 커넥터를 켜야 한다(세션마다 꺼져 있는 게 기본값이다) — 대화창 도구/커넥터 목록에서 방금 만든 커넥터를 토글한다.
5. 챗GPT **프로젝트 지침(Project instructions)** 또는 커스텀 지침에 다음 문구를 붙여넣도록 안내한다:
```
새 대화를 시작하면 먼저 read_file("AGENTS.md")를 호출해서 그 내용을 읽고, 거기 적힌 작업 루프와 규칙을 따를 것.
```
검증: 새 대화를 시작하고 커넥터를 켠 뒤 "list_dir 도구로 워크스페이스 루트를 보여줘"라고 요청해본다. 승인 팝업이 뜨고, 승인하면 방금 만든 `AGENTS.md`·`README-앱규칙.md`가 목록에 보여야 한다.
---
## 8. 사용법
### 바이브코딩 루프
1. 챗GPT 웹 대화에서 원하는 앱을 만들어달라고 요청한다(커넥터가 켜져 있어야 함). 챗GPT는 `AGENTS.md`의 지침에 따라 `write_file`로 코드를 쓰고, 백엔드가 있으면 `deploy_app`으로 배포한다.
2. 챗GPT가 알려주는 `https://<Funnel 도메인>/apps/<이름>/`을 브라우저로 열어 확인한다.
3. 더 다듬고 싶으면 맥에서 직접 이어간다 — 별도 릴레이 스크립트가 필요 없다. 샌드박스가 이미 이 맥 안에 있으므로 `~/gpt-sandbox/workspace/<이름>/`을 Finder로 열거나, 그 경로에서 Claude Code를 실행해 바로 편집하면 된다. macOS의 Docker(OrbStack/Docker Desktop) bind mount는 컨테이너 안 root가 만든 파일도 호스트에서는 네 사용자 계정 소유로 보이게 매핑해준다 — 원본(리눅스 서버) 환경에서는 파일이 root 소유로 나와 `sudo rsync` 같은 우회가 필요했지만, 맥에서는 그런 우회가 필요 없다.
### `deploy_app` / `app_status`
- `deploy_app(name, port, script, interpreter?)`: 백엔드를 pm2에 등록 + `pm2 save`(재시작 생존) + `.apps.json` 등록 + git 커밋까지 한 번에 처리한다. 챗GPT가 pm2를 직접 만지는 대신 항상 이 도구를 쓰도록 `AGENTS.md`에 강제해놓았다.
- `app_status(name?)`: pm2 프로세스 상태(온라인 여부, 재시작 횟수, uptime)와 최근 로그 30줄을 보여준다. 배포 후 헬스체크가 실패했을 때 원인 파악용.
---
## 9. 함정 · 트러블슈팅
- **증상**: `/apps/<앱>/api/...`로 POST를 보내면 백엔드가 요청을 아예 못 받는다(프록시 앞에서 멈춤).
**원인**: `express.json()`을 전역(`app.use(express.json())`)으로 걸면, `/apps` 프록시로 가는 요청의 body 스트림을 이 미들웨어가 먼저 다 읽어버려서 `req.pipe(proxyReq)`로 넘길 게 남지 않는다.
**해법**: `express.json()`은 `POST /<MCP_SECRET>/mcp` 라우트에만 붙인다(3-1의 server.js가 이미 이렇게 되어 있다 — 절대 전역으로 옮기지 마라).
- **증상**: 컨테이너 안에서 `git`을 쓰면 "dubious ownership"(소유권 의심) 오류가 난다.
**원인**: `/workspace`가 호스트 bind mount라 컨테이너 안 소유권 표기가 git 기준에서 "낯선 소유자"로 보인다.
**해법**: 컨테이너 안에서 `git config --global --add safe.directory /workspace`를 수동으로 실행해도 그 순간은 고쳐지지만, **컨테이너를 재생성하면 컨테이너 안 홈 디렉토리(글로벌 설정 저장 위치)도 초기화되어 다시 증발한다.** 그래서 Dockerfile 레이어에 `RUN git config --global --add safe.directory /workspace`를 박아 이미지 자체에 영구 고정했다(3-2 참고). 수동으로 다시 설정하지 마라 — 이미지를 재빌드하면 이미 들어가 있다.
- **증상**: 컨테이너를 재시작했더니 챗GPT가 배포했던 백엔드 앱이 사라졌다.
**원인**: `pm2 save`를 안 했거나, pm2 상태 저장 위치가 컨테이너 내부(휘발성)에 있었다.
**해법**: `deploy_app` 도구가 `pm2 save`를 자동으로 실행하고, `ENV PM2_HOME=/workspace/.pm2`로 pm2 상태 자체를 bind mount(영속 영역) 안에 두게 해놓았다(3-2 Dockerfile). `run_command`로 pm2를 직접 만지면 이 안전장치를 건너뛰게 되니 항상 `deploy_app`을 쓴다.
- **제한**: 챗GPT 커넥터에 등록 가능한 도구 수는 대략 30개 이하로 제한된다. 이 서버는 6개뿐이라 여유가 있지만, 도구를 마음대로 늘리지 않는다.
- **제한**: `run_command`의 실행 타임아웃은 300초(5분)다. 그보다 오래 걸리는 작업(대용량 빌드 등)은 백그라운드로 돌리고 `app_status`나 별도 로그 파일로 진행 상황을 확인하게 한다.
- **정상 동작(버그 아님)**: `write_file`·`run_command`·`deploy_app`처럼 쓰기/실행을 하는 도구는 챗GPT가 호출할 때마다 사용자에게 승인 팝업을 띄운다. `list_dir`·`read_file`·`app_status`는 읽기 전용(`readOnlyHint: true`)이라 승인 없이 바로 실행된다. 이건 의도된 안전장치다.
- **왜 비밀 경로 방식인가**: 챗GPT 개발자 모드 커넥터 화면에는 Bearer 토큰을 입력할 칸이 없다(인증 없음 또는 OAuth뿐). 그래서 "인증 없음 + 추측 불가능한 랜덤 경로"가 현재로선 가장 실용적인 방어선이다(10장 참고).
- **증상**: 맥미니를 재부팅했더니 컨테이너도 Funnel도 죽어 있다.
**원인**: 도커 엔진(OrbStack/Docker Desktop)이 로그인 시 자동 시작하지 않도록 설정돼 있거나, 맥이 잠들어서 데몬 프로세스가 멈췄다.
**해법**: OrbStack/Docker Desktop 설정에서 "로그인 시 시작"을 켜둔다. 컨테이너는 `--restart unless-stopped`라 도커가 뜨면 자동으로 같이 뜬다. `tailscale funnel --bg`도 `tailscaled`가 살아있는 한 재부팅 후 재개된다. 맥미니가 코딩용 상시 서버 역할을 하게 하려면 잠자기 방지가 필요하다(아래 "맥미니 상시가동 체크리스트" 참고).
---
## 10. 보안 노트
- **비밀 URL을 공유하지 마라.** `https://<Funnel 도메인>/<MCP_SECRET>/mcp`가 유일한 인증 수단이다. 이 문자열이 새어나가면 누구든 이 맥미니 안에서 파일을 쓰고 명령을 실행할 수 있다. 스크린샷·대화 캡처·커밋 로그에 남기지 않는다.
- **`/apps/*`는 인증 없이 완전히 공개다.** 민감한 데이터를 다루는 앱을 만든다면 그 앱 자체에 로그인 기능을 넣어야 한다 — 샌드박스 레이어는 그런 보호를 해주지 않는다.
- **격리 범위**: 컨테이너 밖(맥미니 본체의 다른 파일·앱)에는 이 서버가 접근하지 않는다. 컨테이너 안에서는 `run_command`로 임의 셸 명령이 가능하므로, 완전한 신뢰 경계가 아니라 "사고 범위를 컨테이너 안으로 좁히는" 정도로 이해한다. 영속되는 것은 `/workspace`(=`~/gpt-sandbox/workspace`)뿐이다 — 그 밖의 컨테이너 파일시스템 변경은 컨테이너를 지우면 함께 사라진다.
- **비밀 경로를 바꾸고 싶으면**: `~/gpt-sandbox/.env`의 `MCP_SECRET`을 새 값으로 바꾸고 `bash run.sh`로 재기동한 뒤, 챗GPT 커넥터 설정의 URL도 새 값으로 갱신한다.
- **OAuth는 추후 과제다.** 지금 방식(인증 없음 + 추측 불가능한 경로)은 실용적인 1차 방어선이지 최종 해법은 아니다. 이 샌드박스를 여러 사람과 공유하거나 더 민감한 작업에 쓰게 되면 OAuth 연동을 검토한다.
---
## 맥미니 상시가동 체크리스트
이 샌드박스가 항상 켜져 있어야 챗GPT에서 언제든 쓸 수 있다. 아래를 확인한다.
- [ ] **잠자기 방지**: 시스템 설정 → 잠자기(Battery/Energy) → "가능한 경우 하드 디스크 끄기" 해제, "디스플레이가 꺼져도 활성 유지" 등. 명령줄로는:
```bash
sudo pmset -a sleep 0
```
- [ ] **OrbStack/Docker Desktop 로그인 시 자동 시작** 설정이 켜져 있는지 앱 설정에서 확인.
- [ ] **컨테이너는 이미 `--restart unless-stopped`**로 떠 있다(3-4 run.sh) — 도커가 살아나면 컨테이너도 자동으로 같이 뜬다.
- [ ] **Tailscale Funnel**: `tailscale funnel --bg 8931`은 `tailscaled`(백그라운드 서비스)가 살아있는 한 재부팅 후에도 유지된다. Tailscale.app이 로그인 시 자동 시작하도록 앱 설정을 확인한다.
- [ ] 재부팅 후 한 번은 `curl https://<Funnel 도메인>/health`로 실제로 살아 있는지 확인하는 습관을 들인다.