
결정론적 에이전트 정체성: before_tool_call 훅이 모델이 계속 틀리던 토큰을 채워주다
요약
서브 에이전트가 도구 호출 시 필요한 인증 토큰을 전달받지 못해 발생하는 응답 중단 문제를 분석합니다. 프롬프트에 의존하던 기존 방식 대신 before_tool_call 훅을 사용하여 모델이 누락된 토큰을 자동으로 채우도록 해결책을 제시합니다.
핵심 포인트
- 서브 에이전트는 브리지를 거치지 않아 인증 토큰을 전달받지 못하는 구조적 결함이 있음
- 모델이 인증 토큰을 직접 생성하게 하는 방식은 보안상 취약하며 불안정함
- before_tool_call 훅을 통해 모델의 출력에 필요한 토큰을 결정론적으로 주입하여 해결
Francis는 메인 에이전트(main agent)입니다. 대시보드에서 여러분이 대화하는 대상이죠. Dalí는 창의적인 역할을 수행하는 서브 에이전트(sub-agent)로, 오디오나 이미지 작업을 맡기게 됩니다. 어느 날 우리는 Francis에게 Dalí가 짧은 오디오 클립을 생성하도록 요청했는데, 아무런 응답이 돌아오지 않았습니다. 대시보드에 에러도 없었고, 빨간색 토스트 메시지도 떴으며, 사용자가 인지할 수 있는 타임아웃(timeout)도 발생하지 않았습니다. 그저 침묵뿐이었습니다.
우리는 이 버그를 "Dalí가 응답하지 않음"으로 기록했고, 이는 트래커(tracker)에 한동안 머물렀습니다. 마치 모델이 불안정하거나(flaky model) 프롬프트(prompt)가 잘못된 것처럼 보였는데, 이는 소규모 로컬 모델이 컨디션이 좋지 않아 발생하는 문제라고 탓하고 싶어지는 그런 종류의 문제였습니다.
하지만 둘 다 아니었습니다. 그것은 매우 특정한 형태를 가진 인증 구멍(authentication hole)이었으며, 이를 해결한다는 것은 64자리의 비밀 키(secret)를 언어 모델(language model)의 손에서 완전히 빼내는 것을 의미했습니다.
모델이 볼 수 없었던 문제
우리의 에이전트들은 curl로 Core와 통신하지 않습니다. 이들은 claw_notes, claw_calendar, claw_audio와 같이 타입이 지정된 도구(typed tools)를 호출하며, 이 도구들은 127.0.0.1:7250에서 호스트 측에 실행되는 MCP 파사드(facade)에 의해 제공됩니다(이는 이전 포스트의 주제였습니다). 이러한 모든 도구 호출(tool calls)은 반드시 auth_token 인자를 포함해야 합니다. Core는 토큰을 받아 이를 userId와 권한 범위(scopes) 세트로 해석하고, app.inject()를 통해 인증(auth), 권한 범위 확인(scope check), 의미론적 권한 범위 확인(semantic-scope check), 승인(approval), 핸들러(handler), 감사(audit)로 이어지는 전체 파이프라인을 통해 호출을 전달합니다. 파사드는 이러한 보안 사항을 재구현하지 않습니다. 토큰은 파이프라인이 누가 요청하고 있는지를 알 수 있게 해주는 유일한 수단입니다.
따라서 전체 설계는 단 하나의 질문에 달려 있습니다: 그 토큰은 어디에서 오는가?
이 수정 사항이 적용되기 전에는 프롬프트(prompt)에서 토큰이 왔습니다. 두 개의 브리지(bridge)가 이를 주입했습니다. 웹챗(webchat)을 위한 ws/chat-bridge.ts와 음성(voice)을 위한 ws/voice-bridge.ts가 그것입니다. 대시보드에서 Francis에게 타이핑을 하면, 채팅 브리지가 Francis의 토큰을 프롬프트에 떨어뜨려 놓았고, Francis는 모든 claw_* 호출에 해당 토큰을 복사하도록 지시받았습니다. 브리지를 통해 대화하는 메인 에이전트의 경우에는 이 방식이 — 대체로 — 작동합니다.
sessions_spawn으로 생성된 서브에이전트(subagent)는 그 어떤 브리지(bridge)도 거치지 않습니다. Francis가 Dalí에게 작업을 위임할 때, 경로상에 채팅 브리지(chat bridge)도 음성 브리지(voice bridge)도 존재하지 않습니다. 따라서 Dalí는 토큰을 전혀 전달받지 못했습니다. Dalí가 수행한 모든 claw_* 호출은 인증(auth)에 실패했고, 아무런 오류 메시지 없이 조용히 중단되었습니다. 이것이 바로 "Dalí가 대답하지 않는" 현상의 실체였습니다. 모델이 거부한 것이 아니라, 모델이 무언가를 수행하는 데 필요한 자격 증명(credential)을 아예 전달받지 못한 것입니다.
심지어 메인 에이전트조차 정상적인 경로(happy path)에서는 취약했습니다. 우리는 언어 모델(language model)에게 매 호출마다 64자리의 16진수(hex-character) 비밀 값을 토씨 하나 틀리지 않고 그대로 재현하도록 요구하고 있었습니다. 유능한 모델은 대부분의 경우 이를 해냅니다. 하지만 모델이 직접 다시 타이핑해야 하는 비밀 값은 결국 모델이 틀리게 될 수밖에 없는 비밀 값입니다. 모델이 제대로 학습되지 않은 도구(tool)를 임의로 사용(improvise)했을 때, 토큰 또한 임의로 생성해 버렸습니다.
| 사실 (Fact) | 값 (Value) |
|---|---|
| 실제 런타임 토큰 (Real runtime token) | 64 hex characters |
| ... | |
| 6/6이라는 수치는 함정입니다. 정해진 경로(on rails)를 따를 때 강력한 모델은 완벽하게 신뢰할 수 있는 것처럼 보이며, 당신은 설계가 괜찮다고 결론 내릴 것입니다. 그러다 동일한 모델이 단 한 번의 실수로 42자리의 문자열을 출력하면, 그것은 데이터베이스의 그 무엇과도 일치하지 않게 됩니다. 그리고 당신이 세밀하게 관찰할 수 없는 서브에이전트들은 토큰을 아예 받지 못하기 때문에 100% 확률로 실패하게 됩니다. 구조적으로 취약한(Fragile by construction) 상태인 것입니다. |
왜 단순히 헤더(header)를 사용하지 않는가
명백한 해결책은 헤더(header)를 사용하는 것입니다. MCP 서버들은 보통 정적 헤더(static header)로 인증을 수행합니다. 따라서 토큰을 그곳에 넣고 모델에게 토큰을 들고 다니라고 요구하지 않으면 끝나는 문제입니다.
하지만 여기서는 작동하지 않습니다. OpenClaw에서 mcp.servers 설정은 **전역적(global)**입니다. 헤더는 정적이며 모든 에이전트가 공유합니다. 즉, 세션별 에이전트별 신원(identity)이 존재하지 않습니다. 만약 서버의 헤더에 토큰을 하드코딩한다면, 해당 시스템의 모든 에이전트는 동일한 신원으로 인증될 것입니다. 이는 에이전트별 권한 범위(per-agent scopes)를 설정하는 목적 자체를 무너뜨립니다. Dalí는 audio:*/media:* 권한만 가져야 하며 그 외에는 아무것도 없어야 하고, Francis는 모든 권한을 부여받아야 합니다. 공유된 헤더로는 "이 호출은 Dalí이고, 저 호출은 Francis이다"라는 것을 표현할 수 없습니다.
그러한 전역 정적 제약(global-static constraint)이야말로 ADR-10이 애초에 도구 인자(tool argument)에 정체성(identity)을 포함시킨 정확한 이유입니다. ADR-11은 그 결정을 뒤집지 않습니다. 파사드(facade)는 여전히 app.inject()를 통해 Authorization: Bearer <auth_token>을 전달합니다. 변하는 것은 오직 _누가 그 인자를 채우느냐_뿐입니다. 모델 대신 호스트(host)가 채우게 됩니다.
훅 (The hook)
OpenClaw는 네이티브 플러그인이 before_tool_call 훅을 등록할 수 있게 해줍니다. 이는 호스트에서 실행되며, 도구가 실행되기 전에 모든 도구 호출을 확인하고 그 파라미터(params)를 재작성할 수 있는 함수입니다. 저희는 이를 구현했습니다: openclaw-plugins/claw-identity/index.js이며, 별도의 빌드 단계가 없는 순수 ESM JavaScript입니다. 핵심 코드는 약 5줄 정도입니다.
// openclaw-plugins/claw-identity/index.js의 핵심 — before_tool_call 훅
api.on("before_tool_call", async (event, ctx) => {
const toolName = event?.toolName ?? "";
...
이것이 전체 아이디어입니다. 이름이 파사드 접두사인 claw-os__로 시작하는 모든 도구에 대해, 이 훅은 토큰 맵(token map)에서 ctx.agentId를 통해 호출 에이전트를 조회하고 params.auth_token을 해당 에이전트의 토큰으로 재작성합니다. 모델이 그곳에 넣었던 값은 무엇이든 덮어씌워집니다. 에이전트에 대한 맵 항목이 없는 경우, 훅은 undefined를 반환하고 호출을 건드리지 않은 채 그대로 둡니다. 즉, 인식하지 못하는 호출은 절대 망가뜨리지 않습니다.
모델은 토큰을 절대 보지 못하며, 직접 입력하지도 않고, 환각(hallucinate)을 일으키지도 않습니다. 그리고 키(key)가 ctx.agentId이기 때문에, sessions_spawn을 통해 생성된 하위 에이전트(subagent)는 호스트 측에서 자신만의 스코프(scopes)를 가진 자신만의 토큰을 주입받습니다. 이는 과거에 조용히 실패하곤 했던 바로 그 사례입니다. 실제로 도구를 실행하는 주체에 기반한 결정론(Determinism)입니다.
에이전트별 토큰과 맵 (The per-agent token and the map)
그 맵(map)은 어디에서 오는 걸까요? Core가 이를 발행(mint)합니다. core/src/services/agent-runtime-token.service.ts에 있는 syncUserRuntimeTokens(userId)는 각 에이전트당 name='runtime'을 가진 정확히 하나의 정형화된(canonical) agent_tokens 행을 생성합니다. 데이터베이스에는 sha256 해시값만 저장되며, 평문(plaintext)은 디스크 상의 맵에만 존재합니다.
그 맵은 ~/.config/micelclaw/agent-tokens.json이며, 그 형태는 의도적으로 단순합니다:
{
"paco--francis": "…64 hex chars…",
"paco--dali": "…64 hex chars…"
...
}
키(key)는 훅(hook)이 조회하는 ctx.agentId 값과 정확히 일치하며, <prefix>--<agent> 형식으로 사용자 에이전트당 하나씩 존재합니다. 플러그인이 이 파일을 실시간으로 읽기 때문에, 이 파일을 작성하는 과정에서 매우 주의를 기울였습니다:
// 원자적 맵 쓰기 (Atomic map write) — core/src/services/agent-runtime-token.service.ts
async function writeMapAtomic(map) {
const path = runtimeTokenMapPath(); // ~/.config/micelclaw/agent-tokens.json
...
모드 0o700으로 mkdir을 수행하고, 파일 자체는 0o600으로 설정하며, 교체 작업이 원자적(atomic)으로 이루어지도록 임시 파일 생성 후 이름을 변경(temp-file-plus-rename)하는 방식을 사용합니다. 플러그인은 mtime 캐시(loadTokenMap)를 통해 읽어옵니다. Core가 실제로 파일을 다시 작성할 때만 디스크에서 다시 읽으며, 읽기 오류가 발생하면 마지막으로 알려진 맵으로 대체(fallback)하고 절대 예외를 던지지 않습니다. mtime에 의해 트리거되는 이 재읽기 방식 때문에 쓰기 작업은 반드시 원자적이어야 합니다. 만약 파일을 제자리에서(in place) 덮어썼다면, 플러그인이 파일이 절반만 작성된 상태를 포착하여 잘린(truncated) 토큰을 전달할 수도 있기 때문입니다.
스코프(scope) 또한 토큰만큼 중요합니다. 원하는 스코프 세트는 에이전트의 실시간 역할 권한 부여(role grant)로부터 파생됩니다:
deriveScopesFromSkills(skillScopeDomains(skills), roleScopesFor(agentDbId), perms)
roleScopesFor는 에이전트의 가장 넓은 기본 토큰을 반환합니다. 따라서 Francis는 전체 권한(~17개 스코프)을 유지하고, Dalí는 audio:*/media:*를 유지합니다. 아무것도 다운그레이드되지 않으며, 아무것도 에스컬레이션(escalate)되지 않습니다. 즉, 런타임 토큰은 에이전트가 이미 가지고 있던 정체성을 정확히 담고 있습니다. 그리고 스코프가 실제로 변경될 때만 다시 발행됩니다. scopesEqual 가드(guard)가 그렇지 않은 경우에는 상태를 안정적으로 유지하므로, 매 동기화(sync)마다 자격 증명(credentials)을 계속해서 갈아치우지 않습니다.
전체 과정은 다음 네 부분으로 나뉩니다:
| 부분 | 파일 | 역할 |
|---|---|---|
| 훅 (The hook) | openclaw-plugins/claw-identity/index.js | before_tool_call → auth_token 주입, 우선순위 100 |
| ... |
배포 및 검증 (Rollout and verification)
이 훅은 플러그인이 실제로 로드될 때만 도움이 됩니다. 등록은 멱등성(idempotent)을 가진 configPatch로 수행됩니다. 즉, plugins.load.paths와 plugins.entries.claw-identity.enabled를 설정하는 방식이며, openclaw-bootstrap.service.ts 내의 ensureClawIdentityPluginRegistered()에 의해 부팅 시와 regenerate-tools 실행 시 모두 수행됩니다. 그 후 플러그인은 다음 Gateway 재로드(reload) 시에 로드됩니다.
로그가 증명하기 전까지는 이것이 제대로 작동한다고 믿지 않았습니다. Gateway 로그(/tmp/openclaw/openclaw-YYYY-MM-DD.log)의 두 줄이 모든 것을 말해줍니다:
[claw-identity] plugin cargado; mapa de tokens = /home/victor/.config/micelclaw/agent-tokens.json
[claw-identity] before_tool_call tool=claw-os__claw_notes agent=paco--francis token=ok
첫 번째 줄은 플러그인이 로드되었고 토큰 맵(map)을 해결(resolve)했음을 나타냅니다. 두 번째 줄은 실제 MCP 도구 호출(tool call)에 대해, 올바른 에이전트(agent)를 대상으로 훅이 실행되었으며, Core가 수락한 토큰을 주입했음(token=ok)을 나타냅니다.
그다음은 엔드 투 엔드(end-to-end) 증명입니다. 이는
plugins.entries.<id>가env를 거부합니다. 엔트리 스키마(entry schema)는enabled,hooks,config만 허용합니다.env키가 포함된configPatch는INVALID_REQUEST: Unrecognized key: "env"를 반환하며, 잘못된 키 하나만이 아니라 패치(patch) 전체가 폐기됩니다. 따라서 플러그인은 엔트리의env에서 읽는 대신 기본 경로(~/.config/micelclaw/agent-tokens.json)를 통해 맵(map) 경로를 해결합니다.- MCP SDK는 선언되지 않은 파라미터를 제거합니다. 도구의
inputSchema에 선언되지 않은 모든 도구 호출(tool-call) 인자는 조용히 사라집니다. 우리는 내부적인_conversation_id를 호스트 주입(host-inject)합니다 (Core가 L2 승인 카드를 그것이 발생한 채팅으로 다시 라우팅할 수 있도록 하기 위함). 이 값은 반드시 스키마에 선언되어야 하며, 그렇지 않으면 SDK가 이를 버려버려 라우팅이 조용히 깨지게 됩니다. 토큰 자체와 동일한 유형의 함정입니다. - 개발 시 플러그인에는 로컬 SDK 심볼릭 링크(symlink)가 필요합니다.
index.js는openclaw/plugin-sdk/plugin-entry를 임포트(import)합니다. Node.js가 자체 디렉토리에서/usr/lib/node_modules/openclaw를 가리키는 로컬node_modules/openclaw심볼릭 링크(gitignored)를 참조해야 합니다. 패키징된 배포 버전은 자체 의존성과 함께 플러그인을 설치하지만, 개발 환경에서는 이를 흉내 내야 합니다. - 부트 등록(Boot registration) 시 WebSocket 경합에서 패배할 수 있습니다. Core가 플러그인을 등록하려고 할 때 Gateway WebSocket이 아직 준비되지 않았다면,
configPatch는 폐기되고 재시도됩니다.POST /managed-agents/regenerate-tools가 WebSocket이 안정적으로 올라온 후 등록을 다시 보장하는 이유가 바로 이것입니다. 이는 부팅 시점의 불안정함을 보완하기 위한 안전장치입니다. - WSL에서는
tsx watch가 안정적으로 핫 리로드(hot-reload)되지 않습니다. 코드 변경 사항이 적용되려면 Core를 완전히 재시작해야 하며, 플러그인 자체는 Gateway가 재로드될 때만 로드됩니다. 여기서는 수정 후 새로고침(edit-and-refresh) 방식이 적용되지 않습니다. 실제 재시작된 런타임(runtime)을 통해 검증하거나, 아니면 아무것도 검증하지 못하게 됩니다.
성장 과정
이 파일이 어떻게 변해왔는지 솔직하게 말할 가치가 있습니다. 처음에는 auth_token을 채우는 단 한 가지 기능만 수행하는 약 40줄의 코드로 시작했습니다. 현재의 index.js는 327줄에 달하는데, 이는 해당 훅(hook)이 에이전트 정체성(agent identity)을 키(key)로 하여 호스트 측(host-side)에서 반드시 참이어야 하는 모든 것을 강제하기에 가장 자연스러운 장소임이 밝혀졌기 때문입니다.
이제 이는 에이전트 간 위임 (cross-agent delegation)을 위해 sessions_spawn→sessions_send를 리다이렉션합니다. 또한 기본적으로 거부하는 방식의 교차-사용자 (cross-user) sessions_send 가드(crossUserSendBlock)를 강제하며, 5개의 보드 범위 워크보드 도구 (board-scoped workboard tools)에 wb_<prefix> 형태의 boardId를 주입합니다. 또한 (대화 × 전문가)당 전용 위임 세션 (delegation session)을 보장하며, 앞서 언급한 _conversation_id를 주입합니다. 이 각각의 요소들은 모델이 정체성 (identity)을 정확히 파악할 것이라고 신뢰해서는 안 되는 지점들이며, 해당 훅 (hook)은 이미 모든 도구 호출 (tool call)이 통과하는 단일 초크포인트 (chokepoint)에 위치하고 있습니다. 따라서 책임이 계속해서 누적되어 왔습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기