Cloudflare

Cloudflare

Cloudflare를 자체 호스팅 샌드박스 제공자로 사용하는 방법을 안내하는 가이드예요. 이 가이드는 Cloudflare의 레퍼런스 Worker로 **웹훅 관리 프로비저닝(webhook-managed provisioning)**을 사용해요.

출처: 문서

본문

이 가이드는 Cloudflare의 레퍼런스 Worker로 웹훅 관리 프로비저닝을 사용해요.

OpenAI Cookbook의 application-managed 및 webhook-managed 예시를 참고하세요.

동작 방식

  1. 여러분의 애플리케이션이 Agents API 세션을 만들고 입력을 보내요.
  2. OpenAI가 사용자 Cloudflare 계정의 Worker로 세션 웹훅을 보내요.
  3. Worker가 codex exec-server를 실행하는 세션별 Container를 시작하거나 다시 연결해요. executor는 OpenAI로 아웃바운드 연결하므로 에이전트가 명령을 실행하고 파일을 다룰 수 있어요.

여러분의 애플리케이션은 Agents API를 사용하고, 레퍼런스 Worker가 샌드박스 프로비저닝을 관리해요. 연결 및 복구 동작은 Sandbox lifecycle을 참고하세요.

시작하기 전에

Containers 접근이 있는 Cloudflare 계정이 필요해요. 애플리케이션 요청에는 OPENAI_API_KEY를 사용하세요. OPENAI_EXECUTOR_API_KEY를 environment key로 설정하고, 그 키만 CODEX_API_KEY로 Container에 전달하세요.

에이전트를 만들고 그 ID를 OPENAI_AGENT_ID로 저장하세요. 애플리케이션과 레퍼런스 Worker에서 동일한 에이전트 ID를 사용하세요.

레퍼런스 Worker 배포하기

Cloudflare의 reference Worker에는 웹훅 핸들러, Container 이미지, 배포 설정, 정리 엔드포인트가 포함돼요.

정리 엔드포인트용 시크릿을 생성해 EXECUTOR_CLIENT_SECRET으로 저장하세요:

openssl rand -hex 32

Cloudflare 계정에 Worker를 배포하세요:

Deploy to Cloudflare

프롬프트가 나타나면 이 값을 입력하세요:

변수 값
OPENAI_API_KEY Worker가 세션 상태를 검색하는 데 사용하는 키
OPENAI_EXECUTOR_API_KEY CODEX_API_KEY로 executor에 전달되는 환경 키
OPENAI_AGENT_ID 이 Worker가 서비스하는 에이전트 ID
OPENAI_WEBHOOK_SECRET 첫 배포의 경우 pending-webhook-registration
EXECUTOR_CLIENT_SECRET 정리를 위해 생성한 시크릿

배포된 Worker URL을 WORKER_URL로 저장하세요.

웹훅 등록하기

OpenAI 프로젝트에서 $WORKER_URL/webhook을 등록하려면 webhook setup을 따르세요. Cloudflare의 레퍼런스 통합이 나열한 이벤트를 활성화하세요:

  • agent.session.created
  • agent.session.action_required
  • agent.session.in_progress
  • agent.session.idle
  • agent.session.failed

OPENAI_WEBHOOK_SECRET을 OpenAI가 반환한 서명 시크릿으로 교체한 뒤 새 Worker 버전을 배포하세요. 그 설정을 확인하세요. 이 예시들은 표준 HTTP 클라이언트로 Worker를 호출해요:

Worker 상태 확인하기

// Replace the illustrative IDs and URLs below with your own resource values.

const response = await fetch(
  "https://worker.example.com".replace(/\/+$/, "") + "/health",
  { method: "GET" }
);
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
console.log(await response.text());
# Replace the illustrative IDs and URLs below with your own resource values.
import urllib.request

url = "https://worker.example.com".rstrip("/") + "/health"
request = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(request) as response:
    print(response.read().decode())
// Replace the illustrative IDs and URLs below with your own resource values.
import (
	"io"
	"net/http"
	"os"
	"strings"
)

endpoint := strings.TrimRight("https://worker.example.com", "/") + "/health"
request, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
	panic(err)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
	panic(err)
}
defer response.Body.Close()
if response.StatusCode/100 != 2 {
	panic(response.Status)
}
if _, err := io.Copy(os.Stdout, response.Body); err != nil {
	panic(err)
}
// Replace the illustrative IDs and URLs below with your own resource values.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

String endpoint = "https://worker.example.com".replaceAll("/+$", "") + "/health";
var request =
    HttpRequest.newBuilder(URI.create(endpoint))
        .method("GET", HttpRequest.BodyPublishers.noBody())
        .build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2)
  throw new IllegalStateException("Request failed: " + response.statusCode());
System.out.println(response.body());
# Replace the illustrative IDs and URLs below with your own resource values.
require "uri"
require "net/http"

uri = URI("https://worker.example.com".sub(%r{/+\z}, "") + "/health")
request = Net::HTTP::Get.new(uri)
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
raise "Request failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)

puts response.body
curl --fail-with-body "$WORKER_URL/health"

응답에는 "configured": true와 "webhook_configured": true가 모두 포함되어야 해요.

environment_connection required action은 오프라인 executor를 다시 연결하라는 신호예요. 유휴 이벤트만으로는 안전한 종료 신호가 아니에요. lifecycle behavior를 참고하세요.

세션 실행하기

애플리케이션의 OPENAI_API_KEY와 Worker에 설정된 동일한 OPENAI_AGENT_ID로 session steps을 따르세요. 자체 호스팅 세션을 만들고 에이전트에게 /workspace/hello.txt를 쓰고 읽도록 요청하세요.

Worker가 세션 웹훅을 받아 샌드박스 executor를 연결해요. 여러분의 애플리케이션은 Agents API를 통해 에이전트의 출력을 스트리밍해요.

세션 ID를 SESSION_ID로 저장하세요. 대화를 계속하려면 후속 입력을 보내기 전에 세션 이벤트 스트림을 열어 두세요. executor가 오프라인이면 새 입력이 환경 연결을 요청하고 Worker가 다시 연결할 때까지 기다려요. 재연결만으로는 이전 Container의 파일이 자동으로 복원되지 않아요.

애플리케이션을 Worker에서 실행하기

Cloudflare의 basic Worker application은 @openai/agents-api TypeScript SDK를 사용해 세션을 만들고, 초기 및 후속 입력을 보내고, 리소스를 정리해요. 그 POST /demo 엔드포인트가 워크플로우를 실행해요.

이 애플리케이션도 웹훅 관리 프로비저닝을 사용해요. 애플리케이션을 Worker에서 실행한다고 해서 샌드박스를 직접 프로비저닝해야 한다는 뜻은 아니에요.

정리

애플리케이션이 더 이상 샌드박스가 필요하지 않으면 레퍼런스 Worker의 인증된 정리 엔드포인트를 호출하세요:

Worker 샌드박스 정리하기

// Replace the illustrative IDs and URLs below with your own resource values.

const response = await fetch("https://worker.example.com/executors/sess_123", {
  method: "DELETE",
  headers: { Authorization: *** ${process.env.EXECUTOR_CLIENT_SECRET}` },
});
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
console.log(await response.text());
# Replace the illustrative IDs and URLs below with your own resource values.
import os
from urllib.parse import quote
import urllib.request

url = (
    "https://worker.example.com".rstrip("/")
    + "/executors/"
    + quote("sess_123", safe="")
)
request = urllib.request.Request(
    url,
    method="DELETE",
    headers={"Authorization": "Bearer " + os.environ["EXECUTOR_CLIENT_SECRET"]},
)
with urllib.request.urlopen(request) as response:
    print(response.read().decode())
// Replace the illustrative IDs and URLs below with your own resource values.
import (
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
)

endpoint := strings.TrimRight("https://worker.example.com", "/") + "/executors/" + url.PathEscape("sess_123")
request, err := http.NewRequest("DELETE", endpoint, nil)
if err != nil {
	panic(err)
}
request.Header.Set("Authorization", "Bearer "+os.Getenv("EXECUTOR_CLIENT_SECRET"))
response, err := http.DefaultClient.Do(request)
if err != nil {
	panic(err)
}
defer response.Body.Close()
if response.StatusCode/100 != 2 {
	panic(response.Status)
}
if _, err := io.Copy(os.Stdout, response.Body); err != nil {
	panic(err)
}
// Replace the illustrative IDs and URLs below with your own resource values.
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

String endpoint =
    "https://worker.example.com".replaceAll("/+$", "")
        + "/executors/"
        + URLEncoder.encode("sess_123", StandardCharsets.UTF_8).replace("+", "%20");
var request =
    HttpRequest.newBuilder(URI.create(endpoint))
        .header("Authorization", "Bearer " + System.getenv("EXECUTOR_CLIENT_SECRET"))
        .method("DELETE", HttpRequest.BodyPublishers.noBody())
        .build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2)
  throw new IllegalStateException("Request failed: " + response.statusCode());
System.out.println(response.body());
# Replace the illustrative IDs and URLs below with your own resource values.
require "uri"
require "net/http"

uri = URI("https://worker.example.com".sub(%r{/+\z}, "") + "/executors/" + URI.encode_www_form_component("sess_123").gsub("+", "%20"))
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("EXECUTOR_CLIENT_SECRET")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
raise "Request failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)

puts response.body
curl --fail-with-body \
  --request DELETE \
  --header "Authorization: Bearer $EXECU...CRET" \
  "$WORKER_URL/executors/$SESSION_ID"

Agents API 세션은 별도로 삭제하세요. 세션 삭제는 웹훅을 발생시키지 않으므로, 즉시 정리를 위해 두 작업을 모두 수행하세요. Container를 해제하기 전에 필요한 파일을 가져오세요.

고급: 애플리케이션 관리 프로비저닝

샌드박스 프로비저닝을 직접 제어하려면 Cloudflare Sandbox SDK를 application-managed lifecycle 및 executor connection instructions과 함께 사용하세요. 세션당 하나의 프로비저닝 컨트롤러를 사용하세요.

참고 자료

더 알아보기 (Learn more)

관련 문서: 자체 호스팅 샌드박스와 샌드박스 수명 주기를 참고하세요.