Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
79 changes: 79 additions & 0 deletions android/app/src/main/java/com/openclaw/android/CommandRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,46 @@ object CommandRunner {
val stderr: String,
)

/**
* Run a fixed executable with explicit arguments.
* This avoids shell parsing for WebView-triggered operations.
*/
fun runExecutable(
executable: String,
args: List<String>,
env: Map<String, String>,
workDir: File,
timeoutMs: Long = 5_000,
): CommandResult =
try {
val command = listOf(resolveExecutable(executable, env)) + args
val pb = ProcessBuilder(command)
pb.environment().clear()
pb.environment().putAll(env)
pb.directory(workDir)
pb.redirectErrorStream(false)

val process = pb.start()
val stdout = process.inputStream.bufferedReader().readText()
val stderr = process.errorStream.bufferedReader().readText()
val exited = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)

if (!exited) {
process.destroyForcibly()
CommandResult(-1, stdout, "Command timed out after ${timeoutMs}ms")
} else {
CommandResult(process.exitValue(), stdout, stderr)
}
} catch (e: Exception) {
CommandResult(-1, "", e.message ?: "Unknown error")
}

/**
* Run a command synchronously with timeout.
* Returns stdout/stderr and exit code.
*
* Only use this for native-owned fixed command strings. WebView-provided
* input must go through runExecutable with explicit arguments.
*/
fun runSync(
command: String,
Expand Down Expand Up @@ -75,4 +112,46 @@ object CommandRunner {
onOutput("Error: ${e.message}")
}
}

/**
* Run a fixed executable asynchronously without shell parsing.
*/
suspend fun runStreaming(
executable: String,
args: List<String>,
env: Map<String, String>,
workDir: File,
onOutput: (String) -> Unit,
) = withContext(Dispatchers.IO) {
try {
val command = listOf(resolveExecutable(executable, env)) + args
val pb = ProcessBuilder(command)
pb.environment().clear()
pb.environment().putAll(env)
pb.directory(workDir)
pb.redirectErrorStream(true)

val process = pb.start()
process.inputStream.bufferedReader().forEachLine { line ->
onOutput(line)
}
process.waitFor()
} catch (e: Exception) {
onOutput("Error: ${e.message}")
}
}

private fun resolveExecutable(
executable: String,
env: Map<String, String>,
): String {
if (executable.contains(File.separatorChar)) return executable
val path = env["PATH"] ?: return executable
return path
.split(File.pathSeparator)
.asSequence()
.map { File(it, executable) }
.firstOrNull { it.exists() && it.canExecute() }
?.absolutePath ?: executable
}
}
136 changes: 116 additions & 20 deletions android/app/src/main/java/com/openclaw/android/JsBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ class JsBridge(
companion object {
private const val TAG = "JsBridge"
private const val SHELL_INIT_DELAY_MS = 500L
private const val COMMAND_TIMEOUT_MS = 5_000L
private const val PLATFORM_LIST_TIMEOUT_MS = 10_000L
private const val API_TIMEOUT_MS = 5000
private const val PROGRESS_START = 0f
Expand All @@ -41,8 +40,60 @@ class JsBridge(
private const val PROGRESS_EXTRACT = 0.6f
private const val PROGRESS_APPLY = 0.9f
private const val PROGRESS_BOOTSTRAP_START = 0.1f
private const val TERMINAL_COMMAND_NEWLINE = "\n"
}

private data class BridgeCommand(
val executable: String,
val args: List<String> = emptyList(),
)

private val versionCommands =
mapOf(
"nodeVersion" to BridgeCommand("node", listOf("-v")),
"gitVersion" to BridgeCommand("git", listOf("--version")),
"openclawVersion" to BridgeCommand("openclaw", listOf("--version")),
"oaVersion" to BridgeCommand("oa", listOf("--version")),
)

private val terminalCommands =
mapOf(
"openclawGateway" to "openclaw gateway",
"openclawStatus" to "openclaw status",
"openclawOnboard" to "openclaw onboard",
"openclawLogs" to "openclaw logs --follow",
"oaUpdate" to "oa --update",
"oaInstall" to "oa --install",
)

private val platformPackages =
mapOf(
"openclaw" to "openclaw",
)

private val allowedToolIds =
setOf(
"tmux",
"ttyd",
"dufs",
"openssh-server",
"android-tools",
"chromium",
"code-server",
"claude-code",
"gemini-cli",
"codex-cli",
"opencode",
)

private val npmToolBinaries =
mapOf(
"claude-code" to "claude",
"gemini-cli" to "gemini",
"codex-cli" to "codex",
"opencode" to "opencode",
)

/**
* Launch a coroutine on Dispatchers.IO with error handling.
* Catches all exceptions to prevent app crashes from unhandled coroutine failures.
Expand Down Expand Up @@ -113,8 +164,7 @@ class JsBridge(
@JavascriptInterface
fun getTerminalSessions(): String = gson.toJson(sessionManager.getSessionsInfo())

@JavascriptInterface
fun writeToTerminal(
private fun writeToTerminalInternal(
id: String,
data: String,
) {
Expand All @@ -128,12 +178,19 @@ class JsBridge(
}

@JavascriptInterface
fun runInNewSession(command: String) {
fun writeCommandToTerminal(commandId: String) {
val command = terminalCommands[commandId] ?: return
writeToTerminalInternal("", command)
}

@JavascriptInterface
fun runCommandInNewSession(commandId: String) {
val command = terminalCommands[commandId] ?: return
val session = sessionManager.createSession()
activity.showTerminal()
// Delay write until shell process initializes (same pattern as showTerminal post-setup)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
session.write(command)
session.write(command + TERMINAL_COMMAND_NEWLINE)
}, SHELL_INIT_DELAY_MS)
}

Expand Down Expand Up @@ -216,6 +273,7 @@ class JsBridge(

@JavascriptInterface
fun installPlatform(id: String) {
val pkg = platformPackages[id] ?: return
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
Expand All @@ -226,7 +284,8 @@ class JsBridge(
)
val env = EnvironmentBuilder.build(activity)
CommandRunner.runStreaming(
"npm install -g $id@latest --ignore-scripts",
"npm",
listOf("install", "-g", "$pkg@latest", "--ignore-scripts"),
env,
bootstrapManager.homeDir,
) { output ->
Expand All @@ -244,17 +303,24 @@ class JsBridge(

@JavascriptInterface
fun uninstallPlatform(id: String) {
val pkg = platformPackages[id] ?: return
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
) {
val env = EnvironmentBuilder.build(activity)
CommandRunner.runSync("npm uninstall -g $id", env, bootstrapManager.homeDir)
CommandRunner.runExecutable(
"npm",
listOf("uninstall", "-g", pkg),
env,
bootstrapManager.homeDir,
)
}
}

@JavascriptInterface
fun switchPlatform(id: String) {
if (!platformPackages.containsKey(id)) return
// Write active platform marker
val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
markerFile.parentFile?.mkdirs()
Expand All @@ -264,7 +330,8 @@ class JsBridge(
@JavascriptInterface
fun getActivePlatform(): String {
val markerFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
val id = if (markerFile.exists()) markerFile.readText().trim() else "openclaw"
val savedId = if (markerFile.exists()) markerFile.readText().trim() else "openclaw"
val id = if (platformPackages.containsKey(savedId)) savedId else "openclaw"
return gson.toJson(mapOf("id" to id, "name" to id.replaceFirstChar { it.uppercase() }))
}

Expand Down Expand Up @@ -319,6 +386,7 @@ class JsBridge(

@JavascriptInterface
fun installTool(id: String) {
if (!allowedToolIds.contains(id)) return
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
Expand Down Expand Up @@ -372,6 +440,7 @@ class JsBridge(

@JavascriptInterface
fun uninstallTool(id: String) {
if (!allowedToolIds.contains(id)) return
launchWithErrorHandling(
errorEventType = "install_progress",
errorContext = mapOf("target" to id),
Expand Down Expand Up @@ -405,7 +474,7 @@ class JsBridge(
@JavascriptInterface
fun isToolInstalled(id: String): String {
val prefix = bootstrapManager.prefixDir.absolutePath
val env = EnvironmentBuilder.build(activity)
val nodeBin = "${bootstrapManager.homeDir.absolutePath}/.openclaw-android/node/bin"
val exists =
when (id) {
"openssh-server" -> java.io.File("$prefix/bin/sshd").exists()
Expand All @@ -419,15 +488,8 @@ class JsBridge(
}
"code-server" -> java.io.File("$prefix/bin/code-server").exists()
else -> {
// npm global packages: check via command -v
val result =
CommandRunner.runSync(
"command -v $id 2>/dev/null",
env,
bootstrapManager.prefixDir,
timeoutMs = COMMAND_TIMEOUT_MS,
)
result.stdout.trim().isNotEmpty()
val bin = npmToolBinaries[id] ?: return gson.toJson(mapOf("installed" to false))
java.io.File("$nodeBin/$bin").exists()
}
}
return gson.toJson(mapOf("installed" to exists))
Expand All @@ -439,8 +501,15 @@ class JsBridge(

@JavascriptInterface
fun runCommand(cmd: String): String {
val command = versionCommands[cmd] ?: return blockedCommandResult()
val env = EnvironmentBuilder.build(activity)
val result = CommandRunner.runSync(cmd, env, bootstrapManager.homeDir)
val result =
CommandRunner.runExecutable(
command.executable,
command.args,
env,
bootstrapManager.homeDir,
)
return gson.toJson(result)
}

Expand All @@ -449,12 +518,30 @@ class JsBridge(
callbackId: String,
cmd: String,
) {
val command =
versionCommands[cmd]
?: run {
eventBridge.emit(
"command_output",
mapOf(
"callbackId" to callbackId,
"data" to "Command is not allowed",
"done" to true,
),
)
return
}
launchWithErrorHandling(
errorEventType = "command_output",
errorContext = mapOf("callbackId" to callbackId, "done" to true),
) {
val env = EnvironmentBuilder.build(activity)
CommandRunner.runStreaming(cmd, env, bootstrapManager.homeDir) { output ->
CommandRunner.runStreaming(
command.executable,
command.args,
env,
bootstrapManager.homeDir,
) { output ->
eventBridge.emit(
"command_output",
mapOf("callbackId" to callbackId, "data" to output, "done" to false),
Expand All @@ -467,6 +554,15 @@ class JsBridge(
}
}

private fun blockedCommandResult(): String =
gson.toJson(
CommandRunner.CommandResult(
exitCode = -1,
stdout = "",
stderr = "Command is not allowed",
),
)

// ═══════════════════════════════════════════
// Updates domain
// ═══════════════════════════════════════════
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class MainActivity : AppCompatActivity() {
private const val TAB_ADD_PAD_DP = 12
private const val INDICATOR_HEIGHT_DP = 2
private const val INPUT_MODE_TYPE_NULL = 1
private val ALLOWED_PLATFORM_IDS = setOf("openclaw")
}

private lateinit var binding: ActivityMainBinding
Expand Down Expand Up @@ -103,7 +104,8 @@ class MainActivity : AppCompatActivity() {
}
} else if (intent?.getBooleanExtra("from_boot", false) == true) {
val platformFile = java.io.File(bootstrapManager.homeDir, ".openclaw-android/.platform")
val platformId = if (platformFile.exists()) platformFile.readText().trim() else "openclaw"
val savedPlatformId = if (platformFile.exists()) platformFile.readText().trim() else "openclaw"
val platformId = if (ALLOWED_PLATFORM_IDS.contains(savedPlatformId)) savedPlatformId else "openclaw"
AppLogger.i(TAG, "Boot launch \u2014 auto-starting $platformId gateway")
binding.terminalView.post {
session.write("$platformId gateway\n")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ class CommandRunnerTest {
assertEquals("hello", result.stdout.trim())
}

@Test
fun `runExecutable does not interpret shell metacharacters`() {
val marker = File(tempDir, "marker")
val result =
CommandRunner.runExecutable(
"printf",
listOf("hello; touch ${marker.absolutePath}"),
env,
tempDir,
)

assertEquals(0, result.exitCode)
assertEquals("hello; touch ${marker.absolutePath}", result.stdout)
assertTrue(!marker.exists())
}

@Test
fun `runSync returns non-zero exit code for failing command`() {
val result = CommandRunner.runSync("exit 42", env, tempDir)
Expand Down
Loading