diff --git a/README.md b/README.md index fad3a4a03..b2bfb1509 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,70 @@ ocx service [install|start|stop|status|uninstall] # install/update/start backg ocx update [--tag preview] # update opencodex; preview installs stay on @preview ``` +### Public API through Cloudflare Tunnel + +The dashboard's **API Access** page can publish the local Responses endpoint without changing the +loopback bind. A **Named Tunnel is the default**: it provides a stable hostname and supports normal +Server-Sent Events (SSE) streaming. Install Cloudflare's official +[`cloudflared` client](https://developers.cloudflare.com/tunnel/downloads/), create at least one +opencodex API key on the API Access page, and choose one of the two setup methods: + +1. **Configure automatically.** Enter the Cloudflare account ID, zone ID, public hostname, and an + optional tunnel name, then paste a temporary scoped Cloudflare API token. opencodex creates the + remotely managed tunnel, published application, DNS record, and local connector. The API token + is used only for this setup request: it is cleared from the page and is never written to the + opencodex config or credential file. You may revoke it after setup. +2. **Use an existing tunnel.** Create or select a remotely managed tunnel, map its published + application to `http://127.0.0.1:`, then enter its HTTPS public URL on + the API Access page. Paste either the tunnel runner token or the complete `cloudflared` install + command copied from Cloudflare; opencodex extracts the runner token from the command. + +After a Named Tunnel is configured, **Reconfigure** defaults to the existing-tunnel method so a +runner token can be rotated without creating another Tunnel. Choosing automatic setup again creates +a new Tunnel and DNS record; it requires explicit confirmation, does not delete the previous +Cloudflare resources, and reminds you to remove them manually after verifying the new endpoint. + +For automatic setup, scope the temporary API token to the one account and zone you intend to use, +with only these permissions: + +- **Account · Cloudflare Tunnel · Edit** +- **Zone · DNS · Edit** + +See Cloudflare's official [Tunnel setup guide](https://developers.cloudflare.com/tunnel/setup/) and +[API token guide](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/). +For either UI method, only the tunnel-specific runner token remains locally. It is stored in the +fixed `cloudflare-tunnel-token` file in the opencodex config directory (by default +`~/.opencodex/cloudflare-tunnel-token`) with `0600` permissions; its fingerprint, rather than the +secret, is stored in `config.json`. The token is not placed in the `cloudflared` command line or +returned by the management API. + +After setup, click **Enable public access**. While the tunnel is running, the endpoint and copyable +curl example switch to its HTTPS URL; disabling it restores the local URL. Named tunnels use the +fixed configured opencodex port, so public access is refused if startup had to fall back to a +different port. + +For headless or externally managed installations, the same Named Tunnel can instead be supplied +through environment variables: + +```bash +export OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN="..." +export OPENCODEX_CLOUDFLARE_PUBLIC_URL="https://ocx.example.com" +ocx start +``` + +With `cloudflared` 2025.4.0 or newer, you can set `OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE` instead of putting the token in the environment, +and `OPENCODEX_CLOUDFLARED_PATH` when the executable is not on `PATH`. + +Quick Tunnel is an explicit **advanced development mode**, never the default. To opt in, set +`"cloudflareTunnel": { "mode": "quick" }` in `~/.opencodex/config.json` before using the API Access +toggle. It creates a temporary random `trycloudflare.com` URL, has no SLA, is limited to 200 +in-flight requests, and does **not** support SSE. Use it only for short non-streaming tests (or a +supported WebSocket client when opencodex WebSockets are enabled). See Cloudflare's +[Quick Tunnel limitations](https://developers.cloudflare.com/tunnel/setup/#quick-tunnels-development). + +The Cloudflare ingress is intentionally data-plane-only: it serves `/v1/*` with a valid +`X-OpenCodex-API-Key`, while the dashboard and `/api/*` management routes remain local. + ### Autostart: service vs shim opencodex has two ways to auto-start the proxy: diff --git a/README.zh-CN.md b/README.zh-CN.md index 6458b38ec..51e3eea04 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -248,6 +248,62 @@ ocx service [install|start|stop|status|uninstall] # 安装/更新/启动后台 ocx update [--tag preview] # 更新 opencodex;preview 安装保持 @preview ``` +### 通过 Cloudflare Tunnel 开启公网 API + +仪表盘的 **API 访问** 页面可以在不改变 loopback 监听的情况下发布本地 Responses 端点。 +**Named Tunnel 是默认模式**,它提供稳定域名,并支持常规 Server-Sent Events(SSE)流式传输。 +先安装 Cloudflare 官方 +[`cloudflared` 客户端](https://developers.cloudflare.com/tunnel/downloads/),在 API 访问页面至少创建一个 +opencodex API 密钥,再选择下列一种配置方式: + +1. **自动配置。** 填入 Cloudflare 账户 ID、区域 ID、公网域名和可选的 Tunnel 名称,然后粘贴一个 + 临时的最小权限 Cloudflare API Token。opencodex 会自动创建远程管理的 Tunnel、Published + application、DNS 记录和本地连接器。API Token 只用于这一次配置请求:页面会立即清空, + opencodex 不会将它写入配置或凭据文件。配置完成后可以撤销该 Token。 +2. **使用已有 Tunnel。** 创建或选择一个远程管理的 Tunnel,将 Published application 映射到 + `http://127.0.0.1:`,然后在 API 访问页面填入 HTTPS 公网地址。可以粘贴 + Tunnel runner token,也可以直接粘贴从 Cloudflare 复制的完整 `cloudflared` 安装命令; + opencodex 会从命令中提取 runner token。 + +Named Tunnel 配置完成后,点击**重新配置**会默认进入“使用已有 Tunnel”,可以轮换 runner token, +不会额外创建 Tunnel。若再次选择自动配置,系统会新建 Tunnel 和 DNS 记录,并要求明确确认;旧的 +Cloudflare 资源不会自动删除,请在验证新端点后手动清理。 + +用于自动配置的临时 API Token 应只限定到本次使用的账户和区域,并且只授予以下权限: + +- **Account · Cloudflare Tunnel · Edit** +- **Zone · DNS · Edit** + +参阅 Cloudflare 官方 [Tunnel 配置指南](https://developers.cloudflare.com/tunnel/setup/) 和 +[API Token 指南](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/)。两种 UI 配置方式最终都只会在本地 +保留 Tunnel 专用的 runner token。它会写入 opencodex 配置目录下固定的 `cloudflare-tunnel-token` +文件(默认为 `~/.opencodex/cloudflare-tunnel-token`),权限设为 `0600`;`config.json` 只保存指纹, +不保存凭据原文。runner token 不会进入 `cloudflared` 命令行,也不会由管理 API 返回。 + +配置完成后点击**开启公网访问**。Tunnel 运行时,页面端点和可复制的 curl 示例会自动切换到 +HTTPS 公网地址;关闭后恢复本地地址。Named Tunnel 使用固定的 opencodex 配置端口;如果启动时 +因端口占用而切换到备用端口,系统会拒绝开启公网访问。 + +无界面或外部管理的部署也可以通过环境变量提供同一个 Named Tunnel: + +```bash +export OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN="..." +export OPENCODEX_CLOUDFLARE_PUBLIC_URL="https://ocx.example.com" +ocx start +``` + +使用 `cloudflared` 2025.4.0 或更高版本时,也可以通过 `OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE` 代替环境中的 token;如果可执行文件不在 +`PATH`,设置 `OPENCODEX_CLOUDFLARED_PATH`。 + +Quick Tunnel 只作为显式的**高级开发模式**,永远不会默认开启。如需使用,先在 +`~/.opencodex/config.json` 中设置 `"cloudflareTunnel": { "mode": "quick" }`,再使用 API 访问页面的开关。 +它会创建临时的随机 `trycloudflare.com` 地址,无 SLA、最多 200 个同时进行的请求,并且 +**不支持 SSE**。它只适合短期非流式测试(或在开启 opencodex WebSocket 时供支持的 WebSocket 客户端使用)。 +详见 Cloudflare [Quick Tunnel 限制](https://developers.cloudflare.com/tunnel/setup/#quick-tunnels-development)。 + +Cloudflare 公网入口只开放数据面:有效的 `X-OpenCodex-API-Key` 可以访问 `/v1/*`,仪表盘和 +`/api/*` 管理接口仍然只允许本地访问。 + ### 自动启动:service vs shim opencodex 提供两种自动启动代理的方式: diff --git a/gui/src/cloudflare-tunnel.ts b/gui/src/cloudflare-tunnel.ts new file mode 100644 index 000000000..3658fd4f6 --- /dev/null +++ b/gui/src/cloudflare-tunnel.ts @@ -0,0 +1,240 @@ +export type CloudflareTunnelStatus = "stopped" | "starting" | "running" | "stopping" | "error"; +export type CloudflareTunnelMode = "quick" | "named"; +export type CloudflareTunnelSetupMethod = "api" | "token"; + +export interface CloudflareTunnelState { + status: CloudflareTunnelStatus; + mode: CloudflareTunnelMode; + publicUrl: string | null; + supportsSse: boolean; + enabled: boolean; + canEnable: boolean; + canConfigure: boolean; + configured: boolean; + setupRequired: boolean; + configurationSource: string | null; + configurationEditable: boolean; + configuredPublicUrl: string | null; + originUrl: string | null; + setupError?: string; + error?: string; +} + +export const STOPPED_CLOUDFLARE_TUNNEL: CloudflareTunnelState = { + status: "stopped", + mode: "named", + publicUrl: null, + supportsSse: true, + enabled: false, + canEnable: false, + canConfigure: false, + configured: false, + setupRequired: true, + configurationSource: null, + configurationEditable: true, + configuredPublicUrl: null, + originUrl: null, +}; + +export interface CloudflareTunnelApiSetupInput { + accountId: string; + zoneId: string; + hostname: string; + apiToken: string; + tunnelName?: string; + replaceExisting?: boolean; +} + +export interface CloudflareTunnelTokenSetupInput { + publicUrl: string; + tunnelToken: string; +} + +export type CloudflareTunnelSetupRequest = + | ({ method: "api"; enable: true } & CloudflareTunnelApiSetupInput) + | ({ method: "token"; enable: true } & CloudflareTunnelTokenSetupInput); + +export interface CloudflareTunnelToggleRequest { + enabled: boolean; + mode?: CloudflareTunnelMode; +} + +const STATUSES = new Set(["stopped", "starting", "running", "stopping", "error"]); +const MODES = new Set(["quick", "named"]); + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null ? value as Record : null; +} + +/** Accepts both the nested /api/keys shape and the direct tunnel endpoint shape. */ +export function tunnelFromApiPayload( + payload: unknown, + fallback: CloudflareTunnelState = STOPPED_CLOUDFLARE_TUNNEL, +): CloudflareTunnelState { + const root = asRecord(payload); + const candidate = asRecord(root?.tunnel) ?? root; + if (!candidate) return { ...fallback }; + + const status = typeof candidate.status === "string" && STATUSES.has(candidate.status as CloudflareTunnelStatus) + ? candidate.status as CloudflareTunnelStatus + : fallback.status; + const mode = typeof candidate.mode === "string" && MODES.has(candidate.mode as CloudflareTunnelMode) + ? candidate.mode as CloudflareTunnelMode + : fallback.mode; + const publicUrl = typeof candidate.publicUrl === "string" || candidate.publicUrl === null + ? candidate.publicUrl + : fallback.publicUrl; + const supportsSse = typeof candidate.supportsSse === "boolean" + ? candidate.supportsSse + : fallback.supportsSse; + const enabled = typeof candidate.enabled === "boolean" + ? candidate.enabled + : status === "starting" || status === "running" || status === "stopping" || publicUrl !== null + ? true + : fallback.enabled; + const canEnable = typeof candidate.canEnable === "boolean" + ? candidate.canEnable + : fallback.canEnable; + const canConfigure = typeof candidate.canConfigure === "boolean" + ? candidate.canConfigure + : typeof candidate.canEnable === "boolean" + ? candidate.canEnable + : fallback.canConfigure; + const configuredPublicUrl = typeof candidate.configuredPublicUrl === "string" || candidate.configuredPublicUrl === null + ? candidate.configuredPublicUrl + : fallback.configuredPublicUrl; + const originUrl = typeof candidate.originUrl === "string" || candidate.originUrl === null + ? candidate.originUrl + : fallback.originUrl; + const configurationSource = typeof candidate.configurationSource === "string" && candidate.configurationSource.trim() + ? candidate.configurationSource.trim() + : candidate.configurationSource === null + ? null + : fallback.configurationSource; + const configurationEditable = typeof candidate.configurationEditable === "boolean" + ? candidate.configurationEditable + : fallback.configurationEditable; + const configured = typeof candidate.configured === "boolean" + ? candidate.configured + : mode === "quick" || (mode === "named" && (configuredPublicUrl !== null || publicUrl !== null)) + ? true + : fallback.configured; + const setupRequired = typeof candidate.setupRequired === "boolean" + ? candidate.setupRequired + : !configured; + const error = typeof candidate.error === "string" && candidate.error.trim() + ? candidate.error.trim() + : undefined; + const setupError = typeof candidate.setupError === "string" && candidate.setupError.trim() + ? candidate.setupError.trim() + : undefined; + + return { + status, + mode, + publicUrl, + supportsSse, + enabled, + canEnable, + canConfigure, + configured, + setupRequired, + configurationSource, + configurationEditable, + configuredPublicUrl, + originUrl, + ...(error ? { error } : {}), + ...(setupError ? { setupError } : {}), + }; +} + +export function buildCloudflareTunnelSetupRequest( + method: "api", + input: CloudflareTunnelApiSetupInput, +): CloudflareTunnelSetupRequest; +export function buildCloudflareTunnelSetupRequest( + method: "token", + input: CloudflareTunnelTokenSetupInput, +): CloudflareTunnelSetupRequest; +export function buildCloudflareTunnelSetupRequest( + method: CloudflareTunnelSetupMethod, + input: CloudflareTunnelApiSetupInput | CloudflareTunnelTokenSetupInput, +): CloudflareTunnelSetupRequest { + if (method === "api") { + const api = input as CloudflareTunnelApiSetupInput; + const tunnelName = api.tunnelName?.trim(); + return { + method, + accountId: api.accountId.trim(), + zoneId: api.zoneId.trim(), + hostname: api.hostname.trim(), + apiToken: api.apiToken.trim(), + ...(tunnelName ? { tunnelName } : {}), + ...(api.replaceExisting ? { replaceExisting: true } : {}), + enable: true, + }; + } + + const token = input as CloudflareTunnelTokenSetupInput; + return { + method, + publicUrl: token.publicUrl.trim(), + tunnelToken: token.tunnelToken.trim(), + enable: true, + }; +} + +export function buildCloudflareTunnelToggleRequest( + enabled: boolean, + mode?: CloudflareTunnelMode, +): CloudflareTunnelToggleRequest { + return { + enabled, + ...(enabled && mode ? { mode } : {}), + }; +} + +/** The management API is the only authority for the currently advertised endpoint. */ +export function endpointFromApiPayload(payload: unknown, fallback = ""): string { + const endpoint = asRecord(payload)?.endpoint; + return typeof endpoint === "string" && endpoint.trim() ? endpoint : fallback; +} + +export function isTunnelTransitioning(status: CloudflareTunnelStatus): boolean { + return status === "starting" || status === "stopping"; +} + +export function isTunnelEnabled(tunnel: CloudflareTunnelState): boolean { + return tunnel.enabled; +} + +export function canToggleTunnel( + tunnel: CloudflareTunnelState, + requestPending: boolean, +): boolean { + if (requestPending || isTunnelTransitioning(tunnel.status)) return false; + if (isTunnelEnabled(tunnel)) return true; + return shouldOpenTunnelSetup(tunnel) ? tunnel.canConfigure : tunnel.canEnable; +} + +export function shouldOpenTunnelSetup(tunnel: CloudflareTunnelState): boolean { + return !isTunnelEnabled(tunnel) && (tunnel.setupRequired || !tunnel.configured); +} + +export function canReconfigureTunnel(tunnel: CloudflareTunnelState, requestPending: boolean): boolean { + return tunnel.configured + && !isTunnelEnabled(tunnel) + && tunnel.configurationSource !== "environment" + && tunnel.canConfigure + && tunnel.configurationEditable + && !requestPending; +} + +export function tunnelStatusTone( + status: CloudflareTunnelStatus, +): "green" | "amber" | "red" | "muted" { + if (status === "running") return "green"; + if (status === "starting" || status === "stopping") return "amber"; + if (status === "error") return "red"; + return "muted"; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 03221c7fb..71ab74555 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -564,9 +564,62 @@ export const de = { "codexAuth.creditGranted": "Erhalten {date}", "codexAuth.creditExpires": "Läuft ab {date} ({days} Tage übrig)", "api.title": "API-Zugriff", - "api.subtitle": "Nutze generierte API-Schlüssel, um von externen Apps auf den opencodex-Proxy zuzugreifen. Schlüssel authentifizieren über den {authHeader}- oder {altHeader}-Header.", + "api.subtitle": "Nutze generierte API-Schlüssel, um von externen Apps auf den opencodex-Proxy zuzugreifen. Authentifiziere Anfragen mit {authHeader}.", "api.endpoint": "Endpunkt", "api.endpointNote": "Kompatibel mit dem OpenAI Responses API-Format.", + "api.tunnelTitle": "Öffentlicher Cloudflare-Zugriff", + "api.tunnelStatusStopped": "Aus", + "api.tunnelStatusStarting": "Wird gestartet", + "api.tunnelStatusRunning": "Öffentlich", + "api.tunnelStatusStopping": "Wird beendet", + "api.tunnelStatusError": "Fehler", + "api.tunnelModeQuick": "Quick Tunnel · Nur für erweitertes Debugging. Die Adresse ist temporär, Anfragen sind begrenzt und SSE wird nicht unterstützt.", + "api.tunnelModeNamed": "Named Tunnel · Der Standard für öffentlichen Zugriff mit stabilem Hostnamen und vollständiger SSE-Unterstützung.", + "api.tunnelNeedsKey": "Erstelle zuerst einen API-Schlüssel für Named Tunnel. Quick Tunnel kann direkt für temporäres Debugging genutzt werden.", + "api.tunnelEnvironmentManaged": "Die Cloudflare-Konfiguration wird über Umgebungsvariablen verwaltet. Vervollständige oder entferne sie, bevor du diesen Assistenten verwendest.", + "api.tunnelEnable": "Öffentlichen Zugriff aktivieren", + "api.tunnelDisable": "Öffentlichen Zugriff deaktivieren", + "api.tunnelStarting": "Wird gestartet…", + "api.tunnelStopping": "Wird beendet…", + "api.tunnelRequestFailed": "Der öffentliche Cloudflare-Zugriff konnte nicht aktualisiert werden. Prüfe, ob der Proxy läuft, und versuche es erneut.", + "api.tunnelConfigure": "Öffentlichen Zugriff einrichten", + "api.tunnelReconfigure": "Neu konfigurieren", + "api.tunnelUseQuick": "Quick Tunnel verwenden", + "api.tunnelQuickNote": "Quick Tunnel ist eine temporäre Debugging-Option: keine Cloudflare-Einrichtung nötig, aber die Adresse ändert sich und SSE wird nicht unterstützt. Für produktiven öffentlichen API-Zugriff Named Tunnel verwenden.", + "api.tunnelPublicUrl": "Öffentliche URL:", + "api.tunnelOriginUrl": "Lokaler Ursprung:", + "api.tunnelSetupTitle": "Named Tunnel einrichten", + "api.tunnelSetupIntro": "Verbinde dein Cloudflare-Konto automatisch oder nutze die Zugangsdaten eines vorhandenen Tunnels.", + "api.tunnelSseNote": "Named Tunnel ist der Standard und unterstützt SSE-Streaming durchgängig.", + "api.tunnelSetupMethod": "Einrichtungsmethode", + "api.tunnelSetupAutomatic": "Automatisch einrichten", + "api.tunnelSetupAutomaticDesc": "Tunnel, DNS-Route und lokalen Connector mit einem eingeschränkten API-Token in einem Schritt erstellen.", + "api.tunnelSetupExisting": "Vorhandenen Tunnel nutzen", + "api.tunnelSetupExistingDesc": "Öffentliche URL und Tunnel-Token oder den cloudflared-Installationsbefehl einfügen.", + "api.tunnelAccountId": "Cloudflare-Konto-ID", + "api.tunnelAccountIdPlaceholder": "Konto-ID eingeben", + "api.tunnelZoneId": "Cloudflare-Zonen-ID", + "api.tunnelZoneIdPlaceholder": "Zonen-ID eingeben", + "api.tunnelHostname": "Öffentlicher Hostname", + "api.tunnelHostnamePlaceholder": "api.example.com", + "api.tunnelName": "Tunnelname (optional)", + "api.tunnelNamePlaceholder": "opencodex", + "api.tunnelApiToken": "Cloudflare API-Token", + "api.tunnelApiTokenPlaceholder": "Eingeschränktes API-Token einfügen", + "api.tunnelApiPermissions": "Verwende ein eingeschränktes Token nur mit Account: Cloudflare Tunnel Edit und Zone: DNS Edit.", + "api.tunnelReplaceExistingConfirm": "Mir ist bewusst, dass ein neuer Tunnel und DNS-Eintrag erstellt werden. Die bisherigen Cloudflare-Ressourcen werden nicht automatisch gelöscht und müssen nach der Prüfung manuell entfernt werden.", + "api.tunnelExistingPublicUrl": "Vorhandene öffentliche URL", + "api.tunnelExistingPublicUrlPlaceholder": "https://api.example.com", + "api.tunnelTokenOrCommand": "Tunnel-Token oder Installationsbefehl", + "api.tunnelTokenOrCommandPlaceholder": "Tunnel-Token oder cloudflared-Installationsbefehl einfügen", + "api.tunnelTokenSecurity": "Das Cloudflare-API-Token wird nur für diese Einrichtung verwendet und nie gespeichert. Lokal bleibt nur das Tunnel-Runner-Token.", + "api.tunnelRunnerTokenSecurity": "Das Tunnel-Runner-Token wird in einer geschützten lokalen Datei gespeichert und nie von der Verwaltungs-API zurückgegeben.", + "api.tunnelOpenDashboard": "Cloudflare-Dashboard öffnen", + "api.tunnelTokenGuide": "Anleitung für API-Tokens", + "api.tunnelExistingGuide": "Named-Tunnel-Anleitung", + "api.tunnelConfiguring": "Wird eingerichtet…", + "api.tunnelConfigureStart": "Einrichten und aktivieren", + "api.tunnelSetupFailed": "Der Named Tunnel konnte nicht eingerichtet werden. Prüfe die Angaben und Cloudflare-Berechtigungen und versuche es erneut.", "api.newKeyTitle": "Neuer Schlüssel erstellt", "api.newKeyNote": "Kopiere diesen Schlüssel jetzt — er wird nicht erneut angezeigt.", "api.copy": "Kopieren", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2df6bd556..9336e871f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -866,9 +866,62 @@ export const en = { // api access page "api.title": "API Access", - "api.subtitle": "Use generated API keys to access the opencodex proxy from external apps. Keys authenticate via the {authHeader} or {altHeader} header.", + "api.subtitle": "Use generated API keys to access the opencodex proxy from external apps. Authenticate requests with {authHeader}.", "api.endpoint": "Endpoint", "api.endpointNote": "Compatible with OpenAI Responses API format.", + "api.tunnelTitle": "Cloudflare public access", + "api.tunnelStatusStopped": "Off", + "api.tunnelStatusStarting": "Starting", + "api.tunnelStatusRunning": "Public", + "api.tunnelStatusStopping": "Stopping", + "api.tunnelStatusError": "Error", + "api.tunnelModeQuick": "Quick Tunnel · Advanced debugging only. It uses a temporary address, limits in-flight requests, and does not support SSE.", + "api.tunnelModeNamed": "Named Tunnel · The default public access mode, with a stable hostname and full SSE streaming support.", + "api.tunnelNeedsKey": "Generate an API key before enabling Named Tunnel. Quick Tunnel can be used directly for temporary debugging.", + "api.tunnelEnvironmentManaged": "Cloudflare settings are controlled by environment variables. Complete or remove them before using this setup.", + "api.tunnelEnable": "Enable public access", + "api.tunnelDisable": "Disable public access", + "api.tunnelStarting": "Starting…", + "api.tunnelStopping": "Stopping…", + "api.tunnelRequestFailed": "Could not update Cloudflare public access. Check that the proxy is running and try again.", + "api.tunnelConfigure": "Configure public access", + "api.tunnelReconfigure": "Reconfigure", + "api.tunnelUseQuick": "Use Quick Tunnel", + "api.tunnelQuickNote": "Quick Tunnel is a temporary debugging option: it needs no Cloudflare setup, but the address changes and SSE is not supported. Use Named Tunnel for production public API access.", + "api.tunnelPublicUrl": "Public URL:", + "api.tunnelOriginUrl": "Local origin:", + "api.tunnelSetupTitle": "Set up a Named Tunnel", + "api.tunnelSetupIntro": "Connect your Cloudflare account automatically, or use credentials from an existing tunnel.", + "api.tunnelSseNote": "Named Tunnel is the default and supports SSE streaming end to end.", + "api.tunnelSetupMethod": "Tunnel setup method", + "api.tunnelSetupAutomatic": "Configure automatically", + "api.tunnelSetupAutomaticDesc": "Create the tunnel, DNS route, and local connector in one step with a scoped API token.", + "api.tunnelSetupExisting": "Use an existing tunnel", + "api.tunnelSetupExistingDesc": "Paste its public URL and tunnel token, or the cloudflared install command.", + "api.tunnelAccountId": "Cloudflare account ID", + "api.tunnelAccountIdPlaceholder": "Enter the account ID", + "api.tunnelZoneId": "Cloudflare zone ID", + "api.tunnelZoneIdPlaceholder": "Enter the zone ID", + "api.tunnelHostname": "Public hostname", + "api.tunnelHostnamePlaceholder": "api.example.com", + "api.tunnelName": "Tunnel name (optional)", + "api.tunnelNamePlaceholder": "opencodex", + "api.tunnelApiToken": "Cloudflare API token", + "api.tunnelApiTokenPlaceholder": "Paste a scoped API token", + "api.tunnelApiPermissions": "Use a scoped token with only Account: Cloudflare Tunnel Edit and Zone: DNS Edit permissions.", + "api.tunnelReplaceExistingConfirm": "I understand this creates a new Tunnel and DNS record. The previous Cloudflare resources are not deleted automatically and must be removed manually after verification.", + "api.tunnelExistingPublicUrl": "Existing public URL", + "api.tunnelExistingPublicUrlPlaceholder": "https://api.example.com", + "api.tunnelTokenOrCommand": "Tunnel token or install command", + "api.tunnelTokenOrCommandPlaceholder": "Paste the tunnel token or cloudflared install command", + "api.tunnelTokenSecurity": "The Cloudflare API token is used only for this setup request and is never written to disk. Only the tunnel runner token is stored locally.", + "api.tunnelRunnerTokenSecurity": "The tunnel runner token is stored in a protected local credential file and is never returned by the management API.", + "api.tunnelOpenDashboard": "Open Cloudflare dashboard", + "api.tunnelTokenGuide": "API token guide", + "api.tunnelExistingGuide": "Named Tunnel guide", + "api.tunnelConfiguring": "Configuring…", + "api.tunnelConfigureStart": "Configure and enable", + "api.tunnelSetupFailed": "Could not configure the Named Tunnel. Check the values and Cloudflare permissions, then try again.", "api.newKeyTitle": "New key created", "api.newKeyNote": "Copy this key now — it won't be shown again.", "api.copy": "Copy", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3355f0548..2097c1681 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -584,9 +584,62 @@ export const ko: Record = { // api access page "api.title": "API 액세스", - "api.subtitle": "생성한 API 키로 외부 앱에서 opencodex 프록시에 접근합니다. {authHeader} 또는 {altHeader} 헤더로 인증합니다.", + "api.subtitle": "생성한 API 키로 외부 앱에서 opencodex 프록시에 접근합니다. {authHeader}로 요청을 인증합니다.", "api.endpoint": "엔드포인트", "api.endpointNote": "OpenAI Responses API 형식과 호환됩니다.", + "api.tunnelTitle": "Cloudflare 공개 액세스", + "api.tunnelStatusStopped": "꺼짐", + "api.tunnelStatusStarting": "시작 중", + "api.tunnelStatusRunning": "공개됨", + "api.tunnelStatusStopping": "종료 중", + "api.tunnelStatusError": "오류", + "api.tunnelModeQuick": "Quick Tunnel · 고급 디버깅 전용입니다. 주소가 임시이고 요청 수가 제한되며 SSE를 지원하지 않습니다.", + "api.tunnelModeNamed": "Named Tunnel · 안정적인 호스트 이름과 완전한 SSE 스트리밍을 지원하는 기본 공개 액세스 모드입니다.", + "api.tunnelNeedsKey": "Named Tunnel을 켜기 전에 API 키를 생성하세요. Quick Tunnel은 임시 디버깅용으로 바로 사용할 수 있습니다.", + "api.tunnelEnvironmentManaged": "Cloudflare 설정이 환경 변수로 관리되고 있습니다. 이 마법사를 사용하기 전에 변수를 완성하거나 제거하세요.", + "api.tunnelEnable": "공개 액세스 켜기", + "api.tunnelDisable": "공개 액세스 끄기", + "api.tunnelStarting": "시작 중…", + "api.tunnelStopping": "종료 중…", + "api.tunnelRequestFailed": "Cloudflare 공개 액세스 상태를 변경하지 못했습니다. 프록시가 실행 중인지 확인한 뒤 다시 시도하세요.", + "api.tunnelConfigure": "공개 액세스 구성", + "api.tunnelReconfigure": "다시 구성", + "api.tunnelUseQuick": "Quick Tunnel 사용", + "api.tunnelQuickNote": "Quick Tunnel은 임시 디버깅 옵션입니다. Cloudflare 설정은 필요 없지만 주소가 바뀌며 SSE를 지원하지 않습니다. 운영용 공개 API에는 Named Tunnel을 사용하세요.", + "api.tunnelPublicUrl": "공개 URL:", + "api.tunnelOriginUrl": "로컬 원본:", + "api.tunnelSetupTitle": "Named Tunnel 설정", + "api.tunnelSetupIntro": "Cloudflare 계정을 자동으로 연결하거나 기존 Tunnel의 자격 증명을 사용하세요.", + "api.tunnelSseNote": "Named Tunnel이 기본 모드이며 종단 간 SSE 스트리밍을 지원합니다.", + "api.tunnelSetupMethod": "Tunnel 설정 방법", + "api.tunnelSetupAutomatic": "자동 구성", + "api.tunnelSetupAutomaticDesc": "제한된 API 토큰으로 Tunnel, DNS 경로, 로컬 커넥터를 한 번에 만듭니다.", + "api.tunnelSetupExisting": "기존 Tunnel 사용", + "api.tunnelSetupExistingDesc": "공개 URL과 Tunnel 토큰 또는 cloudflared 설치 명령을 붙여 넣으세요.", + "api.tunnelAccountId": "Cloudflare 계정 ID", + "api.tunnelAccountIdPlaceholder": "계정 ID 입력", + "api.tunnelZoneId": "Cloudflare 영역 ID", + "api.tunnelZoneIdPlaceholder": "영역 ID 입력", + "api.tunnelHostname": "공개 호스트 이름", + "api.tunnelHostnamePlaceholder": "api.example.com", + "api.tunnelName": "Tunnel 이름(선택 사항)", + "api.tunnelNamePlaceholder": "opencodex", + "api.tunnelApiToken": "Cloudflare API 토큰", + "api.tunnelApiTokenPlaceholder": "제한된 API 토큰 붙여 넣기", + "api.tunnelApiPermissions": "Account: Cloudflare Tunnel Edit 및 Zone: DNS Edit 권한만 있는 제한된 토큰을 사용하세요.", + "api.tunnelReplaceExistingConfirm": "새 Tunnel과 DNS 레코드가 생성되며 기존 Cloudflare 리소스는 자동 삭제되지 않으므로 확인 후 직접 정리해야 함을 이해합니다.", + "api.tunnelExistingPublicUrl": "기존 공개 URL", + "api.tunnelExistingPublicUrlPlaceholder": "https://api.example.com", + "api.tunnelTokenOrCommand": "Tunnel 토큰 또는 설치 명령", + "api.tunnelTokenOrCommandPlaceholder": "Tunnel 토큰 또는 cloudflared 설치 명령 붙여 넣기", + "api.tunnelTokenSecurity": "Cloudflare API 토큰은 이번 설정 요청에만 사용되며 디스크에 저장되지 않습니다. 로컬에는 Tunnel runner 토큰만 저장됩니다.", + "api.tunnelRunnerTokenSecurity": "Tunnel runner 토큰은 보호된 로컬 자격 증명 파일에 저장되며 관리 API에서 반환되지 않습니다.", + "api.tunnelOpenDashboard": "Cloudflare 대시보드 열기", + "api.tunnelTokenGuide": "API 토큰 가이드", + "api.tunnelExistingGuide": "Named Tunnel 가이드", + "api.tunnelConfiguring": "구성 중…", + "api.tunnelConfigureStart": "구성하고 활성화", + "api.tunnelSetupFailed": "Named Tunnel을 구성하지 못했습니다. 입력값과 Cloudflare 권한을 확인한 후 다시 시도하세요.", "api.newKeyTitle": "새 키 생성됨", "api.newKeyNote": "지금 키를 복사하세요. 다시 표시되지 않습니다.", "api.copy": "복사", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0b36bd603..641f25670 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -866,9 +866,62 @@ export const ru: Record = { // api access page "api.title": "Доступ по API", - "api.subtitle": "Используйте сгенерированные API-ключи для доступа к прокси opencodex из внешних приложений. Ключи аутентифицируются через заголовок {authHeader} или {altHeader}.", + "api.subtitle": "Используйте сгенерированные API-ключи для доступа к прокси opencodex из внешних приложений. Аутентифицируйте запросы с помощью {authHeader}.", "api.endpoint": "Конечная точка", "api.endpointNote": "Совместимо с форматом OpenAI Responses API.", + "api.tunnelTitle": "Публичный доступ Cloudflare", + "api.tunnelStatusStopped": "Отключён", + "api.tunnelStatusStarting": "Запускается", + "api.tunnelStatusRunning": "Доступен", + "api.tunnelStatusStopping": "Останавливается", + "api.tunnelStatusError": "Ошибка", + "api.tunnelModeQuick": "Quick Tunnel · Только для расширенной отладки. Адрес временный, число запросов ограничено, SSE не поддерживается.", + "api.tunnelModeNamed": "Named Tunnel · Режим публичного доступа по умолчанию со стабильным именем и полной поддержкой SSE.", + "api.tunnelNeedsKey": "Для Named Tunnel сначала создайте API-ключ. Quick Tunnel можно использовать сразу для временной отладки.", + "api.tunnelEnvironmentManaged": "Настройки Cloudflare управляются переменными окружения. Заполните или удалите их перед использованием мастера.", + "api.tunnelEnable": "Включить публичный доступ", + "api.tunnelDisable": "Отключить публичный доступ", + "api.tunnelStarting": "Запускается…", + "api.tunnelStopping": "Останавливается…", + "api.tunnelRequestFailed": "Не удалось обновить публичный доступ Cloudflare. Убедитесь, что прокси запущен, и повторите попытку.", + "api.tunnelConfigure": "Настроить публичный доступ", + "api.tunnelReconfigure": "Перенастроить", + "api.tunnelUseQuick": "Использовать Quick Tunnel", + "api.tunnelQuickNote": "Quick Tunnel — временный вариант для отладки: настройка Cloudflare не нужна, но адрес меняется и SSE не поддерживается. Для рабочего публичного API используйте Named Tunnel.", + "api.tunnelPublicUrl": "Публичный URL:", + "api.tunnelOriginUrl": "Локальный источник:", + "api.tunnelSetupTitle": "Настройка Named Tunnel", + "api.tunnelSetupIntro": "Подключите аккаунт Cloudflare автоматически или используйте данные существующего Tunnel.", + "api.tunnelSseNote": "Named Tunnel используется по умолчанию и поддерживает сквозную потоковую передачу SSE.", + "api.tunnelSetupMethod": "Способ настройки Tunnel", + "api.tunnelSetupAutomatic": "Настроить автоматически", + "api.tunnelSetupAutomaticDesc": "Создайте Tunnel, маршрут DNS и локальный коннектор за один шаг с ограниченным API-токеном.", + "api.tunnelSetupExisting": "Использовать существующий Tunnel", + "api.tunnelSetupExistingDesc": "Вставьте публичный URL и токен Tunnel или команду установки cloudflared.", + "api.tunnelAccountId": "ID аккаунта Cloudflare", + "api.tunnelAccountIdPlaceholder": "Введите ID аккаунта", + "api.tunnelZoneId": "ID зоны Cloudflare", + "api.tunnelZoneIdPlaceholder": "Введите ID зоны", + "api.tunnelHostname": "Публичное имя хоста", + "api.tunnelHostnamePlaceholder": "api.example.com", + "api.tunnelName": "Имя Tunnel (необязательно)", + "api.tunnelNamePlaceholder": "opencodex", + "api.tunnelApiToken": "API-токен Cloudflare", + "api.tunnelApiTokenPlaceholder": "Вставьте ограниченный API-токен", + "api.tunnelApiPermissions": "Используйте ограниченный токен только с правами Account: Cloudflare Tunnel Edit и Zone: DNS Edit.", + "api.tunnelReplaceExistingConfirm": "Я понимаю, что будут созданы новый Tunnel и DNS-запись. Прежние ресурсы Cloudflare не удаляются автоматически — после проверки их нужно удалить вручную.", + "api.tunnelExistingPublicUrl": "Существующий публичный URL", + "api.tunnelExistingPublicUrlPlaceholder": "https://api.example.com", + "api.tunnelTokenOrCommand": "Токен Tunnel или команда установки", + "api.tunnelTokenOrCommandPlaceholder": "Вставьте токен Tunnel или команду установки cloudflared", + "api.tunnelTokenSecurity": "API-токен Cloudflare используется только для этого запроса настройки и не записывается на диск. Локально хранится только runner-токен Tunnel.", + "api.tunnelRunnerTokenSecurity": "Runner-токен Tunnel хранится в защищённом локальном файле и никогда не возвращается управляющим API.", + "api.tunnelOpenDashboard": "Открыть панель Cloudflare", + "api.tunnelTokenGuide": "Руководство по API-токенам", + "api.tunnelExistingGuide": "Руководство по Named Tunnel", + "api.tunnelConfiguring": "Настройка…", + "api.tunnelConfigureStart": "Настроить и включить", + "api.tunnelSetupFailed": "Не удалось настроить Named Tunnel. Проверьте значения и разрешения Cloudflare, затем повторите попытку.", "api.newKeyTitle": "Создан новый ключ", "api.newKeyNote": "Скопируйте ключ сейчас — он больше не будет показан.", "api.copy": "Копировать", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e94e8543d..e938722cd 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -584,9 +584,62 @@ export const zh: Record = { // api access page "api.title": "API 访问", - "api.subtitle": "使用生成的 API 密钥从外部应用访问 opencodex 代理。通过 {authHeader} 或 {altHeader} 请求头进行认证。", + "api.subtitle": "使用生成的 API 密钥从外部应用访问 opencodex 代理。通过 {authHeader} 对请求进行认证。", "api.endpoint": "端点", "api.endpointNote": "兼容 OpenAI Responses API 格式。", + "api.tunnelTitle": "Cloudflare 公网访问", + "api.tunnelStatusStopped": "已关闭", + "api.tunnelStatusStarting": "正在开启", + "api.tunnelStatusRunning": "已开启", + "api.tunnelStatusStopping": "正在关闭", + "api.tunnelStatusError": "错误", + "api.tunnelModeQuick": "Quick Tunnel · 仅用于高级调试,地址临时、并发受限且不支持 SSE。", + "api.tunnelModeNamed": "Named Tunnel · 默认公网访问模式,提供稳定域名并完整支持 SSE 流式传输。", + "api.tunnelNeedsKey": "Named Tunnel 请先生成 API 密钥;Quick Tunnel 可直接用于临时调试。", + "api.tunnelEnvironmentManaged": "Cloudflare 配置由环境变量管理。请先补全或移除这些环境变量,再使用页面向导。", + "api.tunnelEnable": "开启公网访问", + "api.tunnelDisable": "关闭公网访问", + "api.tunnelStarting": "正在开启…", + "api.tunnelStopping": "正在关闭…", + "api.tunnelRequestFailed": "无法更新 Cloudflare 公网访问状态。请确认代理正在运行后重试。", + "api.tunnelConfigure": "配置公网访问", + "api.tunnelReconfigure": "重新配置", + "api.tunnelUseQuick": "使用 Quick Tunnel", + "api.tunnelQuickNote": "Quick Tunnel 是临时调试选项:不需要 Cloudflare 配置,但地址会变化且不支持 SSE;正式公网 API 请使用 Named Tunnel。", + "api.tunnelPublicUrl": "公网地址:", + "api.tunnelOriginUrl": "本地源站:", + "api.tunnelSetupTitle": "配置 Named Tunnel", + "api.tunnelSetupIntro": "可以自动连接 Cloudflare 账户,也可以使用已有 Tunnel 的凭据。", + "api.tunnelSseNote": "Named Tunnel 是默认模式,支持端到端 SSE 流式传输。", + "api.tunnelSetupMethod": "Tunnel 配置方式", + "api.tunnelSetupAutomatic": "自动配置", + "api.tunnelSetupAutomaticDesc": "使用最小权限 API Token,一步创建 Tunnel、DNS 路由和本地连接器。", + "api.tunnelSetupExisting": "使用已有 Tunnel", + "api.tunnelSetupExistingDesc": "粘贴公网地址和 Tunnel Token,也可以直接粘贴 cloudflared 安装命令。", + "api.tunnelAccountId": "Cloudflare 账户 ID", + "api.tunnelAccountIdPlaceholder": "输入账户 ID", + "api.tunnelZoneId": "Cloudflare 区域 ID", + "api.tunnelZoneIdPlaceholder": "输入区域 ID", + "api.tunnelHostname": "公网域名", + "api.tunnelHostnamePlaceholder": "api.example.com", + "api.tunnelName": "Tunnel 名称(可选)", + "api.tunnelNamePlaceholder": "opencodex", + "api.tunnelApiToken": "Cloudflare API Token", + "api.tunnelApiTokenPlaceholder": "粘贴最小权限 API Token", + "api.tunnelApiPermissions": "Token 只需要 Account: Cloudflare Tunnel Edit 和 Zone: DNS Edit 权限。", + "api.tunnelReplaceExistingConfirm": "我明白这会新建 Tunnel 和 DNS 记录;旧的 Cloudflare 资源不会自动删除,验证新地址后需要手动清理。", + "api.tunnelExistingPublicUrl": "已有公网地址", + "api.tunnelExistingPublicUrlPlaceholder": "https://api.example.com", + "api.tunnelTokenOrCommand": "Tunnel Token 或安装命令", + "api.tunnelTokenOrCommandPlaceholder": "粘贴 Tunnel Token 或 cloudflared 安装命令", + "api.tunnelTokenSecurity": "Cloudflare API Token 只用于本次配置请求,绝不会写入磁盘;本地仅保存 Tunnel runner token。", + "api.tunnelRunnerTokenSecurity": "Tunnel runner token 会保存在受保护的本地凭据文件中,管理 API 绝不会返回它。", + "api.tunnelOpenDashboard": "打开 Cloudflare 控制台", + "api.tunnelTokenGuide": "API Token 配置指南", + "api.tunnelExistingGuide": "Named Tunnel 配置指南", + "api.tunnelConfiguring": "正在配置…", + "api.tunnelConfigureStart": "配置并开启", + "api.tunnelSetupFailed": "无法配置 Named Tunnel。请检查填写内容和 Cloudflare 权限后重试。", "api.newKeyTitle": "已创建新密钥", "api.newKeyNote": "请立即复制此密钥,它不会再次显示。", "api.copy": "复制", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 5046643c4..d7d57f3e1 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -1,6 +1,22 @@ -import { useCallback, useEffect, useState } from "react"; -import { IconPlus, IconX, IconCheck } from "../icons"; -import { useI18n, LOCALES } from "../i18n/shared"; +import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { IconPlus, IconX, IconCheck, IconGlobe, IconAlert, IconExternal } from "../icons"; +import { useI18n, LOCALES, type TKey } from "../i18n/shared"; +import { apiErrorMessage } from "../api-error"; +import { + STOPPED_CLOUDFLARE_TUNNEL, + buildCloudflareTunnelSetupRequest, + buildCloudflareTunnelToggleRequest, + canReconfigureTunnel, + canToggleTunnel, + endpointFromApiPayload, + isTunnelEnabled, + isTunnelTransitioning, + shouldOpenTunnelSetup, + tunnelFromApiPayload, + tunnelStatusTone, + type CloudflareTunnelStatus, + type CloudflareTunnelSetupMethod, +} from "../cloudflare-tunnel"; interface ApiKeyEntry { id: string; @@ -9,6 +25,14 @@ interface ApiKeyEntry { createdAt: string; } +const TUNNEL_STATUS_KEYS: Record = { + stopped: "api.tunnelStatusStopped", + starting: "api.tunnelStatusStarting", + running: "api.tunnelStatusRunning", + stopping: "api.tunnelStatusStopping", + error: "api.tunnelStatusError", +}; + function formatCreatedDate(iso: string, localeTag?: string): string { return new Date(iso).toLocaleDateString(localeTag); } @@ -18,6 +42,19 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang; const [keys, setKeys] = useState([]); const [endpoint, setEndpoint] = useState(""); + const [tunnel, setTunnel] = useState(STOPPED_CLOUDFLARE_TUNNEL); + const [tunnelRequestPending, setTunnelRequestPending] = useState(false); + const [tunnelRequestError, setTunnelRequestError] = useState(null); + const [tunnelSetupOpen, setTunnelSetupOpen] = useState(false); + const [tunnelSetupMethod, setTunnelSetupMethod] = useState("api"); + const [tunnelAccountId, setTunnelAccountId] = useState(""); + const [tunnelZoneId, setTunnelZoneId] = useState(""); + const [tunnelHostname, setTunnelHostname] = useState(""); + const [tunnelApiToken, setTunnelApiToken] = useState(""); + const [tunnelName, setTunnelName] = useState(""); + const [tunnelReplaceExisting, setTunnelReplaceExisting] = useState(false); + const [tunnelPublicUrl, setTunnelPublicUrl] = useState(""); + const [tunnelToken, setTunnelToken] = useState(""); const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); const [newKey, setNewKey] = useState(null); @@ -30,11 +67,32 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { if (res.ok) { const data = await res.json(); setKeys(data.keys ?? []); - setEndpoint(data.endpoint ?? ""); + setEndpoint(previous => endpointFromApiPayload(data, previous)); + setTunnel(previous => tunnelFromApiPayload(data, previous)); } } catch { /* proxy down */ } }, [apiBase]); + const fetchTunnel = useCallback(async (reportError = false): Promise => { + try { + const res = await fetch(`${apiBase}/api/cloudflare-tunnel`); + if (!res.ok) { + if (reportError) { + setTunnelRequestError(await apiErrorMessage(res, t("api.tunnelRequestFailed"))); + } + return false; + } + const data = await res.json(); + setTunnel(previous => tunnelFromApiPayload(data, previous)); + setEndpoint(previous => endpointFromApiPayload(data, previous)); + setTunnelRequestError(null); + return true; + } catch { + if (reportError) setTunnelRequestError(t("api.tunnelRequestFailed")); + return false; + } + }, [apiBase, t]); + useEffect(() => { const timeout = window.setTimeout(() => { void fetchKeys(); @@ -42,6 +100,24 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { return () => window.clearTimeout(timeout); }, [fetchKeys]); + useEffect(() => { + const transitioning = isTunnelTransitioning(tunnel.status); + if (!transitioning && !tunnel.enabled) return; + + let cancelled = false; + let timeout: number | undefined; + const poll = async () => { + await fetchTunnel(); + if (!cancelled) timeout = window.setTimeout(poll, transitioning ? 1000 : 5000); + }; + timeout = window.setTimeout(poll, transitioning ? 1000 : 5000); + + return () => { + cancelled = true; + if (timeout !== undefined) window.clearTimeout(timeout); + }; + }, [fetchTunnel, tunnel.enabled, tunnel.status]); + const responseEndpoint = endpoint || "http://127.0.0.1:10100/v1/responses"; const handleCreate = async () => { @@ -73,6 +149,139 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { fetchKeys(); }; + const openTunnelSetup = () => { + setTunnelRequestError(null); + setTunnelSetupMethod(tunnel.configured || tunnel.configurationSource === "local" ? "token" : "api"); + setTunnelReplaceExisting(false); + setTunnelPublicUrl(tunnel.configuredPublicUrl ?? ""); + setTunnelSetupOpen(true); + }; + + const handleTunnelToggle = async (mode?: "quick" | "named") => { + if (!mode && shouldOpenTunnelSetup(tunnel)) { + openTunnelSetup(); + return; + } + + const enabled = !isTunnelEnabled(tunnel); + const previousTunnel = tunnel; + setTunnelRequestError(null); + setTunnelRequestPending(true); + setTunnel(current => ({ + ...current, + status: enabled ? "starting" : "stopping", + enabled, + error: undefined, + })); + + try { + const requestTunnelToggle = (body: unknown) => fetch(`${apiBase}/api/cloudflare-tunnel`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + let res = await requestTunnelToggle(buildCloudflareTunnelToggleRequest(enabled, mode)); + if (!res.ok && enabled && mode) { + try { + const payload = await res.clone().json(); + if (payload?.error === "unknown field: mode") { + // Older local management servers do not yet accept the explicit mode field. Retry + // with the legacy body so existing Quick Tunnel configs keep working while the + // backend rolls forward. + res = await requestTunnelToggle(buildCloudflareTunnelToggleRequest(enabled)); + } + } catch { + // Non-JSON errors fall through to the normal localized error path below. + } + } + if (!res.ok) { + setTunnel(previousTunnel); + setTunnelRequestError(await apiErrorMessage(res, t("api.tunnelRequestFailed"))); + return; + } + await fetchTunnel(true); + } catch { + setTunnel(previousTunnel); + setTunnelRequestError(t("api.tunnelRequestFailed")); + } finally { + setTunnelRequestPending(false); + } + }; + + const handleTunnelSetup = async (event: FormEvent) => { + event.preventDefault(); + if (!tunnel.canConfigure) return; + setTunnelRequestError(null); + setTunnelRequestPending(true); + + const body = tunnelSetupMethod === "api" + ? buildCloudflareTunnelSetupRequest("api", { + accountId: tunnelAccountId, + zoneId: tunnelZoneId, + hostname: tunnelHostname, + apiToken: tunnelApiToken, + tunnelName, + replaceExisting: tunnelReplaceExisting, + }) + : buildCloudflareTunnelSetupRequest("token", { + publicUrl: tunnelPublicUrl, + tunnelToken, + }); + + try { + const res = await fetch(`${apiBase}/api/cloudflare-tunnel/setup`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + // Provisioning can succeed even when the final local cloudflared start fails. In that + // case the API returns the saved configuration alongside the start error: adopt it and + // clear both secret inputs so the user can install/fix cloudflared, then retry Enable. + try { + const failurePayload = await res.clone().json(); + const configuredTunnel = tunnelFromApiPayload(failurePayload, tunnel); + if (configuredTunnel.configured) { + setTunnel(configuredTunnel); + setEndpoint(previous => endpointFromApiPayload(failurePayload, previous)); + setTunnelApiToken(""); + setTunnelToken(""); + setTunnelSetupOpen(false); + await fetchTunnel(true); + } + } catch { + // apiErrorMessage below supplies the localized fallback for non-JSON failures. + } + setTunnelRequestError(await apiErrorMessage(res, t("api.tunnelSetupFailed"))); + return; + } + + const data = await res.json(); + setTunnel(previous => tunnelFromApiPayload(data, previous)); + setEndpoint(previous => endpointFromApiPayload(data, previous)); + setTunnelApiToken(""); + setTunnelToken(""); + setTunnelReplaceExisting(false); + setTunnelSetupOpen(false); + await fetchTunnel(true); + } catch { + setTunnelRequestError(t("api.tunnelSetupFailed")); + } finally { + // Secret fields are single-request inputs: clear them after success, validation errors, + // Cloudflare failures, and local network errors alike. + setTunnelApiToken(""); + setTunnelToken(""); + setTunnelRequestPending(false); + } + }; + + const closeTunnelSetup = () => { + setTunnelApiToken(""); + setTunnelToken(""); + setTunnelReplaceExisting(false); + setTunnelSetupOpen(false); + }; + const copyKey = () => { if (newKey) { navigator.clipboard.writeText(newKey); @@ -81,8 +290,33 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { } }; - // Subtitle carries two inline chips; split the localized string on both tokens. - const subtitleParts = t("api.subtitle").split(/\{authHeader\}|\{altHeader\}/); + // Subtitle carries the dedicated admission header as an inline chip. + const subtitleParts = t("api.subtitle").split("{authHeader}"); + const tunnelEnabled = isTunnelEnabled(tunnel); + const tunnelNeedsSetup = shouldOpenTunnelSetup(tunnel); + const tunnelCanProceed = tunnelNeedsSetup ? tunnel.canConfigure : tunnel.canEnable; + const tunnelCanUseQuick = !tunnelEnabled && tunnel.configurationSource !== "environment"; + const tunnelBusy = tunnelRequestPending || isTunnelTransitioning(tunnel.status); + const tunnelError = tunnelRequestError ?? tunnel.error; + const tunnelButtonLabel = tunnel.status === "starting" + ? t("api.tunnelStarting") + : tunnel.status === "stopping" + ? t("api.tunnelStopping") + : tunnelEnabled + ? t("api.tunnelDisable") + : tunnelNeedsSetup + ? t("api.tunnelConfigure") + : t("api.tunnelEnable"); + const tunnelReplaceConfirmationRequired = tunnel.configurationSource === "local"; + const tunnelSetupReady = tunnelSetupMethod === "api" + ? Boolean( + tunnelAccountId.trim() + && tunnelZoneId.trim() + && tunnelHostname.trim() + && tunnelApiToken.trim() + && (!tunnelReplaceConfirmationRequired || tunnelReplaceExisting) + ) + : Boolean(tunnelPublicUrl.trim() && tunnelToken.trim()); return (
@@ -91,16 +325,210 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {

{subtitleParts[0]} - Authorization: Bearer ocx_... + X-OpenCodex-API-Key: ocx_... {subtitleParts[1]} - x-opencodex-api-key - {subtitleParts[2]}

{t("api.endpoint")}

{responseEndpoint}

{t("api.endpointNote")}

+ +
+
+
+ {t("api.tunnelTitle")} + + {t(TUNNEL_STATUS_KEYS[tunnel.status])} + +
+

+ {t(tunnel.mode === "named" ? "api.tunnelModeNamed" : "api.tunnelModeQuick")} +

+ {tunnel.configuredPublicUrl && ( +

+ {t("api.tunnelPublicUrl")} + {tunnel.configuredPublicUrl} +

+ )} + {tunnel.originUrl && ( +

+ {t("api.tunnelOriginUrl")} + {tunnel.originUrl} +

+ )} + {!tunnelCanProceed && !tunnelEnabled && ( +

+ {t(tunnel.configurationSource === "environment" && tunnel.setupRequired + ? "api.tunnelEnvironmentManaged" + : "api.tunnelNeedsKey")} +

+ )} +
+
+ {tunnel.configured && !tunnelEnabled && tunnel.configurationSource !== "environment" && ( + + )} + + {tunnelCanUseQuick && ( + + )} +
+
+ {tunnelCanUseQuick && ( +

{t("api.tunnelQuickNote")}

+ )} + {tunnelError && ( +
+ {tunnelError} +
+ )} + {tunnelSetupOpen && !tunnelEnabled && ( +
+
+
+

{t("api.tunnelSetupTitle")}

+

{t("api.tunnelSetupIntro")}

+
+ +
+ +
+ {t("api.tunnelSseNote")} +
+ +
+ + +
+ +
+ {tunnelSetupMethod === "api" ? ( + <> +
+ + + + +
+ +

{t("api.tunnelApiPermissions")}

+ {tunnelReplaceConfirmationRequired && ( + + )} + + ) : ( + <> + + + + )} + +

+ + {t(tunnelSetupMethod === "api" ? "api.tunnelTokenSecurity" : "api.tunnelRunnerTokenSecurity")} +

+ +
+ +
+
+
+ )}
{newKey && ( @@ -174,7 +602,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {

{t("api.usageTitle")}

{`curl ${responseEndpoint} \\
-  -H "Authorization: Bearer ocx_YOUR_KEY_HERE" \\
+  -H "X-OpenCodex-API-Key: ocx_YOUR_KEY_HERE" \\
   -H "Content-Type: application/json" \\
   -d '{
     "model": "gpt-5.4",
diff --git a/gui/src/styles.css b/gui/src/styles.css
index 2469f43d3..54f8e9ebf 100644
--- a/gui/src/styles.css
+++ b/gui/src/styles.css
@@ -400,6 +400,80 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }
 }
 .api-code-inline { white-space: nowrap; }
 .api-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
+.api-tunnel-control {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-4);
+  min-width: 0;
+  margin-top: var(--space-1);
+  padding-top: var(--space-4);
+  border-top: 1px solid var(--border-soft);
+}
+.api-tunnel-copy { display: flex; flex: 1 1 auto; flex-direction: column; gap: var(--space-1-5); min-width: 0; }
+.api-tunnel-buttons { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); flex-wrap: wrap; }
+.api-tunnel-title-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
+.api-tunnel-title { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-control); font-weight: var(--weight-semibold); }
+.api-tunnel-title svg { width: var(--icon-md); height: var(--icon-md); color: var(--muted); }
+.api-tunnel-detail { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; margin: 0; }
+.api-tunnel-detail code { color: var(--text); overflow-wrap: anywhere; }
+.api-tunnel-key-hint { margin: 0; color: var(--amber); }
+.api-tunnel-error { align-items: flex-start; margin: 0; }
+.api-tunnel-setup {
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-4);
+  margin-top: var(--space-2);
+  padding-top: var(--space-5);
+  border-top: 1px solid var(--border-soft);
+}
+.api-tunnel-setup-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
+.api-tunnel-setup-head h4 { margin: 0 0 var(--space-1-5); font-size: var(--text-control); }
+.api-tunnel-sse-note { align-items: flex-start; margin: 0; color: var(--green); }
+.api-tunnel-sse-note svg { flex: 0 0 auto; }
+.api-tunnel-methods { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
+.api-tunnel-method {
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-1-5);
+  min-width: 0;
+  padding: var(--space-4);
+  color: var(--text);
+  background: var(--bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+  transition: border-color var(--motion-fast), background var(--motion-fast);
+}
+.api-tunnel-method:hover { background: var(--raised); }
+.api-tunnel-method:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; }
+.api-tunnel-method span { color: var(--muted); font-size: var(--text-label); line-height: var(--leading-relaxed); }
+.api-tunnel-method-active { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--surface)); }
+.api-tunnel-setup-form { display: flex; flex-direction: column; gap: var(--space-4); }
+.api-tunnel-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
+.api-tunnel-setup-form label { display: block; min-width: 0; }
+.api-tunnel-setup-form .field-label { color: var(--text); }
+.api-tunnel-setup-form .input { width: 100%; min-width: 0; }
+.api-tunnel-setup-form .api-tunnel-confirm { display: flex; align-items: flex-start; gap: var(--space-2); color: var(--amber); font-size: var(--text-label); line-height: var(--leading-relaxed); }
+.api-tunnel-confirm input { flex: 0 0 auto; margin-top: 3px; }
+.api-tunnel-token-input { font-family: var(--font-code); font-size: var(--text-label); }
+.api-tunnel-security-note { display: flex; align-items: flex-start; gap: var(--space-2); margin: 0; color: var(--amber); }
+.api-tunnel-security-note svg { width: var(--icon-sm); height: var(--icon-sm); flex: 0 0 auto; margin-top: 2px; }
+.api-tunnel-guide-links { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
+.api-tunnel-guide-links a { display: inline-flex; align-items: center; gap: var(--space-1-5); color: var(--accent); font-size: var(--text-label); }
+.api-tunnel-guide-links svg { width: var(--icon-sm); height: var(--icon-sm); }
+.api-tunnel-setup-actions { display: flex; align-items: center; justify-content: flex-end; }
+
+@media (max-width: 640px) {
+  .api-tunnel-control { align-items: stretch; flex-direction: column; }
+  .api-tunnel-buttons { justify-content: flex-start; }
+  .api-tunnel-setup-head { flex-direction: column; }
+  .api-tunnel-methods, .api-tunnel-form-grid { grid-template-columns: 1fr; }
+  .api-tunnel-setup-actions { justify-content: stretch; }
+  .api-tunnel-setup-actions .btn { width: 100%; }
+}
 .maintenance-head { align-items: flex-start; flex-wrap: wrap; }
 .maintenance-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
 .maintenance-notice { margin: 14px 0 0; align-items: flex-start; }
@@ -464,6 +538,7 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }
 .badge-accent { background: var(--accent-soft); color: var(--text); }
 .badge-green { background: var(--green-soft); color: var(--green); }
 .badge-amber { background: var(--amber-soft); color: var(--amber); }
+.badge-red { background: var(--red-soft); color: var(--red); }
 .badge-muted { background: var(--raised); color: var(--muted); border: 1px solid var(--border); }
 .badge-clickable { cursor: pointer; transition: filter var(--motion-fast); appearance: none; }
 .badge-clickable:hover { filter: brightness(1.1); }
diff --git a/src/config.ts b/src/config.ts
index 208ec458a..6dd6657b6 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -539,8 +539,7 @@ export function getRuntimePortPath(): string {
   return resolveRuntimePortPath();
 }
 
-export function hardenConfigDir(): void {
-  const dir = getConfigDir();
+export function hardenConfigDir(dir = getConfigDir()): void {
   if (existsSync(dir)) {
     try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
     if (process.platform === "win32") {
diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts
index 72a5465c6..3f49280d2 100644
--- a/src/server/auth-cors.ts
+++ b/src/server/auth-cors.ts
@@ -11,6 +11,7 @@ import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers
 import { providerConfigSeed } from "../providers/derive";
 import type { OcxConfig, OcxProviderConfig } from "../types";
 import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
+import { isCloudflareTunnelRequest, isCloudflareTunnelRequestOrigin } from "./cloudflare-tunnel";
 
 let _corsOrigin = "http://localhost:10100";
 export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
@@ -65,11 +66,15 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean
     });
   }
   const origin = req.headers.get("Origin");
-  if (!isApiAuthRequired(config)) {
+  if (!isRequestApiAuthRequired(req, config)) {
     if (!isLoopbackRequestHost(req.headers.get("Host"))) return false;
     return !origin || isLoopbackOriginValue(origin) || isExtraAllowedOrigin(origin, config);
   }
-  return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config);
+  return !origin
+    || isLoopbackOriginValue(origin)
+    || isSameOriginAsRequest(req, origin)
+    || isCloudflareTunnelRequestOrigin(origin)
+    || isExtraAllowedOrigin(origin, config);
 }
 
 export function corsHeaders(req?: Request, config?: OcxConfig): Record {
@@ -116,6 +121,14 @@ export function isApiAuthRequired(config: OcxConfig): boolean {
   return !isLoopbackHostname(config.hostname);
 }
 
+/**
+ * Request-scoped admission gate. A Cloudflare connector reaches the loopback listener from the
+ * same machine, so bind-host-only checks would otherwise mistake public traffic for local traffic.
+ */
+export function isRequestApiAuthRequired(req: Request, config: OcxConfig): boolean {
+  return isApiAuthRequired(config) || isCloudflareTunnelRequest(req);
+}
+
 export function assertServerAuthConfig(config: OcxConfig): void {
   if (isApiAuthRequired(config) && !configuredApiAuthToken(config)) {
     throw new Error("OPENCODEX_API_AUTH_TOKEN is required when binding opencodex to a non-loopback hostname");
@@ -155,7 +168,7 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx
 }
 
 export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
-  if (!isApiAuthRequired(config)) return true;
+  if (!isRequestApiAuthRequired(req, config)) return true;
   const actual = req.headers.get("x-opencodex-api-key")?.trim()
     || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
     // Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
@@ -176,7 +189,7 @@ export function requireApiAuth(req: Request, config: OcxConfig, kind: "managemen
  * domains can never be confused.
  */
 export function requireResponsesApiAuth(req: Request, config: OcxConfig): Response | null {
-  if (!isApiAuthRequired(config)) return null;
+  if (!isRequestApiAuthRequired(req, config)) return null;
   const actual = req.headers.get("x-opencodex-api-key")?.trim();
   if (actual && isProxyAdmissionSecret(actual, config)) return null;
   return formatErrorResponse(401, "authentication_error", "opencodex API key required");
diff --git a/src/server/cloudflare-provision.ts b/src/server/cloudflare-provision.ts
new file mode 100644
index 000000000..bb96ad3ff
--- /dev/null
+++ b/src/server/cloudflare-provision.ts
@@ -0,0 +1,232 @@
+import { randomUUID } from "node:crypto";
+import { CLOUDFLARE_TUNNEL_ORIGIN_HOST, normalizeNamedPublicUrl } from "./cloudflare-tunnel";
+
+const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
+const CLOUDFLARE_ID_PATTERN = /^[a-f0-9]{32}$/i;
+const TUNNEL_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,98}[A-Za-z0-9])?$/;
+
+export interface CloudflareProvisionInput {
+  apiToken: string;
+  accountId: string;
+  zoneId: string;
+  hostname: string;
+  tunnelName?: string;
+}
+
+export interface CloudflareProvisionResult {
+  publicUrl: string;
+  tunnelToken: string;
+  tunnelId: string;
+  dnsRecordId: string;
+}
+
+export interface CloudflareProvisionDeps {
+  fetchFn?: typeof fetch;
+  timeoutMs?: number;
+}
+
+interface CloudflareEnvelope {
+  success?: boolean;
+  result?: T;
+  errors?: Array<{ message?: unknown }>;
+}
+
+export class CloudflareProvisionError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "CloudflareProvisionError";
+  }
+}
+
+function safeCloudflareError(value: unknown, apiToken: string): string {
+  if (typeof value !== "string") return "Cloudflare API request failed.";
+  const sanitized = value
+    .replaceAll(apiToken, "[redacted]")
+    .replace(/[\r\n\0]/g, " ")
+    .trim()
+    .slice(0, 240);
+  return sanitized || "Cloudflare API request failed.";
+}
+
+function normalizeApiToken(value: unknown): string | null {
+  if (typeof value !== "string") return null;
+  const token = value.trim();
+  return token.length >= 20 && token.length <= 4_096 && !/[\s\r\n]/.test(token) ? token : null;
+}
+
+export function validateCloudflareProvisionInput(input: CloudflareProvisionInput): {
+  apiToken: string;
+  accountId: string;
+  zoneId: string;
+  hostname: string;
+  publicUrl: string;
+  tunnelName: string;
+} {
+  const apiToken = normalizeApiToken(input.apiToken);
+  const accountId = input.accountId?.trim();
+  const zoneId = input.zoneId?.trim();
+  const rawHostname = input.hostname?.trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
+  const publicUrl = normalizeNamedPublicUrl(rawHostname ? `https://${rawHostname}` : undefined);
+  if (!apiToken) throw new CloudflareProvisionError("A valid Cloudflare API token is required.");
+  if (!CLOUDFLARE_ID_PATTERN.test(accountId)) throw new CloudflareProvisionError("Cloudflare account ID must be 32 hexadecimal characters.");
+  if (!CLOUDFLARE_ID_PATTERN.test(zoneId)) throw new CloudflareProvisionError("Cloudflare zone ID must be 32 hexadecimal characters.");
+  if (!publicUrl) throw new CloudflareProvisionError("Hostname must be a valid public DNS hostname.");
+  const hostname = new URL(publicUrl).hostname;
+  const requestedName = input.tunnelName?.trim();
+  const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
+  const generatedName = `opencodex-${hostname.replace(/[^A-Za-z0-9]+/g, "-").slice(0, 70)}-${suffix}`;
+  const tunnelName = requestedName || generatedName;
+  if (!TUNNEL_NAME_PATTERN.test(tunnelName)) {
+    throw new CloudflareProvisionError("Tunnel name must use letters, numbers, hyphens, or underscores.");
+  }
+  return { apiToken, accountId, zoneId, hostname, publicUrl, tunnelName };
+}
+
+async function cloudflareRequest(
+  path: string,
+  apiToken: string,
+  init: RequestInit,
+  deps: Required>,
+): Promise {
+  let response: Response;
+  try {
+    response = await deps.fetchFn(`${CLOUDFLARE_API_BASE}${path}`, {
+      ...init,
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${apiToken}`,
+        ...(init.headers ?? {}),
+      },
+      signal: AbortSignal.timeout(deps.timeoutMs),
+    });
+  } catch {
+    throw new CloudflareProvisionError("Could not reach the Cloudflare API.");
+  }
+  let envelope: CloudflareEnvelope | null = null;
+  try {
+    envelope = await response.json() as CloudflareEnvelope;
+  } catch {
+    // Preserve a bounded generic error; Cloudflare may return an intermediary HTML page.
+  }
+  if (!response.ok || envelope?.success !== true || envelope.result === undefined) {
+    throw new CloudflareProvisionError(safeCloudflareError(envelope?.errors?.[0]?.message, apiToken));
+  }
+  return envelope.result;
+}
+
+async function bestEffortDelete(
+  path: string,
+  apiToken: string,
+  deps: Required>,
+): Promise {
+  try {
+    await deps.fetchFn(`${CLOUDFLARE_API_BASE}${path}`, {
+      method: "DELETE",
+      headers: { Authorization: `Bearer ${apiToken}` },
+      signal: AbortSignal.timeout(deps.timeoutMs),
+    });
+  } catch {
+    // The original setup error is more useful. Cloudflare resources can be removed in the dashboard.
+  }
+}
+
+export async function cleanupProvisionedCloudflareTunnel(
+  input: Pick,
+  result: Pick,
+  options: CloudflareProvisionDeps = {},
+): Promise {
+  const deps = {
+    fetchFn: options.fetchFn ?? fetch,
+    timeoutMs: options.timeoutMs ?? 15_000,
+  };
+  await bestEffortDelete(`/zones/${input.zoneId}/dns_records/${result.dnsRecordId}`, input.apiToken, deps);
+  await bestEffortDelete(`/accounts/${input.accountId}/cfd_tunnel/${result.tunnelId}`, input.apiToken, deps);
+}
+
+export async function provisionCloudflareNamedTunnel(
+  input: CloudflareProvisionInput,
+  originUrl: string,
+  options: CloudflareProvisionDeps = {},
+): Promise {
+  const normalized = validateCloudflareProvisionInput(input);
+  const deps = {
+    fetchFn: options.fetchFn ?? fetch,
+    timeoutMs: options.timeoutMs ?? 15_000,
+  };
+  let tunnelId: string | null = null;
+  let dnsRecordId: string | null = null;
+  try {
+    const tunnel = await cloudflareRequest<{ id?: unknown }>(
+      `/accounts/${normalized.accountId}/cfd_tunnel`,
+      normalized.apiToken,
+      { method: "POST", body: JSON.stringify({ name: normalized.tunnelName, config_src: "cloudflare" }) },
+      deps,
+    );
+    if (typeof tunnel.id !== "string" || !/^[a-f0-9-]{36}$/i.test(tunnel.id)) {
+      throw new CloudflareProvisionError("Cloudflare returned an invalid tunnel identifier.");
+    }
+    tunnelId = tunnel.id;
+
+    await cloudflareRequest(
+      `/accounts/${normalized.accountId}/cfd_tunnel/${tunnelId}/configurations`,
+      normalized.apiToken,
+      {
+        method: "PUT",
+        body: JSON.stringify({
+          config: {
+            ingress: [
+              {
+                hostname: normalized.hostname,
+                service: originUrl,
+                originRequest: { httpHostHeader: CLOUDFLARE_TUNNEL_ORIGIN_HOST },
+              },
+              { service: "http_status:404" },
+            ],
+          },
+        }),
+      },
+      deps,
+    );
+
+    const dns = await cloudflareRequest<{ id?: unknown }>(
+      `/zones/${normalized.zoneId}/dns_records`,
+      normalized.apiToken,
+      {
+        method: "POST",
+        body: JSON.stringify({
+          type: "CNAME",
+          proxied: true,
+          name: normalized.hostname,
+          content: `${tunnelId}.cfargotunnel.com`,
+        }),
+      },
+      deps,
+    );
+    if (typeof dns.id !== "string" || !CLOUDFLARE_ID_PATTERN.test(dns.id)) {
+      throw new CloudflareProvisionError("Cloudflare returned an invalid DNS record identifier.");
+    }
+    dnsRecordId = dns.id;
+
+    const tunnelToken = await cloudflareRequest(
+      `/accounts/${normalized.accountId}/cfd_tunnel/${tunnelId}/token`,
+      normalized.apiToken,
+      { method: "GET" },
+      deps,
+    );
+    if (typeof tunnelToken !== "string" || !tunnelToken.startsWith("eyJ")) {
+      throw new CloudflareProvisionError("Cloudflare returned an invalid Tunnel token.");
+    }
+    return {
+      publicUrl: normalized.publicUrl,
+      tunnelToken,
+      tunnelId,
+      dnsRecordId,
+    };
+  } catch (error) {
+    if (dnsRecordId) await bestEffortDelete(`/zones/${normalized.zoneId}/dns_records/${dnsRecordId}`, normalized.apiToken, deps);
+    if (tunnelId) await bestEffortDelete(`/accounts/${normalized.accountId}/cfd_tunnel/${tunnelId}`, normalized.apiToken, deps);
+    throw error instanceof CloudflareProvisionError
+      ? error
+      : new CloudflareProvisionError("Cloudflare Tunnel setup failed.");
+  }
+}
diff --git a/src/server/cloudflare-setup.ts b/src/server/cloudflare-setup.ts
new file mode 100644
index 000000000..56e0af0fe
--- /dev/null
+++ b/src/server/cloudflare-setup.ts
@@ -0,0 +1,183 @@
+import { createHash } from "node:crypto";
+import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
+import { join } from "node:path";
+import {
+  atomicWriteFile,
+  getConfigDir,
+  hardenConfigDir,
+} from "../config";
+import type { OcxConfig } from "../types";
+import {
+  normalizeNamedPublicUrl,
+  type CloudflareTunnelMode,
+  type CloudflareTunnelStartOptions,
+} from "./cloudflare-tunnel";
+
+export const CLOUDFLARE_TUNNEL_TOKEN_FILENAME = "cloudflare-tunnel-token";
+
+export type CloudflareTunnelConfigurationSource = "environment" | "local" | "quick" | "none";
+
+export interface ResolvedCloudflareTunnelSetup {
+  mode: CloudflareTunnelMode;
+  configured: boolean;
+  source: CloudflareTunnelConfigurationSource;
+  publicUrl: string | null;
+  supportsSse: boolean;
+  error?: "environment_incomplete" | "public_url_invalid" | "token_missing" | "token_mismatch";
+  /** Internal fixed path. Never include it in an API response. */
+  tokenFile?: string;
+}
+
+interface SetupIO {
+  env?: NodeJS.ProcessEnv;
+  exists?: (path: string) => boolean;
+  read?: (path: string) => string;
+  configDir?: string;
+}
+
+export interface StoredTunnelTokenChange {
+  path: string;
+  fingerprint: string;
+  rollback(): void;
+}
+
+function tokenFingerprint(token: string): string {
+  return createHash("sha256").update(token, "utf8").digest("hex");
+}
+
+export function storedCloudflareTunnelTokenPath(configDir = getConfigDir()): string {
+  return join(configDir, CLOUDFLARE_TUNNEL_TOKEN_FILENAME);
+}
+
+/** Accept a raw Tunnel token or the install/run command copied from Cloudflare's dashboard. */
+export function normalizeCloudflareTunnelToken(input: unknown): string | null {
+  if (typeof input !== "string") return null;
+  const trimmed = input.trim();
+  if (!trimmed || trimmed.length > 16_384 || /[\r\n]/.test(trimmed)) return null;
+  const direct = /^eyJ[A-Za-z0-9._~+/=-]{30,16381}$/.test(trimmed) ? trimmed : null;
+  if (direct) return direct;
+  const candidates = trimmed.match(/eyJ[A-Za-z0-9._~+/=-]{30,16381}/g) ?? [];
+  return candidates.length === 1 ? candidates[0] : null;
+}
+
+export function replaceStoredCloudflareTunnelToken(
+  input: unknown,
+  configDir = getConfigDir(),
+): StoredTunnelTokenChange {
+  const token = normalizeCloudflareTunnelToken(input);
+  if (!token) throw new Error("invalid Cloudflare Tunnel token");
+  const path = storedCloudflareTunnelTokenPath(configDir);
+  const hadPrevious = existsSync(path);
+  const previous = hadPrevious ? readFileSync(path, "utf8") : null;
+
+  if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true, mode: 0o700 });
+  hardenConfigDir(configDir);
+  atomicWriteFile(path, `${token}\n`);
+
+  return {
+    path,
+    fingerprint: tokenFingerprint(token),
+    rollback() {
+      if (previous !== null) atomicWriteFile(path, previous);
+      else {
+        try { unlinkSync(path); } catch (error) {
+          if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+        }
+      }
+    },
+  };
+}
+
+export function resolveCloudflareTunnelSetup(
+  config: Pick,
+  io: SetupIO = {},
+): ResolvedCloudflareTunnelSetup {
+  const env = io.env ?? process.env;
+  const exists = io.exists ?? existsSync;
+  const read = io.read ?? (path => readFileSync(path, "utf8"));
+  const envToken = env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN?.trim();
+  const envTokenFile = env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE?.trim();
+  const envPublicUrl = env.OPENCODEX_CLOUDFLARE_PUBLIC_URL?.trim();
+  const hasEnvironmentSetup = !!(envToken || envTokenFile || envPublicUrl);
+
+  if (hasEnvironmentSetup) {
+    const publicUrl = normalizeNamedPublicUrl(envPublicUrl);
+    const hasOneTokenSource = !!envToken !== !!envTokenFile;
+    const tokenFileExists = !envTokenFile || exists(envTokenFile);
+    return {
+      mode: "named",
+      configured: hasOneTokenSource && tokenFileExists && !!publicUrl,
+      source: "environment",
+      publicUrl,
+      supportsSse: true,
+      ...(!publicUrl
+        ? { error: envPublicUrl ? "public_url_invalid" as const : "environment_incomplete" as const }
+        : !hasOneTokenSource || !tokenFileExists
+          ? { error: "environment_incomplete" as const }
+          : {}),
+    };
+  }
+
+  const requestedMode = config.cloudflareTunnel?.mode ?? "named";
+  if (requestedMode === "quick") {
+    return { mode: "quick", configured: true, source: "quick", publicUrl: null, supportsSse: false };
+  }
+
+  const configuredValue = config.cloudflareTunnel?.publicUrl;
+  const publicUrl = normalizeNamedPublicUrl(configuredValue);
+  const path = storedCloudflareTunnelTokenPath(io.configDir ?? getConfigDir());
+  if (!publicUrl) {
+    return {
+      mode: "named",
+      configured: false,
+      source: configuredValue || exists(path) ? "local" : "none",
+      publicUrl: null,
+      supportsSse: true,
+      ...(configuredValue ? { error: "public_url_invalid" as const } : {}),
+    };
+  }
+  if (!exists(path)) {
+    return {
+      mode: "named",
+      configured: false,
+      source: "local",
+      publicUrl,
+      supportsSse: true,
+      error: "token_missing",
+    };
+  }
+  let actualFingerprint: string;
+  try {
+    actualFingerprint = tokenFingerprint(normalizeCloudflareTunnelToken(read(path)) ?? "");
+  } catch {
+    return {
+      mode: "named", configured: false, source: "local", publicUrl, supportsSse: true, error: "token_missing",
+    };
+  }
+  if (!config.cloudflareTunnel?.tokenFingerprint || actualFingerprint !== config.cloudflareTunnel.tokenFingerprint) {
+    return {
+      mode: "named", configured: false, source: "local", publicUrl, supportsSse: true, error: "token_mismatch",
+    };
+  }
+  return {
+    mode: "named",
+    configured: true,
+    source: "local",
+    publicUrl,
+    supportsSse: true,
+    tokenFile: path,
+  };
+}
+
+export function cloudflareTunnelStartOverrides(
+  setup: ResolvedCloudflareTunnelSetup,
+): Pick {
+  if (setup.mode === "quick") return { mode: "quick" };
+  if (setup.source === "local" && setup.configured && setup.publicUrl && setup.tokenFile) {
+    return {
+      mode: "named",
+      namedTunnel: { publicUrl: setup.publicUrl, tokenFile: setup.tokenFile },
+    };
+  }
+  return { mode: "named" };
+}
diff --git a/src/server/cloudflare-tunnel.ts b/src/server/cloudflare-tunnel.ts
new file mode 100644
index 000000000..5084bd234
--- /dev/null
+++ b/src/server/cloudflare-tunnel.ts
@@ -0,0 +1,597 @@
+import { spawn, type ChildProcess } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { isIP } from "node:net";
+import { join } from "node:path";
+import { commandInvocation } from "../lib/win-exec";
+
+export const CLOUDFLARE_TUNNEL_ORIGIN_HOST = "opencodex-tunnel.invalid";
+
+export type CloudflareTunnelPhase = "stopped" | "starting" | "running" | "stopping" | "error";
+export type CloudflareTunnelMode = "quick" | "named";
+
+export interface CloudflareTunnelStatus {
+  status: CloudflareTunnelPhase;
+  mode: CloudflareTunnelMode;
+  publicUrl: string | null;
+  supportsSse: boolean;
+  error?: string;
+  startedAt?: string;
+}
+
+export interface CloudflareTunnelStartOptions {
+  originUrl: string;
+  actualPort: number;
+  configuredPort: number;
+  mode?: CloudflareTunnelMode;
+  namedTunnel?: { publicUrl: string; tokenFile: string };
+}
+
+export interface CloudflareTunnelController {
+  getStatus(): CloudflareTunnelStatus;
+  start(options: CloudflareTunnelStartOptions): Promise;
+  stop(): Promise;
+}
+
+export interface CloudflareTunnelDeps {
+  spawnFn?: typeof spawn;
+  fetchFn?: typeof fetch;
+  env?: NodeJS.ProcessEnv;
+  platform?: NodeJS.Platform;
+  homeDir?: string;
+  exists?: (path: string) => boolean;
+  readFile?: (path: string) => string;
+  now?: () => Date;
+  startupTimeoutMs?: number;
+  namedReadyDelayMs?: number;
+  stopTimeoutMs?: number;
+}
+
+interface LaunchSpec {
+  mode: CloudflareTunnelMode;
+  binary: string;
+  args: string[];
+  env: NodeJS.ProcessEnv;
+  publicUrl: string | null;
+  supportsSse: boolean;
+}
+
+const HTTPS_URL_TOKEN_PATTERN = /https:\/\/[^\s"'<>|│]+/ig;
+const LOOPBACK_METRICS_PATTERN = /(?:https?:\/\/)?(?:127\.0\.0\.1|localhost):(\d{1,5})\/metrics\b/ig;
+const MAX_OUTPUT_BUFFER = 64 * 1024;
+
+function modeSupportsSse(mode: CloudflareTunnelMode): boolean {
+  return mode === "named";
+}
+
+function isValidDnsHostname(hostname: string, requireMultipleLabels = false): boolean {
+  if (!hostname || hostname.length > 253 || isIP(hostname) !== 0) return false;
+  const labels = hostname.toLowerCase().split(".");
+  if (requireMultipleLabels && labels.length < 2) return false;
+  return labels.every(label => (
+    /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)
+  ));
+}
+
+function selectedMode(env: NodeJS.ProcessEnv): CloudflareTunnelMode {
+  return env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN?.trim()
+    || env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE?.trim()
+    || env.OPENCODEX_CLOUDFLARE_PUBLIC_URL?.trim()
+    ? "named"
+    : "quick";
+}
+
+function stoppedStatus(env: NodeJS.ProcessEnv): CloudflareTunnelStatus {
+  const mode = selectedMode(env);
+  return { status: "stopped", mode, publicUrl: null, supportsSse: modeSupportsSse(mode) };
+}
+
+function sanitizedError(message: string, mode: CloudflareTunnelMode, publicUrl: string | null = null): CloudflareTunnelStatus {
+  return {
+    status: "error",
+    mode,
+    publicUrl,
+    supportsSse: modeSupportsSse(mode),
+    error: message,
+  };
+}
+
+export function parseQuickTunnelUrl(output: string): string | null {
+  HTTPS_URL_TOKEN_PATTERN.lastIndex = 0;
+  for (const match of output.matchAll(HTTPS_URL_TOKEN_PATTERN)) {
+    try {
+      const parsed = new URL(match[0].replace(/[\])},;.!]+$/, ""));
+      const hostname = parsed.hostname.toLowerCase();
+      const suffix = ".trycloudflare.com";
+      const subdomain = hostname.endsWith(suffix) ? hostname.slice(0, -suffix.length) : "";
+      const validLabels = isValidDnsHostname(subdomain);
+      if (
+        parsed.protocol === "https:"
+        && parsed.username === ""
+        && parsed.password === ""
+        && parsed.port === ""
+        && parsed.pathname === "/"
+        && parsed.search === ""
+        && parsed.hash === ""
+        && !!subdomain
+        && validLabels
+      ) return parsed.origin;
+    } catch {
+      // Ignore unrelated or malformed URLs in cloudflared diagnostics.
+    }
+  }
+  return null;
+}
+
+export function normalizeNamedPublicUrl(value: string | undefined): string | null {
+  if (!value?.trim()) return null;
+  try {
+    const parsed = new URL(value.trim());
+    if (
+      parsed.protocol !== "https:"
+      || parsed.username
+      || parsed.password
+      || parsed.port
+      || (parsed.pathname !== "/" && parsed.pathname !== "")
+      || parsed.search
+      || parsed.hash
+      || !parsed.hostname
+      || parsed.hostname.endsWith(".")
+      || !isValidDnsHostname(parsed.hostname, true)
+    ) return null;
+    return parsed.origin;
+  } catch {
+    return null;
+  }
+}
+
+/** Parse only the explicitly loopback-bound metrics listener emitted by cloudflared. */
+export function parseCloudflaredReadyUrl(output: string): string | null {
+  LOOPBACK_METRICS_PATTERN.lastIndex = 0;
+  let readyUrl: string | null = null;
+  for (const match of output.matchAll(LOOPBACK_METRICS_PATTERN)) {
+    const port = Number(match[1]);
+    if (Number.isInteger(port) && port > 0 && port <= 65_535) {
+      readyUrl = `http://127.0.0.1:${port}/ready`;
+    }
+  }
+  return readyUrl;
+}
+
+export function buildCloudflaredLaunch(
+  options: CloudflareTunnelStartOptions,
+  deps: Pick = {},
+): LaunchSpec | CloudflareTunnelStatus {
+  const env = deps.env ?? process.env;
+  const mode = options.mode ?? selectedMode(env);
+  const binary = env.OPENCODEX_CLOUDFLARED_PATH?.trim() || "cloudflared";
+  const childEnv: NodeJS.ProcessEnv = { ...env };
+  let token = options.namedTunnel ? undefined : env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN?.trim();
+  const tokenFile = options.namedTunnel ? undefined : env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE?.trim();
+  const publicUrlValue = options.namedTunnel?.publicUrl ?? env.OPENCODEX_CLOUDFLARE_PUBLIC_URL?.trim();
+  delete childEnv.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN;
+  delete childEnv.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE;
+  // Ambient official cloudflared credentials must never select or conflict with the explicit
+  // opencodex mode. Re-add only the one credential source chosen below.
+  delete childEnv.TUNNEL_TOKEN;
+  delete childEnv.TUNNEL_TOKEN_FILE;
+
+  if (options.namedTunnel) {
+    try {
+      token = (deps.readFile ?? (path => readFileSync(path, "utf8")))(options.namedTunnel.tokenFile).trim();
+    } catch {
+      return sanitizedError("Stored Cloudflare Tunnel credentials could not be read.", "named");
+    }
+    if (!/^eyJ[A-Za-z0-9._~+/=-]{30,16381}$/.test(token)) {
+      return sanitizedError("Stored Cloudflare Tunnel credentials are invalid.", "named");
+    }
+  }
+
+  if (mode === "named") {
+    if ((!token && !tokenFile) || (token && tokenFile) || !publicUrlValue) {
+      return sanitizedError(
+        "Named Tunnel requires one tunnel token source and OPENCODEX_CLOUDFLARE_PUBLIC_URL.",
+        mode,
+      );
+    }
+    const publicUrl = normalizeNamedPublicUrl(publicUrlValue);
+    if (!publicUrl) {
+      return sanitizedError("OPENCODEX_CLOUDFLARE_PUBLIC_URL must be an HTTPS origin without a path.", mode);
+    }
+    if (options.actualPort !== options.configuredPort) {
+      return sanitizedError(
+        "Named Tunnel origin uses the configured opencodex port, but the proxy started on a fallback port.",
+        mode,
+        publicUrl,
+      );
+    }
+    const args = [
+      "tunnel", "--no-autoupdate", "--loglevel", "info", "--protocol", "auto",
+      "--metrics", "127.0.0.1:0", "run",
+    ];
+    if (tokenFile) {
+      args.push("--token-file", tokenFile);
+    } else {
+      childEnv.TUNNEL_TOKEN = token;
+    }
+    return { mode, binary, args, env: childEnv, publicUrl, supportsSse: true };
+  }
+
+  const home = deps.homeDir ?? homedir();
+  const pathExists = deps.exists ?? existsSync;
+  const configDir = join(home, ".cloudflared");
+  if (pathExists(join(configDir, "config.yml")) || pathExists(join(configDir, "config.yaml"))) {
+    return sanitizedError(
+      "Quick Tunnel cannot start while ~/.cloudflared/config.yml or config.yaml exists. Use a Named Tunnel or move that Cloudflare config.",
+      mode,
+    );
+  }
+  return {
+    mode,
+    binary,
+    args: [
+      "tunnel",
+      "--no-autoupdate",
+      "--loglevel",
+      "info",
+      "--metrics",
+      "127.0.0.1:0",
+      "--http-host-header",
+      CLOUDFLARE_TUNNEL_ORIGIN_HOST,
+      "--url",
+      options.originUrl,
+    ],
+    env: childEnv,
+    publicUrl: null,
+    supportsSse: false,
+  };
+}
+
+function hostFromValue(value: string | null): string | null {
+  if (!value) return null;
+  try {
+    return new URL(`http://${value}`).hostname.toLowerCase().replace(/\.+$/, "");
+  } catch {
+    return null;
+  }
+}
+
+export class ManagedCloudflareTunnelController implements CloudflareTunnelController {
+  private readonly deps: Required>;
+  private state: CloudflareTunnelStatus;
+  private child: ChildProcess | null = null;
+  private startPromise: Promise | null = null;
+  private stopPromise: Promise | null = null;
+  private cancelStartWaiter: (() => void) | null = null;
+  private output = "";
+  private generation = 0;
+  private intentEpoch = 0;
+  private desiredRunning = false;
+
+  constructor(deps: CloudflareTunnelDeps = {}) {
+    this.deps = {
+      spawnFn: deps.spawnFn ?? spawn,
+      fetchFn: deps.fetchFn ?? fetch,
+      env: deps.env ?? process.env,
+      platform: deps.platform ?? process.platform,
+      homeDir: deps.homeDir ?? homedir(),
+      exists: deps.exists ?? existsSync,
+      readFile: deps.readFile ?? (path => readFileSync(path, "utf8")),
+      now: deps.now ?? (() => new Date()),
+      startupTimeoutMs: deps.startupTimeoutMs ?? 20_000,
+      namedReadyDelayMs: deps.namedReadyDelayMs ?? 750,
+      stopTimeoutMs: deps.stopTimeoutMs ?? 3_000,
+    };
+    this.state = stoppedStatus(this.deps.env);
+  }
+
+  getStatus(): CloudflareTunnelStatus {
+    if (this.state.status === "stopped") this.state = stoppedStatus(this.deps.env);
+    return { ...this.state };
+  }
+
+  start(options: CloudflareTunnelStartOptions): Promise {
+    this.desiredRunning = true;
+    if (this.state.status === "running") return Promise.resolve(this.getStatus());
+    if (this.startPromise) return this.startPromise;
+    const intent = ++this.intentEpoch;
+    const pending = this.startOnce(options, intent).finally(() => {
+      if (this.startPromise === pending) this.startPromise = null;
+    });
+    this.startPromise = pending;
+    return pending;
+  }
+
+  private shouldStart(intent: number): boolean {
+    return this.desiredRunning && intent === this.intentEpoch;
+  }
+
+  private async startOnce(options: CloudflareTunnelStartOptions, intent: number): Promise {
+    if (this.stopPromise) await this.stopPromise;
+    if (!this.shouldStart(intent)) return this.getStatus();
+    if (this.child) {
+      await this.beginStop();
+      if (!this.shouldStart(intent)) return this.getStatus();
+      if (this.child) return this.getStatus();
+    }
+    const launch = buildCloudflaredLaunch(options, this.deps);
+    if ("status" in launch) {
+      this.state = launch;
+      return this.getStatus();
+    }
+
+    const generation = ++this.generation;
+    this.output = "";
+    this.state = {
+      status: "starting",
+      mode: launch.mode,
+      publicUrl: launch.publicUrl,
+      supportsSse: launch.supportsSse,
+    };
+
+    const invocation = commandInvocation(launch.binary, launch.args, this.deps.platform, { env: launch.env });
+    let child: ChildProcess;
+    try {
+      child = this.deps.spawnFn(invocation.file, invocation.args, {
+        ...invocation.options,
+        env: launch.env,
+        stdio: ["ignore", "pipe", "pipe"],
+        windowsHide: true,
+        detached: false,
+        shell: false,
+      });
+    } catch (error) {
+      const missing = (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
+      this.state = sanitizedError(
+        missing
+          ? "cloudflared is not installed. Install it or set OPENCODEX_CLOUDFLARED_PATH."
+          : "cloudflared could not be started.",
+        launch.mode,
+        launch.publicUrl,
+      );
+      return this.getStatus();
+    }
+    this.child = child;
+
+    return new Promise(resolve => {
+      let settled = false;
+      let readyTimer: ReturnType | undefined;
+      let readyUrl: string | null = null;
+      let discoveredPublicUrl = launch.publicUrl;
+      let readyProbeInFlight = false;
+      const startupTimer = setTimeout(() => {
+        fail("Cloudflare Tunnel did not become ready before the startup timeout.");
+      }, this.deps.startupTimeoutMs);
+
+      const finish = () => {
+        if (settled) return;
+        settled = true;
+        clearTimeout(startupTimer);
+        if (readyTimer) clearTimeout(readyTimer);
+        if (this.cancelStartWaiter === finish) this.cancelStartWaiter = null;
+        resolve(this.getStatus());
+      };
+      this.cancelStartWaiter = finish;
+
+      const markRunning = (publicUrl: string | null) => {
+        if (
+          this.child !== child
+          || this.generation !== generation
+          || this.state.status !== "starting"
+          || !this.shouldStart(intent)
+        ) return;
+        this.state = {
+          status: "running",
+          mode: launch.mode,
+          publicUrl,
+          supportsSse: launch.supportsSse,
+          startedAt: this.deps.now().toISOString(),
+        };
+        finish();
+      };
+
+      const fail = (message: string, missing = false) => {
+        if (this.child !== child || this.generation !== generation) return finish();
+        if (!this.shouldStart(intent)) return finish();
+        this.state = sanitizedError(
+          missing
+            ? "cloudflared is not installed. Install it or set OPENCODEX_CLOUDFLARED_PATH."
+            : message,
+          launch.mode,
+          launch.publicUrl,
+        );
+        try { child.kill("SIGTERM"); } catch { /* best-effort */ }
+        // Keep ownership until close so a slow graceful shutdown cannot become an orphan. Escalate
+        // independently because the start request has already received its sanitized error state.
+        setTimeout(() => {
+          if (this.child !== child || this.generation !== generation) return;
+          try { child.kill("SIGKILL"); } catch { /* direct child may already be gone */ }
+        }, this.deps.stopTimeoutMs);
+        finish();
+      };
+
+      const scheduleReadyProbe = () => {
+        if (
+          settled
+          || !readyUrl
+          || !discoveredPublicUrl
+          || readyTimer
+          || readyProbeInFlight
+          || !this.shouldStart(intent)
+        ) return;
+        readyTimer = setTimeout(() => {
+          readyTimer = undefined;
+          void probeReady();
+        }, this.deps.namedReadyDelayMs);
+      };
+
+      const probeReady = async () => {
+        if (settled || !readyUrl || !discoveredPublicUrl || !this.shouldStart(intent)) return;
+        readyProbeInFlight = true;
+        try {
+          const response = await this.deps.fetchFn(readyUrl, {
+            signal: AbortSignal.timeout(Math.max(1, Math.min(1_000, this.deps.startupTimeoutMs))),
+          });
+          try { await response.body?.cancel(); } catch { /* local probe body is best-effort */ }
+          if (response.status === 200) {
+            markRunning(discoveredPublicUrl);
+            return;
+          }
+        } catch {
+          // Metrics can begin listening before the first edge connection becomes active.
+        } finally {
+          readyProbeInFlight = false;
+        }
+        scheduleReadyProbe();
+      };
+
+      const inspectOutput = (chunk: unknown) => {
+        const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk ?? "");
+        this.output = (this.output + text).slice(-MAX_OUTPUT_BUFFER);
+        readyUrl = parseCloudflaredReadyUrl(this.output) ?? readyUrl;
+        if (launch.mode === "quick") {
+          discoveredPublicUrl = parseQuickTunnelUrl(this.output) ?? discoveredPublicUrl;
+        }
+        scheduleReadyProbe();
+      };
+
+      child.stdout?.on("data", inspectOutput);
+      child.stderr?.on("data", inspectOutput);
+      child.once("error", error => {
+        fail("cloudflared could not be started.", (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT");
+      });
+      child.once("close", () => {
+        if (this.child !== child || this.generation !== generation) return;
+        this.child = null;
+        if (this.state.status === "stopping") {
+          this.state = stoppedStatus(this.deps.env);
+        } else if (this.state.status === "starting") {
+          this.state = sanitizedError("cloudflared exited before the tunnel became ready.", launch.mode, launch.publicUrl);
+        } else if (this.state.status === "running") {
+          this.state = sanitizedError("Cloudflare Tunnel stopped unexpectedly.", launch.mode, this.state.publicUrl);
+        }
+        finish();
+      });
+    });
+  }
+
+  stop(): Promise {
+    this.desiredRunning = false;
+    ++this.intentEpoch;
+    // A pending start keeps running only long enough to observe the changed intent. Clearing the
+    // slot lets a later start express a new intent without waiting on the canceled promise wrapper.
+    this.startPromise = null;
+    const pending = this.beginStop();
+    // beginStop synchronously moves a live child to "stopping" before the canceled start resolves,
+    // so callers never observe a stale "starting" result and its startup timer cannot overwrite a
+    // later forced-stop diagnostic.
+    this.cancelStartWaiter?.();
+    return pending;
+  }
+
+  private beginStop(): Promise {
+    if (this.stopPromise) return this.stopPromise;
+    const pending = this.stopOnce().finally(() => {
+      if (this.stopPromise === pending) this.stopPromise = null;
+    });
+    this.stopPromise = pending;
+    return pending;
+  }
+
+  private async stopOnce(): Promise {
+    const child = this.child;
+    if (!child) {
+      ++this.generation;
+      this.state = stoppedStatus(this.deps.env);
+      return this.getStatus();
+    }
+    this.state = { ...this.state, status: "stopping", error: undefined };
+    const generation = this.generation;
+
+    return new Promise(resolve => {
+      let settled = false;
+      let forceTimer: ReturnType | undefined;
+      let finalTimer: ReturnType | undefined;
+      const finish = (closed: boolean) => {
+        if (closed && this.generation === generation) {
+          if (this.child === child) this.child = null;
+          ++this.generation;
+          this.state = stoppedStatus(this.deps.env);
+        }
+        // A late close after the bounded stop response still clears ownership and state above.
+        if (settled) return;
+        settled = true;
+        if (forceTimer) clearTimeout(forceTimer);
+        if (finalTimer) clearTimeout(finalTimer);
+        if (!closed) {
+          this.state = sanitizedError(
+            "cloudflared did not stop after forced termination.",
+            this.state.mode,
+            this.state.publicUrl,
+          );
+        }
+        resolve(this.getStatus());
+      };
+
+      child.once("close", () => finish(true));
+      try { child.kill("SIGTERM"); } catch { /* escalate below */ }
+      forceTimer = setTimeout(() => {
+        try { child.kill("SIGKILL"); } catch { /* direct child may already be gone */ }
+        finalTimer = setTimeout(() => finish(false), 250);
+      }, this.deps.stopTimeoutMs);
+    });
+  }
+
+  /** Synchronous best-effort cleanup for process.on("exit"), where promises cannot be awaited. */
+  killOwnedChild(): void {
+    const child = this.child;
+    this.child = null;
+    ++this.generation;
+    if (child) {
+      // Async graceful cleanup cannot run from an exit handler; terminate the owned connector so
+      // it cannot survive the origin process as an orphan.
+      try { child.kill("SIGKILL"); } catch { /* process is already exiting */ }
+    }
+  }
+}
+
+export const cloudflareTunnelController = new ManagedCloudflareTunnelController();
+
+function activePublicOrigin(): string | null {
+  const status = cloudflareTunnelController.getStatus();
+  // Keep recognizing a known Named-Tunnel host even in an error state. A failed graceful kill can
+  // leave cloudflared accepting requests briefly; dropping the host gate first would expose the
+  // loopback-only management surface during that interval. stopped always clears publicUrl.
+  // The configured Named host is also reserved while stopped. This fails closed if a hard crash
+  // leaves an old connector alive after the persisted toggle was cleared.
+  return status.publicUrl ?? normalizeNamedPublicUrl(process.env.OPENCODEX_CLOUDFLARE_PUBLIC_URL);
+}
+
+export function isCloudflareTunnelRequest(req: Request): boolean {
+  // The edge-to-origin headers are a fail-closed fallback for Named Tunnel configurations that
+  // override the origin Host header. Cloudflare documents CF-Connecting-IP as edge-to-origin only;
+  // CF-Ray remains useful when visitor-IP headers are removed by a Managed Transform. A forged
+  // marker on a local request only makes the request more restricted, never less restricted.
+  if (req.headers.has("cf-connecting-ip") || req.headers.has("cf-ray")) return true;
+  const headerHost = hostFromValue(req.headers.get("Host"));
+  let requestHost = headerHost;
+  if (!requestHost) {
+    try { requestHost = new URL(req.url).hostname.toLowerCase(); } catch { return false; }
+  }
+  if (requestHost === CLOUDFLARE_TUNNEL_ORIGIN_HOST) return true;
+  const publicOrigin = activePublicOrigin();
+  if (!publicOrigin) return false;
+  try {
+    const publicHost = new URL(publicOrigin).hostname.toLowerCase().replace(/\.+$/, "");
+    return requestHost === publicHost;
+  } catch { return false; }
+}
+
+export function isCloudflareTunnelRequestOrigin(origin: string): boolean {
+  const publicOrigin = activePublicOrigin();
+  if (!publicOrigin) return false;
+  try { return new URL(origin).origin === new URL(publicOrigin).origin; } catch { return false; }
+}
+
+process.once("exit", () => cloudflareTunnelController.killOwnedChild());
diff --git a/src/server/index.ts b/src/server/index.ts
index 7ca676c52..85cda09d3 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -122,6 +122,9 @@ import { buildDesktop3pRegistry } from "../claude/desktop-3p";
 import { handleImages } from "./images";
 import { handleSearch } from "./search";
 import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
+import { cloudflareTunnelController, isCloudflareTunnelRequest } from "./cloudflare-tunnel";
+import { cloudflareTunnelStartOverrides, resolveCloudflareTunnelSetup } from "./cloudflare-setup";
+import { probeHostname } from "./proxy-liveness";
 
 const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
 const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
@@ -198,6 +201,17 @@ export function startServer(port?: number) {
       const url = new URL(req.url);
       markActivity(`${req.method} ${url.pathname}`);
 
+      // Cloudflare public access is a data-plane boundary, not a remote dashboard. API keys are
+      // intentionally valid for model calls only through this ingress; management routes (key
+      // creation, provider config, stop, and the GUI) remain reachable solely on the local bind.
+      if (isCloudflareTunnelRequest(req) && !url.pathname.startsWith("/v1/")) {
+        return withCors(
+          formatErrorResponse(404, "not_found", "Cloudflare public access exposes /v1/* endpoints only"),
+          req,
+          config,
+        );
+      }
+
       if (req.method === "OPTIONS") {
         if (!isAllowedRequestOrigin(req, config)) {
           return new Response(null, { status: 403, headers: corsHeaders() });
@@ -240,7 +254,10 @@ export function startServer(port?: number) {
       if (url.pathname.startsWith("/api/")) {
         const apiAuthError = requireApiAuth(req, config, "management");
         if (apiAuthError) return withCors(apiAuthError, req, config);
-        const mgmtResponse = await handleManagementAPI(req, url, config);
+        const mgmtResponse = await handleManagementAPI(req, url, config, {
+          listenPort: server.port ?? listenPort,
+          cloudflareTunnel: cloudflareTunnelController,
+        });
         if (mgmtResponse) return withCors(mgmtResponse, req, config);
       }
 
@@ -615,6 +632,25 @@ export function startServer(port?: number) {
   const actualPort = server.port ?? listenPort;
   setCorsOrigin(actualPort);
 
+  if (config.cloudflareTunnel?.enabled) {
+    const hasAdmissionSecret = !!config.apiKeys?.length || !!process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
+    const tunnelSetup = resolveCloudflareTunnelSetup(config);
+    if (!hasAdmissionSecret) {
+      console.warn("[cloudflare] public access was enabled previously, but no opencodex API key is available; tunnel not started");
+    } else if (!tunnelSetup.configured) {
+      console.warn("[cloudflare] Named Tunnel setup is incomplete; public access was not started. Open the API page to configure Cloudflare.");
+    } else {
+      void cloudflareTunnelController.start({
+        originUrl: `http://${probeHostname(config.hostname)}:${actualPort}`,
+        actualPort,
+        configuredPort: config.port ?? 10100,
+        ...cloudflareTunnelStartOverrides(tunnelSetup),
+      }).catch(() => {
+        // Sanitized status is available to the local API page; raw child details stay private.
+      });
+    }
+  }
+
   console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`);
   console.log(`   POST /v1/responses → provider translation`);
   console.log(`   GET  /healthz      → health check`);
diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts
index 39f97400d..5fe9e37d2 100644
--- a/src/server/lifecycle.ts
+++ b/src/server/lifecycle.ts
@@ -1,4 +1,5 @@
 import { flushResponseState } from "../responses/state";
+import { cloudflareTunnelController } from "./cloudflare-tunnel";
 
 // ---------------------------------------------------------------------------
 // Active turn tracking + graceful shutdown drain
@@ -68,6 +69,13 @@ export async function drainAndShutdown(
   // Debounced replay-state snapshot may still be pending; flush so the last completed turn's
   // previous_response_id chain survives the restart this shutdown is usually part of.
   flushResponseState();
+  // Public ingress must not outlive the local proxy. The controller escalates to a force kill if
+  // cloudflared does not honor its graceful termination window.
+  try {
+    await cloudflareTunnelController.stop();
+  } catch (err) {
+    console.warn(`[cloudflare] tunnel shutdown failed: ${err instanceof Error ? err.message : String(err)}`);
+  }
   s?.stop(true);
   draining = false;
 }
diff --git a/src/server/management-api.ts b/src/server/management-api.ts
index 9a842246c..e333efa77 100644
--- a/src/server/management-api.ts
+++ b/src/server/management-api.ts
@@ -52,8 +52,29 @@ import { drainAndShutdown } from "./lifecycle";
 import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
 import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
 import type { PersistedUsageAttempt } from "../usage/log";
-import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
+import { configuredApiAuthToken, isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
 import { applySystemEnvToggle } from "./system-env";
+import { probeHostname } from "./proxy-liveness";
+import {
+  cloudflareTunnelController,
+  normalizeNamedPublicUrl,
+  type CloudflareTunnelController,
+  type CloudflareTunnelStatus,
+} from "./cloudflare-tunnel";
+import {
+  cloudflareTunnelStartOverrides,
+  replaceStoredCloudflareTunnelToken,
+  resolveCloudflareTunnelSetup,
+  type ResolvedCloudflareTunnelSetup,
+  type StoredTunnelTokenChange,
+} from "./cloudflare-setup";
+import {
+  cleanupProvisionedCloudflareTunnel,
+  CloudflareProvisionError,
+  provisionCloudflareNamedTunnel,
+  type CloudflareProvisionInput,
+  type CloudflareProvisionResult,
+} from "./cloudflare-provision";
 
 // Single source of truth = package.json (../ from src/), so /healthz + the GUI badge match the
 // installed npm version instead of a stale hardcode.
@@ -65,12 +86,92 @@ export const VERSION = (() => {
   }
 })();
 
+let cloudflareSetupInProgress = false;
+
+function withNoStore(response: Response): Response {
+  const headers = new Headers(response.headers);
+  headers.set("Cache-Control", "no-store");
+  return new Response(response.body, {
+    status: response.status,
+    statusText: response.statusText,
+    headers,
+  });
+}
+
 export interface ManagementApiDeps {
   toggleCodexMultiAgentV2?: (enabled: boolean) => void;
   refreshCodexCatalog?: () => Promise;
   clearThreadAccountMap?: () => void;
   clearProviderQuotaCache?: () => void;
   primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise | void;
+  /** Actual live listener port. It can differ from config.port after an occupied-port fallback. */
+  listenPort?: number;
+  cloudflareTunnel?: CloudflareTunnelController;
+  persistConfig?: (config: OcxConfig) => void;
+  replaceCloudflareTunnelToken?: (input: unknown) => StoredTunnelTokenChange;
+  provisionCloudflareTunnel?: typeof provisionCloudflareNamedTunnel;
+  cleanupProvisionedCloudflareTunnel?: typeof cleanupProvisionedCloudflareTunnel;
+}
+
+function localResponsesEndpoint(config: Pick, listenPort?: number): string {
+  const port = listenPort ?? config.port ?? 10100;
+  return `http://${probeHostname(config.hostname)}:${port}/v1/responses`;
+}
+
+function cloudflareTunnelPayload(
+  status: CloudflareTunnelStatus,
+  localEndpoint: string,
+  desiredEnabled = false,
+  hasAdmissionSecret = false,
+  setup: ResolvedCloudflareTunnelSetup = {
+    mode: "named",
+    configured: false,
+    source: "none",
+    publicUrl: null,
+    supportsSse: true,
+  },
+): CloudflareTunnelStatus & {
+  enabled: boolean;
+  canEnable: boolean;
+  canConfigure: boolean;
+  endpoint: string;
+  configured: boolean;
+  setupRequired: boolean;
+  configurationSource: ResolvedCloudflareTunnelSetup["source"];
+  configurationEditable: boolean;
+  configuredPublicUrl: string | null;
+  originUrl: string;
+  setupError?: ResolvedCloudflareTunnelSetup["error"];
+} {
+  const endpoint = status.status === "running" && status.publicUrl
+    ? `${status.publicUrl.replace(/\/+$/, "")}/v1/responses`
+    : localEndpoint;
+  const enabled = (desiredEnabled && setup.configured)
+    || status.status === "starting"
+    || status.status === "running"
+    || status.status === "stopping";
+  const activeStatus = status.status !== "stopped" ? status : {
+    ...status,
+    mode: setup.mode,
+    supportsSse: setup.supportsSse,
+  };
+  return {
+    ...activeStatus,
+    enabled,
+    canEnable: hasAdmissionSecret && setup.configured,
+    canConfigure: hasAdmissionSecret && setup.source !== "environment",
+    endpoint,
+    configured: setup.configured,
+    setupRequired: !setup.configured,
+    configurationSource: setup.source,
+    configurationEditable: setup.source !== "environment"
+      && status.status !== "starting"
+      && status.status !== "running"
+      && status.status !== "stopping",
+    configuredPublicUrl: setup.publicUrl,
+    originUrl: localEndpoint.replace(/\/v1\/responses$/, ""),
+    ...(setup.error ? { setupError: setup.error } : {}),
+  };
 }
 
 /** Narrow an unknown JSON value to a plain (non-array) object for strict request-body validation. */
@@ -221,6 +322,302 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
     } catch { /* best-effort */ }
   }
 
+  const tunnelController = deps.cloudflareTunnel ?? cloudflareTunnelController;
+  const persistConfig = deps.persistConfig ?? saveConfig;
+  const localEndpoint = localResponsesEndpoint(config, deps.listenPort);
+  const hasAdmissionSecret = !!config.apiKeys?.length || !!configuredApiAuthToken(config);
+  let tunnelSetup = resolveCloudflareTunnelSetup(config);
+  const tunnelPayload = (
+    status = tunnelController.getStatus(),
+    desiredEnabled = config.cloudflareTunnel?.enabled === true,
+  ) => cloudflareTunnelPayload(status, localEndpoint, desiredEnabled, hasAdmissionSecret, tunnelSetup);
+
+  if (url.pathname === "/api/cloudflare-tunnel" && req.method === "GET") {
+    return jsonResponse(tunnelPayload());
+  }
+
+  if (url.pathname === "/api/cloudflare-tunnel/setup" && req.method === "PUT") {
+    const respond = (data: unknown, status = 200) => withNoStore(jsonResponse(data, status, req, config));
+    if (cloudflareSetupInProgress) return respond({ ...tunnelPayload(), error: "Cloudflare setup is already in progress." }, 409);
+    if (tunnelSetup.source === "environment") {
+      return respond({ ...tunnelPayload(), error: "Cloudflare Tunnel configuration is managed by environment variables." }, 409);
+    }
+    const setupInitialStatus = tunnelController.getStatus().status;
+    if (setupInitialStatus !== "stopped" && setupInitialStatus !== "error") {
+      return respond({ ...tunnelPayload(), error: "Stop public access before changing Cloudflare Tunnel configuration." }, 409);
+    }
+
+    cloudflareSetupInProgress = true;
+    try {
+      if (setupInitialStatus === "error") {
+        try { await tunnelController.stop(); } catch { /* sanitized status checked below */ }
+        if (tunnelController.getStatus().status !== "stopped") {
+          return respond({ ...tunnelPayload(), error: "Cloudflare Tunnel could not be stopped safely for reconfiguration." }, 409);
+        }
+      }
+      let raw: unknown;
+      try { raw = await req.json(); } catch { return respond({ error: "invalid JSON body" }, 400); }
+      if (!isPlainRecord(raw)) return respond({ error: "body must be a JSON object" }, 400);
+      const method = raw.method;
+      if (method !== "token" && method !== "api") return respond({ error: "method must be token or api" }, 400);
+      const allowedFields = new Set(method === "token"
+        ? ["method", "publicUrl", "tunnelToken", "enable"]
+        : ["method", "apiToken", "accountId", "zoneId", "hostname", "tunnelName", "replaceExisting", "enable"]);
+      const unknownField = Object.keys(raw).find(field => !allowedFields.has(field));
+      if (unknownField) return respond({ error: `unknown field: ${unknownField}` }, 400);
+      if (raw.enable !== undefined && typeof raw.enable !== "boolean") {
+        return respond({ error: "enable must be a boolean" }, 400);
+      }
+      if (method === "api" && raw.replaceExisting !== undefined && typeof raw.replaceExisting !== "boolean") {
+        return respond({ error: "replaceExisting must be a boolean" }, 400);
+      }
+      if (method === "api" && tunnelSetup.source === "local" && raw.replaceExisting !== true) {
+        return respond({
+          ...tunnelPayload(),
+          error: "Automatic reconfiguration creates new Cloudflare resources. Confirm replaceExisting or use the existing Tunnel token method.",
+        }, 409);
+      }
+      const enable = raw.enable === true;
+      if (enable && !hasAdmissionSecret) {
+        return respond({ ...tunnelPayload(), error: "Create an opencodex API key before enabling public access." }, 409);
+      }
+      const actualPort = deps.listenPort ?? config.port ?? 10100;
+      if (actualPort !== (config.port ?? 10100)) {
+        return respond({
+          ...tunnelPayload(),
+          error: "Named Tunnel setup requires the configured opencodex port to be available. Stop the conflicting service and restart opencodex.",
+        }, 409);
+      }
+
+      const previousTunnelConfig = config.cloudflareTunnel ? { ...config.cloudflareTunnel } : undefined;
+      const restorePreviousConfig = () => {
+        if (previousTunnelConfig) config.cloudflareTunnel = previousTunnelConfig;
+        else delete config.cloudflareTunnel;
+      };
+      let tokenChange: StoredTunnelTokenChange | null = null;
+      let provisioned: CloudflareProvisionResult | null = null;
+      let provisionInput: CloudflareProvisionInput | null = null;
+      let publicUrl: string;
+      let tunnelToken: unknown;
+      if (method === "token") {
+        const normalizedUrl = normalizeNamedPublicUrl(typeof raw.publicUrl === "string" ? raw.publicUrl : undefined);
+        if (!normalizedUrl) return respond({ error: "publicUrl must be a valid HTTPS public origin" }, 400);
+        publicUrl = normalizedUrl;
+        tunnelToken = raw.tunnelToken;
+      } else {
+        if ([raw.apiToken, raw.accountId, raw.zoneId, raw.hostname]
+          .some(value => typeof value !== "string" || !value.trim())) {
+          return respond({ error: "apiToken, accountId, zoneId, and hostname are required" }, 400);
+        }
+        if (raw.tunnelName !== undefined && typeof raw.tunnelName !== "string") {
+          return respond({ error: "tunnelName must be a string" }, 400);
+        }
+        provisionInput = {
+          apiToken: (raw.apiToken as string).trim(),
+          accountId: (raw.accountId as string).trim(),
+          zoneId: (raw.zoneId as string).trim(),
+          hostname: (raw.hostname as string).trim(),
+          ...(typeof raw.tunnelName === "string" && raw.tunnelName.trim()
+            ? { tunnelName: raw.tunnelName.trim() }
+            : {}),
+        };
+        try {
+          provisioned = await (deps.provisionCloudflareTunnel ?? provisionCloudflareNamedTunnel)(
+            provisionInput,
+            `http://${probeHostname(config.hostname)}:${actualPort}`,
+          );
+        } catch (error) {
+          return respond({
+            ...tunnelPayload(),
+            error: error instanceof CloudflareProvisionError ? error.message : "Cloudflare Tunnel setup failed.",
+          }, 502);
+        }
+        publicUrl = provisioned.publicUrl;
+        tunnelToken = provisioned.tunnelToken;
+      }
+
+      try {
+        tokenChange = (deps.replaceCloudflareTunnelToken ?? replaceStoredCloudflareTunnelToken)(tunnelToken);
+      } catch {
+        if (provisioned && provisionInput) {
+          await (deps.cleanupProvisionedCloudflareTunnel ?? cleanupProvisionedCloudflareTunnel)(provisionInput, provisioned);
+        }
+        return respond({ error: "Cloudflare Tunnel token is invalid or could not be stored securely." }, 400);
+      }
+
+      const nextTunnelConfig: NonNullable = {
+        ...previousTunnelConfig,
+        version: 2,
+        enabled: false,
+        mode: "named",
+        publicUrl,
+        tokenFingerprint: tokenChange.fingerprint,
+      };
+      if (provisioned) {
+        nextTunnelConfig.managedTunnelId = provisioned.tunnelId;
+        nextTunnelConfig.managedDnsRecordId = provisioned.dnsRecordId;
+      } else {
+        delete nextTunnelConfig.managedTunnelId;
+        delete nextTunnelConfig.managedDnsRecordId;
+      }
+      config.cloudflareTunnel = nextTunnelConfig;
+      try {
+        persistConfig(config);
+      } catch {
+        restorePreviousConfig();
+        let rollbackFailed = false;
+        try { tokenChange.rollback(); } catch { rollbackFailed = true; }
+        if (provisioned && provisionInput) {
+          await (deps.cleanupProvisionedCloudflareTunnel ?? cleanupProvisionedCloudflareTunnel)(provisionInput, provisioned);
+        }
+        return respond({
+          error: rollbackFailed
+            ? "Cloudflare setup could not be saved and local credential rollback failed. Public access remains disabled."
+            : "Cloudflare setup could not be saved. Public access remains disabled.",
+        }, 500);
+      }
+
+      tunnelSetup = resolveCloudflareTunnelSetup(config);
+      if (!tunnelSetup.configured) {
+        return respond({ ...tunnelPayload(), error: "Stored Cloudflare Tunnel credentials did not pass verification." }, 500);
+      }
+      if (!enable) return respond(tunnelPayload());
+
+      try {
+        await tunnelController.start({
+          originUrl: `http://${probeHostname(config.hostname)}:${actualPort}`,
+          actualPort,
+          configuredPort: config.port ?? 10100,
+          ...cloudflareTunnelStartOverrides(tunnelSetup),
+        });
+      } catch {
+        // Sanitized controller state is returned below.
+      }
+      if (tunnelController.getStatus().status === "running") {
+        config.cloudflareTunnel = { ...config.cloudflareTunnel, enabled: true };
+        try {
+          persistConfig(config);
+        } catch {
+          config.cloudflareTunnel = { ...config.cloudflareTunnel, enabled: false };
+          try { await tunnelController.stop(); } catch { /* sanitized below */ }
+          return respond({ ...tunnelPayload(tunnelController.getStatus(), false), error: "Tunnel started but its enabled state could not be saved, so it was stopped." }, 500);
+        }
+      }
+      const payload = tunnelPayload();
+      return respond(payload, payload.status === "error" ? 503 : 200);
+    } finally {
+      cloudflareSetupInProgress = false;
+    }
+  }
+
+  if (url.pathname === "/api/cloudflare-tunnel" && req.method === "PUT") {
+    let raw: unknown;
+    try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
+    if (cloudflareSetupInProgress) {
+      return jsonResponse({ ...tunnelPayload(), error: "Cloudflare setup is already in progress." }, 409);
+    }
+    if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
+    const unknownField = Object.keys(raw).find(field => field !== "enabled" && field !== "mode");
+    if (unknownField) return jsonResponse({ error: `unknown field: ${unknownField}` }, 400);
+    const body = raw as { enabled?: unknown; mode?: unknown };
+    if (typeof body.enabled !== "boolean") {
+      return jsonResponse({ error: "enabled boolean is required" }, 400);
+    }
+    if (body.mode !== undefined && body.mode !== "quick" && body.mode !== "named") {
+      return jsonResponse({ error: "mode must be quick or named" }, 400);
+    }
+    const requestedQuick = body.enabled && body.mode === "quick";
+    if (body.enabled && !requestedQuick && !hasAdmissionSecret) {
+      return jsonResponse({
+        ...tunnelPayload(),
+        error: "Create an opencodex API key before enabling public access.",
+      }, 409);
+    }
+    if (body.enabled && !requestedQuick && !tunnelSetup.configured) {
+      return jsonResponse({
+        ...tunnelPayload(),
+        error: "Configure a Named Cloudflare Tunnel before enabling public access.",
+      }, 409);
+    }
+    const previousTunnelConfig = config.cloudflareTunnel
+      ? { ...config.cloudflareTunnel }
+      : undefined;
+    const restorePreviousTunnelConfig = () => {
+      if (previousTunnelConfig) config.cloudflareTunnel = previousTunnelConfig;
+      else delete config.cloudflareTunnel;
+    };
+
+    if (body.enabled) {
+      if (requestedQuick) {
+        config.cloudflareTunnel = {
+          ...config.cloudflareTunnel,
+          enabled: false,
+          mode: "quick",
+        };
+        tunnelSetup = resolveCloudflareTunnelSetup(config);
+      }
+      const actualPort = deps.listenPort ?? config.port ?? 10100;
+      try {
+        await tunnelController.start({
+          originUrl: `http://${probeHostname(config.hostname)}:${actualPort}`,
+          actualPort,
+          configuredPort: config.port ?? 10100,
+          ...cloudflareTunnelStartOverrides(tunnelSetup),
+        });
+      } catch {
+        // The controller normally returns sanitized state, but preserve a safe response if an
+        // injected/custom controller rejects unexpectedly.
+      }
+      if (tunnelController.getStatus().status === "running") {
+        config.cloudflareTunnel = { ...config.cloudflareTunnel, enabled: true };
+        try {
+          persistConfig(config);
+        } catch {
+          // Never leave an unpersisted public listener running: a restart would lose the UI's
+          // desired state and operators could reasonably believe the failed action was rolled back.
+          restorePreviousTunnelConfig();
+          try { await tunnelController.stop(); } catch { /* status remains sanitized below */ }
+          tunnelSetup = resolveCloudflareTunnelSetup(config);
+          const payload = tunnelPayload(tunnelController.getStatus(), previousTunnelConfig?.enabled === true);
+          return jsonResponse({
+            ...payload,
+            error: payload.status === "error"
+              ? payload.error
+              : "Public access was rolled back because its setting could not be saved.",
+          }, 500);
+        }
+      }
+    } else {
+      // Process shutdown and persistence are deliberately independent. Even a read-only/corrupt
+      // config directory must not prevent the already-public child process from being terminated.
+      config.cloudflareTunnel = { ...config.cloudflareTunnel, enabled: false };
+      let persistenceFailed = false;
+      try { persistConfig(config); } catch { persistenceFailed = true; }
+      try {
+        await tunnelController.stop();
+      } catch {
+        // The controller owns sanitized diagnostics. Never reflect child argv/env or raw logs here.
+      }
+      if (persistenceFailed) {
+        // The on-disk desired state did not change. Reflect that truth in memory/UI so a failed
+        // disable remains visibly retryable instead of appearing off and silently re-enabling on
+        // the next process start.
+        restorePreviousTunnelConfig();
+        tunnelSetup = resolveCloudflareTunnelSetup(config);
+        const payload = tunnelPayload(tunnelController.getStatus(), previousTunnelConfig?.enabled === true);
+        return jsonResponse({
+          ...payload,
+          error: payload.status === "error"
+            ? payload.error
+            : "Public access was stopped, but the disabled setting could not be saved.",
+        }, 500);
+      }
+    }
+    tunnelSetup = resolveCloudflareTunnelSetup(config);
+    const payload = tunnelPayload();
+    return jsonResponse(payload, payload.status === "error" ? 503 : 200);
+  }
+
   if (url.pathname === "/api/config" && req.method === "GET") {
     return jsonResponse(safeConfigDTO(config));
   }
@@ -1665,7 +2062,12 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
   // ---------------------------------------------------------------------------
   if (url.pathname === "/api/keys" && req.method === "GET") {
     const keys = config.apiKeys ?? [];
-    return jsonResponse({ keys: keys.map(k => ({ id: k.id, name: k.name, prefix: k.key.slice(0, 8) + "...", createdAt: k.createdAt })), endpoint: `http://${config.hostname ?? "127.0.0.1"}:${config.port ?? 10100}/v1/responses` }, 200, req, config);
+    const tunnel = tunnelController.getStatus();
+    return jsonResponse({
+      keys: keys.map(k => ({ id: k.id, name: k.name, prefix: k.key.slice(0, 8) + "...", createdAt: k.createdAt })),
+      endpoint: tunnelPayload(tunnel).endpoint,
+      tunnel: tunnelPayload(tunnel),
+    }, 200, req, config);
   }
 
   if (url.pathname === "/api/keys" && req.method === "POST") {
diff --git a/src/types.ts b/src/types.ts
index 82ceedfb9..93c2e6c53 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -465,6 +465,17 @@ export interface OcxConfig {
   websockets?: boolean;
   /** Generated API keys for external access to the proxy's /v1/responses endpoint. */
   apiKeys?: Array<{ id: string; name: string; key: string; createdAt: string }>;
+  /** Persist Cloudflare public access. Named Tunnel is the default; Quick is explicit dev-only. */
+  cloudflareTunnel?: {
+    version?: 2;
+    enabled?: boolean;
+    mode?: "named" | "quick";
+    publicUrl?: string;
+    /** SHA-256 of the fixed local runner-token file; prevents URL/token crash mismatches. */
+    tokenFingerprint?: string;
+    managedTunnelId?: string;
+    managedDnsRecordId?: string;
+  };
   /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
   codexAutoStart?: boolean;
   /**
diff --git a/tests/cloudflare-provision.test.ts b/tests/cloudflare-provision.test.ts
new file mode 100644
index 000000000..1f94b0a2c
--- /dev/null
+++ b/tests/cloudflare-provision.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, test } from "bun:test";
+import {
+  CloudflareProvisionError,
+  provisionCloudflareNamedTunnel,
+} from "../src/server/cloudflare-provision";
+import { CLOUDFLARE_TUNNEL_ORIGIN_HOST } from "../src/server/cloudflare-tunnel";
+
+const accountId = "a".repeat(32);
+const zoneId = "b".repeat(32);
+const dnsRecordId = "c".repeat(32);
+const tunnelId = "11111111-2222-4333-8444-555555555555";
+const apiToken = `cf-api-${"s".repeat(40)}`;
+const tunnelToken = `eyJ${"t".repeat(64)}`;
+const apiBase = "https://api.cloudflare.com/client/v4";
+
+interface FetchCall {
+  url: string;
+  init: RequestInit;
+}
+
+function ok(result: unknown): Response {
+  return Response.json({ success: true, result });
+}
+
+describe("Cloudflare Named Tunnel provisioning", () => {
+  test("performs the four API steps with protected ingress and proxied tunnel DNS", async () => {
+    const calls: FetchCall[] = [];
+    const responses = [
+      ok({ id: tunnelId }),
+      ok({}),
+      ok({ id: dnsRecordId }),
+      ok(tunnelToken),
+    ];
+    const fetchFn = (async (input: string | URL | Request, init?: RequestInit) => {
+      calls.push({ url: String(input), init: init ?? {} });
+      const response = responses.shift();
+      if (!response) throw new Error("unexpected request");
+      return response;
+    }) as typeof fetch;
+
+    const result = await provisionCloudflareNamedTunnel({
+      apiToken,
+      accountId,
+      zoneId,
+      hostname: "api.example.com",
+      tunnelName: "opencodex-test",
+    }, "http://127.0.0.1:10100", { fetchFn, timeoutMs: 1_000 });
+
+    expect(result).toEqual({
+      publicUrl: "https://api.example.com",
+      tunnelToken,
+      tunnelId,
+      dnsRecordId,
+    });
+    expect(JSON.stringify(result)).not.toContain(apiToken);
+    expect(calls.map(call => [call.init.method, call.url])).toEqual([
+      ["POST", `${apiBase}/accounts/${accountId}/cfd_tunnel`],
+      ["PUT", `${apiBase}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`],
+      ["POST", `${apiBase}/zones/${zoneId}/dns_records`],
+      ["GET", `${apiBase}/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`],
+    ]);
+    for (const call of calls) {
+      expect(new Headers(call.init.headers).get("Authorization")).toBe(`Bearer ${apiToken}`);
+    }
+    expect(JSON.parse(String(calls[0].init.body))).toEqual({
+      name: "opencodex-test",
+      config_src: "cloudflare",
+    });
+    expect(JSON.parse(String(calls[1].init.body))).toEqual({
+      config: {
+        ingress: [
+          {
+            hostname: "api.example.com",
+            service: "http://127.0.0.1:10100",
+            originRequest: { httpHostHeader: CLOUDFLARE_TUNNEL_ORIGIN_HOST },
+          },
+          { service: "http_status:404" },
+        ],
+      },
+    });
+    expect(JSON.parse(String(calls[2].init.body))).toEqual({
+      type: "CNAME",
+      proxied: true,
+      name: "api.example.com",
+      content: `${tunnelId}.cfargotunnel.com`,
+    });
+  });
+
+  test("rolls back DNS and tunnel resources when the final token request fails", async () => {
+    const calls: FetchCall[] = [];
+    let requestIndex = 0;
+    const fetchFn = (async (input: string | URL | Request, init?: RequestInit) => {
+      calls.push({ url: String(input), init: init ?? {} });
+      requestIndex += 1;
+      if (requestIndex === 1) return ok({ id: tunnelId });
+      if (requestIndex === 2) return ok({});
+      if (requestIndex === 3) return ok({ id: dnsRecordId });
+      if (requestIndex === 4) {
+        return Response.json({ success: false, errors: [{ message: "token request denied" }] }, { status: 403 });
+      }
+      return ok({});
+    }) as typeof fetch;
+
+    await expect(provisionCloudflareNamedTunnel({
+      apiToken,
+      accountId,
+      zoneId,
+      hostname: "api.example.com",
+      tunnelName: "opencodex-test",
+    }, "http://127.0.0.1:10100", { fetchFn, timeoutMs: 1_000 }))
+      .rejects.toEqual(new CloudflareProvisionError("token request denied"));
+
+    expect(calls.slice(-2).map(call => [call.init.method, call.url])).toEqual([
+      ["DELETE", `${apiBase}/zones/${zoneId}/dns_records/${dnsRecordId}`],
+      ["DELETE", `${apiBase}/accounts/${accountId}/cfd_tunnel/${tunnelId}`],
+    ]);
+    for (const call of calls.slice(-2)) {
+      expect(new Headers(call.init.headers).get("Authorization")).toBe(`Bearer ${apiToken}`);
+    }
+  });
+
+  test("rejects invalid inputs before contacting Cloudflare", async () => {
+    let requests = 0;
+    const fetchFn = (async () => {
+      requests += 1;
+      return ok({});
+    }) as typeof fetch;
+
+    await expect(provisionCloudflareNamedTunnel({
+      apiToken: "short",
+      accountId,
+      zoneId,
+      hostname: "api.example.com",
+    }, "http://127.0.0.1:10100", { fetchFn })).rejects.toBeInstanceOf(CloudflareProvisionError);
+    await expect(provisionCloudflareNamedTunnel({
+      apiToken,
+      accountId: "not-an-account-id",
+      zoneId,
+      hostname: "api.example.com",
+    }, "http://127.0.0.1:10100", { fetchFn })).rejects.toBeInstanceOf(CloudflareProvisionError);
+    await expect(provisionCloudflareNamedTunnel({
+      apiToken,
+      accountId,
+      zoneId,
+      hostname: "http://127.0.0.1",
+    }, "http://127.0.0.1:10100", { fetchFn })).rejects.toBeInstanceOf(CloudflareProvisionError);
+    expect(requests).toBe(0);
+  });
+
+  test("redacts the API token even if an upstream error unexpectedly reflects it", async () => {
+    const fetchFn = (async () => Response.json({
+      success: false,
+      errors: [{ message: `permission denied for ${apiToken}` }],
+    }, { status: 403 })) as typeof fetch;
+
+    let error: unknown;
+    try {
+      await provisionCloudflareNamedTunnel({
+        apiToken,
+        accountId,
+        zoneId,
+        hostname: "api.example.com",
+        tunnelName: "opencodex-test",
+      }, "http://127.0.0.1:10100", { fetchFn, timeoutMs: 1_000 });
+    } catch (caught) {
+      error = caught;
+    }
+    expect(error).toBeInstanceOf(CloudflareProvisionError);
+    expect(String((error as Error).message)).not.toContain(apiToken);
+    expect(String((error as Error).message)).toContain("[redacted]");
+  });
+});
diff --git a/tests/cloudflare-tunnel-api.test.ts b/tests/cloudflare-tunnel-api.test.ts
new file mode 100644
index 000000000..bec3e6217
--- /dev/null
+++ b/tests/cloudflare-tunnel-api.test.ts
@@ -0,0 +1,930 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { existsSync, mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { loadConfig } from "../src/config";
+import { handleManagementAPI } from "../src/server/management-api";
+import { storedCloudflareTunnelTokenPath } from "../src/server/cloudflare-setup";
+import type {
+  CloudflareTunnelController,
+  CloudflareTunnelStatus,
+} from "../src/server/cloudflare-tunnel";
+import type { OcxConfig } from "../src/types";
+
+const previousHome = process.env.OPENCODEX_HOME;
+const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN;
+const cloudflareEnvKeys = [
+  "OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN",
+  "OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE",
+  "OPENCODEX_CLOUDFLARE_PUBLIC_URL",
+] as const;
+const previousCloudflareEnv = Object.fromEntries(
+  cloudflareEnvKeys.map(key => [key, process.env[key]]),
+) as Record<(typeof cloudflareEnvKeys)[number], string | undefined>;
+const runnerToken = `eyJ${"r".repeat(64)}`;
+const cloudflareApiToken = `cf-api-${"s".repeat(40)}`;
+let testDir = "";
+
+function config(withKey = true): OcxConfig {
+  return {
+    port: 10100,
+    hostname: "127.0.0.1",
+    defaultProvider: "test",
+    providers: {
+      test: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", models: ["model"] },
+    },
+    ...(withKey
+      ? { apiKeys: [{ id: "id", name: "remote", key: "ocx_secret", createdAt: "2026-01-01T00:00:00.000Z" }] }
+      : {}),
+    cloudflareTunnel: { mode: "quick" },
+  };
+}
+
+function stoppedStatus(): CloudflareTunnelStatus {
+  return {
+    status: "stopped",
+    mode: "quick",
+    publicUrl: null,
+    supportsSse: false,
+  };
+}
+
+function request(path: string, init?: RequestInit): Request {
+  return new Request(`http://127.0.0.1:54321${path}`, init);
+}
+
+beforeEach(() => {
+  testDir = mkdtempSync(join(tmpdir(), "opencodex-cloudflare-api-"));
+  process.env.OPENCODEX_HOME = testDir;
+  delete process.env.OPENCODEX_API_AUTH_TOKEN;
+  for (const key of cloudflareEnvKeys) delete process.env[key];
+});
+
+afterEach(() => {
+  if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
+  else process.env.OPENCODEX_HOME = previousHome;
+  if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN;
+  else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken;
+  for (const key of cloudflareEnvKeys) {
+    const previous = previousCloudflareEnv[key];
+    if (previous === undefined) delete process.env[key];
+    else process.env[key] = previous;
+  }
+  rmSync(testDir, { recursive: true, force: true });
+});
+
+describe("Cloudflare tunnel management API", () => {
+  test("rejects non-object, malformed, and unknown-field request bodies", async () => {
+    const controller = {
+      getStatus: stoppedStatus,
+      start: async () => stoppedStatus(),
+      stop: async () => stoppedStatus(),
+    } as unknown as CloudflareTunnelController;
+    const cfg = config();
+
+    for (const body of ["null", "[]", "true", '"enabled"', "not-json"]) {
+      const req = request("/api/cloudflare-tunnel", {
+        method: "PUT",
+        headers: { "content-type": "application/json" },
+        body,
+      });
+      const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+        cloudflareTunnel: controller,
+        listenPort: 54321,
+      });
+      expect(response?.status).toBe(400);
+    }
+
+    const unknownReq = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true, token: "must-not-be-accepted" }),
+    });
+    const unknownResponse = await handleManagementAPI(unknownReq, new URL(unknownReq.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 54321,
+    });
+    expect(unknownResponse?.status).toBe(400);
+    expect(await unknownResponse?.json()).toEqual({ error: "unknown field: token" });
+
+    const badModeReq = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true, mode: "warp" }),
+    });
+    const badModeResponse = await handleManagementAPI(badModeReq, new URL(badModeReq.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 54321,
+    });
+    expect(badModeResponse?.status).toBe(400);
+    expect(await badModeResponse?.json()).toEqual({ error: "mode must be quick or named" });
+  });
+
+  test("uses the live listener port and swaps the API endpoint only while running", async () => {
+    let status = stoppedStatus();
+    const starts: unknown[] = [];
+    let stops = 0;
+    const controller = {
+      getStatus: () => status,
+      start: async (options: unknown) => {
+        starts.push(options);
+        status = {
+          status: "running",
+          mode: "quick",
+          publicUrl: "https://safe-name.trycloudflare.com",
+          supportsSse: false,
+          startedAt: "2026-07-22T00:00:00.000Z",
+        };
+        return status;
+      },
+      stop: async () => {
+        stops += 1;
+        status = stoppedStatus();
+        return status;
+      },
+    } as unknown as CloudflareTunnelController;
+    const cfg = config();
+    const deps = { cloudflareTunnel: controller, listenPort: 54321 };
+
+    const initial = await handleManagementAPI(request("/api/keys"), new URL("http://127.0.0.1:54321/api/keys"), cfg, deps);
+    expect(initial?.status).toBe(200);
+    expect(await initial?.json()).toMatchObject({
+      endpoint: "http://127.0.0.1:54321/v1/responses",
+      tunnel: { status: "stopped", publicUrl: null },
+    });
+
+    const enableReq = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true }),
+    });
+    const enabled = await handleManagementAPI(enableReq, new URL(enableReq.url), cfg, deps);
+    expect(enabled?.status).toBe(200);
+    expect(await enabled?.json()).toMatchObject({
+      status: "running",
+      endpoint: "https://safe-name.trycloudflare.com/v1/responses",
+    });
+    expect(starts).toEqual([{
+      originUrl: "http://127.0.0.1:54321",
+      actualPort: 54321,
+      configuredPort: 10100,
+      mode: "quick",
+    }]);
+    expect(cfg.cloudflareTunnel?.enabled).toBe(true);
+    expect(loadConfig().cloudflareTunnel?.enabled).toBe(true);
+
+    const keysWhileRunning = await handleManagementAPI(request("/api/keys"), new URL("http://127.0.0.1:54321/api/keys"), cfg, deps);
+    expect(await keysWhileRunning?.json()).toMatchObject({
+      endpoint: "https://safe-name.trycloudflare.com/v1/responses",
+    });
+
+    const disableReq = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: false }),
+    });
+    const disabled = await handleManagementAPI(disableReq, new URL(disableReq.url), cfg, deps);
+    expect(disabled?.status).toBe(200);
+    expect(await disabled?.json()).toMatchObject({
+      status: "stopped",
+      endpoint: "http://127.0.0.1:54321/v1/responses",
+    });
+    expect(stops).toBe(1);
+    expect(cfg.cloudflareTunnel?.enabled).toBe(false);
+    expect(loadConfig().cloudflareTunnel?.enabled).toBe(false);
+  });
+
+  test("refuses to publish an endpoint that has no admission secret", async () => {
+    let starts = 0;
+    const controller = {
+      getStatus: stoppedStatus,
+      start: async () => { starts += 1; return stoppedStatus(); },
+      stop: async () => stoppedStatus(),
+    } as unknown as CloudflareTunnelController;
+    const cfg = config(false);
+    const req = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true }),
+    });
+
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 54321,
+    });
+    expect(response?.status).toBe(409);
+    expect(await response?.json()).toMatchObject({ status: "stopped", endpoint: "http://127.0.0.1:54321/v1/responses" });
+    expect(starts).toBe(0);
+  });
+
+  test("allows the API page to enable with only the environment admission token", async () => {
+    process.env.OPENCODEX_API_AUTH_TOKEN = "environment-admission-secret";
+    let status = stoppedStatus();
+    let starts = 0;
+    const controller = {
+      getStatus: () => status,
+      start: async () => {
+        starts += 1;
+        status = {
+          status: "running",
+          mode: "quick",
+          publicUrl: "https://env-token.trycloudflare.com",
+          supportsSse: false,
+        };
+        return status;
+      },
+      stop: async () => stoppedStatus(),
+    } as CloudflareTunnelController;
+    const cfg = config(false);
+    const deps = { cloudflareTunnel: controller, listenPort: 54321 };
+
+    const keysResponse = await handleManagementAPI(
+      request("/api/keys"),
+      new URL("http://127.0.0.1:54321/api/keys"),
+      cfg,
+      deps,
+    );
+    expect(await keysResponse?.json()).toMatchObject({
+      keys: [],
+      tunnel: { canEnable: true, enabled: false },
+    });
+
+    const req = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true }),
+    });
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, deps);
+    expect(response?.status).toBe(200);
+    expect(await response?.json()).toMatchObject({
+      canEnable: true,
+      enabled: true,
+      endpoint: "https://env-token.trycloudflare.com/v1/responses",
+    });
+    expect(starts).toBe(1);
+  });
+
+  test("allows Quick Tunnel as an explicit one-click option even before Named setup or API keys", async () => {
+    let status = stoppedStatus();
+    const starts: unknown[] = [];
+    const controller = {
+      getStatus: () => status,
+      start: async (options: unknown) => {
+        starts.push(options);
+        status = {
+          status: "running",
+          mode: "quick",
+          publicUrl: "https://debug.trycloudflare.com",
+          supportsSse: false,
+        };
+        return status;
+      },
+      stop: async () => stoppedStatus(),
+    } as unknown as CloudflareTunnelController;
+    const cfg = {
+      ...config(false),
+      cloudflareTunnel: { enabled: false, mode: "named" as const },
+    };
+    const deps = { cloudflareTunnel: controller, listenPort: 54321 };
+
+    const namedReq = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true }),
+    });
+    const namedResponse = await handleManagementAPI(namedReq, new URL(namedReq.url), cfg, deps);
+    expect(namedResponse?.status).toBe(409);
+    expect(await namedResponse?.json()).toMatchObject({
+      error: "Create an opencodex API key before enabling public access.",
+    });
+    expect(starts).toEqual([]);
+
+    const quickReq = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true, mode: "quick" }),
+    });
+    const quickResponse = await handleManagementAPI(quickReq, new URL(quickReq.url), cfg, deps);
+    expect(quickResponse?.status).toBe(200);
+    expect(await quickResponse?.json()).toMatchObject({
+      status: "running",
+      mode: "quick",
+      supportsSse: false,
+      endpoint: "https://debug.trycloudflare.com/v1/responses",
+    });
+    expect(starts).toEqual([{
+      originUrl: "http://127.0.0.1:54321",
+      actualPort: 54321,
+      configuredPort: 10100,
+      mode: "quick",
+    }]);
+    expect(cfg.cloudflareTunnel?.mode).toBe("quick");
+    expect(cfg.cloudflareTunnel?.enabled).toBe(true);
+  });
+
+  test("returns sanitized controller failures without replacing the local endpoint", async () => {
+    let status = stoppedStatus();
+    const controller = {
+      getStatus: () => status,
+      start: async () => {
+        status = {
+          status: "error",
+          mode: "quick",
+          publicUrl: null,
+          supportsSse: false,
+          error: "cloudflared is not installed",
+        };
+        return status;
+      },
+      stop: async () => status,
+    } as unknown as CloudflareTunnelController;
+    const cfg = config();
+    const req = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true }),
+    });
+
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 54321,
+    });
+    expect(response?.status).toBe(503);
+    expect(await response?.json()).toMatchObject({
+      status: "error",
+      mode: "quick",
+      publicUrl: null,
+      supportsSse: false,
+      error: "cloudflared is not installed",
+      enabled: false,
+      canEnable: true,
+      endpoint: "http://127.0.0.1:54321/v1/responses",
+      configured: true,
+      setupRequired: false,
+      configurationSource: "quick",
+    });
+    expect(cfg.cloudflareTunnel?.enabled).not.toBe(true);
+  });
+
+  test("reports the default Named Tunnel as SSE-capable but unconfigured and refuses to start", async () => {
+    let starts = 0;
+    const controller = {
+      getStatus: stoppedStatus,
+      start: async () => { starts += 1; return stoppedStatus(); },
+      stop: async () => stoppedStatus(),
+    } as CloudflareTunnelController;
+    const cfg = config();
+    delete cfg.cloudflareTunnel;
+    const deps = { cloudflareTunnel: controller, listenPort: 10100 };
+
+    const getResponse = await handleManagementAPI(
+      request("/api/cloudflare-tunnel"),
+      new URL("http://127.0.0.1:54321/api/cloudflare-tunnel"),
+      cfg,
+      deps,
+    );
+    expect(await getResponse?.json()).toMatchObject({
+      status: "stopped",
+      mode: "named",
+      supportsSse: true,
+      configured: false,
+      setupRequired: true,
+      configurationSource: "none",
+      canEnable: false,
+      endpoint: "http://127.0.0.1:10100/v1/responses",
+    });
+
+    const enableRequest = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true }),
+    });
+    const enableResponse = await handleManagementAPI(enableRequest, new URL(enableRequest.url), cfg, deps);
+    expect(enableResponse?.status).toBe(409);
+    expect(await enableResponse?.json()).toMatchObject({
+      mode: "named",
+      supportsSse: true,
+      configured: false,
+      setupRequired: true,
+      error: "Configure a Named Cloudflare Tunnel before enabling public access.",
+    });
+    expect(starts).toBe(0);
+  });
+
+  test("does not present a legacy enabled Quick preference as active before Named setup", async () => {
+    const cfg = config();
+    cfg.cloudflareTunnel = { enabled: true };
+    const response = await handleManagementAPI(
+      request("/api/cloudflare-tunnel"),
+      new URL("http://127.0.0.1:54321/api/cloudflare-tunnel"),
+      cfg,
+      {
+        cloudflareTunnel: {
+          getStatus: stoppedStatus,
+          start: async () => stoppedStatus(),
+          stop: async () => stoppedStatus(),
+        },
+        listenPort: 10100,
+      },
+    );
+    expect(await response?.json()).toMatchObject({
+      enabled: false,
+      mode: "named",
+      configured: false,
+      setupRequired: true,
+      supportsSse: true,
+    });
+  });
+
+  test("configures a pasted Named Tunnel token, starts with fixed-file overrides, and leaks no secret", async () => {
+    let status = stoppedStatus();
+    const starts: unknown[] = [];
+    const controller = {
+      getStatus: () => status,
+      start: async (options: unknown) => {
+        starts.push(options);
+        status = {
+          status: "running",
+          mode: "named",
+          publicUrl: "https://api.example.com",
+          supportsSse: true,
+          startedAt: "2026-07-22T00:00:00.000Z",
+        };
+        return status;
+      },
+      stop: async () => {
+        status = stoppedStatus();
+        return status;
+      },
+    } as CloudflareTunnelController;
+    const cfg = config();
+    const persisted: OcxConfig[] = [];
+    const req = request("/api/cloudflare-tunnel/setup", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        method: "token",
+        publicUrl: "https://api.example.com/",
+        tunnelToken: `cloudflared service install ${runnerToken}`,
+        enable: true,
+      }),
+    });
+
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+      persistConfig: value => { persisted.push(structuredClone(value)); },
+    });
+    expect(response?.status).toBe(200);
+    expect(response?.headers.get("Cache-Control")).toBe("no-store");
+    const payload = await response?.json();
+    expect(payload).toMatchObject({
+      status: "running",
+      mode: "named",
+      publicUrl: "https://api.example.com",
+      supportsSse: true,
+      configured: true,
+      setupRequired: false,
+      configurationSource: "local",
+      configuredPublicUrl: "https://api.example.com",
+      endpoint: "https://api.example.com/v1/responses",
+      enabled: true,
+    });
+    const tokenPath = storedCloudflareTunnelTokenPath();
+    expect(starts).toEqual([{
+      originUrl: "http://127.0.0.1:10100",
+      actualPort: 10100,
+      configuredPort: 10100,
+      mode: "named",
+      namedTunnel: { publicUrl: "https://api.example.com", tokenFile: tokenPath },
+    }]);
+    expect(cfg.cloudflareTunnel).toMatchObject({
+      version: 2,
+      enabled: true,
+      mode: "named",
+      publicUrl: "https://api.example.com",
+    });
+    expect(cfg.websockets).toBeUndefined();
+    expect(persisted).toHaveLength(2);
+    const serialized = JSON.stringify(payload);
+    expect(serialized).not.toContain(runnerToken);
+    expect(serialized).not.toContain("tokenFingerprint");
+    expect(serialized).not.toContain(tokenPath);
+    expect(serialized).not.toContain(testDir);
+  });
+
+  test("auto-provisions a Named Tunnel without persisting or returning the Cloudflare API token", async () => {
+    let starts = 0;
+    const controller = {
+      getStatus: stoppedStatus,
+      start: async () => { starts += 1; return stoppedStatus(); },
+      stop: async () => stoppedStatus(),
+    } as CloudflareTunnelController;
+    const cfg = config();
+    const provisionCalls: Array<{ input: unknown; originUrl: string }> = [];
+    const req = request("/api/cloudflare-tunnel/setup", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        method: "api",
+        apiToken: cloudflareApiToken,
+        accountId: "a".repeat(32),
+        zoneId: "b".repeat(32),
+        hostname: "api.example.com",
+        tunnelName: "opencodex-user",
+        enable: false,
+      }),
+    });
+
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+      persistConfig: () => {},
+      provisionCloudflareTunnel: async (input, originUrl) => {
+        provisionCalls.push({ input, originUrl });
+        return {
+          publicUrl: "https://api.example.com",
+          tunnelToken: runnerToken,
+          tunnelId: "11111111-2222-4333-8444-555555555555",
+          dnsRecordId: "c".repeat(32),
+        };
+      },
+    });
+    expect(response?.status).toBe(200);
+    expect(response?.headers.get("Cache-Control")).toBe("no-store");
+    expect(provisionCalls).toEqual([{
+      input: {
+        apiToken: cloudflareApiToken,
+        accountId: "a".repeat(32),
+        zoneId: "b".repeat(32),
+        hostname: "api.example.com",
+        tunnelName: "opencodex-user",
+      },
+      originUrl: "http://127.0.0.1:10100",
+    }]);
+    expect(starts).toBe(0);
+    expect(cfg.cloudflareTunnel).toMatchObject({
+      version: 2,
+      enabled: false,
+      mode: "named",
+      publicUrl: "https://api.example.com",
+      managedTunnelId: "11111111-2222-4333-8444-555555555555",
+      managedDnsRecordId: "c".repeat(32),
+    });
+    expect(cfg.websockets).toBeUndefined();
+    const payload = await response?.json();
+    expect(payload).toMatchObject({
+      status: "stopped",
+      mode: "named",
+      supportsSse: true,
+      configured: true,
+      setupRequired: false,
+      configurationSource: "local",
+    });
+    const serialized = JSON.stringify(payload);
+    expect(serialized).not.toContain(cloudflareApiToken);
+    expect(serialized).not.toContain(runnerToken);
+    expect(serialized).not.toContain("tokenFingerprint");
+    expect(serialized).not.toContain(storedCloudflareTunnelTokenPath());
+  });
+
+  test("requires explicit confirmation before automatic setup replaces local Named metadata", async () => {
+    const controller = {
+      getStatus: stoppedStatus,
+      start: async () => stoppedStatus(),
+      stop: async () => stoppedStatus(),
+    } as CloudflareTunnelController;
+    const cfg = config();
+    const manual = request("/api/cloudflare-tunnel/setup", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        method: "token",
+        publicUrl: "https://old.example.com",
+        tunnelToken: runnerToken,
+        enable: false,
+      }),
+    });
+    expect((await handleManagementAPI(manual, new URL(manual.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+      persistConfig: () => {},
+    }))?.status).toBe(200);
+
+    const automaticBody = {
+      method: "api",
+      apiToken: cloudflareApiToken,
+      accountId: "a".repeat(32),
+      zoneId: "b".repeat(32),
+      hostname: "new.example.com",
+      enable: false,
+    };
+    let provisions = 0;
+    const provisionCloudflareTunnel = async () => {
+      provisions += 1;
+      return {
+        publicUrl: "https://new.example.com",
+        tunnelToken: runnerToken,
+        tunnelId: "11111111-2222-4333-8444-555555555555",
+        dnsRecordId: "c".repeat(32),
+      };
+    };
+    const unconfirmed = request("/api/cloudflare-tunnel/setup", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify(automaticBody),
+    });
+    const blocked = await handleManagementAPI(unconfirmed, new URL(unconfirmed.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+      persistConfig: () => {},
+      provisionCloudflareTunnel,
+    });
+    expect(blocked?.status).toBe(409);
+    expect(await blocked?.json()).toMatchObject({ error: expect.stringContaining("replaceExisting") });
+    expect(provisions).toBe(0);
+
+    const confirmed = request("/api/cloudflare-tunnel/setup", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ ...automaticBody, replaceExisting: true }),
+    });
+    expect((await handleManagementAPI(confirmed, new URL(confirmed.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+      persistConfig: () => {},
+      provisionCloudflareTunnel,
+    }))?.status).toBe(200);
+    expect(provisions).toBe(1);
+  });
+
+  test("blocks public-access transitions while Cloudflare provisioning is in progress", async () => {
+    let releaseProvision!: (value: {
+      publicUrl: string;
+      tunnelToken: string;
+      tunnelId: string;
+      dnsRecordId: string;
+    }) => void;
+    let provisioningStarted!: () => void;
+    const started = new Promise(resolve => { provisioningStarted = resolve; });
+    const pendingProvision = new Promise<{
+      publicUrl: string;
+      tunnelToken: string;
+      tunnelId: string;
+      dnsRecordId: string;
+    }>(resolve => { releaseProvision = resolve; });
+    let tunnelStarts = 0;
+    const controller = {
+      getStatus: stoppedStatus,
+      start: async () => { tunnelStarts += 1; return stoppedStatus(); },
+      stop: async () => stoppedStatus(),
+    } as CloudflareTunnelController;
+    const cfg = config();
+    const setupRequest = request("/api/cloudflare-tunnel/setup", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        method: "api",
+        apiToken: cloudflareApiToken,
+        accountId: "a".repeat(32),
+        zoneId: "b".repeat(32),
+        hostname: "api.example.com",
+        enable: false,
+      }),
+    });
+    const setupResponsePromise = handleManagementAPI(setupRequest, new URL(setupRequest.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+      persistConfig: () => {},
+      provisionCloudflareTunnel: async () => {
+        provisioningStarted();
+        return pendingProvision;
+      },
+    });
+    await started;
+
+    const enableRequest = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true }),
+    });
+    const blocked = await handleManagementAPI(enableRequest, new URL(enableRequest.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+    });
+    expect(blocked?.status).toBe(409);
+    expect(await blocked?.json()).toMatchObject({ error: "Cloudflare setup is already in progress." });
+    expect(tunnelStarts).toBe(0);
+
+    releaseProvision({
+      publicUrl: "https://api.example.com",
+      tunnelToken: runnerToken,
+      tunnelId: "11111111-2222-4333-8444-555555555555",
+      dnsRecordId: "c".repeat(32),
+    });
+    expect((await setupResponsePromise)?.status).toBe(200);
+  });
+
+  test("restores the previous config and token when setup persistence fails", async () => {
+    const controller = {
+      getStatus: stoppedStatus,
+      start: async () => stoppedStatus(),
+      stop: async () => stoppedStatus(),
+    } as CloudflareTunnelController;
+    const cfg = config();
+    const req = request("/api/cloudflare-tunnel/setup", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        method: "token",
+        publicUrl: "https://api.example.com",
+        tunnelToken: runnerToken,
+        enable: false,
+      }),
+    });
+
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+      persistConfig: () => { throw new Error("read-only config"); },
+    });
+    expect(response?.status).toBe(500);
+    expect(cfg.websockets).toBeUndefined();
+    expect(cfg.cloudflareTunnel).toEqual({ mode: "quick" });
+    expect(existsSync(storedCloudflareTunnelTokenPath())).toBe(false);
+  });
+
+  test("stops an errored connector before replacing its Named Tunnel credentials", async () => {
+    let status: CloudflareTunnelStatus = {
+      status: "error",
+      mode: "named",
+      publicUrl: "https://old.example.com",
+      supportsSse: true,
+      error: "connector failed",
+    };
+    let stops = 0;
+    const controller = {
+      getStatus: () => status,
+      start: async () => status,
+      stop: async () => {
+        stops += 1;
+        status = stoppedStatus();
+        return status;
+      },
+    } as CloudflareTunnelController;
+    const cfg = config();
+    const req = request("/api/cloudflare-tunnel/setup", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        method: "token",
+        publicUrl: "https://replacement.example.com",
+        tunnelToken: runnerToken,
+        enable: false,
+      }),
+    });
+
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+      persistConfig: () => {},
+    });
+    expect(stops).toBe(1);
+    expect(response?.status).toBe(200);
+    expect(await response?.json()).toMatchObject({
+      status: "stopped",
+      configured: true,
+      configuredPublicUrl: "https://replacement.example.com",
+      supportsSse: true,
+    });
+  });
+
+  test("refuses to replace environment-managed Named Tunnel configuration", async () => {
+    process.env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN = runnerToken;
+    process.env.OPENCODEX_CLOUDFLARE_PUBLIC_URL = "https://environment.example.com";
+    let replacements = 0;
+    let provisions = 0;
+    const controller = {
+      getStatus: stoppedStatus,
+      start: async () => stoppedStatus(),
+      stop: async () => stoppedStatus(),
+    } as CloudflareTunnelController;
+    const cfg = config();
+    const req = request("/api/cloudflare-tunnel/setup", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        method: "token",
+        publicUrl: "https://replacement.example.com",
+        tunnelToken: runnerToken,
+      }),
+    });
+
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 10100,
+      replaceCloudflareTunnelToken: () => {
+        replacements += 1;
+        throw new Error("must not be called");
+      },
+      provisionCloudflareTunnel: async () => {
+        provisions += 1;
+        throw new Error("must not be called");
+      },
+    });
+    expect(response?.status).toBe(409);
+    expect(response?.headers.get("Cache-Control")).toBe("no-store");
+    expect(await response?.json()).toMatchObject({
+      mode: "named",
+      supportsSse: true,
+      configured: true,
+      configurationSource: "environment",
+      configurationEditable: false,
+      error: "Cloudflare Tunnel configuration is managed by environment variables.",
+    });
+    expect(replacements).toBe(0);
+    expect(provisions).toBe(0);
+  });
+
+  test("always stops on disable when persistence fails", async () => {
+    let status: CloudflareTunnelStatus = {
+      status: "running",
+      mode: "quick",
+      publicUrl: "https://safe-name.trycloudflare.com",
+      supportsSse: false,
+    };
+    let stops = 0;
+    const controller = {
+      getStatus: () => status,
+      start: async () => status,
+      stop: async () => {
+        stops += 1;
+        status = stoppedStatus();
+        return status;
+      },
+    } as CloudflareTunnelController;
+    const cfg = config();
+    cfg.cloudflareTunnel = { enabled: true, mode: "quick" };
+    const req = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: false }),
+    });
+
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 54321,
+      persistConfig: () => { throw new Error("read-only config"); },
+    });
+    expect(stops).toBe(1);
+    expect(response?.status).toBe(500);
+    expect(await response?.json()).toMatchObject({
+      status: "stopped",
+      enabled: true,
+      endpoint: "http://127.0.0.1:54321/v1/responses",
+    });
+    expect(cfg.cloudflareTunnel.enabled).toBe(true);
+  });
+
+  test("rolls back a newly started tunnel when persistence fails", async () => {
+    let status = stoppedStatus();
+    let stops = 0;
+    const controller = {
+      getStatus: () => status,
+      start: async () => {
+        status = {
+          status: "running",
+          mode: "quick",
+          publicUrl: "https://safe-name.trycloudflare.com",
+          supportsSse: false,
+        };
+        return status;
+      },
+      stop: async () => {
+        stops += 1;
+        status = stoppedStatus();
+        return status;
+      },
+    } as CloudflareTunnelController;
+    const cfg = config();
+    const req = request("/api/cloudflare-tunnel", {
+      method: "PUT",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({ enabled: true }),
+    });
+
+    const response = await handleManagementAPI(req, new URL(req.url), cfg, {
+      cloudflareTunnel: controller,
+      listenPort: 54321,
+      persistConfig: () => { throw new Error("read-only config"); },
+    });
+    expect(stops).toBe(1);
+    expect(response?.status).toBe(500);
+    expect(await response?.json()).toMatchObject({
+      status: "stopped",
+      enabled: false,
+      endpoint: "http://127.0.0.1:54321/v1/responses",
+    });
+    expect(cfg.cloudflareTunnel?.enabled).toBeUndefined();
+  });
+});
diff --git a/tests/cloudflare-tunnel-security.test.ts b/tests/cloudflare-tunnel-security.test.ts
new file mode 100644
index 000000000..953c17ead
--- /dev/null
+++ b/tests/cloudflare-tunnel-security.test.ts
@@ -0,0 +1,133 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { saveConfig } from "../src/config";
+import {
+  hasValidApiAuth,
+  isRequestApiAuthRequired,
+  requireResponsesApiAuth,
+} from "../src/server/auth-cors";
+import {
+  CLOUDFLARE_TUNNEL_ORIGIN_HOST,
+  isCloudflareTunnelRequest,
+} from "../src/server/cloudflare-tunnel";
+import { startServer } from "../src/server";
+import type { OcxConfig } from "../src/types";
+
+const previousHome = process.env.OPENCODEX_HOME;
+const previousPublicUrl = process.env.OPENCODEX_CLOUDFLARE_PUBLIC_URL;
+const tempDirs: string[] = [];
+
+function tunnelConfig(): OcxConfig {
+  return {
+    port: 10100,
+    hostname: "127.0.0.1",
+    defaultProvider: "test",
+    providers: {
+      test: {
+        adapter: "openai-chat",
+        baseUrl: "https://api.example.test/v1",
+        apiKey: "provider-secret",
+        liveModels: false,
+        models: ["test-model"],
+      },
+    },
+    apiKeys: [{ id: "public", name: "public", key: "ocx_public_secret", createdAt: "2026-01-01T00:00:00.000Z" }],
+  };
+}
+
+function tunnelRequest(path: string, headers: Record = {}): Request {
+  return new Request(`http://${CLOUDFLARE_TUNNEL_ORIGIN_HOST}${path}`, { headers });
+}
+
+afterEach(() => {
+  if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
+  else process.env.OPENCODEX_HOME = previousHome;
+  if (previousPublicUrl === undefined) delete process.env.OPENCODEX_CLOUDFLARE_PUBLIC_URL;
+  else process.env.OPENCODEX_CLOUDFLARE_PUBLIC_URL = previousPublicUrl;
+  while (tempDirs.length) rmSync(tempDirs.pop()!, { recursive: true, force: true });
+});
+
+describe("Cloudflare tunnel security boundary", () => {
+  test("tunnel marker forces admission auth while ordinary loopback remains local", () => {
+    const config = tunnelConfig();
+    const local = new Request("http://127.0.0.1:10100/v1/responses");
+    const publicRequest = tunnelRequest("/v1/responses");
+
+    expect(isRequestApiAuthRequired(local, config)).toBe(false);
+    expect(hasValidApiAuth(local, config)).toBe(true);
+    expect(isRequestApiAuthRequired(publicRequest, config)).toBe(true);
+    expect(hasValidApiAuth(publicRequest, config)).toBe(false);
+    expect(hasValidApiAuth(tunnelRequest("/v1/responses", {
+      "x-opencodex-api-key": "ocx_public_secret",
+    }), config)).toBe(true);
+  });
+
+  test("recognizes edge headers and canonical dotted hostnames as public ingress", () => {
+    const config = tunnelConfig();
+    const edgeMarked = new Request("http://127.0.0.1:10100/v1/responses", {
+      headers: { "cf-ray": "test-ray" },
+    });
+    expect(isCloudflareTunnelRequest(edgeMarked)).toBe(true);
+    expect(isRequestApiAuthRequired(edgeMarked, config)).toBe(true);
+
+    process.env.OPENCODEX_CLOUDFLARE_PUBLIC_URL = "https://ocx.example.com";
+    const dottedHost = new Request("http://127.0.0.1:10100/v1/responses", {
+      headers: { Host: "ocx.example.com." },
+    });
+    expect(isCloudflareTunnelRequest(dottedHost)).toBe(true);
+    expect(hasValidApiAuth(dottedHost, config)).toBe(false);
+  });
+
+  test("Responses keeps its dedicated admission header so caller bearer credentials remain separate", () => {
+    const config = tunnelConfig();
+    expect(requireResponsesApiAuth(tunnelRequest("/v1/responses", {
+      authorization: "Bearer ocx_public_secret",
+    }), config)?.status).toBe(401);
+    expect(requireResponsesApiAuth(tunnelRequest("/v1/responses", {
+      authorization: "Bearer caller-upstream-token",
+      "x-opencodex-api-key": "ocx_public_secret",
+    }), config)).toBeNull();
+  });
+
+  test("public ingress rejects management and GUI routes before they reach local handlers", async () => {
+    const dir = mkdtempSync(join(tmpdir(), "opencodex-cloudflare-security-"));
+    tempDirs.push(dir);
+    process.env.OPENCODEX_HOME = dir;
+    const config = tunnelConfig();
+    config.port = 0;
+    saveConfig(config);
+    const server = startServer(0);
+
+    try {
+      for (const path of ["/", "/api/config", "/api/stop", "/healthz"]) {
+        const response = await fetch(new URL(path, server.url), {
+          method: path === "/api/stop" ? "POST" : "GET",
+          headers: { Host: CLOUDFLARE_TUNNEL_ORIGIN_HOST },
+        });
+        expect(response.status).toBe(404);
+      }
+
+      const unauthorized = await fetch(new URL("/v1/models", server.url), {
+        headers: { Host: CLOUDFLARE_TUNNEL_ORIGIN_HOST },
+      });
+      expect(unauthorized.status).toBe(401);
+
+      const hiddenByEdgeMarker = await fetch(new URL("/api/config", server.url), {
+        headers: { "cf-ray": "test-ray" },
+      });
+      expect(hiddenByEdgeMarker.status).toBe(404);
+
+      const authorized = await fetch(new URL("/v1/models", server.url), {
+        headers: {
+          Host: CLOUDFLARE_TUNNEL_ORIGIN_HOST,
+          "x-opencodex-api-key": "ocx_public_secret",
+        },
+      });
+      expect(authorized.status).toBe(200);
+    } finally {
+      await server.stop(true);
+    }
+  });
+});
diff --git a/tests/cloudflare-tunnel-setup.test.ts b/tests/cloudflare-tunnel-setup.test.ts
new file mode 100644
index 000000000..2a579e00d
--- /dev/null
+++ b/tests/cloudflare-tunnel-setup.test.ts
@@ -0,0 +1,178 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { createHash } from "node:crypto";
+import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, join } from "node:path";
+import {
+  CLOUDFLARE_TUNNEL_TOKEN_FILENAME,
+  cloudflareTunnelStartOverrides,
+  replaceStoredCloudflareTunnelToken,
+  resolveCloudflareTunnelSetup,
+  storedCloudflareTunnelTokenPath,
+} from "../src/server/cloudflare-setup";
+
+const runnerToken = `eyJ${"a".repeat(64)}`;
+let testDir = "";
+let previousHome: string | undefined;
+
+beforeEach(() => {
+  previousHome = process.env.OPENCODEX_HOME;
+  testDir = mkdtempSync(join(tmpdir(), "opencodex-cloudflare-setup-"));
+  process.env.OPENCODEX_HOME = testDir;
+});
+
+afterEach(() => {
+  if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
+  else process.env.OPENCODEX_HOME = previousHome;
+  rmSync(testDir, { recursive: true, force: true });
+});
+
+describe("Cloudflare Tunnel setup resolution", () => {
+  test("defaults to an unconfigured Named Tunnel with SSE and fails closed", () => {
+    const setup = resolveCloudflareTunnelSetup({}, { env: {}, configDir: testDir });
+
+    expect(setup).toEqual({
+      mode: "named",
+      configured: false,
+      source: "none",
+      publicUrl: null,
+      supportsSse: true,
+    });
+    expect(cloudflareTunnelStartOverrides(setup)).toEqual({ mode: "named" });
+  });
+
+  test("accepts a complete environment-managed Named Tunnel", () => {
+    const fromToken = resolveCloudflareTunnelSetup({}, {
+      env: {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN: runnerToken,
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://api.example.com/",
+      },
+      configDir: testDir,
+    });
+    expect(fromToken).toEqual({
+      mode: "named",
+      configured: true,
+      source: "environment",
+      publicUrl: "https://api.example.com",
+      supportsSse: true,
+    });
+
+    const tokenFile = join(testDir, "environment-token");
+    writeFileSync(tokenFile, `${runnerToken}\n`, { mode: 0o600 });
+    const fromFile = resolveCloudflareTunnelSetup({}, {
+      env: {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE: tokenFile,
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://api.example.com",
+      },
+      configDir: testDir,
+    });
+    expect(fromFile).toMatchObject({
+      mode: "named",
+      configured: true,
+      source: "environment",
+      supportsSse: true,
+    });
+  });
+
+  test("rejects partial or ambiguous environment configuration", () => {
+    for (const env of [
+      { OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://api.example.com" },
+      { OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN: runnerToken },
+      {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN: runnerToken,
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE: join(testDir, "other-token"),
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://api.example.com",
+      },
+      {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE: join(testDir, "missing-token"),
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://api.example.com",
+      },
+    ]) {
+      expect(resolveCloudflareTunnelSetup({}, { env, configDir: testDir })).toMatchObject({
+        mode: "named",
+        configured: false,
+        source: "environment",
+        supportsSse: true,
+        error: "environment_incomplete",
+      });
+    }
+  });
+
+  test("stores the runner token only in the fixed 0600 file and verifies its fingerprint", () => {
+    const change = replaceStoredCloudflareTunnelToken(
+      `cloudflared tunnel run --token ${runnerToken}`,
+      testDir,
+    );
+    const expectedPath = join(testDir, CLOUDFLARE_TUNNEL_TOKEN_FILENAME);
+    const fingerprint = createHash("sha256").update(runnerToken, "utf8").digest("hex");
+
+    expect(change.path).toBe(expectedPath);
+    expect(storedCloudflareTunnelTokenPath(testDir)).toBe(expectedPath);
+    expect(basename(change.path)).toBe(CLOUDFLARE_TUNNEL_TOKEN_FILENAME);
+    expect(change.fingerprint).toBe(fingerprint);
+    expect(readFileSync(change.path, "utf8")).toBe(`${runnerToken}\n`);
+    if (process.platform !== "win32") expect(statSync(change.path).mode & 0o777).toBe(0o600);
+
+    const setup = resolveCloudflareTunnelSetup({
+      cloudflareTunnel: {
+        mode: "named",
+        publicUrl: "https://api.example.com",
+        tokenFingerprint: fingerprint,
+      },
+    }, { env: {}, configDir: testDir });
+    expect(setup).toEqual({
+      mode: "named",
+      configured: true,
+      source: "local",
+      publicUrl: "https://api.example.com",
+      supportsSse: true,
+      tokenFile: expectedPath,
+    });
+    expect(cloudflareTunnelStartOverrides(setup)).toEqual({
+      mode: "named",
+      namedTunnel: { publicUrl: "https://api.example.com", tokenFile: expectedPath },
+    });
+  });
+
+  test("fails closed when the local token is missing or its fingerprint changes", () => {
+    const expectedFingerprint = createHash("sha256").update(runnerToken, "utf8").digest("hex");
+    const configured = {
+      cloudflareTunnel: {
+        mode: "named" as const,
+        publicUrl: "https://api.example.com",
+        tokenFingerprint: expectedFingerprint,
+      },
+    };
+
+    expect(resolveCloudflareTunnelSetup(configured, { env: {}, configDir: testDir }))
+      .toMatchObject({ configured: false, error: "token_missing", supportsSse: true });
+
+    const tokenPath = storedCloudflareTunnelTokenPath(testDir);
+    writeFileSync(tokenPath, `eyJ${"b".repeat(64)}\n`, { mode: 0o600 });
+    expect(resolveCloudflareTunnelSetup(configured, { env: {}, configDir: testDir }))
+      .toMatchObject({ configured: false, error: "token_mismatch", supportsSse: true });
+  });
+
+  test("can explicitly opt into the development-only Quick Tunnel mode", () => {
+    const setup = resolveCloudflareTunnelSetup(
+      { cloudflareTunnel: { mode: "quick" } },
+      { env: {}, configDir: testDir },
+    );
+    expect(setup).toEqual({
+      mode: "quick",
+      configured: true,
+      source: "quick",
+      publicUrl: null,
+      supportsSse: false,
+    });
+  });
+
+  test("rolls a new runner-token file back without leaving credentials behind", () => {
+    const change = replaceStoredCloudflareTunnelToken(runnerToken, testDir);
+    expect(existsSync(change.path)).toBe(true);
+
+    change.rollback();
+
+    expect(existsSync(change.path)).toBe(false);
+  });
+});
diff --git a/tests/cloudflare-tunnel-ui.test.ts b/tests/cloudflare-tunnel-ui.test.ts
new file mode 100644
index 000000000..56e417d9b
--- /dev/null
+++ b/tests/cloudflare-tunnel-ui.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, test } from "bun:test";
+import {
+  STOPPED_CLOUDFLARE_TUNNEL,
+  buildCloudflareTunnelSetupRequest,
+  buildCloudflareTunnelToggleRequest,
+  canReconfigureTunnel,
+  canToggleTunnel,
+  endpointFromApiPayload,
+  isTunnelEnabled,
+  isTunnelTransitioning,
+  shouldOpenTunnelSetup,
+  tunnelFromApiPayload,
+  tunnelStatusTone,
+} from "../gui/src/cloudflare-tunnel";
+
+describe("Cloudflare tunnel UI state", () => {
+  test("reads tunnel state from both management API response shapes", () => {
+    const running = {
+      ...STOPPED_CLOUDFLARE_TUNNEL,
+      status: "running",
+      mode: "named",
+      publicUrl: "https://api.example.com",
+      configuredPublicUrl: "https://api.example.com",
+      originUrl: "http://127.0.0.1:10100",
+      configurationSource: "api",
+      supportsSse: true,
+      enabled: true,
+      canEnable: true,
+      canConfigure: true,
+      configured: true,
+      setupRequired: false,
+    } as const;
+
+    expect(tunnelFromApiPayload({ tunnel: running })).toEqual(running);
+    expect(tunnelFromApiPayload(running)).toEqual(running);
+  });
+
+  test("keeps the last valid state when a partial payload omits fields", () => {
+    const fallback = {
+      ...STOPPED_CLOUDFLARE_TUNNEL,
+      status: "running",
+      mode: "named",
+      publicUrl: "https://api.example.com",
+      supportsSse: true,
+      enabled: true,
+      canEnable: true,
+      canConfigure: true,
+      configured: true,
+      setupRequired: false,
+    } as const;
+
+    expect(tunnelFromApiPayload({ status: "stopping" }, fallback)).toEqual({
+      ...fallback,
+      status: "stopping",
+    });
+    expect(tunnelFromApiPayload(null)).toEqual(STOPPED_CLOUDFLARE_TUNNEL);
+  });
+
+  test("defaults unconfigured public access to a streaming-capable Named Tunnel", () => {
+    expect(STOPPED_CLOUDFLARE_TUNNEL).toMatchObject({
+      mode: "named",
+      supportsSse: true,
+      configured: false,
+      setupRequired: true,
+    });
+    expect(shouldOpenTunnelSetup(STOPPED_CLOUDFLARE_TUNNEL)).toBe(true);
+    expect(shouldOpenTunnelSetup({
+      ...STOPPED_CLOUDFLARE_TUNNEL,
+      configured: true,
+      setupRequired: false,
+    })).toBe(false);
+  });
+
+  test("keeps an explicitly selected legacy Quick Tunnel usable for advanced debugging", () => {
+    const quick = tunnelFromApiPayload({
+      status: "stopped",
+      mode: "quick",
+      publicUrl: null,
+      supportsSse: false,
+      enabled: false,
+      canEnable: true,
+    });
+
+    expect(quick).toMatchObject({ configured: true, setupRequired: false, canConfigure: true });
+    expect(shouldOpenTunnelSetup(quick)).toBe(false);
+    expect(canToggleTunnel(quick, false)).toBe(true);
+  });
+
+  test("uses only the backend endpoint and preserves the previous value when absent", () => {
+    expect(endpointFromApiPayload({ endpoint: "https://api.example.com/v1/responses" }, "local"))
+      .toBe("https://api.example.com/v1/responses");
+    expect(endpointFromApiPayload({}, "http://127.0.0.1:10100/v1/responses"))
+      .toBe("http://127.0.0.1:10100/v1/responses");
+  });
+
+  test("disables duplicate transitions and follows the backend admission capability", () => {
+    const starting = { ...STOPPED_CLOUDFLARE_TUNNEL, status: "starting" as const };
+    const running = {
+      ...STOPPED_CLOUDFLARE_TUNNEL,
+      status: "running" as const,
+      publicUrl: "https://random.trycloudflare.com",
+      enabled: true,
+    };
+
+    expect(isTunnelTransitioning(starting.status)).toBe(true);
+    const canConfigure = { ...STOPPED_CLOUDFLARE_TUNNEL, canConfigure: true };
+    const canEnable = {
+      ...STOPPED_CLOUDFLARE_TUNNEL,
+      configured: true,
+      setupRequired: false,
+      canEnable: true,
+    };
+    expect(canToggleTunnel(starting, false)).toBe(false);
+    expect(canToggleTunnel(STOPPED_CLOUDFLARE_TUNNEL, false)).toBe(false);
+    expect(canToggleTunnel(canConfigure, false)).toBe(true);
+    expect(canToggleTunnel(canEnable, false)).toBe(true);
+    expect(isTunnelEnabled(running)).toBe(true);
+    expect(canToggleTunnel(running, false)).toBe(true);
+  });
+
+  test("keeps an enabled error state closable so persisted access can be cleared", () => {
+    const failed = {
+      ...STOPPED_CLOUDFLARE_TUNNEL,
+      status: "error" as const,
+      enabled: true,
+      error: "cloudflared exited",
+    };
+
+    expect(isTunnelEnabled(failed)).toBe(true);
+    expect(canToggleTunnel(failed, false)).toBe(true);
+    expect(shouldOpenTunnelSetup(failed)).toBe(false);
+  });
+
+  test("allows local Named credentials to be rotated only while public access is inactive", () => {
+    const local = {
+      ...STOPPED_CLOUDFLARE_TUNNEL,
+      canConfigure: true,
+      canEnable: true,
+      configured: true,
+      setupRequired: false,
+      configurationSource: "local",
+      configuredPublicUrl: "https://api.example.com",
+    };
+    expect(canReconfigureTunnel(local, false)).toBe(true);
+    expect(canReconfigureTunnel(local, true)).toBe(false);
+    expect(canReconfigureTunnel({ ...local, enabled: true }, false)).toBe(false);
+    expect(canReconfigureTunnel({ ...local, configurationSource: "environment" }, false)).toBe(false);
+    expect(canReconfigureTunnel({ ...local, configurationEditable: false }, false)).toBe(false);
+  });
+
+  test("builds the automatic Named Tunnel setup request without retaining whitespace", () => {
+    expect(buildCloudflareTunnelSetupRequest("api", {
+      accountId: " account-id ",
+      zoneId: " zone-id ",
+      hostname: " api.example.com ",
+      apiToken: " token ",
+      tunnelName: "  ",
+    })).toEqual({
+      method: "api",
+      accountId: "account-id",
+      zoneId: "zone-id",
+      hostname: "api.example.com",
+      apiToken: "token",
+      enable: true,
+    });
+    expect(buildCloudflareTunnelSetupRequest("api", {
+      accountId: "account-id",
+      zoneId: "zone-id",
+      hostname: "new.example.com",
+      apiToken: "token",
+      replaceExisting: true,
+    })).toMatchObject({ replaceExisting: true, enable: true });
+  });
+
+  test("builds setup for an existing tunnel from a token or install command", () => {
+    expect(buildCloudflareTunnelSetupRequest("token", {
+      publicUrl: " https://api.example.com ",
+      tunnelToken: " cloudflared service install eyToken ",
+    })).toEqual({
+      method: "token",
+      publicUrl: "https://api.example.com",
+      tunnelToken: "cloudflared service install eyToken",
+      enable: true,
+    });
+  });
+
+  test("builds a one-click Quick Tunnel enable request as an explicit mode", () => {
+    expect(buildCloudflareTunnelToggleRequest(true, "quick")).toEqual({
+      enabled: true,
+      mode: "quick",
+    });
+    expect(buildCloudflareTunnelToggleRequest(false, "quick")).toEqual({
+      enabled: false,
+    });
+  });
+
+  test("maps every status to a semantic badge tone", () => {
+    expect(tunnelStatusTone("stopped")).toBe("muted");
+    expect(tunnelStatusTone("starting")).toBe("amber");
+    expect(tunnelStatusTone("running")).toBe("green");
+    expect(tunnelStatusTone("stopping")).toBe("amber");
+    expect(tunnelStatusTone("error")).toBe("red");
+  });
+});
diff --git a/tests/cloudflare-tunnel.test.ts b/tests/cloudflare-tunnel.test.ts
new file mode 100644
index 000000000..367adb253
--- /dev/null
+++ b/tests/cloudflare-tunnel.test.ts
@@ -0,0 +1,545 @@
+import { describe, expect, test } from "bun:test";
+import { EventEmitter } from "node:events";
+import { PassThrough } from "node:stream";
+import type { ChildProcess, spawn } from "node:child_process";
+import {
+  CLOUDFLARE_TUNNEL_ORIGIN_HOST,
+  ManagedCloudflareTunnelController,
+  buildCloudflaredLaunch,
+  normalizeNamedPublicUrl,
+  parseCloudflaredReadyUrl,
+  parseQuickTunnelUrl,
+} from "../src/server/cloudflare-tunnel";
+
+class FakeChild extends EventEmitter {
+  stdout = new PassThrough();
+  stderr = new PassThrough();
+  killedWith: NodeJS.Signals[] = [];
+  exitCode: number | null = null;
+
+  kill(signal: NodeJS.Signals = "SIGTERM"): boolean {
+    this.killedWith.push(signal);
+    return true;
+  }
+
+  spawned(): void { this.emit("spawn"); }
+  close(code = 0): void {
+    this.exitCode = code;
+    this.emit("close", code, null);
+  }
+}
+
+function fakeSpawner(child: FakeChild, calls: Array<{ file: string; args: string[]; options: Record }>) {
+  return ((file: string, args: readonly string[], options: Record) => {
+    calls.push({ file, args: [...args], options });
+    return child as unknown as ChildProcess;
+  }) as unknown as typeof spawn;
+}
+
+const startOptions = {
+  originUrl: "http://127.0.0.1:54321",
+  actualPort: 54321,
+  configuredPort: 54321,
+};
+const READY_METRICS_LINE = "Starting metrics server on 127.0.0.1:32123/metrics\n";
+const readyFetch = async () => new Response(null, { status: 200 });
+
+describe("Cloudflare Tunnel launch configuration", () => {
+  test("accepts only exact HTTPS trycloudflare origins", () => {
+    expect(parseQuickTunnelUrl("ready https://safe-name.trycloudflare.com now"))
+      .toBe("https://safe-name.trycloudflare.com");
+    expect(parseQuickTunnelUrl("│ https://one.two.trycloudflare.com │"))
+      .toBe("https://one.two.trycloudflare.com");
+    for (const value of [
+      "http://safe.trycloudflare.com",
+      "https://trycloudflare.com",
+      "https://safe.trycloudflare.com.evil.example",
+      "https://safe.trycloudflare.com/private",
+      "https://-bad.trycloudflare.com",
+      "https://bad-.trycloudflare.com",
+      "https://bad..name.trycloudflare.com",
+    ]) expect(parseQuickTunnelUrl(value)).toBeNull();
+  });
+
+  test("builds a shell-free Quick Tunnel with the protected origin host", () => {
+    const launch = buildCloudflaredLaunch(startOptions, {
+      env: { TUNNEL_TOKEN: "stale", TUNNEL_TOKEN_FILE: "/stale-token" },
+      homeDir: "/isolated-home",
+      exists: () => false,
+    });
+    expect(launch).toMatchObject({
+      mode: "quick",
+      binary: "cloudflared",
+      publicUrl: null,
+      supportsSse: false,
+      args: [
+        "tunnel", "--no-autoupdate", "--loglevel", "info",
+        "--metrics", "127.0.0.1:0",
+        "--http-host-header", CLOUDFLARE_TUNNEL_ORIGIN_HOST,
+        "--url", startOptions.originUrl,
+      ],
+    });
+    if (!("status" in launch)) {
+      expect(launch.env.TUNNEL_TOKEN).toBeUndefined();
+      expect(launch.env.TUNNEL_TOKEN_FILE).toBeUndefined();
+    }
+  });
+
+  test("does not modify an existing user cloudflared config for Quick Tunnel", () => {
+    const launch = buildCloudflaredLaunch(startOptions, {
+      env: {},
+      homeDir: "/isolated-home",
+      exists: path => path.endsWith("config.yml"),
+    });
+    expect(launch).toMatchObject({ status: "error", mode: "quick", publicUrl: null });
+    expect("error" in launch ? launch.error : "").toContain("config.yml");
+  });
+
+  test("keeps Named Tunnel tokens out of argv and requires the fixed configured port", () => {
+    const token = "secret-runner-token";
+    const env = {
+      OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN: token,
+      OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://ocx.example.com",
+      TUNNEL_TOKEN: "stale-token",
+      TUNNEL_TOKEN_FILE: "/stale-token",
+    };
+    const launch = buildCloudflaredLaunch(startOptions, { env });
+    expect(launch).toMatchObject({
+      mode: "named",
+      publicUrl: "https://ocx.example.com",
+      supportsSse: true,
+      args: [
+        "tunnel", "--no-autoupdate", "--loglevel", "info", "--protocol", "auto",
+        "--metrics", "127.0.0.1:0", "run",
+      ],
+    });
+    if (!("status" in launch)) {
+      expect(launch.args.join(" ")).not.toContain(token);
+      expect(launch.env.TUNNEL_TOKEN).toBe(token);
+      expect(launch.env.TUNNEL_TOKEN_FILE).toBeUndefined();
+      expect(launch.env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN).toBeUndefined();
+    }
+
+    const fromFile = buildCloudflaredLaunch(startOptions, {
+      env: {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN_FILE: "/chosen-token",
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://ocx.example.com",
+        TUNNEL_TOKEN: "stale-token",
+        TUNNEL_TOKEN_FILE: "/stale-token",
+      },
+    });
+    expect(fromFile).toMatchObject({
+      mode: "named",
+      args: [
+        "tunnel", "--no-autoupdate", "--loglevel", "info", "--protocol", "auto",
+        "--metrics", "127.0.0.1:0", "run", "--token-file", "/chosen-token",
+      ],
+    });
+    if (!("status" in fromFile)) {
+      expect(fromFile.env.TUNNEL_TOKEN).toBeUndefined();
+      expect(fromFile.env.TUNNEL_TOKEN_FILE).toBeUndefined();
+    }
+
+    const mismatch = buildCloudflaredLaunch({ ...startOptions, actualPort: 54322 }, { env });
+    expect(mismatch).toMatchObject({ status: "error", mode: "named" });
+    expect("error" in mismatch ? mismatch.error : "").toContain("fallback port");
+  });
+
+  test("uses explicit local Named Tunnel overrides without putting the runner token in argv", () => {
+    const tokenFile = "/isolated-opencodex/cloudflare-tunnel-token";
+    const localRunnerToken = `eyJ${"l".repeat(64)}`;
+    const launch = buildCloudflaredLaunch({
+      ...startOptions,
+      mode: "named",
+      namedTunnel: {
+        publicUrl: "https://api.example.com",
+        tokenFile,
+      },
+    }, {
+      env: {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN: "stale-environment-token",
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://stale.example.com",
+      },
+      readFile: path => {
+        expect(path).toBe(tokenFile);
+        return `${localRunnerToken}\n`;
+      },
+    });
+
+    expect(launch).toMatchObject({
+      mode: "named",
+      publicUrl: "https://api.example.com",
+      supportsSse: true,
+      args: [
+        "tunnel", "--no-autoupdate", "--loglevel", "info", "--protocol", "auto",
+        "--metrics", "127.0.0.1:0", "run",
+      ],
+    });
+    if (!("status" in launch)) {
+      expect(launch.args.join(" ")).not.toContain("stale-environment-token");
+      expect(launch.args.join(" ")).not.toContain(localRunnerToken);
+      expect(launch.env.TUNNEL_TOKEN).toBe(localRunnerToken);
+      expect(launch.env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN).toBeUndefined();
+    }
+  });
+
+  test("honors an explicit Quick Tunnel opt-in even when Named credentials are ambient", () => {
+    const launch = buildCloudflaredLaunch({ ...startOptions, mode: "quick" }, {
+      env: {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN: "ambient-runner-token",
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://ambient.example.com",
+      },
+      homeDir: "/isolated-home",
+      exists: () => false,
+    });
+
+    expect(launch).toMatchObject({ mode: "quick", supportsSse: false, publicUrl: null });
+    if (!("status" in launch)) {
+      expect(launch.args).toContain("--url");
+      expect(launch.args).not.toContain("--token-file");
+      expect(launch.env.TUNNEL_TOKEN).toBeUndefined();
+      expect(launch.env.OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN).toBeUndefined();
+    }
+  });
+
+  test("validates Named Tunnel public URLs as exact HTTPS origins", () => {
+    expect(normalizeNamedPublicUrl("https://ocx.example.com/"))
+      .toBe("https://ocx.example.com");
+    expect(normalizeNamedPublicUrl("http://ocx.example.com")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://ocx.example.com/v1")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://user:pass@ocx.example.com")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://ocx.example.com.")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://*.example.com")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://-bad.example.com")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://bad-.example.com")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://bad..example.com")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://localhost")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://127.0.0.1")).toBeNull();
+    expect(normalizeNamedPublicUrl("https://[::1]")).toBeNull();
+  });
+
+  test("parses only loopback cloudflared metrics listeners for readiness", () => {
+    expect(parseCloudflaredReadyUrl("Starting metrics server on 127.0.0.1:32123/metrics"))
+      .toBe("http://127.0.0.1:32123/ready");
+    expect(parseCloudflaredReadyUrl("http://localhost:20241/metrics"))
+      .toBe("http://127.0.0.1:20241/ready");
+    expect(parseCloudflaredReadyUrl("http://0.0.0.0:20241/metrics")).toBeNull();
+    expect(parseCloudflaredReadyUrl("http://127.0.0.1:0/metrics")).toBeNull();
+    expect(parseCloudflaredReadyUrl("http://127.0.0.1:65536/metrics")).toBeNull();
+    expect(parseCloudflaredReadyUrl(
+      "configured 127.0.0.1:0/metrics; listening 127.0.0.1:32124/metrics",
+    )).toBe("http://127.0.0.1:32124/ready");
+  });
+});
+
+describe("managed Cloudflare Tunnel lifecycle", () => {
+  test("parses a split Quick URL, deduplicates concurrent starts, and stops gracefully", async () => {
+    const child = new FakeChild();
+    const calls: Array<{ file: string; args: string[]; options: Record }> = [];
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, calls),
+      fetchFn: readyFetch,
+      env: {},
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 500,
+      namedReadyDelayMs: 0,
+      stopTimeoutMs: 50,
+      now: () => new Date("2026-07-22T00:00:00.000Z"),
+    });
+
+    const first = controller.start(startOptions);
+    const second = controller.start(startOptions);
+    expect(first).toBe(second);
+    expect(calls).toHaveLength(1);
+    child.stderr.write("route: https://split-name.trycloud");
+    child.stdout.write("flare.com\n");
+    child.stderr.write(READY_METRICS_LINE);
+    await expect(first).resolves.toEqual({
+      status: "running",
+      mode: "quick",
+      publicUrl: "https://split-name.trycloudflare.com",
+      supportsSse: false,
+      startedAt: "2026-07-22T00:00:00.000Z",
+    });
+
+    const stopping = controller.stop();
+    expect(controller.getStatus().status).toBe("stopping");
+    expect(child.killedWith).toEqual(["SIGTERM"]);
+    child.close();
+    await expect(stopping).resolves.toMatchObject({ status: "stopped", publicUrl: null });
+  });
+
+  test("does not advertise a Quick URL until cloudflared reports an active edge connection", async () => {
+    const child = new FakeChild();
+    let readinessChecks = 0;
+    let edgeReady = false;
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, []),
+      fetchFn: async () => {
+        readinessChecks += 1;
+        return new Response(null, { status: edgeReady ? 200 : 503 });
+      },
+      env: {},
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 500,
+      namedReadyDelayMs: 1,
+    });
+
+    const pending = controller.start(startOptions);
+    child.stderr.write("https://not-ready-yet.trycloudflare.com\n");
+    child.stderr.write(READY_METRICS_LINE);
+    await new Promise(resolve => setTimeout(resolve, 5));
+    expect(readinessChecks).toBeGreaterThan(0);
+    expect(controller.getStatus()).toMatchObject({ status: "starting", publicUrl: null });
+
+    edgeReady = true;
+    await expect(pending).resolves.toMatchObject({
+      status: "running",
+      publicUrl: "https://not-ready-yet.trycloudflare.com",
+    });
+  });
+
+  test("reports ENOENT without exposing raw process details", async () => {
+    const child = new FakeChild();
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, []),
+      fetchFn: readyFetch,
+      env: {},
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 500,
+      namedReadyDelayMs: 0,
+      stopTimeoutMs: 10,
+    });
+    const pending = controller.start(startOptions);
+    child.emit("error", Object.assign(new Error("spawn /private/path ENOENT"), { code: "ENOENT" }));
+    const status = await pending;
+    expect(status).toMatchObject({ status: "error", publicUrl: null });
+    expect(status.error).toContain("not installed");
+    expect(status.error).not.toContain("/private/path");
+  });
+
+  test("marks an unexpected post-start exit as an error", async () => {
+    const child = new FakeChild();
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, []),
+      fetchFn: readyFetch,
+      env: {},
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 500,
+      namedReadyDelayMs: 0,
+    });
+    const pending = controller.start(startOptions);
+    child.stderr.write("https://exit-later.trycloudflare.com\n");
+    child.stderr.write(READY_METRICS_LINE);
+    await pending;
+    child.close(1);
+    expect(controller.getStatus()).toMatchObject({
+      status: "error",
+      publicUrl: "https://exit-later.trycloudflare.com",
+    });
+  });
+
+  test("starts Named Tunnel only after its metrics readiness probe succeeds", async () => {
+    const child = new FakeChild();
+    const calls: Array<{ file: string; args: string[]; options: Record }> = [];
+    let readinessChecks = 0;
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, calls),
+      fetchFn: async url => {
+        expect(String(url)).toBe("http://127.0.0.1:32123/ready");
+        readinessChecks += 1;
+        return new Response(null, { status: readinessChecks === 1 ? 503 : 200 });
+      },
+      env: {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN: "named-secret",
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://ocx.example.com",
+      },
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 500,
+      namedReadyDelayMs: 0,
+    });
+    const pending = controller.start(startOptions);
+    child.spawned();
+    await new Promise(resolve => setTimeout(resolve, 5));
+    expect(controller.getStatus().status).toBe("starting");
+    child.stderr.write("Starting metrics server on 127.0.0.1:321");
+    child.stdout.write("23/metrics\n");
+    await expect(pending).resolves.toMatchObject({
+      status: "running",
+      mode: "named",
+      publicUrl: "https://ocx.example.com",
+      supportsSse: true,
+    });
+    expect(readinessChecks).toBe(2);
+    expect(String((calls[0].options.env as NodeJS.ProcessEnv).TUNNEL_TOKEN)).toBe("named-secret");
+    child.close();
+  });
+
+  test("a late Named readiness response cannot revive a stopped tunnel", async () => {
+    const child = new FakeChild();
+    let resolveReadiness: ((response: Response) => void) | undefined;
+    const readiness = new Promise(resolve => { resolveReadiness = resolve; });
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, []),
+      fetchFn: async () => readiness,
+      env: {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN: "named-secret",
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://ocx.example.com",
+      },
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 500,
+      namedReadyDelayMs: 0,
+      stopTimeoutMs: 50,
+    });
+
+    const pendingStart = controller.start(startOptions);
+    child.stderr.write("Starting metrics server on 127.0.0.1:32123/metrics\n");
+    await new Promise(resolve => setTimeout(resolve, 5));
+    const pendingStop = controller.stop();
+    await expect(pendingStart).resolves.toMatchObject({ status: "stopping" });
+    resolveReadiness?.(new Response(null, { status: 200 }));
+    child.close();
+    await expect(pendingStop).resolves.toMatchObject({ status: "stopped" });
+    await new Promise(resolve => setTimeout(resolve, 5));
+    expect(controller.getStatus()).toMatchObject({ status: "stopped", publicUrl: null });
+  });
+
+  test("times out a Named Tunnel whose readiness endpoint stays unavailable", async () => {
+    const child = new FakeChild();
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, []),
+      fetchFn: async () => new Response(null, { status: 503 }),
+      env: {
+        OPENCODEX_CLOUDFLARE_TUNNEL_TOKEN: "named-secret",
+        OPENCODEX_CLOUDFLARE_PUBLIC_URL: "https://ocx.example.com",
+      },
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 10,
+      namedReadyDelayMs: 1,
+      stopTimeoutMs: 10,
+    });
+
+    const pending = controller.start(startOptions);
+    child.stderr.write("Starting metrics server on 127.0.0.1:32123/metrics\n");
+    await expect(pending).resolves.toMatchObject({
+      status: "error",
+      error: "Cloudflare Tunnel did not become ready before the startup timeout.",
+    });
+    expect(child.killedWith).toContain("SIGTERM");
+  });
+
+  test("keeps ownership when forced stop receives no close event and cleans up a late close", async () => {
+    const child = new FakeChild();
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, []),
+      fetchFn: readyFetch,
+      env: {},
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 500,
+      namedReadyDelayMs: 0,
+      stopTimeoutMs: 1,
+    });
+    const started = controller.start(startOptions);
+    child.stderr.write("https://slow-stop.trycloudflare.com\n");
+    child.stderr.write(READY_METRICS_LINE);
+    await started;
+
+    const stopped = await controller.stop();
+    expect(stopped).toMatchObject({
+      status: "error",
+      publicUrl: "https://slow-stop.trycloudflare.com",
+    });
+    expect(child.killedWith).toEqual(["SIGTERM", "SIGKILL"]);
+
+    child.close();
+    expect(controller.getStatus()).toMatchObject({ status: "stopped", publicUrl: null });
+  });
+
+  test("honors the final stop intent when start is queued behind an earlier stop", async () => {
+    const firstChild = new FakeChild();
+    const secondChild = new FakeChild();
+    const children = [firstChild, secondChild];
+    let spawnCount = 0;
+    const spawnFn = ((..._args: unknown[]) => (
+      children[spawnCount++] as unknown as ChildProcess
+    )) as unknown as typeof spawn;
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn,
+      fetchFn: readyFetch,
+      env: {},
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 500,
+      namedReadyDelayMs: 0,
+      stopTimeoutMs: 50,
+    });
+
+    const initialStart = controller.start(startOptions);
+    firstChild.stderr.write("https://initial.trycloudflare.com\n");
+    firstChild.stderr.write(READY_METRICS_LINE);
+    await initialStart;
+
+    const firstStop = controller.stop();
+    const queuedStart = controller.start(startOptions);
+    const finalStop = controller.stop();
+    firstChild.close();
+
+    await Promise.all([firstStop, queuedStart, finalStop]);
+    expect(spawnCount).toBe(1);
+    expect(controller.getStatus()).toMatchObject({ status: "stopped", publicUrl: null });
+  });
+
+  test("cancels a pending start promptly without overwriting a forced-stop error", async () => {
+    const child = new FakeChild();
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, []),
+      env: {},
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 500,
+      stopTimeoutMs: 1,
+    });
+
+    const pendingStart = controller.start(startOptions);
+    const pendingStop = controller.stop();
+    expect(await Promise.race([
+      pendingStart.then(() => "start"),
+      pendingStop.then(() => "stop"),
+    ])).toBe("start");
+    await expect(pendingStart).resolves.toMatchObject({ status: "stopping" });
+
+    await expect(pendingStop).resolves.toMatchObject({
+      status: "error",
+      error: "cloudflared did not stop after forced termination.",
+    });
+    await new Promise(resolve => setTimeout(resolve, 550));
+    expect(controller.getStatus()).toMatchObject({
+      status: "error",
+      error: "cloudflared did not stop after forced termination.",
+    });
+  });
+
+  test("times out a child that never publishes a Quick Tunnel URL", async () => {
+    const child = new FakeChild();
+    const controller = new ManagedCloudflareTunnelController({
+      spawnFn: fakeSpawner(child, []),
+      env: {},
+      homeDir: "/isolated-home",
+      exists: () => false,
+      startupTimeoutMs: 10,
+      stopTimeoutMs: 10,
+    });
+    const status = await controller.start(startOptions);
+    expect(status).toMatchObject({ status: "error", publicUrl: null });
+    expect(status.error).toContain("startup timeout");
+    expect(child.killedWith).toContain("SIGTERM");
+  });
+});