Playwright Browser
Playwright Browser
이 문서에서는 PlaywrightBrowser capability를 소개해요. 비동기 Playwright를 통해 에이전트에 실제 상태를 가진 Chromium 브라우저를 제공해요. 탐색, 클릭, 타이핑, 스크롤, 히스토리 이동, 페이지 텍스트 추출, JavaScript 실행, 스크린샷까지 할 수 있어요.
출처: 문서
본문
가벼운 웹 도구로 부족할 때 사용하세요. 웹 검색 도구는 페이지를 로드하지 않고도 연구 질문에 답하고, 웹 fetch 도구는 알려진 정적 URL을 처리해요. 이 capability는 둘 다 닿지 못하는 것을 다뤄요. 로그인이나 세션 쿠키 뒤의 페이지, JavaScript로 렌더링되는 SPA, 대화형 다중 단계 흐름 말이에요.
Pydantic AI Harness가 0.x 릴리스인 동안 API는 마이너 릴리스 사이에 바뀔 수 있어요. 바뀌면 deprecation 경고와 릴리스 노트 마이그레이션 안내가 정확히 어떻게 업그레이드할지 (당신 또는 에이전트에게) 알려줘요. 버전 정책을 참고하세요.
어떤 브라우저 capability? — 이 페이지는 호스트 모델에서 브라우저를 구동하는 것, 즉 타입이 지정된 결정적 동작을 한 번에 하나의 도구 호출로 하는 것을 다뤄요. 열린 목표를 브라우저를 대신 구동하는 자율 에이전트에 넘기려면 Browser Use를 참고하세요. 에이전트에 둘 중 하나만 주세요. 각 capability가 자체 브라우저를 실행하므로, 하나가 연 세션은 다른 하나가 볼 수 없어요.
설치 (Installation)
playwright extra가 Playwright를 끌어오고, Chromium은 별도의 바이너리 다운로드예요:
pip install "pydantic-ai-harness[playwright]"
uv add "pydantic-ai-harness[playwright]"
playwright install chromium
uv run playwright install chromium
런타임에 Chromium 바이너리가 없으면 브라우저 도구가 실행을 끝내는 대신 그 결과로 playwright install chromium 힌트를 반환해요. 그래서 셸을 실행할 수 있는 에이전트는 브라우저를 설치하고 이어갈 수 있어요. 실패는 기억되지 않아서 다음 호출은 실행돼요. 프로세스는 BrowserUnavailableWarning도 받아요. 터미널을 보는 개발자는 도구 결과도 트레이스도 볼 수 없기 때문이에요. 처음 실패 시 바이너리를 자동으로 가져오려면 auto_install_chromium=True를 설정하세요.
사용법 (Usage)
from pydantic_ai import Agent
from pydantic_ai_harness.playwright import PlaywrightBrowser
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[PlaywrightBrowser()])
result = await agent.run('Open https://example.com and tell me the page title.')
PlaywrightBrowser는 capability예요. 브라우저 도구셋을 등록하고, 언제 사용할지에 대한 짧은 안내를 시스템 프롬프트에 주입하며, 실행 동안 Chromium 수명 주기를 관리해요.
도구 (Tools)
| 도구 | 시그니처 | 반환 |
|---|---|---|
navigate |
(url, timeout_ms=None) |
페이지 URL, 제목, 보이는 텍스트 (잘림) |
snapshot |
(timeout_ms=None) |
aria-ref 핸들이 있는 접근성 트리 (잘림) |
click |
(selector, timeout_ms=None) |
클릭 후 페이지 텍스트; selector는 CSS 선택자, aria-ref= 핸들, 또는 'x,y' 픽셀 좌표 |
type_text |
(selector, text, sequential=False, timeout_ms=None) |
타이핑 후 페이지 텍스트 (필드 값을 대체하며 제출하지 않음); sequential=True는 실제 키 입력 전송 |
press_key |
(key, selector=None, timeout_ms=None) |
키 입력 후 페이지 텍스트; key는 Playwright 키 이름 (Enter, Escape, Tab, Control+a) |
select_option |
(selector, values, timeout_ms=None) |
<select>에서 옵션 선택 후 페이지 텍스트 |
hover |
(selector, timeout_ms=None) |
호버 후 페이지 텍스트, 호버 전용 메뉴 노출 |
wait_for |
(selector=None, text=None, gone=False, timeout_ms=None) |
요소/텍스트가 나타나면 — 또는 gone=True로 사라지면 — 페이지 텍스트; selector/text 중 하나만 전달 |
screenshot |
(full_page=False, timeout_ms=None) |
페이지 URL 메모 + PNG 이미지 콘텐츠 |
get_text |
(selector=None, timeout_ms=None) |
요소의 텍스트 또는 전체 페이지의 보이는 텍스트 |
scroll |
(direction, x=None, y=None, timeout_ms=None) |
페이지 위치와 텍스트; direction은 up/down/left/right(한 화면) 또는 top/bottom |
go_back |
(timeout_ms=None) |
이전 페이지의 텍스트 |
go_forward |
(timeout_ms=None) |
다음 페이지의 텍스트 |
execute_js |
(script, timeout_ms=None) |
JavaScript 결과 (문자열은 그대로, 객체는 JSON, null은 undefined) |
console_messages |
(errors_only=False) |
콘솔 출력과 잡히지 않은 스크립트 오류, 오래된 것부터 |
tabs |
(action='list', index=None) |
열린 탭, 또는 확인 + 활성 탭 텍스트; action은 list/select/close/new |
handle_next_dialog |
(accept, prompt_text=None) |
다음 alert/confirm/prompt에 어떻게 답할지의 확인 |
network_requests |
(url_contains=None, errors_only=False) |
페이지가 보낸 요청과 상태, egress 정책이 거부한 것 포함 |
모든 페이지 동작은 선택적 timeout_ms를 받아 그 호출 하나의 두 기본값을 모두 재정의할 수 있어요. 재정의는 0보다 커야 해요. 0은 마감 시간을 완전히 비활성화하는데, 이는 capability 기본값으로는 남지만 모델이 고르는 인자로는 아냐.
마감 시간은 전체 도구 호출을 경계로 하며, 그 안의 개별 Playwright 호출을 경계로 하지 않아요. 하나의 navigate는 goto, 로드 상태, 제목, body 읽기, 어쩌면 스크린샷을 기다려요. 각 단계는 다시 전체 숫자 대신 예산의 남은 부분을 받으므로, timeout_ms는 호출이 걸릴 수 있는 최장 시간이에요.
기본값이 하나가 아니라 둘인 이유는 두 실패가 다르기 때문이에요. 놓치는 동작(click, get_text, wait_for)은 보통 선택자가 아무것도 일치하지 않는 것이고, action_timeout_ms(5s)는 그것을 걸린 에이전트로 읽힐 만큼 긴 대기가 아니라 빠르고 읽을 수 있는 실패로 만들어요. 페이지 로드는 정당하게 더 오래 걸리므로, 탐색·로드 정착·브라우저 시작/연결에는 navigation_timeout_ms(60s)를 사용해요.
snapshot은 페이지의 접근성 트리를 반환해요. 모델이 페이지를 읽고 aria-ref=eN 핸들을 얻는 저비용 구조적 방식이에요. 요소를 aria-ref= 핸들로 (그것을 click이나 type_text에 전달) 타기팅하는 것은 모델이 만든 CSS 선택자보다 더 신뢰할 수 있어요. 스냅샷은 iframe 콘텐츠를 포함해요. 시각적 확인(차트, 레이아웃)이 필요할 때만 screenshot을 사용하세요.
type_text는 필드를 채우지만 제출하지 않아요. press_key('Enter')가 제출해요. 네이티브 <select>는 페이지 콘텐츠로 열리지 않으므로 click이 아니라 select_option이 작동해요. type_text는 값을 한 단계로 설정하고 키 이벤트를 발송하지 않아서 더 빠르고 일반 폼에 충분해요. 각 키 입력에 반응하는 필드(자동완성·타입어헤드 위젯, 마스크·포맷 입력, 직접 설정한 값을 무시하는 에디터)에는 sequential=True를 전달하세요.
wait_for는 콘텐츠가 도착하기를 기다리고, wait_for(gone=True)는 사라지기를 기다려요. 이것이 스피너나 오버레이가 무엇으로 대체될지 미리 모를 때 기다리는 방식이에요.
모든 도구는 활성 탭에 작동해요. target="_blank" 링크, 로그인 팝업, 결제 단계는 두 번째 탭을 열고, 그것은 닫히지 않고 열린 채 남아요. tabs('list')는 무엇이 열렸는지 보여주고 tabs('select', index)는 그리로 이동해요. 세션은 최대 8개 탭을 유지하고, 그 이상은 페이지가 연 탭이 닫히고 기록되며 tabs('new')는 거부되고 먼저 하나를 닫으라고 모델에 요청해요. 페이지 대화상자(alert, confirm, prompt)는 답할 때까지 페이지를 차단하고, 그것을 연 동작 전에 handle_next_dialog(accept=True)를 호출하지 않으면 닫혀요. 그 호출은 실행 나머지가 아니라 대화상자 하나만 다뤄요.
screenshot(및 선택적 screenshot_on_navigate 첨부)은 베이스64 문자열이 아니라 BinaryContent로 이미지를 반환해요. 그래서 비전 모델이 텍스트 컨텍스트의 베이스64 벽 대신 이미지를 네이티브로 봐요. 5MB를 넘는 캡처(보통 긴 페이지의 전체 페이지 스크린샷)는 이미지 콘텐츠 대신 경계 있는 오류로 반환돼요. 모델 프로바이더가 과대한 이미지를 거부하고 그 실패가 실행을 중단시킬 수 있기 때문이에요. 뷰포트를 캡처하거나 스크롤하며 구간을 캡처하세요.
브라우저 도구 실패 — 타임아웃, 아무 요소도 일치하지 않는 선택자, 탐색 오류, 실행 중간에 닫힌 브라우저 — 는 에이전트 실행을 중단시키기 위해 raise되지 않고, 모델이 (재시도, 다른 선택자 시도, 재탐색) 작용할 수 있는 오류 문자열로 반환돼요.
옵션 (Options)
| 옵션 | 기본값 | 목적 |
|---|---|---|
headless |
True |
보이는 창 없이 Chromium 실행 (서버·CI에 적합). |
allowed_domains |
None |
탐색·데이터 요청용 egress 허용 목록; None은 모든 공개 호스트 허용. |
policy |
None |
두 약어로 표현할 수 없는 규칙용 전체 EgressPolicy. 그것들과 상호 배타적. |
block_private_addresses |
True |
사설·루프백·링크로컬·기타 예약 주소 거부 — IP로 쓰든 그것을 가리키는 호스트명을 통해 닿든. |
screenshot_on_navigate |
False |
모든 navigate 결과에 스크린샷 첨부. |
max_content_tokens |
4000 |
모든 텍스트 도구 결과의 근사 토큰 예산. |
action_timeout_ms |
5000 |
요소 동작(click, type, read, wait)의 기본 마감. 0은 비활성화. |
navigation_timeout_ms |
60000 |
탐색·로드 정착·브라우저 시작/연결의 기본 마감. 0은 비활성화. |
chromium_sandbox |
True |
렌더러 샌드박스로 실행된 Chromium 실행. 샌드박스가 시작할 수 없는 곳에서만 끄기. cdp_url과 함께면 무시. |
auto_install_chromium |
False |
바이너리가 없으면 Chromium 자동 가져오기. |
storage_state |
None |
시작 시 로드되는 Playwright 저장 상태 (쿠키 + localStorage). |
cdp_url |
None |
하나를 띄우는 대신 이 CDP 엔드포인트에 이미 실행 중인 Chromium 연결. |
인증된 사이트 (Authenticated sites)
storage_state를 전달해 이미 로그인된 브라우저로 시작하세요. 그것은 Playwright 저장 상태 객체 — 쿠키 + localStorage — 로, 시작 시 브라우저 컨텍스트에 로드되어 첫 탐색이 이미 인증돼요.
자신의 코드에서 보이는 브라우저로 로그인해 한 번 캡처하세요:
from playwright.async_api import async_playwright
async def capture_state() -> object:
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=False)
context = await browser.new_context()
page = await context.new_page()
await page.goto('https://example.com/login')
# 열린 창에서 직접 로그인. 캡처는 로그인된 세션만 닿는 페이지를
# 기다리므로, 로그인과 경쟁하지 않고 로그인 후에 실행됨.
# 사람이 타이핑하므로 마감은 길게.
await page.wait_for_url('https://example.com/account', timeout=300_000)
state = await context.storage_state()
await browser.close()
return state
playwright codegen https://example.com --save-storage=auth.json은 같은 구조를 파일에 쓰며, json.loads(Path('auth.json').read_text())로 로드해요. 어느 쪽이든 객체를 capability에 넘기세요:
from pydantic_ai import Agent
from pydantic_ai_harness.playwright import PlaywrightBrowser
state = ... # 위에서 캡처
agent = Agent(
'anthropic:claude-sonnet-4-6',
capabilities=[
PlaywrightBrowser(storage_state=state, allowed_domains=['example.com']),
],
)
옵션은 경로가 아니라 객체를 받아요. 에이전트는 로컬 파일시스템이 지속 가능한 것을 두기에 적합하지 않은 공유 환경에서 자주 실행되기 때문이에요. 상태가 어디 사는지는 capability가 아니라 당신의 결정이에요. 같은 이유로 PlaywrightBrowser.from_spec은 storage_state를 받지 않아요 — spec이 쿠키를 그것을 저장하는 곳 어디로든 옮길 것이기 때문이에요. 대신 구성된 capability에 설정하세요.
capability는 기본적으로 헤드리스로 실행되며 로그인 흐름을 자체적으로 구동하지 않아요. 상태를 대역 외로 캡처하세요. 일반 패턴은 보이는 브라우저로 한 번 로그인한 뒤 헤드리스 실행에 그 상태를 재사용하는 것이에요.
상태를 자격 증명 자료로 취급하세요. 그것이 계정을 사칭할 수 있어요. 소스 컨트롤과 로그에서 빼고, 유지한다면 제한적 권한으로 저장하며, 세션이 만료되면 버리세요. 이것은 Playwright 자체 인증 가이드 경고를 반영해요. 전체 브라우저 프로필을 재사용하기보다 최소 범위 상태(캡처 시 대상 사이트에만 로그인)를 선호하세요.
실행 중인 브라우저에 연결 (Attaching to a running browser)
cdp_url을 설정해 하나를 띄우는 대신 Chrome DevTools Protocol 엔드포인트에 이미 실행 중인 Chromium에 연결하세요:
from pydantic_ai import Agent
from pydantic_ai_harness.playwright import PlaywrightBrowser
agent = Agent(
'anthropic:claude-sonnet-4-6',
capabilities=[PlaywrightBrowser(cdp_url='http://localhost:9222')],
)
로컬 Chromium 바이너리는 관여하지 않으므로 설치 힌트와 auto_install_chromium은 적용되지 않아요. 이것이 관리 브라우저 프로바이더와 벤치마크 하니스가 기대하는 방식이에요. 그들은 브라우저를 시작하고 엔드포인트를 넘겨줘요.
실행은 여전히 자체 브라우저 컨텍스트를 얻으므로, storage_state, 도메인 허용 목록, 사설 주소 차단이 띄운 브라우저와 똑같이 적용되고, 에이전트는 그 Chrome에서 이미 열린 세션을 상속하지 않아요. cdp_url을 개인 일상 브라우저에 지정하는 것은 여전히 더 위험한 선택이에요. 그 프로세스는 로그인한 모든 계정을 담고 있고, 에이전트가 그걸 통해 닿을 수 있는 모든 것은 한 번의 프롬프트 인젝션으로 닿을 수 있기 때문이에요. 범위가 지정된 storage_state로 에이전트용으로 시작된 브라우저를 선호하세요. 프로바이더 엔드포인트는 URL에 인증 토큰을 때때로 포함해요. 그것을 비밀로 취급하세요.
수명 주기 (Lifecycle)
Chromium은 첫 브라우저 도구 호출에 지연 시작되고, 실행이 끝나면(성공·오류·취소) 닫혀요. 브라우저 도구를 절대 호출하지 않는 실행은 Playwright 비용(서브프로세스도 창도 없음)을 지불하지 않아요. 각 에이전트 실행은 자체 페이지와 브라우저를 얻으므로, 동시 agent.run() 호출은 절대 탭을 공유하지 않아요.
그 수명 주기는 capability가 실행마다 만드는 PlaywrightBrowserSession에 살아요. 에이전트 없이 같은 보호된 브라우저를 원할 때 export돼요. 허용 목록, 사설 주소 차단, 서비스 워커 차단, 탭 추적, 대화상자 처리가 모두 함께 온다:
from pydantic_ai_harness.playwright import PlaywrightBrowserSession
async with PlaywrightBrowserSession() as session:
page = await session.ensure_page() # Chromium은 진입이 아니라 여기서 시작
await page.goto('https://example.com')
PlaywrightBrowserToolset도 같은 기준으로 export돼요. 세션을 넘기면 capability의 훅 없이 18개 도구를 얻어요. 정책은 세션에만, 그리고 거기서만 살아 있으므로, 세션이 설치한 보호와 도구가 실행하는 검사가 갈라질 수 없어요. 그것들은 다른 시점에 적용되는 하나의 결정의 두 계층이에요.
임베디드 콘텐츠 (Embedded content, iframes)
페이지 수준 선택자는 프레임 경계에서 멈춰요. 그래서 임베디드 스케줄, 체크아웃 단계, 채팅 위젯은 CSS 선택자로 닿을 수 없고, 대부분 embed인 페이지는 거의 비어 보일 수 있어요. capability는 세 곳에서 이 틈을 메워요:
- 페이지 텍스트를 반환하는 도구(
navigate,click, 선택자 없는get_text등)는 콘텐츠가 있는 각 하위 프레임의 텍스트를 같은 토큰 예산 안에 덧붙여요. wait_for는 페이지와 모든 하위 프레임을 동시에 지켜봐요. 첫 번째 일치가 이겨요.snapshot은 프레임 콘텐츠를 포함하고, 그 ref는 출처 프레임(e4대신f1e4)을 지녀요. 그런 ref를click,type_text,hover,get_text에 넘기면 그 프레임 안에서 해석돼요 — 임베디드 콘텐츠에 닿는 유일한 핸들이에요.
하위 프레임 순회는 경계가 있어서, 응답하지 않는 embed 하나가 동작의 마감을 소진할 수 없어요. 다른 프레임이 반환한 것은 무엇이든 유지돼요.
실행 디버깅 (Debugging a run)
브라우저 에이전트를 따라가기 어렵게 만드는 세 가지가 있어요. 페이지가 보이지 않고, 없는 요소가 느린 요소처럼 보이며, 흥미로운 실패가 도구 호출 사이에 일어나요.
headless=False를 설정해 진짜 창에서 실행을 지켜보세요.- 모든 브라우저 작업은
browser <action>(browser click,browser navigate)이라는 OpenTelemetry 팬을 열고,browser.action,browser.timeout_ms,browser.outcome, 결과url.full을 지녀요. 그 작업 중 페이지가 한 일은 팬 이벤트로 붙어요. 콘솔 출력, 잡히지 않은 스크립트 오류, 응답, egress 정책이 거부한 요청, 페이지가 연 대화상자, 페이지가 연 탭. 팬은 실행 자신의 트레이서로 가므로, Logfire로 계측된 에이전트는 다른 모든 것과 함께 그것을 보고해요. - 에이전트는
console_messages와network_requests로 같은 로그를 읽을 수 있어요. 이것이 HTML이 아니라 API에서 렌더링하는 페이지에서 회복하는 흔한 방식이에요. 요청을 식별하는 것만 기록되고, 응답 body는 절대 기록되지 않아요. 붐비는 페이지는 토큰 예산에 맞지 않을 만큼 많은 항목을 만들고, 가장 오래된 것이 버려져서 호출을 유발한 실패는 살아남아요.network_requests(errors_only=True)는 더 좁혀요. 기록된 URL은 호스트·경로·파라미터 이름을 유지하지만user:password@자격 증명과 자격 증명을 담는 파라미터(token,code,signature등)의 값은 잃어요. 그것이 모델과 텔레메트리 백엔드 양쪽에 닿기 때문이에요. - 걸린 것처럼 보이는 대기는 보통 액션 타임아웃이에요.
action_timeout_ms는 기본 5s라 실패가 빨리 도착해요. 디버깅 중에는 더 낮추고, 오류 문자열의 타임아웃 값을 읽어 느린 페이지와 잘못된 선택자를 구분하세요.
Logfire의 기본 스크러빙은 session, auth, cookie, credit card 같은 패턴과 일치하는 값을 검열하는데, 이는 페이지 콘텐츠에서 예상보다 자주 일치해요. 모든 헤딩이 "session"인 컨퍼런스 사이트는 [Scrubbed due to 'session']으로, 가격 페이지는 [Scrubbed due to 'credit card']로 돌아와요. 도구 결과를 선택적으로 스크러빙해 읽을 수 있게 유지하세요:
import re
import logfire
# Logfire가 검열하는 단어는 공개 페이지의 일상 어휘다.
page_words = {'session', 'cookie', 'credit card', 'auth'}
def keep_browser_results(match: logfire.ScrubMatch) -> str | None:
# 'tool_response'는 계측 버전 2 아래에서 같은 속성이다
if match.path[:2] not in {('attributes', 'gen_ai.tool.call.result'), ('attributes', 'tool_response')}:
return None
matched = re.sub(r'[._\- ]+', ' ', match.pattern_match.group(0)).strip().lower()
return match.value if matched in page_words else None
logfire.configure(scrubbing=logfire.ScrubbingOptions(callback=keep_browser_results))
match.value를 반환하면 원문을 유지하고, None을 반환하면 검열을 유지해요. 콜백은 그것을 만든 도구가 아니라 속성을 받으므로 브라우저 결과로만 제한할 수 없어요. 검열을 유발한 단어로 좁히는 것이 어느 도구가 반환하든 password, api_key, jwt를 계속 검열되게 하는 방법이에요.
제한 사항 (Limitations)
- 세션은 최대 8개 탭을 열어두고, 그 이상 여는 페이지는 초과분이 닫히며 이벤트 로그에 기록돼요.
- 업로드와 다운로드는 노출되지 않아요. 컨텍스트는 다운로드를 거부하고 페이지에 파일을 넣는 도구가 없어요. 둘 다 페이지와 호스트 파일시스템 사이의 아티팩트 계약이 필요하며, #590에서 추적돼요.
- CSS 선택자는 iframe 안 콘텐츠에 닿을 수 없어요. 거기 읽기·작용은
snapshotref를 통해서 해요. - 지속 실행(예:
TemporalDurability)은 에이전트 구성 시 거부돼요. 살아 있는 Chromium 페이지는 활동 재생이나 워커 재시작을 견디지 못해요. - 모델은
aria-ref=핸들(snapshot에서), CSS 선택자, 픽셀 좌표로 요소를 타기팅해요.
Egress와 SSRF
기본적으로 브라우저는 전역 라우팅이 안 되는 주소 — 169.254.169.254(클라우드 메타데이터 엔드포인트), 127.0.0.1, ::1, localhost, *.localhost 이름, RFC 1918 사설 범위 — 를 허용 목록이 없어도 거부해요. 호스트명이 먼저 해석되므로, 이름을 그 주소 중 하나에 지정해도 통과하지 못해요. 에이전트가 로컬 앱이나 내부 대시보드에 닿아야 하면 block_private_addresses=False를 설정하세요.
allowed_domains=None(기본)이면 에이전트가 어떤 공개 URL에도 닿을 수 있어요. 에이전트가 신뢰할 수 없는 입력에 작용할 수 있으면 allowed_domains를 명시적 허용 목록으로 설정하세요. 각 항목은 정확한 호스트와 모든 하위 도메인과 일치해요. example.com은 api.example.com에 닿아요 — Chromium 자체가 쓰는 ASCII 형태로 비교되므로, 국제화 호스트와 xn-- 표기는 같은 판정을 받아요. 와일드카드(*.example.com)로 쓴 항목은 구성 시 raise해요. 어떤 호스트와도 일치하지 않으면서 구성된 허용 목록처럼 읽히기 때문이에요.
허용 목록이 얼마나 멀리 닿는지는 요청이 무엇을 위한 것이냐에 달려요:
| 요청 | allowed_domains에 경계가 되는가 |
|---|---|
| 최상위 탐색 | 예 |
fetch, XHR, EventSource, WebSocket, sendBeacon |
예 |
| 이미지, 스타일시트, 스크립트, 폰트, 미디어 | 아니요 |
| 하위 프레임 문서 | 아니요 |
데이터 요청이 포함되는 이유는 허용된 페이지의 스크립트가 그렇지 않으면 어디서든 읽거나 게시할 수 있기 때문이에요. 수동적 하위 리소스와 하위 프레임 문서는 포함되지 않아요. 자산이 중단된 페이지는 깨진 페이지로 렌더링되고, 허용된 사이트의 ID 프로바이더·결제 단계는 프레임에 살기 때문이에요. 사설 주소 차단은 그 구분을 완전히 무시해요. 모든 프레임과 모든 리소스 유형에 적용돼요. 네트워크 경로가 절대 보지 못하는 WebSocket 연결은 고유한 보호를 받으며, 위 표가 설명하는 동일한 정책을 적용해요.
아직 주소가 아닌 호스트는 사설 주소 차단이 분류하기 전에 해석돼요. 그래서 내부 주소(169.254.169.254.nip.io 같은 와일드카드 DNS 서비스)를 가리키는 이름이 따라가는 대신 거부돼요. 그 조회는 리터럴 검사와 일치하도록 모든 종류에 대해 실행돼서, 표기가 판정을 결정하지 않아요. 답은 캐시돼 요청마다가 아니라 호스트마다 한 번의 조회가 돼요. resolved_kinds는 어떤 종류를 조회할지 설정해요.
2초 안에 답하지 않는 조회는 허용이 아니라 거부예요. 이름을 제어하는 자가 그 조회가 답할지도 제어하므로, 지연은 그렇지 않으면 차단을 지나치는 방법이 되기 때문이에요. 실패가 정직할 때 그 대가는 작아요. 이 프로세스가 해석할 수 없는 이름은 브라우저도 곧 실패할 것이기 때문이에요. 이것 모두 DNS 리바인딩에 대한 보호는 아니에요. Chromium은 연결 전에 이름을 두 번째로 해석하고, 그 사이에 바뀌는 레코드는 그것을 무너뜨려요. 정책은 독립적이고 거부가 이기지요. 허용 목록에 있는 사설 주소도 block_private_addresses를 해제할 때까지는 여전히 거부돼요.
집행은 두 계층에 있어요. 네트워크 라우트 가드가 떠나기 전에 거부된 요청을 중단하고(navigate뿐 아니라 클릭, execute_js, 히스토리 이동 포함), 각 도구가 결과 URL을 다시 검사해 허용되지 않은 콘텐츠가 모델에 닿지 않도록 about:blank로 되돌려요. 서비스 워커는 브라우저 컨텍스트에서 차단되어 그 트래픽이 라우트 가드를 빠져나가지 못해요.
두 필드로 표현할 수 없는 규칙
EgressPolicy가 전체 정책이고, PlaywrightBrowser(policy=...)는 allowed_domains/block_private_addresses 약어 대신 하나를 받아요. 그 필드는 거부 목록(blocked_domains, 모든 것에 이기고 모든 요청 종류에 닿음), apex 전용 일치(include_subdomains=False), 허용 목록이 경계하는 종류(allowlist_reach)를 다뤄요.
from typing import get_args
from pydantic_ai_harness.playwright import EgressPolicy, PlaywrightBrowser, RequestKind
# 목록 밖의 호스트에 대해서는 요청이 무엇을 위한 것이든 아무것도 나가지 않는다.
locked_down = EgressPolicy(
allowed_domains=['example.com'],
allowlist_reach=frozenset(get_args(RequestKind)),
)
browser = PlaywrightBrowser(policy=locked_down)
필드가 설명하지 않는 결정에는 서브클래싱하고 refuse를 재정의하세요. URL, 종류, Playwright 자체 resource_type, 메서드, 요청이 메인 프레임 자신의 문서인지를 받아요:
from pydantic_ai_harness.playwright import EgressPolicy, EgressRequest
class FontsFromAnywhere(EgressPolicy):
def refuse(self, request: EgressRequest) -> str | None:
if request.resource_type == 'font':
return None
return super().refuse(request)
None을 반환하면 요청을 허용하고, 문자열을 반환하면 거부하고 그 문자열을 이유로 기록해요. 모델은 network_requests로 읽을 수 있어요. refuse가 허용하는 것을 좁히는 재정의는 describe도 재정의해야 해요. describe는 모델이 자신의 닿을 범위에 대해 듣는 것이고, 그것은 필드만 읽어요.
어느 정책도 일반 보안 경계가 아니에요. Microsoft 자체 playwright-mcp도 출처 필터를 같은 방식으로 부인해요. 페이지는 허용 목록이 남겨둔 요청 종류를 통해 여전히 바깥으로 신호할 수 있고(이미지·스크립트 URL은 페이지가 넣는 것을 지님), 호스트명은 이 프로세스가 받는 답으로 분류되지만 Chromium은 연결 전에 다시 해석하므로 그 사이 바뀌는 레코드(DNS 리바인딩)는 여전히 이겨요. 그것과, 그것을 닫는 프록시 기반 집행 모드가 #415에서 추적돼요.
신뢰할 수 없는 입력 시나리오에서는 egress 방화벽이 있는 컨테이너나 VM에서 브라우저를 실행하거나 프록시로 앞단을 대고, 결과적 동작에는 하니스의 도구 승인 훅과 짝지으세요. 이것을 보장이 아니라 심층 방어로 취급하세요.
더 읽을거리 (Further reading)
- Browser automation with Pydantic-AI + Playwright (Microsoft) — 이 capability가 라이브 사이트에서 수동 QA를 수행하고, Microsoft Foundry 모델에 연결되며, 실행의 OpenTelemetry 트레이스를 지님.
- Browser Use — 다른 브라우저 capability. 각각 자체 브라우저를 실행하므로 에이전트에 둘 중 하나만 주세요.
- Playwright for Python — 뒤에 있는 자동화 라이브러리이자 선택자 문법의 참조.
- Capabilities
API 참조 (API reference)
PlaywrightBrowser
Base: AbstractCapability[AgentDepsT]
비동기 Playwright를 통한 에이전트용 실제 상태를 가진 Chromium 브라우저. 18개 도구를 추가해요 — navigate, snapshot, click, type_text, press_key, select_option, hover, wait_for, screenshot, get_text, scroll, go_back, go_forward, execute_js, tabs, handle_next_dialog, console_messages, network_requests — 실행 내 도구 호출 간에 유지되는 Chromium 컨텍스트에 의해 뒷받침돼요. 가벼운 웹 도구로 부족할 때 사용하세요: 로그인/세션 쿠키 뒤의 페이지, JavaScript 렌더링 SPA, 대화형 다중 단계 흐름. 쿼리 기반 연구에는 웹 검색 도구를, 정적 URL에는 웹 fetch 도구를 선호해요.
from pydantic_ai import Agent
from pydantic_ai_harness.playwright import PlaywrightBrowser
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[PlaywrightBrowser()])
playwright 선택 extra와 Chromium 바이너리가 필요해요:
pip install 'pydantic-ai-harness[playwright]'
playwright install chromium
Egress: allowed_domains=None(기본)은 에이전트가 닿을 수 있는 URL에 도메인 제한을 두지 않아요. allowed_domains=[...]를 전달하면 탐색과 데이터를 움직이는 요청 종류(fetch, XHR, EventSource, WebSocket, sendBeacon)를 어느 프레임에서든 경계해서, 허용된 페이지가 자산과 ID 프로바이더 프레임을 유지하도록 수동적 하위 리소스·하위 프레임 문서는 경계하지 않아요. 독립적으로 block_private_addresses=True(기본)는 열린 egress에서도 모든 프레임의 모든 요청 종류에 대해 사설·루프백·링크로컬·기타 예약 주소를 거부하며, 호스트명을 먼저 해석해 그 주소 중 하나를 가리키는 이름도 거부해요. 어느 쪽도 일반 보안 경계가 아니에요. 답하지 않는 DNS 조회는 거부되지만 Chromium은 연결 전에 이름을 다시 해석하므로 리바인딩은 닫히지 않고, 프록시 기반 집행 모드는 https://github.com/pydantic/pydantic-ai-harness/issues/415에서 추적돼요. 에이전트가 신뢰할 수 없는 입력에 작용할 수 있으면 allowed_domains를 설정하세요.
Chromium은 첫 브라우저 도구 호출에 지연 시작되고, 실행이 끝나면(성공·오류·취소) 닫혀요. 브라우저 도구를 절대 호출하지 않는 실행은 Playwright 비용을 지불하지 않아요. 브라우저를 시작할 수 없으면 도구가 실행을 끝내는 대신 playwright install chromium 힌트를 반환해서, 셸을 실행할 수 있는 에이전트가 그것을 설치하고 이어갈 수 있고, 도구 결과를 읽지 않는 개발자를 위해 프로세스가 BrowserUnavailableWarning을 받아요. 대신 바이너리를 자동으로 가져오려면 auto_install_chromium=True를 설정하세요. 하나를 띄우는 대신 이미 실행 중인 Chromium(관리 브라우저 프로바이더, 벤치마크 하니스)에 연결하려면 cdp_url을 설정하세요.
모든 브라우저 작업은 OpenTelemetry 팬 안에서 실행되고, 그 중 페이지가 한 일(콘솔 출력, 응답, egress 정책이 거부한 요청, 연 대화상자, 연 탭)은 그 팬에 팬 이벤트로 붙으며 에이전트가 console_messages와 network_requests로 읽을 수 있어요.
지속 실행(예: TemporalDurability)은 지원되지 않아요. 지속성은 도구 호출을 활동으로 재생하지만 살아 있는 Chromium 페이지는 재생이나 워커 재시작을 견디지 못하므로, 하나의 에이전트에 둘을 결합하면 에이전트 구성 시 UserError를 발생시켜요.
속성 (Attributes)
- headless — 보이는 창 없이 Chromium 실행.
True는 서버·CI에 적합. 타입:bool기본:True - allowed_domains — Egress 허용 목록.
None(기본)은 모든 공개 호스트 허용. 각 항목은 정확한 호스트와 모든 하위 도메인과 일치하므로example.com은api.example.com에 닿아요. 최상위 탐색과 페이지 스크립트가 데이터를 움직이는 데 쓰는 요청(fetch, XHR, EventSource, WebSocket,sendBeacon)을 경계하고, 수동적 하위 리소스·하위 프레임 문서는 두어 허용된 페이지가 CDN 자산과 ID 프로바이더 프레임을 유지해요. 두 계층에서 집행: 네트워크 라우트 가드가 떠나기 전에 거부된 요청을 중단하고, 각 도구가 결과 URL을 다시 검사해 허용되지 않은 콘텐츠가 모델에 닿지 않도록about:blank로 되돌려요. 이것으로 표현되지 않는 것(거부 목록, apex 전용 일치, 모든 요청 유형 잠금, 자신의 규칙)에는policy로EgressPolicy를 전달하세요. 타입:list[str] | None기본:None - policy — 위 두 약어로 표현할 수 없는 규칙용 전체 egress 정책.
allowed_domains와block_private_addresses(미설정 시 기본 정책을 만듦)와 상호 배타적. 필드가 다루지 않는 결정에는EgressPolicy를 서브클래싱하고refuse를 재정의하세요. 정책이 임의 코드를 지닐 수 있으므로from_spec에는 없어요. 타입:EgressPolicy | None기본:None - block_private_addresses — 모든 프레임의 모든 요청 종류에 대해 전역 라우팅이 안 되는 주소 거부. 클라우드 메타데이터 엔드포인트(
169.254.169.254), 루프백(127.0.0.1,::1,localhost), RFC 1918 범위를allowed_domains와 무관하게 다뤄요. 열린 egress에서도 닿을 수 없어요. 호스트명이 먼저 해석되어 그 주소 중 하나를 가리키는 이름도 거부되고, 조회가 답하지 않는 이름도 거부돼요. 에이전트가 로컬 앱이나 내부 대시보드에 닿아야 하면False설정. 타입:bool기본:True - screenshot_on_navigate — 모든
navigate결과에 스크린샷(이미지 콘텐츠로) 첨부. 타입:bool기본:False - max_content_tokens — 텍스트 도구 결과의 근사 토큰 예산. 타입:
int기본:DEFAULT_MAX_CONTENT_TOKENS - action_timeout_ms — 요소 동작(click, type, read, wait)의 기본 마감(밀리초). 탐색 예산보다 의도적으로 짧아요. 놓치는 동작은 보통 아무것도 일치하지 않는 선택자이고, 긴 마감은 모델이 반응할 수 있는 빠른 실패가 아니라 걸린 에이전트처럼 보이게 해요. 요소가 느리게 나타나는 페이지에서는 올리세요. 타입:
int기본:DEFAULT_ACTION_TIMEOUT_MS - navigation_timeout_ms — 탐색·로드 정착·브라우저 시작/연결의 기본 마감(밀리초). 타입:
int기본:DEFAULT_NAVIGATION_TIMEOUT_MS - chromium_sandbox — 렌더러 샌드박스로 실행된 Chromium 실행. Playwright 자체와 달리 기본 켜짐. 이 capability는 아무도 검증하지 않은 페이지를 열고, 샌드박스는 조작된 페이지가 손상시킨 렌더러가 호스트에 닿는 것을 막는 것이에요. 샌드박스가 시작할 수 없는 곳(필요한 커널 권한이 없는 컨테이너가 보통)에서는
False로 하고, 렌더러 익스플로잇이 에이전트 자신의 접근으로 실행된다는 것을 받아들이세요.cdp_url이 설정되면 무시돼요. 그 브라우저는 이미 자체 구성으로 실행 중이에요. 타입:bool기본:True - auto_install_chromium — 첫 실패 시
playwright install chromium으로 Chromium 바이너리 가져오기. 기본 꺼짐. 라이브러리는 부작용으로 브라우저를 다운로드하지 말아야 해요. 바이너리가 없으면 브라우저 도구가 명확한 설치 힌트를 반환하고, 셸을 실행할 수 있는 에이전트가 그것에 작용할 수 있어요. 자동 다운로드를 선택하려면True설정. 타입:bool기본:False - storage_state — 시작 시 브라우저 컨텍스트에 로드되는 Playwright 저장 상태(쿠키, localStorage).
await context.storage_state()또는playwright codegen --save-storage가 쓴 파일을 위한json.loads(Path('auth.json').read_text())로 자신의 코드에서 얻어요. 그래서 로그인이 당신이 제어하는 곳에서 실행되고 자격 증명이 에이전트가 실행되는 곳 어디로든 닿을 필요가 없어요. 이것은 계정과 동등한 세션 자료예요. 소스 컨트롤과 로그에서 빼고, 세션이 만료되면 버리세요. 경로가 아니라 객체여서 capability가 읽을 수 있는 파일시스템을 가정하지 않아요. 에이전트는 로컬 디스크가 지속도 쓰기도 안 되는 곳에서 자주 실행되므로, 상태가 어디 사는지는 호출자의 결정으로 남아요. 같은 이유로from_spec은 받지 않아요. 타입:StorageState | None기본:field(default=None, repr=False) - cdp_url — 이 Chrome DevTools Protocol 엔드포인트에 이미 실행 중인 Chromium에 연결. 설정되면 capability가 띄우는 대신 연결하므로 로컬 Chromium 바이너리가 필요 없고
auto_install_chromium은 적용되지 않아요. 에이전트에 브라우저를 넘겨주는 관리 브라우저 프로바이더·벤치마크 하니스에 사용. 실행을 위해 여전히 새 브라우저 컨텍스트가 만들어지므로storage_state와 egress 보호가 띄운 브라우저처럼 적용되고, 실행은 그 Chrome에서 이미 열린 세션을 상속하지 않아요. 프로바이더 엔드포인트는 때때로 URL에 인증 토큰을 지녀요. 그것을 비밀로 취급하세요. 타입:str | None기본:field(default=None, repr=False)
메서드 (Methods)
- for_agent — 지속 실행 에이전트에 바인딩을 거부. 지속 실행(예:
TemporalDurability)은 에이전트 구성 시 캡처한 도구셋을 감싸고 도구 호출을 활동으로 재생해요. 살아 있는 Chromium 페이지는 활동 경계나 워커 재시작을 가로질러 체크포인트될 수 없으므로, 그 조합은 작동할 수 없어요. 이 보호 없이는 첫 브라우저 도구 호출 깊숙이에서 실패해요. 탐지는 번들된 Temporal/DBOS/Prefect 통합의 공통 베이스인BaseDurabilityCapability와 일치해요. Pydantic AI는 지속성 티어에 대한 공개 마커를 노출하지 않고,innermost순서 위치는 그것이 아니에요.InputGuard도innermost를 선언하므로, 순서만으로는 지원되는 가드+브라우저 조합을 거부할 것이에요. - for_run (
@async) — 실행마다 새 인스턴스를 반환해 동시 실행이 페이지나 브라우저를 절대 공유하지 않게 해요. - get_toolset — 18개 브라우저 도구 제공.
- get_instructions — 브라우저에 대한 언제 사용할지 안내.
- wrap_run (
@async) — 실행의 브라우저 세션을 열어두고, 실행이 어떻게 끝나든 해제. Chromium은 여기가 아니라 첫 브라우저 도구 호출에 시작하므로, 결코 브라우징하지 않는 실행은 절대 하나를 띄우지 않아요. 실행의 트레이서가 여기서 채택되어 브라우저 팬이 이 모듈이 고른 트레이서가 아니라 에이전트의 계측 설정을 따르게 해요. - from_spec (
@classmethod) — 직렬화 가능한 spec 옵션(모든 필드는 평범한 스칼라/리스트)으로 capability 구성. spec은 세션 자격 증명이 아니라 연결 구성을 지녀요.storage_state는 의도적으로 없어서, 그것을 지명하는 spec은 쿠키를 spec을 저장하는 곳으로 옮기는 대신 raise해요. 대신 구성된 capability에 설정하세요.