feat(android): add Wear proxy protocol (#108835)

* feat(android): add Wear proxy protocol

Introduce a bounded, versioned phone/watch RPC contract and wire its test, lint, and build gates before either runtime endpoint lands.

Co-authored-by: Sebastian Schubotz <git@sibbl.de>

* test(android): lock Wear wire names

Cover every supported RPC method and event discriminator so phone and watch cannot silently drift.

Co-authored-by: Sebastian Schubotz <git@sibbl.net>

* fix(android): bound Wear JSON depth

Reject excessively nested Data Layer envelopes before kotlinx.serialization can recurse through them.

* fix(android): enforce Wear depth on encode

Keep outbound messages inside the same JSON nesting contract enforced by the decoder.

* fix(android): preflight Wear payload depth

Traverse arbitrary JSON payloads iteratively before serialization so deeply nested local trees fail safely.

---------

Co-authored-by: Sebastian Schubotz <git@sibbl.de>
Co-authored-by: Sebastian Schubotz <git@sibbl.net>
This commit is contained in:
Peter Steinberger
2026-07-16 10:21:38 -07:00
committed by GitHub
parent c11d112b63
commit 0815f6a4f9
9 changed files with 511 additions and 8 deletions
+10 -3
View File
@@ -2897,7 +2897,9 @@ jobs:
set -euo pipefail
case "$TASK" in
test-play)
./gradlew --no-daemon --build-cache :app:testPlayDebugUnitTest
./gradlew --no-daemon --build-cache \
:app:testPlayDebugUnitTest \
:wear-shared:testDebugUnitTest
;;
test-third-party)
./gradlew --no-daemon --build-cache :app:testThirdPartyDebugUnitTest
@@ -2908,7 +2910,9 @@ jobs:
:app:assembleThirdPartyDebug \
:app:lintPlayDebug \
:app:lintThirdPartyDebug \
:benchmark:assembleDebug
:benchmark:assembleDebug \
:wear-shared:assembleDebug \
:wear-shared:lintDebug
;;
build-play-compat)
# Frozen targets keep their target-owned Android build contract. New lint rules
@@ -2917,7 +2921,10 @@ jobs:
;;
ktlint)
# Mirrors `pnpm android:lint`; keeps formatting drift out of main (see PR #100304 sweep).
./gradlew --no-daemon --build-cache :app:ktlintCheck :benchmark:ktlintCheck
./gradlew --no-daemon --build-cache \
:app:ktlintCheck \
:benchmark:ktlintCheck \
:wear-shared:ktlintCheck
;;
*)
echo "Unsupported Android task: $TASK" >&2
+1
View File
@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.ktlint) apply false
alias(libs.plugins.kotlin.compose) apply false
+1
View File
@@ -80,6 +80,7 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
android-test = { id = "com.android.test", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+1
View File
@@ -17,3 +17,4 @@ dependencyResolutionManagement {
rootProject.name = "OpenClawNodeAndroid"
include(":app")
include(":benchmark")
include(":wear-shared")
+44
View File
@@ -0,0 +1,44 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ktlint)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "ai.openclaw.wear.shared"
compileSdk = 37
defaultConfig {
minSdk = 31
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
lint {
warningsAsErrors = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
ktlint {
android.set(true)
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
}
dependencies {
api(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,258 @@
package ai.openclaw.wear.shared
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import java.nio.charset.CharacterCodingException
object WearProtocol {
const val VERSION = 1
const val REQUEST_PATH = "/openclaw/wear/v1/request"
const val RESPONSE_PATH = "/openclaw/wear/v1/response"
const val EVENT_PATH = "/openclaw/wear/v1/event"
// MessageClient has a 100 KiB ceiling. Keep headroom for transport metadata and
// force transcript pagination instead of depending on an edge-sized message.
const val MAX_MESSAGE_BYTES = 64 * 1024
// Bound recursive JSON parsing at the untrusted Data Layer boundary.
const val MAX_JSON_DEPTH = 32
}
@Serializable
enum class WearRpcMethod {
@SerialName("proxy.status")
ProxyStatus,
@SerialName("sessions.list")
SessionsList,
@SerialName("chat.history")
ChatHistory,
@SerialName("chat.send")
ChatSend,
@SerialName("chat.abort")
ChatAbort,
}
@Serializable
enum class WearEventType {
@SerialName("chat")
Chat,
@SerialName("connection")
Connection,
}
@Serializable
sealed interface WearMessage {
val version: Int
@Serializable
@SerialName("request")
data class Request(
override val version: Int = WearProtocol.VERSION,
val requestId: String,
val method: WearRpcMethod,
val params: JsonObject = buildJsonObject {},
) : WearMessage
@Serializable
@SerialName("response")
data class Response(
override val version: Int = WearProtocol.VERSION,
val requestId: String,
val ok: Boolean,
val result: JsonElement? = null,
val error: WearRpcError? = null,
) : WearMessage
@Serializable
@SerialName("event")
data class Event(
override val version: Int = WearProtocol.VERSION,
val sequence: Long,
val event: WearEventType,
val payload: JsonElement? = null,
) : WearMessage
}
@Serializable
data class WearRpcError(
val code: String,
val message: String,
)
enum class WearDecodeFailureReason {
Empty,
TooLarge,
TooDeep,
Malformed,
UnsupportedVersion,
InvalidEnvelope,
}
sealed interface WearDecodeResult {
data class Success(
val message: WearMessage,
) : WearDecodeResult
data class Failure(
val reason: WearDecodeFailureReason,
) : WearDecodeResult
}
object WearProtocolCodec {
private val json =
Json {
classDiscriminator = "type"
encodeDefaults = true
explicitNulls = false
ignoreUnknownKeys = true
}
fun encode(message: WearMessage): ByteArray {
requireValid(message)
require(hasValidPayloadDepth(message)) {
"Wear message exceeds JSON depth ${WearProtocol.MAX_JSON_DEPTH}"
}
val encoded = json.encodeToString(WearMessage.serializer(), message)
require(!exceedsJsonDepth(encoded)) {
"Wear message exceeds JSON depth ${WearProtocol.MAX_JSON_DEPTH}"
}
val bytes =
encoded.encodeToByteArray(throwOnInvalidSequence = true)
require(bytes.size <= WearProtocol.MAX_MESSAGE_BYTES) {
"Wear message exceeds ${WearProtocol.MAX_MESSAGE_BYTES} bytes"
}
return bytes
}
private fun hasValidPayloadDepth(message: WearMessage): Boolean {
val payloads =
when (message) {
is WearMessage.Request -> listOf(message.params)
is WearMessage.Response -> listOfNotNull(message.result)
is WearMessage.Event -> listOfNotNull(message.payload)
}
return payloads.all { element -> hasValidElementDepth(element, parentDepth = 1) }
}
private fun hasValidElementDepth(
element: JsonElement,
parentDepth: Int,
): Boolean {
val pending = ArrayDeque<Pair<JsonElement, Int>>()
pending.addLast(element to parentDepth)
while (pending.isNotEmpty()) {
val (current, parent) = pending.removeLast()
val children =
when (current) {
is JsonArray -> current
is JsonObject -> current.values
else -> continue
}
val depth = parent + 1
if (depth > WearProtocol.MAX_JSON_DEPTH) return false
children.forEach { child -> pending.addLast(child to depth) }
}
return true
}
fun decode(bytes: ByteArray): WearDecodeResult {
if (bytes.isEmpty()) return WearDecodeResult.Failure(WearDecodeFailureReason.Empty)
if (bytes.size > WearProtocol.MAX_MESSAGE_BYTES) {
return WearDecodeResult.Failure(WearDecodeFailureReason.TooLarge)
}
val text =
try {
bytes.decodeToString(throwOnInvalidSequence = true)
} catch (_: CharacterCodingException) {
return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed)
}
if (exceedsJsonDepth(text)) {
return WearDecodeResult.Failure(WearDecodeFailureReason.TooDeep)
}
val root =
try {
json.parseToJsonElement(text).jsonObject
} catch (_: SerializationException) {
return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed)
} catch (_: IllegalArgumentException) {
return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed)
}
val version =
(root["version"] as? JsonPrimitive)?.intOrNull
?: return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed)
if (version != WearProtocol.VERSION) {
return WearDecodeResult.Failure(WearDecodeFailureReason.UnsupportedVersion)
}
val message =
try {
json.decodeFromJsonElement(WearMessage.serializer(), root)
} catch (_: SerializationException) {
return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed)
} catch (_: IllegalArgumentException) {
return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed)
}
if (!isValid(message)) {
return WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope)
}
return WearDecodeResult.Success(message)
}
private fun exceedsJsonDepth(text: String): Boolean {
var depth = 0
var inString = false
var escaped = false
for (character in text) {
if (inString) {
when {
escaped -> escaped = false
character == '\\' -> escaped = true
character == '"' -> inString = false
}
continue
}
when (character) {
'"' -> inString = true
'{', '[' -> {
depth += 1
if (depth > WearProtocol.MAX_JSON_DEPTH) return true
}
'}', ']' -> depth -= 1
}
}
return false
}
private fun requireValid(message: WearMessage) {
require(message.version == WearProtocol.VERSION) { "Unsupported Wear protocol version: ${message.version}" }
require(isValid(message)) { "Invalid Wear protocol envelope" }
}
private fun isValid(message: WearMessage): Boolean =
when (message) {
is WearMessage.Request -> message.requestId.isNotBlank()
is WearMessage.Response ->
message.requestId.isNotBlank() &&
if (message.ok) {
message.error == null
} else {
message.error != null && message.result == null && message.error.code.isNotBlank()
}
is WearMessage.Event -> message.sequence >= 0
}
}
@@ -0,0 +1,190 @@
package ai.openclaw.wear.shared
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class WearProtocolTest {
@Test
fun roundTripsEveryEnvelopeKind() {
val messages =
listOf(
WearMessage.Request(
requestId = "req-1",
method = WearRpcMethod.ChatHistory,
params = buildJsonObject { put("sessionKey", "main") },
),
WearMessage.Response(
requestId = "req-1",
ok = true,
result = buildJsonObject { put("count", 2) },
),
WearMessage.Response(
requestId = "req-2",
ok = false,
error = WearRpcError(code = "unavailable", message = "Phone offline"),
),
WearMessage.Event(
sequence = 7,
event = WearEventType.Chat,
payload = buildJsonObject { put("state", "delta") },
),
)
messages.forEach { message ->
assertEquals(WearDecodeResult.Success(message), WearProtocolCodec.decode(WearProtocolCodec.encode(message)))
}
}
@Test
fun usesStableWireNamesAndPaths() {
val methodNames =
mapOf(
WearRpcMethod.ProxyStatus to "proxy.status",
WearRpcMethod.SessionsList to "sessions.list",
WearRpcMethod.ChatHistory to "chat.history",
WearRpcMethod.ChatSend to "chat.send",
WearRpcMethod.ChatAbort to "chat.abort",
)
methodNames.forEach { (method, wireName) ->
val request = WearMessage.Request(requestId = "req-1", method = method)
val root = Json.parseToJsonElement(WearProtocolCodec.encode(request).decodeToString()).jsonObject
assertEquals("request", root.getValue("type").jsonPrimitive.content)
assertEquals(wireName, root.getValue("method").jsonPrimitive.content)
}
val eventNames =
mapOf(
WearEventType.Chat to "chat",
WearEventType.Connection to "connection",
)
eventNames.forEach { (event, wireName) ->
val message = WearMessage.Event(sequence = 1, event = event)
val root = Json.parseToJsonElement(WearProtocolCodec.encode(message).decodeToString()).jsonObject
assertEquals("event", root.getValue("type").jsonPrimitive.content)
assertEquals(wireName, root.getValue("event").jsonPrimitive.content)
}
assertEquals("/openclaw/wear/v1/request", WearProtocol.REQUEST_PATH)
assertEquals("/openclaw/wear/v1/response", WearProtocol.RESPONSE_PATH)
assertEquals("/openclaw/wear/v1/event", WearProtocol.EVENT_PATH)
}
@Test
fun ignoresUnknownFieldsWithinCurrentVersion() {
val bytes =
"""{"type":"request","version":1,"requestId":"req-1","method":"proxy.status","params":{},"future":true}"""
.encodeToByteArray()
assertEquals(
WearDecodeResult.Success(
WearMessage.Request(requestId = "req-1", method = WearRpcMethod.ProxyStatus),
),
WearProtocolCodec.decode(bytes),
)
}
@Test
fun rejectsMalformedUnsupportedAndInvalidMessages() {
assertEquals(
WearDecodeResult.Failure(WearDecodeFailureReason.Empty),
WearProtocolCodec.decode(byteArrayOf()),
)
assertEquals(
WearDecodeResult.Failure(WearDecodeFailureReason.Malformed),
WearProtocolCodec.decode("not-json".encodeToByteArray()),
)
val invalidUtf8 =
"""{"type":"request","version":1,"requestId":"""".encodeToByteArray() +
byteArrayOf(0xc3.toByte(), 0x28) +
"""","method":"proxy.status","params":{}}""".encodeToByteArray()
assertEquals(
WearDecodeResult.Failure(WearDecodeFailureReason.Malformed),
WearProtocolCodec.decode(invalidUtf8),
)
assertEquals(
WearDecodeResult.Failure(WearDecodeFailureReason.UnsupportedVersion),
WearProtocolCodec.decode(
"""{"type":"future-message","version":2,"futureRequiredField":true}"""
.encodeToByteArray(),
),
)
assertEquals(
WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope),
WearProtocolCodec.decode(
"""{"type":"response","version":1,"requestId":"req-1","ok":false}""".encodeToByteArray(),
),
)
}
@Test
fun rejectsOversizedMessagesOnEncodeAndDecode() {
val oversizedBytes = ByteArray(WearProtocol.MAX_MESSAGE_BYTES + 1)
assertEquals(
WearDecodeResult.Failure(WearDecodeFailureReason.TooLarge),
WearProtocolCodec.decode(oversizedBytes),
)
val oversizedMessage =
WearMessage.Request(
requestId = "req-1",
method = WearRpcMethod.ChatSend,
params = buildJsonObject { put("message", "x".repeat(WearProtocol.MAX_MESSAGE_BYTES)) },
)
assertThrows(IllegalArgumentException::class.java) {
WearProtocolCodec.encode(oversizedMessage)
}
}
@Test
fun rejectsExcessiveJsonDepthBeforeParsing() {
val nesting = WearProtocol.MAX_JSON_DEPTH + 1
val deeplyNested =
"""{"type":"request","version":1,"requestId":"req-1","method":"chat.send","params":{"payload":${"[".repeat(nesting)}0${"]".repeat(nesting)}}}"""
assertEquals(
WearDecodeResult.Failure(WearDecodeFailureReason.TooDeep),
WearProtocolCodec.decode(deeplyNested.encodeToByteArray()),
)
val bracketsInString =
WearMessage.Request(
requestId = "req-2",
method = WearRpcMethod.ChatSend,
params = buildJsonObject { put("message", "[".repeat(WearProtocol.MAX_JSON_DEPTH + 1)) },
)
assertEquals(
WearDecodeResult.Success(bracketsInString),
WearProtocolCodec.decode(WearProtocolCodec.encode(bracketsInString)),
)
var nestedPayload: JsonElement = JsonPrimitive(0)
repeat(4_096) {
nestedPayload = JsonArray(listOf(nestedPayload))
}
val deeplyNestedMessage =
WearMessage.Request(
requestId = "req-3",
method = WearRpcMethod.ChatSend,
params = buildJsonObject { put("payload", nestedPayload) },
)
assertThrows(IllegalArgumentException::class.java) {
WearProtocolCodec.encode(deeplyNestedMessage)
}
}
@Test
fun encodingIsDeterministic() {
val message = WearMessage.Request(requestId = "req-1", method = WearRpcMethod.SessionsList)
assertArrayEquals(WearProtocolCodec.encode(message), WearProtocolCodec.encode(message))
}
}
+5 -5
View File
@@ -1505,14 +1505,14 @@
"./cli-entry": "./openclaw.mjs"
},
"scripts": {
"android:assemble": "node scripts/run-android-gradle.mjs :app:assemblePlayDebug",
"android:assemble": "node scripts/run-android-gradle.mjs :app:assemblePlayDebug :wear-shared:assembleDebug",
"android:assemble:third-party": "node scripts/run-android-gradle.mjs :app:assembleThirdPartyDebug",
"android:bundle:release": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android play_store_archive'",
"android:format": "cd apps/android && ./gradlew :app:ktlintFormat :benchmark:ktlintFormat",
"android:format": "cd apps/android && ./gradlew :app:ktlintFormat :benchmark:ktlintFormat :wear-shared:ktlintFormat",
"android:install": "node scripts/run-android-gradle.mjs :app:installPlayDebug",
"android:install:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug",
"android:lint": "cd apps/android && ./gradlew :app:ktlintCheck :benchmark:ktlintCheck",
"android:lint:android": "node scripts/run-android-gradle.mjs :app:lintPlayDebug :app:lintThirdPartyDebug",
"android:lint": "cd apps/android && ./gradlew :app:ktlintCheck :benchmark:ktlintCheck :wear-shared:ktlintCheck",
"android:lint:android": "node scripts/run-android-gradle.mjs :app:lintPlayDebug :app:lintThirdPartyDebug :wear-shared:lintDebug",
"android:run": "node scripts/run-android-gradle.mjs :app:installPlayDebug -- adb shell am start -n ai.openclaw.app/.MainActivity",
"android:run:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug -- adb shell am start -n ai.openclaw.app/.MainActivity",
"android:release": "bash scripts/android-release.sh",
@@ -1526,7 +1526,7 @@
"android:release:signing:sync:push": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android signing_sync_push'",
"android:release:upload": "bash scripts/android-release-upload.sh",
"android:screenshots": "bash scripts/android-screenshots.sh",
"android:test": "node scripts/run-android-gradle.mjs :app:testPlayDebugUnitTest",
"android:test": "node scripts/run-android-gradle.mjs :app:testPlayDebugUnitTest :wear-shared:testDebugUnitTest",
"android:test:integration": "node scripts/run-with-env.mjs OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
"android:test:third-party": "node scripts/run-android-gradle.mjs :app:testThirdPartyDebugUnitTest",
"android:version": "node --import tsx scripts/android-version.ts --json",