헤드리스 OpenClaw 에이전트에게 손발 달아주기 — ssh backend 코딩 샌드박스
TL;DR
- 홈랩 K3s에 헤드리스로 띄운 OpenClaw 봇이 대화는 되는데
pwd하나를 못 돌렸다 — exec를 돌릴 백엔드가 없어서- docker / openshell / node host / ssh 4가지를 저울질해, “30년 된 표준”이자 격리 모델이 명확한 ssh backend 선택
- 전용 sandbox Pod(sshd + 코딩 런타임 + local-path PVC) ← ssh ← Gateway Pod 구조로 분리
- uid 1024 우회(1부)가 ssh의
No user exists for uid 1024, Trivy의USER root까지 세 곳으로 번진 연쇄 함정을 밟음
홈랩 K3s에 헤드리스로 띄운 OpenClaw 봇이 멀쩡히 대화는 하는데, 막상 뭘 시키면 못 했다. “응답하는 챗봇”과 “일하는 에이전트”의 경계가 어디인지,
pwd하나로 정확히 드러난 기록이다. 전용 sandbox Pod를 세우고 ssh backend로 손발을 붙이기까지, 봇 하나치고는 또 꽤 긴 길이었다.
증상 — “응답은 하는데, 시키면 못 한다”
Discord로 OpenClaw 봇(@claw)을 붙이고 나서 처음 뭔가를 시켜봤다. 봇은 멀쩡히 대화한다. 그런데 pwd 하나를 시키자 이렇게 돌아왔다.

⚠️ 🛠️ run pwd → search "SOUL.md" in memory/2026-05-30.md (agent) failed
봇이 turn을 시작하면서 두 가지를 시도했다 — (1) shell로 pwd 실행, (2) 자기 메모리에서 SOUL.md 검색. 둘 다 failed. 텍스트 대화는 되는데 도구(tool)가 안 먹는 상태. 에이전트라기보다 그냥 챗봇이었다.
이 글은 그중 (1) shell 실행을 푸는 이야기다. (메모리 검색은 별개 이슈라 따로 다룬다.)
진단 — exec를 돌릴 곳이 없다
OpenClaw에는 sandbox라는 개념이 있다. 에이전트의 exec(shell), process, read/write 같은 도구가 실제로 어디서 실행되는지를 정하는 레이어다. 상태를 까보니:
$ openclaw sandbox explain
mode: off
runtime: direct
$ openclaw sandbox list
No sandbox runtimes found.
$ openclaw nodes list
(paired: 0)
exec-policy는 허용(security=full)인데 실행 백엔드가 없다. 헤드리스 Gateway Pod 안엔 명령을 돌릴 sandbox 런타임도, 페어링된 외부 node host도 없다. exec 도구는 호출되지만 “돌릴 곳”이 없어 failed로 떨어진 것.
정리하면: 에이전트는 “손”(도구)을 뻗었는데, 그 손이 닿을 “작업대”가 없었다.
해결책 후보 — 4가지, 그리고 트레이드오프
에이전트에게 격리된 실행 환경을 주는 방법은 크게 넷이었다.
| 방식 | 어떻게 | 홈랩 K3s에서 |
|---|---|---|
| docker backend | 에이전트가 매번 컨테이너를 띄워 그 안에서 실행 (OpenClaw 기본 권장) | K3s에서 DinD/privileged 필요 → 보안·복잡도 부담. ❌ |
| openshell | 플러그인으로 셸 실행기 추가 | 플러그인 설치가 NFS uid squash 이슈에 또 막힘(1부에서 겪은 그것). ❌ |
| node host | 별도 머신/Pod에서 openclaw node 데몬을 띄워 Gateway와 자체 프로토콜(WebSocket) 연결 | ssh 클라이언트 불필요라 매력적. 단 OpenClaw-native하지만 추가 데몬 운영 |
| ssh backend | Gateway가 ssh로 원격 sandbox에 붙어 명령 실행 | 가장 고전적·범용적. “원격에 ssh로 붙어 실행”은 30년 된 표준. ✅ |
왜 ssh backend인가
- docker: K3s에서 Docker-in-Docker는 privileged 컨테이너를 요구한다. 격리하려고 만드는 샌드박스가 오히려 권한 구멍이 되는 건 본말전도. 제외.
- openshell: 플러그인 설치 경로가 1부에서 겪은 NFS
all_squash(모든 파일 uid 1024) 검증 이슈를 또 건드린다. 같은 함정을 반복하기 싫었다. 제외. - node host vs ssh: 둘 다 “클러스터에 별도 Pod를 띄워 거기서 실행”이라는 그림은 같다. node host는 ssh 클라이언트가 필요 없다는 장점이 있지만, ssh backend는 어디서나 통하는 표준이고 backend가 config 값 하나라 나중에 교체도 쉽다. 무엇보다 “전용 sandbox Pod”라는 격리 모델이 명확했다. → ssh 선택.
[openclaw Gateway Pod] --ssh--> [sandbox Pod: sshd + python/node/git/uv + /workspace PVC]
구현 — 그리고 두 개의 복병
설계
- sandbox Pod:
debian-slim+openssh-server+ 코딩 런타임(python/node/git/uv), 비루트agent유저, 작업 디렉토리는 노드 로컬local-pathPVC(NFS squash 회피) - 인증: ed25519 키쌍. 공개키는 sandbox의
authorized_keys, 개인키는 Gateway 쪽 - 격리: ServiceAccount 토큰 미마운트(클러스터 API 차단) + NetworkPolicy(ingress는 Gateway에서 22번만, egress는 클러스터 내부망 차단·인터넷만 허용 → 패키지 설치는 되지만 옆 서비스로는 못 감)
키 주입에서 작은 발견이 있었다. OpenClaw의 minify된 zod 스키마(dist/zod-schema.agent-runtime-*.js)를 직접 까보니, ssh 설정의 identityData가 inline secret 타입을 받는다.
const SandboxSshSchema = object({
target: string().optional(),
identityFile: string().optional(),
identityData: SecretInputSchema.optional().register(sensitive), // ← 이것
knownHostsData: SecretInputSchema.optional()...
})
덕분에 개인키를 파일로 마운트할 필요 없이 "${SANDBOX_SSH_KEY}" 환경변수 보간으로 넣을 수 있었다(1부의 Discord 토큰과 똑같은 패턴). 볼륨 마운트 한 겹을 덜었다.
복병 1 — Gateway 이미지엔 ssh 클라이언트가 없다
ssh backend는 Gateway에서 spawn("/usr/bin/ssh", ...)로 동작한다. 그런데 stock 이미지엔 ssh가 없다.
$ ssh: not found
컨테이너는 uid 1024 비루트라 런타임 apt-get도 막힌다. 정석대로 파생 이미지를 만들었다.
FROM ghcr.io/openclaw/openclaw:2026.5.28
USER root
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client \
&& rm -rf /var/lib/apt/lists/*
복병 2 — “No user exists for uid 1024”
이미지를 새로 말아 배포했더니 ssh가 이렇게 죽었다.
$ ssh -V
No user exists for uid 1024 # exit 255
버전 출력(-V)조차 안 된다. OpenSSH는 기동할 때 getpwuid()로 현재 uid의 사용자/홈을 찾는데, 1부에서 NFS squash 때문에 컨테이너를 uid 1024로 돌리고 있었고, 이 uid가 /etc/passwd에 없으니 그냥 죽은 것이다. arbitrary-uid 컨테이너의 고전적인 함정.
uid가 1024로 고정이니, 커스텀 이미지에 passwd 엔트리 한 줄을 박아 끝냈다.
RUN echo 'openclaw:x:1024:100:openclaw:/home/openclaw:/bin/sh' >> /etc/passwd
1부의 우회(uid 1024)가 2부의 복병을 낳았다. 인프라 제약은 이렇게 연쇄한다. 우회를 택할 땐 그게 어디로 번질지도 같이 봐야 한다.
해결 — 손이 작업대에 닿았다
배포 후 Discord에서 다시 pwd.

/workspace/openclaw-ssh-agent-main-25bffc4d/workspace
failed가 아니라 경로가 돌아온다. 더 확실히 하려고 agent 턴으로 hostname && id -un && pwd를 시켜봤다.
"finalAssistantVisibleText": "openclaw-sandbox-7bd5f9d74f-8mdbq\nagent\n/workspace/openclaw-ssh-agent-main-...",
"toolSummary": { "calls": 1, "tools": ["sandbox_exec"], "failures": 0 }
hostname이 sandbox Pod(Gateway가 아니라), 유저가 agent(uid 1024가 아니라), 작업경로가 /workspace. exec가 ssh를 타고 격리된 Pod에서 돈다. 챗봇이 드디어 손발을 얻었다.
의외의 꼬리 — 고친 것에도 꼬리가 있다
PR을 올리자 CI의 Trivy가 빨갛게 떴다. 이번엔 보안 스캔 차례였다.
- 레포에 처음으로 Dockerfile이 생기면서, config 스캔(scan-ref
.)이USER root(DS-0002)를 잡았다. → Gateway 이미지는USER 1024:100명시로 해소(어차피 k8s securityContext가 1024로 덮으니 무해), sandbox는 sshd 때문에 root가 필수라 path-scoped baseline 처리. - sandbox Deployment의
readOnlyRootFilesystem미설정(KSV-0014), 기본 securityContext(KSV-0118)도 걸렸다. → 코딩 샌드박스는 본래 root+writable이 의도다(에이전트가 임의 코드를 쓰고 실행하는 곳). 격리는 컨테이너 권한이 아니라 Pod 분리·NetworkPolicy·토큰 미마운트로 잡으므로, 근거 주석과 함께.trivyignore.yaml로 baseline.
.trivyignore(전역) 대신 .trivyignore.yaml(path 스코프)로 바꿔, sandbox에만 면제가 적용되고 다른 매니페스트의 같은 misconfig는 여전히 잡히게 했다.
교훈
- 챗봇과 에이전트의 경계는 “도구가 실제로 도는가”다. 텍스트 응답은 모델만 있으면 되지만, exec는 돌릴 곳이 있어야 한다. 헤드리스로 띄울 땐 실행 백엔드를 처음부터 설계에 넣자.
- 스키마는 문서가 아니라 dist에 있다.
identityData가 inline secret을 받는다는 건 minify된 코드를 직접 까서 알았다. 공식 예시가 내 버전과 일치하리란 보장은 없다. - 우회는 부채를 남긴다. uid 1024 우회 → ssh의 passwd 복병 → Trivy의
USER root까지, 하나의 제약이 세 곳으로 번졌다. - 샌드박스의 격리 경계를 명확히 하라. “컨테이너를 단단히”가 아니라 “이 Pod가 뚫려도 옆으로 못 가게”가 코딩 샌드박스의 올바른 위협 모델이다. readOnlyRootFilesystem 같은 항목은 여기선 오히려 방해다.
남은 일
- sandbox 호스트키 고정(
strictHostKeyChecking: true+knownHostsData)으로 known_hosts 강화 - 그리고 — 맨 앞 스크린샷의 나머지 절반, 에이전트 메모리 검색(
~/.openclaw/memory부재) 해결