# 소비하는 외부 operation 계약

<a id="overview"></a>

Norma가 제공하는 inbound HTTP/RPC route는 **0개**다. 아래는 이 snapshot이 소비하는 outbound HTTP 또는 stdio JSON-RPC operation 전부다. URL의 `/v1` 표기는 실제 suffix를 뜻하며, `BaseURL`에 이미 잘못된 suffix가 있으면 중복될 수 있다.

<a id="model-http"></a>

## Model HTTP

| operation | auth | 주요 input | result·fidelity | retry | timeout·failure |
|---|---|---|---|---|---|
| [Anthropic](evidence:llm-anthropic) `POST {BaseURL}/v1/messages` | `x-api-key`, `anthropic-version`; JSON content type | model, segmented system/cache control, normalized messages, tools, max tokens(0→8192), temperature/stop/thinking/effort, stream | SSE 또는 JSON을 text/thinking/signature/tool-use/stop/usage로 정규화; adjacent same-role merge와 thinking filter가 wire shape를 바꿈 | 공통 establishment: 기본 3회, network/408/429/500/502/503/504, 0.5/1/2s…; mid-stream retry 없음 | SDK 자체 request deadline 없음; caller context/custom `HTTPClient`가 상한. non-200 body를 error에 포함 |
| [OpenAI Chat](evidence:llm-openai-chat) `POST {BaseURL}/chat/completions` | `Authorization: Bearer` | model, role messages/tool_calls, tools, 정확히 하나의 max token key, temperature/stop/thinking/reasoning, stream | SSE/JSON을 neutral blocks로 변환; tool pair 정리, 빈 assistant placeholder, provider-specific reasoning replay 때문에 byte fidelity 없음 | 공통 establishment + 정상 stop인데 content가 전혀 없으면 기본 2회 **전체 prompt 재요청** | SDK 자체 deadline 없음; mid-stream drop은 error. 200 error object도 parser에서 error |
| [OpenAI Responses](evidence:llm-openai-responses) `POST {BaseURL}/responses` | `Authorization: Bearer` | model, instructions, flat message/function items, tools, max_output_tokens, reasoning, `store:false`, stream | named SSE/JSON output을 neutral blocks로 변환; full history 매번 전송, thinking replay는 버림; `previous_response_id` 사용 안 함 | 공통 establishment만; Chat의 empty-response retry 없음 | SDK 자체 deadline 없음; unknown SSE events는 건너뜀 |

공통 rate limiter는 한 provider 객체의 logical request 시작 전에 한 번 적용되고 retry를 추가 count하지 않는다. built-in LLM client는 `Config.Proxy`가 비면 Go `http.ProxyFromEnvironment`를 명시해 `HTTP_PROXY`·`HTTPS_PROXY`·`NO_PROXY`와 각각의 lowercase alias를 읽는다. `REQUEST_METHOD`가 설정된 CGI 환경에서는 HTTP URL에 환경 proxy를 쓰지 않고 오류를 반환하는 표준 라이브러리 예외가 적용된다. custom `HTTPClient`를 host가 주면 `Config.Proxy`와 이 built-in transport 설정은 모두 무시되고 timeout도 그 client가 소유한다. [transport 근거](evidence:llm-http-client) [Anthropic 근거](evidence:llm-anthropic) [Chat 근거](evidence:llm-openai-chat) [Responses 근거](evidence:llm-openai-responses) [retry 근거](evidence:llm-retry)

<a id="webfetch-http"></a>

## WebFetch HTTP

| operation | auth/input | result·fidelity | retry/redirect | timeout·failure |
|---|---|---|---|---|
| `GET <model-supplied http(s) URL>` | SDK auth header 없음; `User-Agent: norma/0.4`; input `url`, optional `extract` | status, selected headers와 body 최대 2 MiB를 읽고 전체 결과 50k chars로 cap. HTML은 Markdown으로 재렌더; `extract`는 comment/script/form/link/meta 신호를 덧붙임. non-HTML은 bytes→string | `http.Client` 기본 redirect 정책으로 최대 10 hop; hop별 scope/auth 정책 없음. configured proxy에서 proxy-like transport error면 direct-intended client로 1회 fallback; HTTP status 자체는 retry 조건 아님 | runner context 30s. static asset suffix와 non-http(s)는 call 전 error result; non-2xx도 body와 함께 성공 tool result 형태 |

redirect 대상과 fallback traffic은 permission callback이 처음 본 URL과 다를 수 있다. proxy가 capture/scope 강제점이면 fallback은 그 경계를 이탈할 수 있다. 다만 fallback이 `InsecureTLS=false`이면 nil Transport의 `http.Client{}`를 만들어 다시 `http.DefaultTransport`와 proxy environment를 상속하므로 실제 direct 연결도 보장하지 않는다. [WebFetch 근거](evidence:webfetch-runner)

WebFetch와 네 search backend의 `newFetchClient`는 built-in LLM transport와 다른 조건 분기를 쓴다. `Proxy`, `CACert`, `InsecureTLS`가 모두 zero면 nil Transport의 `http.Client{}`이므로 `http.DefaultTransport`를 통해 `HTTP_PROXY`·`HTTPS_PROXY`·`NO_PROXY`와 lowercase alias를 상속한다. 세 값 중 하나라도 non-zero면 새 `http.Transport{}`를 만들며, 이때 explicit `Proxy`가 없으면 `ProxyFromEnvironment`를 설정하지 않아 direct가 된다. built-in LLM client는 반대로 custom `HTTPClient`가 없는 한 빈 `Config.Proxy`에서 항상 cloned default transport에 `ProxyFromEnvironment`를 명시한다. [공유 client 근거](evidence:web-tool-http-client) [WebSearch caller 근거](evidence:websearch-config)

<a id="search-http"></a>

## WebSearch provider HTTP

| backend/operation | auth·input | result·fidelity | retry | timeout·failure |
|---|---|---|---|---|
| [DDG](evidence:websearch-ddg) `POST https://html.duckduckgo.com/html/` | key 없음; form `q`, `kl=wt-wt`; browser UA/Referer | HTML title/link/snippet를 parse, DDG redirect URL의 `uddg` 해제; markup 의존 | 없음 | runner 30s; 202/429는 rate-limit error, non-200 error; body 2 MiB cap |
| [Brave](evidence:websearch-brave) `GET https://api.search.brave.com/res/v1/web/search?q=&count=` | `X-Subscription-Token`; count≤20 | JSON title/url/description을 순서대로 변환 | 없음 | runner 30s; HTTP 200만 성공; body 2 MiB cap |
| [Tavily](evidence:websearch-tavily) `POST https://api.tavily.com/search` | JSON body에 `api_key`, query, max_results≤20, `search_depth:basic` | JSON title/url/content를 description으로 변환 | 없음 | runner 30s; HTTP 200만 성공; body 2 MiB cap |
| [DeepSeek](evidence:websearch-deepseek) `POST <normalized-base>/v1/messages` | `x-api-key`, Anthropic version; model, search instruction, server tool `web_search_20250305`, max uses 1..3 | SSE의 `web_search_tool_result` URL/title만 보존; encrypted content/snippet은 버림, URL 중복 제거 | 없음 | runner 120s; streaming만 사용, body stream 2 MiB cap; tool error가 모든 hit을 없앨 때 error |

`web_search` call은 기본 5, 최대 20 results를 요청한다. `WebSearchProbe`는 같은 backend를 직접 부르지만 agent schema/permission/hook pipeline을 거치지 않는다. [search 근거](evidence:websearch-operations)

<a id="mcp-jsonrpc"></a>

## MCP stdio JSON-RPC 2.0

newline-delimited stdio transport이며 HTTP operation이 아니다. protocol-level auth는 없고 host가 child command와 optional environment를 정한다.

| 순서/method | id | input | result·fidelity | retry/timeout/failure |
|---|---:|---|---|---|
| [`initialize`](evidence:mcp-handshake) | 증가 integer | protocol `2024-11-05`, empty capabilities, clientInfo `norma/0.2` | result body는 성공 여부 외에 사용하지 않음; server-negotiated version/capabilities를 저장하지 않음 | retry 없음; caller context 또는 connection close까지 |
| [`notifications/initialized`](evidence:mcp-handshake) | 없음 | empty object | response를 기다리지 않는 notification | write error만 반환 |
| [`tools/list`](evidence:mcp-tools) | 증가 integer | empty object | name/description/inputSchema를 `mcp__server__name` CoreTool로 snapshot | retry/refresh 없음; invalid JSON result는 error |
| [`tools/call`](evidence:mcp-call) | 증가 integer | original remote name와 arguments | `content[].text`를 단순 연결하고 `isError` 보존; non-text/resource/image/annotations/structured result는 보존하지 않음 | retry 없음; RPC error는 error tool result; caller context/connection close까지 |

reader는 최대 8 MiB line을 받고 JSON parse 불가 frame, notification, id 0 response를 건너뛴다. context timeout은 호출자가 제공해야 하며 subprocess lifecycle은 `Client.Close`가 소유한다. [MCP handshake 근거](evidence:mcp-handshake) [MCP tool 근거](evidence:mcp-tools) [stdio 근거](evidence:mcp-stdio)

<a id="grep-bootstrap"></a>

## Grep의 숨은 ripgrep bootstrap

`Grep`은 검색 전 `RipgrepPath`를 부른다. 이 resolution은 검색 metadata 밖에서 다음 순서로 process-wide 한 번 실행된다. host가 미리 `EnsureRipgrep`을 호출해도 같은 경로다. [구현 근거](evidence:ripgrep-bootstrap)

| 단계 | trigger·operation | 결과와 실패 경계 |
|---|---|---|
| 1 | `NORMA_DISABLE_RIPGREP`가 non-empty | 즉시 pure-Go backend; `sync.Once`를 소비하지 않음 |
| 2 | `rg`가 `PATH`에 존재 | 그 executable을 사용; install 없음 |
| 3 | `rg` 없음 + `NORMA_RIPGREP_NO_INSTALL`가 non-empty | install 없이 pure-Go fallback |
| 4 | 위 조건 모두 아님 + `npm` 존재 | exact `npm install -g @vscode/ripgrep`; process 사용자 권한, npm global prefix·registry·proxy 설정으로 network와 전역 filesystem을 변경할 수 있음 |
| 5 | install 뒤 lookup | `PATH`의 `rg`, 이어 `npm root -g` 아래 package binary를 찾고, 어느 단계든 실패하면 오류를 노출하지 않고 pure-Go fallback |

`exec.Command`를 context 없이 사용하므로 bootstrap 자체 timeout/cancel 상한이 없다. npm 부재, network·권한·install·lookup 실패는 모두 정상 fallback처럼 보인다. 따라서 `Grep`의 `RO=Y`, `C=Y`, self permission `allow`와 실제 최초 호출 효과가 일치하지 않으며 중앙 permission callback도 install을 보지 못한다. 승인된 운영에서는 image에 `rg`를 미리 넣거나 `NORMA_DISABLE_RIPGREP=1`/`NORMA_RIPGREP_NO_INSTALL=1`로 이 side effect를 닫아야 한다.
