diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 885d87fcb..e00b659de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,10 @@ jobs: pnpm -v pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + - name: Prebuild A2UI bundle (bun tests) + if: ${{ matrix.runtime == 'bun' && matrix.task == 'test' }} + run: pnpm canvas:a2ui:bundle + - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) run: ${{ matrix.command }} diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 31323942e..16c495ec1 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -98,6 +98,9 @@ dependencies { // Material Components (XML theme + resources) implementation("com.google.android.material:material:1.13.0") + // Crypto provider for Ed25519 on Android (some OEM builds lack reliable support). + implementation("org.bouncycastle:bcprov-jdk15to18:1.80") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt index 0868fcb79..da4d29fe0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt @@ -35,6 +35,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val instanceId: StateFlow = runtime.instanceId val displayName: StateFlow = runtime.displayName + val deviceIdentityStatusText: StateFlow = runtime.deviceIdentityStatusText val cameraEnabled: StateFlow = runtime.cameraEnabled val locationMode: StateFlow = runtime.locationMode val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled @@ -51,6 +52,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val manualHost: StateFlow = runtime.manualHost val manualPort: StateFlow = runtime.manualPort val manualTls: StateFlow = runtime.manualTls + + val gatewayToken: StateFlow = runtime.gatewayToken + val gatewayPassword: StateFlow = runtime.gatewayPassword val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled val chatSessionKey: StateFlow = runtime.chatSessionKey @@ -104,6 +108,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setManualTls(value) } + fun setGatewayToken(value: String) { + runtime.setGatewayToken(value) + } + + fun setGatewayPassword(value: String) { + runtime.setGatewayPassword(value) + } + fun setCanvasDebugStatusEnabled(value: Boolean) { runtime.setCanvasDebugStatusEnabled(value) } @@ -140,6 +152,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.disconnect() } + fun requestNodePairing() { + runtime.requestNodePairing() + } + + fun resetDeviceIdentity() { + runtime.resetDeviceIdentity() + } + fun handleCanvasA2UIActionFromWebView(payloadJson: String) { runtime.handleCanvasA2UIActionFromWebView(payloadJson) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt index ab5e159cf..96b18ef00 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt @@ -2,12 +2,23 @@ package ai.openclaw.android import android.app.Application import android.os.StrictMode +import java.security.Security +import org.bouncycastle.jce.provider.BouncyCastleProvider class NodeApp : Application() { val runtime: NodeRuntime by lazy { NodeRuntime(this) } override fun onCreate() { super.onCreate() + + // Ensure a working provider for Ed25519/EdDSA. Some Android builds ship broken or missing support. + try { + Security.removeProvider("BC") + Security.insertProviderAt(BouncyCastleProvider(), 1) + } catch (_: Throwable) { + // best-effort + } + if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy.Builder() diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index e6ceae598..381ea3155 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -113,6 +113,15 @@ class NodeRuntime(context: Context) { private val identityStore = DeviceIdentityStore(appContext) + fun resetDeviceIdentity() { + identityStore.resetIdentity() + _deviceIdentityStatusText.value = computeDeviceIdentityStatusText() + refreshGatewayConnection() + } + + private val _deviceIdentityStatusText = MutableStateFlow(computeDeviceIdentityStatusText()) + val deviceIdentityStatusText: StateFlow = _deviceIdentityStatusText.asStateFlow() + private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() @@ -170,8 +179,11 @@ class NodeRuntime(context: Context) { onDisconnected = { message -> operatorConnected = false operatorStatusText = message - _serverName.value = null - _remoteAddress.value = null + // If the node session is still connected, keep server/remote around so Settings stays usable. + if (!nodeConnected) { + _serverName.value = null + _remoteAddress.value = null + } _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { _mainSessionKey.value = "main" @@ -192,15 +204,22 @@ class NodeRuntime(context: Context) { scope = scope, identityStore = identityStore, deviceAuthStore = deviceAuthStore, - onConnected = { _, _, _ -> + onConnected = { name, remote, _ -> nodeConnected = true nodeStatusText = "Connected" + // When operator auth is failing, node can still connect; keep UI usable. + if (_serverName.value == null) _serverName.value = name + if (_remoteAddress.value == null) _remoteAddress.value = remote updateStatus() maybeNavigateToA2uiOnConnect() }, onDisconnected = { message -> nodeConnected = false nodeStatusText = message + if (!operatorConnected) { + _serverName.value = null + _remoteAddress.value = null + } updateStatus() showLocalCanvasOnDisconnect() }, @@ -241,7 +260,7 @@ class NodeRuntime(context: Context) { } private fun updateStatus() { - _isConnected.value = operatorConnected + _isConnected.value = operatorConnected || nodeConnected _statusText.value = when { operatorConnected && nodeConnected -> "Connected" @@ -284,6 +303,9 @@ class NodeRuntime(context: Context) { val manualHost: StateFlow = prefs.manualHost val manualPort: StateFlow = prefs.manualPort val manualTls: StateFlow = prefs.manualTls + + val gatewayToken: StateFlow = prefs.gatewayToken + val gatewayPassword: StateFlow = prefs.gatewayPassword val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled @@ -428,6 +450,14 @@ class NodeRuntime(context: Context) { prefs.setManualTls(value) } + fun setGatewayToken(value: String) { + prefs.setGatewayToken(value) + } + + fun setGatewayPassword(value: String) { + prefs.setGatewayPassword(value) + } + fun setCanvasDebugStatusEnabled(value: Boolean) { prefs.setCanvasDebugStatusEnabled(value) } @@ -529,25 +559,52 @@ class NodeRuntime(context: Context) { caps = buildCapabilities(), commands = buildInvokeCommands(), permissions = emptyMap(), - client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"), + client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"), userAgent = buildUserAgent(), ) } + private fun computeDeviceIdentityStatusText(): String { + return try { + val identity = identityStore.loadOrCreate() + val publicKey = identityStore.publicKeyBase64Url(identity)?.trim().orEmpty() + val signature = identityStore.signPayload("clawdbot:identity-probe", identity)?.trim().orEmpty() + + // Fallback identity uses a placeholder public key (Base64 of a single 0 byte is "AA=="). + val placeholder = identity.publicKeyRawBase64.trim() == "AA==" || identity.privateKeyPkcs8Base64.trim() == "AA==" + + when { + placeholder -> "Missing (crypto fallback)" + publicKey.isEmpty() -> "Missing (no public key)" + signature.isEmpty() -> "Missing (cannot sign)" + else -> "OK" + } + } catch (err: Throwable) { + "Missing (${err::class.java.simpleName})" + } + } + private fun buildOperatorConnectOptions(): GatewayConnectOptions { + // IMPORTANT: + // Do NOT use the CONTROL_UI client id here. The gateway enforces extra "secure context" + // rules for the Control UI (HTTPS/localhost), and our Android operator session is a native + // client over WS/WSS. return GatewayConnectOptions( role = "operator", scopes = emptyList(), caps = emptyList(), commands = emptyList(), permissions = emptyMap(), - client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"), + // Use a known client id/mode so the gateway accepts the handshake. + // Avoid CONTROL_UI (web control panel) which enforces secure-context rules. + client = buildClientInfo(clientId = "clawdbot-android", clientMode = "ui"), userAgent = buildUserAgent(), ) } fun refreshGatewayConnection() { val endpoint = connectedEndpoint ?: return + _deviceIdentityStatusText.value = computeDeviceIdentityStatusText() val token = prefs.loadGatewayToken() val password = prefs.loadGatewayPassword() val tls = resolveTlsParams(endpoint) @@ -562,6 +619,7 @@ class NodeRuntime(context: Context) { operatorStatusText = "Connecting…" nodeStatusText = "Connecting…" updateStatus() + _deviceIdentityStatusText.value = computeDeviceIdentityStatusText() val token = prefs.loadGatewayToken() val password = prefs.loadGatewayPassword() val tls = resolveTlsParams(endpoint) @@ -613,6 +671,52 @@ class NodeRuntime(context: Context) { nodeSession.disconnect() } + fun requestNodePairing() { + val endpoint = connectedEndpoint + if (!_isConnected.value || endpoint == null) { + _statusText.value = "Pairing failed: not connected" + return + } + + scope.launch { + try { + val params = + buildJsonObject { + // IMPORTANT: Pair the actual nodeId used by the gateway for this device. + // With device identity enabled, this will be the deviceId (sha256 of public key). + val nodeId = identityStore.loadOrCreate().deviceId + put("nodeId", JsonPrimitive(nodeId)) + put("displayName", JsonPrimitive(displayName.value)) + put("platform", JsonPrimitive("android")) + put("version", JsonPrimitive(resolvedVersionName())) + put("deviceFamily", JsonPrimitive("Android")) + resolveModelIdentifier()?.let { put("modelIdentifier", JsonPrimitive(it)) } + put("caps", JsonArray(buildCapabilities().map(::JsonPrimitive))) + put("commands", JsonArray(buildInvokeCommands().map(::JsonPrimitive))) + put("silent", JsonPrimitive(false)) + } + + val res = operatorSession.request("node.pair.request", params.toString(), timeoutMs = 15_000) + val requestId = + try { + val obj = json.parseToJsonElement(res).asObjectOrNull() + obj?.get("request")?.asObjectOrNull()?.get("requestId")?.asStringOrNull() + } catch (_: Throwable) { + null + } + + _statusText.value = + if (requestId.isNullOrBlank()) { + "Pairing requested" + } else { + "Pairing requested: $requestId" + } + } catch (err: Throwable) { + _statusText.value = "Pairing failed: ${err.message ?: err::class.java.simpleName}" + } + } + } + private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? { val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() @@ -667,51 +771,123 @@ class NodeRuntime(context: Context) { } val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch - val surfaceId = - (userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" } - val sourceComponentId = - (userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } - val contextJson = (userActionObj["context"] as? JsonObject)?.toString() + // Extract context as key/value map (supports literal* values). + fun contextString(key: String): String? { + val ctx = userActionObj["context"] as? JsonObject ?: return null + val entry = ctx[key] as? JsonPrimitive ?: return null + return entry.content.trim().takeIf { it.isNotEmpty() } + } - val sessionKey = resolveMainSessionKey() - val message = - OpenClawCanvasA2UIAction.formatAgentMessage( - actionName = name, - sessionKey = sessionKey, - surfaceId = surfaceId, - sourceComponentId = sourceComponentId, - host = displayName.value, - instanceId = instanceId.value.lowercase(), - contextJson = contextJson, - ) + fun contextNumber(key: String): Double? { + val ctx = userActionObj["context"] as? JsonObject ?: return null + val entry = ctx[key] as? JsonPrimitive ?: return null + return entry.content.toDoubleOrNull() + } - val connected = nodeConnected + val nodeId = contextString("nodeId") + + var ok = false var error: String? = null - if (connected) { - try { - nodeSession.sendNodeEvent( - event = "agent.request", - payloadJson = + + // If the action name matches a known node command, invoke it directly via the operator session. + // (Do not require operatorConnected here; if it's offline we'll return a visible error.) + val (command, params) = + when (name) { + "camera.snap" -> "camera.snap" to buildJsonObject { } + "camera.clip" -> { + val durationSec = (contextNumber("durationSec") ?: 5.0).toLong().coerceIn(1, 60) + "camera.clip" to buildJsonObject { - put("message", JsonPrimitive(message)) - put("sessionKey", JsonPrimitive(sessionKey)) - put("thinking", JsonPrimitive("low")) - put("deliver", JsonPrimitive(false)) - put("key", JsonPrimitive(actionId)) - }.toString(), - ) - } catch (e: Throwable) { - error = e.message ?: "send failed" + put("durationMs", JsonPrimitive(durationSec * 1000)) + put("includeAudio", JsonPrimitive(true)) + } + } + "screen.record" -> { + val durationSec = (contextNumber("durationSec") ?: 15.0).toLong().coerceIn(1, 180) + "screen.record" to + buildJsonObject { + put("durationMs", JsonPrimitive(durationSec * 1000)) + put("includeAudio", JsonPrimitive(false)) + } + } + "location.get" -> + "location.get" to + buildJsonObject { + put("timeoutMs", JsonPrimitive(10_000)) + put("desiredAccuracy", JsonPrimitive("balanced")) + } + else -> null + } ?: (null to null) + + val isDirectNodeCommand = command != null && params != null + if (isDirectNodeCommand) { + if (nodeId == null) { + error = "missing nodeId in action context" + } else { + try { + val invokeParams = + buildJsonObject { + put("nodeId", JsonPrimitive(nodeId)) + put("command", JsonPrimitive(command!!)) + put("params", params!!) + put("timeoutMs", JsonPrimitive(30_000)) + put("idempotencyKey", JsonPrimitive(actionId)) + } + operatorSession.request("node.invoke", invokeParams.toString(), timeoutMs = 35_000) + ok = true + } catch (e: Throwable) { + error = e.message ?: "invoke failed" + } + } + } + + // Fall back to agent request only for unknown action names. + if (!ok && error == null && !isDirectNodeCommand) { + val surfaceId = + (userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" } + val sourceComponentId = + (userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } + val contextJson = (userActionObj["context"] as? JsonObject)?.toString() + + val sessionKey = resolveMainSessionKey() + val message = + OpenClawCanvasA2UIAction.formatAgentMessage( + actionName = name, + sessionKey = sessionKey, + surfaceId = surfaceId, + sourceComponentId = sourceComponentId, + host = displayName.value, + instanceId = instanceId.value.lowercase(), + contextJson = contextJson, + ) + + if (nodeConnected) { + try { + nodeSession.sendNodeEvent( + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(message)) + put("sessionKey", JsonPrimitive(sessionKey)) + put("thinking", JsonPrimitive("low")) + put("deliver", JsonPrimitive(false)) + put("key", JsonPrimitive(actionId)) + }.toString(), + ) + ok = true + } catch (e: Throwable) { + error = e.message ?: "send failed" + } + } else { + error = "gateway not connected" } - } else { - error = "gateway not connected" } try { canvas.eval( OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus( actionId = actionId, - ok = connected && error == null, + ok = ok && error == null, error = error, ), ) @@ -1115,7 +1291,7 @@ class NodeRuntime(context: Context) { val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw if (raw.isBlank()) return null val base = raw.trimEnd('/') - return "${base}/__openclaw__/a2ui/?platform=android" + return "${base}/__clawdbot__/a2ui/?platform=android" } private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean { @@ -1207,8 +1383,7 @@ private const val a2uiReadyCheckJS: String = """ (() => { try { - const host = globalThis.openclawA2UI; - return !!host && typeof host.applyMessages === 'function'; + return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function'; } catch (_) { return false; } @@ -1219,9 +1394,8 @@ private const val a2uiResetJS: String = """ (() => { try { - const host = globalThis.openclawA2UI; - if (!host) return { ok: false, error: "missing openclawA2UI" }; - return host.reset(); + if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" }; + return globalThis.clawdbotA2UI.reset(); } catch (e) { return { ok: false, error: String(e?.message ?? e) }; } @@ -1232,10 +1406,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String { return """ (() => { try { - const host = globalThis.openclawA2UI; - if (!host) return { ok: false, error: "missing openclawA2UI" }; + if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" }; const messages = $messagesJson; - return host.applyMessages(messages); + return globalThis.clawdbotA2UI.applyMessages(messages); } catch (e) { return { ok: false, error: String(e?.message ?? e) }; } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt index 881d724fd..775800858 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt @@ -71,6 +71,16 @@ class SecurePrefs(context: Context) { MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true)) val manualTls: StateFlow = _manualTls + // Gateway auth (used for both discovery + manual connects) + private val _gatewayToken = MutableStateFlow(loadGatewayToken().orEmpty()) + val gatewayToken: StateFlow = _gatewayToken + + private val _gatewayPassword = MutableStateFlow(loadGatewayPassword().orEmpty()) + val gatewayPassword: StateFlow = _gatewayPassword + + // NOTE: node pairing tokens are not valid gateway connect auth tokens. + // Keep node pairing state on the gateway; do not store a separate node token here. + private val _lastDiscoveredStableId = MutableStateFlow( prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", @@ -143,6 +153,26 @@ class SecurePrefs(context: Context) { _manualTls.value = value } + fun setGatewayToken(value: String) { + val trimmed = value.trim() + val key = "gateway.token.${_instanceId.value}" + prefs.edit { + if (trimmed.isEmpty()) remove(key) else putString(key, trimmed) + } + _gatewayToken.value = trimmed + } + + fun setGatewayPassword(value: String) { + val trimmed = value.trim() + val key = "gateway.password.${_instanceId.value}" + prefs.edit { + if (trimmed.isEmpty()) remove(key) else putString(key, trimmed) + } + _gatewayPassword.value = trimmed + } + + // node token setter removed (see note above) + fun setCanvasDebugStatusEnabled(value: Boolean) { prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } _canvasDebugStatusEnabled.value = value @@ -157,6 +187,7 @@ class SecurePrefs(context: Context) { fun saveGatewayToken(token: String) { val key = "gateway.token.${_instanceId.value}" prefs.edit { putString(key, token.trim()) } + _gatewayToken.value = token.trim() } fun loadGatewayPassword(): String? { @@ -168,8 +199,11 @@ class SecurePrefs(context: Context) { fun saveGatewayPassword(password: String) { val key = "gateway.password.${_instanceId.value}" prefs.edit { putString(key, password.trim()) } + _gatewayPassword.value = password.trim() } + // node token loader removed (see note above) + fun loadGatewayTlsFingerprint(stableId: String): String? { val key = "gateway.tls.$stableId" return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt index accbb79e4..ebfdfd68a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt @@ -7,7 +7,9 @@ import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.MessageDigest import java.security.Signature +import java.security.Security import java.security.spec.PKCS8EncodedKeySpec +import java.util.UUID import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -23,6 +25,14 @@ class DeviceIdentityStore(context: Context) { private val json = Json { ignoreUnknownKeys = true } private val identityFile = File(context.filesDir, "openclaw/identity/device.json") + fun resetIdentity() { + try { + if (identityFile.exists()) identityFile.delete() + } catch (_: Throwable) { + // best-effort + } + } + @Synchronized fun loadOrCreate(): DeviceIdentity { val existing = load() @@ -44,9 +54,11 @@ class DeviceIdentityStore(context: Context) { return try { val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT) val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) - val keyFactory = KeyFactory.getInstance("Ed25519") + val keyFactory = runCatching { KeyFactory.getInstance("Ed25519", "BC") }.getOrNull() + ?: KeyFactory.getInstance("Ed25519") val privateKey = keyFactory.generatePrivate(keySpec) - val signature = Signature.getInstance("Ed25519") + val signature = runCatching { Signature.getInstance("Ed25519", "BC") }.getOrNull() + ?: Signature.getInstance("Ed25519") signature.initSign(privateKey) signature.update(payload.toByteArray(Charsets.UTF_8)) base64UrlEncode(signature.sign()) @@ -97,17 +109,33 @@ class DeviceIdentityStore(context: Context) { } private fun generate(): DeviceIdentity { - val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() - val spki = keyPair.public.encoded - val rawPublic = stripSpkiPrefix(spki) - val deviceId = sha256Hex(rawPublic) - val privateKey = keyPair.private.encoded - return DeviceIdentity( - deviceId = deviceId, - publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP), - privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP), - createdAtMs = System.currentTimeMillis(), - ) + // Some Android builds/devices can be missing Ed25519 support at runtime (or have + // broken providers). The gateway can still accept a token-only connection, so + // we fall back to a "no-signature" identity rather than crashing. + return try { + // Prefer BC provider when available. + val kpg = runCatching { KeyPairGenerator.getInstance("Ed25519", "BC") }.getOrNull() + ?: KeyPairGenerator.getInstance("Ed25519") + val keyPair = kpg.generateKeyPair() + val spki = keyPair.public.encoded + val rawPublic = stripSpkiPrefix(spki) + val deviceId = sha256Hex(rawPublic) + val privateKey = keyPair.private.encoded + DeviceIdentity( + deviceId = deviceId, + publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP), + privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP), + createdAtMs = System.currentTimeMillis(), + ) + } catch (_: Throwable) { + val placeholder = Base64.encodeToString(byteArrayOf(0), Base64.NO_WRAP) + DeviceIdentity( + deviceId = UUID.randomUUID().toString(), + publicKeyRawBase64 = placeholder, + privateKeyPkcs8Base64 = placeholder, + createdAtMs = System.currentTimeMillis(), + ) + } } private fun deriveDeviceId(publicKeyRawBase64: String): String? { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt index c46770a63..7cc750027 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt @@ -68,6 +68,7 @@ class CanvasController { fun onPageFinished() { applyDebugStatus() + injectA2uiActionBridgeCompat() } private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { @@ -122,6 +123,37 @@ class CanvasController { } } + // Some WebView/JS-bridge combos don't reliably preserve `handler === globalThis.clawdbotCanvasA2UIAction`, + // which makes the A2UI bundle try to post an object instead of a string. Android JS interfaces only + // accept primitives, so clicks appear to do nothing. This shim forces string payloads. + private fun injectA2uiActionBridgeCompat() { + withWebViewOnMain { wv -> + val js = + """ + (() => { + try { + const native = globalThis.clawdbotCanvasA2UIAction; + if (!native || typeof native.postMessage !== 'function') return; + if (native.__clawdbotWrapped) return; + const wrapper = { + __clawdbotWrapped: true, + postMessage: (payload) => { + try { + if (typeof payload === 'string') return native.postMessage(payload); + return native.postMessage(JSON.stringify(payload)); + } catch (e) { + try { native.postMessage(String(payload)); } catch (_) {} + } + } + }; + globalThis.clawdbotCanvasA2UIAction = wrapper; + } catch (_) {} + })(); + """.trimIndent() + wv.evaluateJavascript(js, null) + } + } + suspend fun eval(javaScript: String): String = withContext(Dispatchers.Main) { val wv = webView ?: throw IllegalStateException("no webview") diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt index fa32f7bb8..d2452406e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt @@ -70,6 +70,7 @@ fun SettingsSheet(viewModel: MainViewModel) { val context = LocalContext.current val instanceId by viewModel.instanceId.collectAsState() val displayName by viewModel.displayName.collectAsState() + val deviceIdentityStatusText by viewModel.deviceIdentityStatusText.collectAsState() val cameraEnabled by viewModel.cameraEnabled.collectAsState() val locationMode by viewModel.locationMode.collectAsState() val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() @@ -82,6 +83,8 @@ fun SettingsSheet(viewModel: MainViewModel) { val manualHost by viewModel.manualHost.collectAsState() val manualPort by viewModel.manualPort.collectAsState() val manualTls by viewModel.manualTls.collectAsState() + val gatewayToken by viewModel.gatewayToken.collectAsState() + val gatewayPassword by viewModel.gatewayPassword.collectAsState() val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() val statusText by viewModel.statusText.collectAsState() val serverName by viewModel.serverName.collectAsState() @@ -277,6 +280,8 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) } + item { Text("Identity: $deviceIdentityStatusText", color = MaterialTheme.colorScheme.onSurfaceVariant) } + item { Button(onClick = viewModel::resetDeviceIdentity) { Text("Reset identity") } } item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) } item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -292,15 +297,26 @@ fun SettingsSheet(viewModel: MainViewModel) { item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) } } item { - // UI sanity: "Disconnect" only when we have an active remote. - if (isConnected && remoteAddress != null) { - Button( - onClick = { - viewModel.disconnect() - NodeForegroundService.stop(context) - }, - ) { - Text("Disconnect") + // Keep Disconnect/Pairing visible even if only the node session is connected. + if (isConnected) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + viewModel.disconnect() + NodeForegroundService.stop(context) + }, + ) { + Text("Disconnect") + } + + Button( + onClick = { + // Triggers a node pairing request (so it appears in `clawdbot nodes pending`). + viewModel.requestNodePairing() + }, + ) { + Text("Request Pairing") + } } } } @@ -403,6 +419,25 @@ fun SettingsSheet(viewModel: MainViewModel) { modifier = Modifier.fillMaxWidth(), enabled = manualEnabled, ) + + OutlinedTextField( + value = gatewayToken, + onValueChange = viewModel::setGatewayToken, + label = { Text("Gateway token") }, + modifier = Modifier.fillMaxWidth(), + enabled = manualEnabled, + ) + + OutlinedTextField( + value = gatewayPassword, + onValueChange = viewModel::setGatewayPassword, + label = { Text("Gateway password") }, + modifier = Modifier.fillMaxWidth(), + enabled = manualEnabled, + ) + + // Node pairing token removed: gateway does not accept node pairing tokens as connect auth. + ListItem( headlineContent = { Text("Require TLS") }, supportingContent = { Text("Pin the gateway certificate on first connect.") }, diff --git a/scripts/bundle-a2ui.ts b/scripts/bundle-a2ui.ts new file mode 100644 index 000000000..91c1de395 --- /dev/null +++ b/scripts/bundle-a2ui.ts @@ -0,0 +1,112 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const HASH_FILE = path.join(repoRoot, "src", "canvas-host", "a2ui", ".bundle.hash"); +const OUTPUT_FILE = path.join(repoRoot, "src", "canvas-host", "a2ui", "a2ui.bundle.js"); + +const INPUT_PATHS = [ + path.join(repoRoot, "package.json"), + path.join(repoRoot, "pnpm-lock.yaml"), + path.join(repoRoot, "vendor", "a2ui", "renderers", "lit"), + path.join(repoRoot, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI"), +]; + +async function exists(p: string) { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +async function listFiles(root: string): Promise { + const out: string[] = []; + async function walk(dir: string) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const ent of entries) { + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + await walk(full); + } else if (ent.isFile()) { + out.push(full); + } + } + } + await walk(root); + return out; +} + +async function collectInputFiles(): Promise { + const files: string[] = []; + for (const p of INPUT_PATHS) { + const st = await fs.stat(p); + if (st.isDirectory()) { + files.push(...(await listFiles(p))); + } else { + files.push(p); + } + } + files.sort((a, b) => a.localeCompare(b, "en")); + return files; +} + +async function sha256File(filePath: string): Promise { + const buf = await fs.readFile(filePath); + return crypto.createHash("sha256").update(buf).digest("hex"); +} + +async function computeHash(): Promise { + const inputs = await collectInputFiles(); + const h = crypto.createHash("sha256"); + for (const p of inputs) { + // include relative path to avoid collisions and keep stable ordering + const rel = path.relative(repoRoot, p).replace(/\\/g, "/"); + h.update(rel); + h.update("\0"); + h.update(await fs.readFile(p)); + h.update("\0"); + } + return h.digest("hex"); +} + +function run(cmd: string, args: string[], label: string) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: "inherit", cwd: repoRoot, shell: process.platform === "win32" }); + child.on("exit", (code, signal) => { + if (code === 0) return resolve(); + reject(new Error(`${label} failed (code=${code ?? "?"}${signal ? ` signal=${signal}` : ""})`)); + }); + }); +} + +async function main() { + const currentHash = await computeHash(); + const previousHash = (await exists(HASH_FILE)) ? (await fs.readFile(HASH_FILE, "utf8")).trim() : null; + + if (previousHash && previousHash === currentHash && (await exists(OUTPUT_FILE))) { + console.log("A2UI bundle up to date; skipping."); + return; + } + + // Build vendor A2UI lit renderer TS output, then bundle our bootstrap. + await run("pnpm", ["-s", "exec", "tsc", "-p", "vendor/a2ui/renderers/lit/tsconfig.json"], "A2UI lit tsc"); + await run("pnpm", ["-s", "exec", "rolldown", "-c", "apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs"], "A2UI rolldown"); + + await fs.writeFile(HASH_FILE, `${currentHash}\n`, "utf8"); + + // sanity + const outHash = await sha256File(OUTPUT_FILE); + if (!outHash) throw new Error("A2UI bundle not generated"); +} + +main().catch((err) => { + console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle"); + console.error(String(err)); + process.exit(1); +}); diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 433b37376..2312fa4ea 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -3,6 +3,16 @@ import os from "node:os"; const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +function runOnce(args, label) { + return new Promise((resolve, reject) => { + const child = spawn(pnpm, args, { stdio: "inherit", shell: process.platform === "win32" }); + child.on("exit", (code, signal) => { + if (code === 0) return resolve(); + reject(new Error(`${label} failed (code=${code ?? "?"}${signal ? ` signal=${signal}` : ""})`)); + }); + }); +} + const runs = [ { name: "unit", @@ -44,7 +54,7 @@ const WARNING_SUPPRESSION_FLAGS = [ "--disable-warning=DEP0060", ]; -const runOnce = (entry, extraArgs = []) => +const runTestOnce = (entry, extraArgs = []) => new Promise((resolve) => { const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...extraArgs] @@ -67,10 +77,10 @@ const runOnce = (entry, extraArgs = []) => }); const run = async (entry) => { - if (shardCount <= 1) return runOnce(entry); + if (shardCount <= 1) return runTestOnce(entry); for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) { // eslint-disable-next-line no-await-in-loop - const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]); + const code = await runTestOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]); if (code !== 0) return code; } return 0; @@ -85,6 +95,10 @@ const shutdown = (signal) => { process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); +// Some unit tests expect the gateway-hosted A2UI scaffold + bundle to be present. +// The bundle is a generated artifact (gitignored), so ensure it's built before running Vitest. +await runOnce(["canvas:a2ui:bundle"], "A2UI bundle"); + const parallelCodes = await Promise.all(parallelRuns.map(run)); const failedParallel = parallelCodes.find((code) => code !== 0); if (failedParallel !== undefined) {