-
Notifications
You must be signed in to change notification settings - Fork 21
Fix: connect to an OpenClaw gateway over the LAN #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import CryptoKit | ||
| import Foundation | ||
|
|
||
| /// Ed25519 device identity for the OpenClaw gateway handshake. | ||
| /// | ||
| /// The gateway trusts loopback connections implicitly, so a client running on | ||
| /// the same machine as the gateway connects with a token alone. A connection | ||
| /// from another host — which is every connection from this phone — is granted | ||
| /// no write scope on a token by itself: `chat.send` comes back | ||
| /// "missing scope: operator.write" even though the handshake succeeded. It has | ||
| /// to present a signed device identity, which the gateway then holds as a | ||
| /// pairing request until it is approved once with: | ||
| /// | ||
| /// openclaw devices approve <requestId> | ||
| /// | ||
| /// The key is persisted in the Keychain so the phone presents the same device | ||
| /// on every launch; a fresh key each time would mean a new pairing request each | ||
| /// time, and a list full of stale devices. | ||
| enum OpenClawDeviceIdentity { | ||
|
|
||
| private static let service = "ai.openclaw.openvision" | ||
| private static let account = "openclaw-device-key" | ||
|
|
||
| private static let key: Curve25519.Signing.PrivateKey = { | ||
| if let stored = loadKey() { return stored } | ||
| let fresh = Curve25519.Signing.PrivateKey() | ||
| storeKey(fresh) | ||
| return fresh | ||
| }() | ||
|
|
||
| /// Raw 32-byte public key, base64url — the form the gateway expects. | ||
| static var publicKeyBase64URL: String { base64URL(key.publicKey.rawRepresentation) } | ||
|
|
||
| /// sha256 of the raw public key, hex. The gateway derives this itself and | ||
| /// compares, so it cannot be chosen freely. | ||
| static var deviceId: String { | ||
| SHA256.hash(data: key.publicKey.rawRepresentation) | ||
| .map { String(format: "%02x", $0) } | ||
| .joined() | ||
| } | ||
|
|
||
| /// The `device` object for the connect params. `nonce` must be the nonce | ||
| /// from the server's `connect.challenge` event, and the signature covers | ||
| /// the exact field order below — any deviation fails verification. | ||
| static func deviceParams( | ||
| clientId: String, | ||
| clientMode: String, | ||
| role: String, | ||
| scopes: [String], | ||
| token: String, | ||
| nonce: String | ||
| ) -> [String: Any]? { | ||
| let signedAt = Int(Date().timeIntervalSince1970 * 1000) | ||
| let payload = [ | ||
| "v2", deviceId, clientId, clientMode, role, | ||
| scopes.joined(separator: ","), String(signedAt), token, nonce, | ||
| ].joined(separator: "|") | ||
|
|
||
| guard let data = payload.data(using: .utf8), | ||
| let signature = try? key.signature(for: data) else { return nil } | ||
|
|
||
| return [ | ||
| "id": deviceId, | ||
| "publicKey": publicKeyBase64URL, | ||
| "signature": base64URL(signature), | ||
| "signedAt": signedAt, | ||
| "nonce": nonce, | ||
| ] | ||
| } | ||
|
|
||
| // MARK: - Helpers | ||
|
|
||
| private static func base64URL(_ data: Data) -> String { | ||
| data.base64EncodedString() | ||
| .replacingOccurrences(of: "+", with: "-") | ||
| .replacingOccurrences(of: "/", with: "_") | ||
| .replacingOccurrences(of: "=", with: "") | ||
| } | ||
|
|
||
| private static func loadKey() -> Curve25519.Signing.PrivateKey? { | ||
| let query: [String: Any] = [ | ||
| kSecClass as String: kSecClassGenericPassword, | ||
| kSecAttrService as String: service, | ||
| kSecAttrAccount as String: account, | ||
| kSecReturnData as String: true, | ||
| kSecMatchLimit as String: kSecMatchLimitOne, | ||
| ] | ||
| var item: CFTypeRef? | ||
| guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, | ||
| let data = item as? Data else { return nil } | ||
| return try? Curve25519.Signing.PrivateKey(rawRepresentation: data) | ||
| } | ||
|
|
||
| private static func storeKey(_ key: Curve25519.Signing.PrivateKey) { | ||
| let query: [String: Any] = [ | ||
| kSecClass as String: kSecClassGenericPassword, | ||
| kSecAttrService as String: service, | ||
| kSecAttrAccount as String: account, | ||
| ] | ||
| SecItemDelete(query as CFDictionary) | ||
| var attributes = query | ||
| attributes[kSecValueData as String] = key.rawRepresentation | ||
| attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: Keychain items configured with the kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly attribute are cryptographically bound to the hardware UID of the specific device on which they were created [1][2]. Consequently, these items do not migrate to new devices and are not included in backups intended for restoration on other devices [1][3][2]. Key behavior points regarding device migration and backup: - Non-Migratory: Items with the ThisDeviceOnly suffix are excluded from both iCloud backups and direct device-to-device transfers (such as Quick Start) [2]. - Restoration: If a backup containing these items is restored to a different device, the items will not be present, and the app will receive an errSecItemNotFound (-25300) error when attempting to access them [2]. - Security Intent: This attribute is designed for data that must remain bound to a specific physical device for security reasons. Developers are strongly advised to implement re-authentication or data-recovery flows within their applications to handle scenarios where these items are unexpectedly absent after a migration [2][4]. This attribute remains accessible after the first user unlock following a device restart and persists in memory until the next restart, making it suitable for background application access on the original device [1][5][2]. However, it is not appropriate for data that users expect to persist when upgrading to a new phone [2]. Citations:
🏁 Script executed: #!/bin/bash
set -eu
file="OpenVision/Services/OpenClaw/OpenClawDeviceIdentity.swift"
sed -n '1,125p' "$file"
rg -n "OpenClawDeviceIdentity|deviceId|publicKey|authToken|Keychain|kSecAttrAccessible" OpenVision/Services/OpenClaw "$file"Repository: rayl15/OpenVision Length of output: 7236 Authorization Bypass (CWE-922) Reachability: Internal · Exploitability: Difficult Prevent device identity migration. Use 🤖 Prompt for AI Agents |
||
| SecItemAdd(attributes as CFDictionary, nil) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,6 +85,9 @@ final class OpenClawService: ObservableObject { | |
| private var requestCounter: Int = 0 | ||
| private var pendingRequests: [String: CheckedContinuation<OpenClawResponse, Error>] = [:] | ||
| private var receiveTask: Task<Void, Never>? | ||
| /// Nonce from the server's connect.challenge, needed to sign the device identity. | ||
| private var challengeNonce: String? | ||
| private var challengeWaiter: CheckedContinuation<String, Never>? | ||
|
|
||
| // MARK: - Reconnection | ||
|
|
||
|
|
@@ -221,6 +224,11 @@ final class OpenClawService: ObservableObject { | |
| requestCounter = 0 | ||
| failPendingRequests(error: AIBackendError.notConnected) | ||
|
|
||
| // Every socket gets its own connect.challenge nonce. Carrying one over | ||
| // from a previous socket makes the gateway reject the handshake with | ||
| // "device nonce mismatch", so each attempt must wait for a fresh one. | ||
| challengeNonce = nil | ||
|
|
||
| guard !Task.isCancelled else { return } | ||
|
|
||
| do { | ||
|
|
@@ -358,12 +366,39 @@ final class OpenClawService: ObservableObject { | |
|
|
||
| // MARK: - Handshake | ||
|
|
||
| /// Wait briefly for the server's connect.challenge nonce. The socket is | ||
| /// already receiving by the time the handshake is built, so this normally | ||
| /// returns immediately. | ||
| private func waitForChallengeNonce() async -> String? { | ||
| if let nonce = challengeNonce { return nonce } | ||
| return await withTaskGroup(of: String?.self) { group in | ||
| group.addTask { [weak self] in | ||
| await withCheckedContinuation { (c: CheckedContinuation<String, Never>) in | ||
| Task { @MainActor in | ||
| if let existing = self?.challengeNonce { c.resume(returning: existing) } | ||
| else { self?.challengeWaiter = c } | ||
| } | ||
| } | ||
| } | ||
| group.addTask { | ||
| try? await Task.sleep(nanoseconds: 3_000_000_000) | ||
| return nil | ||
| } | ||
| let first = await group.next() ?? nil | ||
| group.cancelAll() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: printf '%s\n' '--- review conventions ---'
find /tmp/coderabbit-repo-knowledge/rayl15-openvision-116f9120 -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- target source ---'
sed -n '330,410p' OpenVision/Services/OpenClaw/OpenClawService.swift
printf '%s\n' '--- related declarations and uses ---'
rg -n -C 4 'challengeWaiter|waitForChallengeNonce|connect\.challenge|cancelAll\(\)|withTaskGroup|withCheckedContinuation' OpenVision/Services/OpenClaw/OpenClawService.swiftRepository: rayl15/OpenVision Length of output: 6501 🏁 Script executed: printf '%s\n' '--- repository convention ---'
cat /tmp/coderabbit-repo-knowledge/rayl15-openvision-116f9120/conventions/openvision.md
printf '%s\n' '--- connection lifecycle ---'
sed -n '190,255p' OpenVision/Services/OpenClaw/OpenClawService.swift
sed -n '680,770p' OpenVision/Services/OpenClaw/OpenClawService.swift
printf '%s\n' '--- all waiter lifecycle references ---'
rg -n -C 3 'challengeWaiter|closeWebSocket\(\)|sendHandshake\(\)|waitForChallengeNonce\(\)' OpenVision/Services/OpenClaw/OpenClawService.swiftRepository: rayl15/OpenVision Length of output: 11268 Settle the nonce waiter when the timeout wins.
🤖 Prompt for AI Agents |
||
| return first | ||
| } | ||
| } | ||
|
|
||
| /// Send initial connect handshake | ||
| private func sendHandshake() async throws { | ||
| // Match xmeta's handshake format exactly | ||
| let params: [String: Any] = [ | ||
| "minProtocol": 3, | ||
| "maxProtocol": 3, | ||
| var params: [String: Any] = [ | ||
| // Protocol 4: this gateway (OpenClaw 2026.7.1-2) sets | ||
| // MIN_CLIENT_PROTOCOL_VERSION = 4 and rejects a 3/3 offer outright with | ||
| // PROTOCOL_MISMATCH, so the shipped values cannot connect to it. | ||
| "minProtocol": 4, | ||
| "maxProtocol": 4, | ||
| "client": [ | ||
| "id": "cli", | ||
| "displayName": "OpenVision", | ||
|
|
@@ -372,11 +407,30 @@ final class OpenClawService: ObservableObject { | |
| "mode": "cli" | ||
| ], | ||
| "caps": [String](), // Empty array like xmeta | ||
| // Without these the handshake still succeeds, but every chat.send is | ||
| // rejected with "missing scope: operator.write" -- the gateway grants | ||
| // no write scope to a connection that requests none. | ||
| "role": "operator", | ||
| "scopes": ["operator.read", "operator.write"], | ||
| "auth": ["token": authToken], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- repository-scoped review guidance ---'
find /tmp/coderabbit-repo-knowledge/rayl15-openvision-116f9120 -type f -path '*/conventions/*' -o -path '*/learnings/*' -o -path '*/architecture/*' | sort
printf '%s\n' '--- OpenClawService.swift relevant definitions ---'
cat -n OpenVision/Services/OpenClaw/OpenClawService.swift | sed -n '250,450p'Repository: rayl15/OpenVision Length of output: 8757 Sensitive Data Exposure (CWE-319): Cleartext Transmission of Sensitive Information Reachability: External · Exploitability: Moderate Require encrypted transport for LAN gateways. When 🤖 Prompt for AI Agents |
||
| "locale": "en-US", | ||
| "userAgent": "OpenVision/1.0.0" | ||
| ] | ||
|
|
||
| // A connection from another host is granted no write scope on a token | ||
| // alone, so sign a device identity with the challenge nonce. First run | ||
| // returns PAIRING_REQUIRED until the device is approved once on the | ||
| // gateway host with: openclaw devices approve <requestId> | ||
| if let nonce = await waitForChallengeNonce(), | ||
| let device = OpenClawDeviceIdentity.deviceParams( | ||
| clientId: "cli", clientMode: "cli", role: "operator", | ||
| scopes: ["operator.read", "operator.write"], | ||
| token: authToken, nonce: nonce) { | ||
| params["device"] = device | ||
| } else { | ||
| print("[OpenClaw] no challenge nonce — connecting without device identity") | ||
| } | ||
|
|
||
| let response = try await sendRequest(method: .connect, params: params) | ||
|
|
||
| guard response.ok else { | ||
|
|
@@ -683,7 +737,16 @@ final class OpenClawService: ObservableObject { | |
| print("[OpenClaw] Chat state: \(state)") | ||
| } | ||
|
|
||
| case "connect.challenge", "tick", "presence", "health": | ||
| case "connect.challenge": | ||
| // The nonce is required to sign the device identity; without it a | ||
| // remote connection gets no operator.write scope. | ||
| if let nonce = payload["nonce"]?.stringValue { | ||
| challengeNonce = nonce | ||
| challengeWaiter?.resume(returning: nonce) | ||
| challengeWaiter = nil | ||
| } | ||
|
|
||
| case "tick", "presence", "health": | ||
| // Ignore these events | ||
| break | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use the U.S. English form.
Change “afterwards” to “afterward”.
🧰 Tools
🪛 LanguageTool
[locale-violation] ~12-~12: In American English, ‘afterward’ is the preferred variant. ‘Afterwards’ is more commonly used in British English and other dialects.
Context: ...er launch succeeded and every reconnect afterwards failed - Added `NSLocalNetworkUsageDesc...
(AFTERWARDS_US)
🤖 Prompt for AI Agents
Source: Linters/SAST tools