feat(apitoken,agent-tools): PAT 인증 + MCP 서버 + CLI [ #304 ] - #306
Open
Danto7632 wants to merge 7 commits into
Open
feat(apitoken,agent-tools): PAT 인증 + MCP 서버 + CLI [ #304 ]#306Danto7632 wants to merge 7 commits into
Danto7632 wants to merge 7 commits into
Conversation
MCP·CLI 가 쓸 장수명 자격을 만든다. 현행 인증은 GitHub OAuth → JWT 이고 액세스 토큰이 1시간
이라(JWT_EXPIRATION_MS 기본값) 브라우저 없는 클라이언트가 쓸 수 없었다.
- 해시만 저장한다. ai_provider_credentials 는 키를 벤더 CLI 에 전달해야 해서 복호화 가능한 AES
저장이었지만, PAT 는 우리가 비교만 하면 되므로 원문을 보관할 이유가 없다. DB 를 잃어도 동작하는
토큰이 함께 새지 않고, 재노출 경로가 애초에 존재하지 않는다.
- SHA-256 을 쓴다. bcrypt 류의 work factor 는 엔트로피가 낮은 비밀번호를 느리게 만들려는 장치인데
이 토큰은 256비트 난수라 work factor 가 추측 가능성을 바꾸지 않는다. 바꾸는 것은 모든 인증
요청의 비용뿐이다.
- 평문은 IssuedApiToken 안에서만 존재한다. 별도 타입이라 실수로 흘러다니지 않고, 조회 경로는
전부 그 필드가 없는 ApiToken 을 쓴다. record 기본 toString 이 토큰을 찍으므로 재정의했다.
- 스코프는 READ/WRITE 둘뿐이다. 리소스별 세분화는 엔드포인트가 늘 때마다 낡아서, 어제 발급한
토큰이 조용히 접근을 잃거나 얻는다. 진짜 좁히기는 에이전트 도구 집합에서 되돌리기 어려운
조작을 아예 노출하지 않는 것으로 하고, 이 enum 은 2차 방어선이다.
- last_used_at 은 1시간 스로틀로 갱신한다. 이 필드는 사용자가 낯선 토큰을 알아보라고 있는 것이라
시간 단위면 충분한데, 매 요청 UPDATE 는 인증 경로에 그대로 얹힌다.
- 만료는 expires_at 시각을 포함해 만료로 본다("12:00 만료" 토큰이 12:00:00 에 동작하지 않는다).
테스트 11개.
Claude-Session: https://claude.ai/code/session_01JCngUrveLLJ6HMnhB6mj5c
기존 JWT 필터가 qp_ 접두사를 보고 PAT 경로로 분기한다. - 접두사로 구분한다. JWT 파싱을 먼저 시도하고 실패하면 PAT 로 넘어가는 방식은 에이전트 요청마다 예외를 던지고, "던졌으니 다른 쪽" 이라는 제어흐름은 조용히 엉뚱한 것을 통과시키기 시작한다. - 조회는 해시로 한다. 평문을 비교하는 경로가 없으므로 비교를 잘못 짜서 인증이 뚫리는 종류의 실수가 성립하지 않고, 모르는 토큰은 그냥 행이 없다. - 미상·만료·폐기가 호출자에게 전부 같아 보이게 했다. 어느 쪽인지 알려줄 이유가 없다. - 스코프는 HTTP 메서드로 강제한다. 엔드포인트별 애노테이션이면 새 엔드포인트가 누군가 애노테이션을 기억한 날에야 보호되는데, 메서드 기준은 작성한 날부터 덮인다. 거친 대신 확실하다. 진짜 좁히기는 에이전트 도구 집합에서 되돌리기 어려운 조작을 아예 노출하지 않는 것으로 한다. - 스코프 거절은 403 이고 체인을 끊는다. 401 이면 제대로 인증한 호출자를 재인증하러 보내는 막다른 길이 되고, 체인을 이어가면 익명으로 흘러가 다른 곳에서 엉뚱한 401 이 난다. - last_used_at 갱신 실패는 요청을 죽이지 않는다(best-effort). 테스트 1336개 통과. 필터를 건드렸지만 기존 JWT 경로(정상·폐기 토큰)가 그대로임을 테스트로 고정했다. Claude-Session: https://claude.ai/code/session_01JCngUrveLLJ6HMnhB6mj5c
PAT 의 사용자 접점. 엔드포인트 3개와 실 서버 검증.
- 평문은 발급 응답에서만 나온다. IssuedApiTokenResult 만 그 필드를 갖고, 목록이 쓰는
ApiTokenResult 에는 아예 없다 — 조회 경로 아래에는 흘릴 평문이 존재하지 않는다.
- 폐기는 (id, userId) 스코프 삭제다. 찾아서 소유자를 확인하는 방식이면 그 확인을 빠뜨릴 분기가
생기지만, 스코프 삭제는 남의 토큰이 그냥 안 찾힌다. 그래서 403 이 아니라 404 다.
- 만료 상한 365일. 상한이 있어야 아무도 기억하지 못하는 토큰이 스스로 멈춘다.
실 서버 검증(로컬 기동 후 실제 HTTP, 10건 전수):
발급 201 / 발급된 토큰으로 GET 200 / 그 READ 토큰의 POST 403 / 목록 200 /
목록 응답에 평문 부재 / DELETE 204 / 폐기 직후 401 / 같은 ID 재폐기 404 /
READ 토큰으로 발급 시도 403 / 만료 366일 400
검증용 사용자·토큰은 삭제했다.
그 과정에서 실패한 것은 코드가 아니라 내 검증 스크립트였다 — 이 저장소가 응답을
{status, code, message, data} 봉투로 감싸는데 벗겨진 형태로 파싱했다. 코드는 처음부터 맞았다.
문서: FRONTEND_API_GUIDE §4.13(총계도 실측 22개·112개로 정정), api.md §17,
state.md §4.22, ROADMAP PAT 행과 현재 상태.
테스트 1347개 통과.
Claude-Session: https://claude.ai/code/session_01JCngUrveLLJ6HMnhB6mj5c
사용자의 Claude Code·Codex 가 Qeploy 를 도구로 호출한다. BYOK 와 호출 방향이 반대라 Qeploy 는 추론하지 않고 AI 자격증명을 보지도 중계하지도 않는다 — 컴플라이언스 이슈가 없고 이 경로의 AI 비용은 0 이다. - agent-tools/ 워크스페이스에 @qeploy/client(REST 클라이언트)와 @qeploy/mcp(stdio 서버). 두 표면이 같은 클라이언트를 쓰므로 봉투 해제·인증 헤더·에러 번역을 아는 곳이 하나다. - 읽기 전용 도구 11개. 되돌리기 어려운 조작(프로젝트·서버 삭제, 승인, 비용예산)은 앞으로도 노출하지 않는다. 도구 이름 집합을 테스트로 못박아 추가가 의도적 편집이 되게 했다 — "이름에 deploy 가 없을 것" 같은 부분문자열 검사는 qeploy_list_deployments(읽기)까지 막는다. - API 실패를 프로토콜 예외가 아니라 에러 결과로 돌려준다. 예외면 에이전트가 읽지 못하지만 결과면 모델이 읽고 대응한다 — 만료 토큰이면 무한 재시도 대신 재발급을 안내한다. - 토큰 미설정 안내는 stderr 로 낸다. stdout 은 프로토콜 채널이라 무엇이든 쓰면 깨진다. - 타임아웃을 둔다. 없으면 서버가 안 뜰 때 에이전트의 도구 호출이 영원히 멈춘다. - 읽기 보장은 클라이언트 규약이 아니라 서버 강제다 — READ 스코프 PAT 는 변경 메서드에서 403. 실 stdio 검증: MCP 클라이언트로 서버 프로세스를 실제로 띄워 initialize → tools/list(11개) → tools/call 왕복 확인. 없는 프로젝트는 에러 결과로, 토큰 미설정이면 종료코드 1 이고 stdout 은 오염되지 않는다. 지금은 이 저장소 안에 둔다. API 와 함께 반복하기 위해서고, 자립적인 디렉터리라 공개 저장소 분리와 npm 배포는 오픈소스 공개를 결정할 때 하면 된다. JS 테스트 19개(클라이언트 10, MCP 9). 문서: 설계 문서 단계표, state.md §4.23, ROADMAP. Claude-Session: https://claude.ai/code/session_01JCngUrveLLJ6HMnhB6mj5c
deploy·retry·create_env·update_env·bind_domain·verification_guide. 방어가 세 층이고, 하나가 뚫려도 나머지가 남게 했다. 1. 기본 비활성. QEPLOY_ENABLE_WRITES=true 없이는 도구 목록에 아예 없다. 과하게 적극적인 에이전트의 실패는 틀린 답이 아니라 진짜 배포라, 기본값은 시작할 수 없는 쪽이어야 한다. 2. 서버 강제. READ 스코프 PAT 는 변경 메서드에서 403 이다 — 클라이언트를 우회해도, 플래그를 켜도 막힌다. 읽기 보장이 클라이언트 규약이 아니라 서버 사실이다. 3. 승인 게이트. 배포·인프라 변경의 기존 승인 흐름이 이 경로에도 그대로 적용된다. MCP 표준 destructiveHint 를 붙여 클라이언트가 실행 전 사용자에게 묻게 했다. 우리 산문을 에이전트가 읽어주길 바라는 대신 프로토콜 자체의 수단을 쓴다. deploy 응답이 200 이어도 "배포됨"이 아니라 "승인 대기"일 수 있다. 도구 설명이 그 점을 명시해 에이전트가 사용자에게 사실 아닌 것을 말하지 않게 했다. 되돌리기 어려운 조작은 플래그와 무관하게 노출하지 않는다 — 프로젝트·서버 삭제, 저장소 연결 해제, 승인 결정, 비용예산 변경. 테스트가 그 이름들의 부재를 못박는다. 실 stdio 검증(agent-tools/e2e.mjs): 기본 11개 / 활성 17개, READ 토큰의 배포 시도를 서버가 스코프 사유와 함께 거부, WRITE 토큰은 인가를 통과해 404 까지 도달. 실제 배포는 트리거하지 않았다 — 확인해야 할 것은 인가 계층이 실제로 막느냐이고, 그건 mock 이 세울 수 없는 사실이다. JS 테스트 27개. 문서: 설계 문서 안전장치 절·단계표, state.md §4.23, ROADMAP, README. Claude-Session: https://claude.ai/code/session_01JCngUrveLLJ6HMnhB6mj5c
MC 단위의 브랜치를 `danto/agent-mcp-cli` 에서 `danto/agent-pat` 으로 정정했다. MCP 는 PAT 없이는 인증 자체가 성립하지 않아 나눠 머지할 실익이 없고, 실제로도 한 브랜치에 있다. 로드맵이 존재하지 않는 브랜치를 가리키고 있으면 다음 사람이 그걸 찾으러 간다. Claude-Session: https://claude.ai/code/session_01JCngUrveLLJ6HMnhB6mj5c
읽기 8개 + 쓰기 3개(deploy·retry·env:set). 같은 클라이언트를 감싸므로 인증·스코프·오류 문장이 MCP 와 같다. 종료 코드가 이 CLI 의 실질적 인터페이스다. 0 성공 · 1 실패 · 2 사용법 · 3 인증. 인증을 따로 뺀 것은 토큰이 만료된 파이프라인과 빌드가 깨진 파이프라인이 서로 다른 대응을 요구하기 때문이다. stderr 를 파싱하지 않고 갈라질 수 있어야 한다. 비대화형에서는 확인을 묻는 대신 즉시 거절한다. CI 로그에 뜬 프롬프트는 답할 사람이 없어 러너 타임아웃까지 매달릴 뿐이고, 그건 사실 플래그 하나가 빠진 것이다. 느린 실패보다 빠르고 읽히는 실패가 낫다. stdin 이 닫혀도 매달리지 않는다. 종료된 스트림에서 readline.question() 이 resolve 도 reject 도 하지 않는 것을 실측하고, close 이벤트를 함께 기다려 "아니오" 로 떨어뜨렸다. Ctrl+D 는 취소지 실패가 아니다. env:set 은 API 에 없는 upsert 를 만든다. 중복 (프로젝트, scope, key) 생성은 409 라 목록을 먼저 보고 생성·수정을 고른다. scope 는 필수다 — COMMON 스코프가 없으므로 기본값을 두면 의도하지 않은 환경에 쓰게 된다. secret 전환은 서버가 되돌리기를 막으므로 따로 묻는다. 토큰은 옵션으로 받지 않는다. 명령행 인자는 셸 히스토리와 ps 출력에 남는다. 표는 응답 모양이 어긋나면 원본 JSON 으로 물러난다. 이름이 바뀐 필드를 대시로 채운 표는 변화를 감추지만, 원본은 적어도 사용자의 질문에는 답한다. 실 서버 검증이 mock 이 통과시킨 결함을 잡았다 — 배포 확인 프롬프트가 프로젝트 이름을 보여주려고 개요 응답의 name 을 읽었는데 ProjectOverviewResponse 에는 name 도 projectId 도 없다. 테스트 mock 이 없는 필드를 지어내고 있었다. 이름은 목록 응답에만 있어 그쪽으로 고쳤고 mock 을 실제 모양에 맞췄다. 확인 프롬프트가 이름을 못 보여주면 "프로젝트 12 를 배포할까요?" 가 되어, 대조할 것이 없는 프롬프트가 된다. bind_domain 은 넣지 않았다. 비동기이고 중간에 사용자가 DNS 레코드를 넣어야 끝나므로 한 번 실행하고 끝나는 명령의 모양이 아니다. 실 서버 검증 11건(agent-tools/e2e-cli.sh) 전체 통과. JS 테스트 60개. 문서: README, 설계 문서 5단계·CLI 안전장치 절, state.md §4.23, ROADMAP. Claude-Session: https://claude.ai/code/session_01JCngUrveLLJ6HMnhB6mj5c
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
왜
구독 연동은 불가능하다는 결론(#245 의 조사)에서 나온 대안이다. 우리가 사용자의 AI 를 대신 호출하는 대신, 사용자의 AI 가 Qeploy 를 도구로 호출하게 관계를 뒤집는다. 실행 주체가 사용자 본인의 클라이언트이므로 벤더 약관 문제가 애초에 발생하지 않고, 구독이든 API 키든 사용자가 이미 쓰는 것을 그대로 쓴다.
그 전제가 헤드리스 클라이언트가 쓸 수 있는 자격증명이다. JWT 는 1시간이라 CLI·MCP 가 쓸 수 없다. 그래서 이 PR 은 PAT 부터 깐다.
무엇
PAT (Java) —
V56__add_api_tokens.sql+apitoken도메인qp_접두사로 기존 필터가 JWT 와 분기한다. 접두사가 없으면 토큰 종류를 알아내려 두 경로를 모두 시도해야 하고, 그건 실패 응답을 흐리게 만든다.last_used는 1시간 스로틀. 매 요청 UPDATE 는 읽기 전용 트래픽에 쓰기 부하를 만든다.MCP 서버 (JS) —
agent-tools/워크스페이스@qeploy/client가 응답 봉투({status, code, message, data})·인증 헤더·에러 문장을 한 곳에서 안다. MCP 와 CLI 가 같은 걸 두 번 알 필요가 없다.@qeploy/mcp는 stdio 서버. 읽기 11개가 기본이고, 쓰기 6개는QEPLOY_ENABLE_WRITES=true없이는 목록에 아예 없다.@qeploy/cli는 MCP 를 못 쓰는 에이전트·CI·사람용. 읽기 8 + 쓰기 3.쓰기 도구의 방어가 세 층인 이유
과하게 적극적인 에이전트의 실패는 틀린 답이 아니라 진짜 배포다. 그래서 하나가 뚫려도 나머지가 남게 했다.
되돌리기 어려운 조작(프로젝트·서버 삭제, 저장소 연결 해제, 승인 결정, 예산 변경)은 플래그와 무관하게 미노출이고, 테스트가 그 이름들의 부재를 못박는다.
MCP 표준
destructiveHint를 붙여 클라이언트가 실행 전 사용자에게 묻게 했다. 우리 산문을 에이전트가 읽어주길 바라는 대신 프로토콜 자체의 수단을 쓴다.deploy가 200 이어도 "배포됨"이 아니라 "승인 대기"일 수 있다. 도구 설명이 그 점을 명시해 에이전트가 사용자에게 사실 아닌 것을 말하지 않게 했다.CLI 는 CI 를 1급으로 본다
종료 코드가 이 CLI 의 실질적 인터페이스다. 인증 실패를 일반 실패와 분리한 것은, 토큰이 만료된 파이프라인과 빌드가 깨진 파이프라인이 서로 다른 대응을 요구하기 때문이다 — stderr 를 파싱하지 않고 갈라질 수 있어야 한다.
비대화형에서는 확인을 묻는 대신 즉시 거절한다. CI 로그에 뜬 프롬프트는 답할 사람이 없어 러너 타임아웃까지 매달릴 뿐이고, 실제로는
--yes하나가 빠진 것이다. 느린 실패보다 빠르고 읽히는 실패가 낫다.stdin 이 닫혀도 매달리지 않는다. 종료된 스트림에서
readline.question()이 resolve 도 reject 도 하지 않는 것을 실측하고, close 이벤트를 함께 기다려 "아니오" 로 떨어뜨렸다.env:set은 API 에 없는 upsert 를 만든다 — 중복 (프로젝트, scope, key) 생성이 409 라 목록을 먼저 보고 생성·수정을 고른다.--scope는 필수다. COMMON 스코프가 없으므로 기본값을 두면 의도하지 않은 환경에 쓰게 된다.토큰은 옵션으로 받지 않는다 — 명령행 인자는 셸 히스토리와
ps에 남는다.검증
실제 서버(:8099)에 실제 stdio 로 붙여 확인했다. mock 이 세울 수 없는 사실만 여기서 확인한다.
CLI 도 같은 방식으로 11건 확인했다 — 종료 코드 0·1·2·3 이 문서대로 나오는지, READ 토큰이 3 으로 끝나는지, 파이프로 실행하면 매달리지 않고 2 로 거절하는지.
재현:
agent-tools/e2e.mjs(MCP),agent-tools/e2e-cli.sh(CLI)Java 테스트 전체 통과, JS 테스트 60개 통과.
남은 것
FE 의 PAT 발급 화면 —
Dvely_FE#76 으로 넘겼다. 발급 창구가 웹 UI 뿐이라 그것이 없으면 이 기능 전체가 시작되지 않는다.OSS 저장소 분리·npm 배포는 공개 여부를 정한 뒤. 그때까지
agent-tools/는 API 와 같은 저장소에 둔다 — 계약이 함께 움직이는 동안은 그 편이 낫다.리뷰 포인트
V56은 현재 develop 기준으로 비어 있다. 머지 시점에 다시 확인 필요.JwtAuthenticationFilter가 분기점이다. JWT 경로 회귀가 없는지 봐 달라.https://claude.ai/code/session_01JCngUrveLLJ6HMnhB6mj5c