Cloudflare (엣지, 고급)
코딩 에이전트가 엣지에서 실행되도록 하려 합니다. UI, 에이전트 루프, 샌드박스 컨테이너를 하나의 Cloudflare Worker에 구성하고, 에이전트가 빌드한 결과의 라이브 미리보기 URL을 사용자에게 제공할 수 있습니다. @tanstack/ai-sandbox-cloudflare는 cloudflareSandbox 프로바이더와 즉시 사용할 수 있는 에이전트 코디네이터를 제공하므로 Worker ↔ 컨테이너 연결을 직접 구현할 필요가 없습니다.
이 페이지에서는 엣지에 특화된 고려 사항을 다룹니다. 프로바이더와 무관한 기본 사항(워크스페이스, 도구, 정책, 수명 주기)은 개요에서 시작합니다.
두 가지 실행 모델
하네스 루프와 도구 브리지를 어디에서 실행할지는 배포 시 선택합니다. Cloudflare 계층은 두 가지 형태를 지원합니다.
DO가 컨테이너를 구동하는 방식(기본값)
오케스트레이터(Durable Object)가 chat()과 도구 브리지를 실행하고, 컨테이너는 에이전트 CLI만 실행합니다. 브리지는 오케스트레이터 자체의 fetch 핸들러에서 제공되므로 원시 TCP 리스너가 필요하지 않습니다. 에이전트는 컨테이너 → 오케스트레이터 경계를 넘어 브리지에 접근하므로 전체 MCP 프로토콜이 이 경계를 통과합니다. examples/sandbox-cloudflare TanStack Start 앱에서 UI, 에이전트, Durable Objects, 컨테이너를 하나의 Worker에 구성한 예를 확인할 수 있습니다.
공동 배치(컨테이너 내부)
하네스 루프와 도구 브리지가 모두 컨테이너 내부에서 실행됩니다. 컨테이너 내부 샌드박스는 네이티브 stdin과 localhost node:http 브리지를 사용하는 localProcessSandbox()일 뿐입니다. 오케스트레이터로 다시 넘어가는 것은 호스트 도구 실행뿐입니다. chat() 도구의 execute() 클로저(DB, 시크릿, 앱 상태)는 컨테이너가 아니라 오케스트레이터에 있습니다. 공개 인터페이스는 전체 MCP 프로토콜에서 인증된 단일 도구 실행 호출로 축소됩니다.
createCloudflareSandboxAgent({ mode: 'colocated' })와 @tanstack/ai-sandbox-cloudflare/runner의 runInContainerHarness 컨테이너 프로그램을 사용해 활성화합니다. 이 연결 지점은 @tanstack/ai-sandbox의 네 가지 export로 구성됩니다. 오케스트레이터는 toolDescriptors(tools)로 도구를 직렬화해 전송하고, 컨테이너는 remoteToolStubs(descriptors, executor)로 도구를 재구성합니다. 각 스텁의 execute()는 RemoteToolExecutor에 위임하며(httpRemoteToolExecutor(url, token)은 { name, args }를 POST로 다시 보냄), 오케스트레이터는 executeHostTool(tools, name, args)로 해당 호출 하나에 응답합니다.
import { chat } from '@tanstack/ai'
import { grokBuildText } from '@tanstack/ai-grok-build'
import {
defineSandbox,
defineWorkspace,
httpRemoteToolExecutor,
remoteToolStubs,
withSandbox,
} from '@tanstack/ai-sandbox'
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
import { request } from './run-request'
// Inside the container: the orchestrator POSTed `{ messages, toolDescriptors,
// toolExecUrl, toolExecToken }`. Rebuild its tools as stubs whose execute()
// POSTs back; the adapter bridges them over the in-container localhost MCP
// transport, and only that one tool-exec call leaves the container.
chat({
threadId: request.threadId,
adapter: grokBuildText('grok-build'),
messages: request.messages,
tools: remoteToolStubs(
request.toolDescriptors,
httpRemoteToolExecutor(request.toolExecUrl, request.toolExecToken),
),
// The in-container sandbox is just local-process (native stdin + a localhost
// node:http bridge).
middleware: [
withSandbox(
defineSandbox({
id: 'in-container',
provider: localProcessSandbox(),
workspace: defineWorkspace({ source: { type: 'none' } }),
}),
),
],
})
엣지에서의 영속 실행
Cloudflare는 이식 가능한 영속 실행 프로토콜의 로그 우선 계층이며, 별도의 아키텍처가 아닙니다. 먼저 영속 실행 설명을 읽으세요. 이 절에서는 엣지에서 달라지는 부분과 의도적으로 달라지지 않는 부분을 설명합니다.
코디네이터 Durable Object는 각 실행을 소유하고, 방출된 모든 청크를 단조 증가하는 seq에 따라 DO 스토리지가 지원하는 실행 로그(DurableObjectRunEventLog)에 영속화합니다. 클라이언트는 커서부터 해당 로그를 이어 읽기만 하므로 새로 고침, WebSocket 연결 끊김, 청크 사이에서 절전 상태가 된 코디네이터가 샌드박스를 전혀 건드리지 않고 다시 연결됩니다. 저널은 드라이버 측에서 이식 가능한 역할(활성 재연결과 드라이버의 재개 위치)을 담당하며, 우선순위 규칙도 이식 가능한 방식입니다. 클라이언트에 표시할 내용은 로그가 우선하고, 드라이버가 재개할 위치는 저널이 우선합니다.
진정으로 Cloudflare에 특화된 부분은 DO의 감독되는 수명입니다. 드라이버는 모든 요청보다 오래 실행되고 alarm()이 스케줄러를 제공합니다. Cloudflare 외부에서는 테이크오버가 정확히 이를 대신하므로 모든 계층에서 저널이 계속 필수입니다.
여기서 "하나의 이식 가능한 프로토콜"은 단순한 표현이 아닙니다. 코디네이터는 core의 실행 드라이버(@tanstack/ai-sandbox의 RunController)로 실행을 구동하고, 두 어댑터를 통해 DO 로그에 연결합니다. runLogStore는 로그를 core의 RunStore로 노출하고, runLogStream은 로그의 한 실행을 core의 StreamDurability로 노출합니다. 두 어댑터는 직접 조합하는 앱을 위해 @tanstack/ai-sandbox-cloudflare/agent에서 모두 export되며, 이 덕분에 이식 가능한 구성 요소(alignToStoredLog, replayRunStream)가 memoryStream 또는 durableStream에 대해 작동하는 것과 같은 방식으로 DO 로그에 대해 작동합니다.
전체 용어는 core의 용어를 사용합니다. 상태는 completed / failed / aborted이고, 레코드는 core의 RunRecord와 로그 자체의 lastSeq 커서(RunLogRecord)입니다. 1.0 이전 레이아웃(done / error 상태, createdAt/updatedAt 필드)으로 Durable Object가 영속화한 레코드는 처음 읽을 때 즉시 마이그레이션됩니다. 실행할 작업은 없지만 다음 사항에 유의하세요.
GET /runs/:id와 WebSocket의 종료 status 프레임에는 이제 통합된 상태 문자열과 필드 이름이 포함됩니다.
세 계층, 세 위치
이들을 분리해서 유지해야 합니다. Cloudflare에서 각 계층은 서로 다른 위치에 있으며, 이를 혼동하면 워크스페이스 바이트가 /tmp에 "영속적으로" 저장되는 문제가 발생합니다.
| 계층 | Cloudflare에서의 위치 | 관련 문서 |
|---|---|---|
| 실행 + 이벤트 | 코디네이터 DO의 실행 로그(DO 스토리지) | 실행 저널, 테이크오버 |
| 워크스페이스(에이전트가 편집하는 파일) | 컨테이너 파일 시스템 — 영속적이지 않음; 생성 시 다시 프로비저닝해야 함 | 워크스페이스, 프로비저닝 |
| 아티팩트(보존할 가치가 있는 출력) | 영속성 BlobStore / ArtifactStore를 통한 R2 | 생성된 파일 보존 |
엣지 영속성이 보장하지 않는 것
- 여기서는 저널이 영속적이지 않습니다.
cloudflareSandbox는durableFilesystem: false를 선언하므로/tmp저널은 컨테이너 인스턴스와 정확히 같은 기간 동안만 유지됩니다. 이 계층에서는 DO 로그가 영속적인 복사본이고 저널은 드라이버 복구용이므로 괜찮지만, 저널을 기록의 원본으로 취급해서는 안 된다는 의미입니다. - 새로 고침에 대한 안전성은 절전 상태에서도 살아남는다는 뜻이 아닙니다. 로그는 다시 연결하는 클라이언트를 안전하게 만듭니다. 컨테이너를 계속 깨어 있게 하지는 않으므로 컨테이너가 중지되면 다른 환경에서와 마찬가지로 실행 중간에 에이전트 프로세스가 종료되고, 로그가 보존하는 것은 그 시점까지 방출된 모든 내용입니다.
sleepAfter의 함정입니다. Sandbox 컨테이너 클래스에는sleepAfter유휴 시간이 설정되어 있습니다. 분리되었지만 조용한 실행(연결된 사용자가 없고 에이전트가 생각하거나 대기 중인 경우)은 유휴 상태처럼 보일 수 있으며 유휴 컨테이너는 절전 상태가 됩니다. 로그가 앞부분을 충실히 보존했는데도 실행이 "이유 없이 종료된" 것처럼 보이는 것이 증상입니다. 실제로는 컨테이너 수명 주기가 구성된 대로 작동하는 상황이 영속성 실패처럼 읽히는 것입니다. 분리된 실행에 의존하기 전에 가장 긴 예상 조용한 구간보다sleepAfter를 충분히 길게 설정합니다.threadId로 샌드박스 이름을 지정합니다. 고정 ID보다defineSandbox({ id: input.threadId, … })(examples/sandbox-cloudflare에서 사용하는 방식)를 선호해야 합니다. 그러면 DO가 축출된 뒤에도 다시 연결,exposePreview,reuse: 'thread'가 모두 동일한 컨테이너를 가리킵니다.
연결 해제는 분리하고, 중지는 취소합니다
이 경로에서도 이식 가능한 규칙이 적용됩니다. 탭을 닫거나 WebSocket 연결이 끊겨도 꼬리 부분만 분리되며, DO는 계속 실행을 구동하고 로그를 채웁니다. 중지는 명시적인 취소이며 이 프로바이더에서는 실제 효과가 있습니다. killableProcesses가 false(Workers RPC 경계를 넘어 신호가 전달되지 않음)이므로 에이전트를 실제로 중지하는 유일한 취소 방법은 컨테이너를 삭제하는 것입니다. 컨테이너를 삭제하지 않고 실행을 중단해 로그만 aborted로 표시하면, 중지되었다고 표시하는 UI 뒤에서 에이전트가 계속 실행되고 비용도 계속 발생합니다. 중지할 수 없는 프로바이더에서 취소가 의미하는 것을 참조하세요.
콜백 호스트: 브리지와 미리보기
두 모델 모두 컨테이너는 비격리 컴퓨팅입니다 — 서비스 바인딩이나 프로세스 내 호출을 통해 워커에 접근할 수 없으며 네트워크만 사용할 수 있습니다. 따라서 컨테이너의 콜백 URL 은 실제 호스트가 필요합니다. 서로 다른 리처를 갖는 두 개의 명확한 표면이 있으며, resolveBridgeOrigin 과 resolvePreviewHost (둘 다 @tanstack/ai-sandbox-cloudflare/agent 에서) 를 통해 올바른 값으로 해결됩니다.
-
브리지 / tool-exec (컨테이너 → Worker:
/_bridge,/tool-exec). Worker에 접근하기만 하면 됩니다.PUBLIC_HOSTNAME은 선택 사항입니다. 설정하지 않으면POST /runs트리거 요청에서 호스트를 파생하므로*.workers.dev배포가 구성 없이 작동하고, 로컬 개발에서는host.docker.internal(Docker 호스트 게이트웨이,http사용)을 사용하므로 터널이 필요하지 않습니다.요청에서 파생하는 방식은 Cloudflare에서는 안전하지만 일반적인 Node 서버에서는 안전하지 않습니다. 엣지는 호스트 이름이 소유한 경로와 일치할 때만 Worker로 요청을 전달하므로 요청의
Host는 항상 소유한 호스트 이름 중 하나이며 공격자가 선택할 수 없습니다. URL에 포함된 실행별 bearer 토큰도 도메인 외부로 유도할 수 없습니다. 일반 Node에서는Host헤더를 공격자가 제어할 수 있으므로, 그 환경에서 요청 파생은 토큰 탈취 / SSRF 벡터가 됩니다. (엣지가 아닌 브리지는 도구를 참조하세요.) -
미리보기 (브라우저 → Worker → 컨테이너:
exposePort). 와일드카드 DNS가 필요하므로PREVIEW_HOSTNAME은 별도의 설정입니다. 로컬에서는*.localhost를 사용합니다(브라우저가 설정 없이 루프백으로 확인하므로 터널 없이도 미리보기가 로컬에서 작동합니다). 배포 환경에서는*.<domain>경로가 있는 사용자 지정 도메인이 필요합니다.*.workers.dev에는 와일드카드 하위 도메인이 없으므로 SDK의exposePort가 이를 거부하고,resolvePreviewHost는 실행 깊은 곳에서 실패하는 대신PREVIEW_HOSTNAME을 가리키는 명확한 오류를 발생시킵니다.
라이브 미리보기 노출
이 패키지는 브라우저 미리보기 연결을 제공하므로 직접 구현할 필요가 없습니다. 두 항목 모두 @tanstack/ai-sandbox-cloudflare/agent에서 export됩니다.
exposePreviewTool(input, env)— 바로 사용할 수 있는chat()서버 도구입니다(에이전트에는exposePreview로 표시됩니다).threadId로 실행의 컨테이너를 식별하고 개발 서버 포트에 Cloudflare 빠른 터널을 엽니다(sandbox.tunnels.get(port)). 그런 다음https://<name>.trycloudflare.comURL을 반환합니다.PREVIEW_GUIDANCE— 터널 미리보기가 작동하는 개발 서버를 시작하는 방법을 에이전트에 알려주는 시스템 프롬프트입니다. 의도적으로 앱에 종속되지 않습니다.
이 팩토리는 인증에서 하네스에 종속되지 않습니다. 자체 API 키를 바인딩하지 않습니다. 앱은 자체 env 타입에 하네스에 필요한 키(Grok Build의 XAI_API_KEY, Claude Code의 ANTHROPIC_API_KEY, Codex의 CODEX_API_KEY 등)를 선언하고 워크스페이스 시크릿으로 제공합니다. 코디네이터는 선언된 각 시크릿을 이름에 따라 샌드박스 env에 주입합니다.
import {
PREVIEW_GUIDANCE,
createCloudflareSandboxAgent,
exposePreviewTool,
resolvePreviewHost,
} from '@tanstack/ai-sandbox-cloudflare/agent'
import { cloudflareSandbox } from '@tanstack/ai-sandbox-cloudflare'
import { createSecrets, defineSandbox, defineWorkspace } from '@tanstack/ai-sandbox'
import { grokBuildText } from '@tanstack/ai-grok-build'
import type { SandboxAgentEnv } from '@tanstack/ai-sandbox-cloudflare/agent'
// Extend the package's harness-agnostic env with the key YOUR harness needs.
interface AppEnv extends SandboxAgentEnv {
XAI_API_KEY: string
}
export const agent = createCloudflareSandboxAgent<AppEnv>({
adapter: () => grokBuildText('grok-build'),
systemPrompts: [PREVIEW_GUIDANCE],
tools: (input, env) => [exposePreviewTool(input, env)],
// Supply the harness's auth here — the package binds no key. The `sandbox`
// resolver receives the Worker `env` per run, so the secret VALUE is read from
// it. A different harness declares its own, e.g. `ANTHROPIC_API_KEY` for Claude
// Code or `CODEX_API_KEY` for Codex.
sandbox: (input, env) =>
defineSandbox({
id: 'cf-edge-agent',
provider: cloudflareSandbox({
binding: env.Sandbox,
previewHostname: resolvePreviewHost(env, input),
}),
workspace: defineWorkspace({
source: { type: 'none' },
secrets: createSecrets({ XAI_API_KEY: env.XAI_API_KEY }),
}),
lifecycle: { reuse: 'thread' },
}),
})
실행 가능한 예제는
examples/sandbox-cloudflare: Claude Code, Codex 또는 Grok Build를 실행하는 하나의 앱입니다. UI에서(또는HARNESS변수로) 하네스를 선택합니다. 엣지 토폴로지는 동일하고 어댑터와 키만 다릅니다.
exposePort가 아닌 빠른 터널을 사용하는 이유
exposePort + proxyToSandbox는 Worker 자체의 origin을 통해 미리보기를 라우팅합니다. 로컬 개발에서는 해당 origin이 Vite 개발 서버이므로 Vite의 미들웨어가 미리보기의 모듈/에셋 요청(/@vite/client, /src/*, /@fs/*)을 컨테이너가 아닌 호스트에서 제공합니다. 그 결과 페이지가 잘못된 코드를 로드해 작동하지 않습니다.
빠른 터널은 샌드박스 내부에서 cloudflared가 제공합니다(cloudflared는 cloudflare/sandbox 기본 이미지에 포함됨). 따라서 Vite 포트를 완전히 우회하고, 배포 시 사용자 지정 도메인이 필요하지 않으며, WebSocket을 전달하므로 앱의 HMR이 작동합니다. PREVIEW_GUIDANCE가 안내하는 유일한 요구 사항은 개발 서버가 터널 호스트 이름을 허용해야 한다는 것입니다(서버는 알 수 없는 호스트를 거부함). Vite에서는 server: { host: true, allowedHosts: true }, webpack-dev-server에서는 allowedHosts: 'all'로 설정합니다. (Worker가 사용자 지정 도메인에서 요청을 앞단에서 처리하도록 하려는 앱에서는 exposePort + resolvePreviewHost를 계속 사용할 수 있습니다.)
전송:
sandbox.tunnels는 SDK의 RPC 전송에서만 존재합니다. 기본값인http에서는 "requires the RPC transport" 오류를 발생시킵니다. 따라서cloudflareSandbox의 기본값은transport: 'rpc'이며(예제도 Sandbox DO에SANDBOX_TRANSPORT=rpc를 설정함), 하나의 ID에 대한 모든getSandbox()에서 전송이 일치해야 하므로 사용자 지정 프로바이더도{ transport: 'rpc' }를 전달해야 합니다. 터널 미리보기를 사용하지 않는 경우에만'http'로 재정의합니다.