Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a89e595
feat: Serial over BT/USB (#21), log timestamps (#54), ADB backup (#55)
mithun50 Mar 5, 2026
a94c515
fix: Node capabilities not available to AI (#56)
mithun50 Mar 5, 2026
448d679
docs: Add #56 fix to changelog
mithun50 Mar 5, 2026
1201009
feat: Check for Updates menu option (#59)
mithun50 Mar 5, 2026
0c94229
docs: Add #59 update check to changelog
mithun50 Mar 5, 2026
720d89d
fix: Terminal ENTER key (#58), ADB backup (#55), background capabilit…
mithun50 Mar 6, 2026
5e8144f
fix: Remove invalid exclude rules from backup XML (#55)
mithun50 Mar 6, 2026
0140474
fix: SSHD crash with VPN (#61), DNS resolution (#60)
mithun50 Mar 6, 2026
67a3969
fix: Kill stale gateway processes before starting new instance (#60)
mithun50 Mar 6, 2026
1f41fe7
fix: Prevent deleteRecursively from following symlinks (data loss)
mithun50 Mar 6, 2026
5980af5
fix: Include models array in provider config (#60)
mithun50 Mar 6, 2026
ecd552f
revert: Remove pkill before gateway start
mithun50 Mar 6, 2026
60852aa
fix: Prevent duplicate gateway instances via port check (#60)
mithun50 Mar 6, 2026
3618c93
fix: Storage safety, gateway crash loop, auth UX, canvas status, miss…
mithun50 Mar 8, 2026
414dd17
fix: Prevent gateway crash loop from orphaned timers and race conditions
mithun50 Mar 8, 2026
33773de
fix: Detect actual process death instead of relying on stale boolean …
mithun50 Mar 8, 2026
6418a81
fix: Post emitLog to main thread — EventSink.success() requires UI th…
mithun50 Mar 8, 2026
8409df6
fix: isProcessAlive reports true while gateway thread is still settin…
mithun50 Mar 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Changelog

## v1.8.4 — Serial, Log Timestamps & ADB Backup

### New Features

- **Serial over Bluetooth & USB (#21)** — New `serial` node capability with 5 commands (`list`, `connect`, `disconnect`, `write`, `read`). Supports USB serial devices via `usb_serial` and BLE devices via Nordic UART Service (flutter_blue_plus). Device IDs prefixed with `usb:` or `ble:` for disambiguation
- **Gateway Log Timestamps (#54)** — All gateway log messages (both Kotlin and Dart side) now include ISO 8601 UTC timestamps for easier debugging
- **ADB Backup Support (#55)** — Added `android:allowBackup="true"` to AndroidManifest so users can back up app data via `adb backup`

### Enhancements

- **Check for Updates (#59)** — New "Check for Updates" option in Settings > About. Queries the GitHub Releases API, compares semver versions, and shows an update dialog with a download link if a newer release is available

### Bug Fixes

- **Node Capabilities Not Available to AI (#56)**`_writeNodeAllowConfig()` silently failed when proot/node wasn't ready, causing the gateway to start with no `allowCommands`. Added direct file I/O fallback to write `openclaw.json` directly on the Android filesystem. Also fixed `node.capabilities` event to send both `commands` and `caps` fields matching the connect frame format

### Node Command Reference Update

| Capability | Commands |
|------------|----------|
| Serial | `serial.list`, `serial.connect`, `serial.disconnect`, `serial.write`, `serial.read` |

---

## v1.8.3 — Multi-Instance Guard

### Bug Fixes
Expand Down
16 changes: 15 additions & 1 deletion flutter_app/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,22 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" tools:replace="android:maxSdkVersion" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-feature android:name="android.hardware.usb.host" android:required="false" />

<application
android:label="OpenClaw"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true"
android:extractNativeLibs="true">
android:extractNativeLibs="true"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules">

<activity
android:name=".MainActivity"
Expand All @@ -44,6 +52,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/usb_device_filter" />
</activity>

<service
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.nxg.openclawproot

import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.os.Build
import android.system.Os
import java.io.BufferedInputStream
import java.io.File
Expand Down Expand Up @@ -787,6 +790,27 @@ class BootstrapManager(
}

private fun deleteRecursively(file: File) {
// CRITICAL: Do NOT follow symlinks — the rootfs contains symlinks
// to /storage/emulated/0 (sdcard). Following them would delete the
// user's photos, downloads, and other real files.

// Path boundary check: refuse to delete anything outside filesDir.
// This is a secondary safeguard against accidental data loss (#67, #63).
try {
if (!file.canonicalPath.startsWith(filesDir)) {
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: canonicalPath resolves symlinks, which prevents the symlink guard below from ever being reached. If a symlink inside the rootfs points outside filesDir (e.g., to /storage/emulated/0), canonicalPath returns the target path, the boundary check fails, and the method returns early — leaving the symlink itself undeleted. Use absolutePath instead so the check operates on the link's own path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/BootstrapManager.kt, line 800:

<comment>`canonicalPath` resolves symlinks, which prevents the symlink guard below from ever being reached. If a symlink inside the rootfs points outside `filesDir` (e.g., to `/storage/emulated/0`), `canonicalPath` returns the *target* path, the boundary check fails, and the method returns early — leaving the symlink itself undeleted. Use `absolutePath` instead so the check operates on the link's own path.</comment>

<file context>
@@ -793,6 +793,17 @@ class BootstrapManager(
+        // Path boundary check: refuse to delete anything outside filesDir.
+        // This is a secondary safeguard against accidental data loss (#67, #63).
+        try {
+            if (!file.canonicalPath.startsWith(filesDir)) {
+                return
+            }
</file context>
Suggested change
if (!file.canonicalPath.startsWith(filesDir)) {
if (!file.absolutePath.startsWith(filesDir + File.separator) && file.absolutePath != filesDir) {
Fix with Cubic

Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Path prefix check without a trailing separator: startsWith(filesDir) would also match sibling directories like files-extra/. Append File.separator to the prefix to ensure the match is on a directory boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/BootstrapManager.kt, line 800:

<comment>Path prefix check without a trailing separator: `startsWith(filesDir)` would also match sibling directories like `files-extra/`. Append `File.separator` to the prefix to ensure the match is on a directory boundary.</comment>

<file context>
@@ -793,6 +793,17 @@ class BootstrapManager(
+        // Path boundary check: refuse to delete anything outside filesDir.
+        // This is a secondary safeguard against accidental data loss (#67, #63).
+        try {
+            if (!file.canonicalPath.startsWith(filesDir)) {
+                return
+            }
</file context>
Suggested change
if (!file.canonicalPath.startsWith(filesDir)) {
if (!file.absolutePath.startsWith(filesDir + File.separator) && file.absolutePath != filesDir) {
Fix with Cubic

return
}
} catch (_: Exception) {
return // If we can't resolve the path, don't risk deleting
}

try {
val path = file.toPath()
if (java.nio.file.Files.isSymbolicLink(path)) {
file.delete()
return
}
} catch (_: Exception) {}
if (file.isDirectory) {
file.listFiles()?.forEach { deleteRecursively(it) }
}
Expand Down Expand Up @@ -1212,8 +1236,31 @@ require('/root/.openclaw/proot-compat.js');
}
}

/**
* Read DNS servers from Android's active network. Falls back to
* Google DNS (8.8.8.8, 8.8.4.4) if system DNS is unavailable (#60).
*/
private fun getSystemDnsServers(): String {
try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
if (cm != null) {
val network = cm.activeNetwork
if (network != null) {
val linkProps: LinkProperties? = cm.getLinkProperties(network)
val dnsServers = linkProps?.dnsServers
if (dnsServers != null && dnsServers.isNotEmpty()) {
val lines = dnsServers.joinToString("\n") { "nameserver ${it.hostAddress}" }
// Always append Google DNS as fallback
return "$lines\nnameserver 8.8.8.8\n"
}
}
}
} catch (_: Exception) {}
return "nameserver 8.8.8.8\nnameserver 8.8.4.4\n"
}

fun writeResolvConf() {
val content = "nameserver 8.8.8.8\nnameserver 8.8.4.4\n"
val content = getSystemDnsServers()
// Try context.filesDir first (Android-guaranteed), fall back to
// string-based configDir. Always call mkdirs() unconditionally. (#40)
try {
Expand All @@ -1230,10 +1277,8 @@ require('/root/.openclaw/proot-compat.js');
// even if the bind-mount fails or hasn't been set up yet (#40).
try {
val rootfsResolv = File(rootfsDir, "etc/resolv.conf")
if (!rootfsResolv.exists() || rootfsResolv.length() == 0L) {
rootfsResolv.parentFile?.mkdirs()
rootfsResolv.writeText(content)
}
rootfsResolv.parentFile?.mkdirs()
rootfsResolv.writeText(content)
} catch (_: Exception) {}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import io.flutter.plugin.common.EventChannel
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.net.InetSocketAddress
import java.net.Socket

class GatewayService : Service() {
companion object {
Expand Down Expand Up @@ -42,7 +44,7 @@ class GatewayService : Service() {
private var gatewayProcess: Process? = null
private var wakeLock: PowerManager.WakeLock? = null
private var restartCount = 0
private val maxRestarts = 3
private val maxRestarts = 5
private var startTime: Long = 0
private var uptimeThread: Thread? = null

Expand Down Expand Up @@ -74,8 +76,34 @@ class GatewayService : Service() {
super.onDestroy()
}

/** Check if gateway port is already in use (another instance running). */
private fun isPortInUse(port: Int = 18789): Boolean {
return try {
Socket().use { socket ->
socket.connect(InetSocketAddress("127.0.0.1", port), 1000)
true
}
} catch (_: Exception) {
false
}
}

private fun startGateway() {
if (gatewayProcess?.isAlive == true) return

// Check if an existing gateway is already listening on the port.
// This prevents duplicate instances when the service is recreated
// but an old proot process is still alive (#60).
if (isPortInUse()) {
emitLog("Gateway already running on port 18789, adopting existing instance")
isRunning = true
instance = this
startTime = System.currentTimeMillis()
updateNotificationRunning()
startUptimeTicker()
return
}

isRunning = true
instance = this
startTime = System.currentTimeMillis()
Expand Down Expand Up @@ -122,6 +150,15 @@ class GatewayService : Service() {
}
} catch (_: Exception) {}

// Final check right before launch — another instance may have
// started between the first check and now
if (isPortInUse()) {
emitLog("Gateway already running on port 18789, skipping launch")
updateNotificationRunning()
startUptimeTicker()
return@Thread
}

gatewayProcess = pm.startProotProcess("openclaw gateway --verbose")
updateNotificationRunning()
emitLog("Gateway started")
Expand All @@ -139,22 +176,31 @@ class GatewayService : Service() {
} catch (_: Exception) {}
}.start()

// Read stderr
// Read stderr — log all lines on first attempt for debugging visibility
val stderrReader = BufferedReader(InputStreamReader(gatewayProcess!!.errorStream))
val currentRestartCount = restartCount
Thread {
try {
var line: String?
while (stderrReader.readLine().also { line = it } != null) {
val l = line ?: continue
if (!l.contains("proot warning") && !l.contains("can't sanitize")) {
if (currentRestartCount == 0 ||
(!l.contains("proot warning") && !l.contains("can't sanitize"))) {
emitLog("[ERR] $l")
}
}
} catch (_: Exception) {}
}.start()

val exitCode = gatewayProcess!!.waitFor()
emitLog("Gateway exited with code $exitCode")
val uptimeMs = System.currentTimeMillis() - startTime
val uptimeSec = uptimeMs / 1000
emitLog("Gateway exited with code $exitCode (uptime: ${uptimeSec}s)")

// If the gateway ran for >60s, it was a transient crash — reset counter
if (uptimeMs > 60_000) {
restartCount = 0
}

if (isRunning && restartCount < maxRestarts) {
restartCount++
Expand Down Expand Up @@ -219,7 +265,8 @@ class GatewayService : Service() {

private fun emitLog(message: String) {
try {
logSink?.success(message)
val ts = java.time.Instant.now().toString()
logSink?.success("$ts $message")
} catch (_: Exception) {}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,17 @@ class MainActivity : FlutterActivity() {
result.error("INVALID_ARGS", "path and content required", null)
}
}
"bringToForeground" -> {
try {
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
}
applicationContext.startActivity(intent)
result.success(true)
} catch (e: Exception) {
result.error("FOREGROUND_ERROR", e.message, null)
}
}
"readSensor" -> {
val sensorType = call.argument<String>("sensor") ?: "accelerometer"
Thread {
Expand Down Expand Up @@ -530,7 +541,6 @@ class MainActivity : FlutterActivity() {

createUrlNotificationChannel()
requestNotificationPermission()
requestStoragePermissionOnLaunch()

EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
Expand Down Expand Up @@ -558,30 +568,6 @@ class MainActivity : FlutterActivity() {
}
}

private fun requestStoragePermissionOnLaunch() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!Environment.isExternalStorageManager()) {
try {
val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
startActivity(intent)
} catch (_: Exception) {}
}
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
),
STORAGE_PERMISSION_REQUEST
)
}
}
}

private fun createUrlNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
Expand Down
Loading