This commit is contained in:
Deano Calver 2026-01-30 17:05:38 +05:30 committed by GitHub
commit 0f77698a5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 540 additions and 74 deletions

View File

@ -156,6 +156,10 @@ jobs:
pnpm -v 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 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 }}) - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }} run: ${{ matrix.command }}

View File

@ -98,6 +98,9 @@ dependencies {
// Material Components (XML theme + resources) // Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0") 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-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")

View File

@ -35,6 +35,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val instanceId: StateFlow<String> = runtime.instanceId val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName val displayName: StateFlow<String> = runtime.displayName
val deviceIdentityStatusText: StateFlow<String> = runtime.deviceIdentityStatusText
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val locationMode: StateFlow<LocationMode> = runtime.locationMode val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
@ -51,6 +52,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualHost: StateFlow<String> = runtime.manualHost val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls val manualTls: StateFlow<Boolean> = runtime.manualTls
val gatewayToken: StateFlow<String> = runtime.gatewayToken
val gatewayPassword: StateFlow<String> = runtime.gatewayPassword
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
@ -104,6 +108,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualTls(value) runtime.setManualTls(value)
} }
fun setGatewayToken(value: String) {
runtime.setGatewayToken(value)
}
fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value) runtime.setCanvasDebugStatusEnabled(value)
} }
@ -140,6 +152,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.disconnect() runtime.disconnect()
} }
fun requestNodePairing() {
runtime.requestNodePairing()
}
fun resetDeviceIdentity() {
runtime.resetDeviceIdentity()
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) { fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson) runtime.handleCanvasA2UIActionFromWebView(payloadJson)
} }

View File

@ -2,12 +2,23 @@ package ai.openclaw.android
import android.app.Application import android.app.Application
import android.os.StrictMode import android.os.StrictMode
import java.security.Security
import org.bouncycastle.jce.provider.BouncyCastleProvider
class NodeApp : Application() { class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) } val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() { override fun onCreate() {
super.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) { if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()

View File

@ -113,6 +113,15 @@ class NodeRuntime(context: Context) {
private val identityStore = DeviceIdentityStore(appContext) private val identityStore = DeviceIdentityStore(appContext)
fun resetDeviceIdentity() {
identityStore.resetIdentity()
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
refreshGatewayConnection()
}
private val _deviceIdentityStatusText = MutableStateFlow(computeDeviceIdentityStatusText())
val deviceIdentityStatusText: StateFlow<String> = _deviceIdentityStatusText.asStateFlow()
private val _isConnected = MutableStateFlow(false) private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -170,8 +179,11 @@ class NodeRuntime(context: Context) {
onDisconnected = { message -> onDisconnected = { message ->
operatorConnected = false operatorConnected = false
operatorStatusText = message operatorStatusText = message
_serverName.value = null // If the node session is still connected, keep server/remote around so Settings stays usable.
_remoteAddress.value = null if (!nodeConnected) {
_serverName.value = null
_remoteAddress.value = null
}
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main" _mainSessionKey.value = "main"
@ -192,15 +204,22 @@ class NodeRuntime(context: Context) {
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore, deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ -> onConnected = { name, remote, _ ->
nodeConnected = true nodeConnected = true
nodeStatusText = "Connected" 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() updateStatus()
maybeNavigateToA2uiOnConnect() maybeNavigateToA2uiOnConnect()
}, },
onDisconnected = { message -> onDisconnected = { message ->
nodeConnected = false nodeConnected = false
nodeStatusText = message nodeStatusText = message
if (!operatorConnected) {
_serverName.value = null
_remoteAddress.value = null
}
updateStatus() updateStatus()
showLocalCanvasOnDisconnect() showLocalCanvasOnDisconnect()
}, },
@ -241,7 +260,7 @@ class NodeRuntime(context: Context) {
} }
private fun updateStatus() { private fun updateStatus() {
_isConnected.value = operatorConnected _isConnected.value = operatorConnected || nodeConnected
_statusText.value = _statusText.value =
when { when {
operatorConnected && nodeConnected -> "Connected" operatorConnected && nodeConnected -> "Connected"
@ -284,6 +303,9 @@ class NodeRuntime(context: Context) {
val manualHost: StateFlow<String> = prefs.manualHost val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val gatewayPassword: StateFlow<String> = prefs.gatewayPassword
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
@ -428,6 +450,14 @@ class NodeRuntime(context: Context) {
prefs.setManualTls(value) prefs.setManualTls(value)
} }
fun setGatewayToken(value: String) {
prefs.setGatewayToken(value)
}
fun setGatewayPassword(value: String) {
prefs.setGatewayPassword(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value) prefs.setCanvasDebugStatusEnabled(value)
} }
@ -529,25 +559,52 @@ class NodeRuntime(context: Context) {
caps = buildCapabilities(), caps = buildCapabilities(),
commands = buildInvokeCommands(), commands = buildInvokeCommands(),
permissions = emptyMap(), permissions = emptyMap(),
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"), client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
userAgent = buildUserAgent(), 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 { 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( return GatewayConnectOptions(
role = "operator", role = "operator",
scopes = emptyList(), scopes = emptyList(),
caps = emptyList(), caps = emptyList(),
commands = emptyList(), commands = emptyList(),
permissions = emptyMap(), 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(), userAgent = buildUserAgent(),
) )
} }
fun refreshGatewayConnection() { fun refreshGatewayConnection() {
val endpoint = connectedEndpoint ?: return val endpoint = connectedEndpoint ?: return
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
val token = prefs.loadGatewayToken() val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword() val password = prefs.loadGatewayPassword()
val tls = resolveTlsParams(endpoint) val tls = resolveTlsParams(endpoint)
@ -562,6 +619,7 @@ class NodeRuntime(context: Context) {
operatorStatusText = "Connecting…" operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…" nodeStatusText = "Connecting…"
updateStatus() updateStatus()
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
val token = prefs.loadGatewayToken() val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword() val password = prefs.loadGatewayPassword()
val tls = resolveTlsParams(endpoint) val tls = resolveTlsParams(endpoint)
@ -613,6 +671,52 @@ class NodeRuntime(context: Context) {
nodeSession.disconnect() 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? { private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
@ -667,51 +771,123 @@ class NodeRuntime(context: Context) {
} }
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId = // Extract context as key/value map (supports literal* values).
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" } fun contextString(key: String): String? {
val sourceComponentId = val ctx = userActionObj["context"] as? JsonObject ?: return null
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } val entry = ctx[key] as? JsonPrimitive ?: return null
val contextJson = (userActionObj["context"] as? JsonObject)?.toString() return entry.content.trim().takeIf { it.isNotEmpty() }
}
val sessionKey = resolveMainSessionKey() fun contextNumber(key: String): Double? {
val message = val ctx = userActionObj["context"] as? JsonObject ?: return null
OpenClawCanvasA2UIAction.formatAgentMessage( val entry = ctx[key] as? JsonPrimitive ?: return null
actionName = name, return entry.content.toDoubleOrNull()
sessionKey = sessionKey, }
surfaceId = surfaceId,
sourceComponentId = sourceComponentId,
host = displayName.value,
instanceId = instanceId.value.lowercase(),
contextJson = contextJson,
)
val connected = nodeConnected val nodeId = contextString("nodeId")
var ok = false
var error: String? = null var error: String? = null
if (connected) {
try { // If the action name matches a known node command, invoke it directly via the operator session.
nodeSession.sendNodeEvent( // (Do not require operatorConnected here; if it's offline we'll return a visible error.)
event = "agent.request", val (command, params) =
payloadJson = when (name) {
"camera.snap" -> "camera.snap" to buildJsonObject { }
"camera.clip" -> {
val durationSec = (contextNumber("durationSec") ?: 5.0).toLong().coerceIn(1, 60)
"camera.clip" to
buildJsonObject { buildJsonObject {
put("message", JsonPrimitive(message)) put("durationMs", JsonPrimitive(durationSec * 1000))
put("sessionKey", JsonPrimitive(sessionKey)) put("includeAudio", JsonPrimitive(true))
put("thinking", JsonPrimitive("low")) }
put("deliver", JsonPrimitive(false)) }
put("key", JsonPrimitive(actionId)) "screen.record" -> {
}.toString(), val durationSec = (contextNumber("durationSec") ?: 15.0).toLong().coerceIn(1, 180)
) "screen.record" to
} catch (e: Throwable) { buildJsonObject {
error = e.message ?: "send failed" 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 { try {
canvas.eval( canvas.eval(
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus( OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId = actionId, actionId = actionId,
ok = connected && error == null, ok = ok && error == null,
error = error, error = error,
), ),
) )
@ -1115,7 +1291,7 @@ class NodeRuntime(context: Context) {
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null if (raw.isBlank()) return null
val base = raw.trimEnd('/') val base = raw.trimEnd('/')
return "${base}/__openclaw__/a2ui/?platform=android" return "${base}/__clawdbot__/a2ui/?platform=android"
} }
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean { private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
@ -1207,8 +1383,7 @@ private const val a2uiReadyCheckJS: String =
""" """
(() => { (() => {
try { try {
const host = globalThis.openclawA2UI; return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
return !!host && typeof host.applyMessages === 'function';
} catch (_) { } catch (_) {
return false; return false;
} }
@ -1219,9 +1394,8 @@ private const val a2uiResetJS: String =
""" """
(() => { (() => {
try { try {
const host = globalThis.openclawA2UI; if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
if (!host) return { ok: false, error: "missing openclawA2UI" }; return globalThis.clawdbotA2UI.reset();
return host.reset();
} catch (e) { } catch (e) {
return { ok: false, error: String(e?.message ?? e) }; return { ok: false, error: String(e?.message ?? e) };
} }
@ -1232,10 +1406,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
return """ return """
(() => { (() => {
try { try {
const host = globalThis.openclawA2UI; if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
if (!host) return { ok: false, error: "missing openclawA2UI" };
const messages = $messagesJson; const messages = $messagesJson;
return host.applyMessages(messages); return globalThis.clawdbotA2UI.applyMessages(messages);
} catch (e) { } catch (e) {
return { ok: false, error: String(e?.message ?? e) }; return { ok: false, error: String(e?.message ?? e) };
} }

View File

@ -71,6 +71,16 @@ class SecurePrefs(context: Context) {
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true)) MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow<Boolean> = _manualTls val manualTls: StateFlow<Boolean> = _manualTls
// Gateway auth (used for both discovery + manual connects)
private val _gatewayToken = MutableStateFlow(loadGatewayToken().orEmpty())
val gatewayToken: StateFlow<String> = _gatewayToken
private val _gatewayPassword = MutableStateFlow(loadGatewayPassword().orEmpty())
val gatewayPassword: StateFlow<String> = _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 = private val _lastDiscoveredStableId =
MutableStateFlow( MutableStateFlow(
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
@ -143,6 +153,26 @@ class SecurePrefs(context: Context) {
_manualTls.value = value _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) { fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value _canvasDebugStatusEnabled.value = value
@ -157,6 +187,7 @@ class SecurePrefs(context: Context) {
fun saveGatewayToken(token: String) { fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}" val key = "gateway.token.${_instanceId.value}"
prefs.edit { putString(key, token.trim()) } prefs.edit { putString(key, token.trim()) }
_gatewayToken.value = token.trim()
} }
fun loadGatewayPassword(): String? { fun loadGatewayPassword(): String? {
@ -168,8 +199,11 @@ class SecurePrefs(context: Context) {
fun saveGatewayPassword(password: String) { fun saveGatewayPassword(password: String) {
val key = "gateway.password.${_instanceId.value}" val key = "gateway.password.${_instanceId.value}"
prefs.edit { putString(key, password.trim()) } prefs.edit { putString(key, password.trim()) }
_gatewayPassword.value = password.trim()
} }
// node token loader removed (see note above)
fun loadGatewayTlsFingerprint(stableId: String): String? { fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId" val key = "gateway.tls.$stableId"
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }

View File

@ -7,7 +7,9 @@ import java.security.KeyFactory
import java.security.KeyPairGenerator import java.security.KeyPairGenerator
import java.security.MessageDigest import java.security.MessageDigest
import java.security.Signature import java.security.Signature
import java.security.Security
import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.PKCS8EncodedKeySpec
import java.util.UUID
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -23,6 +25,14 @@ class DeviceIdentityStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json") private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
fun resetIdentity() {
try {
if (identityFile.exists()) identityFile.delete()
} catch (_: Throwable) {
// best-effort
}
}
@Synchronized @Synchronized
fun loadOrCreate(): DeviceIdentity { fun loadOrCreate(): DeviceIdentity {
val existing = load() val existing = load()
@ -44,9 +54,11 @@ class DeviceIdentityStore(context: Context) {
return try { return try {
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT) val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) 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 privateKey = keyFactory.generatePrivate(keySpec)
val signature = Signature.getInstance("Ed25519") val signature = runCatching { Signature.getInstance("Ed25519", "BC") }.getOrNull()
?: Signature.getInstance("Ed25519")
signature.initSign(privateKey) signature.initSign(privateKey)
signature.update(payload.toByteArray(Charsets.UTF_8)) signature.update(payload.toByteArray(Charsets.UTF_8))
base64UrlEncode(signature.sign()) base64UrlEncode(signature.sign())
@ -97,17 +109,33 @@ class DeviceIdentityStore(context: Context) {
} }
private fun generate(): DeviceIdentity { private fun generate(): DeviceIdentity {
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() // Some Android builds/devices can be missing Ed25519 support at runtime (or have
val spki = keyPair.public.encoded // broken providers). The gateway can still accept a token-only connection, so
val rawPublic = stripSpkiPrefix(spki) // we fall back to a "no-signature" identity rather than crashing.
val deviceId = sha256Hex(rawPublic) return try {
val privateKey = keyPair.private.encoded // Prefer BC provider when available.
return DeviceIdentity( val kpg = runCatching { KeyPairGenerator.getInstance("Ed25519", "BC") }.getOrNull()
deviceId = deviceId, ?: KeyPairGenerator.getInstance("Ed25519")
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP), val keyPair = kpg.generateKeyPair()
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP), val spki = keyPair.public.encoded
createdAtMs = System.currentTimeMillis(), 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? { private fun deriveDeviceId(publicKeyRawBase64: String): String? {

View File

@ -68,6 +68,7 @@ class CanvasController {
fun onPageFinished() { fun onPageFinished() {
applyDebugStatus() applyDebugStatus()
injectA2uiActionBridgeCompat()
} }
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { 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 = suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview") val wv = webView ?: throw IllegalStateException("no webview")

View File

@ -70,6 +70,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val context = LocalContext.current val context = LocalContext.current
val instanceId by viewModel.instanceId.collectAsState() val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState() val displayName by viewModel.displayName.collectAsState()
val deviceIdentityStatusText by viewModel.deviceIdentityStatusText.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState() val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val locationMode by viewModel.locationMode.collectAsState() val locationMode by viewModel.locationMode.collectAsState()
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
@ -82,6 +83,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualHost by viewModel.manualHost.collectAsState() val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState() val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.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 canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState() val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.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("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("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { Text("Version: $appVersion", 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 { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
} }
item { item {
// UI sanity: "Disconnect" only when we have an active remote. // Keep Disconnect/Pairing visible even if only the node session is connected.
if (isConnected && remoteAddress != null) { if (isConnected) {
Button( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
onClick = { Button(
viewModel.disconnect() onClick = {
NodeForegroundService.stop(context) viewModel.disconnect()
}, NodeForegroundService.stop(context)
) { },
Text("Disconnect") ) {
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(), modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled, 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( ListItem(
headlineContent = { Text("Require TLS") }, headlineContent = { Text("Require TLS") },
supportingContent = { Text("Pin the gateway certificate on first connect.") }, supportingContent = { Text("Pin the gateway certificate on first connect.") },

112
scripts/bundle-a2ui.ts Normal file
View File

@ -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<string[]> {
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<string[]> {
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<string> {
const buf = await fs.readFile(filePath);
return crypto.createHash("sha256").update(buf).digest("hex");
}
async function computeHash(): Promise<string> {
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<void>((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);
});

View File

@ -3,6 +3,16 @@ import os from "node:os";
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; 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 = [ const runs = [
{ {
name: "unit", name: "unit",
@ -44,7 +54,7 @@ const WARNING_SUPPRESSION_FLAGS = [
"--disable-warning=DEP0060", "--disable-warning=DEP0060",
]; ];
const runOnce = (entry, extraArgs = []) => const runTestOnce = (entry, extraArgs = []) =>
new Promise((resolve) => { new Promise((resolve) => {
const args = maxWorkers const args = maxWorkers
? [...entry.args, "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...extraArgs] ? [...entry.args, "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...extraArgs]
@ -67,10 +77,10 @@ const runOnce = (entry, extraArgs = []) =>
}); });
const run = async (entry) => { const run = async (entry) => {
if (shardCount <= 1) return runOnce(entry); if (shardCount <= 1) return runTestOnce(entry);
for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) { for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) {
// eslint-disable-next-line no-await-in-loop // 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; if (code !== 0) return code;
} }
return 0; return 0;
@ -85,6 +95,10 @@ const shutdown = (signal) => {
process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM")); 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 parallelCodes = await Promise.all(parallelRuns.map(run));
const failedParallel = parallelCodes.find((code) => code !== 0); const failedParallel = parallelCodes.find((code) => code !== 0);
if (failedParallel !== undefined) { if (failedParallel !== undefined) {