Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Chowder/Chowder/Models/ConnectionConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ struct ConnectionConfig {
private static let gatewayURLKey = "gatewayURL"
private static let sessionKeyKey = "sessionKey"
private static let tokenKeychainKey = "gatewayToken"
private static let cfAccessClientIdKeychainKey = "cfAccessClientId"
private static let cfAccessClientSecretKeychainKey = "cfAccessClientSecret"

var gatewayURL: String {
get { UserDefaults.standard.string(forKey: Self.gatewayURLKey) ?? "" }
Expand All @@ -20,8 +22,25 @@ struct ConnectionConfig {
get { KeychainService.load(key: Self.tokenKeychainKey) ?? "" }
set { KeychainService.save(key: Self.tokenKeychainKey, value: newValue) }
}

// Cloudflare Zero Trust service token credentials.
// When both are set, they are sent as HTTP headers during the WebSocket upgrade.
var cfAccessClientId: String {
get { KeychainService.load(key: Self.cfAccessClientIdKeychainKey) ?? "" }
set { KeychainService.save(key: Self.cfAccessClientIdKeychainKey, value: newValue) }
}

var cfAccessClientSecret: String {
get { KeychainService.load(key: Self.cfAccessClientSecretKeychainKey) ?? "" }
set { KeychainService.save(key: Self.cfAccessClientSecretKeychainKey, value: newValue) }
}

var hasCloudflareAccessTokens: Bool {
!cfAccessClientId.isEmpty && !cfAccessClientSecret.isEmpty
}

var isConfigured: Bool {
!gatewayURL.isEmpty && !token.isEmpty
}
}

17 changes: 15 additions & 2 deletions Chowder/Chowder/Services/ChatService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ final class ChatService: NSObject {
private let gatewayURL: String
private let token: String
private let sessionKey: String
private let cfAccessClientId: String
private let cfAccessClientSecret: String

private var webSocketTask: URLSessionWebSocketTask?
private var urlSession: URLSession?
Expand Down Expand Up @@ -56,10 +58,12 @@ final class ChatService: NSObject {
private var visibleRunStartedAt: Date?
private var hiddenRunIds: Set<String> = []

init(gatewayURL: String, token: String, sessionKey: String = "agent:main:main") {
init(gatewayURL: String, token: String, sessionKey: String = "agent:main:main", cfAccessClientId: String = "", cfAccessClientSecret: String = "") {
self.gatewayURL = gatewayURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
self.token = token
self.sessionKey = sessionKey
self.cfAccessClientId = cfAccessClientId
self.cfAccessClientSecret = cfAccessClientSecret

// Use identifierForVendor when available; fall back to a UUID persisted in UserDefaults.
if let vendorId = UIDevice.current.identifierForVendor?.uuidString {
Expand Down Expand Up @@ -166,7 +170,16 @@ final class ChatService: NSObject {
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
self.urlSession = session

let task = session.webSocketTask(with: url)
// Use URLRequest so we can inject HTTP headers before the WebSocket upgrade.
// CF-Access headers must be present in the initial HTTP upgrade request —
// they cannot be sent after the connection is established.
var request = URLRequest(url: url)
if !cfAccessClientId.isEmpty && !cfAccessClientSecret.isEmpty {
request.setValue(cfAccessClientId, forHTTPHeaderField: "CF-Access-Client-ID")
request.setValue(cfAccessClientSecret, forHTTPHeaderField: "CF-Access-Client-Secret")
log("[CONNECT] CF-Access headers injected")
}
let task = session.webSocketTask(with: request)
self.webSocketTask = task
log("[CONNECT] Calling task.resume() ...")
task.resume()
Expand Down
4 changes: 3 additions & 1 deletion Chowder/Chowder/ViewModels/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,9 @@ final class ChatViewModel: ChatServiceDelegate, LocationServiceDelegate {
let service = ChatService(
gatewayURL: config.gatewayURL,
token: config.token,
sessionKey: config.sessionKey
sessionKey: config.sessionKey,
cfAccessClientId: config.cfAccessClientId,
cfAccessClientSecret: config.cfAccessClientSecret
)
service.delegate = self
self.chatService = service
Expand Down
66 changes: 66 additions & 0 deletions Chowder/Chowder/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,9 @@ struct ConnectionDetailView: View {
@State private var gatewayURL: String = ""
@State private var token: String = ""
@State private var sessionKey: String = ""
@State private var cfAccessEnabled: Bool = false
@State private var cfAccessClientId: String = ""
@State private var cfAccessClientSecret: String = ""

var body: some View {
ScrollView {
Expand Down Expand Up @@ -537,6 +540,64 @@ struct ConnectionDetailView: View {
}
}

// Cloudflare Zero Trust
VStack(alignment: .leading, spacing: 6) {
Text("Cloudflare Zero Trust")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.textCase(.uppercase)
.padding(.horizontal, 4)

GlassCard(padding: 0) {
VStack(spacing: 0) {
HStack {
Text("Enable")
.font(.system(size: 16))
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $cfAccessEnabled.animation())
.labelsHidden()
}
.padding(.horizontal, 16)
.padding(.vertical, 13)

if cfAccessEnabled {
Divider().padding(.leading, 16)

HStack {
Text("Client ID")
.font(.system(size: 16))
.foregroundStyle(.primary)
.frame(width: 80, alignment: .leading)

SecureField("Service token client ID", text: $cfAccessClientId)
.font(.system(size: 16))
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
.padding(.horizontal, 16)
.padding(.vertical, 13)

Divider().padding(.leading, 16)

HStack {
Text("Secret")
.font(.system(size: 16))
.foregroundStyle(.primary)
.frame(width: 80, alignment: .leading)

SecureField("Service token secret", text: $cfAccessClientSecret)
.font(.system(size: 16))
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
.padding(.horizontal, 16)
.padding(.vertical, 13)
}
}
}
}

// Session
VStack(alignment: .leading, spacing: 6) {
Text("Session")
Expand All @@ -561,6 +622,8 @@ struct ConnectionDetailView: View {
config.gatewayURL = gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines)
config.token = token.trimmingCharacters(in: .whitespacesAndNewlines)
config.sessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
config.cfAccessClientId = cfAccessEnabled ? cfAccessClientId.trimmingCharacters(in: .whitespacesAndNewlines) : ""
config.cfAccessClientSecret = cfAccessEnabled ? cfAccessClientSecret.trimmingCharacters(in: .whitespacesAndNewlines) : ""
onSave?()
dismiss()
}
Expand All @@ -577,6 +640,9 @@ struct ConnectionDetailView: View {
gatewayURL = config.gatewayURL
token = config.token
sessionKey = config.sessionKey
cfAccessEnabled = config.hasCloudflareAccessTokens
cfAccessClientId = config.cfAccessClientId
cfAccessClientSecret = config.cfAccessClientSecret
}
}
}
Expand Down
51 changes: 48 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/5af73b21-0ec1-4804-8a40-39dbd2f10adb
## Prerequisites

- **Mac mini (or any macOS/Linux host)** running OpenClaw gateway
- **Tailscale** installed on both the gateway host and the iOS device (same tailnet)
- **Tailscale** installed on both the gateway host and the iOS device (same tailnet), **or** a **Cloudflare Tunnel** (`cloudflared`) exposing the gateway with **Cloudflare Access** protecting it via service token (see step 3b below)
- **Xcode 15+** on a Mac to build and install Chowder
- **iOS 17+** on the target device

Expand Down Expand Up @@ -134,6 +134,41 @@ tailscale ip -4
# Example output: 100.104.164.27
```

### 3b. (Alternative) Expose the Gateway via Cloudflare Tunnel + Access

Instead of Tailscale, you can expose the gateway publicly using a **Cloudflare Tunnel** (`cloudflared`) and protect it with **Cloudflare Access** (part of the Zero Trust platform) using a service token for machine-to-machine authentication.

**On the gateway host:**

1. Install and authenticate `cloudflared`:
```bash
brew install cloudflare/cloudflare/cloudflared
cloudflared tunnel login
```
2. Create a tunnel and route it to the gateway:
```bash
cloudflared tunnel create my-gateway
cloudflared tunnel route dns my-gateway gateway.yourdomain.com
```
3. Configure the tunnel to forward to the local gateway port (`~/.cloudflared/config.yml`):
```yaml
tunnel: <tunnel-id>
credentials-file: ~/.cloudflared/<tunnel-id>.json
ingress:
- hostname: gateway.yourdomain.com
service: http://localhost:18789
- service: http_status:404
```
4. Start the tunnel: `cloudflared tunnel run my-gateway`

**In the Cloudflare Zero Trust dashboard:**

1. Go to **Access → Applications** and create an application for `gateway.yourdomain.com`
2. Add a **Service Auth** policy (not Allow) and attach a service token to it
3. Go to **Access → Service Auth → Service Tokens** and create a token — copy the **Client ID** and **Client Secret**

The gateway URL to use in Chowder will be `wss://gateway.yourdomain.com` (port 443, no explicit port needed).

### 4. Find Your Gateway Token

The gateway token was generated during onboarding. To find it:
Expand Down Expand Up @@ -166,9 +201,10 @@ In Xcode:
1. Open Chowder -- the Settings sheet appears on first launch
2. *(Optional)* To try UI interactions without OpenClaw, use the **demo** in Settings (e.g. Live Activity demo) — no gateway or token required.
3. Fill in the fields:
- **Gateway**: `ws://<tailscale-ip>:18789` (e.g. `ws://100.104.164.27:18789`)
- **Gateway**: `ws://<tailscale-ip>:18789` (e.g. `ws://100.104.164.27:18789`), or `wss://gateway.yourdomain.com` if using Cloudflare Zero Trust
- **Token**: paste the gateway token from step 4
- **Session**: leave as `agent:main:main` (default) or change to target a specific agent
- **Cloudflare Zero Trust** *(optional)*: enable the toggle and paste the **Client ID** and **Client Secret** of your service token (from step 3b). These are sent as `CF-Access-Client-ID` / `CF-Access-Client-Secret` headers during the WebSocket upgrade and stored securely in the Keychain.
4. Tap **Save**

Chowder will connect to the gateway, complete the WebSocket handshake, and show **Online** in the header.
Expand Down Expand Up @@ -347,6 +383,15 @@ Expected result after fix:
- If you see `"Filtered: X by toolCallId, Y by timestamp → 0 new items"` every poll, all items are from a previous run
- If the agent responds very quickly (simple questions), there may not be any thinking or tool steps to show

### Cloudflare Zero Trust — connection hangs or gets a 302 redirect

If the WebSocket connection hangs at "waiting for didOpen" or `curl` returns a `302` redirect to `cloudflareaccess.com`:

- **Check `service_token_status` in the redirect JWT**: decode the `meta` query param — if `"service_token_status": false`, Cloudflare didn't accept the token
- **Verify the Access policy**: the application must have a **Service Auth** policy (not an Allow policy) with the service token explicitly allowed. Identity-based policies (email, IdP groups) do not accept service tokens.
- **Verify the credentials**: go to **Access → Service Auth → Service Tokens** and confirm the Client ID and Secret match what's in Chowder. Regenerating a token invalidates the old secret.
- **Check the port**: Cloudflare only proxies standard HTTPS ports. Use `wss://gateway.yourdomain.com` (port 443) — not `wss://gateway.yourdomain.com:18789`.

### Gateway not reachable over Tailscale

- Ensure `gateway.bind` is set to `"tailnet"` (not `"loopback"`)
Expand All @@ -361,7 +406,7 @@ Chowder/
Models/
AgentActivity.swift -- Thinking/tool step tracking for shimmer
BotIdentity.swift -- Parsed IDENTITY.md model + markdown serialization
ConnectionConfig.swift -- Gateway URL, token, session key storage
ConnectionConfig.swift -- Gateway URL, token, session key, and Cloudflare Zero Trust credentials (Keychain)
Message.swift -- Chat message model (Codable, persisted)
UserProfile.swift -- Parsed USER.md model + markdown serialization
Services/
Expand Down