workspace-runner
원천 · framework/evomind/web/workflow-project-evo-service기준 · 릴리스 v0.12.0커밋 · 2e32c6cc파일 · docs/operations/workspace-runner.md기준선 · 2026-11-12
웹 채팅의 workflow 작업 공간(~170)에서 컨테이너 1개 안에 사용자별 계정·홈 폴더를 두고 도구(git·glab·evo-workflow)를 사용자 uid 로 내려 실행하는 내부 서비스다. 터미널 화면은 없다. evo-api 가 서비스 토큰으로 내부 HTTP 를 부르고, 이 서비스는 evo-api 에도 Docker 권한을 주지 않는다. 이 문서는 첫 단계(뼈대·계정 격리·실행 API), 둘째 단계(프로젝트 clone·대화별 worktree), 셋째 단계(push 래퍼), 넷째 단계(파일 API), 다섯째 단계(사용량·초기화·디스크 상한·에이전트 등록 worktree)다. evo-api 연동은 workspace-tools.md, compose 배포·네트워크 격리는 workspace-deploy.md 를 따른다.
1. 구조와 격리
섹션 제목: “1. 구조와 격리”evo-api ──내부 HTTP(서비스 토큰)──▶ workspace-runner(Go, root 로 시작) ──uid 로 내려서──▶ git · glab · evo-workflow └ 사용자 번호 n → uid 20000+n, 홈 /work/u<uid>/{home,tmp} (700, 본인 소유) home/repos/<프로젝트>(clone) · home/work/<대화 id>/<프로젝트>(대화별 worktree)- 사용자 번호는 evo-api 가 정한다(사용자마다 고정, 재사용하지 않는다 — 삭제된 사용자의 파일이 새 사용자에게 넘어가지 않게). runner 는 번호만 받는다(이름·메일 아님). 범위 0~1,000,000.
- 격리는 운영체제 권한이 1차 방어다: 사용자마다 다른 uid, 보조 그룹 없음, 홈 700. 다른 사용자의 파일·폴더 목록·
/proc/<pid>/environ·프로세스에 접근하지 못한다(통합 시험으로 확인). - 실행되는 자식 프로세스는 유효 권한이 0이다(runner 가 가진 SETUID·SETGID·CHOWN 은 자식에게 가지 않는다). 서비스 환경 변수(서비스 토큰 등)도 물려받지 않는다. 환경은
PATH·LANG·HOME과 요청이 준 변수뿐이다. - 요청의 환경 변수(예: GitLab 토큰)는 그 실행에만 전달하고 디스크·감사에 남기지 않는다.
2. 컨테이너 권한 (compose 는 workspace-deploy.md)
섹션 제목: “2. 컨테이너 권한 (compose 는 workspace-deploy.md)”user: "0" # runner 는 root 로 시작해 필요한 때만 uid 를 내린다cap_drop: [ALL]cap_add: [SETUID, SETGID, CHOWN] # 이 셋만. DAC_OVERRIDE·FOWNER 는 주지 않는다security_opt: ["no-new-privileges:true"]read_only: truetmpfs: ["/tmp:mode=1777"]volumes: ["workspaces:/work"] # 사용자별 공간이 쌓이는 볼륨command: ["workspace-runner"]DAC_OVERRIDE·FOWNER 가 없다는 것이 설계에 영향을 준다(실제 컨테이너 시험으로 확인한 함정):
- 소유자를 바꾼 뒤에는
chmod가 거부되어 폴더가 755 로 남는다 → 소유자를 바꾸기 전에 700 으로 정한다. - 소유자를 바꾼 700 폴더 안에는 root 도 들어가지 못한다 → 하위 폴더(
home·tmp)를 모두 먼저 만들고, 맨 마지막에 안쪽부터 바깥쪽으로 소유자를 바꾼다. - 이미 만들어진 공간의 안쪽은 runner 가 볼 수 없다 → 바깥 폴더(
/work/u<uid>)의 모드·소유자·종류만 검사한다. 700 이 아니거나 소유자가 다르거나 링크로 바뀌었으면 조용히 고치지 않고workspace_broken(500)으로 거절하고 감사에 남긴다(격리가 깨졌다는 신호라 사람이 본다).
- Alpine 에는 util-linux 의
setpriv가 없어 계정 전환을 Gosyscall.Credential로 직접 한다. - 컨테이너 안에서도 도구 이미지는 읽기 전용 루트로 실행된다. 쓰는 곳은
/work볼륨과/tmp뿐이다.
3. API (evo-api 만 부른다)
섹션 제목: “3. API (evo-api 만 부른다)”서비스 토큰: Authorization: Bearer <RUNNER_TOKEN>(상수 시간 비교). 없거나 틀리거나 설정이 비어 있으면 모든 요청이 401이고 감사에 auth.denied 가 남는다. GET /healthz 만 인증이 없다(비밀·주소 없음, 204).
POST /v1/exec — 도구 실행
섹션 제목: “POST /v1/exec — 도구 실행”{"user": 7, "tool": "git", "args": ["status"], "dir": "repo", "env": ["GITLAB_TOKEN=..."]}| 항목 | 설명 |
|---|---|
tool |
허용 목록의 이름만(git·glab·evo-workflow). 셸을 거치지 않는다. 그 밖은 403 tool_not_allowed |
args |
도구 인자. 인자의 의미 검증(허용 하위 명령, --yes 금지 등)은 evo-api가 한다 — runner 는 실행·격리·상한만 맡는다 |
dir |
홈 기준 상대 경로(비우면 홈). 절대 경로·.. 로 홈을 벗어나면 400 outside_workspace |
env |
KEY=VALUE. 그 실행에만 전달 |
agent |
(선택) 에이전트 id. tool 이 evo-workflow 일 때만 받고 서버가 인자 맨 뒤에 --session-agent <id> 를 붙인다. 다른 도구에 오면 400 bad_agent |
| 응답 | exitCode·stdout·stderr·truncated·timedOut·durationMs |
- 같은 사용자의 동시 실행은 하나다(두 번째는 409
busy). 작업 공간 초기화가 진행 중 실행을 거부하는 데도 쓴다. - 실행 경로: 도구를 바로 띄우지 않고 runner 가 자기 자신을 헬퍼로 재실행한다. 헬퍼는 root 로 시작해 준비 신호를 보내고 대기 → 부모가 그 pid 에
rlimit을 건다 → 헬퍼가 uid 를 내리고 요청한 작업 폴더로 옮긴 뒤 도구로exec한다. 이렇게 하는 이유: ① 부모가 이미 uid 를 내린 자식에prlimit을 걸면 이 컨테이너 권한(SETUID·SETGID·CHOWN 만)에서는EPERM이다, ② 도구가 상한 없이 도는 구간이 없게 하려면 상한을 건 뒤에 시작해야 한다, ③ 사용자 홈(700)은 runner(root)에게 DAC_OVERRIDE 가 없어 먼저 들어갈 수 없으므로 작업 폴더 이동은 uid 를 내린 뒤에 한다. 헬퍼용 변수는EXECX_접두사라 요청 환경 변수가 덮어쓸 수 없다(걸러 낸다). 상한을 걸지 못하면 도구를 띄우지 않고 프로세스 그룹을 끝낸 뒤 500 으로 알린다. --session-agent는 서버가 붙인다 — 호출 쪽args는 옵션 위치를 정하지 못한다(사용자가 준 인자와 섞이지 않게 맨 뒤에 붙인다). evo 의 이 플래그는 하위 명령의 옵션이라 하위 명령 앞에 두면 실패한다. id 는[A-Za-z0-9._][A-Za-z0-9._-]{0,127}만(옵션처럼 보이는-…는 거절), 감사에는 남기지 않는다.- 본문은 최대 1 MiB, 모르는 필드는 400.
- 사용자당 디스크 상한(
RUNNER_USER_QUOTA_BYTES, 기본 10 GiB)을 넘은 사용자의 실행은 507disk_quota로 거부한다(§4).
POST /v1/repo/clone — 프로젝트 clone
섹션 제목: “POST /v1/repo/clone — 프로젝트 clone”{"user": 7, "url": "https://<허용 호스트>/group/proj.git", "env": ["GIT_ASKPASS=...", "GITLAB_TOKEN=..."]}- 주소는 서버가 검증하고 git 인자도 서버가 만든다(호출 쪽이 인자를 정하지 못한다). https 이고
RUNNER_ALLOWED_GIT_HOSTS의 호스트일 때만 받는다(그 밖은 403host_not_allowed). 허용 호스트가 비어 있으면 모든 clone 이 거절된다. 내부 서비스 주소(메타데이터 주소·localhost 등)를 clone 하려는 요청(SSRF)이 이 목록으로 막힌다. - 주소에 사용자 정보(
user:token@)·쿼리·프래그먼트·..·공백이 있으면 400bad_url— 토큰은 주소에 넣지 않는다(.git/config에 남기 때문). 인증은 요청의 환경 변수(GIT_ASKPASS등)로만 한다. - 얕게(
--depth 1, 단일 브랜치, 태그·서브모듈·LFS 없음, 훅 끔)home/repos/<그룹>__<프로젝트>에 만든다. 응답의folder는 홈 기준 상대 경로다.
POST /v1/repo/worktree — 대화별 worktree
섹션 제목: “POST /v1/repo/worktree — 대화별 worktree”{"user": 7, "project": "group__proj", "conversation": "<UUID>", "baseBranch": "main", "env": []}- 기본 브랜치를 먼저 최신으로 받고(
fetch --depth 1) 그 시점의 커밋에서 새 브랜치chat/<대화 id>로home/work/<대화 id>/<프로젝트>에 worktree 를 만든다 — 대화마다 따로라 한쪽 변경이 다른 쪽 작업 트리에 보이지 않는다. 브랜치 이름이chat/접두라 보호 브랜치와 겹치지 않는다. conversation은 UUID(소문자) 형식만,project는 clone 폴더 이름 형식만,baseBranch는 안전한 브랜치 이름만 받는다(경로 조작·옵션 주입 방지, 아니면 400bad_name). 같은 대화·프로젝트로 다시 만들면 실패한다(이미 있음).- 보호 브랜치 push 거부: runner 의
RUNNER_PROTECTED_BRANCHES(기본main,master,develop,release/*)와repo.IsProtected는 push 래퍼(다음 단계)와 evo-api 도구 검증이 쓴다. 이 단계의 worktree 는 항상chat/…브랜치라 보호 브랜치로 직접 만들어지지 않는다.
POST /v1/repo/push — 대화 브랜치 push
섹션 제목: “POST /v1/repo/push — 대화 브랜치 push”{"user": 7, "project": "group__proj", "conversation": "<UUID>", "env": ["GIT_ASKPASS=..."]}- 원격 브랜치 이름·옵션을 호출 쪽이 정하지 못한다. runner 가 그 대화 worktree 의 현재 브랜치를 git 에서 직접 읽어(호출 쪽 말을 믿지 않는다)
chat/<대화 id>인지 확인한 뒤, 서버가 만든 인자git push --no-tags origin HEAD:refs/heads/chat/<대화 id>만 실행한다.--force·--delete·태그·다른 refspec 을 줄 방법이 없다. - 사용자가 worktree 안에서 브랜치를 바꿨어도 push 대상은
chat/<대화 id>로 고정되고, 그 외에는 거절한다: 보호 브랜치(RUNNER_PROTECTED_BRANCHES)로 바뀜 → 403protected_branch, 임의 이름·다른 대화의 이름·분리된 HEAD → 403wrong_branch, 없는 worktree → 404no_worktree, 형식 오류·모르는 필드(force등) → 400. - 원격이 갈라져 있으면(다른 곳에서 push 됨) 강제로 덮어쓰지 않고 실패한다(응답
ok:false). - 이 거절은 앞단 방어다. 최종 방어선은 GitLab 의 보호 브랜치 설정과 사용자 토큰의 권한이다.
POST /v1/repo/agent-worktree — 에이전트 ID 등록 MR 용 임시 worktree
섹션 제목: “POST /v1/repo/agent-worktree — 에이전트 ID 등록 MR 용 임시 worktree”{"user": 7, "project": "group__proj", "id": "<UUID>", "baseBranch": "main", "env": []}- 에이전트 등록 파일을 커밋해 MR 을 올릴 일회용 worktree 다. 대화 worktree 와 폴더·브랜치 접두가 다르다:
work/agent-<id>/<프로젝트>, 브랜치agent-id/<id>. - 최신 기본 브랜치를 받아 그 커밋에서 만들고(대화 worktree 와 같은 순서:
prune→fetch→add), 이름은id·프로젝트 폴더에서 서버가 정한다(형식 오류·모르는 필드 400bad_name). 정리는 파일 API 삭제(work/agent-<id>— git 등록도 함께 정리)로 한다.
POST /v1/repo/agent-push — 등록용 브랜치만 push
섹션 제목: “POST /v1/repo/agent-push — 등록용 브랜치만 push”{"user": 7, "project": "group__proj", "id": "<UUID>", "env": ["GIT_ASKPASS=..."]}- 대화 push 와 같은 방식이다: worktree 의 현재 브랜치를 git 에서 읽어
agent-id/<id>와 정확히 같을 때만 서버가 만든 인자로 올린다. 다른 이름·보호 브랜치·분리된 HEAD 는 403, worktree 가 없으면 404, 형식 오류·모르는 필드 400.
POST /v1/files/{list,read,delete} — 본인 작업 공간 파일 (창)
섹션 제목: “POST /v1/files/{list,read,delete} — 본인 작업 공간 파일 (창)”{"user": 7, "path": "repos/group__proj/README.md"}- 경로는 홈 기준 상대 경로다(비우면 홈). 절대 경로·
..·NUL 은 400outside_workspace. - runner 는 경로를 직접 열지 않는다. 사용자 uid 로 내려 다시 실행한 헬퍼(
workspace-runner fs-helper)가 홈을 루트로 열어(os.Root— 홈 폴더의 파일 디스크립터를 기준으로 경로 조각마다openat으로 내려가고, 홈 밖으로 나가는 경로·절대 경로 링크를 거절한다) 그 안에서만 동작한다. 경로 문자열을 검사한 뒤 따로 여는 방식이 아니라 검사 뒤 링크를 바꿔치는 TOCTOU 가 통하지 않고, 헬퍼에는 runner 의 SETUID·SETGID·CHOWN 이 가지 않는다(유효 권한 0, 사용자 파일 권한만). 헬퍼는 결과·오류를 JSON 한 줄로 쓴다. - 심볼릭 링크는 따라가지 않는다: 목록에는
"kind":"symlink"로만 표시(대상 경로는 보여 주지 않는다), 읽기는 400symlink, 삭제는 링크 자체만 지운다. 홈 밖(다른 사용자 홈 포함)을 가리키는 링크를 지나가려는 요청은 400 이다. 중간 폴더의 링크는 홈 안에 머무는 것만 따라간다(홈 밖이면os.Root가 거절한다). list응답:{"path","entries":[{"name","kind"("file"|"dir"|"symlink"|"other"),"size","modified"}],"truncated"}— 폴더를 먼저, 최대 2000개(넘으면truncated:true).read응답:{"path","size","text","content","reason"}— 텍스트만 내용을 준다(앞 8KiB 에 NUL 없음 + UT). 바이너리는 내용 없이 정보만(reason:"binary"), 256KiB 초과는reason:"too_large". 폴더는 400is_directory, 없는 파일은 404not_found, 소켓·장치·파이프는 열지 않고 400bad_target.delete응답:{"deleted":true}— 파일·폴더(재귀)·링크. 홈 자체와 최상위 관리 폴더(repos·work)는 400bad_target. worktree 폴더를 지우면 git 등록 정보(repos/<프로젝트>/.git/worktrees/<이름>)도 정리하고(등록이 남으면 git 이 같은 폴더를 다시 만들지 못한다), 대화 폴더(work/<대화 id>)를 지우면 그 아래 worktree 들의 등록을 모두 정리한다. 정리는 worktree 의.git파일과 등록 정보의gitdir파일이 서로를 가리킬 때만 한다(다른 저장소의 등록 정보를 지우지 않게). worktree 생성도git worktree prune으로 없어진 등록을 먼저 정리한 뒤 만든다.- 삭제만 그 사용자의 실행이 진행 중이면 409
busy— 목록·읽기는 대화가 도는 중에도 볼 수 있다(사용자 권한의 읽기 전용 헬퍼이고, 긴 대화가 도는 동안 파일 창을 쓰기 때문). 감사에는 경로·파일 이름·내용을 남기지 않는다.
POST /v1/files/write — 본인 작업 공간 파일 쓰기
섹션 제목: “POST /v1/files/write — 본인 작업 공간 파일 쓰기”{"user": 7, "path": "repos/group__proj/README.md", "content": "..."}- 새 파일을 만들거나 덮어쓴다. 내용은 UT 이고 NUL 이 없어야 하며(아니면 400
bad_request), 경로는 홈 기준 상대 경로다. 부모 폴더는 이미 있어야 한다(만들지 않는다 — 없으면 404not_found). - 경로 안전은 목록·읽기·삭제와 같은 헬퍼(
files.Write+os.Root)가 확인한다: 홈 밖(..·절대 경로) 400outside_workspace, 심볼릭 링크 400symlink(마지막 조각은O_NOFOLLOW, 중간 링크가 홈 밖으로 나가면os.Root가 거절), 폴더 400is_directory, 소켓·장치·파이프 400bad_target, 홈 자체와 최상위 관리 폴더(repos·work) 400bad_target.repos·work안쪽에는 쓸 수 있다. - 파일 이름은 인자로, 내용은 표준 입력으로만 헬퍼에 넘긴다(프로세스 목록·감사에 내용이 드러나지 않는다). 헬퍼는 runner 자신을
fs-helper write <경로>로 다시 실행한 프로세스라 runner 의 권한이 없고 사용자 uid 의 파일 권한만 갖는다. - 내용 상한은
RUNNER_MAX_WRITE_BYTES(기본 256KiB, 0 이면 내용 크기만 보지 않는다 — 본문 상한 1MiB는 그대로다). 넘으면 400bad_request. 새 파일은 0600 으로 만든다. - 공간을 늘리므로 할당량을 먼저 검사하고(넘으면 507
disk_quota, 감사reason:"disk_quota"), 그 사용자의 실행이 진행 중이면 409busy다. 성공 응답은{"written":true,"size":<바이트 수>}. 권한·공간 부족 등으로 쓰지 못하면 500write_failed. - 감사 동작은
files.write이며 사용자 번호·상태·사유·소요 시간만 남긴다. 경로·파일 이름·내용은 남기지 않는다.
POST /v1/usage — 사용자 디스크 사용량 (관리 화면용)
섹션 제목: “POST /v1/usage — 사용자 디스크 사용량 (관리 화면용)”{"user": 7}- 응답:
{"user","bytes","files","dirs","links","truncated","limitBytes","over","top":[{"name","bytes","files"}],"groups":[{"path","bytes","files"}],"groupsTruncated"}.top은 사용자 공간 루트(/work/u<uid>, 홈과 임시 폴더)의 바로 아래 항목(home·tmp)별 크기다. groups는 프로젝트 clone(home/repos/<폴더>)과 대화 worktree(home/work/<대화 id>/<폴더>)별 크기다(창). 경로는 사용자 공간 루트 기준이고, 묶음이 1000개를 넘으면groupsTruncated:true로 크기 순 상위 1000개만 준다(전체 합계는 그대로 정확하다).- 세는 범위는 홈 + 임시 폴더(
tmp)이고, 파일은 겉보기 크기(apparent size)의 합이다. 링크는 따라가지 않고 개수만 센다. 항목 50만 개를 넘으면truncated:true로 그만 센다. - 측정은 파일 API 와 같은 방식으로 사용자 uid 로 내려간 헬퍼가 한다(runner 에는 DAC_OVERRIDE 가 없어 700 홈을 직접 열 수 없다). 공간이 아직 없으면 0 을 돌려주고 공간을 만들지는 않는다.
- 30초 캐시를 쓴다(clone·worktree·삭제·초기화가 성공하면 즉시 버린다). 잴 수 없으면(헬퍼 실패) 500
usage_failed.
POST /v1/workspace/reset — 작업 공간 초기화
섹션 제목: “POST /v1/workspace/reset — 작업 공간 초기화”{"user": 7}- 사용자 공간 루트의 내용을 모두 지우고 기본 폴더(
tmp·home·home/repos·home/work, 0700)만 다시 만든다. 계정(uid)·폴더 소유는 그대로다. 응답{"reset":true}. - 그 사용자의 실행이 진행 중이면 409
busy— 지우는 도중 도구가 파일을 만들면 정리가 반쪽이 된다. 감사 동작 이름은workspace.reset(경로·파일 이름 없음). - evo-api 의 관리 화면·계정 삭제 연동은 workspace-admin.md 를 따른다.
4. 상한과 감사
섹션 제목: “4. 상한과 감사”| 설정(환경 변수) | 기본값 | 설명 |
|---|---|---|
RUNNER_TOKEN |
(필수, 32자 이상) | 서비스 토큰. 값은 로그·응답·감사에 쓰지 않는다 |
RUNNER_ADDR |
:8090 |
듣는 주소(내부 네트워크에서만) |
RUNNER_WORK_DIR |
/work |
사용자 공간 루트(볼륨) |
RUNNER_EXEC_TIMEOUT_SEC |
120 | 실행 시간 상한. 넘으면 프로세스 그룹 전체를 종료하고 timedOut |
RUNNER_MAX_OUTPUT_BYTES |
262144 | 표준 출력·오류 각각의 상한. 넘는 부분은 버리고 truncated |
RUNNER_MAX_BODY_BYTES |
1048576 | 요청 본문 상한 |
RUNNER_MAX_WRITE_BYTES |
262144 (256KiB) | 파일 쓰기(/v1/files/write) 내용 상한. 0 이면 내용 크기 검사를 하지 않는다(본문 상한은 그대로) |
RUNNER_ALLOWED_GIT_HOSTS |
(비움 — clone 모두 거절) | clone 을 허용할 호스트(쉼표 구분, 포트 포함 가능). 배포 설정으로 GitLab 서버 하나를 준다 |
RUNNER_PROTECTED_BRANCHES |
main,master,develop,release/* |
보호 브랜치(정확히 일치 또는 접두/*) |
RUNNER_GIT·RUNNER_GLAB·RUNNER_EVO_WORKFLOW |
이미지의 경로 | 도구 위치 |
RUNNER_USER_QUOTA_BYTES |
10737418240 (10 GiB) | 사용자당 디스크 상한. 세는 범위는 §3 /v1/usage. 0 이면 검사하지 않는다 |
RUNNER_MAX_PROCESSES |
128 | 실행마다 거는 프로세스 수 상한(RLIMIT_NPROC). 사용자 uid 의 프로세스·스레드 수다 |
RUNNER_MAX_FILE_BYTES |
1073741824 (1 GiB) | 실행이 만들 수 있는 파일 하나의 상한(RLIMIT_FSIZE). 넘는 쓰기는 EFBIG·SIGXFSZ |
RUNNER_MAX_ADDRESS_BYTES |
0 (걸지 않음) | 실행의 가상 메모리 상한(RLIMIT_AS). Go 로 만든 도구는 런타임이 주소 공간을 크게 예약하므로 기본값은 걸지 않는다 — 켤 때는 도구가 실제로 쓰는 값보다 넉넉히 준다 |
- 디스크 상한: 실행(
/v1/exec)·clone·worktree(대화·등록용)를 받기 전에 사용량을 재고,bytes >= limit이면 507disk_quota로 거부한다(감사status:"denied",reason:"disk_quota"). 파일 삭제는 상한을 넘어도 된다 — 공간을 비울 수 있어야 한다. 초기화도 막지 않는다. 잴 수 없으면(헬퍼 실패) 넘었다고 단정하지 않고 500usage_failed. - 실행 상한(
rlimit): 위 세 값은 §3 의 헬퍼 재실행 경로에서prlimit64로 soft·hard 를 같은 값으로 건다(자식이 스스로 올리지 못한다).0이면 그 상한은 걸지 않는다. 상한을 걸지 못하면 도구를 띄우지 않고 그룹을 종료하고 500 을 돌려준다. 컨테이너 전체의 메모리·CPU 상한은 compose 가 건다(workspace-deploy.md 3절). - 감사는 표준 출력으로 한 줄 JSON이다:
time·action(exec·repo.clone|worktree|push|agent_worktree|agent_push·files.list|read|write|delete·usage·workspace.reset·auth.denied)·user(번호)·tool(이름만)·args(개수만)·exit·ms·status(ok·denied·timeout·error·busy)·reason(고정 코드). 인자 내용·환경 변수 값·출력·토큰·파일 이름·경로는 남기지 않는다(시험으로 확인).
5. 빌드·시험
섹션 제목: “5. 빌드·시험”sh scripts/workspace/test-runner.sh # 단위 시험(gofmt·vet·go test, root 컨테이너) + 통합 시험(운영과 같은 권한의 컨테이너)sh scripts/workspace/build.sh evo-workspace:local # runner 를 포함한 작업 이미지(172MB)sh scripts/workspace/test-runner-image.sh evo-workspace:local # 이미지의 runner 로 통합 시험 100가지sh scripts/workspace/test-runner-repo.sh evo-workspace:local # clone·worktree·push·파일·사용량 API 통합 시험 72가지(시험 전용 https git 서버)- runner 는 Go 표준 라이브러리만 쓴다(외부 모듈 없음). 소스는
runner/, 시험은runner/internal/*/*_test.go(단위)와runner/test/integration.sh(통합). - 통합 시험 100가지: 사용자 간 파일·폴더·쓰기 거부, 폴더 700·본인 소유, 유효 권한 0, 서비스 토큰·이전 토큰 비노출, 토큰이 디스크·감사에 없음, 홈 밖·절대 경로 400, 허용 밖 도구 403, 토큰 없음·틀림 401, 시간·출력 상한, 동시 실행 409, 감사에 내용 없음. 일부러 버그(폴더 755·환경 상속·root 실행)를 넣으면 각각 해당 시험이 실패하는 것을 확인했다.
- 파일 API 시험 59가지(통합 100가지에 포함): 실제 헬퍼(사용자 uid 로 내려간 같은 바이너리)로 목록·텍스트 읽기·바이너리/256KiB 초과는 정보만·파이프는 멈추지 않고 거절, 링크(홈 안·다른 사용자 홈을 가리키는 링크) 읽기·지나가기·지우기 거절과 대상 보존, 홈 밖(
../)·절대 경로·다른 사용자·NUL 경로 거절, 삭제(링크는 링크만·파일·폴더 재귀·repos/work거절·없는 항목 404), 실행 중 삭제 409·목록 200, 쓰기(새 파일·덮어쓰기 뒤 내용 그대로·부모 없음 404·홈/repos/work자체 400·내용 NUL·모르는 필드 400·링크 쓰기 거절과 대상 보존·실행 중 409·상한 초과 507과 감사disk_quota), 토큰 없음 401, 감사에 파일 이름·내용 없음. 시험 준비(파일·파이프·큰 파일·링크 만들기)가 실제로 성공했는지 확인한다. 버그 주입(목록·읽기도 실행 중 잠금, worktree 등록 정리 생략)이 각각 해당 시험을 실패시키는 것을 확인했다. 단위 시험(internal/files)은 쓰기의 새 파일·덮어쓰기·링크(마지막 조각·중간 폴더)·홈 밖·폴더/특수 파일·홈/repos/work자체·잘못된 UT·NUL·크기 상한을 덮는다. - clone·worktree·push 시험(통합 72가지): 같은 컨테이너에 시험 전용 https git 서버(lighttpd·git-http-backend, 자체 서명 인증서)를 띄우고, 이미지의 git(https 전용·훅 끔 설정 그대로)과 runner 로 확인한다 — 허용 호스트 clone·얕은 clone(커밋 1개)·원격 주소에 토큰 없음, 허용 밖 호스트·메타데이터 주소·http·file·주소의 토큰 거절, 대화 두 개의 worktree 가 따로이고 한쪽 변경이 다른 쪽에 안 보임(파일이 실제로 만들어졌는지 먼저 확인), 브랜치가
chat/<대화 id>, 이름 검증(../·UUID 아님·옵션 같은 브랜치), 다른 사용자가 clone·worktree 를 못 읽음, 감사에 프로젝트 이름·파일 내용 없음. 서버 준비를 기다리지 않으면 첫 clone 이 간헐적으로 실패했다(서버 응답을 기다리도록 고쳐 5번 연속 통과). - push 시험(72가지에 포함): 대화 브랜치 push 성공·원격에
chat/<대화 id>생성, 보호 브랜치(develop)·임의 이름·다른 대화 이름·분리된 HEAD 로 바뀐 worktree 는 403 이고 원격에 그 브랜치가 생기지 않음(각 시험 준비가 실제로 성공했는지도 확인), 원격이 갈라져 있으면 덮어쓰지 않음, 형식 오류·없는 worktree·남의 worktree·모르는 필드 거절, 감사에 커밋 메시지 없음. 버그 주입(보호·이름 검사 생략 → 4개 실패,--force포함 → 2개 실패)으로 확인.PushArgs가HEAD만 올리는 버그는 통합 시험으로 구분되지 않는다 — 현재 브랜치가 이미chat/<대화 id>로 확인된 뒤라 결과가 같기 때문이고, 단위 시험(TestPushArgs_fixedRefspecNoForceNoDelete)이 인자 모양을 직접 검사해 잡는다. 시험 준비가 조용히 실패하면(예:main은 clone 쪽 worktree 가 쓰고 있어 같은 이름으로 만들 수 없음) 시험이 무엇도 확인하지 못하고 통과하므로, 준비 단계마다 성공 여부를 확인한다. - 파일 API·worktree 정리 시험(72가지에 포함): 파일 API 로 worktree 폴더를 지우면 git 등록 정보가 정리되어
git worktree list에서 사라지고(등록이 남으면 같은 폴더를 다시 만들 수 없다), 대화 폴더(work/<대화 id>)를 지우면 그 아래 worktree 들의 등록도 정리된다. 폴더만 지우고 등록을 남긴 상태에서는/v1/repo/worktree가git worktree prune으로 정리한 뒤 만들어지는 것도 실제 git 으로 확인한다. - 사용량·초기화·디스크 상한 시험(통합 100가지에 포함, 14가지): 크기를 아는 파일(홈 13바이트+임시 폴더 2바이트)을 만들고 사용량이 정확히 15바이트·파일 3개·링크 1개로 나오는지,
limitBytes·over를 응답에 담는지, 파일을 지우면 줄어드는지, 실행 중 초기화는 409·끝난 뒤 200·사용량 0·기본 폴더 유지·파일 삭제, 토큰 없음 401. 상한 10바이트로 띄운 두 번째 runner로 상한 초과 시 실행 507·쓰기 507·over:true·감사disk_quota(쓰기는files.write로도 확인)·상한을 넘어도 파일 삭제 200 을 확인한다. 감사에 사용량·초기화 동작이 남고 파일 이름은 없다. 단위 시험은 센 값·링크 개수·50만 개 상한(truncated)·초기화 뒤 폴더 모양·상한 거절·측정 실패 500 을 덮는다. - 에이전트 등록 worktree·push 시험(72가지에 포함, 12가지): 실제 git 으로
agent-id/<id>브랜치와work/agent-<id>/<프로젝트>worktree 생성·현재 브랜치 확인, 등록 파일 커밋 뒤 push 로 원격에agent-id/<id>브랜치 생성, id 형식·모르는 필드 400, 파일 API 삭제로 worktree 와 git 등록 정리, 지운 만큼 사용량이 줄어듦, clone·worktree 가 있는 사용자의 사용량이over:false.--session-agent는 단위 시험으로 본다(evo-workflow 도구일 때만 인자 맨 뒤에 붙고, 다른 도구·잘못된 id 는 400, 감사에 id 없음). - 실행 상한 시험(단위,
internal/execx):ulimit -a로 process=64·file(blocks)=8·vmemory(kbytes)=262144 이 걸렸는지 확인하고,RUNNER_MAX_ADDRESS_BYTES가 0 이면 vmemory=unlimited 인지 확인한다. 사용자 소유 700 폴더를 작업 폴더로 주면 도구가 그 폴더에서 돈다 — runner(root)에게 DAC_OVERRIDE 가 없는 운영 컨테이너에서는 헬퍼가 uid 를 내리기 전에 폴더로 들어가면 exec 가EACCES로 실패하므로, 폴더 이동 순서를 통합 시험(최소 권한 컨테이너)이 함께 지킨다.envIntAllowZero(디스크 상한 0=끔)도 단위 시험으로 본다. - 버그 주입 시험으로 확인(폴더 공유·허용 호스트 우회(SSRF)·주소의 토큰 허용·브랜치 이름 main): 각각 해당 항목만 실패. 로컬에서 소스를 바꿔 시험할 때는
build.sh … --no-cache로 빌드한다 — 이 환경에서는COPY runner/가 바뀐 소스를 감지하지 못하고 캐시를 재사용한 적이 있어(그 상태로 버그를 주입하면 정상 이미지로 시험하게 된다) 처음 얻은 버그 주입 결과가 틀렸다. CI 는 새 클론이라 영향이 없다. - 이 기기·러너 환경은 호스트 폴더를 컨테이너에 마운트하지 못해(폴더가 비어 보임) 소스·시험을 표준 입력(tar)으로 넣는다.
6. 아직 없는 것 (의 나머지)
섹션 제목: “6. 아직 없는 것 (의 나머지)”- 사용자별 GitLab 토큰 등록·암호화 — 지금은 배포 토큰 하나를 쓴다(workspace-token.md). 실행에 붙이는 경로는 다.
- 디스크 상한은 사용량 합산 검사다(파일 시스템 쿼터 아님) —
RUNNER_MAX_FILE_BYTES와 함께 크기를 제한하지만, 이미 쓴 공간은 줄이지 않는다(사용자가 정리하거나 초기화) - compose 배포·네트워크 격리는 workspace-deploy.md 다.
답변은 문서를 근거로 만들지만 틀릴 수 있습니다. 출처 문서를 함께 확인하세요.