Skill 스크립트 실행, CubeSandbox 연동 사례
한 개발자가 CubeSandbox로 Skill 패키지 내 스크립트를 실행하고 stdio MCP를 사이드카로 HTTP 변환해 커뮤니티 패키지 지원을 보완했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
아무도 규칙이 python3 scripts/foo.py 하나 때문에 깨질 줄은 몰랐다.
싱우(星悟)가 MCP와 Skill을 붙인 뒤, 채팅 프로세스 안에는 한 가지 철칙이 세워져 있었다. 절대 spawn 하지 말 것. MCP는 SSE와 Streamable HTTP만 인정하고, Skill은 Markdown만 인정한다. 모델과 사용자 설정이 호스트 머신을 건드려서는 안 된다는 것이 당초의 마지노선이었다.
그런데 나중에 사장이 커뮤니티 패키지를 훑어보다가, 남들은 너나 할 것 없이 python3 scripts/foo.py 를 쓰는 걸 보고는 이걸 지원해야 한다고 말했다. 겸사겸사 stdio 계열 MCP도 들어올 수 있어야 한다고 했다. 작업은 대화 파트에 떨어졌다.
다행히 방안 차원에서는 별다른 논란거리가 없었다. Agent Skills 규격과 CubeSandbox의 라이프사이클, 커스텀 이미지 문서를 한 번 훑고, 다시 cubesandbox.ts, run-script.ts 를 대조해 사실을 확인했더니, 커뮤니티는 이미 진작에 결론을 내려 둔 상태였다. 스크립트 실행과 샌드박스는 묶여 있는 것이 기정 노선이다. 싱우에게 부족한 것은 그것을 자신의 chatbot에 연결하면서 한 가지를 지키는 일이었다. 바로 Next.js 프로세스가 아무 인터프리터나 마음대로 끌어올 수 있는 기계가 되어서는 안 된다는 것이다.
01 배경
v1의 MCP는 SSE와 Streamable HTTP만 사용한다. 사용자의 mcp.json 에 command 나 args 가 한 번이라도 나타나면 파싱이 곧바로 오류를 던졌고, 문구에는 로컬 프로세스 MCP를 지원하지 않는다고 적혀 있었다. Skill은 SKILL.md 와 한 층의 references/, assets/ 텍스트만 남겼다. scripts/ 는 스캔하지도, 실행하지도 않았다.
chatbot은 Next.js 안에서 돌아간다. 프로세스가 한 번 Python, bash 또는 stdio MCP를 띄울 수 있게 되면, 모델과 사용자 설정이 호스트 머신을 건드리게 된다. 당시에는 그 능력 하나가 없었고, 대화 핫패스 안에서 로컬 프로세스를 끌어올리지도 않았다.
Agent Skills 규격에서 하나의 Skill은 디렉터리에 SKILL.md 를 더한 것이고, 그 옆에 scripts/ 를 둘 수 있다. 커뮤니티 패키지가 python3 scripts/foo.py 를 쓰는 것은 일상이고, 파이프로 JSON을 받는 것도 많다. 또 한 부류의 MCP Server는 표준 입력·출력만 제공하는데, 각 클라이언트는 command 로 이를 띄운다. 싱우는 이 길을 막아 버린 것이고, 달리 말하면 싱우는 커뮤니티 표준 Skill 능력에 대한 지원이 부족했던 것이다.
사장은 이걸 보고 마음속으로 '그거 되겠네?' 했다. 남들이 가진 건 우리도 다 있어야지. 보안? 일단 생각하지 말고, 일단 돌아가게 만들고 나서 보자!
02 방안 조사
이 두 가지를 보완하려면 먼저 코드가 어디서 돌아가는지, 격리가 얼마나 견고한지를 물어야 한다.
Skill 스크립트는 짧은 작업이고, 신뢰할 수 없으며, 쓰고 나면 없애도 된다. stdio MCP는 장기 연결이라 상주해야 하고, 게다가 싱우가 이미 인식하는 HTTP로 변환해야 한다. 두 가지 일은 같은 프로세스 경로로 갈 수 없고, 둘 다 실행면이 필요하다. 커뮤니티는 이 실행면을 Agent Sandbox라고 부른다.
일반 Docker 컨테이너는 호스트 커널을 공유한다. 시작이 빠르고, 내부의 신뢰할 수 있는 도구용으로는 충분하다. 사용자 Skill이나 모델이 간접적으로 접촉하는 스크립트를 거기에 던져 넣으면, 커널에 구멍이 하나 생기는 순간 호스트 머신까지 함께 끝난다.
Agent 쪽에서 더 흔한 것은 MicroVM이다. Firecracker는 각 샌드박스에 독립적인 게스트 커널을 주고, 하드웨어 가상화가 탈출을 게스트 안에 가둔다. E2B의 공개 자료에 적힌 것이 바로 Firecracker이며, 제품 형태는 호스팅 API로, 생성, 파일 전송, 코드 실행, 해체를 제공한다. gVisor는 다른 길을 간다. 사용자 공간에서 시스템 콜을 가로막아, 맨 컨테이너보다 견고하다. 다만 일부 Linux 소프트웨어는 완전히 에뮬레이션되지 않은 syscall에 부딪힐 수 있다.
Anthropic은 컨테이너화된 Agent Skills를 벤더 측에 두고, 대화에서 선언하면 컨테이너를 그들이 띄운다. 싱우 게이트웨이 뒤에는 여러 모델이 연결되어 있는데, 벤더 컨테이너는 특정 한 곳의 도구 기록과 묶여 있어 모델을 바꾸면 되돌려 재생하기 어렵다. 커뮤니티 스크립트 한 조각을 돌리자고 해석권을 넘겨주는 것은 우리는 하지 않는다.
자체 구축은 컨트롤 플레인도 봐야 한다. E2B는 생성, 실행, 파기를 HTTP SDK로 정리했고, 커뮤니티의 적잖은 프로젝트가 이 인터페이스 세트에 맞춰 클라이언트를 작성한다. 격리 원시 요소는 바꿀 수 있어도 인터페이스는 바꾸지 않는 게 좋다. 싱우는 이미 TypeScript와 AI SDK이므로, Node 안에 또 한 세트의 protobuf 프로세스 프로토콜을 구현하고 싶지 않다.
stdio MCP는 커뮤니티에서 보통 Host가 Server를 띄운다. 싱우는 그렇게 할 수 없다. 실행은 운영자 호스트 머신에 남기고, sidecar가 stdio를 내부망 HTTP 또는 SSE로 변환하며, 싱우는 계속 url 과 headers 만 연결한다.
03 sandbox 선정
운영자는 이미 CubeSandbox가 설치된 CVM 한 대를 갖고 있고, 컨트롤 플레인의 로컬 헬스 체크는 http://127.0.0.1:3000/health 로 간다. 호스팅 샌드박스를 한 세트 더 사면 계정, 외부 통신, 데이터 국외 이전이 한 겹 더 늘어나는데, 이미 있는 기계는 놀고 있다.
CubeSandbox는 몇 가지 강한 조건에 부합한다. 샌드박스는 KVM MicroVM이라 신뢰할 수 없는 Skill 스크립트를 감당할 수 있다. 컨트롤 플레인은 E2B의 그 생성·실행 인터페이스와 호환되어, Node가 HTTP로 바로 연결하면 되고, chatbot 안에 Firecracker나 gVisor를 넣을 필요가 없다. 공식 sandbox-code 템플릿에는 Python 3.12, bash, Jupyter(49999 포트)가 기본 포함되어 있어, 플랫폼이 생성한 부트스트랩 프로그램을 돌리기에 딱 알맞다. 그 문서에는 자신이 Firecracker를 쓰지 않는다고 명시되어 있고, envd는 Firecracker의 MMDS를 조회하지 말라고 되어 있다. 호환되는 것은 호출 방식이고, 그 밑의 가상화는 또 다른 세트다.
한 가지 길은 보자마자 기각했다. CubeSandbox의 stdio MCP 어댑터를 써서 최종 사용자가 mcp.json 에서 MicroVM에 직접 연결하게 하는 것이다. MicroVM 세션은 짧고 콜드 스타트가 비싸서, MCP 장기 연결과 맞지 않는다. 사용자 설정에서 command 를 되살리는 것은, 즉시 spawn을 Node에서 브라우저가 제출하는 필드로 옮기는 것과 같다.
최종 분업은 아래와 같다.
객체 / 무엇을 실행하는가 / 누가 책임지는가 skill_run_script / 체크된 Skill 패키지 내 스크립트 / 싱우 애플리케이션, CubeAPI에 직접 연결 CubeSandbox MicroVM / 스크립트 실행 / 운영자 CVM stdio MCP + sidecar / 로컬 Server를 내부망 HTTP 또는 SSE로 변환 / 운영자 CVM 사용자 mcp.json / url 과 headers 만 / 사용자 브라우저 chatbot Next.js / 인터프리터를 spawn하지 않고 MCP도 spawn하지 않음 / 기존 프로세스
Cube가 설정되지 않았을 때, 대화는 여전히 순수 Markdown Skill을 쓸 수 있고, 도구는 configured: false 를 반환한다. stdio sidecar가 떠 있지 않으면, 그 지식 소스는 탐지에 실패하여 체크할 수 없다. 능력이 다 갖춰지지 않았으면, 말을 분명히 해야지, 이미 성공적으로 돌았다고 거짓말해서는 안 된다.
04 방안 구현
전체 흐름은 이렇다.
flowchart TB subgraph xingwu["싱우 chatbot Next.js"] skillTool["skill_run_script / skilltool_*"] mcpPool["MCP 풀 HTTP/SSE만"] end subgraph cvm["운영자 CVM"] cubeApi["CubeAPI :3000"] microVm["KVM MicroVM 패키지 내 스크립트 실행"] stdioProc["stdio MCP 프로세스"] sidecar["sidecar HTTP/SSE 변환"] end model["모델"] -->|"체크된 패키지 내 경로"| skillTool skillTool -->|"POST /sandboxes + /execute"| cubeApi cubeApi --> microVm microVm -->|"stdout stderr exit"| skillTool skillManifest["Skill manifest HTTP MCP"] --> mcpPool ops["운영자 systemd"] --> stdioProc stdioProc --> sidecar sidecar -->|"내부망 url"| mcpPool mcpPool -->|"mcp.json 또는 Skill이 선언한 url"| model
모델은 이미 체크되고 이미 검증된 패키지 내 경로만 지정할 수 있다. chatbot은 플랫폼이 생성한 부트스트랩 프로그램과 패키지 파일을 CubeAPI로 보내고, MicroVM 안에서 실행이 끝나면 stdout, stderr, 종료 코드가 돌아온다. MCP 풀은 여전히 HTTP만 인정하는데, 이제 한 가지 출처가 늘었다. Skill이 스스로 선언한 원격 MCP다.
진입점은 처음보다 넓어졌지만, 레드라인은 풀리지 않았다. 패키지 내 임의의 상대 경로가 진입점이 될 수 있어, 루트 디렉터리에 main.py 를 둬도 된다. 깊이 상한은 16층이다. 확장자는 .py, .sh, .js, .ts, .rb 만 인정한다. .. 는 금지하고, node_modules, .git 은 건너뛴다. 바이너리는 base64로 수록하고, 패키지 전체를 샌드박스에 쓴다. 인자는 최대 64개, 각각 32KB다. stdin은 최대 256KB로, 커뮤니티 패키지의 printf | python3 같은 것을 받아낸다. 모델에게 '아무 Python 코드나 써서 내가 돌려줘' 하는 필드는 없다. 그 필드가 한 번 열리면, 샌드박스는 숙주만 바꾼 원격 코드 실행 기계가 된다.
실행은 envd의 Process API를 거치지 않는다. 그 길은 commands.run 이고, 포트 49983이며, protobuf와 Connect 클라이언트를 직접 만들어야 하고, 임의 명령을 범용 RCE 면으로 만들기 더 쉽다. 첫 버전은 전부 Jupyter로 간다.
POST http://49999-{sandboxId}.{domain}/execute
요청 본문의 code 는 언제나 플랫폼이 생성한 Python이다. 그것은 패키지 전체를 /tmp/xingwu-skill/ 로 쓰고, 다시 subprocess.run(..., shell=False) 로 확장자에 따라 인터프리터를 호출한다. 작업 디렉터리는 패키지 루트이므로, 중첩 스크립트는 상대 경로로 같은 패키지의 파일을 찾을 수 있다. .ts 는 node --experimental-strip-types 로 가고, 템플릿은 반드시 Node 22 이상이어야 한다. 런타임이 없으면 읽을 수 있는 오류를 반환하고, 호스트 머신에서 대신 돌리지도, 외부 통신으로 tsx를 임시 설치하지도 않는다.
샌드박스를 생성하는 클라이언트는 매우 얇다.
export async function createCubeSandbox ( ): Promise < CubeSandbox > { const apiUrl = requiredEnv( "CUBE_API_URL" ). replace ( /\/+$/ , "" ); const payload = await readJson ( await requestCubeHttp ( ` ${apiUrl} /sandboxes` , { body : JSON . stringify ({ allowInternetAccess : isCubeInternetAllowed (), templateID : requiredEnv( "CUBE_TEMPLATE_ID" ), timeout : getCubeSandboxTimeoutSec (), }), headers : { "Content-Type" : "application/json" }, method : "POST" , }), ); const id = readString (payload, "sandboxID" , "sandboxId" , "id" ); if (!id) throw new Error ( "CubeAPI 创建沙箱未返回 sandboxID。" ); return { domain : readString (payload, "domain" ), id, trafficAccessToken : readString (payload, "trafficAccessToken" ), }; } export function isCubeInternetAllowed ( ): boolean { const value = process. env . CUBE_ALLOW_INTERNET ?. trim (). toLowerCase (); return value !== "0" && value !== "false" && value !== "off" ; }
CUBE_API_URL과 CUBE_TEMPLATE_ID가 모두 갖춰져야 설정이 완료된 것으로 본다. 외부 네트워크는 기본적으로 열려 있으며, 설정하지 않거나 아무 값이나 대충 써도 외부 네트워크로 간주하고, 0, false, off일 때만 차단한다. 커뮤니티 패키지는 pip install, npm install을 수시로 하는데, 기본적으로 네트워크를 막으면 패키지 대부분이 막힌다. 네트워크를 열어주면 샌드박스가 클라우드 메타데이터 169.254.169.254와 제어 평면에도 접근할 수 있게 된다. 두 가지 일이 함께 일어난다. 샌드박스 유휴 TTL 기본값은 900초, 단일 Jupyter /execute 기본값은 120초이며, 이는 /api/chat의 maxDuration = 300 안에 들어가야 한다. 부트스트랩 프로그램의 subprocess 타임아웃은 5초를 더 줄여서, 스크립트가 HTTP 창을 다 잡아먹지 않도록 한다.
환경 변수는 선별적으로 주입된다. 비즈니스 env는 샌드박스에 들어가고, CUBE_API_URL과 CUBE_TEMPLATE_ID도 들어가므로, 스크립트는 자신이 星悟(싱우) 안에 있음을 판단할 수 있다. OPENAI_API_KEY, CUBE_API_KEY, SKILL_SCAN_SECRET, 검색 키, 그리고 PATH, NODE_, NEXT_ 같은 호스트 머신의 현장 값은 일괄 건너뛴다. 추가하고 싶으면 SKILL_SANDBOX_ENV로 JSON 객체 한 덩어리를 전달하면 된다.
세션 샌드박스 재사용
MicroVM 콜드 스타트는 비싸다. 星悟가 구현한 것은 현재 Node 프로세스 안에서 conversationId 기준으로 재사용하는 것이며, 아직 사용자 레벨 풀은 없다. 브라우저가 요청에 이 ID를 함께 보내고, 서버는 128자까지만 잘라 쓰며, 이를 선택이나 인가에 사용하지 않는다. 같은 세션에서 여러 번의 skill_run_script나 skilltool_*은 하나의 MicroVM을 공유하고, /tmp/xingwu-skill과 홈 디렉터리도 그대로 남아 있다. 커뮤니티 패키지에서 흔한 Device Auth의 경우, device_code가 디스크에 떨어질 수 있어 다음 라운드에서 이어서 폴링할 수 있고, 단일 120초 창 안에서 15분을 마냥 기다리지 않아도 된다.
const sessionSandboxes = new Map < string , { expiresAt : number ; sandbox : CubeSandbox }>(); async function acquireSandbox ( conversationId?: string ) { if (!conversationId) return { ephemeral : true , sandbox : await createCubeSandbox () }; const cached = sessionSandboxes. get (conversationId); if (cached && cached. expiresAt > Date . now ()) return { ephemeral : false , sandbox : cached. sandbox }; if (cached) sessionSandboxes. delete (conversationId); const sandbox = await createCubeSandbox (); sessionSandboxes. set (conversationId, { expiresAt : Date . now () + getCubeSandboxTimeoutSec () * 1000 , sandbox, }); return { ephemeral : false , sandbox }; }
세션 식별자가 없으면 일회성 샌드박스로 취급하고, finally에서 DELETE한다. 식별자가 있으면 TTL을 CUBE_SANDBOX_TIMEOUT_SEC로 작성하며, 기본값은 900초이고, 동시에 CubeAPI의 timeout에도 넘긴다. 대화를 바꾸거나 TTL이 만료되면 디스크 상태도 함께 사라져서, 세션을 넘나드는 ~/.beatra는 남길 수 없다. 여러 worker가 각자 메모리 테이블 한 벌씩을 들고 있고, 프로세스가 재시작되면 매핑도 사라진다.
기술 방안에는 축출 후 재생성도 적혀 있다. 캐시에 있는 샌드박스가 이미 Cube에 의해 죽었을 경우, 실행 시 404 / 502가 나오면 캐시를 버리고 다시 한 번 create한다. Jupyter가 준비되지 않았을 때는 502 / 503에 대해 짧게 백오프한다. 이러한 클라이언트는 아직 코드로 작성되지 않았고, 지금은 실패하면 그대로 도구 결과로 돌아간다.
왜 먼저 사용자 레벨 재사용을 하지 않았나
CubeSandbox와 E2B 제어 평면은 이미 사용자 레벨을 다 깔아두었다. 생성 시 metadata={"user_id": ...}를 붙이고, 유휴 시 on_timeout=pause로 가며, 다음번 Sandbox.connect(sandboxId) 또는 auto_resume이 VM 스냅샷을 깨운다. sandbox_id를 로그인 신원에 묶고, pause는 메모리와 디스크를 보존하며, CPU는 해제한다. 星悟는 이 API 세트를 연결하지 않았다.
양쪽 셈은 어렵지 않다.
세션 재사용의 이점은 구체적이다. 같은 대화 라운드에서 스크립트를 여러 번 실행하면 Device Auth의 device_code가 여전히 디스크에 있어서 다음 라운드에서 이어서 폴링할 수 있다. 대화를 바꾸면 샌드박스가 해체되어, 이전 라운드의 Skill이 남긴 파일이 다음 라운드로 따라가지 않는다. 구현은 프로세스 안의 Map 하나이고, 사용자 테이블도 없으며, pause 스냅샷과 복구 할당량을 관리할 필요도 없다. 대가도 분명하다. 새로 대화를 시작하면 여전히 콜드 스타트 비용을 치른다. conversationId는 브라우저에서 오므로, ID 하나를 베껴 오면 같은 샌드박스에 부딪히고, 동시성은 같은 작업 디렉터리를 다툰다. 프로세스가 한 번 재시작하면 매핑이 사라진다.
사용자 레벨은 반대다. 대화를 넘나들며 ~/.beatra, pip 캐시, 이미 설치한 패키지를 남길 수 있고, 유휴 시 pause하므로 다음번에 전체 생성 비용을 한 번 덜 치른다. 장시간 떠 있는 샌드박스는 오염 면적도 늘린다. 같은 사람이 먼저 나중에 두 개의 Skill을 실행하면, 뒤엣것이 앞엣것이 디스크에 쓴 것을 볼 수 있다. 신뢰할 수 있는 사용자 신원이 필요한데, 星悟에는 지금 로그인이 없다. pause 이후 Cube는 기본적으로 일시정지된 샌드박스도 여전히 점유로 계산하고, 비율을 키우면 깨울 때 409가 날 수도 있다. 제어 평면, 신원 테이블, 잔여 파일 규약 중 하나라도 빠지면 '콜드 스타트 절약'이 또 다른 운영 짐으로 바뀐다.
星悟는 내부 chatbot이고 동시성이 높지 않다. 동시에 살아 있는 대화가 제한적이어서, CVM 위에 수명이 짧은 MicroVM 몇 대 더 얹는 것은 견딜 만하다. 세션 재사용은 이미 같은 라운드 안에서 반복 생성되는 그 부분을 막아냈다. 사용자 레벨이라는 셈은, 동시성이 정말로 콜드 스타트를 아프게 만들 때가 되면 신원에 연결하면 된다.
CubeProxy는 가상 Host에 따라 트래픽을 해당 MicroVM으로 밀어 넣는다. Node 내장 fetch는 우리가 설정한 Host를 버리므로, 클라이언트를 node:http / node:https로 바꾸고, 필요할 때 host 헤더를 다시 써 넣는다. 실행 타임아웃과 제어 평면 타임아웃은 따로 설정한다. 소멸 실패가 스크립트 결과를 덮어서는 안 된다.
부트스트랩 프로그램에서 텍스트 리소스는 chatbot 쪽에서 base64로 변환하고, 스캔 시 이미 바이너리인 것은 그대로 가져간다. 샌드박스 안에서 디코딩해 디스크에 떨어뜨린다. 인터프리터 argv는 플랫폼이 조립하고, shell을 거치지 않는다. stdin, 타임아웃, 환경 변수를 이 Python 조각에 함께 써 넣는다.
for key, value in env_overlay.items(): os.environ[ str (key)] = str (value) root = pathlib.Path( '/tmp/xingwu-skill' ) root.mkdir(parents= True , exist_ok= True ) for item in files: path = root / item[ 'relativePath' ] path.parent.mkdir(parents= True , exist_ok= True ) path.write_bytes(base64.b64decode(item[ 'content' ])) suffix = pathlib.Path(entry).suffix.lower() command = { '.py' : [ 'python3' ], '.js' : [ 'node' ], '.ts' : [ 'node' , '--experimental-strip-types' ], '.rb' : [ 'ruby' ]}.get(suffix) if suffix == '.sh' : first = (root / entry).read_text(errors= 'replace' ).splitlines()[: 1 ] command = [ 'sh' ] if first and first[ 0 ].strip() == '#!/bin/sh' else [ 'bash' ] completed = subprocess.run( command + [entry] + args, cwd=root, input =stdin_text or None , capture_output= True , text= True , shell= False , timeout=timeout_sec, )
timeout_sec는 CUBE_EXECUTE_TIMEOUT_MS에서 환산한 뒤 다시 5를 뺀 값이다. stdout 절단 상한은 256000자다. 인터프리터가 이미지에 없을 때, 부트스트랩 프로그램은 FileNotFoundError를 붙잡아 '샌드박스 템플릿이 필요한 런타임을 제공하지 않음'을 출력한다. JS가 나중에 이 문구에 부딪혔다.
커뮤니티 Skill 패키지의 두 번째 형태
커뮤니티의 Skill 패키지에는 '패키지 하나에 스크립트 하나'라는 형태만 있는 게 아니다. 많은 패키지가 SKILL.md의 YAML에 도구 한 묶음을 선언하고, 기본 진입 스크립트 하나만 두며, 첫 번째 인자로 분기한다 — 마치 같은 명령의 서로 다른 하위 명령처럼, git commit, git push 뒤에는 같은 바이너리가 있는 것과 같다.
星悟가 이런 커뮤니티 패키지도 실행되게 하려면, 선언에 있는 각 도구를 모두 (frontmatter로) 모델이 볼 수 있는 함수 skilltool_*로 포장해야 하고, 뒤에는 여전히 같은 샌드박스가 있다.
星悟는 이런 패키지도 받아들였다. 선언에 나타난 각 도구는 모델에 함수 하나로 붙여지며, 최대 40개이고, 이름은 skilltool_{skillName}_{toolName}이다. 진입 스크립트는 scripts/main.py, main.py, scripts/mcp_client.py 순서로 찾고, 존재하는 것을 쓴다. args와 stdin 모두 '도구 이름 + JSON 한 덩어리'를 전달한다. 세 위치 모두에서 기본 실행 가능 스크립트를 찾지 못하면, 이 도구는 마운트하지 않는다.
모델에게 그것은 그냥 평범한 도구를 호출하는 것이고, 인식상 차이가 없다. 星悟에게 뒤에는 여전히 같은 샌드박스, 같은 실행 경로다 — skill_run_script와 본질적 차이가 없고, 다만 '도구 이름 → 진입 스크립트' 분기 계층이 하나 더 있을 뿐이다.
Skill 자체 탑재 HTTP MCP
Skill은 지식 소스를 자체 탑재할 수 있다. 패키지 안의 manifest.json 또는 _meta.json이 mcp.url, mcpServers를 선언하면, 이 Skill을 선택한 후 그 원격 MCP가 자동으로 이번 라운드의 지식 소스로 들어간다. 중첩 경로 안의 manifest.json도 읽는다. HTTP/SSE만 인정하고, url이 없거나 command만 있는 설정은 그대로 버린다.
星悟는 credential_file을 읽지 않고, Device Token을 요청 헤더에 집어넣지도 않는다. 로그인이 필요한 서비스는 패키지 내 스크립트로 간다. Device Auth 같은 흐름은 스크립트가 승인 URL을 stdout에 출력하고, 사용자가 자기 브라우저에서 Allow를 누른 뒤 다시 실행한다. 자동으로 붙은 MCP를 로그인된 것으로 여기지 말아야 한다.
에디터와 발견
사용자 Skill의 진입은 단일 붙여넣기에서 세 가지로 확장되었다. Markdown 붙여넣기, 폴더 가져오기, zip 가져오기 모두 가능하고, 스캔 인터페이스는 resources[]를 받는다. 디렉터리 이름이 frontmatter의 name과 같을 필요도 없고, 이름이 충돌하면 내장이 우선한다. 내장 패키지에는 sandbox-smoke와 hot-topic-content-maker가 추가되었는데, 하나는 샌드박스를 테스트하고, 하나는 실제 흐름으로 커뮤니티 패키지가 星悟 안에서 어떻게 실행되어야 하는지 시연한다.
stdio MCP는 chatbot 쪽에서 전송 스택을 전혀 바꾸지 않는다. 검증 계층은 계속 command / args를 거부한다. 운영 측은 같은 CVM에서 systemd로 읽기 전용 Server를 띄우고, sidecar가 내부망 HTTP로 변환한다. 星悟는 여전히 이미 변환된 url에만 연결한다. sidecar는 아직 상주 서비스로 자리 잡지 못했고, 코드로 대조해 볼 수 있는 것은 주로 Skill 스크립트 쪽 경로다.
사용자 업로드는 먼저 스캔을 거친다
가져오기 진입점은 입력창의 Skill 메뉴 안에 있다. 사용자는 SKILL.md를 붙여넣을 수 있고, 폴더를 선택할 수도 있으며, zip을 업로드할 수도 있다. 폴더와 zip은 먼저 브라우저에서 풀리고, 본문·리소스 경로·리소스 인코딩이 브라우저 측에서 계산한 SHA-256과 함께 POST /api/skills/scan 으로 전송된다. 서버는 이 내용을 저장하지 않으며, 스캔이 통과되기 전에는 IndexedDB에도 기록하지 않는다.
스캔 계층이 하는 일은 구조·경계·완전성 검증이지, 그것을 백신이라고 부르면 안 된다. 가져오기 시점에 Python, bash, Node 또는 패키지 안의 다른 스크립트를 실행하지 않는다. 스크립트의 실제 실행은 여전히 뒤에 오는 CubeSandbox MicroVM에서 일어난다.
서버는 validateUserSkill로 몇 가지 구체적인 일을 한다.
- source: "user" 인 Skill만 받는다.
- SKILL.md의 YAML frontmatter를 파싱하고, 본문에 선언된 name과 description으로 브라우저가 보낸 같은 이름의 필드를 덮어쓴다.
- 리소스는 최대 200개다. 각 리소스는 8MB를 초과할 수 없다.
- 리소스 경로는 패키지 내 상대 경로만 가능하다. 절대 경로, 역슬래시, 디렉터리 탈출, 지나치게 깊은 디렉터리, 그리고 node_modules, .git 같은 디렉터리는 모두 거부된다.
- 모든 리소스는 SHA-256을 다시 계산해야 한다. 본문 해시도 브라우저가 제출한 contentSha256과 일치해야 한다.
- 같은 패키지 안에서 중복된 리소스 경로는 허용되지 않는다. 바이너리 리소스는 base64 형태로만 가져올 수 있다.
이 검증은 아래 알고리즘으로 압축할 수 있다. 먼저 본문을 파싱해 진짜 디렉터리 정보를 얻고, 그다음 리소스 경계를 하나씩 조인다. 마지막에 돌아와 본문 해시를 다시 계산한다. 클라이언트가 가져온 name, description은 힌트일 뿐이며, 서버가 무엇을 기록할지 결정할 자격은 없다.
검증된 resources와 본문
인터페이스 자체에는 두 가지 상태가 없다. 검증을 통과하면 증명을 발급하고, 실패하면 일괄적으로 400을 반환한다. 본문은 데이터베이스에 들어가지 않는다.
export const POST = withRequestLogging ( "/api/skills/scan" , async (request, context) => { try { const body = ( await request. json ()) as { skill?: unknown }; const skill = validateUserSkill (body. skill ); return Response . json ({ manifest : { contentSha256 : skill. contentSha256 , description : skill. description , name : skill. name , source : skill. source , version : skill. version , }, attestation : issueSkillAttestation (skill. contentSha256 ), resources : skill. resources . map ( ( resource ) => ({ contentSha256 : resource. contentSha256 , relativePath : resource. relativePath , })), scannerVersion : SKILL_SCANNER_VERSION , }); } catch (error) { logError ( "skills.scan.failed" , error, { requestId : context. requestId }); return Response . json ( { error : error instanceof Error ? error. message : "Skill 스캔 실패." }, { status : 400 }, ); } });
이 단계를 통과하면 스캔 인터페이스는 본문에서 다시 파싱한 디렉터리 항목, 리소스 요약, 스캐너 버전과 증명 한 장만 돌려준다. 증명은 서버가 HMAC으로 서명한 단기 티켓으로, 본문 해시와 스캐너 버전에 묶여 있으며 기본 24시간 후 만료된다. 브라우저가 저장한 사용자 Skill에는 이 티켓이 함께 붙는다.
이후 매 대화마다 서버는 한 번 더 검증한다. 사용자 패키지는 선택됨, 본문 해시 일치, 증명 서명 정확 및 미만료를 동시에 충족할 때만 이번 회차 Skill 세션에 들어간다. SKILL.md를 수정했거나 리소스를 다시 가져왔거나 티켓이 만료되었다면, 낡은 증명으로 넘어갈 수 없다.
증명 내용은 복잡하지 않다. 본문 해시, 만료 시간, 스캐너 버전을 직렬화한 뒤 서명한다. 대화 요청이 돌아오면 먼저 서명을 대조하고, 그다음 해시·버전·시간을 대조한다. 증명 안에는 본문이 없으며, 본문은 여전히 브라우저가 필요할 때 지닌다.
export function issueSkillAttestation ( contentSha256: string ): string { const payload = { contentSha256, expiresAt : Date . now () + 24 * 60 * 60 * 1000 , scannerVersion : "v1" , }; const encoded = Buffer . from ( JSON . stringify (payload)). toString ( "base64url" ); return ` ${encoded} . ${sign(encoded)} ` ; } export function verifySkillAttestation ( contentSha256: string , value: unknown ): boolean { if ( typeof value !== "string" ) return false ; const [encoded, signature, extra] = value. split ( "." ); if (!encoded || !signature || extra) return false ; const expected = sign (encoded); if ( signature. length !== expected. length || ! timingSafeEqual ( Buffer . from (signature), Buffer . from (expected)) ) { return false ; } try { const payload = JSON . parse ( Buffer . from (encoded, "base64url" ). toString ( "utf8" )); return ( payload. contentSha256 === contentSha256 && payload. expiresAt > Date . now () && payload. scannerVersion === "v1" ); } catch { return false ; } }
편집기의 상태도 이 순서를 따른다. 패키지를 가져오면 자동으로 "싱우가 백그라운드에서 스캔 중"이 표시되고, 통과한 뒤에야 "Skill 저장"이 나타난다. 사용자가 본문을 수동으로 고치면 통과 상태가 즉시 지워진다. 스캔 실패 시에는 서버가 반환한 이유를 보여주고, 저장 버튼도 나타나지 않는다. 이렇게 하면 로컬 브라우저에 겉으로는 가져오기에 성공했지만 실제로는 결코 싱우의 검증을 통과한 적 없는 패키지가 남지 않는다.
입력창 안의 @ Skill
상시 체크는 어떤 Skill을 모델에 쓸 수 있게 할지를 해결하고, @ 는 이 한 문장에서 명확히 어느 것을 쓰려는지를 해결한다.
입력창이 행 첫머리나 공백 뒤의 @ 를 감지하면 사용 가능한 모든 Skill의 후보 목록을 연다. 현재 체크된 항목만 나열하는 것이 아니다. 내장 Skill과 이미 스캔·저장된 사용자 Skill이 모두 나타날 수 있고, 이름이 같으면 여전히 내장 패키지가 우선한다. 사용자가 이름을 계속 입력하면 후보 항목은 name 접두사로 필터링된다. 클릭하면 입력 텍스트가 @skill-name 으로 유지되며, 동시에 해당 Skill을 활성화로 표시한다.
메시지를 보낼 때 resolveMessageSkills가 두 갈래 선택을 하나로 합친다.
- 입력 영역에서 이미 체크된 상시 Skill.
- 현재 메시지에서 명명 규칙에 맞는 @skill-name.
참조된 Skill은 현재 요청과 함께 skillBundle.selected 로 들어간다. 사용자 Skill은 본문·리소스·스캔 증명을 함께 지녀야 하고, 내장 Skill은 이름만 지닌다. 서버는 이름·출처·사용자 패키지 해시로 패키지를 다시 선택하여, 브라우저가 남의 내용을 위조하거나 같은 이름의 사용자 패키지가 내장 패키지를 덮어쓰는 것을 막는다.
@ 매칭은 의도적으로 매우 좁게 작성되어 소문자·숫자·하이픈만 받는다. 이렇게 하면 이메일, 일반 중국어 @ 기호 또는 경로가 붙은 문자열이 Skill로 잘못 인식되지 않는다. 이미 체크된 상시 항목이 먼저 표에 들어가고, 메시지 안의 @name이 뒤이어 보충된다. 이름 충돌 시에는 내장 패키지 우선 규칙을 재사용한다.
export function resolveMessageSkills ( text: string , selectedSkills: SkillCatalogEntry[], availableSkills: SkillCatalogEntry[], ): SkillCatalogEntry [] { const selected = indexSkillsByPreferredName (selectedSkills); const byName = indexSkillsByPreferredName (availableSkills); for ( const match of text. matchAll ( /(?:^|\s)@([a-z0-9]+(?:-[a-z0-9]+)*)/gi )) { const skill = byName. get (match[ 1 ]. toLowerCase ()); if (skill) { selected. set ( skill. name , preferSkillOnNameConflict (selected. get (skill. name ), skill), ); } } return [...selected. values ()]. slice ( 0 , MAX_ENABLED_SKILLS ); }
입력창 쪽은 상호작용만 하고, 컴포넌트 안에서 권한을 결정하지 않는다. 커서 뒤에 @ 가 나타나면 전체 사용 가능 디렉터리에서 후보를 필터링한다. 사용자가 항목을 클릭하면, 정규식은 마지막의 미완성 @ 조각만 교체하고 앞의 자연어는 보존한다.
const mentionMatch = currentValue. match ( /(?:^|\s)@([a-z0-9-]*)$/i ); const mentionQuery = mentionMatch?.[ 1 ]?. toLowerCase (); const skillHints = mentionQuery === undefined ? [] : skillMentions. filter ( ( skill ) => skill. name . toLowerCase (). startsWith (mentionQuery), ); function applySkillMention ( name: string ) { const next = currentValue. replace ( /(?:^|\s)@[a-z0-9-]*$/i , ( matched ) => ` ${matched.startsWith( " " ) ? " " : "" } @ ${name} ` , ); controller?. textInput . setInput (next); onSkillMention?.(name); }
모델은 처음에는 체크된 Skill의 짧은 디렉터리만 보고, 디렉터리에는 이름과 설명만 있다. 일반 작업에서 특정 Skill을 로드할지 여부는 여전히 모델이 설명을 근거로 판단한다. 사용자가 @skill-name을 명시적으로 쓴 것은 또 다른 층위의 의미로, 시스템 프롬프트는 해당 Skill의 완전한 작업 설명을 먼저 읽고 그 안의 절차에 따라 작업을 완수하도록 요구한다.
이 층은 '강제 도구 호출'을 모든 모델이 지킬 수 있는 전제로 삼지 않았다. 싱우(星悟)는 모델과 프로토콜에 따라 가벼운 탐지를 한 번 수행해, 해당 모델이 정말 지정된 도구 호출을 준수할 수 있는지 확인한 뒤에야 첫 단계에서 skill_load를 강제한다. 탐지가 실패하거나 타임아웃이 나거나 호환 게이트웨이가 지원하지 않으면, 여전히 해당 Skill을 사용 가능으로 두고 모델 지시에 의존해 자체적으로 로드하게 한다. 이렇게 하면 required tool choice를 준수하지 않는 GLM 같은 게이트웨이가 함수 호출을 내보내지 않았다는 이유로 전체 라운드가 오류 나는 일은 없다.
모델이 skill_load를 호출한 뒤에야 본문을 받는다. 만약 본문이 템플릿, 참고 자료 또는 스크립트를 읽으라고 요구하면, 그때 필요에 따라 skill_read_resource, skill_run_script 또는 frontmatter에 선언된 skilltool_* 를 호출한다. 체크되지 않은 Skill은 모델이 디렉터리에서 볼 수 있더라도 도구 계층에서 거부된다. 본문에 있는 말 역시 시스템 제약, 사용자의 명확한 요구, 권한 경계를 덮어쓸 수 없다.
함정, 공식 템플릿에 Node가 없다
공식 sandbox-code 템플릿은 코드 인터프리터용이다. Python 3.12, bash, Jupyter, envd는 다 있는데 Node는 없다.
커뮤니티 Skill에는 .js / .ts 가 매우 흔하다. 예전 템플릿에서 scripts/foo/bar.js 를 실행하면 부트스트랩 프로그램이 node 를 찾다가 바로 FileNotFoundError 가 난다. 도구 결과가 바로 위의 그 문장 "샌드박스 템플릿이 필요한 런타임을 제공하지 않음"이다. Python과 bash의 스모크 스크립트는 통과하는데 JS는 통과하지 못한다. 문제는 스캔에 있지도, 경로 검증에 있지도 않고, 바로 이미지에 런타임이 빠진 것이다.
이미지를 처음부터 만들려면 envd, 진입 스크립트, 활성 포트를 직접 처리해야 한다. CubeSandbox의 '커스텀 템플릿 이미지' 튜토리얼에는 또 다른 길이 적혀 있다. 기존 이미지 위에 한 겹만 얹고, 남의 ENTRYPOINT / CMD 는 건드리지 말라. 공식 이미지는 이미 Jupyter를 49999에서, envd를 49983에서 띄워 두었다. 우리는 Node만 넣으면 된다.
일단 시작하니, 최종 Dockerfile은 한 겹뿐이었다. 기반 이미지는 공식 sandbox-code:latest 다. Node는 공식 linux-x64 바이너리 패키지를 /usr/local 에 풀어 넣고, 버전은 22.17.0으로 고정해야 .ts 가 node --experimental-strip-types 로 갈 수 있다. 빌드가 끝날 때 이미지 안에서 node -v 와 python3 --version 을 한 번 실행해 두 런타임이 모두 있는지 확인한다.
FROM <registry>/cube-sandbox/sandbox-code:latest ARG NODE_VERSION=22.17.0 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates xz-utils \ && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ | tar -xJ -C /usr/local --strip-components=1 \ && apt-get purge -y xz-utils \ && rm -rf /var/lib/apt/lists/* \ && node -v \ && python3 --version
이미지를 만들었으면 CubeMaster가 가져올 수 있어야 한다. 먼저 Docker Hub에 푸시해 봤지만 풀 타임아웃이 났다. 나중에 CVM에서 평문 HTTP Registry를 하나 띄웠다. Cube 문서에 아주 분명히 적혀 있듯, 평문 HTTP 이미지 참조는 반드시 http:// 접두사를 붙여야 한다. 예를 들어 http://my-registry.example.com/my-team/my-sandbox:v1 처럼. 이 접두사를 빠뜨리면 CubeMaster가 HTTPS나 Docker Hub로 해석해서 템플릿이 풀 단계에서 멈춘다. 푸시할 때는 OCI mediatype도 꺼야 하고, Docker schema2로 바꿔야 한다. 그렇지 않으면 Registry와 Cube가 맞지 않는다.
템플릿을 만들 때 Jupyter와 envd의 포트를 함께 노출하고, 활성 확인은 49999의 /health 로 하며, 공식 진입점은 바꾸지 말라.
cubemastercli tpl create-from-image \ --image "http:// ${REGISTRY} /xingwu/sandbox-code-node:22" \ -- alias sandbox-code-node \ --writable-layer-size 2Gi \ --expose-port 49999 \ --expose-port 49983 \ --probe 49999 \ --probe-path /health
새 템플릿이 READY 된 뒤에는 샌드박스에서 python3 --version 은 3.12.12 , node -v 는 v22.17.0 이다. chatbot이 CUBE_TEMPLATE_ID 를 이 템플릿으로 지정해야 JS에 인터프리터를 붙여 쓸 수 있다.
05 소결
KVM과 제어 평면은 완성품을 사면 된다. 싱우가 시간을 들인 곳은, 모델이 임의 코드 필드에 영원히 닿을 수 없고, 인터프리터가 영원히 Next.js에 들어가지 않으며, 공식 이미지에 무엇이 빠졌든 직접 한 겹 얹는 것이었다.
세션 재사용은 이미 같은 대화 안에서 반복 생성되는 부분을 막아 준다. 싱우는 내부 chatbot이라 동시성이 높지 않아 사용자 수준 pause / connect 는 우선 연결하지 않는다. 축출 후 재생성과 stdio 사이드카도 아직 적용하지 못했다...스크립트가 돌아가면 됐지^_^.
06 참고
- Agent Skills 규격
- CubeSandbox 커스텀 템플릿 이미지
- CubeSandbox 샌드박스 수명 주기
- E2B Sandbox persistence
- E2B
- gVisor
- Firecracker