diff --git a/android/app/src/main/java/com/openclaw/android/CommandRunner.kt b/android/app/src/main/java/com/openclaw/android/CommandRunner.kt index 7bfea9d0..e84e6b00 100644 --- a/android/app/src/main/java/com/openclaw/android/CommandRunner.kt +++ b/android/app/src/main/java/com/openclaw/android/CommandRunner.kt @@ -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, + env: Map, + 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, @@ -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, + env: Map, + 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 { + 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 + } } diff --git a/android/app/src/main/java/com/openclaw/android/JsBridge.kt b/android/app/src/main/java/com/openclaw/android/JsBridge.kt index 9c97d6cb..f81f5450 100644 --- a/android/app/src/main/java/com/openclaw/android/JsBridge.kt +++ b/android/app/src/main/java/com/openclaw/android/JsBridge.kt @@ -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 @@ -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 = 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. @@ -113,8 +164,7 @@ class JsBridge( @JavascriptInterface fun getTerminalSessions(): String = gson.toJson(sessionManager.getSessionsInfo()) - @JavascriptInterface - fun writeToTerminal( + private fun writeToTerminalInternal( id: String, data: String, ) { @@ -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) } @@ -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), @@ -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 -> @@ -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() @@ -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() })) } @@ -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), @@ -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), @@ -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() @@ -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)) @@ -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) } @@ -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), @@ -467,6 +554,15 @@ class JsBridge( } } + private fun blockedCommandResult(): String = + gson.toJson( + CommandRunner.CommandResult( + exitCode = -1, + stdout = "", + stderr = "Command is not allowed", + ), + ) + // ═══════════════════════════════════════════ // Updates domain // ═══════════════════════════════════════════ diff --git a/android/app/src/main/java/com/openclaw/android/MainActivity.kt b/android/app/src/main/java/com/openclaw/android/MainActivity.kt index 319b38d4..93bab59c 100644 --- a/android/app/src/main/java/com/openclaw/android/MainActivity.kt +++ b/android/app/src/main/java/com/openclaw/android/MainActivity.kt @@ -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 @@ -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") diff --git a/android/app/src/test/java/com/openclaw/android/CommandRunnerTest.kt b/android/app/src/test/java/com/openclaw/android/CommandRunnerTest.kt index 212ddfcb..4dcb8229 100644 --- a/android/app/src/test/java/com/openclaw/android/CommandRunnerTest.kt +++ b/android/app/src/test/java/com/openclaw/android/CommandRunnerTest.kt @@ -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) diff --git a/android/www/src/lib/bridge.ts b/android/www/src/lib/bridge.ts index 25ed4695..c102c903 100644 --- a/android/www/src/lib/bridge.ts +++ b/android/www/src/lib/bridge.ts @@ -10,7 +10,8 @@ interface OpenClawBridge { switchSession(id: string): void closeSession(id: string): void getTerminalSessions(): string - writeToTerminal(id: string, data: string): void + writeCommandToTerminal(commandId: string): void + runCommandInNewSession(commandId: string): void getSetupStatus(): string getBootstrapStatus(): string startSetup(): void @@ -25,8 +26,8 @@ interface OpenClawBridge { installTool(id: string): void uninstallTool(id: string): void isToolInstalled(id: string): string - runCommand(cmd: string): string - runCommandAsync(callbackId: string, cmd: string): void + runCommand(commandId: string): string + runCommandAsync(callbackId: string, commandId: string): void checkForUpdates(): string applyUpdate(component: string): void getApkUpdateInfo(): string diff --git a/android/www/src/screens/Dashboard.tsx b/android/www/src/screens/Dashboard.tsx index 3742a776..97bdf641 100644 --- a/android/www/src/screens/Dashboard.tsx +++ b/android/www/src/screens/Dashboard.tsx @@ -14,17 +14,17 @@ interface PlatformInfo { function getCommands() { return [ - { label: 'Gateway', cmd: 'openclaw gateway', desc: t('cmd_gateway') }, - { label: 'Status', cmd: 'openclaw status', desc: t('cmd_status') }, - { label: 'Onboard', cmd: 'openclaw onboard', desc: t('cmd_onboard') }, - { label: 'Logs', cmd: 'openclaw logs --follow', desc: t('cmd_logs') }, + { label: 'Gateway', commandId: 'openclawGateway', cmd: 'openclaw gateway', desc: t('cmd_gateway') }, + { label: 'Status', commandId: 'openclawStatus', cmd: 'openclaw status', desc: t('cmd_status') }, + { label: 'Onboard', commandId: 'openclawOnboard', cmd: 'openclaw onboard', desc: t('cmd_onboard') }, + { label: 'Logs', commandId: 'openclawLogs', cmd: 'openclaw logs --follow', desc: t('cmd_logs') }, ] } function getManagement() { return [ - { label: 'Update', cmd: 'oa --update', desc: t('cmd_update') }, - { label: 'Install Tools', cmd: 'oa --install', desc: t('cmd_install_tools') }, + { label: 'Update', commandId: 'oaUpdate', cmd: 'oa --update', desc: t('cmd_update') }, + { label: 'Install Tools', commandId: 'oaInstall', cmd: 'oa --install', desc: t('cmd_install_tools') }, ] } @@ -40,9 +40,9 @@ export function Dashboard() { const ap = bridge.callJson('getActivePlatform') if (ap) setPlatform(ap) - const nodeV = bridge.callJson<{ stdout: string }>('runCommand', 'node -v 2>/dev/null') - const gitV = bridge.callJson<{ stdout: string }>('runCommand', 'git --version 2>/dev/null') - const ocV = bridge.callJson<{ stdout: string }>('runCommand', 'openclaw --version 2>/dev/null') + const nodeV = bridge.callJson<{ stdout: string }>('runCommand', 'nodeVersion') + const gitV = bridge.callJson<{ stdout: string }>('runCommand', 'gitVersion') + const ocV = bridge.callJson<{ stdout: string }>('runCommand', 'openclawVersion') setRuntimeInfo({ 'Node.js': nodeV?.stdout?.trim() || '—', 'git': gitV?.stdout?.trim()?.replace('git version ', '') || '—', @@ -54,9 +54,9 @@ export function Dashboard() { refreshStatus() }, []) - function runInTerminal(cmd: string) { + function runInTerminal(commandId: string) { bridge.call('showTerminal') - bridge.call('writeToTerminal', '', cmd) + bridge.call('writeCommandToTerminal', commandId) } @@ -95,7 +95,7 @@ export function Dashboard() { key={item.cmd} className="card-row" style={{ cursor: 'pointer', borderTop: i > 0 ? '1px solid var(--border)' : 'none', padding: '10px 0' }} - onClick={() => runInTerminal(item.cmd)} + onClick={() => runInTerminal(item.commandId)} >
{item.label}
@@ -125,7 +125,7 @@ export function Dashboard() { key={item.cmd} className="card-row" style={{ cursor: 'pointer', borderTop: i > 0 ? '1px solid var(--border)' : 'none', padding: '10px 0' }} - onClick={() => runInTerminal(item.cmd)} + onClick={() => runInTerminal(item.commandId)} >
{item.label}
diff --git a/android/www/src/screens/SettingsAbout.tsx b/android/www/src/screens/SettingsAbout.tsx index 7349de7a..991e6cb6 100644 --- a/android/www/src/screens/SettingsAbout.tsx +++ b/android/www/src/screens/SettingsAbout.tsx @@ -30,9 +30,9 @@ export function SettingsAbout() { }, 0) // Get runtime versions - const nodeV = bridge.callJson<{ stdout: string }>('runCommand', 'node -v 2>/dev/null') - const gitV = bridge.callJson<{ stdout: string }>('runCommand', 'git --version 2>/dev/null') - const oaV = bridge.callJson<{ stdout: string }>('runCommand', 'oa --version 2>/dev/null | head -1') + const nodeV = bridge.callJson<{ stdout: string }>('runCommand', 'nodeVersion') + const gitV = bridge.callJson<{ stdout: string }>('runCommand', 'gitVersion') + const oaV = bridge.callJson<{ stdout: string }>('runCommand', 'oaVersion') setScriptVersion(oaV?.stdout?.trim() || '—') setRuntimeInfo({ 'Node.js': nodeV?.stdout?.trim() || '—',