From fae3de21f16adac65eb7c7c1a6c4f2c04f4e76d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 22:48:17 +0000 Subject: [PATCH 01/15] Show the Wi-Fi address instead of the mobile data one getLanIp returned the first IPv4 address of any interface. On phones that is often the mobile data interface, whose carrier address nothing on the local network can reach, so the MCP bridge and web server showed an unusable LAN endpoint. Prefer Wi-Fi, hotspot and Ethernet, then tethering. Skip mobile data, 464xlat and VPN interfaces, and fall back to 127.0.0.1 when no LAN address exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- .../com/runcode/app/network/PortManager.kt | 52 +++++++++++++++++-- .../app/network/PortManagerLanIpTest.kt | 52 +++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/runcode/app/network/PortManagerLanIpTest.kt diff --git a/app/src/main/java/com/runcode/app/network/PortManager.kt b/app/src/main/java/com/runcode/app/network/PortManager.kt index 87a0389..fcb0392 100644 --- a/app/src/main/java/com/runcode/app/network/PortManager.kt +++ b/app/src/main/java/com/runcode/app/network/PortManager.kt @@ -56,9 +56,16 @@ class PortManager { throw IllegalStateException("No open developer port found in range 8080-8999") } + /** + * Address other devices on the local network can reach, or 127.0.0.1 when there is none. + * + * Interface order is arbitrary, so taking the first IPv4 address often picked the mobile + * data interface, whose carrier-assigned address nothing on the Wi-Fi can reach. + */ fun getLanIp(): String { return try { - val interfaces = NetworkInterface.getNetworkInterfaces() + val candidates = mutableListOf() + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return LOOPBACK while (interfaces.hasMoreElements()) { val iface = interfaces.nextElement() if (iface.isLoopback || !iface.isUp) continue @@ -66,13 +73,50 @@ class PortManager { while (addresses.hasMoreElements()) { val addr = addresses.nextElement() if (addr is Inet4Address && !addr.isLoopbackAddress) { - return addr.hostAddress ?: "127.0.0.1" + val host = addr.hostAddress ?: continue + candidates += LanCandidate(iface.name, host, addr.isSiteLocalAddress) } } } - "127.0.0.1" + selectLanIp(candidates) ?: LOOPBACK } catch (_: Exception) { - "127.0.0.1" + LOOPBACK + } + } + + data class LanCandidate(val interfaceName: String, val address: String, val isPrivate: Boolean) + + companion object { + private const val LOOPBACK = "127.0.0.1" + + // Wi-Fi, hotspot and Ethernet, then USB/Bluetooth tethering and Wi-Fi Direct. + private val LAN_PREFIXES = listOf("wlan", "swlan", "ap", "softap", "eth") + private val TETHER_PREFIXES = listOf("rndis", "usb", "ncm", "bt-pan", "p2p") + + // Mobile data (rmnet/ccmni/...), 464xlat, VPN tunnels and virtual links. + private val UNREACHABLE_PREFIXES = listOf( + "rmnet", "ccmni", "pdp", "seth", "clat", "v4-", "tun", "ppp", "ipsec", "dummy", "ifb" + ) + + /** Picks the most reachable address, or null when only unreachable ones exist. */ + fun selectLanIp(candidates: List): String? { + return candidates + .mapNotNull { candidate -> rank(candidate)?.let { it to candidate } } + .minWithOrNull(compareBy> { it.first }.thenBy { !it.second.isPrivate }) + ?.second + ?.address + } + + private fun rank(candidate: LanCandidate): Int? { + val name = candidate.interfaceName.lowercase() + return when { + UNREACHABLE_PREFIXES.any { name.startsWith(it) } -> null + LAN_PREFIXES.any { name.startsWith(it) } -> 0 + TETHER_PREFIXES.any { name.startsWith(it) } -> 1 + // Unknown interface names count only with a private address. + candidate.isPrivate -> 2 + else -> null + } } } } diff --git a/app/src/test/java/com/runcode/app/network/PortManagerLanIpTest.kt b/app/src/test/java/com/runcode/app/network/PortManagerLanIpTest.kt new file mode 100644 index 0000000..3329733 --- /dev/null +++ b/app/src/test/java/com/runcode/app/network/PortManagerLanIpTest.kt @@ -0,0 +1,52 @@ +package com.runcode.app.network + +import com.runcode.app.network.PortManager.Companion.selectLanIp +import com.runcode.app.network.PortManager.LanCandidate +import org.junit.Assert.* +import org.junit.Test + +class PortManagerLanIpTest { + private fun c(name: String, address: String, private: Boolean) = LanCandidate(name, address, private) + + @Test fun `wifi wins over mobile data listed first`() { + val picked = selectLanIp(listOf( + c("rmnet_data0", "21.91.237.60", false), + c("wlan0", "192.168.1.23", true) + )) + assertEquals("192.168.1.23", picked) + } + + @Test fun `mobile data alone is not a LAN address`() { + assertNull(selectLanIp(listOf(c("rmnet_data0", "21.91.237.60", false), c("ccmni1", "10.12.0.4", true)))) + } + + @Test fun `vpn and 464xlat interfaces are skipped`() { + assertNull(selectLanIp(listOf(c("tun0", "10.8.0.2", true), c("v4-rmnet_data0", "192.0.0.4", false)))) + } + + @Test fun `hotspot is used when wifi is off`() { + assertEquals("192.168.43.1", selectLanIp(listOf( + c("rmnet_data0", "21.91.237.60", false), + c("swlan0", "192.168.43.1", true) + ))) + } + + @Test fun `wifi beats usb tethering`() { + assertEquals("192.168.1.23", selectLanIp(listOf( + c("rndis0", "192.168.42.129", true), + c("wlan0", "192.168.1.23", true) + ))) + } + + @Test fun `unknown interface counts only with a private address`() { + assertEquals("10.0.0.5", selectLanIp(listOf(c("mystery0", "10.0.0.5", true)))) + assertNull(selectLanIp(listOf(c("mystery0", "21.91.237.60", false)))) + } + + @Test fun `interface names are matched case-insensitively`() { + assertEquals("192.168.1.23", selectLanIp(listOf( + c("RMNET0", "21.91.237.60", false), + c("WLAN0", "192.168.1.23", true) + ))) + } +} From 14d627a7ea5d1bc46d033dbc3d534dfe68007103 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 23:17:24 +0000 Subject: [PATCH 02/15] Add a public URL for the MCP bridge The bridge was only reachable over adb or the local network, which fails behind carrier NAT and with a VPN on. A new Public URL switch opens an outbound SSH reverse tunnel and shows an https endpoint to copy. The connection starts on the device, so it works behind NAT and goes through an active VPN like any other traffic. Pinggy on port 443 is tried first, then localhost.run. Dropped tunnels reconnect with backoff, and a default-network change (Wi-Fi, mobile data, VPN) reconnects at once. Tests run the real JSch client against an in-process SSH server that behaves like Pinggy, including an HTTP request through the forward. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- README.md | 26 +- app/build.gradle.kts | 3 + .../main/java/com/runcode/app/RuncodeApp.kt | 32 +++ .../java/com/runcode/app/mcp/McpTunnel.kt | 227 ++++++++++++++++++ .../com/runcode/app/mcp/SshTunnelConnector.kt | 127 ++++++++++ .../java/com/runcode/app/ui/MainViewModel.kt | 25 ++ .../runcode/app/ui/screens/HealthScreen.kt | 86 +++++++ .../java/com/runcode/app/mcp/McpTunnelTest.kt | 151 ++++++++++++ .../runcode/app/mcp/SshTunnelConnectorTest.kt | 148 ++++++++++++ 9 files changed, 824 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/runcode/app/mcp/McpTunnel.kt create mode 100644 app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt create mode 100644 app/src/test/java/com/runcode/app/mcp/McpTunnelTest.kt create mode 100644 app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt diff --git a/README.md b/README.md index e401262..84f5e80 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,27 @@ curl -s -X POST http://127.0.0.1:8765/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` +### Connecting from anywhere + +Turn on **Public URL (internet)** under the bridge. The app opens an outbound SSH reverse +tunnel and shows an `https://…/mcp` endpoint with a **Copy** button. No port forwarding, +public IP or extra app is needed, and it works behind carrier NAT and with a VPN on: the +connection starts on the phone, so Android sends it through the VPN like any other traffic. + +- The relay is [Pinggy](https://pinggy.io) over SSH on port 443, with + [localhost.run](https://localhost.run) on port 22 as a fallback. +- A dropped tunnel reconnects with backoff (2 s up to 30 s). Switching between Wi-Fi and + mobile data, or a VPN reconnecting, reconnects it straight away. +- Free relays hand out a new URL on every connection and Pinggy's free tunnels last 60 + minutes, so copy the endpoint again after a reconnect. +- If it keeps retrying with a VPN on, check that runcode is not excluded from the VPN + (split tunneling). + +```bash +claude mcp add --transport http runcode https://.run.pinggy-free.link/mcp \ + --header "Authorization: Bearer $RUNCODE_TOKEN" +``` + ### Security The bridge exposes a shell, arbitrary Python and read/write file access on the device. @@ -134,6 +155,9 @@ The bridge exposes a shell, arbitrary Python and read/write file access on the d - The listener binds `127.0.0.1` unless you turn on **Expose on local network**, which makes anyone on the same Wi-Fi with the token able to run commands on the device. Prefer `adb forward` or a tunnel. +- **Public URL** makes the bridge reachable from the whole internet for anyone with both the + URL and the token. The relay terminates HTTPS, so it can read requests, including the + token. Turn it off when you are done and rotate the token after sharing it. - Secret-valued environment variables are returned as ``, never echoed back. - Registered vault values are redacted from logs and text tool results. This is not an isolation boundary: MCP's arbitrary code tools and project scripts run with app privileges. @@ -148,7 +172,7 @@ app/src/main/java/com/runcode/app/ runtime/ PythonEngine (Chaquopy), StaticWebEngine, engine contracts supervisor/ ServiceSupervisor — lifecycle, restart policy, ports, wake lock terminal/ TerminalSession — interactive sh plus one-shot command runner - mcp/ McpServer (JSON-RPC over HTTP), McpTools, McpToolHost + mcp/ McpServer (JSON-RPC over HTTP), McpTools, McpToolHost, McpTunnel (public URL) storage/ Path-checked project files, zip import/export, entry-point tracking database/ App metadata DB and the project SQLite browser security/ Keystore-backed secret vault, log redaction diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 981e4ed..eb1fd90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -135,7 +135,10 @@ dependencies { implementation("androidx.compose.material:material-icons-extended") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + // SSH client for the MCP bridge's public tunnel. Pure Java, no native code. + implementation("com.github.mwiede:jsch:2.28.7") testImplementation("junit:junit:4.13.2") + testImplementation("org.apache.sshd:sshd-core:2.20.0") testImplementation("org.robolectric:robolectric:4.16.1") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index 1698c2a..bb0b7cb 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -1,6 +1,8 @@ package com.runcode.app import android.app.Application +import android.net.ConnectivityManager +import android.net.Network import com.chaquo.python.Python import com.runcode.app.backup.BackupManager import com.runcode.app.database.AppMetaDatabase @@ -10,6 +12,8 @@ import com.runcode.app.domain.models.ProjectProfile import com.runcode.app.logging.LogManager import com.runcode.app.mcp.McpServer import com.runcode.app.mcp.McpToolHost +import com.runcode.app.mcp.McpTunnel +import com.runcode.app.mcp.SshTunnelConnector import com.runcode.app.network.PortManager import com.runcode.app.runtime.PythonEngine import com.runcode.app.runtime.RuntimeRegistry @@ -57,6 +61,8 @@ class RuncodeApp : Application() { private set lateinit var mcpServer: McpServer private set + lateinit var mcpTunnel: McpTunnel + private set lateinit var processMonitor: ProcessMonitor private set lateinit var projectArchive: ProjectArchive @@ -129,6 +135,11 @@ class RuncodeApp : Application() { ), onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } ) + mcpTunnel = McpTunnel( + connector = SshTunnelConnector(), + onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } + ) + watchDefaultNetwork() CoroutineScope(Dispatchers.IO).launch { seedStarterProjectsIfEmpty() @@ -154,6 +165,27 @@ class RuncodeApp : Application() { return mcpToken() } + /** + * Reconnects the public tunnel when the default network changes, e.g. Wi-Fi to mobile + * data or a VPN coming up or reconnecting, instead of waiting for keepalives to time out. + */ + private fun watchDefaultNetwork() { + val connectivity = getSystemService(ConnectivityManager::class.java) ?: return + try { + connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + // The first call reports the network at registration, not a change. + private var registered = false + + override fun onAvailable(network: Network) { + if (registered) mcpTunnel.onNetworkChanged() + registered = true + } + }) + } catch (e: RuntimeException) { + logManager.log(MCP_LOG_ID, "MCP Bridge", LogLevel.WARN, "Network changes are not tracked: ${e.message}") + } + } + private fun runPythonSnippet(code: String, workingDir: File): String { if (!Python.isStarted()) return "Embedded Python interpreter is not available on this device." return try { diff --git a/app/src/main/java/com/runcode/app/mcp/McpTunnel.kt b/app/src/main/java/com/runcode/app/mcp/McpTunnel.kt new file mode 100644 index 0000000..cb35686 --- /dev/null +++ b/app/src/main/java/com/runcode/app/mcp/McpTunnel.kt @@ -0,0 +1,227 @@ +package com.runcode.app.mcp + +import com.runcode.app.domain.models.LogLevel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.Closeable + +/** An SSH relay that hands out a public HTTPS URL for a reverse-forwarded port. */ +data class TunnelProvider( + val name: String, + val host: String, + val port: Int, + val user: String, + /** Port requested with tcpip-forward. 0 lets the relay choose. */ + val remotePort: Int, + /** The relay's own sites, which its banner links to. Never the tunnel URL. */ + val siteDomains: List +) { + companion object { + // SSH on 443 gets through networks that only allow web traffic. + val PINGGY = TunnelProvider("Pinggy", "free.pinggy.io", 443, "runcode", 0, listOf("pinggy.io")) + val LOCALHOST_RUN = TunnelProvider("localhost.run", "localhost.run", 22, "nokey", 80, listOf("localhost.run")) + val DEFAULTS = listOf(PINGGY, LOCALHOST_RUN) + } +} + +enum class TunnelStatus { OFF, CONNECTING, ONLINE, RETRYING } + +data class McpTunnelState( + val status: TunnelStatus = TunnelStatus.OFF, + /** Public base URL, without the /mcp path. */ + val url: String? = null, + val provider: String? = null, + val lastError: String? = null, + val failures: Int = 0, + val retryInSeconds: Int = 0 +) + +interface TunnelConnection : Closeable { + val isAlive: Boolean +} + +class OpenTunnel(val connection: TunnelConnection, val url: String) + +fun interface TunnelConnector { + /** Opens the tunnel and returns once the relay has announced its public URL. */ + fun open(provider: TunnelProvider, localPort: Int, timeoutMs: Int): OpenTunnel +} + +object TunnelUrls { + private val ANSI = Regex("\u001B\\[[0-9;?]*[A-Za-z]") + private val HTTPS = Regex("https://([A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)+)/?(?=[\\s,;)\"'<>]|$)") + + /** First bare https://host in the relay's output that is not one of its own sites. */ + fun find(output: String, provider: TunnelProvider): String? { + val clean = ANSI.replace(output, "") + return HTTPS.findAll(clean) + .map { it.groupValues[1].lowercase() } + .firstOrNull { host -> provider.siteDomains.none { host == it || host.endsWith(".$it") } } + ?.let { "https://$it" } + } +} + +/** + * Keeps the MCP bridge reachable from the internet through an outbound SSH reverse tunnel. + * + * The connection starts on the device, so it works behind NAT, carrier networks and VPNs: + * with a VPN up, Android routes it through the VPN like any other app traffic. Relays are + * tried in turn; a dropped tunnel reconnects with backoff, and a network change (Wi-Fi to + * mobile data, a VPN reconnecting) reconnects straight away. Free relays hand out a new URL + * on every connection. + */ +class McpTunnel( + private val connector: TunnelConnector, + private val onLog: (LogLevel, String) -> Unit, + private val providers: List = TunnelProvider.DEFAULTS, + private val connectTimeoutMs: Int = 20_000, + private val pollMs: Long = 1_000, + private val backoffMs: (Int) -> Long = { failures -> minOf(30_000L, 2_000L shl minOf(failures - 1, 4)) } +) { + + private val _state = MutableStateFlow(McpTunnelState()) + val state: StateFlow = _state.asStateFlow() + + private val lock = Object() + private var generation = 0 + private var running = false + private var wakeRequested = false + private var reconnectRequested = false + private var current: TunnelConnection? = null + + fun start(localPort: Int) { + val gen: Int + synchronized(lock) { + if (running) return + running = true + reconnectRequested = false + gen = ++generation + } + _state.value = McpTunnelState(status = TunnelStatus.CONNECTING) + Thread({ run(gen, localPort) }, "runcode-mcp-tunnel").apply { isDaemon = true; start() } + } + + fun stop() { + val connection: TunnelConnection? + synchronized(lock) { + if (!running) return + running = false + generation++ + connection = current + current = null + wakeRequested = true + lock.notifyAll() + } + closeQuietly(connection) + _state.value = McpTunnelState() + onLog(LogLevel.SYSTEM, "[MCP] Public tunnel stopped.") + } + + /** The default network changed; the old socket is probably dead, so reconnect now. */ + fun onNetworkChanged() { + synchronized(lock) { + if (!running) return + reconnectRequested = true + wakeRequested = true + lock.notifyAll() + } + } + + private fun run(gen: Int, localPort: Int) { + var failures = 0 + var index = 0 + while (isCurrent(gen)) { + val provider = providers[index % providers.size] + update(gen) { it.copy(status = TunnelStatus.CONNECTING, provider = provider.name, retryInSeconds = 0) } + + val opened = try { + connector.open(provider, localPort, connectTimeoutMs) + } catch (e: Exception) { + failures++ + index++ + val reason = describe(e) + onLog(LogLevel.WARN, "[MCP] Tunnel via ${provider.name} failed: $reason") + update(gen) { it.copy(lastError = "${provider.name}: $reason", failures = failures) } + if (!pause(gen, backoffMs(failures))) break + continue + } + + if (!adopt(gen, opened.connection)) { + closeQuietly(opened.connection) + break + } + failures = 0 + update(gen) { + McpTunnelState(status = TunnelStatus.ONLINE, url = opened.url, provider = provider.name) + } + onLog(LogLevel.SYSTEM, "[MCP] Public endpoint via ${provider.name}: ${opened.url}/mcp") + + val networkChanged = hold(gen, opened.connection) + release(opened.connection) + if (!isCurrent(gen)) break + + if (networkChanged) { + onLog(LogLevel.INFO, "[MCP] Network changed; reconnecting the tunnel.") + continue + } + // Dropped after working: retry the same relay after a short pause. + failures = 1 + onLog(LogLevel.WARN, "[MCP] Tunnel via ${provider.name} dropped.") + update(gen) { it.copy(url = null, lastError = "${provider.name}: connection lost", failures = failures) } + if (!pause(gen, backoffMs(failures))) break + } + } + + /** Waits while the tunnel is up. Returns true when a network change asked for a reconnect. */ + private fun hold(gen: Int, connection: TunnelConnection): Boolean { + synchronized(lock) { + while (generation == gen && running && !reconnectRequested && connection.isAlive) { + if (!wakeRequested) lock.wait(pollMs) + wakeRequested = false + } + val changed = reconnectRequested + reconnectRequested = false + return changed + } + } + + /** Sleeps before the next attempt; a network change cuts it short. Returns false once stopped. */ + private fun pause(gen: Int, delayMs: Long) = synchronized(lock) { + if (generation != gen) return@synchronized false + update(gen) { it.copy(status = TunnelStatus.RETRYING, retryInSeconds = ((delayMs + 999) / 1000).toInt()) } + if (!wakeRequested && !reconnectRequested) lock.wait(delayMs) + wakeRequested = false + reconnectRequested = false + generation == gen && running + } + + private fun adopt(gen: Int, connection: TunnelConnection) = synchronized(lock) { + if (generation != gen || !running) return@synchronized false + current = connection + true + } + + private fun release(connection: TunnelConnection) { + synchronized(lock) { if (current === connection) current = null } + closeQuietly(connection) + } + + private fun isCurrent(gen: Int) = synchronized(lock) { generation == gen && running } + + private fun update(gen: Int, change: (McpTunnelState) -> McpTunnelState) { + synchronized(lock) { + if (generation == gen && running) _state.value = change(_state.value) + } + } + + private fun describe(e: Exception): String = + e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + + private fun closeQuietly(closeable: Closeable?) { + try { + closeable?.close() + } catch (_: Exception) { + } + } +} diff --git a/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt b/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt new file mode 100644 index 0000000..fc8e971 --- /dev/null +++ b/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt @@ -0,0 +1,127 @@ +package com.runcode.app.mcp + +import com.jcraft.jsch.ChannelShell +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session +import com.jcraft.jsch.UIKeyboardInteractive +import com.jcraft.jsch.UserInfo +import java.io.IOException +import java.io.InputStream + +/** + * Opens a reverse tunnel the way `ssh -p 443 -R0:localhost:8765 free.pinggy.io` does: remote + * forward to the local bridge, then read the public URL from the relay's shell output. + */ +class SshTunnelConnector : TunnelConnector { + + override fun open(provider: TunnelProvider, localPort: Int, timeoutMs: Int): OpenTunnel { + val session = JSch().getSession(provider.user, provider.host, provider.port) + // Free relays take no credentials and publish no host keys to pin. The tunnel URL is + // HTTPS, terminated at the relay; the bearer token is what guards the bridge. + session.setConfig("StrictHostKeyChecking", "no") + session.setConfig("PreferredAuthentications", "none,keyboard-interactive,password") + session.userInfo = EmptyCredentials + session.setPassword("") + try { + session.connect(timeoutMs) + // Keepalives notice a dead socket within about 45 seconds. + session.setServerAliveInterval(KEEPALIVE_MS) + session.setServerAliveCountMax(3) + session.setPortForwardingR(null, provider.remotePort, "127.0.0.1", localPort) + + val channel = session.openChannel("shell") as ChannelShell + channel.setPty(false) + val output = channel.inputStream + // No timeout here: with one, JSch gives up on the first wake-up of its wait loop, + // which fails about one open in fifty. Without one it polls for up to 20 seconds. + channel.connect() + + val url = awaitUrl(provider, session, output, channel, timeoutMs) + return OpenTunnel(SshConnection(session, channel, output), url) + } catch (e: Exception) { + session.disconnect() + throw e + } + } + + private fun awaitUrl( + provider: TunnelProvider, + session: Session, + output: InputStream, + channel: ChannelShell, + timeoutMs: Int + ): String { + val text = StringBuilder() + val buffer = ByteArray(4096) + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val available = output.available() + if (available > 0) { + val n = output.read(buffer, 0, minOf(available, buffer.size)) + if (n > 0 && text.length < MAX_BANNER) text.append(String(buffer, 0, n, Charsets.UTF_8)) + TunnelUrls.find(text.toString(), provider)?.let { return it } + continue + } + if (channel.isClosed || !session.isConnected) { + throw IOException(closedReason(text)) + } + Thread.sleep(100) + } + throw IOException("No public URL within ${timeoutMs / 1000}s" + tail(text)) + } + + private fun closedReason(text: CharSequence): String = "Relay closed the session" + tail(text) + + private fun tail(text: CharSequence): String { + val last = text.toString().trim().lines().lastOrNull { it.isNotBlank() }?.trim()?.take(160) + return if (last.isNullOrEmpty()) "" else ": $last" + } + + private class SshConnection( + private val session: Session, + private val channel: ChannelShell, + private val output: InputStream + ) : TunnelConnection { + + init { + // Keep reading so the relay's status lines never fill the channel window. + Thread({ + val sink = ByteArray(1024) + try { + while (output.read(sink) >= 0) Unit + } catch (_: IOException) { + } + }, "runcode-mcp-tunnel-drain").apply { isDaemon = true; start() } + } + + override val isAlive: Boolean + get() = session.isConnected && !channel.isClosed + + override fun close() { + channel.disconnect() + session.disconnect() + } + } + + /** Answers every prompt with an empty string, as pressing Enter at the ssh prompt does. */ + private object EmptyCredentials : UserInfo, UIKeyboardInteractive { + override fun getPassphrase(): String? = null + override fun getPassword(): String = "" + override fun promptPassword(message: String?) = true + override fun promptPassphrase(message: String?) = false + override fun promptYesNo(message: String?) = true + override fun showMessage(message: String?) = Unit + override fun promptKeyboardInteractive( + destination: String?, + name: String?, + instruction: String?, + prompt: Array?, + echo: BooleanArray? + ): Array = Array(prompt?.size ?: 0) { "" } + } + + private companion object { + const val KEEPALIVE_MS = 15_000 + const val MAX_BANNER = 64 * 1024 + } +} diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index a324781..8dbdf38 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -15,6 +15,8 @@ import com.runcode.app.domain.models.RuntimeEvent import com.runcode.app.domain.models.RuntimeInstance import com.runcode.app.domain.models.TableInfo import com.runcode.app.mcp.McpServerState +import com.runcode.app.mcp.McpTunnelState +import com.runcode.app.mcp.TunnelStatus import com.runcode.app.runtime.PythonEngine import com.runcode.app.settings.ProjectSettings import com.runcode.app.storage.EntryPointEffect @@ -109,6 +111,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _mcpAllowLan = MutableStateFlow(false) val mcpAllowLan: StateFlow = _mcpAllowLan.asStateFlow() + val mcpTunnelState: StateFlow = app.mcpTunnel.state + + // The tunnel outlives this view model, so read the switch back from it. + private val _mcpPublic = MutableStateFlow(app.mcpTunnel.state.value.status != TunnelStatus.OFF) + val mcpPublic: StateFlow = _mcpPublic.asStateFlow() + /** Process-wide resource snapshot, refreshed alongside device capabilities. */ private val _processStats = MutableStateFlow(ProcessStats()) val processStats: StateFlow = _processStats.asStateFlow() @@ -167,6 +175,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { app.mcpServer.stop() app.mcpServer.start(MCP_PORT, _mcpToken.value, allow) app.serviceSupervisor.setExternalHold(app.mcpServer.state.value.isRunning) + syncMcpTunnel() } } @@ -180,16 +189,32 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _userMessage.value = state.lastError?.let { "MCP bridge failed: $it" } ?: "MCP bridge listening on ${state.boundAddress}:${state.port}" } + syncMcpTunnel() // Hold the process in the foreground while the bridge is up, otherwise Android // reclaims it as soon as the user leaves the app and the client loses its server. app.serviceSupervisor.setExternalHold(app.mcpServer.state.value.isRunning) } + fun setMcpPublic(enabled: Boolean) { + _mcpPublic.value = enabled + syncMcpTunnel() + } + + /** The tunnel forwards to the bridge, so it runs only while both are switched on. */ + private fun syncMcpTunnel() { + if (_mcpPublic.value && app.mcpServer.state.value.isRunning) { + app.mcpTunnel.start(MCP_PORT) + } else { + app.mcpTunnel.stop() + } + } + fun regenerateMcpToken() { _mcpToken.value = app.regenerateMcpToken() if (app.mcpServer.state.value.isRunning) { app.mcpServer.stop() app.mcpServer.start(MCP_PORT, _mcpToken.value, _mcpAllowLan.value) + syncMcpTunnel() } _userMessage.value = "New MCP token generated. Existing clients must be updated." } diff --git a/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt index 80c6d03..909bf72 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.runcode.app.mcp.McpTools +import com.runcode.app.mcp.TunnelStatus import com.runcode.app.ui.MainViewModel import com.runcode.app.ui.theme.AccentCyan import com.runcode.app.ui.theme.AccentGreen @@ -430,6 +431,9 @@ private fun McpBridgeCard(viewModel: MainViewModel) { ) } + Spacer(modifier = Modifier.height(8.dp)) + McpPublicUrlSection(viewModel) + Spacer(modifier = Modifier.height(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -468,6 +472,88 @@ private fun McpBridgeCard(viewModel: MainViewModel) { } } +@Composable +private fun McpPublicUrlSection(viewModel: MainViewModel) { + val context = LocalContext.current + val enabled by viewModel.mcpPublic.collectAsState() + val tunnel by viewModel.mcpTunnelState.collectAsState() + val bridge by viewModel.mcpState.collectAsState() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Public URL (internet)", fontSize = 12.sp, color = TextPrimary) + Text( + text = if (enabled) { + "Anyone with the URL and token can run commands here. The relay sees the traffic." + } else { + "HTTPS link through an SSH relay. Works behind NAT and with a VPN on." + }, + fontSize = 10.sp, + color = if (enabled) Color(0xFFFFB300) else TextMuted + ) + } + Switch( + checked = enabled, + onCheckedChange = { viewModel.setMcpPublic(it) }, + modifier = Modifier.testTag("mcp_public_switch") + ) + } + + if (!enabled) return + Spacer(modifier = Modifier.height(4.dp)) + + val status = when { + !bridge.isRunning -> "Starts with the bridge" + tunnel.status == TunnelStatus.ONLINE -> "Online via ${tunnel.provider}" + tunnel.status == TunnelStatus.CONNECTING -> "Connecting via ${tunnel.provider ?: "relay"}…" + tunnel.status == TunnelStatus.RETRYING -> "Retrying in ${tunnel.retryInSeconds}s" + else -> "Off" + } + HealthRow(label = "Tunnel", value = status) + + val url = tunnel.url + if (tunnel.status == TunnelStatus.ONLINE && url != null) { + val endpoint = "$url/mcp" + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = endpoint, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = AccentGreen, + modifier = Modifier.weight(1f).testTag("mcp_public_url") + ) + TextButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("runcode_mcp_url", endpoint)) + }) { + Text("Copy", fontSize = 11.sp, color = AccentCyan) + } + } + Text( + text = "Free relays give a new URL on every reconnect.", + fontSize = 10.sp, + color = TextMuted + ) + } else if (tunnel.status != TunnelStatus.OFF) { + tunnel.lastError?.let { + Text(text = it, fontSize = 10.sp, color = AccentRed, lineHeight = 14.sp) + } + if (tunnel.failures >= 2) { + Text( + text = "Check the internet connection. If a VPN is on, make sure runcode is not " + + "excluded from it (split tunneling).", + fontSize = 10.sp, + color = TextMuted, + lineHeight = 14.sp + ) + } + } +} + @Composable fun HealthRow(label: String, value: String) { Row( diff --git a/app/src/test/java/com/runcode/app/mcp/McpTunnelTest.kt b/app/src/test/java/com/runcode/app/mcp/McpTunnelTest.kt new file mode 100644 index 0000000..955c077 --- /dev/null +++ b/app/src/test/java/com/runcode/app/mcp/McpTunnelTest.kt @@ -0,0 +1,151 @@ +package com.runcode.app.mcp + +import com.runcode.app.domain.models.LogLevel +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import java.io.IOException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class McpTunnelTest { + + private val relayA = TunnelProvider("A", "a.example", 443, "u", 0, listOf("a.example")) + private val relayB = TunnelProvider("B", "b.example", 22, "u", 80, listOf("b.example")) + private var tunnel: McpTunnel? = null + + @After fun tearDown() { + tunnel?.stop() + } + + // ------------------------------------------------------------ URL parsing + + @Test fun `pinggy banner yields the tunnel URL, not its own links`() { + val banner = """ + You are not authenticated. + Your tunnel will expire in 60 minutes. Upgrade to Pinggy Pro to get unrestricted tunnels. https://dashboard.pinggy.io + + http://rnxyz-1-2-3-4.run.pinggy-free.link + https://rnxyz-1-2-3-4.run.pinggy-free.link + """.trimIndent() + assertEquals("https://rnxyz-1-2-3-4.run.pinggy-free.link", TunnelUrls.find(banner, TunnelProvider.PINGGY)) + } + + @Test fun `localhost run banner yields the lhr life URL`() { + val banner = """ + ** your connection id is 1234, please mention it if you send me a message about an issue. ** + To set up and manage custom domains go to https://admin.localhost.run/ + More details on custom domains (and how to enable subdomains of your custom domain) at https://localhost.run/docs/custom-domains + + a1b2c3d4e5f6.lhr.life tunneled with tls termination, https://a1b2c3d4e5f6.lhr.life + """.trimIndent() + assertEquals("https://a1b2c3d4e5f6.lhr.life", TunnelUrls.find(banner, TunnelProvider.LOCALHOST_RUN)) + } + + @Test fun `ANSI colour codes around the URL are ignored`() { + val banner = "\u001B[32mhttps://abc.run.pinggy-free.link\u001B[0m\r\n" + assertEquals("https://abc.run.pinggy-free.link", TunnelUrls.find(banner, TunnelProvider.PINGGY)) + } + + @Test fun `no URL until the relay prints one`() { + assertNull(TunnelUrls.find("Connecting…\r\nhttps://pinggy.io/docs/ for help", TunnelProvider.PINGGY)) + assertNull(TunnelUrls.find("https://abc.run.pinggy-fr", TunnelProvider.PINGGY)?.takeIf { it.endsWith(".link") }) + } + + // ------------------------------------------------------------ supervision + + private class FakeConnection : TunnelConnection { + val alive = AtomicBoolean(true) + override val isAlive get() = alive.get() + override fun close() = alive.set(false) + } + + private fun newTunnel(connector: TunnelConnector) = McpTunnel( + connector = connector, + onLog = { _: LogLevel, _: String -> }, + providers = listOf(relayA, relayB), + pollMs = 20, + backoffMs = { 20L } + ).also { tunnel = it } + + private fun awaitState(tunnel: McpTunnel, predicate: (McpTunnelState) -> Boolean): McpTunnelState { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (System.nanoTime() < deadline) { + val state = tunnel.state.value + if (predicate(state)) return state + Thread.sleep(10) + } + fail("state never matched; last = ${tunnel.state.value}") + throw AssertionError() + } + + @Test fun `a failing relay falls through to the next one`() { + val tried = CopyOnWriteArrayList() + val t = newTunnel { provider, _, _ -> + tried += provider.name + if (provider == relayA) throw IOException("port 443 blocked") + OpenTunnel(FakeConnection(), "https://b.tunnel") + } + t.start(8765) + val online = awaitState(t) { it.status == TunnelStatus.ONLINE } + assertEquals("https://b.tunnel", online.url) + assertEquals("B", online.provider) + assertEquals(listOf("A", "B"), tried.take(2)) + } + + @Test fun `a dropped tunnel reconnects and publishes the new URL`() { + val connections = CopyOnWriteArrayList() + val t = newTunnel { _, _, _ -> + val c = FakeConnection().also { connections += it } + OpenTunnel(c, "https://tunnel-${connections.size}") + } + t.start(8765) + awaitState(t) { it.url == "https://tunnel-1" } + connections[0].alive.set(false) + awaitState(t) { it.status == TunnelStatus.ONLINE && it.url == "https://tunnel-2" } + } + + @Test fun `a network change replaces a tunnel that still looks alive`() { + val connections = CopyOnWriteArrayList() + val t = newTunnel { _, _, _ -> + val c = FakeConnection().also { connections += it } + OpenTunnel(c, "https://tunnel-${connections.size}") + } + t.start(8765) + awaitState(t) { it.url == "https://tunnel-1" } + t.onNetworkChanged() + awaitState(t) { it.url == "https://tunnel-2" } + assertFalse("old socket must be closed", connections[0].isAlive) + } + + @Test fun `stop closes the connection and resets the state`() { + val connection = FakeConnection() + val t = newTunnel { _, _, _ -> OpenTunnel(connection, "https://x.tunnel") } + t.start(8765) + awaitState(t) { it.status == TunnelStatus.ONLINE } + t.stop() + assertEquals(McpTunnelState(), t.state.value) + assertFalse(connection.isAlive) + } + + @Test fun `a tunnel that opens after stop is closed, not adopted`() { + val release = Object() + var go = false + val entered = java.util.concurrent.CountDownLatch(1) + val late = FakeConnection() + val t = newTunnel { _, _, _ -> + entered.countDown() + synchronized(release) { while (!go) release.wait() } + OpenTunnel(late, "https://late.tunnel") + } + t.start(8765) + assertTrue(entered.await(5, TimeUnit.SECONDS)) + t.stop() + synchronized(release) { go = true; release.notifyAll() } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (late.isAlive && System.nanoTime() < deadline) Thread.sleep(10) + assertFalse(late.isAlive) + assertEquals(TunnelStatus.OFF, t.state.value.status) + } +} diff --git a/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt b/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt new file mode 100644 index 0000000..9ab8b5b --- /dev/null +++ b/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt @@ -0,0 +1,148 @@ +package com.runcode.app.mcp + +import org.apache.sshd.common.forward.PortForwardingEventListener +import org.apache.sshd.common.session.Session +import org.apache.sshd.common.util.net.SshdSocketAddress +import org.apache.sshd.server.Environment +import org.apache.sshd.server.ExitCallback +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.auth.password.PasswordAuthenticator +import org.apache.sshd.server.channel.ChannelSession +import org.apache.sshd.server.command.Command +import org.apache.sshd.server.forward.AcceptAllForwardingFilter +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import org.apache.sshd.server.shell.ShellFactory +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.io.InputStream +import java.io.OutputStream +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Runs the real JSch connector against a local SSH server that behaves like Pinggy: any + * credentials, a remote forward on port 0, and the public URL printed on the shell. + */ +class SshTunnelConnectorTest { + + private lateinit var relay: SshServer + private lateinit var bridge: ServerSocket + private val forwardedPort = AtomicInteger(-1) + private val shellOpened = CountDownLatch(1) + + @Before fun setUp() { + bridge = ServerSocket(0) + Thread { + while (!bridge.isClosed) { + val client = try { bridge.accept() } catch (_: Exception) { break } + client.use { + it.getInputStream().read(ByteArray(1024)) + it.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\npong".toByteArray()) + } + } + }.apply { isDaemon = true; start() } + + relay = SshServer.setUpDefaultServer().apply { + port = 0 + keyPairProvider = SimpleGeneratorHostKeyProvider() + passwordAuthenticator = PasswordAuthenticator { _, _, _ -> true } + forwardingFilter = AcceptAllForwardingFilter.INSTANCE + shellFactory = ShellFactory { BannerShell() } + addPortForwardingEventListener(object : PortForwardingEventListener { + override fun establishedExplicitTunnel( + session: Session, + local: SshdSocketAddress?, + remote: SshdSocketAddress?, + localForwarding: Boolean, + boundAddress: SshdSocketAddress?, + reason: Throwable? + ) { + // A client's -R is a listener on the server, so the flag does not tell us much here. + if (boundAddress != null && boundAddress.port > 0) forwardedPort.set(boundAddress.port) + } + }) + } + relay.start() + } + + @After fun tearDown() { + relay.stop(true) + bridge.close() + } + + private inner class BannerShell : Command { + private var out: OutputStream? = null + override fun setInputStream(`in`: InputStream?) = Unit + override fun setOutputStream(out: OutputStream?) { this.out = out } + override fun setErrorStream(err: OutputStream?) = Unit + override fun setExitCallback(callback: ExitCallback?) = Unit + override fun start(channel: ChannelSession?, env: Environment?) { + out?.apply { + write("Upgrade at https://dashboard.pinggy.io\r\n".toByteArray()) + write("https://test-1-2-3-4.run.pinggy-free.link\r\n".toByteArray()) + flush() + } + shellOpened.countDown() + } + override fun destroy(channel: ChannelSession?) = Unit + } + + private fun relayProvider(remotePort: Int) = + TunnelProvider.PINGGY.copy(host = "127.0.0.1", port = relay.port, remotePort = remotePort) + + private fun requestThroughRelay(): String { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (forwardedPort.get() <= 0 && System.nanoTime() < deadline) Thread.sleep(10) + Socket("127.0.0.1", forwardedPort.get()).use { socket -> + socket.soTimeout = 5_000 + socket.getOutputStream().write("GET /health HTTP/1.1\r\nHost: x\r\n\r\n".toByteArray()) + return socket.getInputStream().readBytes().decodeToString() + } + } + + @Test fun `port 0 forward reaches the local bridge and reports the relay URL`() { + val opened = SshTunnelConnector().open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + try { + assertEquals("https://test-1-2-3-4.run.pinggy-free.link", opened.url) + assertTrue(opened.connection.isAlive) + assertTrue(requestThroughRelay().endsWith("pong")) + } finally { + opened.connection.close() + } + assertFalse(opened.connection.isAlive) + } + + @Test fun `fixed remote port works as localhost run expects`() { + val fixed = ServerSocket(0).use { it.localPort } + val opened = SshTunnelConnector().open(relayProvider(remotePort = fixed), bridge.localPort, 10_000) + try { + assertEquals(fixed, forwardedPort.get()) + assertTrue(requestThroughRelay().endsWith("pong")) + } finally { + opened.connection.close() + } + } + + @Test fun `relay stopping is seen as a dead connection`() { + val opened = SshTunnelConnector().open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + assertTrue(shellOpened.await(5, TimeUnit.SECONDS)) + relay.stop(true) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (opened.connection.isAlive && System.nanoTime() < deadline) Thread.sleep(20) + assertFalse(opened.connection.isAlive) + opened.connection.close() + } + + @Test fun `unreachable relay fails fast with a reason`() { + val closed = ServerSocket(0).use { it.localPort } + val error = runCatching { + SshTunnelConnector().open(TunnelProvider.PINGGY.copy(host = "127.0.0.1", port = closed), bridge.localPort, 3_000) + }.exceptionOrNull() + assertNotNull(error) + } +} From 3c7f000b26b02639d61fc84a76b2e380683e6152 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:19:46 +0000 Subject: [PATCH 03/15] Add diagnostics and log sharing The System screen gets a Diagnostics card that checks the app and the network on demand and produces a report to copy or share: - App: Android and ABI, storage, memory, notifications, battery optimisation, Python, whether the foreground service really holds running work, and the last crash. - Network: connection type, validation, VPN, LAN address, reserved ports, DNS, HTTPS and Telegram API reachability. - Services: whether each running web service accepts connections. Profiles that never listen are not judged by their port. - MCP bridge: a GET /health self-test and the public tunnel state. Share logs sends the report and all log lines through a FileProvider. Lines were already redacted when logged. The new run_diagnostics MCP tool returns the same report. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- app/src/main/AndroidManifest.xml | 10 + .../main/java/com/runcode/app/RuncodeApp.kt | 6 +- .../app/diagnostics/DiagnosticReport.kt | 39 ++++ .../app/diagnostics/DiagnosticRules.kt | 172 ++++++++++++++++++ .../app/diagnostics/DiagnosticsRunner.kt | 138 ++++++++++++++ .../runcode/app/diagnostics/NetworkProbes.kt | 93 ++++++++++ .../java/com/runcode/app/mcp/McpToolHost.kt | 4 +- .../main/java/com/runcode/app/mcp/McpTools.kt | 11 ++ .../app/service/RuntimeForegroundService.kt | 8 + .../java/com/runcode/app/ui/MainViewModel.kt | 57 ++++++ .../runcode/app/ui/screens/HealthScreen.kt | 95 ++++++++++ app/src/main/res/xml/file_paths.xml | 5 + .../app/diagnostics/DiagnosticRulesTest.kt | 117 ++++++++++++ .../app/diagnostics/DiagnosticsRunnerTest.kt | 53 ++++++ .../app/diagnostics/NetworkProbesTest.kt | 62 +++++++ .../com/runcode/app/mcp/McpSettingsTest.kt | 2 +- .../java/com/runcode/app/ui/LogShareTest.kt | 42 +++++ 17 files changed, 911 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/runcode/app/diagnostics/DiagnosticReport.kt create mode 100644 app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt create mode 100644 app/src/main/java/com/runcode/app/diagnostics/DiagnosticsRunner.kt create mode 100644 app/src/main/java/com/runcode/app/diagnostics/NetworkProbes.kt create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/com/runcode/app/diagnostics/DiagnosticRulesTest.kt create mode 100644 app/src/test/java/com/runcode/app/diagnostics/DiagnosticsRunnerTest.kt create mode 100644 app/src/test/java/com/runcode/app/diagnostics/NetworkProbesTest.kt create mode 100644 app/src/test/java/com/runcode/app/ui/LogShareTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a1b365a..4043216 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,6 +41,16 @@ android:value="Local developer runtime and service supervisor" /> + + + + diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index bb0b7cb..7e48e92 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -7,6 +7,7 @@ import com.chaquo.python.Python import com.runcode.app.backup.BackupManager import com.runcode.app.database.AppMetaDatabase import com.runcode.app.database.ProjectDatabaseManager +import com.runcode.app.diagnostics.DiagnosticsRunner import com.runcode.app.domain.models.LogLevel import com.runcode.app.domain.models.ProjectProfile import com.runcode.app.logging.LogManager @@ -63,6 +64,8 @@ class RuncodeApp : Application() { private set lateinit var mcpTunnel: McpTunnel private set + + val diagnostics: DiagnosticsRunner by lazy { DiagnosticsRunner(this) } lateinit var processMonitor: ProcessMonitor private set lateinit var projectArchive: ProjectArchive @@ -131,7 +134,8 @@ class RuncodeApp : Application() { terminalSession = terminalSession, runPython = ::runPythonSnippet, projectSettings = projectSettings, - onProjectChanged = { projectId -> _projectChanges.tryEmit(projectId) } + onProjectChanged = { projectId -> _projectChanges.tryEmit(projectId) }, + runDiagnostics = { diagnostics.run().toText() } ), onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } ) diff --git a/app/src/main/java/com/runcode/app/diagnostics/DiagnosticReport.kt b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticReport.kt new file mode 100644 index 0000000..1b8e286 --- /dev/null +++ b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticReport.kt @@ -0,0 +1,39 @@ +package com.runcode.app.diagnostics + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +enum class CheckStatus { PASS, WARN, FAIL, INFO } + +data class DiagnosticCheck( + val group: String, + val name: String, + val status: CheckStatus, + val detail: String +) + +data class DiagnosticReport( + val generatedAt: Long, + val appVersion: String, + val checks: List +) { + fun count(status: CheckStatus) = checks.count { it.status == status } + + val summary: String + get() = "${count(CheckStatus.PASS)} pass, ${count(CheckStatus.WARN)} warn, ${count(CheckStatus.FAIL)} fail" + + /** Plain text meant to be pasted into an issue or a chat. */ + fun toText(): String = buildString { + val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(generatedAt)) + append("runcode diagnostics — $time — v$appVersion\n") + append("Summary: $summary\n") + val width = checks.maxOfOrNull { it.name.length } ?: 0 + checks.groupBy { it.group }.forEach { (group, items) -> + append("\n[$group]\n") + items.forEach { check -> + append(" ${check.status.name.padEnd(4)} ${check.name.padEnd(width)} ${check.detail}\n") + } + } + } +} diff --git a/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt new file mode 100644 index 0000000..9b7922b --- /dev/null +++ b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt @@ -0,0 +1,172 @@ +package com.runcode.app.diagnostics + +import com.runcode.app.diagnostics.CheckStatus.FAIL +import com.runcode.app.diagnostics.CheckStatus.INFO +import com.runcode.app.diagnostics.CheckStatus.PASS +import com.runcode.app.diagnostics.CheckStatus.WARN +import com.runcode.app.domain.models.ProjectProfile + +data class DeviceSnapshot( + val apiLevel: Int, + val release: String, + val abis: List, + val freeStorageMb: Long, + val availableMemoryMb: Long, + val totalMemoryMb: Long, + val lowMemory: Boolean, + val notificationsAllowed: Boolean, + val batteryExempt: Boolean, + val pythonVersion: String?, + val lastCrash: String?, + val inForeground: Boolean, + /** Supervised services running plus the MCP bridge: anything that needs the foreground. */ + val foregroundWork: Int +) + +data class ServiceSnapshot( + val name: String, + val profile: ProjectProfile, + val port: Int, + val probe: ProbeResult? +) + +data class NetworkSnapshot( + /** Null when no network is connected at all. */ + val transports: List?, + val validated: Boolean, + val vpn: Boolean, + val lanIp: String, + val reservedPorts: Map, + val dns: ProbeResult, + val https: ProbeResult, + val telegram: ProbeResult +) + +data class BridgeSnapshot( + val running: Boolean, + val port: Int, + val health: ProbeResult?, + val tunnelEnabled: Boolean, + val tunnelOnline: Boolean, + val tunnelUrl: String?, + val tunnelError: String? +) + +/** Turns observed facts into checks. No Android and no I/O here, so it is unit tested. */ +object DiagnosticRules { + + const val APP = "App" + const val NETWORK = "Network" + const val SERVICES = "Services" + const val BRIDGE = "MCP bridge" + + /** Profiles whose runtime serves HTTP on the project's port. The rest never listen. */ + val LISTENING_PROFILES = setOf(ProjectProfile.STATIC_WEB, ProjectProfile.PYTHON_HTTP) + + fun device(s: DeviceSnapshot): List = buildList { + add(check(APP, "Android", INFO, "${s.release} (API ${s.apiLevel}), ${s.abis.joinToString()}")) + + add(when { + s.freeStorageMb < 50 -> check(APP, "Storage", FAIL, "${s.freeStorageMb} MB free — saves and logs will fail") + s.freeStorageMb < 200 -> check(APP, "Storage", WARN, "${s.freeStorageMb} MB free") + else -> check(APP, "Storage", PASS, "${s.freeStorageMb} MB free") + }) + + val memory = "${s.availableMemoryMb} of ${s.totalMemoryMb} MB available" + add(when { + s.totalMemoryMb <= 0 -> check(APP, "Memory", INFO, "not reported by Android") + s.lowMemory -> check(APP, "Memory", WARN, "$memory — Android reports low memory") + else -> check(APP, "Memory", PASS, memory) + }) + + add(if (s.notificationsAllowed) check(APP, "Notifications", PASS, "allowed") + else check(APP, "Notifications", WARN, "blocked — the running-services notification is hidden")) + + add(if (s.batteryExempt) check(APP, "Battery", PASS, "exempt from battery optimisation") + else check(APP, "Battery", WARN, "optimised — Android may stop services in the background")) + + add(if (s.pythonVersion != null) check(APP, "Python", PASS, "CPython ${s.pythonVersion} started") + else check(APP, "Python", FAIL, "embedded interpreter did not start")) + + add(when { + s.foregroundWork == 0 -> check(APP, "Foreground", INFO, "not needed — nothing is running") + s.inForeground -> check(APP, "Foreground", PASS, "held for ${s.foregroundWork} running item(s)") + else -> check(APP, "Foreground", FAIL, "work is running but the foreground service is not") + }) + + add(if (s.lastCrash == null) check(APP, "Last crash", PASS, "none recorded") + else check(APP, "Last crash", WARN, crashSummary(s.lastCrash))) + } + + /** "time · exception" from CrashReporter's header block and the trace that follows it. */ + fun crashSummary(report: String): String { + val lines = report.lines() + val time = lines.firstOrNull { it.startsWith("time:") }?.substringAfter(':')?.trim() + val headerEnd = lines.indexOfFirst { it.isBlank() } + val exception = lines.drop(headerEnd + 1).firstOrNull { it.isNotBlank() }?.trim()?.take(120) + return listOfNotNull(time, exception).joinToString(" · ").ifEmpty { "recorded" } + } + + fun network(n: NetworkSnapshot): List = buildList { + if (n.transports == null) { + add(check(NETWORK, "Connection", FAIL, "no network connected")) + } else { + val via = n.transports.joinToString(" + ").ifEmpty { "unknown" } + add(if (n.validated) check(NETWORK, "Connection", PASS, "$via, internet validated") + else check(NETWORK, "Connection", WARN, "$via, Android could not validate internet access")) + } + if (n.vpn) add(check(NETWORK, "VPN", INFO, "active — traffic leaves through the VPN")) + + add(if (n.lanIp == "127.0.0.1") check(NETWORK, "LAN address", INFO, "none — not on Wi-Fi, hotspot or Ethernet") + else check(NETWORK, "LAN address", INFO, n.lanIp)) + + add(check(NETWORK, "Reserved ports", INFO, + n.reservedPorts.entries.sortedBy { it.key }.joinToString { "${it.key}" }.ifEmpty { "none" })) + + add(probe(NETWORK, "DNS", n.dns)) + add(probe(NETWORK, "HTTPS", n.https)) + // Blocked on some networks; only bots need it, so it is a warning. + add(if (n.telegram.ok) check(NETWORK, "Telegram API", PASS, "${n.telegram.detail} in ${n.telegram.millis} ms") + else check(NETWORK, "Telegram API", WARN, "unreachable (${n.telegram.detail}) — bots need it; a VPN may be required")) + } + + fun services(list: List): List { + if (list.isEmpty()) return listOf(check(SERVICES, "Running", INFO, "no services running")) + return list.map { s -> + when { + s.profile !in LISTENING_PROFILES -> + check(SERVICES, s.name, INFO, "${s.profile.displayName}, does not serve a port") + s.probe == null -> check(SERVICES, s.name, INFO, "port ${s.port} not probed") + s.probe.ok -> check(SERVICES, s.name, PASS, "port ${s.port} accepts connections") + else -> check(SERVICES, s.name, FAIL, "port ${s.port} is not listening (${s.probe.detail})") + } + } + } + + fun bridge(b: BridgeSnapshot): List = buildList { + if (!b.running) { + add(check(BRIDGE, "Bridge", INFO, "stopped")) + } else { + val health = b.health + add(when { + health == null -> check(BRIDGE, "Bridge", INFO, "port ${b.port}, not probed") + health.ok -> check(BRIDGE, "Bridge", PASS, "port ${b.port}, ${health.detail}") + else -> check(BRIDGE, "Bridge", FAIL, "port ${b.port} did not answer (${health.detail})") + }) + } + if (b.tunnelEnabled) { + add(when { + b.tunnelOnline -> check(BRIDGE, "Public URL", PASS, "${b.tunnelUrl}/mcp") + !b.running -> check(BRIDGE, "Public URL", INFO, "waits for the bridge") + else -> check(BRIDGE, "Public URL", WARN, b.tunnelError ?: "connecting") + }) + } + } + + private fun probe(group: String, name: String, result: ProbeResult) = + if (result.ok) check(group, name, PASS, "${result.detail} in ${result.millis} ms") + else check(group, name, FAIL, result.detail) + + private fun check(group: String, name: String, status: CheckStatus, detail: String) = + DiagnosticCheck(group, name, status, detail) +} diff --git a/app/src/main/java/com/runcode/app/diagnostics/DiagnosticsRunner.kt b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticsRunner.kt new file mode 100644 index 0000000..abb47bc --- /dev/null +++ b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticsRunner.kt @@ -0,0 +1,138 @@ +package com.runcode.app.diagnostics + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import com.runcode.app.BuildConfig +import com.runcode.app.RuncodeApp +import com.runcode.app.mcp.TunnelStatus +import com.runcode.app.runtime.PythonEngine +import com.runcode.app.service.RuntimeForegroundService +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.Future + +/** + * Collects the facts for a diagnostics report from Android and the running app, runs the + * network probes in parallel, and hands everything to [DiagnosticRules]. + * + * Blocking: call it off the main thread. The slowest probe bounds it at about 8 seconds. + */ +class DiagnosticsRunner(private val app: RuncodeApp) { + + fun run(): DiagnosticReport { + val pool = Executors.newFixedThreadPool(6) { r -> Thread(r, "runcode-diagnostics").apply { isDaemon = true } } + try { + fun async(block: () -> T): Future = pool.submit(Callable(block)) + + val dns = async { NetworkProbes.resolves(DNS_HOST) } + val https = async { firstReachable(HTTPS_TARGETS) } + val telegram = async { NetworkProbes.httpReachable(TELEGRAM_URL) } + + val bridge = app.mcpServer.state.value + val health = if (bridge.isRunning) async { NetworkProbes.mcpHealth(bridge.port) } else null + + val running = app.serviceSupervisor.instances.value.values.filter { it.isRunning } + val serviceProbes = running.map { instance -> + val probe = if (instance.profile in DiagnosticRules.LISTENING_PROFILES && instance.port > 0) { + async { NetworkProbes.tcpAccepts("127.0.0.1", instance.port) } + } else null + instance to probe + } + + val device = deviceSnapshot(foregroundWork = running.size + if (bridge.isRunning) 1 else 0) + val network = networkSnapshot(dns.get(), https.get(), telegram.get()) + val services = serviceProbes.map { (instance, probe) -> + ServiceSnapshot(instance.projectName, instance.profile, instance.port, probe?.get()) + } + val tunnel = app.mcpTunnel.state.value + val bridgeSnapshot = BridgeSnapshot( + running = bridge.isRunning, + port = bridge.port, + health = health?.get(), + tunnelEnabled = tunnel.status != TunnelStatus.OFF, + tunnelOnline = tunnel.status == TunnelStatus.ONLINE, + tunnelUrl = tunnel.url, + tunnelError = tunnel.lastError + ) + + val checks = DiagnosticRules.device(device) + + DiagnosticRules.network(network) + + DiagnosticRules.services(services) + + DiagnosticRules.bridge(bridgeSnapshot) + return DiagnosticReport( + System.currentTimeMillis(), + "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + checks + ) + } finally { + pool.shutdownNow() + } + } + + private fun deviceSnapshot(foregroundWork: Int): DeviceSnapshot { + val caps = app.compatibilityManager.getCapabilities(runningServicesCount = 0, wakeLockActive = false) + return DeviceSnapshot( + apiLevel = caps.androidApi, + release = caps.releaseVersion, + abis = caps.supportedAbis, + freeStorageMb = caps.freeStorageMb, + availableMemoryMb = caps.availableMemoryMb, + totalMemoryMb = caps.totalMemoryMb, + lowMemory = caps.isLowMemory, + notificationsAllowed = caps.notificationsAllowed, + batteryExempt = caps.isIgnoringBatteryOptimizations, + pythonVersion = if (app.isPythonAvailable) PythonEngine.pythonVersion() else null, + lastCrash = app.lastCrashReport(), + inForeground = RuntimeForegroundService.isInForeground, + foregroundWork = foregroundWork + ) + } + + private fun networkSnapshot(dns: ProbeResult, https: ProbeResult, telegram: ProbeResult): NetworkSnapshot { + val connectivity = app.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val caps = connectivity?.activeNetwork?.let { connectivity.getNetworkCapabilities(it) } + val transports = caps?.let { c -> + TRANSPORTS.filter { (transport, _) -> c.hasTransport(transport) }.map { it.second } + } + return NetworkSnapshot( + transports = transports, + validated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true, + vpn = caps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true, + lanIp = app.portManager.getLanIp(), + reservedPorts = app.portManager.getActiveReservations(), + dns = dns, + https = https, + telegram = telegram + ) + } + + /** Some networks block one big provider but not another, so either one counts. */ + private fun firstReachable(urls: List): ProbeResult { + var last: ProbeResult? = null + for (url in urls) { + val result = NetworkProbes.httpReachable(url) + if (result.ok) return result.copy(detail = "${result.detail} from ${hostOf(url)}") + last = result + } + return last!!.copy(detail = "no HTTPS site answered (${last.detail})") + } + + private fun hostOf(url: String) = url.substringAfter("://").substringBefore('/') + + private companion object { + const val DNS_HOST = "connectivitycheck.gstatic.com" + const val TELEGRAM_URL = "https://api.telegram.org/" + val HTTPS_TARGETS = listOf( + "https://connectivitycheck.gstatic.com/generate_204", + "https://www.cloudflare.com/cdn-cgi/trace" + ) + val TRANSPORTS = listOf( + NetworkCapabilities.TRANSPORT_WIFI to "Wi-Fi", + NetworkCapabilities.TRANSPORT_CELLULAR to "mobile data", + NetworkCapabilities.TRANSPORT_ETHERNET to "Ethernet", + NetworkCapabilities.TRANSPORT_VPN to "VPN", + NetworkCapabilities.TRANSPORT_BLUETOOTH to "Bluetooth" + ) + } +} diff --git a/app/src/main/java/com/runcode/app/diagnostics/NetworkProbes.kt b/app/src/main/java/com/runcode/app/diagnostics/NetworkProbes.kt new file mode 100644 index 0000000..05bc174 --- /dev/null +++ b/app/src/main/java/com/runcode/app/diagnostics/NetworkProbes.kt @@ -0,0 +1,93 @@ +package com.runcode.app.diagnostics + +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URL +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** Outcome of a probe: ok plus what was observed, or why it failed. */ +data class ProbeResult(val ok: Boolean, val detail: String, val millis: Long) + +/** Real network probes. Each one has a hard deadline so a report never hangs. */ +object NetworkProbes { + + /** Does something accept TCP connections on host:port? */ + fun tcpAccepts(host: String, port: Int, timeoutMs: Int = 2_000): ProbeResult = timed { + Socket().use { it.connect(InetSocketAddress(host, port), timeoutMs) } + "accepts connections" + } + + /** DNS lookup with a deadline; InetAddress has no timeout of its own. */ + fun resolves(host: String, timeoutMs: Long = 5_000): ProbeResult = timed { + val pool = Executors.newSingleThreadExecutor { r -> Thread(r, "runcode-dns").apply { isDaemon = true } } + try { + val addresses = pool.submit(Callable { InetAddress.getAllByName(host) }) + .get(timeoutMs, TimeUnit.MILLISECONDS) + addresses.joinToString(", ") { it.hostAddress ?: "?" }.take(80) + } catch (_: TimeoutException) { + throw IllegalStateException("no answer within ${timeoutMs / 1000}s") + } finally { + pool.shutdownNow() + } + } + + /** + * Any HTTP response at all proves the host is reachable: DNS, TCP, TLS and HTTP all worked. + * Redirects are not followed, so a 301 counts as reachable instead of chasing it. + */ + fun httpReachable(url: String, timeoutMs: Int = 6_000): ProbeResult = timed { + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.connectTimeout = timeoutMs + connection.readTimeout = timeoutMs + connection.instanceFollowRedirects = false + connection.requestMethod = "GET" + connection.setRequestProperty("User-Agent", "runcode-diagnostics") + "HTTP ${connection.responseCode}" + } finally { + connection.disconnect() + } + } + + /** Self-test of the MCP bridge's unauthenticated health route. */ + fun mcpHealth(port: Int, timeoutMs: Int = 3_000): ProbeResult { + val result = httpBody("http://127.0.0.1:$port/health", timeoutMs) + if (!result.ok) return result + return if (result.detail.contains("runcode-mcp")) { + result.copy(detail = "GET /health answered") + } else { + result.copy(ok = false, detail = "unexpected answer: ${result.detail.take(80)}") + } + } + + private fun httpBody(url: String, timeoutMs: Int): ProbeResult = timed { + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.connectTimeout = timeoutMs + connection.readTimeout = timeoutMs + val code = connection.responseCode + check(code == 200) { "HTTP $code" } + connection.inputStream.bufferedReader().use { it.readText() } + } finally { + connection.disconnect() + } + } + + private inline fun timed(block: () -> String): ProbeResult { + val start = System.nanoTime() + return try { + val detail = block() + ProbeResult(true, detail, elapsed(start)) + } catch (e: Exception) { + val reason = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + ProbeResult(false, reason, elapsed(start)) + } + } + + private fun elapsed(start: Long) = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) +} diff --git a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt index 8e66c3f..b4441f7 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt @@ -24,7 +24,9 @@ class McpToolHost( val runPython: (code: String, workingDir: File) -> String, val projectSettings: ProjectSettingsManager, /** Tells the UI a project's files or settings changed underneath it. */ - val onProjectChanged: (projectId: String) -> Unit + val onProjectChanged: (projectId: String) -> Unit, + /** Runs the same checks as the System screen and returns the plain-text report. */ + val runDiagnostics: () -> String ) { suspend fun projectOrNull(projectId: String): Project? = appMetaDatabase.getAllProjects().find { it.id == projectId } diff --git a/app/src/main/java/com/runcode/app/mcp/McpTools.kt b/app/src/main/java/com/runcode/app/mcp/McpTools.kt index d0195e4..838ce8d 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpTools.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpTools.kt @@ -224,6 +224,16 @@ object McpTools { ) ) + tools.put( + tool( + "run_diagnostics", + "Check the device and network: storage, memory, notification and battery settings, Python, " + + "the foreground service, the last crash, DNS, HTTPS, Telegram API reachability, whether each " + + "running web service accepts connections, and the bridge's own health. Takes up to ~10 s.", + JSONObject() + ) + ) + return tools } @@ -262,6 +272,7 @@ object McpTools { args.getString("sql"), args.optString("database").ifBlank { null } ) + "run_diagnostics" -> textResult(host.runDiagnostics()) else -> errorResult("Unknown tool: $name") } } catch (e: Exception) { diff --git a/app/src/main/java/com/runcode/app/service/RuntimeForegroundService.kt b/app/src/main/java/com/runcode/app/service/RuntimeForegroundService.kt index 33f9acf..9dfb6b3 100644 --- a/app/src/main/java/com/runcode/app/service/RuntimeForegroundService.kt +++ b/app/src/main/java/com/runcode/app/service/RuntimeForegroundService.kt @@ -31,6 +31,11 @@ class RuntimeForegroundService : Service() { const val ACTION_STOP_ALL = "com.runcode.app.action.STOP_ALL" const val EXTRA_RUNNING_COUNT = "extra_running_count" const val EXTRA_BRIDGE_ACTIVE = "extra_bridge_active" + + /** Whether startForeground() last succeeded. Diagnostics reports this, not the intent. */ + @Volatile + var isInForeground: Boolean = false + private set } private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -73,6 +78,7 @@ class RuntimeForegroundService : Service() { } else { startForeground(NOTIFICATION_ID, notification) } + isInForeground = true } catch (_: Exception) { // e.g. ForegroundServiceStartNotAllowedException when the app is in the background. // The runtimes keep going; we just cannot show the ongoing notification. @@ -80,11 +86,13 @@ class RuntimeForegroundService : Service() { } private fun shutdown() { + isInForeground = false stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } override fun onDestroy() { + isInForeground = false serviceScope.cancel() super.onDestroy() } diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index 8dbdf38..8028b0e 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -1,8 +1,10 @@ package com.runcode.app.ui import android.app.Application +import android.content.Intent import android.net.Uri import android.provider.OpenableColumns +import androidx.core.content.FileProvider import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.runcode.app.RuncodeApp @@ -14,6 +16,7 @@ import com.runcode.app.domain.models.QueryResult import com.runcode.app.domain.models.RuntimeEvent import com.runcode.app.domain.models.RuntimeInstance import com.runcode.app.domain.models.TableInfo +import com.runcode.app.diagnostics.DiagnosticReport import com.runcode.app.mcp.McpServerState import com.runcode.app.mcp.McpTunnelState import com.runcode.app.mcp.TunnelStatus @@ -34,6 +37,9 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -129,6 +135,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val threads: Int = 0 ) + private val _diagnostics = MutableStateFlow(null) + val diagnostics: StateFlow = _diagnostics.asStateFlow() + + private val _diagnosticsRunning = MutableStateFlow(false) + val diagnosticsRunning: StateFlow = _diagnosticsRunning.asStateFlow() + private val _lastCrash = MutableStateFlow(app.lastCrashReport()) val lastCrash: StateFlow = _lastCrash.asStateFlow() @@ -222,6 +234,51 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun mcpLanAddress(): String = app.portManager.getLanIp() + // ---------------------------------------------------------------- diagnostics + + fun runDiagnostics() { + if (_diagnosticsRunning.value) return + _diagnosticsRunning.value = true + viewModelScope.launch(Dispatchers.IO) { + try { + _diagnostics.value = app.diagnostics.run() + } catch (e: Exception) { + _userMessage.value = "Diagnostics failed: ${e.message}" + } finally { + _diagnosticsRunning.value = false + } + } + } + + /** + * Writes the latest diagnostics report and every log line in memory to a shareable file. + * Secrets were already redacted when each line was logged. + */ + fun logShareIntent(): Intent? { + return try { + val dir = File(app.cacheDir, "shared").apply { mkdirs() } + dir.listFiles()?.forEach { it.delete() } + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + val file = File(dir, "runcode-logs-$stamp.txt") + file.writeText(buildString { + _diagnostics.value?.let { append(it.toText()).append("\n") } + append("---- logs ----\n") + append(app.logManager.exportLogs().ifEmpty { "(no log lines)\n" }) + }) + val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", file) + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "runcode logs $stamp") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + Intent.createChooser(send, "Share runcode logs") + } catch (e: Exception) { + _userMessage.value = "Could not prepare logs: ${e.message}" + null + } + } + fun dismissCrashReport() { app.clearCrashReport() _lastCrash.value = null diff --git a/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt index f3e6d33..dded96c 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt @@ -60,6 +60,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.runcode.app.diagnostics.CheckStatus +import com.runcode.app.diagnostics.DiagnosticCheck import com.runcode.app.mcp.McpTools import com.runcode.app.mcp.TunnelStatus import com.runcode.app.ui.MainViewModel @@ -207,6 +209,10 @@ fun HealthScreen( CrashReportCard(viewModel) } + item { + DiagnosticsCard(viewModel) + } + // MCP Bridge item { McpBridgeCard(viewModel) @@ -330,6 +336,95 @@ private fun CrashReportCard(viewModel: MainViewModel) { } } +/** Runs every check on demand and turns the result into something a user can paste. */ +@Composable +private fun DiagnosticsCard(viewModel: MainViewModel) { + val context = LocalContext.current + val report by viewModel.diagnostics.collectAsState() + val running by viewModel.diagnosticsRunning.collectAsState() + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Diagnostics", fontWeight = FontWeight.Bold, color = AccentCyan, fontSize = 14.sp) + Text( + text = report?.summary ?: "Checks settings, storage, Python, network, service ports and the MCP bridge.", + fontSize = 11.sp, + color = TextSecondary + ) + + report?.let { current -> + Spacer(modifier = Modifier.height(8.dp)) + current.checks.groupBy { it.group }.forEach { (group, checks) -> + Text(group, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = TextPrimary, + modifier = Modifier.padding(top = 6.dp, bottom = 2.dp)) + checks.forEach { check -> DiagnosticRow(check) } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = { viewModel.runDiagnostics() }, + enabled = !running, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("diagnostics_run_btn") + ) { + Text(if (running) "Checking…" else if (report == null) "Run" else "Run again", + color = Color.Black, fontWeight = FontWeight.Bold) + } + report?.let { current -> + TextButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("runcode_diagnostics", current.toText())) + }) { + Text("Copy", fontSize = 11.sp, color = AccentCyan) + } + } + TextButton(onClick = { + viewModel.logShareIntent()?.let { intent -> + try { + context.startActivity(intent) + } catch (_: android.content.ActivityNotFoundException) { + } + } + }) { + Text("Share logs", fontSize = 11.sp, color = AccentCyan) + } + } + } + } +} + +@Composable +private fun DiagnosticRow(check: DiagnosticCheck) { + val color = when (check.status) { + CheckStatus.PASS -> AccentGreen + CheckStatus.WARN -> Color(0xFFFFB300) + CheckStatus.FAIL -> AccentRed + CheckStatus.INFO -> TextMuted + } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.Top) { + Text( + text = check.status.name, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = color, + modifier = Modifier.width(36.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text(check.name, fontSize = 11.sp, color = TextPrimary) + Text(check.detail, fontSize = 10.sp, color = TextSecondary, lineHeight = 13.sp) + } + } +} + @Composable private fun McpBridgeCard(viewModel: MainViewModel) { val context = LocalContext.current diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c2dc47e --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/runcode/app/diagnostics/DiagnosticRulesTest.kt b/app/src/test/java/com/runcode/app/diagnostics/DiagnosticRulesTest.kt new file mode 100644 index 0000000..5d73518 --- /dev/null +++ b/app/src/test/java/com/runcode/app/diagnostics/DiagnosticRulesTest.kt @@ -0,0 +1,117 @@ +package com.runcode.app.diagnostics + +import com.runcode.app.domain.models.ProjectProfile +import org.junit.Assert.* +import org.junit.Test + +class DiagnosticRulesTest { + + private val ok = ProbeResult(true, "HTTP 204", 40) + private val down = ProbeResult(false, "connect timed out", 6000) + + private val healthyDevice = DeviceSnapshot( + apiLevel = 36, release = "16", abis = listOf("arm64-v8a"), + freeStorageMb = 4000, availableMemoryMb = 3000, totalMemoryMb = 8000, lowMemory = false, + notificationsAllowed = true, batteryExempt = true, pythonVersion = "3.12.4", + lastCrash = null, inForeground = false, foregroundWork = 0 + ) + + private val healthyNetwork = NetworkSnapshot( + transports = listOf("Wi-Fi"), validated = true, vpn = false, lanIp = "192.168.1.5", + reservedPorts = mapOf(8080 to "svc"), dns = ok, https = ok, telegram = ok + ) + + private fun List.status(name: String) = single { it.name == name }.status + + @Test fun `a healthy device has no warnings or failures`() { + val checks = DiagnosticRules.device(healthyDevice) + DiagnosticRules.network(healthyNetwork) + assertTrue(checks.joinToString("\n"), checks.none { it.status == CheckStatus.WARN || it.status == CheckStatus.FAIL }) + } + + @Test fun `blocked notifications and battery optimisation are warnings`() { + val checks = DiagnosticRules.device(healthyDevice.copy(notificationsAllowed = false, batteryExempt = false)) + assertEquals(CheckStatus.WARN, checks.status("Notifications")) + assertEquals(CheckStatus.WARN, checks.status("Battery")) + } + + @Test fun `running work without the foreground service fails`() { + val checks = DiagnosticRules.device(healthyDevice.copy(foregroundWork = 2, inForeground = false)) + assertEquals(CheckStatus.FAIL, checks.status("Foreground")) + assertEquals(CheckStatus.PASS, DiagnosticRules.device(healthyDevice.copy(foregroundWork = 2, inForeground = true)).status("Foreground")) + assertEquals(CheckStatus.INFO, DiagnosticRules.device(healthyDevice).status("Foreground")) + } + + @Test fun `storage thresholds`() { + assertEquals(CheckStatus.WARN, DiagnosticRules.device(healthyDevice.copy(freeStorageMb = 150)).status("Storage")) + assertEquals(CheckStatus.FAIL, DiagnosticRules.device(healthyDevice.copy(freeStorageMb = 20)).status("Storage")) + } + + @Test fun `missing python fails`() { + assertEquals(CheckStatus.FAIL, DiagnosticRules.device(healthyDevice.copy(pythonVersion = null)).status("Python")) + } + + @Test fun `crash summary shows the time and the exception line`() { + val report = """ + runcode crash report + time: 2026-09-30 10:11:12 + thread: main + device: Samsung SM-A546E + android: 16 (API 36) + abi: arm64-v8a + + java.lang.IndexOutOfBoundsException: Index 1 out of bounds for length 1 + at androidx.compose.material3.TabRowKt.foo(TabRow.kt:12) + """.trimIndent() + assertEquals( + "2026-09-30 10:11:12 · java.lang.IndexOutOfBoundsException: Index 1 out of bounds for length 1", + DiagnosticRules.crashSummary(report) + ) + assertEquals(CheckStatus.WARN, DiagnosticRules.device(healthyDevice.copy(lastCrash = report)).status("Last crash")) + } + + @Test fun `no network at all fails, unvalidated warns`() { + assertEquals(CheckStatus.FAIL, DiagnosticRules.network(healthyNetwork.copy(transports = null)).status("Connection")) + assertEquals(CheckStatus.WARN, DiagnosticRules.network(healthyNetwork.copy(validated = false)).status("Connection")) + } + + @Test fun `a blocked Telegram API only warns, broken DNS fails`() { + val checks = DiagnosticRules.network(healthyNetwork.copy(telegram = down, dns = down)) + assertEquals(CheckStatus.WARN, checks.status("Telegram API")) + assertEquals(CheckStatus.FAIL, checks.status("DNS")) + } + + @Test fun `vpn is reported`() { + val checks = DiagnosticRules.network(healthyNetwork.copy(vpn = true, transports = listOf("Wi-Fi", "VPN"))) + assertEquals(CheckStatus.INFO, checks.status("VPN")) + assertTrue(checks.single { it.name == "Connection" }.detail.startsWith("Wi-Fi + VPN")) + } + + @Test fun `only web profiles are judged by their port`() { + val checks = DiagnosticRules.services(listOf( + ServiceSnapshot("site", ProjectProfile.STATIC_WEB, 8080, ProbeResult(false, "Connection refused", 1)), + ServiceSnapshot("api", ProjectProfile.PYTHON_HTTP, 8081, ProbeResult(true, "accepts connections", 1)), + ServiceSnapshot("bot", ProjectProfile.TELEGRAM_BOT, 8082, null) + )) + assertEquals(CheckStatus.FAIL, checks.status("site")) + assertEquals(CheckStatus.PASS, checks.status("api")) + assertEquals(CheckStatus.INFO, checks.status("bot")) + } + + @Test fun `bridge health and tunnel state`() { + val bridge = BridgeSnapshot(true, 8765, ProbeResult(false, "Connection refused", 1), true, false, null, "Pinggy: timeout") + val checks = DiagnosticRules.bridge(bridge) + assertEquals(CheckStatus.FAIL, checks.status("Bridge")) + assertEquals(CheckStatus.WARN, checks.status("Public URL")) + val online = DiagnosticRules.bridge(bridge.copy(health = ok, tunnelOnline = true, tunnelUrl = "https://x.run.pinggy-free.link")) + assertEquals("https://x.run.pinggy-free.link/mcp", online.single { it.name == "Public URL" }.detail) + } + + @Test fun `report text groups checks and counts them`() { + val report = DiagnosticReport(0, "1.5.0 (6)", DiagnosticRules.device(healthyDevice.copy(batteryExempt = false))) + val text = report.toText() + assertTrue(text.contains("v1.5.0 (6)")) + assertTrue(text.contains("[App]")) + assertTrue(text.contains("WARN Battery")) + assertEquals(1, report.count(CheckStatus.WARN)) + } +} diff --git a/app/src/test/java/com/runcode/app/diagnostics/DiagnosticsRunnerTest.kt b/app/src/test/java/com/runcode/app/diagnostics/DiagnosticsRunnerTest.kt new file mode 100644 index 0000000..496749e --- /dev/null +++ b/app/src/test/java/com/runcode/app/diagnostics/DiagnosticsRunnerTest.kt @@ -0,0 +1,53 @@ +package com.runcode.app.diagnostics + +import com.runcode.app.RuncodeApp +import com.runcode.app.mcp.McpTools +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.net.ServerSocket + +/** The whole runner against the real application object and a real bridge listener. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class DiagnosticsRunnerTest { + + private val app get() = RuntimeEnvironment.getApplication() as RuncodeApp + + @After fun tearDown() { + app.mcpServer.stop() + } + + @Test fun `report covers every group and self-tests a running bridge`() { + val port = ServerSocket(0).use { it.localPort } + app.mcpServer.start(port, "test-token", allowLan = false) + assertTrue(app.mcpServer.state.value.isRunning) + + val report = app.diagnostics.run() + println(report.toText()) + val groups = report.checks.map { it.group }.toSet() + assertEquals(setOf(DiagnosticRules.APP, DiagnosticRules.NETWORK, DiagnosticRules.SERVICES, DiagnosticRules.BRIDGE), groups) + + val bridge = report.checks.single { it.name == "Bridge" } + assertEquals(bridge.detail, CheckStatus.PASS, bridge.status) + // Chaquopy does not run on the JVM, and the report must say so rather than pretend. + assertEquals(CheckStatus.FAIL, report.checks.single { it.name == "Python" }.status) + // The bridge is running but nothing put the app in the foreground in this test. + assertEquals(CheckStatus.FAIL, report.checks.single { it.name == "Foreground" }.status) + } + + @Test fun `stopped bridge is reported as information, not failure`() { + val bridge = app.diagnostics.run().checks.single { it.name == "Bridge" } + assertEquals(CheckStatus.INFO, bridge.status) + } + + @Test fun `run_diagnostics is listed and callable over MCP`() { + val names = (0 until McpTools.descriptors().length()).map { McpTools.descriptors().getJSONObject(it).getString("name") } + assertTrue("run_diagnostics" in names) + } +} diff --git a/app/src/test/java/com/runcode/app/diagnostics/NetworkProbesTest.kt b/app/src/test/java/com/runcode/app/diagnostics/NetworkProbesTest.kt new file mode 100644 index 0000000..a279ef6 --- /dev/null +++ b/app/src/test/java/com/runcode/app/diagnostics/NetworkProbesTest.kt @@ -0,0 +1,62 @@ +package com.runcode.app.diagnostics + +import org.junit.Assert.* +import org.junit.Test +import java.net.ServerSocket + +class NetworkProbesTest { + + /** Answers every connection with [response] until closed. */ + private fun httpServer(response: String): ServerSocket { + val server = ServerSocket(0) + Thread { + while (!server.isClosed) { + val client = try { server.accept() } catch (_: Exception) { break } + client.use { + it.getInputStream().read(ByteArray(2048)) + it.getOutputStream().write(response.toByteArray()) + } + } + }.apply { isDaemon = true; start() } + return server + } + + private fun closedPort() = ServerSocket(0).use { it.localPort } + + @Test fun `tcp probe sees a listener and a closed port`() { + ServerSocket(0).use { server -> + assertTrue(NetworkProbes.tcpAccepts("127.0.0.1", server.localPort).ok) + } + val closed = NetworkProbes.tcpAccepts("127.0.0.1", closedPort()) + assertFalse(closed.ok) + assertTrue(closed.detail.isNotBlank()) + } + + @Test fun `any HTTP answer counts as reachable, redirects are not followed`() { + httpServer("HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/\r\nContent-Length: 0\r\n\r\n").use { server -> + val result = NetworkProbes.httpReachable("http://127.0.0.1:${server.localPort}/") + assertTrue(result.detail, result.ok) + assertEquals("HTTP 302", result.detail) + } + assertFalse(NetworkProbes.httpReachable("http://127.0.0.1:${closedPort()}/", 2_000).ok) + } + + @Test fun `mcp health accepts only the bridge's own answer`() { + val body = """{"status":"ok","service":"runcode-mcp"}""" + httpServer("HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n$body").use { server -> + assertTrue(NetworkProbes.mcpHealth(server.localPort).ok) + } + httpServer("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello").use { server -> + val result = NetworkProbes.mcpHealth(server.localPort) + assertFalse(result.ok) + assertTrue(result.detail.startsWith("unexpected answer")) + } + assertFalse(NetworkProbes.mcpHealth(closedPort()).ok) + } + + @Test fun `unresolvable names fail with a reason`() { + val result = NetworkProbes.resolves("does-not-exist.invalid", 5_000) + assertFalse(result.ok) + assertTrue(result.detail.isNotBlank()) + } +} diff --git a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt index 5a1b889..27a56cb 100644 --- a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt @@ -31,7 +31,7 @@ class McpSettingsTest { runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } host = McpToolHost(app.projectStorage, app.appMetaDatabase, app.projectDatabaseManager, app.serviceSupervisor, app.logManager, app.terminalSession, { _, _ -> "unused" }, - app.projectSettings, { notifications.add(it) }) + app.projectSettings, { notifications.add(it) }, { "diagnostics report" }) } @Test fun `mcp replaces plain variables but preserves secret references`() = runBlocking { diff --git a/app/src/test/java/com/runcode/app/ui/LogShareTest.kt b/app/src/test/java/com/runcode/app/ui/LogShareTest.kt new file mode 100644 index 0000000..15d8475 --- /dev/null +++ b/app/src/test/java/com/runcode/app/ui/LogShareTest.kt @@ -0,0 +1,42 @@ +package com.runcode.app.ui + +import android.content.Intent +import android.net.Uri +import com.runcode.app.RuncodeApp +import com.runcode.app.domain.models.LogLevel +import com.runcode.app.security.SecretRedactor +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class LogShareTest { + + @Test fun `shared logs go through the file provider with secrets redacted`() { + val app = RuntimeEnvironment.getApplication() as RuncodeApp + SecretRedactor.register("bot-token-123456:ABCDEF") + app.logManager.log("p1", "Bot", LogLevel.INFO, "connecting with bot-token-123456:ABCDEF") + app.logManager.log("p1", "Bot", LogLevel.WARN, "second line") + + val chooser = MainViewModel(app).logShareIntent() + assertNotNull(chooser) + @Suppress("DEPRECATION") + val send = chooser!!.getParcelableExtra(Intent.EXTRA_INTENT)!! + assertEquals(Intent.ACTION_SEND, send.action) + assertTrue(send.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + + @Suppress("DEPRECATION") + val uri = send.getParcelableExtra(Intent.EXTRA_STREAM)!! + assertEquals("content", uri.scheme) + assertEquals("${app.packageName}.files", uri.authority) + + val text = app.contentResolver.openInputStream(uri)!!.bufferedReader().use { it.readText() } + assertTrue(text.contains("second line")) + assertTrue(text.contains("[REDACTED]")) + assertFalse(text.contains("bot-token-123456:ABCDEF")) + } +} From 70bf3140127601a98e35650fc343b565a9a30ac1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:25:54 +0000 Subject: [PATCH 04/15] Add backups to a user-chosen folder The Backups screen can now save to any folder picked with the system folder picker: device storage, a memory card, or a cloud app that offers folders such as Google Drive or OneDrive. No account or API key is involved, and access is kept across restarts. - Back up to folder creates a fresh backup, copies it, then reads the copy back and compares SHA-256. A mismatch removes the copy. - Backups in the folder are listed with Verify and Restore; restore goes through the existing checksum verification. - Optional daily backup of all projects, off by default, while the app is running. It keeps the last 7 automatic copies per project and never deletes manual ones. - New backup_project MCP tool, with to_folder for the folder copy. The screen is now one scrolling list, with the verification result near the top so it is visible without scrolling. Also fixes manifests built from string templates: a quote in a project name produced invalid JSON and broke restore. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- .../main/java/com/runcode/app/RuncodeApp.kt | 49 +- .../app/backup/BackupFolderSettings.kt | 97 ++++ .../com/runcode/app/backup/BackupManager.kt | 50 +- .../com/runcode/app/backup/BackupStore.kt | 85 ++++ .../com/runcode/app/backup/FolderBackups.kt | 123 +++++ .../java/com/runcode/app/mcp/McpToolHost.kt | 4 +- .../main/java/com/runcode/app/mcp/McpTools.kt | 19 + .../java/com/runcode/app/ui/MainViewModel.kt | 80 +++ .../runcode/app/ui/screens/BackupsScreen.kt | 463 ++++++++++++------ .../runcode/app/backup/FolderBackupsTest.kt | 172 +++++++ .../runcode/app/backup/SafBackupStoreTest.kt | 118 +++++ .../com/runcode/app/mcp/McpSettingsTest.kt | 2 +- 12 files changed, 1071 insertions(+), 191 deletions(-) create mode 100644 app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt create mode 100644 app/src/main/java/com/runcode/app/backup/BackupStore.kt create mode 100644 app/src/main/java/com/runcode/app/backup/FolderBackups.kt create mode 100644 app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt create mode 100644 app/src/test/java/com/runcode/app/backup/SafBackupStoreTest.kt diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index 7e48e92..9da73d8 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -4,11 +4,14 @@ import android.app.Application import android.net.ConnectivityManager import android.net.Network import com.chaquo.python.Python +import com.runcode.app.backup.BackupFolderSettings import com.runcode.app.backup.BackupManager +import com.runcode.app.backup.FolderBackups import com.runcode.app.database.AppMetaDatabase import com.runcode.app.database.ProjectDatabaseManager import com.runcode.app.diagnostics.DiagnosticsRunner import com.runcode.app.domain.models.LogLevel +import com.runcode.app.domain.models.Project import com.runcode.app.domain.models.ProjectProfile import com.runcode.app.logging.LogManager import com.runcode.app.mcp.McpServer @@ -32,6 +35,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.File import java.security.SecureRandom @@ -52,6 +56,10 @@ class RuncodeApp : Application() { private set lateinit var backupManager: BackupManager private set + lateinit var backupFolder: BackupFolderSettings + private set + lateinit var folderBackups: FolderBackups + private set lateinit var compatibilityManager: CompatibilityManager private set lateinit var runtimeRegistry: RuntimeRegistry @@ -98,6 +106,8 @@ class RuncodeApp : Application() { appMetaDatabase = AppMetaDatabase(this) projectDatabaseManager = ProjectDatabaseManager() backupManager = BackupManager(this, projectStorage) + backupFolder = BackupFolderSettings(this) + folderBackups = FolderBackups(backupManager, cacheDir) projectArchive = ProjectArchive(this, projectStorage) compatibilityManager = CompatibilityManager(this) terminalSession = TerminalSession(this) @@ -135,7 +145,8 @@ class RuncodeApp : Application() { runPython = ::runPythonSnippet, projectSettings = projectSettings, onProjectChanged = { projectId -> _projectChanges.tryEmit(projectId) }, - runDiagnostics = { diagnostics.run().toText() } + runDiagnostics = { diagnostics.run().toText() }, + backupProject = ::backupProjectForMcp ), onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } ) @@ -148,6 +159,13 @@ class RuncodeApp : Application() { CoroutineScope(Dispatchers.IO).launch { seedStarterProjectsIfEmpty() } + // Daily backups only happen while the process is alive; the switch in the UI says so. + CoroutineScope(Dispatchers.IO).launch { + while (true) { + runAutoBackupIfDue() + delay(AUTO_BACKUP_CHECK_MS) + } + } } /** Bearer token for the MCP bridge. Generated once and kept in the Keystore-backed vault. */ @@ -169,6 +187,33 @@ class RuncodeApp : Application() { return mcpToken() } + private suspend fun backupProjectForMcp(project: Project, toFolder: Boolean): String { + if (!toFolder) { + val file = backupManager.createProjectBackup(project) + return "Created ${file.name} (${file.length()} bytes) in the project's backups/ folder." + } + val store = backupFolder.store() + ?: throw IllegalStateException("No backup folder is chosen. Pick one under Backups in the app.") + val stored = folderBackups.export(store, project) + return "Created a local backup and copied it to ${backupFolder.state.value.label} as ${stored.name}; the copy was read back and verified." + } + + /** Backs every project up to the chosen folder when the daily run is switched on and due. */ + suspend fun runAutoBackupIfDue() { + val now = System.currentTimeMillis() + if (!backupFolder.isAutoDue(now)) return + val store = backupFolder.store() + val result = if (store == null) { + "Skipped: the backup folder is no longer accessible. Choose it again." + } else { + val projects = appMetaDatabase.getAllProjects() + val (done, error) = folderBackups.exportAll(store, projects) + if (error == null) "Backed up $done project(s)" else "Backed up $done of ${projects.size}. $error" + } + backupFolder.recordAutoRun(now, result) + logManager.log(BACKUP_LOG_ID, "Backups", LogLevel.SYSTEM, "Automatic backup: $result") + } + /** * Reconnects the public tunnel when the default network changes, e.g. Wi-Fi to mobile * data or a VPN coming up or reconnecting, instead of waiting for keepalives to time out. @@ -224,5 +269,7 @@ class RuncodeApp : Application() { private companion object { const val MCP_TOKEN_KEY = "MCP_BRIDGE_TOKEN" const val MCP_LOG_ID = "__mcp__" + const val BACKUP_LOG_ID = "__backup__" + const val AUTO_BACKUP_CHECK_MS = 60L * 60L * 1000L } } diff --git a/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt b/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt new file mode 100644 index 0000000..9327af2 --- /dev/null +++ b/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt @@ -0,0 +1,97 @@ +package com.runcode.app.backup + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class BackupFolderState( + /** Folder name as the provider shows it, or null when none is chosen or access was lost. */ + val label: String? = null, + val autoEnabled: Boolean = false, + val lastAutoRun: Long = 0L, + val lastAutoResult: String? = null +) + +/** The chosen backup folder and the automatic-backup switch, kept across restarts. */ +class BackupFolderSettings(private val context: Context) { + + private val prefs = context.getSharedPreferences("backup_folder", Context.MODE_PRIVATE) + + private val _state = MutableStateFlow(load()) + val state: StateFlow = _state.asStateFlow() + + private val treeUri: Uri? + get() = prefs.getString(KEY_TREE, null)?.let(Uri::parse) + + /** The folder as a store, or null when none is chosen or Android revoked access. */ + fun store(): BackupStore? = treeUri?.takeIf(::hasAccess)?.let { SafBackupStore(context, it) } + + /** Keeps access to [uri] across reboots and forgets the previous folder. */ + fun choose(uri: Uri) { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + treeUri?.takeIf { it != uri }?.let { old -> + runCatching { context.contentResolver.releasePersistableUriPermission(old, flags) } + } + prefs.edit().putString(KEY_TREE, uri.toString()).apply() + _state.value = load() + } + + fun clear() { + treeUri?.let { old -> + runCatching { + context.contentResolver.releasePersistableUriPermission( + old, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + } + prefs.edit().remove(KEY_TREE).putBoolean(KEY_AUTO, false).apply() + _state.value = load() + } + + fun setAutoEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_AUTO, enabled).apply() + _state.value = load() + } + + fun isAutoDue(now: Long): Boolean = + prefs.getBoolean(KEY_AUTO, false) && now - prefs.getLong(KEY_LAST_AUTO, 0L) >= FolderBackups.AUTO_INTERVAL_MS + + fun recordAutoRun(time: Long, result: String) { + prefs.edit().putLong(KEY_LAST_AUTO, time).putString(KEY_LAST_RESULT, result).apply() + _state.value = load() + } + + private fun hasAccess(uri: Uri): Boolean = + context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission } + + private fun load(): BackupFolderState { + val uri = treeUri?.takeIf(::hasAccess) + return BackupFolderState( + label = uri?.let(::folderName), + autoEnabled = uri != null && prefs.getBoolean(KEY_AUTO, false), + lastAutoRun = prefs.getLong(KEY_LAST_AUTO, 0L), + lastAutoResult = prefs.getString(KEY_LAST_RESULT, null) + ) + } + + private fun folderName(tree: Uri): String { + val document = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) + val name = runCatching { + context.contentResolver.query(document, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null) + ?.use { if (it.moveToFirst()) it.getString(0) else null } + }.getOrNull() + return name ?: DocumentsContract.getTreeDocumentId(tree).substringAfterLast(':').ifEmpty { "Chosen folder" } + } + + private companion object { + const val KEY_TREE = "tree_uri" + const val KEY_AUTO = "auto_enabled" + const val KEY_LAST_AUTO = "last_auto_run" + const val KEY_LAST_RESULT = "last_auto_result" + } +} diff --git a/app/src/main/java/com/runcode/app/backup/BackupManager.kt b/app/src/main/java/com/runcode/app/backup/BackupManager.kt index 20bf1b4..ed5f68e 100644 --- a/app/src/main/java/com/runcode/app/backup/BackupManager.kt +++ b/app/src/main/java/com/runcode/app/backup/BackupManager.kt @@ -7,6 +7,7 @@ import com.runcode.app.domain.models.ProjectProfile import com.runcode.app.storage.ProjectStorage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONObject import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File @@ -67,18 +68,17 @@ class BackupManager( } val checksum = digest.digest().joinToString("") { "%02x".format(it) } - val manifestContent = """ -{ - "version": 1, - "projectId": "${project.id}", - "projectName": "${project.name}", - "profileId": "${project.profile.id}", - "createdAt": $timestamp, - "fileCount": ${filesToInclude.size}, - "totalSizeBytes": $totalBytes, - "checksum": "$checksum" -} -""".trimIndent() + // Built with JSONObject so a quote or backslash in the project name stays valid JSON. + val manifestContent = JSONObject() + .put("version", 1) + .put("projectId", project.id) + .put("projectName", project.name) + .put("profileId", project.profile.id) + .put("createdAt", timestamp) + .put("fileCount", filesToInclude.size) + .put("totalSizeBytes", totalBytes) + .put("checksum", checksum) + .toString(2) // Create the zip archive ZipOutputStream(BufferedOutputStream(FileOutputStream(backupFile))).use { zos -> @@ -252,24 +252,16 @@ class BackupManager( } private fun parseManifest(json: String): BackupManifest { - // Simple robust JSON extractor without external dependency - val projectId = json.substringAfter("\"projectId\": \"").substringBefore("\"") - val projectName = json.substringAfter("\"projectName\": \"").substringBefore("\"") - val profileId = json.substringAfter("\"profileId\": \"").substringBefore("\"") - val createdAt = json.substringAfter("\"createdAt\": ").substringBefore(",").trim().toLongOrNull() ?: System.currentTimeMillis() - val fileCount = json.substringAfter("\"fileCount\": ").substringBefore(",").trim().toIntOrNull() ?: 0 - val totalSizeBytes = json.substringAfter("\"totalSizeBytes\": ").substringBefore(",").trim().toLongOrNull() ?: 0L - val checksum = json.substringAfter("\"checksum\": \"").substringBefore("\"") - + val manifest = JSONObject(json) return BackupManifest( - version = 1, - projectId = projectId, - projectName = projectName, - profileId = profileId, - createdAt = createdAt, - fileCount = fileCount, - totalSizeBytes = totalSizeBytes, - checksum = checksum + version = manifest.optInt("version", 1), + projectId = manifest.optString("projectId"), + projectName = manifest.optString("projectName"), + profileId = manifest.optString("profileId"), + createdAt = manifest.optLong("createdAt", System.currentTimeMillis()), + fileCount = manifest.optInt("fileCount", 0), + totalSizeBytes = manifest.optLong("totalSizeBytes", 0L), + checksum = manifest.optString("checksum") ) } } diff --git a/app/src/main/java/com/runcode/app/backup/BackupStore.kt b/app/src/main/java/com/runcode/app/backup/BackupStore.kt new file mode 100644 index 0000000..589b92a --- /dev/null +++ b/app/src/main/java/com/runcode/app/backup/BackupStore.kt @@ -0,0 +1,85 @@ +package com.runcode.app.backup + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** A backup archive stored outside the app. [id] is whatever the store needs to find it again. */ +data class StoredBackup(val id: String, val name: String, val sizeBytes: Long, val modifiedAt: Long) + +/** Somewhere backups can be copied to and read back from. */ +interface BackupStore { + fun list(): List + fun write(name: String, source: File): StoredBackup + fun open(id: String): InputStream + fun delete(id: String) +} + +/** + * A folder the user picked with the system folder picker (Storage Access Framework). It can be + * on the device, a memory card, or a cloud app that offers folders — Google Drive, OneDrive and + * the like — with no account or API key in this app. + */ +class SafBackupStore(private val context: Context, private val treeUri: Uri) : BackupStore { + + private val resolver get() = context.contentResolver + + private val folderDocument: Uri + get() = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)) + + override fun list(): List { + val children = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)) + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED + ) + val found = mutableListOf() + resolver.query(children, columns, null, null)?.use { cursor -> + while (cursor.moveToNext()) { + val name = cursor.getString(1) ?: continue + if (!name.endsWith(BACKUP_EXTENSION)) continue + val document = DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(0)) + found += StoredBackup(document.toString(), name, cursor.getLong(2), cursor.getLong(3)) + } + } ?: throw IOException("The backup folder cannot be read. Choose it again.") + return found + } + + override fun write(name: String, source: File): StoredBackup { + val document = DocumentsContract.createDocument(resolver, folderDocument, MIME_TYPE, name) + ?: throw IOException("The backup folder refused a new file. Choose it again.") + try { + resolver.openOutputStream(document, "w")?.use { output -> + source.inputStream().use { it.copyTo(output) } + } ?: throw IOException("Cannot write to the backup folder") + } catch (e: Exception) { + runCatching { DocumentsContract.deleteDocument(resolver, document) } + throw e + } + // Providers may rename on collision, e.g. "name (1).rcpkg". + val shownName = displayName(document) ?: name + return StoredBackup(document.toString(), shownName, source.length(), System.currentTimeMillis()) + } + + override fun open(id: String): InputStream = + resolver.openInputStream(Uri.parse(id)) ?: throw IOException("Cannot read $id") + + override fun delete(id: String) { + if (!DocumentsContract.deleteDocument(resolver, Uri.parse(id))) throw IOException("Cannot delete $id") + } + + private fun displayName(document: Uri): String? = + resolver.query(document, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null) + ?.use { if (it.moveToFirst()) it.getString(0) else null } + + companion object { + const val BACKUP_EXTENSION = ".rcpkg" + // Not a registered type; octet-stream keeps providers from appending an extension. + const val MIME_TYPE = "application/octet-stream" + } +} diff --git a/app/src/main/java/com/runcode/app/backup/FolderBackups.kt b/app/src/main/java/com/runcode/app/backup/FolderBackups.kt new file mode 100644 index 0000000..9b2a25c --- /dev/null +++ b/app/src/main/java/com/runcode/app/backup/FolderBackups.kt @@ -0,0 +1,123 @@ +package com.runcode.app.backup + +import com.runcode.app.domain.models.Project +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Backups in a user-chosen folder: export with read-back verification, listing, restore, and + * the optional daily automatic run. + */ +class FolderBackups( + private val backupManager: BackupManager, + private val cacheDir: File, + private val now: () -> Long = System::currentTimeMillis +) { + + /** + * Creates a fresh backup of [project] and copies it to [store]. The copy is read back and + * compared byte for byte by hash, so a truncated upload is caught here, not at restore time. + */ + suspend fun export(store: BackupStore, project: Project, automatic: Boolean = false): StoredBackup = + withContext(Dispatchers.IO) { + val local = backupManager.createProjectBackup(project) + try { + val stored = store.write(fileName(project, automatic), local) + val expected = sha256(local.inputStream()) + val actual = try { + sha256(store.open(stored.id)) + } catch (e: IOException) { + null + } + if (actual != expected) { + runCatching { store.delete(stored.id) } + throw IOException("The copy in the backup folder does not match the original. It was removed; try again.") + } + if (automatic) prune(store, project, KEEP_AUTOMATIC) + stored + } finally { + // Automatic runs leave nothing behind locally; manual ones keep the local copy too. + if (automatic) local.delete() + } + } + + suspend fun list(store: BackupStore): List = withContext(Dispatchers.IO) { + store.list().sortedByDescending { it.modifiedAt } + } + + suspend fun verify(store: BackupStore, backup: StoredBackup): BackupPreview = + withLocalCopy(store, backup) { backupManager.verifyBackup(it) } + + suspend fun restore(store: BackupStore, backup: StoredBackup): Project = + withLocalCopy(store, backup) { backupManager.restoreProjectFromBackup(it) } + + /** Backs up every project. Returns how many succeeded and the first error, if any. */ + suspend fun exportAll(store: BackupStore, projects: List): Pair { + var done = 0 + var firstError: String? = null + projects.forEach { project -> + try { + export(store, project, automatic = true) + done++ + } catch (e: Exception) { + if (firstError == null) firstError = "${project.name}: ${e.message}" + } + } + return done to firstError + } + + /** Deletes this project's oldest automatic backups beyond [keep]. Manual ones are never touched. */ + fun prune(store: BackupStore, project: Project, keep: Int) { + store.list() + .filter { it.name.startsWith(autoPrefix(project)) } + .sortedByDescending { it.name } // names carry a sortable timestamp + .drop(keep) + .forEach { runCatching { store.delete(it.id) } } + } + + private suspend fun withLocalCopy(store: BackupStore, backup: StoredBackup, block: suspend (File) -> T): T = + withContext(Dispatchers.IO) { + val dir = File(cacheDir, "folder_backups").apply { mkdirs() } + val copy = File(dir, "import_${now()}${SafBackupStore.BACKUP_EXTENSION}") + try { + store.open(backup.id).use { input -> copy.outputStream().use { input.copyTo(it) } } + block(copy) + } finally { + copy.delete() + } + } + + fun fileName(project: Project, automatic: Boolean): String { + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date(now())) + return if (automatic) "${autoPrefix(project)}$stamp${SafBackupStore.BACKUP_EXTENSION}" + else "${safeName(project)}-${project.id}-$stamp${SafBackupStore.BACKUP_EXTENSION}" + } + + private fun autoPrefix(project: Project) = "auto-${safeName(project)}-${project.id}-" + + private fun safeName(project: Project): String = + project.name.replace(Regex("[^A-Za-z0-9._-]+"), "_").trim('_').take(40).ifEmpty { "project" } + + private fun sha256(input: InputStream): String = input.use { stream -> + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + while (true) { + val read = stream.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + } + digest.digest().joinToString("") { "%02x".format(it) } + } + + companion object { + const val KEEP_AUTOMATIC = 7 + const val AUTO_INTERVAL_MS = 24L * 60 * 60 * 1000 + } +} diff --git a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt index b4441f7..5c01420 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt @@ -26,7 +26,9 @@ class McpToolHost( /** Tells the UI a project's files or settings changed underneath it. */ val onProjectChanged: (projectId: String) -> Unit, /** Runs the same checks as the System screen and returns the plain-text report. */ - val runDiagnostics: () -> String + val runDiagnostics: () -> String, + /** Backs a project up locally, and to the chosen folder when asked. Returns a summary. */ + val backupProject: suspend (project: Project, toFolder: Boolean) -> String ) { suspend fun projectOrNull(projectId: String): Project? = appMetaDatabase.getAllProjects().find { it.id == projectId } diff --git a/app/src/main/java/com/runcode/app/mcp/McpTools.kt b/app/src/main/java/com/runcode/app/mcp/McpTools.kt index 838ce8d..5cd4585 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpTools.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpTools.kt @@ -224,6 +224,19 @@ object McpTools { ) ) + tools.put( + tool( + "backup_project", + "Create a checksummed backup of a project's source/ and data/ (never secrets). With to_folder, " + + "also copy it to the backup folder chosen in the app and verify the copy.", + properties( + "project_id" to stringProp("Project id"), + "to_folder" to JSONObject().put("type", "boolean") + ), + required = listOf("project_id") + ) + ) + tools.put( tool( "run_diagnostics", @@ -273,6 +286,7 @@ object McpTools { args.optString("database").ifBlank { null } ) "run_diagnostics" -> textResult(host.runDiagnostics()) + "backup_project" -> backupProject(host, args.getString("project_id"), args.optBoolean("to_folder", false)) else -> errorResult("Unknown tool: $name") } } catch (e: Exception) { @@ -501,6 +515,11 @@ object McpTools { return textResult(array.toString(2)) } + private fun backupProject(host: McpToolHost, projectId: String, toFolder: Boolean): JSONObject = runBlocking { + val project = host.projectOrNull(projectId) ?: return@runBlocking errorResult("Project not found") + textResult(host.backupProject(project, toFolder)) + } + private fun getLogs(host: McpToolHost, projectId: String?, limit: Int): JSONObject { val capped = limit.coerceIn(1, 1000) val events = host.logManager.eventsFlow.value diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index 8028b0e..dc1bfaa 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -8,7 +8,9 @@ import androidx.core.content.FileProvider import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.runcode.app.RuncodeApp +import com.runcode.app.backup.BackupFolderState import com.runcode.app.backup.BackupPreview +import com.runcode.app.backup.StoredBackup import com.runcode.app.domain.models.DeviceCapabilities import com.runcode.app.domain.models.Project import com.runcode.app.domain.models.ProjectProfile @@ -99,6 +101,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _backupPreview = MutableStateFlow(null) val backupPreview: StateFlow = _backupPreview.asStateFlow() + val backupFolderState: StateFlow = app.backupFolder.state + + private val _folderBackups = MutableStateFlow>(emptyList()) + val folderBackups: StateFlow> = _folderBackups.asStateFlow() + + private val _folderBusy = MutableStateFlow(false) + val folderBusy: StateFlow = _folderBusy.asStateFlow() + // System Health private val _capabilities = MutableStateFlow(null) val capabilities: StateFlow = _capabilities.asStateFlow() @@ -894,6 +904,76 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + // Backup folder (Storage Access Framework) + + fun chooseBackupFolder(uri: Uri) { + try { + app.backupFolder.choose(uri) + refreshFolderBackups() + } catch (e: SecurityException) { + _userMessage.value = "That folder cannot be kept across restarts. Pick another one." + } + } + + fun forgetBackupFolder() { + app.backupFolder.clear() + _folderBackups.value = emptyList() + } + + fun setAutoBackup(enabled: Boolean) { + app.backupFolder.setAutoEnabled(enabled) + if (enabled) viewModelScope.launch(Dispatchers.IO) { app.runAutoBackupIfDue(); refreshFolderBackups() } + } + + fun refreshFolderBackups() { + val store = app.backupFolder.store() ?: run { _folderBackups.value = emptyList(); return } + folderTask("Cannot read the backup folder") { + _folderBackups.value = app.folderBackups.list(store) + } + } + + fun backupToFolder(project: Project) { + val store = app.backupFolder.store() ?: return + folderTask("Backup to folder failed") { + val stored = app.folderBackups.export(store, project) + refreshBackups(project.id) + _folderBackups.value = app.folderBackups.list(store) + _userMessage.value = "Saved and verified ${stored.name} in ${app.backupFolder.state.value.label}" + } + } + + fun verifyFolderBackup(backup: StoredBackup) { + val store = app.backupFolder.store() ?: return + folderTask("Verification failed") { + _backupPreview.value = app.folderBackups.verify(store, backup) + } + } + + fun restoreFolderBackup(backup: StoredBackup) { + val store = app.backupFolder.store() ?: return + folderTask("Restore failed") { + val restored = app.folderBackups.restore(store, backup) + app.appMetaDatabase.insertOrUpdateProject(restored) + loadProjects() + withContext(Dispatchers.Main) { selectProject(restored) } + _userMessage.value = "Restored '${restored.name}' from ${backup.name}" + } + } + + private fun folderTask(failure: String, block: suspend () -> Unit) { + if (_folderBusy.value) return + _folderBusy.value = true + viewModelScope.launch(Dispatchers.IO) { + try { + block() + } catch (e: Exception) { + _userMessage.value = "$failure: ${e.message}" + } finally { + _folderBusy.value = false + } + } + } + fun refreshCapabilities() { viewModelScope.launch { val runningCount = app.serviceSupervisor.instances.value.values.count { it.isRunning } diff --git a/app/src/main/java/com/runcode/app/ui/screens/BackupsScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/BackupsScreen.kt index 1a70f31..01867f4 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/BackupsScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/BackupsScreen.kt @@ -1,8 +1,11 @@ package com.runcode.app.ui.screens +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -18,6 +21,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Restore import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.VerifiedUser @@ -31,9 +35,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,11 +49,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.runcode.app.backup.BackupFolderState +import com.runcode.app.backup.FolderBackups +import com.runcode.app.backup.StoredBackup import com.runcode.app.ui.MainViewModel import com.runcode.app.ui.theme.AccentCyan import com.runcode.app.ui.theme.AccentGreen @@ -70,198 +79,187 @@ fun BackupsScreen( val project by viewModel.selectedProject.collectAsState() val backups by viewModel.backupList.collectAsState() val preview by viewModel.backupPreview.collectAsState() + val folder by viewModel.backupFolderState.collectAsState() + val folderBackups by viewModel.folderBackups.collectAsState() + val folderBusy by viewModel.folderBusy.collectAsState() var fileToRestore by remember { mutableStateOf(null) } + var folderBackupToRestore by remember { mutableStateOf(null) } - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - // Top Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text("Backups & Portability", style = MaterialTheme.typography.titleLarge, color = TextPrimary) - Text("Create verified archives and restore project state", fontSize = 12.sp, color = TextSecondary) - } - - project?.let { currentProj -> - Button( - onClick = { viewModel.createBackup(currentProj) }, - colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), - shape = RoundedCornerShape(8.dp), - modifier = Modifier.testTag("backup_create_btn") - ) { - Icon(Icons.Default.Archive, contentDescription = null, tint = Color.Black, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("New Backup", color = Color.Black, fontWeight = FontWeight.Bold) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) + val pickFolder = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + uri?.let { viewModel.chooseBackupFolder(it) } + } + LaunchedEffect(folder.label) { viewModel.refreshFolderBackups() } - // Security Notice Card - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors(containerColor = DarkSurfaceElevated), - border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) - ) { + // One scrolling list: the folder section and two backup lists do not fit a phone otherwise. + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + // Top Header Row( - modifier = Modifier.padding(12.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Default.Security, contentDescription = null, tint = AccentCyan, modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(10.dp)) Column { - Text("Zero Secret Leak Policy", fontWeight = FontWeight.Bold, fontSize = 12.sp, color = TextPrimary) - Text( - "Backups exclude secrets, logs, and caches by default. Checksums are cryptographically verified before restore.", - fontSize = 11.sp, - color = TextSecondary - ) + Text("Backups & Portability", style = MaterialTheme.typography.titleLarge, color = TextPrimary) + Text("Create verified archives and restore project state", fontSize = 12.sp, color = TextSecondary) } - } - } - Spacer(modifier = Modifier.height(16.dp)) + project?.let { currentProj -> + Button( + onClick = { viewModel.createBackup(currentProj) }, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("backup_create_btn") + ) { + Icon(Icons.Default.Archive, contentDescription = null, tint = Color.Black, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("New Backup", color = Color.Black, fontWeight = FontWeight.Bold) + } + } + } - Text("Available Backup Packages (${backups.size})", style = MaterialTheme.typography.titleMedium, color = TextPrimary) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) - if (backups.isEmpty()) { + // Security Notice Card Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = DarkSurfaceElevated) + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurfaceElevated), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) ) { - Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { - Text("No backups found for '${project?.name}'. Click 'New Backup' to generate one.", color = TextMuted) + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Security, contentDescription = null, tint = AccentCyan, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text("Zero Secret Leak Policy", fontWeight = FontWeight.Bold, fontSize = 12.sp, color = TextPrimary) + Text( + "Backups exclude secrets, logs, and caches by default. Checksums are cryptographically verified before restore.", + fontSize = 11.sp, + color = TextSecondary + ) + } } } - } else { - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - items(backups) { file -> - val sizeKb = file.length() / 1024 - val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(file.lastModified())) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = DarkSurface), - border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) - ) { - Column(modifier = Modifier.padding(14.dp)) { - // Filename and buttons stacked rather than side by side: a long - // .rcpkg name used to squeeze the actions until "Verify" wrapped - // one letter per line and "Restore" fell off the screen entirely. - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = file.name, - fontWeight = FontWeight.Bold, - color = TextPrimary, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "Size: $sizeKb KB • Created: $dateStr", - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - color = TextMuted - ) + } + + item { + BackupFolderCard( + state = folder, + busy = folderBusy, + projectName = project?.name, + onChoose = { pickFolder.launch(null) }, + onForget = { viewModel.forgetBackupFolder() }, + onBackup = { project?.let { viewModel.backupToFolder(it) } }, + onAutoChange = { viewModel.setAutoBackup(it) } + ) + } - Spacer(modifier = Modifier.height(10.dp)) + // Verification result sits up here so it is visible without scrolling past every backup. + preview?.let { prev -> + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + if (prev.isValid) AccentGreen.copy(alpha = 0.5f) else AccentRed.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (prev.isValid) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (prev.isValid) AccentGreen else AccentRed, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (prev.isValid) "Verification Passed" else "Verification Failed", + fontWeight = FontWeight.Bold, + color = if (prev.isValid) AccentGreen else AccentRed + ) + } + + // A failed verification with no reason on screen is useless; show why. + prev.error?.let { reason -> + Spacer(modifier = Modifier.height(6.dp)) + Text(reason, fontSize = 11.sp, color = AccentRed, lineHeight = 16.sp) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - onClick = { viewModel.verifyBackup(file) }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .weight(1f) - .testTag("verify_backup_${file.name}") - ) { - Text("Verify", color = AccentCyan, maxLines = 1) - } - Button( - onClick = { fileToRestore = file }, - colors = ButtonDefaults.buttonColors(containerColor = AccentGreen), - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .weight(1f) - .testTag("restore_backup_${file.name}") - ) { - Text("Restore", color = Color.Black, fontWeight = FontWeight.Bold, maxLines = 1) - } - } - } + prev.manifest?.let { man -> + Spacer(modifier = Modifier.height(6.dp)) + Text("Project: ${man.projectName} • Files: ${man.fileCount} • SHA-256: ${man.checksum.take(12)}...", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextSecondary) + Spacer(modifier = Modifier.height(4.dp)) + Text("Payload entries: ${prev.fileList.joinToString(", ")}", fontSize = 11.sp, color = TextMuted, maxLines = 2) } } } } } - // Backup Verification Result Card - preview?.let { prev -> - Spacer(modifier = Modifier.height(12.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = DarkSurface), - border = androidx.compose.foundation.BorderStroke( - 1.dp, - if (prev.isValid) AccentGreen.copy(alpha = 0.5f) else AccentRed.copy(alpha = 0.5f) + if (folder.label != null) { + item { + Text("In the backup folder (${folderBackups.size})", style = MaterialTheme.typography.titleMedium, color = TextPrimary) + } + items(folderBackups, key = { it.id }) { backup -> + BackupRow( + name = backup.name, + sizeBytes = backup.sizeBytes, + modifiedAt = backup.modifiedAt, + tag = backup.name, + onVerify = { viewModel.verifyFolderBackup(backup) }, + onRestore = { folderBackupToRestore = backup } ) - ) { - Column(modifier = Modifier.padding(14.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - if (prev.isValid) Icons.Default.CheckCircle else Icons.Default.Error, - contentDescription = null, - tint = if (prev.isValid) AccentGreen else AccentRed, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (prev.isValid) "Verification Passed" else "Verification Failed", - fontWeight = FontWeight.Bold, - color = if (prev.isValid) AccentGreen else AccentRed - ) - } + } + } - // A failed verification with no reason on screen is useless; show why. - prev.error?.let { reason -> - Spacer(modifier = Modifier.height(6.dp)) - Text(reason, fontSize = 11.sp, color = AccentRed, lineHeight = 16.sp) - } + item { + Text("On this device (${backups.size})", style = MaterialTheme.typography.titleMedium, color = TextPrimary) + } - prev.manifest?.let { man -> - Spacer(modifier = Modifier.height(6.dp)) - Text("Project: ${man.projectName} • Files: ${man.fileCount} • SHA-256: ${man.checksum.take(12)}...", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextSecondary) - Spacer(modifier = Modifier.height(4.dp)) - Text("Payload entries: ${prev.fileList.joinToString(", ")}", fontSize = 11.sp, color = TextMuted, maxLines = 2) + if (backups.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurfaceElevated) + ) { + Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Text("No backups found for '${project?.name}'. Click 'New Backup' to generate one.", color = TextMuted) } } } + } else { + items(backups, key = { it.absolutePath }) { file -> + BackupRow( + name = file.name, + sizeBytes = file.length(), + modifiedAt = file.lastModified(), + tag = file.name, + onVerify = { viewModel.verifyBackup(file) }, + onRestore = { fileToRestore = file } + ) + } } + } // Restore Confirmation Dialog - fileToRestore?.let { file -> + if (fileToRestore != null || folderBackupToRestore != null) { + val dismiss = { fileToRestore = null; folderBackupToRestore = null } AlertDialog( - onDismissRequest = { fileToRestore = null }, + onDismissRequest = dismiss, title = { Text("Restore Project Backup?", color = TextPrimary) }, text = { Text( @@ -272,8 +270,9 @@ fun BackupsScreen( confirmButton = { Button( onClick = { - viewModel.restoreBackup(file) - fileToRestore = null + fileToRestore?.let { viewModel.restoreBackup(it) } + folderBackupToRestore?.let { viewModel.restoreFolderBackup(it) } + dismiss() }, colors = ButtonDefaults.buttonColors(containerColor = AccentGreen) ) { @@ -281,10 +280,156 @@ fun BackupsScreen( } }, dismissButton = { - TextButton(onClick = { fileToRestore = null }) { + TextButton(onClick = dismiss) { Text("Cancel", color = TextSecondary) } } ) } } + +@Composable +private fun BackupRow( + name: String, + sizeBytes: Long, + modifiedAt: Long, + tag: String, + onVerify: () -> Unit, + onRestore: () -> Unit +) { + val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(modifiedAt)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) + ) { + // Filename and buttons stacked rather than side by side: a long .rcpkg name used to + // squeeze the actions until "Verify" wrapped one letter per line and "Restore" fell + // off the screen entirely. + Column(modifier = Modifier.padding(14.dp)) { + Text( + text = name, + fontWeight = FontWeight.Bold, + color = TextPrimary, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Size: ${sizeBytes / 1024} KB • Created: $dateStr", + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = TextMuted + ) + Spacer(modifier = Modifier.height(10.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onVerify, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f).testTag("verify_backup_$tag") + ) { + Text("Verify", color = AccentCyan, maxLines = 1) + } + Button( + onClick = onRestore, + colors = ButtonDefaults.buttonColors(containerColor = AccentGreen), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f).testTag("restore_backup_$tag") + ) { + Text("Restore", color = Color.Black, fontWeight = FontWeight.Bold, maxLines = 1) + } + } + } + } +} + +/** The user-chosen folder: Drive, OneDrive, a memory card or local storage, via the system picker. */ +@Composable +private fun BackupFolderCard( + state: BackupFolderState, + busy: Boolean, + projectName: String?, + onChoose: () -> Unit, + onForget: () -> Unit, + onBackup: () -> Unit, + onAutoChange: (Boolean) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Folder, contentDescription = null, tint = AccentCyan, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text("Backup folder", fontWeight = FontWeight.Bold, fontSize = 13.sp, color = TextPrimary) + Text( + text = state.label ?: "Pick any folder, including Google Drive or OneDrive. No account needed here.", + fontSize = 11.sp, + color = if (state.label != null) AccentGreen else TextSecondary + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + if (state.label == null) { + Button( + onClick = onChoose, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("backup_folder_choose_btn") + ) { + Text("Choose folder", color = Color.Black, fontWeight = FontWeight.Bold) + } + return@Column + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = onBackup, + enabled = !busy && projectName != null, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f).testTag("backup_to_folder_btn") + ) { + Text( + if (busy) "Working…" else "Back up to folder", + color = Color.Black, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + } + TextButton(onClick = onChoose) { Text("Change", fontSize = 11.sp, color = AccentCyan) } + TextButton(onClick = onForget) { Text("Forget", fontSize = 11.sp, color = TextMuted) } + } + + Spacer(modifier = Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Daily automatic backup", fontSize = 12.sp, color = TextPrimary) + Text( + text = "All projects, once a day while runcode is running. Keeps the last " + + "${FolderBackups.KEEP_AUTOMATIC} automatic copies per project.", + fontSize = 10.sp, + color = TextMuted, + lineHeight = 13.sp + ) + if (state.lastAutoRun > 0) { + val time = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(state.lastAutoRun)) + Text("Last run $time: ${state.lastAutoResult ?: ""}", fontSize = 10.sp, color = TextSecondary) + } + } + Switch( + checked = state.autoEnabled, + onCheckedChange = onAutoChange, + modifier = Modifier.testTag("backup_auto_switch") + ) + } + } + } +} diff --git a/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt b/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt new file mode 100644 index 0000000..2ddc037 --- /dev/null +++ b/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt @@ -0,0 +1,172 @@ +package com.runcode.app.backup + +import android.app.Application +import android.net.Uri +import com.runcode.app.domain.models.Project +import com.runcode.app.domain.models.ProjectProfile +import com.runcode.app.storage.ProjectStorage +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.nio.file.Files + +/** A folder on disk standing in for the SAF folder. [truncate] imitates a broken upload. */ +private class DirStore(val dir: File, var truncate: Boolean = false, var refuse: String? = null) : BackupStore { + override fun list() = dir.listFiles()!!.filter { it.name.endsWith(".rcpkg") } + .map { StoredBackup(it.absolutePath, it.name, it.length(), it.lastModified()) } + + override fun write(name: String, source: File): StoredBackup { + refuse?.let { if (name.contains(it)) throw IOException("quota exceeded") } + val target = File(dir, name) + val bytes = source.readBytes() + target.writeBytes(if (truncate) bytes.copyOf(bytes.size / 2) else bytes) + return StoredBackup(target.absolutePath, name, target.length(), target.lastModified()) + } + + override fun open(id: String): InputStream = File(id).inputStream() + + override fun delete(id: String) { + if (!File(id).delete()) throw IOException("cannot delete $id") + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class FolderBackupsTest { + + private lateinit var storage: ProjectStorage + private lateinit var manager: BackupManager + private lateinit var store: DirStore + private var clock = 1_760_000_000_000L + private lateinit var folder: FolderBackups + + @Before fun setup() { + val app = RuntimeEnvironment.getApplication() + storage = ProjectStorage(app) + manager = BackupManager(app, storage) + store = DirStore(Files.createTempDirectory("saf").toFile()) + folder = FolderBackups(manager, app.cacheDir) { clock } + } + + private fun project(name: String = "Notes App"): Project { + val project = storage.createProjectFromTemplate(name, ProjectProfile.PYTHON_SCRIPT) + File(storage.getDataDir(project.id), "notes.txt").writeText("remember the milk") + return project + } + + @Test fun `export is verified, listed and restorable with identical files`() = runBlocking { + val original = project() + val stored = folder.export(store, original) + + assertTrue(stored.name.startsWith("Notes_App-${original.id}-")) + assertEquals(listOf(stored.name), folder.list(store).map { it.name }) + assertTrue(folder.verify(store, stored).isValid) + + val restored = folder.restore(store, stored) + assertNotEquals(original.id, restored.id) + assertEquals("remember the milk", File(storage.getDataDir(restored.id), "notes.txt").readText()) + assertEquals( + File(storage.getSourceDir(original.id), original.entryPoint).readText(), + File(storage.getSourceDir(restored.id), restored.entryPoint).readText() + ) + // A manual export keeps the local copy too. + assertEquals(1, storage.getBackupsDir(original.id).listFiles()!!.size) + } + + @Test fun `a copy that does not read back identically is removed and reported`() = runBlocking { + store.truncate = true + val error = runCatching { folder.export(store, project()) }.exceptionOrNull() + assertTrue(error is IOException) + assertTrue(store.list().isEmpty()) + } + + @Test fun `automatic runs keep seven copies, never touch manual ones and leave nothing locally`() = runBlocking { + val p = project() + val manual = folder.export(store, p) + repeat(9) { + clock += 24L * 60 * 60 * 1000 + folder.export(store, p, automatic = true) + } + val names = store.list().map { it.name } + assertEquals(FolderBackups.KEEP_AUTOMATIC, names.count { it.startsWith("auto-") }) + assertTrue(manual.name in names) + // Only the manual export's local copy remains. + assertEquals(1, storage.getBackupsDir(p.id).listFiles()!!.size) + } + + @Test fun `pruning only applies to the same project`() = runBlocking { + val a = project("A") + val b = project("B") + repeat(3) { clock += 1000; folder.export(store, a, automatic = true) } + folder.export(store, b, automatic = true) + folder.prune(store, a, keep = 1) + val names = store.list().map { it.name } + assertEquals(1, names.count { it.contains(a.id) }) + assertEquals(1, names.count { it.contains(b.id) }) + } + + @Test fun `exportAll keeps going after a failure and reports it`() = runBlocking { + val good = project("Good") + val bad = project("Bad") + store.refuse = "Bad" + val (done, error) = folder.exportAll(store, listOf(bad, good)) + assertEquals(1, done) + assertEquals("Bad: quota exceeded", error) + assertTrue(store.list().single().name.contains(good.id)) + } + + @Test fun `a quote in the project name no longer breaks the manifest`() = runBlocking { + val p = project("The \"best\" app\\v2") + val local = manager.createProjectBackup(p) + val preview = manager.verifyBackup(local) + assertTrue(preview.error, preview.isValid) + assertEquals("The \"best\" app\\v2", preview.manifest!!.projectName) + assertEquals("The \"best\" app\\v2", manager.restoreProjectFromBackup(local).name) + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class BackupFolderSettingsTest { + + private val tree = Uri.parse("content://com.android.externalstorage.documents/tree/primary%3ABackups") + + @Test fun `choosing keeps access, forgetting releases it`() { + val settings = BackupFolderSettings(RuntimeEnvironment.getApplication()) + assertNull(settings.store()) + assertNull(settings.state.value.label) + + settings.choose(tree) + assertNotNull(settings.store()) + assertEquals("Backups", settings.state.value.label) + + // A new instance (app restart) still has it. + assertNotNull(BackupFolderSettings(RuntimeEnvironment.getApplication()).store()) + + settings.clear() + assertNull(settings.store()) + assertTrue(RuntimeEnvironment.getApplication().contentResolver.persistedUriPermissions.isEmpty()) + } + + @Test fun `automatic backup is due once a day and only when switched on`() { + val settings = BackupFolderSettings(RuntimeEnvironment.getApplication()) + settings.choose(tree) + val now = 1_760_000_000_000L + assertFalse(settings.isAutoDue(now)) + + settings.setAutoEnabled(true) + assertTrue(settings.isAutoDue(now)) + settings.recordAutoRun(now, "Backed up 2 project(s)") + assertFalse(settings.isAutoDue(now + 60_000)) + assertTrue(settings.isAutoDue(now + FolderBackups.AUTO_INTERVAL_MS)) + assertEquals("Backed up 2 project(s)", settings.state.value.lastAutoResult) + } +} diff --git a/app/src/test/java/com/runcode/app/backup/SafBackupStoreTest.kt b/app/src/test/java/com/runcode/app/backup/SafBackupStoreTest.kt new file mode 100644 index 0000000..5aa0801 --- /dev/null +++ b/app/src/test/java/com/runcode/app/backup/SafBackupStoreTest.kt @@ -0,0 +1,118 @@ +package com.runcode.app.backup + +import android.app.Application +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.provider.DocumentsProvider +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files + +/** A minimal file-backed provider, the shape of what Drive or the device's storage expose. */ +class FolderDocumentsProvider : DocumentsProvider() { + companion object { + const val AUTHORITY = "com.runcode.test.documents" + lateinit var root: File + private val COLUMNS = arrayOf( + Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS + ) + } + + override fun onCreate() = true + + private fun file(id: String) = if (id == "root") root else File(root, id) + + private fun row(cursor: MatrixCursor, id: String) { + val f = file(id) + cursor.addRow(arrayOf( + id, if (id == "root") "Backups" else f.name, f.length(), f.lastModified(), + if (f.isDirectory) Document.MIME_TYPE_DIR else "application/octet-stream", + Document.FLAG_DIR_SUPPORTS_CREATE or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_WRITE + )) + } + + override fun queryRoots(projection: Array?): Cursor = MatrixCursor(arrayOf("root_id")) + + override fun queryDocument(documentId: String, projection: Array?): Cursor = + MatrixCursor(COLUMNS).also { row(it, documentId) } + + override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor = + MatrixCursor(COLUMNS).also { c -> file(parentDocumentId).listFiles()!!.forEach { row(c, it.name) } } + + override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = + ParcelFileDescriptor.open(file(documentId), ParcelFileDescriptor.parseMode(mode)) + + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { + var name = displayName + var n = 1 + while (File(root, name).exists()) name = displayName.replace(".rcpkg", " (${n++}).rcpkg") + File(root, name).createNewFile() + return name + } + + override fun deleteDocument(documentId: String) { + file(documentId).delete() + } + + override fun isChildDocument(parentDocumentId: String, documentId: String) = parentDocumentId == "root" +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class SafBackupStoreTest { + + private lateinit var store: SafBackupStore + private lateinit var source: File + + @Before fun setup() { + FolderDocumentsProvider.root = Files.createTempDirectory("tree").toFile() + // DocumentsProvider insists on exactly the manifest attributes a real one declares. + val info = ProviderInfo().apply { + authority = FolderDocumentsProvider.AUTHORITY + exported = true + grantUriPermissions = true + readPermission = android.Manifest.permission.MANAGE_DOCUMENTS + writePermission = android.Manifest.permission.MANAGE_DOCUMENTS + } + Robolectric.buildContentProvider(FolderDocumentsProvider::class.java).create(info) + val tree = DocumentsContract.buildTreeDocumentUri(FolderDocumentsProvider.AUTHORITY, "root") + store = SafBackupStore(RuntimeEnvironment.getApplication(), tree) + source = File.createTempFile("backup", ".rcpkg").apply { writeBytes(ByteArray(50_000) { (it % 251).toByte() }) } + } + + @Test fun `write, list, read back and delete through the documents provider`() { + val stored = store.write("one.rcpkg", source) + assertEquals("one.rcpkg", stored.name) + assertTrue(Uri.parse(stored.id).toString().startsWith("content://${FolderDocumentsProvider.AUTHORITY}/tree/root/document/")) + + File(FolderDocumentsProvider.root, "notes.txt").writeText("not a backup") + val listed = store.list() + assertEquals(listOf("one.rcpkg"), listed.map { it.name }) + assertEquals(source.length(), listed.single().sizeBytes) + + assertArrayEquals(source.readBytes(), store.open(stored.id).use { it.readBytes() }) + + store.delete(stored.id) + assertTrue(store.list().isEmpty()) + } + + @Test fun `a provider rename on collision is reported back`() { + store.write("same.rcpkg", source) + val second = store.write("same.rcpkg", source) + assertEquals("same (1).rcpkg", second.name) + } +} diff --git a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt index 27a56cb..290b203 100644 --- a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt @@ -31,7 +31,7 @@ class McpSettingsTest { runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } host = McpToolHost(app.projectStorage, app.appMetaDatabase, app.projectDatabaseManager, app.serviceSupervisor, app.logManager, app.terminalSession, { _, _ -> "unused" }, - app.projectSettings, { notifications.add(it) }, { "diagnostics report" }) + app.projectSettings, { notifications.add(it) }, { "diagnostics report" }, { _, _ -> "backup" }) } @Test fun `mcp replaces plain variables but preserves secret references`() = runBlocking { From b5d2c7ac1ab678a08d1d8893d9cbe076a837da95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:29:39 +0000 Subject: [PATCH 05/15] Prepare v1.5.0 Bump to versionCode 6 / 1.5.0 and report the app version in MCP serverInfo instead of a fixed 1.0.0. Document backups to a folder, diagnostics and the two new MCP tools, and record what was verified and what still needs a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- README.md | 43 +++++++++++++++++-- app/build.gradle.kts | 4 +- .../app/backup/BackupFolderSettings.kt | 12 +++--- .../com/runcode/app/backup/BackupStore.kt | 5 ++- .../java/com/runcode/app/mcp/McpServer.kt | 3 +- review-notes.md | 33 ++++++++++++++ 6 files changed, 87 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d4ca29c..22154e7 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,39 @@ just `source/`, so whatever a script writes into `data/` is visible too. Binary files and files over 512 KB do not open in the editor; they can still be saved out. +## Backups + +**Backups** creates checksummed archives of a project's `source/` and `data/`. Secrets, logs +and caches are never included, and the checksum is verified before every restore. + +**Choose folder** keeps backups outside the app as well. The system folder picker accepts +device storage, a memory card, or a cloud app that offers folders, such as Google Drive or +OneDrive, with no account or API key in runcode. **Back up to folder** writes a fresh backup, +reads the copy back and compares its SHA-256; a copy that does not match is removed. Backups +already in the folder can be verified and restored from the same screen, including on another +device. + +**Daily automatic backup** is off by default. When on, it backs up every project once a day +while runcode is running, keeps the last 7 automatic copies per project and never deletes +manual ones. + +## Diagnostics + +**System → Diagnostics → Run** checks the device and the network and produces a report to copy +or share: + +- App: Android version and ABI, free storage and memory, notification permission, battery + optimisation, the Python interpreter, whether the foreground service really holds running + work, and the last crash. +- Network: connection type and validation, VPN, LAN address, reserved ports, DNS, HTTPS, and + whether the Telegram API is reachable (bots need it; some networks block it). +- Services: whether each running static site or Python HTTP API accepts connections on its + port. Other project types do not listen on a port and are not judged by one. +- MCP bridge: a `GET /health` self-test, and the public tunnel's state. + +**Share logs** sends the latest report and every log line through the share sheet. Secrets +were already redacted when each line was logged. + ## Project settings Open **Projects → settings icon** to edit the port, restart policy, start on boot, CPU/heap/idle @@ -109,9 +142,10 @@ Authorization: Bearer Content-Type: application/json ``` -Seventeen tools: `list_projects`, `get_project`, `update_project_settings`, `list_files`, `read_file`, `write_file`, +Nineteen tools: `list_projects`, `get_project`, `update_project_settings`, `list_files`, `read_file`, `write_file`, `create_directory`, `rename_path`, `delete_path`, `set_entry_point`, `start_service`, -`stop_service`, `service_status`, `get_logs`, `run_command`, `run_python`, `sql_query`. +`stop_service`, `service_status`, `get_logs`, `run_command`, `run_python`, `sql_query`, +`run_diagnostics`, `backup_project`. File changes made over the bridge show up in the app straight away: the file tree refreshes, and a file open in the editor reloads unless it has unsaved edits. @@ -183,6 +217,8 @@ The bridge exposes a shell, arbitrary Python and read/write file access on the d app/src/main/java/com/runcode/app/ runtime/ PythonEngine (Chaquopy), StaticWebEngine, engine contracts supervisor/ ServiceSupervisor — lifecycle, restart policy, ports, wake lock + backup/ Checksummed archives, backups to a user-chosen folder (SAF), daily runs + diagnostics/ Device and network checks behind the System screen and run_diagnostics terminal/ TerminalSession — interactive sh plus one-shot command runner mcp/ McpServer (JSON-RPC over HTTP), McpTools, McpToolHost, McpTunnel (public URL) storage/ Path-checked project files, zip import/export, entry-point tracking @@ -201,7 +237,8 @@ python -m unittest discover -s tests -v ``` The JVM tests cover settings validation, vault rollback, secret handling, archive paths, -editor ownership and supervisor restarts. Robolectric tests use API 28; device testing is +editor ownership, supervisor restarts, diagnostics rules and probes, folder backups through a +real `DocumentsProvider`, and the public tunnel against an in-process SSH server. Robolectric tests use API 28; device testing is still needed for Keystore, Compose interaction and foreground-service behavior on API 36. ## License diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eb1fd90..21b4c3d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "com.runcode.app" minSdk = 26 targetSdk = 36 - versionCode = 5 - versionName = "1.4.0" + versionCode = 6 + versionName = "1.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt b/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt index 9327af2..c8b8f2b 100644 --- a/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt +++ b/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt @@ -4,6 +4,8 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.provider.DocumentsContract +import androidx.core.content.edit +import androidx.core.net.toUri import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,7 +27,7 @@ class BackupFolderSettings(private val context: Context) { val state: StateFlow = _state.asStateFlow() private val treeUri: Uri? - get() = prefs.getString(KEY_TREE, null)?.let(Uri::parse) + get() = prefs.getString(KEY_TREE, null)?.toUri() /** The folder as a store, or null when none is chosen or Android revoked access. */ fun store(): BackupStore? = treeUri?.takeIf(::hasAccess)?.let { SafBackupStore(context, it) } @@ -37,7 +39,7 @@ class BackupFolderSettings(private val context: Context) { treeUri?.takeIf { it != uri }?.let { old -> runCatching { context.contentResolver.releasePersistableUriPermission(old, flags) } } - prefs.edit().putString(KEY_TREE, uri.toString()).apply() + prefs.edit { putString(KEY_TREE, uri.toString()) } _state.value = load() } @@ -49,12 +51,12 @@ class BackupFolderSettings(private val context: Context) { ) } } - prefs.edit().remove(KEY_TREE).putBoolean(KEY_AUTO, false).apply() + prefs.edit { remove(KEY_TREE).putBoolean(KEY_AUTO, false) } _state.value = load() } fun setAutoEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_AUTO, enabled).apply() + prefs.edit { putBoolean(KEY_AUTO, enabled) } _state.value = load() } @@ -62,7 +64,7 @@ class BackupFolderSettings(private val context: Context) { prefs.getBoolean(KEY_AUTO, false) && now - prefs.getLong(KEY_LAST_AUTO, 0L) >= FolderBackups.AUTO_INTERVAL_MS fun recordAutoRun(time: Long, result: String) { - prefs.edit().putLong(KEY_LAST_AUTO, time).putString(KEY_LAST_RESULT, result).apply() + prefs.edit { putLong(KEY_LAST_AUTO, time).putString(KEY_LAST_RESULT, result) } _state.value = load() } diff --git a/app/src/main/java/com/runcode/app/backup/BackupStore.kt b/app/src/main/java/com/runcode/app/backup/BackupStore.kt index 589b92a..a35e3f6 100644 --- a/app/src/main/java/com/runcode/app/backup/BackupStore.kt +++ b/app/src/main/java/com/runcode/app/backup/BackupStore.kt @@ -3,6 +3,7 @@ package com.runcode.app.backup import android.content.Context import android.net.Uri import android.provider.DocumentsContract +import androidx.core.net.toUri import java.io.File import java.io.IOException import java.io.InputStream @@ -67,10 +68,10 @@ class SafBackupStore(private val context: Context, private val treeUri: Uri) : B } override fun open(id: String): InputStream = - resolver.openInputStream(Uri.parse(id)) ?: throw IOException("Cannot read $id") + resolver.openInputStream(id.toUri()) ?: throw IOException("Cannot read $id") override fun delete(id: String) { - if (!DocumentsContract.deleteDocument(resolver, Uri.parse(id))) throw IOException("Cannot delete $id") + if (!DocumentsContract.deleteDocument(resolver, id.toUri())) throw IOException("Cannot delete $id") } private fun displayName(document: Uri): String? = diff --git a/app/src/main/java/com/runcode/app/mcp/McpServer.kt b/app/src/main/java/com/runcode/app/mcp/McpServer.kt index 7a935e1..36a0e5c 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpServer.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpServer.kt @@ -1,5 +1,6 @@ package com.runcode.app.mcp +import com.runcode.app.BuildConfig import com.runcode.app.domain.models.LogLevel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -213,7 +214,7 @@ class McpServer( .put("capabilities", JSONObject().put("tools", JSONObject().put("listChanged", false))) .put( "serverInfo", - JSONObject().put("name", "runcode").put("version", "1.0.0") + JSONObject().put("name", "runcode").put("version", BuildConfig.VERSION_NAME) ) .put("instructions", INSTRUCTIONS) diff --git a/review-notes.md b/review-notes.md index 9c2a07e..b186af6 100644 --- a/review-notes.md +++ b/review-notes.md @@ -1,5 +1,38 @@ # Review notes +## v1.5.0 (versionCode 6) — in progress on `ccr-00c06d8d-qqvzlg` + +Roadmap phases 3 (diagnostics) and 4 (backups to a chosen folder), plus a public URL for the +MCP bridge. + +| Change | Where | +| --- | --- | +| LAN address prefers Wi-Fi, hotspot and Ethernet; mobile data and VPN interfaces are skipped. | `PortManager.getLanIp` | +| **Public URL**: outbound SSH reverse tunnel (Pinggy on 443, localhost.run fallback) with backoff and reconnect on network change. Works behind NAT and with a VPN on. | `mcp/McpTunnel`, `mcp/SshTunnelConnector` | +| **Diagnostics** card and `run_diagnostics`: app, network, service ports, bridge self-test. **Share logs** through a FileProvider. | `diagnostics/`, System screen | +| **Backup folder** through the Storage Access Framework, export verified by read-back hash, restore from the folder, optional daily run keeping 7 automatic copies. `backup_project` MCP tool. | `backup/`, Backups screen | +| Backup manifests are built with `JSONObject`; a quote in a project name broke restore. | `BackupManager` | +| `serverInfo.version` reports the app version instead of `1.0.0`. | `McpServer` | + +Validation in this environment (no Android device or KVM available): + +- 87 JVM/Robolectric tests and 5 Python tests pass; `testDebugUnitTest lintDebug assembleDebug` + succeeds with 0 lint errors and the same 38 warnings as v1.4.0. +- The tunnel runs the real JSch client against an in-process SSH server that behaves like + Pinggy, including an HTTP request through the forward. A JSch race in `channel.connect(timeout)` + failed about 2% of opens; the fix took a 150-run stress loop from 3 failures to 0. +- The SAF store runs against a real `DocumentsProvider` under Robolectric. +- The diagnostics runner runs against the real application object with live DNS, HTTPS and + Telegram probes. + +Not yet verified: + +- On a device: the Pinggy relay itself, the folder picker with Google Drive and OneDrive, the + share sheet, the diagnostics card on a 360dp screen, and notification/battery checks. +- Release signing: `assembleRelease` with the release key and `apksigner verify`. + +## v1.4.0 + Baseline: `929cc23` on `main`. This pass covers the editor, runtime lifecycle, project storage, secrets and MCP, then implements the project-settings step from the supplied roadmap. From 6eb49cf646573fab33bea2c09c19de7ae02c28c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:44:46 +0000 Subject: [PATCH 06/15] Add git for projects on dulwich A project's source/ folder can now be a git repository, driven by dulwich in the embedded Python, since a native git binary cannot run on Android. - runcode_git.py: init, status with ahead/behind, add (including deletions), commit, log, branches, checkout, remote, clone, push and fast-forward-only pull. Every call returns JSON; credentials are scrubbed from errors and never written to the repository config. - GitManager: the Kotlin side. The GitHub token is kept in the Keystore-backed vault and the author name and email in preferences. Clone creates a new project and detects its entry point like a zip import. - MCP tools: git_status, git_commit, git_push, git_pull. - dulwich 1.2.15 is bundled through Chaquopy and installed in CI for the Python tests. Tests run the real module: Python tests against bare repositories and an authenticated smart-HTTP server, and Kotlin tests that call runcode_git.py through the host's Python. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- .github/workflows/check.yml | 2 + app/build.gradle.kts | 2 + .../main/java/com/runcode/app/RuncodeApp.kt | 10 +- .../com/runcode/app/git/ChaquopyGitBackend.kt | 14 + .../java/com/runcode/app/git/GitManager.kt | 198 ++++++++++++ .../java/com/runcode/app/mcp/McpToolHost.kt | 5 +- .../main/java/com/runcode/app/mcp/McpTools.kt | 94 ++++++ .../com/runcode/app/storage/ProjectArchive.kt | 4 + app/src/main/python/runcode_git.py | 303 ++++++++++++++++++ .../com/runcode/app/git/GitManagerTest.kt | 169 ++++++++++ .../com/runcode/app/mcp/McpGitToolsTest.kt | 73 +++++ .../com/runcode/app/mcp/McpSettingsTest.kt | 2 +- tests/test_git.py | 206 ++++++++++++ 13 files changed, 1079 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/runcode/app/git/ChaquopyGitBackend.kt create mode 100644 app/src/main/java/com/runcode/app/git/GitManager.kt create mode 100644 app/src/main/python/runcode_git.py create mode 100644 app/src/test/java/com/runcode/app/git/GitManagerTest.kt create mode 100644 app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt create mode 100644 tests/test_git.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c307fca..fcafada 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -25,6 +25,8 @@ jobs: packages: 'platform-tools platforms;android-36 build-tools;36.0.0' log-accepted-android-sdk-licenses: false - uses: gradle/actions/setup-gradle@v4 + - name: Install Python test dependencies + run: python -m pip install dulwich==1.2.15 - name: Check Python runtime run: python -m unittest discover -s tests -v - name: Build and test diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21b4c3d..9263231 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -108,6 +108,8 @@ chaquopy { pip { install("python-telegram-bot==21.9") install("requests==2.32.3") + // Pure-Python git for the Git screen; a native git binary cannot run on Android. + install("dulwich==1.2.15") } } } diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index 9da73d8..f59f0ff 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -10,6 +10,8 @@ import com.runcode.app.backup.FolderBackups import com.runcode.app.database.AppMetaDatabase import com.runcode.app.database.ProjectDatabaseManager import com.runcode.app.diagnostics.DiagnosticsRunner +import com.runcode.app.git.ChaquopyGitBackend +import com.runcode.app.git.GitManager import com.runcode.app.domain.models.LogLevel import com.runcode.app.domain.models.Project import com.runcode.app.domain.models.ProjectProfile @@ -74,6 +76,11 @@ class RuncodeApp : Application() { private set val diagnostics: DiagnosticsRunner by lazy { DiagnosticsRunner(this) } + + val git: GitManager by lazy { + GitManager(ChaquopyGitBackend(), projectStorage, projectArchive, secretStore, + getSharedPreferences("git", MODE_PRIVATE)) + } lateinit var processMonitor: ProcessMonitor private set lateinit var projectArchive: ProjectArchive @@ -146,7 +153,8 @@ class RuncodeApp : Application() { projectSettings = projectSettings, onProjectChanged = { projectId -> _projectChanges.tryEmit(projectId) }, runDiagnostics = { diagnostics.run().toText() }, - backupProject = ::backupProjectForMcp + backupProject = ::backupProjectForMcp, + git = { git } ), onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } ) diff --git a/app/src/main/java/com/runcode/app/git/ChaquopyGitBackend.kt b/app/src/main/java/com/runcode/app/git/ChaquopyGitBackend.kt new file mode 100644 index 0000000..d931cdc --- /dev/null +++ b/app/src/main/java/com/runcode/app/git/ChaquopyGitBackend.kt @@ -0,0 +1,14 @@ +package com.runcode.app.git + +import com.chaquo.python.Python +import org.json.JSONObject + +/** Runs `runcode_git.py` in the embedded interpreter. */ +class ChaquopyGitBackend : GitBackend { + override fun call(function: String, vararg args: Any): String { + if (!Python.isStarted()) { + return JSONObject().put("ok", false).put("error", "The embedded Python interpreter is not running.").toString() + } + return Python.getInstance().getModule("runcode_git").callAttr(function, *args).toString() + } +} diff --git a/app/src/main/java/com/runcode/app/git/GitManager.kt b/app/src/main/java/com/runcode/app/git/GitManager.kt new file mode 100644 index 0000000..ec023e5 --- /dev/null +++ b/app/src/main/java/com/runcode/app/git/GitManager.kt @@ -0,0 +1,198 @@ +package com.runcode.app.git + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.runcode.app.domain.models.Project +import com.runcode.app.security.SecretRedactor +import com.runcode.app.security.SecretVault +import com.runcode.app.storage.ProjectArchive +import com.runcode.app.storage.ProjectStorage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** Calls one function of `runcode_git.py` and returns its JSON reply. */ +fun interface GitBackend { + fun call(function: String, vararg args: Any): String +} + +data class GitStatus( + val branch: String?, + val head: String?, + val added: List, + val modified: List, + val deleted: List, + val unstaged: List, + val untracked: List, + /** Commits not on the remote yet; null until the branch has been pushed or pulled once. */ + val ahead: Int?, + val behind: Int?, + val remote: String? +) { + val staged: List get() = added + modified + deleted + val isClean: Boolean get() = staged.isEmpty() && unstaged.isEmpty() && untracked.isEmpty() +} + +data class GitCommit(val sha: String, val author: String, val time: Long, val message: String) + +data class GitIdentity(val name: String, val email: String) + +class GitException(message: String) : Exception(message) + +/** + * Git for projects, backed by dulwich in the embedded Python. The repository is the project's + * `source/` folder, so data, logs and caches never end up in commits. + * + * The GitHub token lives in the Keystore-backed vault and is only ever handed to the Python + * call that needs it; the Python side scrubs it from every error message. + */ +class GitManager( + private val backend: GitBackend, + private val storage: ProjectStorage, + private val archive: ProjectArchive, + private val vault: SecretVault, + private val prefs: SharedPreferences +) { + // dulwich is not safe to run twice at once on one repository; one at a time keeps it simple. + private val mutex = Mutex() + + fun repoDir(project: Project): File = storage.getSourceDir(project.id) + + fun isRepository(project: Project): Boolean = File(repoDir(project), ".git").isDirectory + + // ---------------------------------------------------------------- settings + + var identity: GitIdentity + get() = GitIdentity(prefs.getString(KEY_NAME, "") ?: "", prefs.getString(KEY_EMAIL, "") ?: "") + set(value) = prefs.edit { putString(KEY_NAME, value.name.trim()).putString(KEY_EMAIL, value.email.trim()) } + + val hasToken: Boolean get() = vault.getSecret(TOKEN_KEY) != null + + fun setToken(token: String) { + val trimmed = token.trim() + if (trimmed.isEmpty()) vault.removeSecret(TOKEN_KEY) + else if (!vault.setSecret(TOKEN_KEY, trimmed)) throw GitException("The token could not be stored in the vault.") + } + + private fun token(): String = vault.getSecret(TOKEN_KEY)?.also { SecretRedactor.register(it) } ?: "" + + /** GitHub accepts any user name with a token; this is the one its docs use. */ + private fun username(): String = if (hasToken) TOKEN_USER else "" + + // ---------------------------------------------------------------- local + + suspend fun init(project: Project) = run { call("init", repoDir(project).path) } + + suspend fun status(project: Project): GitStatus = run { + val r = call("status", repoDir(project).path) + val staged = r.getJSONObject("staged") + GitStatus( + branch = r.optStringOrNull("branch"), + head = r.optStringOrNull("head"), + added = staged.getJSONArray("added").strings(), + modified = staged.getJSONArray("modified").strings(), + deleted = staged.getJSONArray("deleted").strings(), + unstaged = r.getJSONArray("unstaged").strings(), + untracked = r.getJSONArray("untracked").strings(), + ahead = r.optIntOrNull("ahead"), + behind = r.optIntOrNull("behind"), + remote = r.optStringOrNull("remote") + ) + } + + /** Stages [paths], or everything including deletions when empty. */ + suspend fun stage(project: Project, paths: List = emptyList()) = run { + call("add", repoDir(project).path, JSONArray(paths).toString()) + } + + suspend fun commit(project: Project, message: String): String = run { + val who = identity + call("commit", repoDir(project).path, message, who.name, who.email).getString("sha") + } + + suspend fun log(project: Project, limit: Int = 30): List = run { + val commits = call("log", repoDir(project).path, limit.toString()).getJSONArray("commits") + (0 until commits.length()).map { i -> + val c = commits.getJSONObject(i) + GitCommit(c.getString("sha"), c.getString("author"), c.getLong("time"), c.getString("message")) + } + } + + suspend fun branches(project: Project): Pair> = run { + val r = call("branches", repoDir(project).path) + r.optStringOrNull("current") to r.getJSONArray("branches").strings() + } + + suspend fun checkout(project: Project, branch: String, create: Boolean) = run { + require(BRANCH_NAME.matches(branch)) { "Branch names use letters, digits, '.', '_', '-' and '/'." } + call("checkout", repoDir(project).path, branch, create) + } + + suspend fun setRemote(project: Project, url: String) = run { + call("set_remote", repoDir(project).path, url.trim()) + } + + // ---------------------------------------------------------------- network + + suspend fun push(project: Project) = run { + val branch = currentBranch(project) + call("push", repoDir(project).path, branch, username(), token()) + } + + /** Fast-forward only. Returns the number of new commits. */ + suspend fun pull(project: Project): Int = run { + val branch = currentBranch(project) + call("pull", repoDir(project).path, branch, username(), token()).getInt("new_commits") + } + + /** + * Clones [url] into a new project and returns it, unsaved. The repository becomes the + * project's source/ folder and the entry point is detected like a zip import. + */ + suspend fun cloneProject(url: String): Project = run { + val name = url.trim().trimEnd('/').substringAfterLast('/').removeSuffix(".git").ifBlank { "Cloned project" } + val id = storage.newProjectId() + storage.initializeProjectDirectories(id) + val source = storage.getSourceDir(id) + try { + // dulwich creates the folder itself and refuses an existing one. + source.deleteRecursively() + call("clone", url.trim(), source.path, username(), token()) + archive.projectForSource(id, name, "Cloned from ${url.trim()}") + } catch (e: Exception) { + storage.deleteProject(id) + throw e + } + } + + // ---------------------------------------------------------------- plumbing + + private fun currentBranch(project: Project): String { + val r = call("branches", repoDir(project).path) + return r.optStringOrNull("current") ?: throw GitException("Check out a branch first.") + } + + private suspend fun run(block: () -> T): T = withContext(Dispatchers.IO) { mutex.withLock { block() } } + + private fun call(function: String, vararg args: Any): JSONObject { + val reply = JSONObject(backend.call(function, *args)) + if (!reply.optBoolean("ok")) throw GitException(SecretRedactor.redact(reply.optString("error", "git failed"))) + return reply + } + + private fun JSONArray.strings() = (0 until length()).map { getString(it) } + private fun JSONObject.optStringOrNull(key: String) = if (isNull(key) || !has(key)) null else getString(key) + private fun JSONObject.optIntOrNull(key: String) = if (isNull(key) || !has(key)) null else getInt(key) + + companion object { + const val TOKEN_KEY = "GIT_GITHUB_TOKEN" + const val TOKEN_USER = "x-access-token" + private const val KEY_NAME = "author_name" + private const val KEY_EMAIL = "author_email" + private val BRANCH_NAME = Regex("[A-Za-z0-9._/-]+") + } +} diff --git a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt index 5c01420..1a1c14a 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt @@ -3,6 +3,7 @@ package com.runcode.app.mcp import com.runcode.app.database.AppMetaDatabase import com.runcode.app.database.ProjectDatabaseManager import com.runcode.app.domain.models.Project +import com.runcode.app.git.GitManager import com.runcode.app.logging.LogManager import com.runcode.app.storage.ProjectStorage import com.runcode.app.settings.ProjectSettingsManager @@ -28,7 +29,9 @@ class McpToolHost( /** Runs the same checks as the System screen and returns the plain-text report. */ val runDiagnostics: () -> String, /** Backs a project up locally, and to the chosen folder when asked. Returns a summary. */ - val backupProject: suspend (project: Project, toFolder: Boolean) -> String + val backupProject: suspend (project: Project, toFolder: Boolean) -> String, + /** Lazy, because building it touches the vault and preferences. */ + val git: () -> GitManager ) { suspend fun projectOrNull(projectId: String): Project? = appMetaDatabase.getAllProjects().find { it.id == projectId } diff --git a/app/src/main/java/com/runcode/app/mcp/McpTools.kt b/app/src/main/java/com/runcode/app/mcp/McpTools.kt index 5cd4585..6f49561 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpTools.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpTools.kt @@ -1,6 +1,7 @@ package com.runcode.app.mcp import com.runcode.app.domain.models.Project +import com.runcode.app.git.GitException import com.runcode.app.domain.models.RestartPolicy import com.runcode.app.security.SecretRedactor import com.runcode.app.settings.EnvironmentEdit @@ -237,6 +238,48 @@ object McpTools { ) ) + tools.put( + tool( + "git_status", + "Git state of a project's source/ folder: branch, staged/unstaged/untracked files, " + + "commits ahead of or behind origin, and the remote URL.", + properties("project_id" to stringProp("Project id")), + required = listOf("project_id") + ) + ) + + tools.put( + tool( + "git_commit", + "Commit in a project's source/ folder using the author set in the app. Stages every change " + + "first unless stage_all is false. Initialises the repository if there is none.", + properties( + "project_id" to stringProp("Project id"), + "message" to stringProp("Commit message"), + "stage_all" to JSONObject().put("type", "boolean") + ), + required = listOf("project_id", "message") + ) + ) + + tools.put( + tool( + "git_push", + "Push the current branch to origin with the GitHub token stored in the app. The token is never returned.", + properties("project_id" to stringProp("Project id")), + required = listOf("project_id") + ) + ) + + tools.put( + tool( + "git_pull", + "Fetch and fast-forward the current branch from origin. Refuses when histories have diverged.", + properties("project_id" to stringProp("Project id")), + required = listOf("project_id") + ) + ) + tools.put( tool( "run_diagnostics", @@ -286,6 +329,10 @@ object McpTools { args.optString("database").ifBlank { null } ) "run_diagnostics" -> textResult(host.runDiagnostics()) + "git_status" -> gitStatus(host, args.getString("project_id")) + "git_commit" -> gitCommit(host, args.getString("project_id"), args.getString("message"), args.optBoolean("stage_all", true)) + "git_push" -> gitPush(host, args.getString("project_id")) + "git_pull" -> gitPull(host, args.getString("project_id")) "backup_project" -> backupProject(host, args.getString("project_id"), args.optBoolean("to_folder", false)) else -> errorResult("Unknown tool: $name") } @@ -515,6 +562,53 @@ object McpTools { return textResult(array.toString(2)) } + private fun withRepo(host: McpToolHost, projectId: String, block: suspend (Project) -> T): JSONObject = runBlocking { + val project = host.projectOrNull(projectId) ?: return@runBlocking errorResult("Project not found") + try { + val result = block(project) + if (result is JSONObject) result else textResult(result.toString()) + } catch (e: GitException) { + errorResult(e.message ?: "git failed") + } + } + + private fun gitStatus(host: McpToolHost, projectId: String) = withRepo(host, projectId) { project -> + val git = host.git() + if (!git.isRepository(project)) return@withRepo "No repository yet. git_commit creates one." + val s = git.status(project) + JSONObject() + .put("branch", s.branch ?: JSONObject.NULL) + .put("head", s.head ?: JSONObject.NULL) + .put("staged", JSONArray(s.staged)) + .put("unstaged", JSONArray(s.unstaged)) + .put("untracked", JSONArray(s.untracked)) + .put("ahead", s.ahead ?: JSONObject.NULL) + .put("behind", s.behind ?: JSONObject.NULL) + .put("remote", s.remote ?: JSONObject.NULL) + .toString(2) + } + + private fun gitCommit(host: McpToolHost, projectId: String, message: String, stageAll: Boolean) = + withRepo(host, projectId) { project -> + val git = host.git() + if (!git.isRepository(project)) git.init(project) + if (stageAll) git.stage(project) + val sha = git.commit(project, message) + host.onProjectChanged(project.id) + "Committed $sha on ${git.status(project).branch}" + } + + private fun gitPush(host: McpToolHost, projectId: String) = withRepo(host, projectId) { project -> + host.git().push(project) + "Pushed ${host.git().status(project).branch} to origin" + } + + private fun gitPull(host: McpToolHost, projectId: String) = withRepo(host, projectId) { project -> + val count = host.git().pull(project) + host.onProjectChanged(project.id) + if (count == 0) "Already up to date" else "Pulled $count new commit(s)" + } + private fun backupProject(host: McpToolHost, projectId: String, toFolder: Boolean): JSONObject = runBlocking { val project = host.projectOrNull(projectId) ?: return@runBlocking errorResult("Project not found") textResult(host.backupProject(project, toFolder)) diff --git a/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt b/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt index 68f9528..1d4c660 100644 --- a/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt +++ b/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt @@ -97,6 +97,10 @@ class ProjectArchive( } } + /** A project around files already placed in [id]'s source/, such as a git clone. */ + fun projectForSource(id: String, name: String, description: String): Project = + buildProject(id, storage.getProjectDir(id), null, name).copy(description = description) + private fun buildProject(id: String, root: File, manifest: JSONObject?, fallbackName: String): Project { val sourceDir = storage.getSourceDir(id) val entryPoint = detectEntryPoint(sourceDir, manifest?.optString("entry_point")) diff --git a/app/src/main/python/runcode_git.py b/app/src/main/python/runcode_git.py new file mode 100644 index 0000000..302df0c --- /dev/null +++ b/app/src/main/python/runcode_git.py @@ -0,0 +1,303 @@ +""" +Git for runcode projects, on dulwich (pure Python, so it runs inside the embedded CPython; a +native git binary cannot be shipped or executed on Android). + +Every public function takes plain strings and returns a JSON string, so the Kotlin side never +handles Python objects: {"ok": true, ...} on success, {"ok": false, "error": "..."} otherwise. + +Credentials arrive as arguments, are handed to dulwich's HTTP client, and are scrubbed from +every error message. They are never written to the repository config or to any output. +""" + +import io +import json +import re +import time + +from dulwich import porcelain +from dulwich.errors import NotGitRepository +from dulwich.repo import Repo + +DEFAULT_BRANCH = "main" +GITIGNORE = "__pycache__/\n*.pyc\n" +_URL_CREDENTIALS = re.compile(r"(://)[^/@\s]+@") + + +# ----------------------------------------------------------------------------- helpers + +def _result(**fields): + return json.dumps(dict(ok=True, **fields)) + + +def _failure(error, secrets=()): + text = str(error) or error.__class__.__name__ + for secret in secrets: + if secret: + text = text.replace(secret, "[REDACTED]") + text = _URL_CREDENTIALS.sub(r"\1[REDACTED]@", text) + return json.dumps({"ok": False, "error": text}) + + +def _guarded(*secrets): + """Turns any exception into a JSON failure with the given secrets scrubbed out.""" + def wrap(fn): + def inner(*args): + try: + return fn(*args) + except NotGitRepository: + return _failure("Not a git repository. Initialise it first.") + except Exception as error: # noqa: BLE001 - reported to the UI, never raised + found = [args[i] for i in secrets if i < len(args)] + return _failure(error, found) + inner.__name__ = fn.__name__ + inner.__doc__ = fn.__doc__ + return inner + return wrap + + +def _text(value): + return value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value) + + +def _quiet(): + """dulwich writes progress to stdout/stderr by default; keep it out of the app log.""" + return {"outstream": io.BytesIO(), "errstream": io.BytesIO()} + + +def _credentials(username, password): + return {"username": username, "password": password} if password else {} + + +def _current_branch(repo): + try: + return _text(porcelain.active_branch(repo)) + except (KeyError, IndexError, ValueError): + return None + + +def _head(repo): + try: + return repo.head() + except KeyError: + return None + + +def _count(repo, include, exclude): + if include is None: + return 0 + return sum(1 for _ in repo.get_walker(include=[include], exclude=[exclude] if exclude else None)) + + +# ----------------------------------------------------------------------------- local + +def is_repo(repo_dir): + try: + Repo(repo_dir).close() + return True + except NotGitRepository: + return False + + +@_guarded() +def init(repo_dir): + """Creates a repository on the main branch with a small .gitignore for Python caches.""" + import os + + repo = porcelain.init(repo_dir) + try: + repo.refs.set_symbolic_ref(b"HEAD", ("refs/heads/" + DEFAULT_BRANCH).encode()) + finally: + repo.close() + ignore = os.path.join(repo_dir, ".gitignore") + if not os.path.exists(ignore): + with open(ignore, "w") as handle: + handle.write(GITIGNORE) + return _result(branch=DEFAULT_BRANCH) + + +@_guarded() +def status(repo_dir): + with Repo(repo_dir) as repo: + state = porcelain.status(repo) + staged = {key: sorted(_text(p) for p in paths) for key, paths in state.staged.items()} + branch = _current_branch(repo) + head = _head(repo) + tracking = repo.refs.as_dict().get(("refs/remotes/origin/" + branch).encode()) if branch else None + return _result( + branch=branch, + head=_text(head)[:7] if head else None, + staged={"added": staged.get("add", []), "modified": staged.get("modify", []), + "deleted": staged.get("delete", [])}, + unstaged=sorted(_text(p) for p in state.unstaged), + untracked=sorted(_text(p) for p in state.untracked), + ahead=_count(repo, head, tracking) if tracking else None, + behind=_count(repo, tracking, head) if tracking else None, + remote=remote_url_of(repo), + ) + + +@_guarded() +def add(repo_dir, paths_json): + """Stages the given paths, or everything (including deletions) when the list is empty.""" + paths = json.loads(paths_json) if paths_json else [] + with Repo(repo_dir) as repo: + if paths: + import os + porcelain.add(repo, [os.path.join(repo_dir, p) for p in paths]) + else: + porcelain.add(repo) + # porcelain.add does not stage removals; mirror `git add -A`. + removed = porcelain.status(repo).unstaged + gone = [p for p in removed if not _exists(repo_dir, p)] + if gone: + porcelain.remove(repo, [_abs(repo_dir, p) for p in gone], cached=True) + return _result() + + +def _abs(repo_dir, path): + import os + return os.path.join(repo_dir, _text(path)) + + +def _exists(repo_dir, path): + import os + return os.path.lexists(_abs(repo_dir, path)) + + +@_guarded() +def commit(repo_dir, message, author_name, author_email): + if not message.strip(): + raise ValueError("Write a commit message first.") + if not author_name.strip() or not author_email.strip(): + raise ValueError("Set your name and email under Git settings; GitHub uses them to show the author.") + identity = ("%s <%s>" % (author_name.strip(), author_email.strip())).encode() + with Repo(repo_dir) as repo: + state = porcelain.status(repo) + if not any(state.staged.values()): + raise ValueError("Nothing is staged. Stage changes before committing.") + # No signing: there is no gpg or ssh-keygen on the device, and a global git config + # asking for it must not make every commit fail. + sha = porcelain.commit(repo, message=message.encode(), author=identity, committer=identity, sign=False) + return _result(sha=_text(sha)[:7]) + + +@_guarded() +def log(repo_dir, limit): + with Repo(repo_dir) as repo: + if _head(repo) is None: + return _result(commits=[]) + entries = [] + for entry in repo.get_walker(max_entries=int(limit)): + c = entry.commit + entries.append({ + "sha": _text(c.id)[:7], + "author": _text(c.author), + "time": c.author_time, + "message": _text(c.message).strip().split("\n")[0], + }) + return _result(commits=entries) + + +@_guarded() +def branches(repo_dir): + with Repo(repo_dir) as repo: + names = sorted(_text(b) for b in porcelain.branch_list(repo)) + return _result(current=_current_branch(repo), branches=names) + + +@_guarded() +def checkout(repo_dir, name, create): + """Switches branch. Refuses when uncommitted changes would be overwritten.""" + with Repo(repo_dir) as repo: + if create: + if _head(repo) is None: + raise ValueError("Make a first commit before creating branches.") + porcelain.branch_create(repo, name) + porcelain.checkout(repo, name) + return _result(branch=name) + + +def remote_url_of(repo): + try: + return _text(repo.get_config().get((b"remote", b"origin"), b"url")) + except KeyError: + return None + + +@_guarded() +def set_remote(repo_dir, url): + if _URL_CREDENTIALS.search(url): + raise ValueError("Leave credentials out of the URL; set a token under Git settings instead.") + with Repo(repo_dir) as repo: + config = repo.get_config() + config.set((b"remote", b"origin"), b"url", url.encode()) + config.set((b"remote", b"origin"), b"fetch", b"+refs/heads/*:refs/remotes/origin/*") + config.write_to_path() + return _result(remote=url) + + +# ----------------------------------------------------------------------------- network +# Arguments 2 and 3 (username, password) are scrubbed from errors by @_guarded. + +@_guarded(2, 3) +def clone(url, target_dir, username, password): + started = time.time() + repo = porcelain.clone(url, target_dir, checkout=True, errstream=io.BytesIO(), + **_credentials(username, password)) + try: + if _head(repo) is None: + _checkout_fallback_branch(repo) + branch = _current_branch(repo) + finally: + repo.close() + return _result(branch=branch, seconds=round(time.time() - started, 1)) + + +def _checkout_fallback_branch(repo): + """The remote's HEAD named a branch that does not exist: check out main, or the first one.""" + prefix = b"refs/remotes/origin/" + remote = sorted(r[len(prefix):] for r in repo.refs.keys() if r.startswith(prefix) and r != prefix + b"HEAD") + if not remote: + return # genuinely empty repository + name = DEFAULT_BRANCH.encode() if DEFAULT_BRANCH.encode() in remote else remote[0] + repo.refs[b"refs/heads/" + name] = repo.refs[prefix + name] + repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/" + name) + porcelain.reset(repo, "hard", b"refs/heads/" + name) + + +@_guarded(2, 3) +def push(repo_dir, branch, username, password): + with Repo(repo_dir) as repo: + url = remote_url_of(repo) + if not url: + raise ValueError("Set a remote URL first.") + head = _head(repo) + if head is None: + raise ValueError("Nothing to push yet. Make a commit first.") + ref = ("refs/heads/" + branch).encode() + porcelain.push(repo, url, ref, **_credentials(username, password), **_quiet()) + # Record what the remote now has, so status can count ahead/behind. + repo.refs[("refs/remotes/origin/" + branch).encode()] = repo.refs[ref] + return _result(branch=branch) + + +@_guarded(2, 3) +def pull(repo_dir, branch, username, password): + """Fetches and fast-forwards. A diverged branch is reported instead of merged.""" + with Repo(repo_dir) as repo: + url = remote_url_of(repo) + if not url: + raise ValueError("Set a remote URL first.") + before = _head(repo) + ref = ("refs/heads/" + branch).encode() + try: + porcelain.pull(repo, url, [ref], fast_forward=True, ff_only=True, + **_credentials(username, password), **_quiet()) + except porcelain.DivergedBranches: + raise ValueError("Local and remote history have diverged. runcode can only fast-forward; " + "push or reset from a computer to reconcile them.") + after = _head(repo) + if after is not None: + repo.refs[("refs/remotes/origin/" + branch).encode()] = after + new = _count(repo, after, before) if before else (_count(repo, after, None) if after else 0) + return _result(branch=branch, new_commits=new) diff --git a/app/src/test/java/com/runcode/app/git/GitManagerTest.kt b/app/src/test/java/com/runcode/app/git/GitManagerTest.kt new file mode 100644 index 0000000..99f1f88 --- /dev/null +++ b/app/src/test/java/com/runcode/app/git/GitManagerTest.kt @@ -0,0 +1,169 @@ +package com.runcode.app.git + +import android.app.Application +import android.content.Context +import com.runcode.app.domain.models.ProjectProfile +import com.runcode.app.security.SecretVault +import com.runcode.app.storage.ProjectArchive +import com.runcode.app.storage.ProjectStorage +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +/** + * Runs the real runcode_git.py in the host's Python, standing in for Chaquopy, so the Kotlin + * side is tested against actual dulwich behaviour rather than a mock. + */ +class HostPythonGitBackend(private val python: String) : GitBackend { + private val moduleDir = File("src/main/python").absoluteFile + + override fun call(function: String, vararg args: Any): String { + val shim = "import json,sys; sys.path.insert(0, sys.argv[1]); import runcode_git as g; " + + "r = json.load(sys.stdin); print(getattr(g, r['f'])(*r['a']))" + val process = ProcessBuilder(python, "-c", shim, moduleDir.path).redirectErrorStream(false).start() + process.outputStream.use { it.write(JSONObject().put("f", function).put("a", JSONArray(args.toList())).toString().toByteArray()) } + val out = process.inputStream.bufferedReader().readText() + val err = process.errorStream.bufferedReader().readText() + check(process.waitFor(60, TimeUnit.SECONDS) && process.exitValue() == 0) { "python failed: $err" } + return out.trim().lines().last() + } + + companion object { + /** A Python 3 that can import dulwich, or null. */ + fun find(): String? = listOfNotNull(System.getenv("RUNCODE_TEST_PYTHON"), System.getenv("RUNCODE_BUILD_PYTHON"), "python3") + .firstOrNull { candidate -> + runCatching { + val p = ProcessBuilder(candidate, "-c", "import dulwich").start() + p.waitFor(30, TimeUnit.SECONDS) && p.exitValue() == 0 + }.getOrDefault(false) + } + } +} + +private class MapVault : SecretVault { + val values = mutableMapOf() + override fun setSecret(key: String, plainText: String): Boolean { values[key] = plainText; return true } + override fun getSecret(key: String) = values[key] + override fun removeSecret(key: String) { values.remove(key) } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class GitManagerTest { + + private lateinit var storage: ProjectStorage + private lateinit var vault: MapVault + private lateinit var git: GitManager + + @Before fun setup() { + val python = HostPythonGitBackend.find() + assumeTrue("No Python with dulwich; set RUNCODE_TEST_PYTHON or pip install dulwich", python != null) + val app = RuntimeEnvironment.getApplication() + storage = ProjectStorage(app) + vault = MapVault() + git = GitManager(HostPythonGitBackend(python!!), storage, ProjectArchive(app, storage), vault, + app.getSharedPreferences("git-test-${System.nanoTime()}", Context.MODE_PRIVATE)) + git.identity = GitIdentity("Ada Lovelace", "ada@example.com") + } + + private fun bareRemote(): File { + val dir = Files.createTempDirectory("remote").toFile() + val p = ProcessBuilder(HostPythonGitBackend.find()!!, "-c", + "import sys; from dulwich.repo import Repo; r = Repo.init_bare(sys.argv[1]); " + + "r.refs.set_symbolic_ref(b'HEAD', b'refs/heads/main'); r.close()", dir.path).start() + assertTrue(p.waitFor(30, TimeUnit.SECONDS) && p.exitValue() == 0) + return dir + } + + @Test fun `init, stage, commit and log in source only`() = runBlocking { + val project = storage.createProjectFromTemplate("Git demo", ProjectProfile.PYTHON_SCRIPT) + File(storage.getDataDir(project.id), "app.db").writeText("not for git") + assertFalse(git.isRepository(project)) + + git.init(project) + assertTrue(git.isRepository(project)) + val before = git.status(project) + assertEquals("main", before.branch) + assertTrue(project.entryPoint in before.untracked) + assertTrue("data/ must stay out of the repository", before.untracked.none { it.contains("app.db") }) + + git.stage(project) + val sha = git.commit(project, "First commit") + assertTrue(git.status(project).isClean) + val log = git.log(project) + assertEquals(sha, log.single().sha) + assertEquals("Ada Lovelace ", log.single().author) + } + + @Test fun `missing identity is a readable error`() = runBlocking { + val project = storage.createProjectFromTemplate("No author", ProjectProfile.PYTHON_SCRIPT) + git.identity = GitIdentity("", "") + git.init(project) + git.stage(project) + val error = runCatching { git.commit(project, "x") }.exceptionOrNull() + assertTrue(error is GitException) + assertTrue(error!!.message!!.contains("name and email")) + } + + @Test fun `push, then clone it back as a new project with its entry point`() = runBlocking { + val remote = bareRemote() + val project = storage.createProjectFromTemplate("Bot", ProjectProfile.TELEGRAM_BOT) + git.init(project) + git.stage(project) + git.commit(project, "bot") + git.setRemote(project, remote.path) + git.push(project) + assertEquals(0, git.status(project).ahead) + + val cloned = git.cloneProject(remote.path + "/") + assertEquals(remote.name, cloned.name) + assertEquals(project.entryPoint, cloned.entryPoint) + assertTrue(git.isRepository(cloned)) + assertEquals( + File(storage.getSourceDir(project.id), project.entryPoint).readText(), + File(storage.getSourceDir(cloned.id), cloned.entryPoint).readText() + ) + } + + @Test fun `a failed clone leaves no project behind`() = runBlocking { + val before = storage.getProjectDir("x").parentFile!!.list()!!.toSet() + val error = runCatching { git.cloneProject("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/nonexistent/repo.git") }.exceptionOrNull() + assertTrue(error is GitException) + assertEquals(before, storage.getProjectDir("x").parentFile!!.list()!!.toSet()) + } + + @Test fun `the token is used for push but never appears in errors`() = runBlocking { + val token = "ghp_secretsecretsecret123" + git.setToken(token) + assertTrue(git.hasToken) + val project = storage.createProjectFromTemplate("Leak check", ProjectProfile.PYTHON_SCRIPT) + git.init(project) + git.stage(project) + git.commit(project, "x") + val closedPort = java.net.ServerSocket(0).use { it.localPort } + git.setRemote(project, "http://127.0.0.1:$closedPort/repo.git") + val error = runCatching { git.push(project) }.exceptionOrNull() + assertTrue(error is GitException) + assertFalse(error!!.message!!.contains(token)) + + git.setToken("") + assertFalse(git.hasToken) + } + + @Test fun `invalid branch names are refused before reaching git`() = runBlocking { + val project = storage.createProjectFromTemplate("Branches", ProjectProfile.PYTHON_SCRIPT) + git.init(project) + assertTrue(runCatching { git.checkout(project, "bad name; rm -rf", true) }.exceptionOrNull() is IllegalArgumentException) + } +} diff --git a/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt new file mode 100644 index 0000000..f98cc91 --- /dev/null +++ b/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt @@ -0,0 +1,73 @@ +package com.runcode.app.mcp + +import android.content.Context +import com.runcode.app.RuncodeApp +import com.runcode.app.domain.models.ProjectProfile +import com.runcode.app.git.GitIdentity +import com.runcode.app.git.GitManager +import com.runcode.app.git.HostPythonGitBackend +import com.runcode.app.security.SecretVault +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class McpGitToolsTest { + + @Test fun `commit, status and push through the bridge`() = runBlocking { + val python = HostPythonGitBackend.find() + assumeTrue("No Python with dulwich", python != null) + val app = RuntimeEnvironment.getApplication() as RuncodeApp + val vault = object : SecretVault { + override fun setSecret(key: String, plainText: String) = true + override fun getSecret(key: String): String? = null + override fun removeSecret(key: String) = Unit + } + val git = GitManager(HostPythonGitBackend(python!!), app.projectStorage, app.projectArchive, vault, + app.getSharedPreferences("mcp-git-test", Context.MODE_PRIVATE)) + git.identity = GitIdentity("Bridge Bot", "bot@example.com") + val changed = mutableListOf() + val host = McpToolHost(app.projectStorage, app.appMetaDatabase, app.projectDatabaseManager, + app.serviceSupervisor, app.logManager, app.terminalSession, { _, _ -> "" }, + app.projectSettings, { changed += it }, { "" }, { _, _ -> "" }, { git }) + + val project = app.projectStorage.createProjectFromTemplate("MCP git", ProjectProfile.PYTHON_HTTP) + app.appMetaDatabase.insertOrUpdateProject(project) + + fun call(name: String, args: JSONObject): JSONObject = McpTools.call(host, name, args.put("project_id", project.id)) + fun text(result: JSONObject) = result.getJSONArray("content").getJSONObject(0).getString("text") + + assertTrue(text(call("git_status", JSONObject())).startsWith("No repository yet")) + + val commit = call("git_commit", JSONObject().put("message", "From the bridge")) + assertFalse(text(commit), commit.getBoolean("isError")) + assertTrue(text(commit).startsWith("Committed ")) + assertEquals(listOf(project.id), changed) + + val status = JSONObject(text(call("git_status", JSONObject()))) + assertEquals("main", status.getString("branch")) + assertEquals(0, status.getJSONArray("untracked").length()) + + // No remote yet: a clear tool error, not an exception. + val push = call("git_push", JSONObject()) + assertTrue(push.getBoolean("isError")) + assertTrue(text(push).contains("remote")) + + val remote = Files.createTempDirectory("remote").toFile() + val init = ProcessBuilder(python, "-c", "import sys; from dulwich.repo import Repo; Repo.init_bare(sys.argv[1]).close()", remote.path).start() + assertTrue(init.waitFor(30, TimeUnit.SECONDS)) + git.setRemote(project, remote.path) + val pushed = call("git_push", JSONObject()) + assertFalse(text(pushed), pushed.getBoolean("isError")) + assertEquals("Already up to date", text(call("git_pull", JSONObject()))) + } +} diff --git a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt index 290b203..b1543e7 100644 --- a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt @@ -31,7 +31,7 @@ class McpSettingsTest { runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } host = McpToolHost(app.projectStorage, app.appMetaDatabase, app.projectDatabaseManager, app.serviceSupervisor, app.logManager, app.terminalSession, { _, _ -> "unused" }, - app.projectSettings, { notifications.add(it) }, { "diagnostics report" }, { _, _ -> "backup" }) + app.projectSettings, { notifications.add(it) }, { "diagnostics report" }, { _, _ -> "backup" }, { app.git }) } @Test fun `mcp replaces plain variables but preserves secret references`() = runBlocking { diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..018fb23 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,206 @@ +"""Host-side checks for runcode_git against local repositories and an authenticated HTTP server.""" +import base64 +import json +import os +from pathlib import Path +import sys +import tempfile +import threading +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "app/src/main/python")) + +try: + import runcode_git as git + from dulwich.repo import Repo + from dulwich.server import DictBackend + from dulwich.web import make_server, make_wsgi_chain, WSGIRequestHandlerLogger, WSGIServerLogger +except ImportError as missing: # pragma: no cover + git = None + MISSING = missing + +TOKEN = "ghp_testtoken1234567890" + + +def ok(raw): + result = json.loads(raw) + if not result["ok"]: + raise AssertionError(result["error"]) + return result + + +def fail(raw): + result = json.loads(raw) + assert not result["ok"], result + return result["error"] + + +def write(directory, name, text): + path = Path(directory, name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +@unittest.skipIf(git is None, "dulwich is not installed") +class LocalGitTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.repo = os.path.join(self.tmp.name, "source") + os.makedirs(self.repo) + write(self.repo, "main.py", "print('hi')\n") + + def tearDown(self): + self.tmp.cleanup() + + def commit(self, message="work"): + ok(git.add(self.repo, "[]")) + return ok(git.commit(self.repo, message, "Ada Lovelace", "ada@example.com")) + + def test_init_status_commit_log(self): + self.assertFalse(git.is_repo(self.repo)) + self.assertEqual("main", ok(git.init(self.repo))["branch"]) + self.assertTrue(git.is_repo(self.repo)) + self.assertTrue(Path(self.repo, ".gitignore").read_text().startswith("__pycache__/")) + + status = ok(git.status(self.repo)) + self.assertEqual([".gitignore", "main.py"], status["untracked"]) + + ok(git.add(self.repo, json.dumps(["main.py"]))) + self.assertEqual(["main.py"], ok(git.status(self.repo))["staged"]["added"]) + + sha = self.commit("First commit")["sha"] + entries = ok(git.log(self.repo, "10"))["commits"] + self.assertEqual(sha, entries[0]["sha"]) + self.assertEqual("First commit", entries[0]["message"]) + self.assertEqual("Ada Lovelace ", entries[0]["author"]) + self.assertEqual([], ok(git.status(self.repo))["untracked"]) + + def test_commit_needs_message_identity_and_staged_changes(self): + ok(git.init(self.repo)) + ok(git.add(self.repo, "[]")) + self.assertIn("commit message", fail(git.commit(self.repo, " ", "A", "a@b.c"))) + self.assertIn("name and email", fail(git.commit(self.repo, "msg", "", "a@b.c"))) + ok(git.commit(self.repo, "msg", "A", "a@b.c")) + self.assertIn("Nothing is staged", fail(git.commit(self.repo, "again", "A", "a@b.c"))) + + def test_add_all_stages_deletions(self): + ok(git.init(self.repo)) + self.commit() + os.remove(os.path.join(self.repo, "main.py")) + ok(git.add(self.repo, "[]")) + self.assertEqual(["main.py"], ok(git.status(self.repo))["staged"]["deleted"]) + + def test_branches_and_checkout(self): + ok(git.init(self.repo)) + self.assertIn("first commit", fail(git.checkout(self.repo, "feature", True))) + self.commit() + ok(git.checkout(self.repo, "feature", True)) + write(self.repo, "feature.py", "x = 1\n") + self.commit("feature work") + self.assertEqual({"current": "feature", "branches": ["feature", "main"]}, + {k: v for k, v in ok(git.branches(self.repo)).items() if k != "ok"}) + ok(git.checkout(self.repo, "main", False)) + self.assertFalse(Path(self.repo, "feature.py").exists()) + + def test_not_a_repository_is_a_clear_error(self): + self.assertIn("Not a git repository", fail(git.status(self.repo))) + + def test_credentials_in_remote_url_are_refused(self): + ok(git.init(self.repo)) + error = fail(git.set_remote(self.repo, "https://user:%s@github.com/a/b.git" % TOKEN)) + self.assertNotIn(TOKEN, error) + + def test_push_clone_pull_and_divergence_with_a_bare_remote(self): + remote = os.path.join(self.tmp.name, "remote.git") + Repo.init_bare(remote, mkdir=True).close() + + ok(git.init(self.repo)) + self.commit("first") + ok(git.set_remote(self.repo, remote)) + self.assertIsNone(ok(git.status(self.repo))["ahead"]) + ok(git.push(self.repo, "main", "", "")) + status = ok(git.status(self.repo)) + self.assertEqual((0, 0), (status["ahead"], status["behind"])) + self.assertEqual(remote, status["remote"]) + + other = os.path.join(self.tmp.name, "other") + self.assertEqual("main", ok(git.clone(remote, other, "", ""))["branch"]) + self.assertEqual("print('hi')\n", Path(other, "main.py").read_text()) + + write(other, "second.py", "y = 2\n") + ok(git.add(other, "[]")) + ok(git.commit(other, "second", "Bob", "bob@example.com")) + ok(git.push(other, "main", "", "")) + + self.assertEqual(1, ok(git.pull(self.repo, "main", "", ""))["new_commits"]) + self.assertTrue(Path(self.repo, "second.py").exists()) + + # Both sides commit: pull must refuse rather than merge. + write(other, "third.py", "z = 3\n") + ok(git.add(other, "[]")) + ok(git.commit(other, "third", "Bob", "bob@example.com")) + ok(git.push(other, "main", "", "")) + write(self.repo, "mine.py", "m = 1\n") + self.commit("mine") + self.assertIn("diverged", fail(git.pull(self.repo, "main", "", ""))) + + +class _BasicAuth: + """Rejects every request that does not carry the token, like GitHub does.""" + + def __init__(self, app): + self.app = app + self.expected = "Basic " + base64.b64encode(("x-access-token:" + TOKEN).encode()).decode() + + def __call__(self, environ, start_response): + if environ.get("HTTP_AUTHORIZATION") != self.expected: + start_response("401 Unauthorized", [("WWW-Authenticate", 'Basic realm="git"'), ("Content-Length", "0")]) + return [b""] + return self.app(environ, start_response) + + +@unittest.skipIf(git is None, "dulwich is not installed") +class HttpGitTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + bare = os.path.join(self.tmp.name, "remote.git") + self.remote = Repo.init_bare(bare, mkdir=True) + app = _BasicAuth(make_wsgi_chain(DictBackend({"/": self.remote}))) + self.server = make_server("127.0.0.1", 0, app, handler_class=WSGIRequestHandlerLogger, + server_class=WSGIServerLogger) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.url = "http://127.0.0.1:%d/" % self.server.server_port + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.remote.close() + self.tmp.cleanup() + + def test_push_and_clone_need_the_token_and_never_echo_it(self): + repo = os.path.join(self.tmp.name, "source") + os.makedirs(repo) + write(repo, "app.py", "print('pushed over http')\n") + ok(git.init(repo)) + ok(git.add(repo, "[]")) + ok(git.commit(repo, "http", "Ada", "ada@example.com")) + ok(git.set_remote(repo, self.url)) + + wrong = "ghp_wrongwrongwrong" + error = fail(git.push(repo, "main", "x-access-token", wrong)) + self.assertNotIn(wrong, error) + self.assertNotIn(TOKEN, error) + + ok(git.push(repo, "main", "x-access-token", TOKEN)) + self.assertIn(b"refs/heads/main", self.remote.refs.keys()) + + target = os.path.join(self.tmp.name, "cloned") + ok(git.clone(self.url, target, "x-access-token", TOKEN)) + self.assertEqual("print('pushed over http')\n", Path(target, "app.py").read_text()) + # The token must not end up in the clone's config. + self.assertNotIn(TOKEN, Path(target, ".git", "config").read_text()) + + +if __name__ == "__main__": + unittest.main() From 61632e461d887f0856c3bcfe8898b97e185ac9a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:49:46 +0000 Subject: [PATCH 07/15] Add the Git screen A new Git destination works on the selected project's source/ folder: initialise, see staged, changed and new files, commit with or without staging everything, switch and create branches, set the remote, push, pull (fast-forward only), and read the last 30 commits. Clone creates a new project from a URL and opens it. Settings holds the author name and email and a GitHub token, which goes to the Keystore-backed vault and is never shown again. Pull and branch switches refuse while the editor has unsaved changes, which they would otherwise overwrite on the next save; afterwards the editor and file tree refresh as they do for MCP edits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- README.md | 32 +- .../main/java/com/runcode/app/MainActivity.kt | 4 +- .../main/java/com/runcode/app/RuncodeApp.kt | 14 +- .../java/com/runcode/app/ui/MainViewModel.kt | 124 ++++++ .../runcode/app/ui/navigation/Navigation.kt | 2 + .../com/runcode/app/ui/screens/GitScreen.kt | 397 ++++++++++++++++++ .../com/runcode/app/ui/GitViewModelTest.kt | 73 ++++ 7 files changed, 641 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt create mode 100644 app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt diff --git a/README.md b/README.md index 22154e7..a7eac8d 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,27 @@ just `source/`, so whatever a script writes into `data/` is visible too. Binary files and files over 512 KB do not open in the editor; they can still be saved out. +## Git + +**Git** works on the selected project's `source/` folder, so `data/`, logs and secrets never +end up in commits. It runs on [dulwich](https://www.dulwich.io), a pure-Python git bundled with +the app, because a native git binary cannot be shipped or executed on Android. + +- **Initialise repository** creates one on `main` with a `.gitignore` for Python caches. +- The screen lists staged, changed and new files; commit with or without staging everything + first, and see the last 30 commits. +- **Branch** switches or creates branches. Switching refuses to overwrite uncommitted changes. +- **Remote**, **Push** and **Pull** work against GitHub or any smart-HTTP git server. Pull only + fast-forwards: when histories have diverged it says so instead of merging. +- **Clone** creates a new project from a URL, with the entry point detected as for a zip. +- **Settings** holds the author name and email (GitHub matches commits to accounts by email) + and a GitHub token. Use a fine-grained token with *Contents: read and write*. It is kept in + the Keystore-backed vault, never shown again, scrubbed from errors and never written into the + repository's config. + +Git operations that rewrite files refuse while the editor has unsaved changes. SSH remotes +are not supported; use HTTPS URLs. + ## Backups **Backups** creates checksummed archives of a project's `source/` and `data/`. Secrets, logs @@ -142,10 +163,10 @@ Authorization: Bearer Content-Type: application/json ``` -Nineteen tools: `list_projects`, `get_project`, `update_project_settings`, `list_files`, `read_file`, `write_file`, +Twenty-three tools: `list_projects`, `get_project`, `update_project_settings`, `list_files`, `read_file`, `write_file`, `create_directory`, `rename_path`, `delete_path`, `set_entry_point`, `start_service`, `stop_service`, `service_status`, `get_logs`, `run_command`, `run_python`, `sql_query`, -`run_diagnostics`, `backup_project`. +`run_diagnostics`, `backup_project`, `git_status`, `git_commit`, `git_push`, `git_pull`. File changes made over the bridge show up in the app straight away: the file tree refreshes, and a file open in the editor reloads unless it has unsaved edits. @@ -217,6 +238,7 @@ The bridge exposes a shell, arbitrary Python and read/write file access on the d app/src/main/java/com/runcode/app/ runtime/ PythonEngine (Chaquopy), StaticWebEngine, engine contracts supervisor/ ServiceSupervisor — lifecycle, restart policy, ports, wake lock + git/ GitManager over runcode_git.py (dulwich); token in the vault backup/ Checksummed archives, backups to a user-chosen folder (SAF), daily runs diagnostics/ Device and network checks behind the System screen and run_diagnostics terminal/ TerminalSession — interactive sh plus one-shot command runner @@ -227,15 +249,21 @@ app/src/main/java/com/runcode/app/ ui/ Compose screens app/src/main/python/ runcode_runner.py stdout/stderr bridge, cooperative stop, snippet runner + runcode_git.py git on dulwich, JSON in and out, credentials scrubbed ``` ## Tests ```bash +python -m pip install dulwich==1.2.15 python -m unittest discover -s tests -v ./gradlew testDebugUnitTest lintDebug assembleDebug ``` +Git tests in both suites run the real `runcode_git.py`. The Kotlin ones use the Python named by +`RUNCODE_TEST_PYTHON` or `RUNCODE_BUILD_PYTHON`, or `python3`, and are skipped when none of +them can import dulwich. + The JVM tests cover settings validation, vault rollback, secret handling, archive paths, editor ownership, supervisor restarts, diagnostics rules and probes, folder backups through a real `DocumentsProvider`, and the public tunnel against an in-process SSH server. Robolectric tests use API 28; device testing is diff --git a/app/src/main/java/com/runcode/app/MainActivity.kt b/app/src/main/java/com/runcode/app/MainActivity.kt index cb05e1b..2ba9491 100644 --- a/app/src/main/java/com/runcode/app/MainActivity.kt +++ b/app/src/main/java/com/runcode/app/MainActivity.kt @@ -55,6 +55,7 @@ import com.runcode.app.ui.navigation.Screen import com.runcode.app.ui.screens.BackupsScreen import com.runcode.app.ui.screens.DatabaseScreen import com.runcode.app.ui.screens.EditorScreen +import com.runcode.app.ui.screens.GitScreen import com.runcode.app.ui.screens.HealthScreen import com.runcode.app.ui.screens.HomeScreen import com.runcode.app.ui.screens.ProjectsScreen @@ -140,6 +141,7 @@ fun MainApp(viewModel: MainViewModel) { Screen.HOME -> HomeScreen(viewModel = viewModel, onNavigate = { currentScreen = it }) Screen.PROJECTS -> ProjectsScreen(viewModel = viewModel, onNavigate = { currentScreen = it }) Screen.EDITOR -> EditorScreen(viewModel = viewModel) + Screen.GIT -> GitScreen(viewModel = viewModel) Screen.TERMINAL -> TerminalScreen(viewModel = viewModel) Screen.SERVICES -> ServicesScreen(viewModel = viewModel) Screen.DATABASE -> DatabaseScreen(viewModel = viewModel) @@ -151,7 +153,7 @@ fun MainApp(viewModel: MainViewModel) { } /** - * Eight destinations do not fit a phone width. Material's NavigationBar distributes its items + * Nine destinations do not fit a phone width. Material's NavigationBar distributes its items * with weights, which collapses under the infinite width a horizontal scroll hands it, so the * bar is laid out by hand instead: fixed-width items in a scrolling row. */ diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index f59f0ff..fab9dbc 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -3,6 +3,7 @@ package com.runcode.app import android.app.Application import android.net.ConnectivityManager import android.net.Network +import androidx.annotation.VisibleForTesting import com.chaquo.python.Python import com.runcode.app.backup.BackupFolderSettings import com.runcode.app.backup.BackupManager @@ -11,6 +12,7 @@ import com.runcode.app.database.AppMetaDatabase import com.runcode.app.database.ProjectDatabaseManager import com.runcode.app.diagnostics.DiagnosticsRunner import com.runcode.app.git.ChaquopyGitBackend +import com.runcode.app.git.GitBackend import com.runcode.app.git.GitManager import com.runcode.app.domain.models.LogLevel import com.runcode.app.domain.models.Project @@ -77,9 +79,12 @@ class RuncodeApp : Application() { val diagnostics: DiagnosticsRunner by lazy { DiagnosticsRunner(this) } + /** Tests swap this for the host's Python before first use of [git]. */ + @VisibleForTesting + var gitBackend: GitBackend = ChaquopyGitBackend() + val git: GitManager by lazy { - GitManager(ChaquopyGitBackend(), projectStorage, projectArchive, secretStore, - getSharedPreferences("git", MODE_PRIVATE)) + GitManager(gitBackend, projectStorage, projectArchive, secretStore, getSharedPreferences("git", MODE_PRIVATE)) } lateinit var processMonitor: ProcessMonitor private set @@ -96,6 +101,11 @@ class RuncodeApp : Application() { */ val projectChanges: SharedFlow = _projectChanges + /** Something other than the editor rewrote a project's files (MCP, git pull, checkout). */ + fun notifyProjectChanged(projectId: String) { + _projectChanges.tryEmit(projectId) + } + var isPythonAvailable: Boolean = false private set diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index dc1bfaa..621c345 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -19,6 +19,9 @@ import com.runcode.app.domain.models.RuntimeEvent import com.runcode.app.domain.models.RuntimeInstance import com.runcode.app.domain.models.TableInfo import com.runcode.app.diagnostics.DiagnosticReport +import com.runcode.app.git.GitCommit +import com.runcode.app.git.GitIdentity +import com.runcode.app.git.GitStatus import com.runcode.app.mcp.McpServerState import com.runcode.app.mcp.McpTunnelState import com.runcode.app.mcp.TunnelStatus @@ -145,6 +148,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val threads: Int = 0 ) + /** Everything the Git screen shows for the selected project. */ + data class GitUiState( + val projectId: String? = null, + val isRepository: Boolean = false, + val status: GitStatus? = null, + val branches: List = emptyList(), + val log: List = emptyList(), + val busy: String? = null, + val error: String? = null + ) + + private val _git = MutableStateFlow(GitUiState()) + val git: StateFlow = _git.asStateFlow() + private val _diagnostics = MutableStateFlow(null) val diagnostics: StateFlow = _diagnostics.asStateFlow() @@ -244,6 +261,113 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun mcpLanAddress(): String = app.portManager.getLanIp() + // ---------------------------------------------------------------- git + + val gitIdentity: GitIdentity get() = app.git.identity + val gitHasToken: Boolean get() = app.git.hasToken + + fun refreshGit() { + val project = _selectedProject.value ?: run { _git.value = GitUiState(); return } + gitTask(project, null) { refreshGitState(project) } + } + + private suspend fun refreshGitState(project: Project, error: String? = null) { + val git = app.git + if (!git.isRepository(project)) { + _git.value = GitUiState(projectId = project.id, error = error) + return + } + val status = git.status(project) + val (_, branches) = git.branches(project) + _git.value = GitUiState(project.id, true, status, branches, git.log(project), error = error) + } + + fun gitInit() = gitAction("Initialising") { app.git.init(it); "Repository created on main" } + + fun gitStageAll() = gitAction("Staging") { app.git.stage(it); null } + + fun gitCommit(message: String, stageAll: Boolean) = gitAction("Committing") { project -> + if (stageAll) app.git.stage(project) + "Committed ${app.git.commit(project, message)}" + } + + fun gitPush() = gitAction("Pushing") { app.git.push(it); "Pushed to origin" } + + fun gitPull() = gitAction("Pulling", changesFiles = true) { project -> + val count = app.git.pull(project) + if (count == 0) "Already up to date" else "Pulled $count new commit(s)" + } + + fun gitCheckout(branch: String, create: Boolean) = gitAction("Switching branch", changesFiles = true) { project -> + app.git.checkout(project, branch, create) + "On branch $branch" + } + + fun gitSetRemote(url: String) = gitAction("Saving remote") { app.git.setRemote(it, url); "Remote saved" } + + fun saveGitSettings(name: String, email: String, token: String?) { + app.git.identity = GitIdentity(name, email) + try { + token?.let { app.git.setToken(it) } + _userMessage.value = if (token?.isBlank() == true) "Git settings saved; token removed" else "Git settings saved" + } catch (e: Exception) { + _userMessage.value = e.message + } + } + + /** Clones into a new project, saves it and opens it. */ + fun gitClone(url: String) { + if (_git.value.busy != null) return + _git.value = _git.value.copy(busy = "Cloning", error = null) + viewModelScope.launch { + try { + val project = app.git.cloneProject(url) + app.appMetaDatabase.insertOrUpdateProject(project) + loadProjects() + selectProject(project) + _userMessage.value = "Cloned into '${project.name}'" + _git.value = GitUiState() + refreshGit() + } catch (e: Exception) { + _git.value = _git.value.copy(busy = null, error = e.message) + } + } + } + + /** + * Runs a git operation on the selected project and refreshes the screen. Operations that + * rewrite files refuse while the editor has unsaved changes, which they would otherwise + * be silently overwritten by on the next save. + */ + private fun gitAction(label: String, changesFiles: Boolean = false, block: suspend (Project) -> String?) { + val project = _selectedProject.value ?: return + if (changesFiles && _isDirty.value) { + _git.value = _git.value.copy(error = "Save the file open in the editor first.") + return + } + gitTask(project, label) { + val message = block(project) + if (changesFiles) app.notifyProjectChanged(project.id) + refreshGitState(project) + message?.let { _userMessage.value = it } + } + } + + private fun gitTask(project: Project, label: String?, block: suspend () -> Unit) { + if (_git.value.busy != null) return + _git.value = _git.value.copy(projectId = project.id, busy = label ?: "Loading", error = null) + viewModelScope.launch { + try { + block() + } catch (e: Exception) { + runCatching { refreshGitState(project, e.message ?: e.javaClass.simpleName) } + .onFailure { _git.value = _git.value.copy(error = e.message) } + } finally { + _git.value = _git.value.copy(busy = null) + } + } + } + // ---------------------------------------------------------------- diagnostics fun runDiagnostics() { diff --git a/app/src/main/java/com/runcode/app/ui/navigation/Navigation.kt b/app/src/main/java/com/runcode/app/ui/navigation/Navigation.kt index d600d1f..b28fee4 100644 --- a/app/src/main/java/com/runcode/app/ui/navigation/Navigation.kt +++ b/app/src/main/java/com/runcode/app/ui/navigation/Navigation.kt @@ -1,6 +1,7 @@ package com.runcode.app.ui.navigation import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Folder @@ -15,6 +16,7 @@ enum class Screen(val title: String, val icon: ImageVector) { HOME("Home", Icons.Default.Dashboard), PROJECTS("Projects", Icons.Default.Folder), EDITOR("Editor", Icons.Default.Code), + GIT("Git", Icons.Default.AccountTree), TERMINAL("Shell", Icons.Default.Terminal), SERVICES("Services", Icons.Default.PlayCircle), DATABASE("Data", Icons.Default.Storage), diff --git a/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt new file mode 100644 index 0000000..0e400c4 --- /dev/null +++ b/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt @@ -0,0 +1,397 @@ +package com.runcode.app.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.runcode.app.git.GitStatus +import com.runcode.app.ui.MainViewModel +import com.runcode.app.ui.theme.AccentCyan +import com.runcode.app.ui.theme.AccentGreen +import com.runcode.app.ui.theme.AccentRed +import com.runcode.app.ui.theme.DarkBorder +import com.runcode.app.ui.theme.DarkSurface +import com.runcode.app.ui.theme.TextMuted +import com.runcode.app.ui.theme.TextPrimary +import com.runcode.app.ui.theme.TextSecondary +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val Amber = Color(0xFFFFB300) + +/** Git for the selected project's source/ folder. */ +@Composable +fun GitScreen(viewModel: MainViewModel) { + val project by viewModel.selectedProject.collectAsState() + val state by viewModel.git.collectAsState() + var dialog by remember { mutableStateOf(null) } + + LaunchedEffect(project?.id) { viewModel.refreshGit() } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Git", style = MaterialTheme.typography.titleLarge, color = TextPrimary) + Text( + text = project?.let { "${it.name} · source/" } ?: "Select a project first", + fontSize = 12.sp, + color = TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton(onClick = { dialog = GitDialog.Settings }) { Text("Settings", color = AccentCyan, fontSize = 12.sp) } + TextButton(onClick = { dialog = GitDialog.Clone }, modifier = Modifier.testTag("git_clone_btn")) { + Text("Clone", color = AccentCyan, fontSize = 12.sp) + } + } + } + + state.busy?.let { label -> + item { Text("$label…", fontSize = 12.sp, color = AccentCyan) } + } + state.error?.let { error -> + item { Text(error, fontSize = 12.sp, color = AccentRed, lineHeight = 16.sp) } + } + + if (project == null) return@LazyColumn + + if (!state.isRepository) { + item { + GitCard { + Text("Not a repository yet", fontWeight = FontWeight.Bold, color = TextPrimary, fontSize = 13.sp) + Text( + "Initialising creates a repository in source/ on the main branch, with a .gitignore for " + + "Python caches. data/, logs and secrets stay out of it.", + fontSize = 11.sp, + color = TextSecondary, + lineHeight = 15.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { viewModel.gitInit() }, + enabled = state.busy == null, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + modifier = Modifier.testTag("git_init_btn") + ) { Text("Initialise repository", color = Color.Black, fontWeight = FontWeight.Bold) } + } + } + return@LazyColumn + } + + val status = state.status ?: return@LazyColumn + item { BranchCard(status, state.busy == null, onBranch = { dialog = GitDialog.Branch }, onRemote = { dialog = GitDialog.Remote }, + onPush = { viewModel.gitPush() }, onPull = { viewModel.gitPull() }) } + item { ChangesCard(status, state.busy == null, onStageAll = { viewModel.gitStageAll() }) } + item { CommitCard(status, state.busy == null, onCommit = { message, stageAll -> viewModel.gitCommit(message, stageAll) }) } + + item { Text("History (${state.log.size})", style = MaterialTheme.typography.titleMedium, color = TextPrimary) } + items(state.log, key = { it.sha }) { commit -> + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text(commit.message, fontSize = 12.sp, color = TextPrimary, maxLines = 2, overflow = TextOverflow.Ellipsis) + val time = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(commit.time * 1000)) + Text("${commit.sha} · ${commit.author.substringBefore(" <")} · $time", + fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = TextMuted) + } + } + } + + when (dialog) { + GitDialog.Settings -> SettingsDialog(viewModel) { dialog = null } + GitDialog.Clone -> TextDialog( + title = "Clone a repository", + label = "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/user/repo.git", + hint = "Creates a new project. Private repositories use the token from Settings.", + confirm = "Clone" + ) { url -> dialog = null; url?.let { viewModel.gitClone(it) } } + GitDialog.Remote -> TextDialog( + title = "Remote (origin)", + label = "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/user/repo.git", + initial = state.status?.remote ?: "", + hint = "Do not put a token in the URL; it goes in Settings.", + confirm = "Save" + ) { url -> dialog = null; url?.let { viewModel.gitSetRemote(it) } } + GitDialog.Branch -> BranchDialog(state.branches, state.status?.branch) { name, create -> + dialog = null + name?.let { viewModel.gitCheckout(it, create) } + } + null -> Unit + } +} + +private enum class GitDialog { Settings, Clone, Remote, Branch } + +@Composable +private fun GitCard(content: @Composable () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) + ) { + Column(modifier = Modifier.padding(14.dp)) { content() } + } +} + +@Composable +private fun BranchCard( + status: GitStatus, + enabled: Boolean, + onBranch: () -> Unit, + onRemote: () -> Unit, + onPush: () -> Unit, + onPull: () -> Unit +) { + GitCard { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(status.branch ?: "(detached)", fontWeight = FontWeight.Bold, color = AccentGreen, fontSize = 14.sp) + val sync = when { + status.remote == null -> "no remote" + status.ahead == null -> "not pushed yet" + status.ahead == 0 && status.behind == 0 -> "up to date with origin" + else -> "${status.ahead} to push · ${status.behind} to pull" + } + Text("${status.head ?: "no commits"} · $sync", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextSecondary) + } + TextButton(onClick = onBranch, enabled = enabled) { Text("Branch", color = AccentCyan, fontSize = 12.sp) } + } + Text( + text = status.remote ?: "No remote set", + fontSize = 11.sp, + color = TextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onRemote, enabled = enabled, modifier = Modifier.weight(1f)) { + Text("Remote", color = AccentCyan, maxLines = 1) + } + OutlinedButton(onClick = onPull, enabled = enabled && status.remote != null, modifier = Modifier.weight(1f)) { + Text("Pull", color = AccentCyan, maxLines = 1) + } + Button( + onClick = onPush, + enabled = enabled && status.remote != null && status.head != null, + colors = ButtonDefaults.buttonColors(containerColor = AccentGreen), + modifier = Modifier.weight(1f).testTag("git_push_btn") + ) { Text("Push", color = Color.Black, fontWeight = FontWeight.Bold, maxLines = 1) } + } + } +} + +@Composable +private fun ChangesCard(status: GitStatus, enabled: Boolean, onStageAll: () -> Unit) { + GitCard { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (status.isClean) "No changes" else "Changes", + fontWeight = FontWeight.Bold, + color = TextPrimary, + fontSize = 13.sp, + modifier = Modifier.weight(1f) + ) + if (status.unstaged.isNotEmpty() || status.untracked.isNotEmpty()) { + TextButton(onClick = onStageAll, enabled = enabled) { Text("Stage all", color = AccentCyan, fontSize = 12.sp) } + } + } + status.added.forEach { FileLine("A", it, AccentGreen) } + status.modified.forEach { FileLine("M", it, AccentGreen) } + status.deleted.forEach { FileLine("D", it, AccentGreen) } + status.unstaged.forEach { FileLine("M", it, Amber) } + status.untracked.forEach { FileLine("?", it, TextMuted) } + if (!status.isClean) { + Text("Green is staged, amber changed, grey new.", fontSize = 10.sp, color = TextMuted) + } + } +} + +@Composable +private fun FileLine(mark: String, path: String, color: Color) { + Row(modifier = Modifier.padding(vertical = 1.dp)) { + Text(mark, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = color, modifier = Modifier.width(16.dp)) + Text(path, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun CommitCard(status: GitStatus, enabled: Boolean, onCommit: (String, Boolean) -> Unit) { + var message by remember { mutableStateOf("") } + var stageAll by remember { mutableStateOf(true) } + val hasWork = if (stageAll) !status.isClean else status.staged.isNotEmpty() + GitCard { + OutlinedTextField( + value = message, + onValueChange = { message = it }, + label = { Text("Commit message") }, + modifier = Modifier.fillMaxWidth().testTag("git_commit_message"), + minLines = 2 + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = stageAll, onCheckedChange = { stageAll = it }) + Text("Stage all changes first", fontSize = 12.sp, color = TextSecondary, modifier = Modifier.weight(1f)) + Button( + onClick = { onCommit(message, stageAll); message = "" }, + enabled = enabled && hasWork && message.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + modifier = Modifier.testTag("git_commit_btn") + ) { Text("Commit", color = Color.Black, fontWeight = FontWeight.Bold) } + } + } +} + +@Composable +private fun SettingsDialog(viewModel: MainViewModel, onDone: () -> Unit) { + val identity = remember { viewModel.gitIdentity } + var name by remember { mutableStateOf(identity.name) } + var email by remember { mutableStateOf(identity.email) } + var token by remember { mutableStateOf("") } + var clearToken by remember { mutableStateOf(false) } + val hasToken = remember { viewModel.gitHasToken } + + AlertDialog( + onDismissRequest = onDone, + title = { Text("Git settings", color = TextPrimary) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("GitHub shows commits under the account whose email matches.", fontSize = 11.sp, color = TextSecondary) + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Author name") }, singleLine = true) + OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("Author email") }, singleLine = true) + OutlinedTextField( + value = token, + onValueChange = { token = it; clearToken = false }, + label = { Text(if (hasToken) "New token (one is saved)" else "GitHub token") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation() + ) + Text( + "A fine-grained token with Contents read and write. It is kept in the Keystore vault and never shown again.", + fontSize = 10.sp, + color = TextMuted, + lineHeight = 13.sp + ) + if (hasToken) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = clearToken, onCheckedChange = { clearToken = it; if (it) token = "" }) + Text("Remove the saved token", fontSize = 12.sp, color = TextSecondary) + } + } + } + }, + confirmButton = { + Button(onClick = { + viewModel.saveGitSettings(name, email, when { + clearToken -> "" + token.isNotBlank() -> token + else -> null + }) + onDone() + }, colors = ButtonDefaults.buttonColors(containerColor = AccentCyan)) { + Text("Save", color = Color.Black, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { TextButton(onClick = onDone) { Text("Cancel", color = TextSecondary) } } + ) +} + +@Composable +private fun TextDialog( + title: String, + label: String, + hint: String, + confirm: String, + initial: String = "", + onDone: (String?) -> Unit +) { + var value by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = { onDone(null) }, + title = { Text(title, color = TextPrimary) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(label) }, singleLine = true) + Text(hint, fontSize = 11.sp, color = TextMuted) + } + }, + confirmButton = { + Button(onClick = { onDone(value.trim()) }, enabled = value.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan)) { + Text(confirm, color = Color.Black, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { TextButton(onClick = { onDone(null) }) { Text("Cancel", color = TextSecondary) } } + ) +} + +@Composable +private fun BranchDialog(branches: List, current: String?, onDone: (String?, Boolean) -> Unit) { + var newName by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { onDone(null, false) }, + title = { Text("Branches", color = TextPrimary) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + branches.forEach { branch -> + TextButton(onClick = { onDone(branch, false) }, enabled = branch != current) { + Text(if (branch == current) "● $branch" else branch, + color = if (branch == current) AccentGreen else AccentCyan, fontFamily = FontFamily.Monospace) + } + } + OutlinedTextField(value = newName, onValueChange = { newName = it.trim() }, + label = { Text("New branch from here") }, singleLine = true) + Text("Switching refuses to overwrite uncommitted changes.", fontSize = 10.sp, color = TextMuted) + } + }, + confirmButton = { + Button(onClick = { onDone(newName, true) }, enabled = newName.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan)) { + Text("Create", color = Color.Black, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { TextButton(onClick = { onDone(null, false) }) { Text("Close", color = TextSecondary) } } + ) +} diff --git a/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt new file mode 100644 index 0000000..845c8fe --- /dev/null +++ b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt @@ -0,0 +1,73 @@ +package com.runcode.app.ui + +import android.os.Looper +import com.runcode.app.RuncodeApp +import com.runcode.app.domain.models.ProjectProfile +import com.runcode.app.git.GitIdentity +import com.runcode.app.git.HostPythonGitBackend +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** The Git screen's view model against real git (dulwich in the host's Python). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class GitViewModelTest { + + private fun await(what: String, condition: () -> Boolean) { + val deadline = System.nanoTime() + 30_000_000_000 + while (!condition() && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + assertTrue("Timed out waiting for: $what", condition()) + } + + @Test fun `initialise, commit, and refuse a pull over unsaved editor changes`() { + val python = HostPythonGitBackend.find() + assumeTrue("No Python with dulwich", python != null) + val app = RuntimeEnvironment.getApplication() as RuncodeApp + app.gitBackend = HostPythonGitBackend(python!!) + app.git.identity = GitIdentity("Ada", "ada@example.com") + + val project = app.projectStorage.createProjectFromTemplate("VM git", ProjectProfile.PYTHON_SCRIPT) + runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } + val model = MainViewModel(app) + await("projects") { model.projects.value.isNotEmpty() } + model.selectProject(project) + await("selection") { model.selectedProject.value?.id == project.id && model.editorContent.value.isNotEmpty() } + + model.refreshGit() + await("first refresh") { model.git.value.busy == null && model.git.value.projectId == project.id } + assertFalse(model.git.value.isRepository) + + model.gitInit() + await("init") { model.git.value.isRepository && model.git.value.busy == null } + assertEquals("main", model.git.value.status!!.branch) + assertFalse(model.git.value.status!!.isClean) + + model.gitCommit("First", stageAll = true) + await("commit") { model.git.value.log.size == 1 && model.git.value.busy == null } + assertTrue(model.git.value.status!!.isClean) + assertEquals("First", model.git.value.log.single().message) + + // Unsaved edits: a pull would rewrite files underneath the editor. + model.updateEditorContent("unsaved change") + model.gitPull() + assertEquals("Save the file open in the editor first.", model.git.value.error) + + // A failing operation surfaces its reason and leaves the screen usable. + model.saveCurrentFile() + await("save") { !model.isDirty.value } + model.gitPush() + await("push error") { model.git.value.busy == null && model.git.value.error != null } + assertTrue(model.git.value.error!!.contains("remote")) + assertTrue(model.git.value.isRepository) + } +} From b0d26068fb82f0bbe51c2110460739749d351bfe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:55:16 +0000 Subject: [PATCH 08/15] Verify relay host keys and harden daily backups Addresses review findings: - The tunnel accepted any relay host key, so anyone on the network path could pose as the relay, show their own URL and collect the bearer token. Relay keys are now trusted on first use per host:port and a different key is refused before any forward is set up. A host-key error offers Forget relay keys for a relay that did rotate its key. - An exception during an automatic backup could end the daily loop and crash the app. runAutoBackupIfDue now records the failure instead of throwing. - Automatic backup names use UTC, so pruning keeps the newest copies across time-zone and daylight-saving changes. - A failed port probe reports that the port did not accept a connection rather than claiming nothing listens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- .../main/java/com/runcode/app/RuncodeApp.kt | 28 ++++-- .../com/runcode/app/backup/FolderBackups.kt | 7 +- .../app/diagnostics/DiagnosticRules.kt | 2 +- .../com/runcode/app/mcp/SshTunnelConnector.kt | 42 ++++---- .../java/com/runcode/app/mcp/TofuHostKeys.kt | 98 +++++++++++++++++++ .../java/com/runcode/app/ui/MainViewModel.kt | 8 ++ .../runcode/app/ui/screens/HealthScreen.kt | 5 + .../runcode/app/backup/FolderBackupsTest.kt | 12 +++ .../runcode/app/mcp/SshTunnelConnectorTest.kt | 50 +++++++++- 9 files changed, 213 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/runcode/app/mcp/TofuHostKeys.kt diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index fab9dbc..54afbbe 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -35,6 +35,7 @@ import com.runcode.app.system.CompatibilityManager import com.runcode.app.system.CrashReporter import com.runcode.app.system.ProcessMonitor import com.runcode.app.terminal.TerminalSession +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -77,6 +78,9 @@ class RuncodeApp : Application() { lateinit var mcpTunnel: McpTunnel private set + /** Host keys of the tunnel relays, trusted on first use. */ + val tunnelHostKeys: File get() = File(filesDir, "tunnel_known_hosts") + val diagnostics: DiagnosticsRunner by lazy { DiagnosticsRunner(this) } /** Tests swap this for the host's Python before first use of [git]. */ @@ -169,7 +173,7 @@ class RuncodeApp : Application() { onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } ) mcpTunnel = McpTunnel( - connector = SshTunnelConnector(), + connector = SshTunnelConnector(tunnelHostKeys), onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } ) watchDefaultNetwork() @@ -220,13 +224,21 @@ class RuncodeApp : Application() { suspend fun runAutoBackupIfDue() { val now = System.currentTimeMillis() if (!backupFolder.isAutoDue(now)) return - val store = backupFolder.store() - val result = if (store == null) { - "Skipped: the backup folder is no longer accessible. Choose it again." - } else { - val projects = appMetaDatabase.getAllProjects() - val (done, error) = folderBackups.exportAll(store, projects) - if (error == null) "Backed up $done project(s)" else "Backed up $done of ${projects.size}. $error" + // Never throws: it runs in an endless loop and from the UI, and one failure must not + // end daily backups or crash the app. + val result = try { + val store = backupFolder.store() + if (store == null) { + "Skipped: the backup folder is no longer accessible. Choose it again." + } else { + val projects = appMetaDatabase.getAllProjects() + val (done, error) = folderBackups.exportAll(store, projects) + if (error == null) "Backed up $done project(s)" else "Backed up $done of ${projects.size}. $error" + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + "Failed: ${e.message ?: e.javaClass.simpleName}" } backupFolder.recordAutoRun(now, result) logManager.log(BACKUP_LOG_ID, "Backups", LogLevel.SYSTEM, "Automatic backup: $result") diff --git a/app/src/main/java/com/runcode/app/backup/FolderBackups.kt b/app/src/main/java/com/runcode/app/backup/FolderBackups.kt index 9b2a25c..129c74d 100644 --- a/app/src/main/java/com/runcode/app/backup/FolderBackups.kt +++ b/app/src/main/java/com/runcode/app/backup/FolderBackups.kt @@ -10,6 +10,7 @@ import java.security.MessageDigest import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.TimeZone /** * Backups in a user-chosen folder: export with read-back verification, listing, restore, and @@ -95,7 +96,11 @@ class FolderBackups( } fun fileName(project: Project, automatic: Boolean): String { - val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date(now())) + // UTC, so names sort in creation order across time-zone and daylight-saving changes; + // pruning relies on that order. + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + .format(Date(now())) return if (automatic) "${autoPrefix(project)}$stamp${SafBackupStore.BACKUP_EXTENSION}" else "${safeName(project)}-${project.id}-$stamp${SafBackupStore.BACKUP_EXTENSION}" } diff --git a/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt index 9b7922b..4dbb9ae 100644 --- a/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt +++ b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt @@ -138,7 +138,7 @@ object DiagnosticRules { check(SERVICES, s.name, INFO, "${s.profile.displayName}, does not serve a port") s.probe == null -> check(SERVICES, s.name, INFO, "port ${s.port} not probed") s.probe.ok -> check(SERVICES, s.name, PASS, "port ${s.port} accepts connections") - else -> check(SERVICES, s.name, FAIL, "port ${s.port} is not listening (${s.probe.detail})") + else -> check(SERVICES, s.name, FAIL, "port ${s.port} did not accept a connection (${s.probe.detail})") } } } diff --git a/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt b/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt index fc8e971..41fc2bb 100644 --- a/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt +++ b/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt @@ -3,8 +3,8 @@ package com.runcode.app.mcp import com.jcraft.jsch.ChannelShell import com.jcraft.jsch.JSch import com.jcraft.jsch.Session -import com.jcraft.jsch.UIKeyboardInteractive -import com.jcraft.jsch.UserInfo +import com.jcraft.jsch.JSchChangedHostKeyException +import java.io.File import java.io.IOException import java.io.InputStream @@ -12,18 +12,27 @@ import java.io.InputStream * Opens a reverse tunnel the way `ssh -p 443 -R0:localhost:8765 free.pinggy.io` does: remote * forward to the local bridge, then read the public URL from the relay's shell output. */ -class SshTunnelConnector : TunnelConnector { +class SshTunnelConnector(private val knownHosts: File) : TunnelConnector { override fun open(provider: TunnelProvider, localPort: Int, timeoutMs: Int): OpenTunnel { val session = JSch().getSession(provider.user, provider.host, provider.port) - // Free relays take no credentials and publish no host keys to pin. The tunnel URL is - // HTTPS, terminated at the relay; the bearer token is what guards the bridge. - session.setConfig("StrictHostKeyChecking", "no") + // Free relays take no credentials and publish no host keys to pin, so the first key + // each relay presents is trusted and a different one later is refused. + val hostKeys = TofuHostKeys(knownHosts) + session.hostKeyRepository = hostKeys + session.setConfig("StrictHostKeyChecking", "ask") session.setConfig("PreferredAuthentications", "none,keyboard-interactive,password") - session.userInfo = EmptyCredentials + session.userInfo = hostKeys.prompts session.setPassword("") try { - session.connect(timeoutMs) + try { + session.connect(timeoutMs) + } catch (e: JSchChangedHostKeyException) { + throw IOException( + "${provider.host} presented a different host key than on first use. Someone may be " + + "intercepting the connection. If the relay changed its key, use Forget relay keys." + ) + } // Keepalives notice a dead socket within about 45 seconds. session.setServerAliveInterval(KEEPALIVE_MS) session.setServerAliveCountMax(3) @@ -103,23 +112,6 @@ class SshTunnelConnector : TunnelConnector { } } - /** Answers every prompt with an empty string, as pressing Enter at the ssh prompt does. */ - private object EmptyCredentials : UserInfo, UIKeyboardInteractive { - override fun getPassphrase(): String? = null - override fun getPassword(): String = "" - override fun promptPassword(message: String?) = true - override fun promptPassphrase(message: String?) = false - override fun promptYesNo(message: String?) = true - override fun showMessage(message: String?) = Unit - override fun promptKeyboardInteractive( - destination: String?, - name: String?, - instruction: String?, - prompt: Array?, - echo: BooleanArray? - ): Array = Array(prompt?.size ?: 0) { "" } - } - private companion object { const val KEEPALIVE_MS = 15_000 const val MAX_BANNER = 64 * 1024 diff --git a/app/src/main/java/com/runcode/app/mcp/TofuHostKeys.kt b/app/src/main/java/com/runcode/app/mcp/TofuHostKeys.kt new file mode 100644 index 0000000..d1222be --- /dev/null +++ b/app/src/main/java/com/runcode/app/mcp/TofuHostKeys.kt @@ -0,0 +1,98 @@ +package com.runcode.app.mcp + +import com.jcraft.jsch.HostKey +import com.jcraft.jsch.HostKeyRepository +import com.jcraft.jsch.UIKeyboardInteractive +import com.jcraft.jsch.UserInfo +import java.io.File + +/** + * Trust on first use for relay host keys, keyed by "[host]:port" as JSch names them. + * + * The free relays publish no keys to pin, so the first key seen is saved and any later + * connection that presents a different key is refused. Without this, anyone on the network + * path could pose as the relay, hand out a URL of their own and collect the bearer token. + * + * One instance per connection: [lastCheck] tells [prompts] which question JSch is asking. + */ +class TofuHostKeys(private val file: File) : HostKeyRepository { + + @Volatile + private var lastCheck = HostKeyRepository.OK + + /** + * Answers JSch's prompts: accept an unknown host once, never a changed key, and give the + * free relays the empty password they expect, as pressing Enter at the ssh prompt does. + */ + val prompts: UserInfo = object : UserInfo, UIKeyboardInteractive { + override fun promptYesNo(message: String?) = lastCheck == HostKeyRepository.NOT_INCLUDED + override fun promptKeyboardInteractive( + destination: String?, + name: String?, + instruction: String?, + prompt: Array?, + echo: BooleanArray? + ): Array = Array(prompt?.size ?: 0) { "" } + override fun getPassphrase(): String? = null + override fun getPassword(): String = "" + override fun promptPassword(message: String?) = true + override fun promptPassphrase(message: String?) = false + override fun showMessage(message: String?) = Unit + } + + override fun check(host: String, key: ByteArray): Int { + val offered = HostKey(host, key).key + val known = read().filter { it.host == host } + lastCheck = when { + known.isEmpty() -> HostKeyRepository.NOT_INCLUDED + known.any { it.key == offered } -> HostKeyRepository.OK + else -> HostKeyRepository.CHANGED + } + return lastCheck + } + + override fun add(hostkey: HostKey, ui: UserInfo?) { + synchronized(LOCK) { + if (read().any { it.host == hostkey.host && it.key == hostkey.key }) return + file.parentFile?.mkdirs() + file.appendText("${hostkey.host} ${hostkey.type} ${hostkey.key}\n") + } + } + + override fun remove(host: String, type: String?) = remove(host, type, null) + + override fun remove(host: String, type: String?, key: ByteArray?) { + synchronized(LOCK) { + val keep = read().filterNot { it.host == host && (type == null || it.type == type) } + file.writeText(keep.joinToString("") { "${it.host} ${it.type} ${it.key}\n" }) + } + } + + override fun getKnownHostsRepositoryID(): String = file.path + + override fun getHostKey(): Array = getHostKey(null, null) + + override fun getHostKey(host: String?, type: String?): Array = + read().filter { (host == null || it.host == host) && (type == null || it.type == type) } + .mapNotNull { entry -> + runCatching { HostKey(entry.host, java.util.Base64.getDecoder().decode(entry.key)) }.getOrNull() + } + .toTypedArray() + + private data class Entry(val host: String, val type: String, val key: String) + + private fun read(): List = synchronized(LOCK) { + if (!file.isFile) return emptyList() + file.readLines().mapNotNull { line -> + val parts = line.trim().split(' ') + if (parts.size == 3) Entry(parts[0], parts[1], parts[2]) else null + } + } + + companion object { + private val LOCK = Any() + + /** Forgets every saved relay key, for when a relay really did change its key. */ + fun forgetAll(file: File) = synchronized(LOCK) { file.delete() } + } +} diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index 621c345..577a0cf 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -24,6 +24,7 @@ import com.runcode.app.git.GitIdentity import com.runcode.app.git.GitStatus import com.runcode.app.mcp.McpServerState import com.runcode.app.mcp.McpTunnelState +import com.runcode.app.mcp.TofuHostKeys import com.runcode.app.mcp.TunnelStatus import com.runcode.app.runtime.PythonEngine import com.runcode.app.settings.ProjectSettings @@ -239,6 +240,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { syncMcpTunnel() } + /** For a relay that really did change its host key: trust the next key it presents. */ + fun forgetRelayKeys() { + TofuHostKeys.forgetAll(app.tunnelHostKeys) + app.mcpTunnel.onNetworkChanged() + _userMessage.value = "Relay keys forgotten; the next ones will be trusted" + } + /** The tunnel forwards to the bridge, so it runs only while both are switched on. */ private fun syncMcpTunnel() { if (_mcpPublic.value && app.mcpServer.state.value.isRunning) { diff --git a/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt index dded96c..1137d5a 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt @@ -637,6 +637,11 @@ private fun McpPublicUrlSection(viewModel: MainViewModel) { } else if (tunnel.status != TunnelStatus.OFF) { tunnel.lastError?.let { Text(text = it, fontSize = 10.sp, color = AccentRed, lineHeight = 14.sp) + if (it.contains("host key")) { + TextButton(onClick = { viewModel.forgetRelayKeys() }) { + Text("Forget relay keys", fontSize = 11.sp, color = AccentCyan) + } + } } if (tunnel.failures >= 2) { Text( diff --git a/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt b/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt index 2ddc037..fe98cb3 100644 --- a/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt +++ b/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt @@ -102,6 +102,18 @@ class FolderBackupsTest { assertEquals(1, storage.getBackupsDir(p.id).listFiles()!!.size) } + @Test fun `automatic names use UTC so they sort by creation time`() { + val original = java.util.TimeZone.getDefault() + try { + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Asia/Tehran")) + val name = folder.fileName(project("Zone"), automatic = true) + // 1_760_000_000_000 ms is 2025-10-09 08:53:20 UTC. + assertTrue(name, name.endsWith("-20251009-085320.rcpkg")) + } finally { + java.util.TimeZone.setDefault(original) + } + } + @Test fun `pruning only applies to the same project`() = runBlocking { val a = project("A") val b = project("B") diff --git a/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt b/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt index 9ab8b5b..2323710 100644 --- a/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt @@ -16,6 +16,8 @@ import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test +import java.io.File +import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.net.ServerSocket @@ -32,6 +34,7 @@ class SshTunnelConnectorTest { private lateinit var relay: SshServer private lateinit var bridge: ServerSocket + private val knownHosts = File.createTempFile("known_hosts", "").apply { delete() } private val forwardedPort = AtomicInteger(-1) private val shellOpened = CountDownLatch(1) @@ -106,7 +109,7 @@ class SshTunnelConnectorTest { } @Test fun `port 0 forward reaches the local bridge and reports the relay URL`() { - val opened = SshTunnelConnector().open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + val opened = SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000) try { assertEquals("https://test-1-2-3-4.run.pinggy-free.link", opened.url) assertTrue(opened.connection.isAlive) @@ -119,7 +122,7 @@ class SshTunnelConnectorTest { @Test fun `fixed remote port works as localhost run expects`() { val fixed = ServerSocket(0).use { it.localPort } - val opened = SshTunnelConnector().open(relayProvider(remotePort = fixed), bridge.localPort, 10_000) + val opened = SshTunnelConnector(knownHosts).open(relayProvider(remotePort = fixed), bridge.localPort, 10_000) try { assertEquals(fixed, forwardedPort.get()) assertTrue(requestThroughRelay().endsWith("pong")) @@ -129,7 +132,7 @@ class SshTunnelConnectorTest { } @Test fun `relay stopping is seen as a dead connection`() { - val opened = SshTunnelConnector().open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + val opened = SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000) assertTrue(shellOpened.await(5, TimeUnit.SECONDS)) relay.stop(true) val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) @@ -141,8 +144,47 @@ class SshTunnelConnectorTest { @Test fun `unreachable relay fails fast with a reason`() { val closed = ServerSocket(0).use { it.localPort } val error = runCatching { - SshTunnelConnector().open(TunnelProvider.PINGGY.copy(host = "127.0.0.1", port = closed), bridge.localPort, 3_000) + SshTunnelConnector(knownHosts).open(TunnelProvider.PINGGY.copy(host = "127.0.0.1", port = closed), bridge.localPort, 3_000) }.exceptionOrNull() assertNotNull(error) } + + @Test fun `the relay's key is trusted on first use and checked afterwards`() { + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000).connection.close() + val saved = knownHosts.readText() + assertTrue(saved, saved.startsWith("[127.0.0.1]:${relay.port} ")) + + // Same server, same key: accepted, nothing new stored. + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000).connection.close() + assertEquals(saved, knownHosts.readText()) + } + + @Test fun `a different key for a known relay is refused`() { + // Learn some other server's key, then record it as if it belonged to this relay. + val impostor = SshServer.setUpDefaultServer().apply { + port = 0 + keyPairProvider = SimpleGeneratorHostKeyProvider() + passwordAuthenticator = PasswordAuthenticator { _, _, _ -> true } + forwardingFilter = AcceptAllForwardingFilter.INSTANCE + shellFactory = ShellFactory { BannerShell() } + } + impostor.start() + try { + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0).copy(port = impostor.port), bridge.localPort, 10_000) + .connection.close() + } finally { + impostor.stop(true) + } + knownHosts.writeText(knownHosts.readText().replace("[127.0.0.1]:${impostor.port}", "[127.0.0.1]:${relay.port}")) + + val error = runCatching { + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + }.exceptionOrNull() + assertTrue(error.toString(), error is IOException) + assertTrue(error!!.message!!.contains("different host key")) + assertEquals("no forward may be set up for an untrusted relay", -1, forwardedPort.get()) + + TofuHostKeys.forgetAll(knownHosts) + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000).connection.close() + } } From ee7b805a28f07b39020d0f1a7aa9a8af270d5377 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:44:14 +0000 Subject: [PATCH 09/15] Send the GitHub token only to GitHub over HTTPS Addresses review findings on the git commits: - The token went with every clone, push and pull, whatever the remote. A plain-HTTP or non-GitHub remote would have received it. It is now sent only to https://github.com URLs without user info; any other remote gets empty credentials. - Switching project while a git task ran dropped the new project's refresh, so the screen kept the previous repository. The screen now refreshes again once the task ends if the state belongs to another project. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- README.md | 3 +- .../java/com/runcode/app/git/GitManager.kt | 35 +++++++++--- .../com/runcode/app/ui/screens/GitScreen.kt | 5 ++ .../com/runcode/app/git/GitManagerTest.kt | 57 +++++++++++++++++++ 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a7eac8d..7597d4a 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ the app, because a native git binary cannot be shipped or executed on Android. - **Settings** holds the author name and email (GitHub matches commits to accounts by email) and a GitHub token. Use a fine-grained token with *Contents: read and write*. It is kept in the Keystore-backed vault, never shown again, scrubbed from errors and never written into the - repository's config. + repository's config. It is only sent to `https://github.com`; other remotes, and GitHub over + plain HTTP, get no credentials. Git operations that rewrite files refuse while the editor has unsaved changes. SSH remotes are not supported; use HTTPS URLs. diff --git a/app/src/main/java/com/runcode/app/git/GitManager.kt b/app/src/main/java/com/runcode/app/git/GitManager.kt index ec023e5..a5fa832 100644 --- a/app/src/main/java/com/runcode/app/git/GitManager.kt +++ b/app/src/main/java/com/runcode/app/git/GitManager.kt @@ -78,10 +78,16 @@ class GitManager( else if (!vault.setSecret(TOKEN_KEY, trimmed)) throw GitException("The token could not be stored in the vault.") } - private fun token(): String = vault.getSecret(TOKEN_KEY)?.also { SecretRedactor.register(it) } ?: "" - - /** GitHub accepts any user name with a token; this is the one its docs use. */ - private fun username(): String = if (hasToken) TOKEN_USER else "" + /** + * Username and token for [url], or empty ones. The token is a GitHub token, so it only + * goes to GitHub over HTTPS: a mistyped or hostile remote, or plain HTTP, never sees it. + */ + private fun credentialsFor(url: String?): Pair { + if (url == null || !sendsToken(url)) return "" to "" + val token = vault.getSecret(TOKEN_KEY)?.also { SecretRedactor.register(it) } ?: return "" to "" + // GitHub accepts any user name with a token; this is the one its docs use. + return TOKEN_USER to token + } // ---------------------------------------------------------------- local @@ -140,13 +146,15 @@ class GitManager( suspend fun push(project: Project) = run { val branch = currentBranch(project) - call("push", repoDir(project).path, branch, username(), token()) + val (user, token) = credentialsFor(remoteOf(project)) + call("push", repoDir(project).path, branch, user, token) } /** Fast-forward only. Returns the number of new commits. */ suspend fun pull(project: Project): Int = run { val branch = currentBranch(project) - call("pull", repoDir(project).path, branch, username(), token()).getInt("new_commits") + val (user, token) = credentialsFor(remoteOf(project)) + call("pull", repoDir(project).path, branch, user, token).getInt("new_commits") } /** @@ -161,7 +169,8 @@ class GitManager( try { // dulwich creates the folder itself and refuses an existing one. source.deleteRecursively() - call("clone", url.trim(), source.path, username(), token()) + val (user, token) = credentialsFor(url.trim()) + call("clone", url.trim(), source.path, user, token) archive.projectForSource(id, name, "Cloned from ${url.trim()}") } catch (e: Exception) { storage.deleteProject(id) @@ -171,6 +180,9 @@ class GitManager( // ---------------------------------------------------------------- plumbing + private fun remoteOf(project: Project): String? = + call("status", repoDir(project).path).optStringOrNull("remote") + private fun currentBranch(project: Project): String { val r = call("branches", repoDir(project).path) return r.optStringOrNull("current") ?: throw GitException("Check out a branch first.") @@ -194,5 +206,14 @@ class GitManager( private const val KEY_NAME = "author_name" private const val KEY_EMAIL = "author_email" private val BRANCH_NAME = Regex("[A-Za-z0-9._/-]+") + private val TOKEN_HOSTS = setOf("github.com") + + /** Whether [url] is an HTTPS URL on a host the GitHub token belongs to. */ + fun sendsToken(url: String): Boolean { + val uri = runCatching { java.net.URI(url.trim()) }.getOrNull() ?: return false + return uri.scheme.equals("https", ignoreCase = true) && + uri.userInfo == null && + uri.host?.lowercase() in TOKEN_HOSTS + } } } diff --git a/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt index 0e400c4..b0c1439 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt @@ -65,6 +65,11 @@ fun GitScreen(viewModel: MainViewModel) { var dialog by remember { mutableStateOf(null) } LaunchedEffect(project?.id) { viewModel.refreshGit() } + // A refresh asked for while another project's task runs is dropped, so try again once it + // ends; otherwise the screen would keep showing the previous project's repository. + LaunchedEffect(state.busy) { + if (state.busy == null && state.projectId != project?.id) viewModel.refreshGit() + } LazyColumn( modifier = Modifier.fillMaxSize(), diff --git a/app/src/test/java/com/runcode/app/git/GitManagerTest.kt b/app/src/test/java/com/runcode/app/git/GitManagerTest.kt index 99f1f88..24086c5 100644 --- a/app/src/test/java/com/runcode/app/git/GitManagerTest.kt +++ b/app/src/test/java/com/runcode/app/git/GitManagerTest.kt @@ -167,3 +167,60 @@ class GitManagerTest { assertTrue(runCatching { git.checkout(project, "bad name; rm -rf", true) }.exceptionOrNull() is IllegalArgumentException) } } + +/** Records what reaches the backend, to check which calls carry the token. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class GitCredentialsTest { + + private val calls = mutableListOf>>() + private var remote: String? = null + private lateinit var git: GitManager + private lateinit var storage: ProjectStorage + private val token = "ghp_onlyforgithub123456" + + @Before fun setup() { + val app = RuntimeEnvironment.getApplication() + storage = ProjectStorage(app) + val backend = GitBackend { function, args -> + calls += function to args.toList() + when (function) { + "status" -> JSONObject().put("ok", true).put("remote", remote ?: JSONObject.NULL).toString() + "branches" -> JSONObject().put("ok", true).put("current", "main").put("branches", JSONArray()).toString() + "pull" -> JSONObject().put("ok", true).put("new_commits", 0).toString() + else -> JSONObject().put("ok", true).toString() + } + } + git = GitManager(backend, storage, ProjectArchive(app, storage), MapVault(), + app.getSharedPreferences("git-cred-${System.nanoTime()}", Context.MODE_PRIVATE)) + git.setToken(token) + } + + private fun sentToken(function: String) = calls.last { it.first == function }.second.contains(token) + + @Test fun `token goes only to https github`() = runBlocking { + val project = storage.createProjectFromTemplate("Creds", ProjectProfile.PYTHON_SCRIPT) + for ((url, expected) in listOf( + "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/a/b.git" to true, + "https://GitHub.com/a/b" to true, + "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/a/b.git" to false, + "https://gitlab.com/a/b.git" to false, + "https://github.com.evil.example/a/b.git" to false, + "https://user@github.com/a/b.git" to false, + "/local/path/repo.git" to false + )) { + remote = url + git.push(project) + assertEquals("push to $url", expected, sentToken("push")) + git.pull(project) + assertEquals("pull from $url", expected, sentToken("pull")) + } + } + + @Test fun `clone sends the token only for github over https`() = runBlocking { + runCatching { git.cloneProject("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/a/b.git") } + assertTrue(sentToken("clone")) + runCatching { git.cloneProject("http://example.com/a/b.git") } + assertFalse(sentToken("clone")) + } +} From 28958a41eabe397cb8544fe10bf8210fe4e7cffd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:52:44 +0000 Subject: [PATCH 10/15] Add a Python debugger core on bdb runcode_debugger runs a project script through the normal runner, so environment, working directory, output routing and stop handling are the same as a plain run, with bdb's trace hook installed only for the debug run. - Breakpoints by absolute file and line, changeable while running. - Pauses publish file, line, call stack, locals and user globals to a listener and wait for continue, step (into), next (over), return (out) or stop from another thread. - Expressions are evaluated on the paused thread, in the real frame. - Only files under the project's source folder are stopped in or stepped into; library code runs untraced line by line. - A supervisor stop interrupts a paused script; a debugger stop is reported as stopped, not completed. run_script gains an optional executor so the debugger can wrap runpy without duplicating the runner's isolation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- app/src/main/python/runcode_debugger.py | 304 ++++++++++++++++++++++++ app/src/main/python/runcode_runner.py | 31 ++- tests/test_debugger.py | 244 +++++++++++++++++++ 3 files changed, 570 insertions(+), 9 deletions(-) create mode 100644 app/src/main/python/runcode_debugger.py create mode 100644 tests/test_debugger.py diff --git a/app/src/main/python/runcode_debugger.py b/app/src/main/python/runcode_debugger.py new file mode 100644 index 0000000..af94549 --- /dev/null +++ b/app/src/main/python/runcode_debugger.py @@ -0,0 +1,304 @@ +""" +Python debugger for runcode projects, built on bdb (the standard library base that pdb uses). + +The trace hook exists only while a script runs under debug_script(). Normal runs never pay +for it: a permanent sys.settrace made every script 25x slower (see runcode_runner). + +Flow: debug_script() runs the project through runcode_runner.run_script with an executor that +wraps runpy in Bdb.runcall. When execution reaches a breakpoint or finishes a step, the script +thread publishes its state to the Kotlin listener and waits for a command from command(), which +the app calls from another thread. Evaluation runs on the paused thread itself, so expressions +see the real frame. + +Only the script's main thread is traced, and only files under the project root are stepped +into or stopped in; library code runs at full speed. Like any trace hook it acts between Python +lines, so a script blocked inside a C call (time.sleep, a socket read) pauses on the next line. + +All public functions return JSON strings. +""" + +import bdb +import json +import os +import reprlib +import sys +import threading +import types + +import runcode_runner + +_sessions = {} +_sessions_lock = threading.Lock() + +_COMMANDS = ("continue", "step", "next", "return", "stop") +_NOWHERE = object() # a stop frame no real frame is +_MAX_VARIABLES = 60 +_EVAL_TIMEOUT_S = 10.0 + +_repr = reprlib.Repr() +_repr.maxstring = 160 +_repr.maxother = 160 +_repr.maxlist = _repr.maxtuple = _repr.maxset = _repr.maxdict = 12 +_repr.maxlevel = 3 + + +def _ok(**fields): + return json.dumps(dict(ok=True, **fields)) + + +def _fail(message): + return json.dumps({"ok": False, "error": message}) + + +def _show(value): + try: + return _repr.repr(value) + except Exception as error: # noqa: BLE001 - a broken __repr__ must not stop the debugger + return "" % error.__class__.__name__ + + +def _variables(namespace, globals_only=False): + shown = [] + for name, value in namespace.items(): + if name.startswith("__") and name.endswith("__"): + continue + if globals_only and isinstance(value, (types.ModuleType, types.FunctionType, type, + types.BuiltinFunctionType)): + continue + shown.append({"name": name, "type": type(value).__name__, "value": _show(value)}) + if len(shown) >= _MAX_VARIABLES: + break + return shown + + +class _Session(bdb.Bdb): + + def __init__(self, session_id, root, listener): + super().__init__() + self.session_id = session_id + self.root = os.path.abspath(root) + os.sep + self.listener = listener + self.cond = threading.Condition() + self.pending = None # (command, argument) waiting for the paused thread + self.eval_results = {} # request id -> JSON reply + self.paused = False + self.stopped_by_user = False + self.state = {"status": "starting"} + + # -- where to stop ------------------------------------------------------------------- + + def is_user_frame(self, frame): + return self.canonic(frame.f_code.co_filename).startswith(self.root) + + def stop_here(self, frame): + # Never stop inside the standard library or packages; stepping passes through them. + return self.is_user_frame(frame) and super().stop_here(frame) + + # -- bdb callbacks ------------------------------------------------------------------- + + def user_line(self, frame): + if not self.is_user_frame(frame): + return + filename = self.canonic(frame.f_code.co_filename) + reason = "breakpoint" if self.get_breaks(filename, frame.f_lineno) else "step" + self._pause(frame, reason) + + # Calls, returns and exceptions do not stop on their own: stepping lands on the next line. + def user_call(self, frame, argument_list): + pass + + def user_return(self, frame, return_value): + pass + + def user_exception(self, frame, exc_info): + pass + + # -- pausing ------------------------------------------------------------------------- + + def _pause(self, frame, reason): + self._publish(self._snapshot(frame, reason)) + command = None + with self.cond: + self.paused = True + try: + while True: + while self.pending is None: + # Short waits, so the async stop exception from the supervisor lands. + self.cond.wait(0.1) + command, argument = self.pending + self.pending = None + if command != "eval": + break + request_id, expression = argument + self.eval_results[request_id] = self._evaluate(frame, expression) + self.cond.notify_all() + finally: + self.paused = False + + if command == "continue": + self.set_continue() + elif command == "step": + self.set_step() + elif command == "next": + self.set_next(frame) + elif command == "return": + self.set_return(frame) + elif command == "stop": + self.stopped_by_user = True + self.set_quit() + self._publish({"status": "running"}) + + def _evaluate(self, frame, expression): + try: + code = compile(expression, "", "eval") + except SyntaxError as error: + return _fail("SyntaxError: %s" % error.msg) + try: + value = eval(code, frame.f_globals, frame.f_locals) # noqa: S307 - the user's own code + except Exception as error: # noqa: BLE001 - reported to the user + return _fail("%s: %s" % (error.__class__.__name__, error)) + return _ok(type=type(value).__name__, value=_show(value)) + + def _snapshot(self, frame, reason): + stack = [] + walk = frame + while walk is not None: + if self.is_user_frame(walk): + stack.append({ + "file": self._relative(walk.f_code.co_filename), + "line": walk.f_lineno, + "function": walk.f_code.co_name, + }) + walk = walk.f_back + return { + "status": "paused", + "reason": reason, + "file": self._relative(frame.f_code.co_filename), + "line": frame.f_lineno, + "function": frame.f_code.co_name, + "stack": stack, + "locals": _variables(frame.f_locals) if frame.f_locals is not frame.f_globals else [], + "globals": _variables(frame.f_globals, globals_only=True), + } + + def _relative(self, filename): + path = self.canonic(filename) + return path[len(self.root):] if path.startswith(self.root) else path + + def _publish(self, state): + self.state = state + if self.listener is not None: + try: + self.listener.onState(json.dumps(state)) + except Exception: # noqa: BLE001 - a UI problem must not kill the script + pass + + # -- commands from other threads ----------------------------------------------------- + + def send(self, command, argument=None): + with self.cond: + if not self.paused: + return _fail("The program is running, not paused.") + self.pending = (command, argument) + self.cond.notify_all() + return _ok() + + def evaluate(self, expression): + request_id = object() + with self.cond: + if not self.paused: + return _fail("Pause the program to evaluate expressions.") + self.pending = ("eval", (request_id, expression)) + self.cond.notify_all() + if not self.cond.wait_for(lambda: request_id in self.eval_results, _EVAL_TIMEOUT_S): + return _fail("The expression did not finish within %d seconds." % _EVAL_TIMEOUT_S) + return self.eval_results.pop(request_id) + + def run(self, script_path): + # Bdb.runcall starts in single-step mode and would stop on line 1. Start "continue" + # instead: a stop frame that never matches means only breakpoints stop the script. + self.reset() + self._set_stopinfo(_NOWHERE, None, -1) + sys.settrace(self.trace_dispatch) + try: + runcode_runner._run_path(script_path) + except bdb.BdbQuit: + pass + finally: + self.quitting = True + sys.settrace(None) + self.paused = False + if self.stopped_by_user: + # bdb swallows its own quit signal; report the run as stopped, not completed. + raise runcode_runner.ScriptInterrupt() + + +# ----------------------------------------------------------------------------- public API + +def debug_script(session_id, script_path, working_dir, env_pairs, sink, listener, breakpoints_json): + """ + Run a script under the debugger. Blocks until it ends and returns the same outcome + strings as runcode_runner.run_script. [breakpoints_json] maps absolute file paths to + line numbers. + """ + root = os.path.dirname(os.path.abspath(script_path)) + session = _Session(session_id, root, listener) + errors = _apply_breakpoints(session, json.loads(breakpoints_json or "{}")) + for message in errors: + sink.onOutput("stderr", "Breakpoint ignored: " + message) + with _sessions_lock: + _sessions[session_id] = session + try: + outcome = runcode_runner.run_script(session_id, script_path, working_dir, env_pairs, sink, + executor=session.run) + finally: + with _sessions_lock: + _sessions.pop(session_id, None) + session.clear_all_breaks() + session._publish({"status": "finished", "outcome": outcome}) + return outcome + + +def _apply_breakpoints(session, by_file): + errors = [] + for filename, lines in by_file.items(): + canonical = session.canonic(filename) + session.clear_all_file_breaks(canonical) + for line in sorted(set(int(n) for n in lines)): + error = session.set_break(canonical, line) + if error: + errors.append(error) + return errors + + +def set_breakpoints(session_id, filename, lines_json): + """Replaces one file's breakpoints in a running session.""" + session = _sessions.get(session_id) + if session is None: + return _fail("No debug session is running.") + errors = _apply_breakpoints(session, {filename: json.loads(lines_json)}) + return _ok(errors=errors) + + +def command(session_id, name): + """continue, step (into), next (over), return (out) or stop. Only while paused.""" + if name not in _COMMANDS: + return _fail("Unknown command %r" % name) + session = _sessions.get(session_id) + if session is None: + return _fail("No debug session is running.") + return session.send(name) + + +def evaluate(session_id, expression): + session = _sessions.get(session_id) + if session is None: + return _fail("No debug session is running.") + return session.evaluate(expression) + + +def state(session_id): + session = _sessions.get(session_id) + if session is None: + return _ok(status="idle") + return json.dumps(dict(ok=True, **session.state)) diff --git a/app/src/main/python/runcode_runner.py b/app/src/main/python/runcode_runner.py index bed1bd9..a1ac88c 100644 --- a/app/src/main/python/runcode_runner.py +++ b/app/src/main/python/runcode_runner.py @@ -156,18 +156,27 @@ def tracer(frame, event, arg): return tracer -def run_script(service_id, script_path, working_dir, env_pairs, sink): +def run_script(service_id, script_path, working_dir, env_pairs, sink, executor=None): + """ + Run a project script. [executor], when given, is called with the script path in place of + runpy.run_path; the debugger uses it to run the script under its trace hook while keeping + the environment, working directory, output routing and stop handling identical. + """ # CPython's cwd, environment and import state are shared by all JVM threads. A # second script must not receive the first project's secrets or relative writes. if not _execution_lock.acquire(blocking=False): return "fatal:Another Python task is active. Stop it before starting this project." try: - return _run_script_locked(service_id, script_path, working_dir, env_pairs, sink) + return _run_script_locked(service_id, script_path, working_dir, env_pairs, sink, executor) finally: _execution_lock.release() -def _run_script_locked(service_id, script_path, working_dir, env_pairs, sink): +def _run_path(script_path): + runpy.run_path(script_path, run_name="__main__") + + +def _run_script_locked(service_id, script_path, working_dir, env_pairs, sink, executor=None): """ Execute `script_path` as __main__. @@ -210,13 +219,17 @@ def _run_script_locked(service_id, script_path, working_dir, env_pairs, sink): sys.argv = [script_path] - if using_fallback: - sys.settrace(_fallback_tracer(service_id)) - try: - runpy.run_path(script_path, run_name="__main__") - finally: + if executor is not None: + # The debugger owns the trace hook for the whole run. + executor(script_path) + else: if using_fallback: - sys.settrace(None) + sys.settrace(_fallback_tracer(service_id)) + try: + _run_path(script_path) + finally: + if using_fallback: + sys.settrace(None) return "completed" diff --git a/tests/test_debugger.py b/tests/test_debugger.py new file mode 100644 index 0000000..dbdf147 --- /dev/null +++ b/tests/test_debugger.py @@ -0,0 +1,244 @@ +"""Host-side checks for runcode_debugger: real scripts, real trace hook, commands from another thread.""" +import json +import os +from pathlib import Path +import sys +import tempfile +import threading +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "app/src/main/python")) +import runcode_debugger as debugger # noqa: E402 +import runcode_runner as runner # noqa: E402 + +SCRIPT = """\ +import helper + +total = 0 +for n in range(3): + total = helper.double(n) + total +message = "done %d" % total +print(message) +""" + +HELPER = """\ +def double(x): + y = x * 2 + return y +""" + + +class Sink: + def __init__(self): + self.lines = [] + + def onOutput(self, level, line): + self.lines.append((level, line)) + + +class Listener: + def __init__(self): + self.states = [] + self.cond = threading.Condition() + + def onState(self, raw): + with self.cond: + self.states.append(json.loads(raw)) + self.cond.notify_all() + + def mark(self): + with self.cond: + return len(self.states) + + def wait_for(self, predicate, timeout=10, after=0): + """First state published at index >= after that matches.""" + found = [] + + def match(): + for state in self.states[after:]: + if predicate(state): + found.append(state) + return True + return False + + with self.cond: + ok = self.cond.wait_for(match, timeout) + assert ok, "timed out; states %r" % (self.states[after:] or None) + return found[0] + + def paused(self, after=0): + return self.wait_for(lambda s: s["status"] == "paused", after=after) + + +class DebuggerTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.source = os.path.join(self.tmp.name, "source") + os.makedirs(self.source) + Path(self.source, "main.py").write_text(SCRIPT) + Path(self.source, "helper.py").write_text(HELPER) + self.script = os.path.join(self.source, "main.py") + self.helper = os.path.join(self.source, "helper.py") + self.sink = Sink() + self.listener = Listener() + self.outcome = [] + self.session = "svc_py_test_%d" % time.monotonic_ns() + + def tearDown(self): + # A failed test must not leave a paused script holding the interpreter. + if json.loads(debugger.state(self.session))["status"] != "idle": + runner.request_stop(self.session) + deadline = time.monotonic() + 5 + while json.loads(debugger.state(self.session))["status"] != "idle" and time.monotonic() < deadline: + time.sleep(0.05) + self.tmp.cleanup() + sys.modules.pop("helper", None) + + def start(self, breakpoints): + thread = threading.Thread(target=lambda: self.outcome.append(debugger.debug_script( + self.session, self.script, self.tmp.name, [], self.sink, self.listener, json.dumps(breakpoints))), + daemon=True) + thread.start() + return thread + + def send(self, name): + """Sends a command; returns the index after which its effects are published.""" + mark = self.listener.mark() + reply = json.loads(debugger.command(self.session, name)) + self.assertTrue(reply["ok"], reply) + return mark + + def finish(self, thread): + thread.join(10) + self.assertFalse(thread.is_alive()) + return self.listener.wait_for(lambda s: s["status"] == "finished") + + def test_breakpoint_variables_and_continue(self): + thread = self.start({self.script: [6]}) + state = self.listener.paused() + self.assertEqual(("main.py", 6, "breakpoint"), (state["file"], state["line"], state["reason"])) + names = {v["name"]: v["value"] for v in state["globals"]} + self.assertEqual("6", names["total"]) + self.assertNotIn("helper", names, "modules are not shown as variables") + self.send("continue") + self.assertEqual("completed", self.finish(thread)["outcome"]) + self.assertIn(("stdout", "done 6"), self.sink.lines) + + def test_breakpoint_in_another_file_shows_the_call_stack(self): + thread = self.start({self.helper: [3]}) + state = self.listener.paused() + self.assertEqual(("helper.py", 3, "double"), (state["file"], state["line"], state["function"])) + self.assertEqual(["double", ""], [f["function"] for f in state["stack"]]) + self.assertEqual({"x": "0", "y": "0"}, {v["name"]: v["value"] for v in state["locals"]}) + # Hit again on the next loop iteration. + mark = self.send("continue") + state = self.listener.paused(after=mark) + self.assertEqual("1", {v["name"]: v["value"] for v in state["locals"]}["x"]) + mark = self.send("continue") + self.listener.paused(after=mark) + self.send("continue") + self.assertEqual("completed", self.finish(thread)["outcome"]) + + def test_step_into_over_and_out(self): + thread = self.start({self.script: [5]}) + self.assertEqual(5, self.listener.paused()["line"]) + + state = self.listener.paused(after=self.send("step")) # into helper.double + self.assertEqual(("helper.py", 2), (state["file"], state["line"])) + + state = self.listener.paused(after=self.send("next")) + self.assertEqual(("helper.py", 3), (state["file"], state["line"])) + + state = self.listener.paused(after=self.send("return")) # back in the caller + self.assertEqual("main.py", state["file"]) + self.assertIn(state["line"], (4, 5)) + + state = self.listener.paused(after=self.send("next")) + self.assertEqual("main.py", state["file"], "next must not descend into helper.double") + # The breakpoint sits in a loop: every remaining stop is on it, until the end. + while True: + state = self.listener.wait_for(lambda s: s["status"] in ("paused", "finished"), + after=self.send("continue")) + if state["status"] == "finished": + break + self.assertEqual((5, "breakpoint"), (state["line"], state["reason"])) + self.assertEqual("completed", state["outcome"]) + + def test_stepping_never_enters_library_code(self): + Path(self.script).write_text("import json\ndata = json.dumps({'a': 1})\nprint(data)\n") + thread = self.start({self.script: [2]}) + self.listener.paused() + state = self.listener.paused(after=self.send("step")) + self.assertEqual(("main.py", 3), (state["file"], state["line"])) + self.send("continue") + self.finish(thread) + + def test_evaluate_in_the_paused_frame(self): + thread = self.start({self.helper: [3]}) + self.listener.paused() + self.assertEqual({"ok": True, "type": "int", "value": "10"}, + json.loads(debugger.evaluate(self.session, "y + 10"))) + self.assertIn("NameError", json.loads(debugger.evaluate(self.session, "nope"))["error"]) + self.assertIn("SyntaxError", json.loads(debugger.evaluate(self.session, "1 +"))["error"]) + for _ in range(2): + self.listener.paused(after=self.send("continue")) + self.send("continue") + self.finish(thread) + + def test_commands_are_refused_while_running(self): + self.assertFalse(json.loads(debugger.command("nobody", "continue"))["ok"]) + self.assertFalse(json.loads(debugger.command(self.session, "jump"))["ok"]) + + def test_stop_command_reports_stopped(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + self.send("stop") + self.assertEqual("stopped", self.finish(thread)["outcome"]) + self.assertNotIn(("stdout", "done 6"), self.sink.lines) + + def test_supervisor_stop_interrupts_a_paused_script(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + runner.request_stop(self.session) + self.assertEqual("stopped", self.finish(thread)["outcome"]) + + def test_breakpoints_can_change_while_running(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + reply = json.loads(debugger.set_breakpoints(self.session, self.script, json.dumps([7]))) + self.assertTrue(reply["ok"], reply) + self.assertEqual(7, self.listener.paused(after=self.send("continue"))["line"]) + self.send("continue") + self.finish(thread) + + def test_invalid_breakpoint_is_reported_not_fatal(self): + thread = self.start({self.script: [999]}) + self.assertEqual("completed", self.finish(thread)["outcome"]) + self.assertTrue(any("Breakpoint ignored" in line for _, line in self.sink.lines)) + + def test_without_breakpoints_nothing_pauses(self): + thread = self.start({}) + self.assertEqual("completed", self.finish(thread)["outcome"]) + self.assertFalse(any(s["status"] == "paused" for s in self.listener.states)) + + def test_lines_are_not_traced_in_files_without_breakpoints(self): + # bdb only installs a per-line hook in frames whose file has a breakpoint, so a loop + # in other code runs without per-line callbacks. + Path(self.helper).write_text("def double(x):\n import sys\n return 0 if sys._getframe().f_trace is None else 1\n") + Path(self.script).write_text("import helper\nprint('lines traced: %d' % helper.double(1))\nx = 1\n") + thread = self.start({self.script: [3]}) + self.listener.paused() + self.send("continue") + self.finish(thread) + self.assertIn(("stdout", "lines traced: 0"), self.sink.lines) + + def test_normal_runs_are_unaffected(self): + outcome = runner.run_script("plain_%d" % time.monotonic_ns(), self.script, self.tmp.name, [], self.sink) + self.assertEqual("completed", outcome) + self.assertEqual(json.loads(debugger.state("plain"))["status"], "idle") + + +if __name__ == "__main__": + unittest.main() From 207846262d644436e9c2b51c29713e2d6f55cd1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:10:32 +0000 Subject: [PATCH 11/15] Add the Python debugger to the editor and the bridge Roadmap phase 6. The editor gets a Debug button that runs the project under runcode_debugger through the normal supervisor path: - Tap a line number to set or clear a breakpoint; changes reach a running session at once. - The paused line is highlighted and its file opened; a debug panel shows locals, user globals and the call stack, with Continue, Over, Into, Out, Stop and expression evaluation. - A debug run is never restarted automatically: RuntimeHandle gains restartable, false for debug runs. - MCP tools debug_start, debug_control, debug_status and debug_eval; start and control wait for the next pause or the end. Also fixes a race in the debugger: a pause was announced before commands were accepted, so a client reacting at once was told the program was still running and the script waited forever. Tests: Python tests drive real scripts, including a client that answers the moment a pause is announced; Kotlin tests cover the debugger state with a fake bridge, a contract test parses the real Python debugger's output, and supervisor tests confirm debug runs are not restarted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- README.md | 27 ++- .../main/java/com/runcode/app/RuncodeApp.kt | 10 +- .../java/com/runcode/app/mcp/McpToolHost.kt | 4 +- .../main/java/com/runcode/app/mcp/McpTools.kt | 79 +++++++ .../app/runtime/ChaquopyDebugBridge.kt | 17 ++ .../com/runcode/app/runtime/PythonDebugger.kt | 211 ++++++++++++++++++ .../com/runcode/app/runtime/PythonEngine.kt | 43 +++- .../com/runcode/app/runtime/RuntimeEngine.kt | 7 + .../app/supervisor/ServiceSupervisor.kt | 14 ++ .../java/com/runcode/app/ui/MainViewModel.kt | 69 ++++++ .../com/runcode/app/ui/screens/CodeEditor.kt | 62 ++++- .../com/runcode/app/ui/screens/DebugPanel.kt | 154 +++++++++++++ .../runcode/app/ui/screens/EditorScreen.kt | 26 ++- app/src/main/python/runcode_debugger.py | 6 +- .../com/runcode/app/mcp/McpGitToolsTest.kt | 2 +- .../com/runcode/app/mcp/McpSettingsTest.kt | 2 +- .../runcode/app/runtime/PythonDebuggerTest.kt | 181 +++++++++++++++ .../app/supervisor/ServiceSupervisorTest.kt | 26 ++- review-notes.md | 6 +- tests/test_debugger.py | 16 ++ 20 files changed, 939 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/runcode/app/runtime/ChaquopyDebugBridge.kt create mode 100644 app/src/main/java/com/runcode/app/runtime/PythonDebugger.kt create mode 100644 app/src/main/java/com/runcode/app/ui/screens/DebugPanel.kt create mode 100644 app/src/test/java/com/runcode/app/runtime/PythonDebuggerTest.kt diff --git a/README.md b/README.md index 7597d4a..c7a4cf1 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,24 @@ just `source/`, so whatever a script writes into `data/` is visible too. Binary files and files over 512 KB do not open in the editor; they can still be saved out. +## Debugger + +**Editor → bug icon** runs the project's entry point under a Python debugger. + +- Tap a line number in a `.py` file under `source/` to set or clear a breakpoint (a red dot). + Breakpoints can be changed while the program runs. +- When execution reaches one, the line is highlighted, the file opens if needed, and the + debug panel shows locals, user globals and the call stack. +- **Continue**, **Over** (next line), **Into** (into a call), **Out** (finish the function) and + **Stop**. **Eval** evaluates an expression in the paused frame. +- Stepping stays in the project's own files; library code runs without stopping. + +It is built on `bdb`, the base of `pdb`, and its trace hook exists only during a debug run, so +normal runs keep their full speed. Only the script's main thread is debugged. Like any Python +trace hook it acts between lines: a script blocked inside a C call, such as `time.sleep` or a +socket read, pauses on its next line. A debug run is never restarted automatically, whatever +the project's restart policy. + ## Git **Git** works on the selected project's `source/` folder, so `data/`, logs and secrets never @@ -164,10 +182,14 @@ Authorization: Bearer Content-Type: application/json ``` -Twenty-three tools: `list_projects`, `get_project`, `update_project_settings`, `list_files`, `read_file`, `write_file`, +Twenty-seven tools: `list_projects`, `get_project`, `update_project_settings`, `list_files`, `read_file`, `write_file`, `create_directory`, `rename_path`, `delete_path`, `set_entry_point`, `start_service`, `stop_service`, `service_status`, `get_logs`, `run_command`, `run_python`, `sql_query`, -`run_diagnostics`, `backup_project`, `git_status`, `git_commit`, `git_push`, `git_pull`. +`run_diagnostics`, `backup_project`, `git_status`, `git_commit`, `git_push`, `git_pull`, +`debug_start`, `debug_control`, `debug_status`, `debug_eval`. + +`debug_start` and `debug_control` wait (up to `wait_ms`) for the program to pause or end, and +return the state: file, line, call stack and variables. File changes made over the bridge show up in the app straight away: the file tree refreshes, and a file open in the editor reloads unless it has unsaved edits. @@ -251,6 +273,7 @@ app/src/main/java/com/runcode/app/ app/src/main/python/ runcode_runner.py stdout/stderr bridge, cooperative stop, snippet runner runcode_git.py git on dulwich, JSON in and out, credentials scrubbed + runcode_debugger.py debugger on bdb: breakpoints, stepping, eval in the paused frame ``` ## Tests diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index 54afbbe..0e7cfce 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -23,6 +23,8 @@ import com.runcode.app.mcp.McpToolHost import com.runcode.app.mcp.McpTunnel import com.runcode.app.mcp.SshTunnelConnector import com.runcode.app.network.PortManager +import com.runcode.app.runtime.ChaquopyDebugBridge +import com.runcode.app.runtime.PythonDebugger import com.runcode.app.runtime.PythonEngine import com.runcode.app.runtime.RuntimeRegistry import com.runcode.app.runtime.StaticWebEngine @@ -77,6 +79,8 @@ class RuncodeApp : Application() { private set lateinit var mcpTunnel: McpTunnel private set + lateinit var debugger: PythonDebugger + private set /** Host keys of the tunnel relays, trusted on first use. */ val tunnelHostKeys: File get() = File(filesDir, "tunnel_known_hosts") @@ -137,7 +141,8 @@ class RuncodeApp : Application() { // The embedded CPython has to be started once per process, before any engine uses it. isPythonAvailable = PythonEngine.ensureStarted(this) - val pythonEngine = PythonEngine(this, secretStore, processMonitor) + debugger = PythonDebugger(ChaquopyDebugBridge()) + val pythonEngine = PythonEngine(this, secretStore, processMonitor, debugger) val staticWebEngine = StaticWebEngine(portManager, processMonitor) runtimeRegistry = RuntimeRegistry(pythonEngine, staticWebEngine) @@ -168,7 +173,8 @@ class RuncodeApp : Application() { onProjectChanged = { projectId -> _projectChanges.tryEmit(projectId) }, runDiagnostics = { diagnostics.run().toText() }, backupProject = ::backupProjectForMcp, - git = { git } + git = { git }, + debugger = debugger ), onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } ) diff --git a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt index 1a1c14a..eb01083 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt @@ -5,6 +5,7 @@ import com.runcode.app.database.ProjectDatabaseManager import com.runcode.app.domain.models.Project import com.runcode.app.git.GitManager import com.runcode.app.logging.LogManager +import com.runcode.app.runtime.PythonDebugger import com.runcode.app.storage.ProjectStorage import com.runcode.app.settings.ProjectSettingsManager import com.runcode.app.supervisor.ServiceSupervisor @@ -31,7 +32,8 @@ class McpToolHost( /** Backs a project up locally, and to the chosen folder when asked. Returns a summary. */ val backupProject: suspend (project: Project, toFolder: Boolean) -> String, /** Lazy, because building it touches the vault and preferences. */ - val git: () -> GitManager + val git: () -> GitManager, + val debugger: PythonDebugger ) { suspend fun projectOrNull(projectId: String): Project? = appMetaDatabase.getAllProjects().find { it.id == projectId } diff --git a/app/src/main/java/com/runcode/app/mcp/McpTools.kt b/app/src/main/java/com/runcode/app/mcp/McpTools.kt index 6f49561..f88e3e6 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpTools.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpTools.kt @@ -238,6 +238,47 @@ object McpTools { ) ) + tools.put( + tool( + "debug_start", + "Run a Python project under the debugger. breakpoints is a list of {path, line} with paths " + + "relative to the project root (source/main.py) and 1-based lines; each listed file's breakpoints " + + "replace its previous ones. Waits up to wait_ms (default 10000) for the first pause or the end " + + "and returns the debugger state: file, line, stack, locals, globals.", + properties( + "project_id" to stringProp("Project id"), + "breakpoints" to JSONObject().put("type", "array").put("items", JSONObject().put("type", "object") + .put("properties", JSONObject().put("path", stringProp("source/…")).put("line", intProp("1-based line")))), + "wait_ms" to intProp("How long to wait for a pause, up to 60000") + ), + required = listOf("project_id") + ) + ) + + tools.put( + tool( + "debug_control", + "Drive a paused debug run: continue, step (into), next (over), return (out) or stop. Waits up to " + + "wait_ms (default 10000) for the next pause or the end and returns the debugger state.", + properties( + "command" to stringProp("continue, step, next, return or stop"), + "wait_ms" to intProp("How long to wait, up to 60000") + ), + required = listOf("command") + ) + ) + + tools.put(tool("debug_status", "The current debugger state: idle, starting, running, paused (with file, line, stack and variables) or finished (with the outcome).", JSONObject())) + + tools.put( + tool( + "debug_eval", + "Evaluate a Python expression in the paused frame and return its type and value.", + properties("expression" to stringProp("A Python expression")), + required = listOf("expression") + ) + ) + tools.put( tool( "git_status", @@ -329,6 +370,11 @@ object McpTools { args.optString("database").ifBlank { null } ) "run_diagnostics" -> textResult(host.runDiagnostics()) + "debug_start" -> debugStart(host, args) + "debug_control" -> debugControl(host, args.getString("command"), waitMs(args)) + "debug_status" -> textResult(host.debugger.toJson().toString(2)) + "debug_eval" -> host.debugger.evaluate(args.getString("expression")).fold( + onSuccess = { textResult(it) }, onFailure = { errorResult(it.message ?: "evaluation failed") }) "git_status" -> gitStatus(host, args.getString("project_id")) "git_commit" -> gitCommit(host, args.getString("project_id"), args.getString("message"), args.optBoolean("stage_all", true)) "git_push" -> gitPush(host, args.getString("project_id")) @@ -562,6 +608,39 @@ object McpTools { return textResult(array.toString(2)) } + private fun waitMs(args: JSONObject) = args.optLong("wait_ms", 10_000L).coerceIn(0L, 60_000L) + + private fun debugStart(host: McpToolHost, args: JSONObject): JSONObject = runBlocking { + val project = host.projectOrNull(args.getString("project_id")) ?: return@runBlocking errorResult("Project not found") + val debugger = host.debugger + args.optJSONArray("breakpoints")?.let { list -> + val byFile = mutableMapOf>() + for (i in 0 until list.length()) { + val entry = list.getJSONObject(i) + val path = entry.getString("path").removePrefix("./") + require(path.startsWith("source/") && path.endsWith(".py")) { "Breakpoints go on .py files under source/: $path" } + byFile.getOrPut(path.removePrefix("source/")) { mutableSetOf() } += entry.getInt("line") + } + byFile.forEach { (file, lines) -> debugger.setBreakpoints(project.id, file, lines) } + } + val before = debugger.state.value + debugger.arm(project.id) + val started = try { + host.serviceSupervisor.startProject(project) + } finally { + debugger.disarm(project.id) + } + if (!started) return@runBlocking errorResult("Could not start the project. Stop it first if it is running.") + textResult(debugger.toJson(debugger.awaitSettled(before, waitMs(args))).toString(2)) + } + + private fun debugControl(host: McpToolHost, command: String, waitMs: Long): JSONObject = runBlocking { + val debugger = host.debugger + val before = debugger.state.value + debugger.command(command)?.let { return@runBlocking errorResult(it) } + textResult(debugger.toJson(debugger.awaitSettled(before, waitMs)).toString(2)) + } + private fun withRepo(host: McpToolHost, projectId: String, block: suspend (Project) -> T): JSONObject = runBlocking { val project = host.projectOrNull(projectId) ?: return@runBlocking errorResult("Project not found") try { diff --git a/app/src/main/java/com/runcode/app/runtime/ChaquopyDebugBridge.kt b/app/src/main/java/com/runcode/app/runtime/ChaquopyDebugBridge.kt new file mode 100644 index 0000000..1f997d6 --- /dev/null +++ b/app/src/main/java/com/runcode/app/runtime/ChaquopyDebugBridge.kt @@ -0,0 +1,17 @@ +package com.runcode.app.runtime + +import com.chaquo.python.Python + +/** [DebugBridge] over runcode_debugger.py in the embedded interpreter. */ +class ChaquopyDebugBridge : DebugBridge { + private val module get() = Python.getInstance().getModule("runcode_debugger") + + override fun command(sessionId: String, name: String): String = + module.callAttr("command", sessionId, name).toString() + + override fun evaluate(sessionId: String, expression: String): String = + module.callAttr("evaluate", sessionId, expression).toString() + + override fun setBreakpoints(sessionId: String, file: String, linesJson: String): String = + module.callAttr("set_breakpoints", sessionId, file, linesJson).toString() +} diff --git a/app/src/main/java/com/runcode/app/runtime/PythonDebugger.kt b/app/src/main/java/com/runcode/app/runtime/PythonDebugger.kt new file mode 100644 index 0000000..db9d21b --- /dev/null +++ b/app/src/main/java/com/runcode/app/runtime/PythonDebugger.kt @@ -0,0 +1,211 @@ +package com.runcode.app.runtime + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** Calls into runcode_debugger.py. Swapped for a fake in tests. */ +interface DebugBridge { + fun command(sessionId: String, name: String): String + fun evaluate(sessionId: String, expression: String): String + fun setBreakpoints(sessionId: String, file: String, linesJson: String): String +} + +enum class DebugStatus { IDLE, STARTING, RUNNING, PAUSED, FINISHED } + +data class DebugFrame(val file: String, val line: Int, val function: String) + +data class DebugVariable(val name: String, val type: String, val value: String) + +data class DebugState( + val status: DebugStatus = DebugStatus.IDLE, + val projectId: String? = null, + /** Path relative to source/, e.g. "main.py". */ + val file: String? = null, + val line: Int = 0, + val function: String? = null, + /** "breakpoint" or "step". */ + val reason: String? = null, + val stack: List = emptyList(), + val locals: List = emptyList(), + val globals: List = emptyList(), + /** For FINISHED: completed, stopped, failed:… or fatal:… as the runner reports it. */ + val outcome: String? = null +) { + companion object { + fun parse(projectId: String, json: String): DebugState { + val o = JSONObject(json) + val status = when (o.optString("status")) { + "starting" -> DebugStatus.STARTING + "running" -> DebugStatus.RUNNING + "paused" -> DebugStatus.PAUSED + "finished" -> DebugStatus.FINISHED + else -> DebugStatus.IDLE + } + fun variables(key: String) = o.optJSONArray(key).objects().map { + DebugVariable(it.optString("name"), it.optString("type"), it.optString("value")) + } + return DebugState( + status = status, + projectId = projectId, + file = o.optString("file").ifEmpty { null }, + line = o.optInt("line", 0), + function = o.optString("function").ifEmpty { null }, + reason = o.optString("reason").ifEmpty { null }, + stack = o.optJSONArray("stack").objects().map { + DebugFrame(it.optString("file"), it.optInt("line"), it.optString("function")) + }, + locals = variables("locals"), + globals = variables("globals"), + outcome = o.optString("outcome").ifEmpty { null } + ) + } + + private fun JSONArray?.objects(): List = + if (this == null) emptyList() else (0 until length()).map { getJSONObject(it) } + } +} + +/** + * The debugger as the app sees it: breakpoints per project file, the state of the one debug + * run that can exist at a time, and the commands that drive it. + * + * A debug run is an ordinary run of the project through the supervisor; [arm] marks the next + * start of a project as a debug run, and the Python engine [takeLaunch]es it. + */ +class PythonDebugger(private val bridge: DebugBridge) { + + private val _state = MutableStateFlow(DebugState()) + val state: StateFlow = _state.asStateFlow() + + /** projectId -> path under source/ -> 1-based lines. */ + private val _breakpoints = MutableStateFlow>>>(emptyMap()) + val breakpoints: StateFlow>>> = _breakpoints.asStateFlow() + + private val armed = ConcurrentHashMap.newKeySet() + + @Volatile + private var session: Session? = null + + private class Session(val projectId: String, val sessionId: String, val sourceDir: File) + + fun breakpointsFor(projectId: String, file: String): Set = + _breakpoints.value[projectId]?.get(file).orEmpty() + + /** Adds or removes a breakpoint; a running debug session picks the change up at once. */ + fun toggleBreakpoint(projectId: String, file: String, line: Int) { + val forProject = _breakpoints.value[projectId].orEmpty() + val lines = forProject[file].orEmpty().let { if (line in it) it - line else it + line } + val updated = if (lines.isEmpty()) forProject - file else forProject + (file to lines) + _breakpoints.value = if (updated.isEmpty()) _breakpoints.value - projectId else _breakpoints.value + (projectId to updated) + session?.takeIf { it.projectId == projectId }?.let { s -> + runCatching { bridge.setBreakpoints(s.sessionId, File(s.sourceDir, file).absolutePath, JSONArray(lines.sorted()).toString()) } + } + } + + fun setBreakpoints(projectId: String, file: String, lines: Set) { + val forProject = _breakpoints.value[projectId].orEmpty() + val updated = if (lines.isEmpty()) forProject - file else forProject + (file to lines) + _breakpoints.value = _breakpoints.value + (projectId to updated) + } + + // ---------------------------------------------------------------- launching + + /** The next start of [projectId] is a debug run. Call [disarm] after the start attempt. */ + fun arm(projectId: String) { + armed += projectId + _state.value = DebugState(DebugStatus.STARTING, projectId) + } + + fun disarm(projectId: String) { + if (armed.remove(projectId) && _state.value.projectId == projectId && _state.value.status == DebugStatus.STARTING) { + _state.value = DebugState() + } + } + + /** Called by the engine when it starts [projectId]: breakpoints as JSON, or null for a normal run. */ + fun takeLaunch(projectId: String, sessionId: String, sourceDir: File): String? { + if (!armed.remove(projectId)) return null + session = Session(projectId, sessionId, sourceDir) + val byFile = JSONObject() + _breakpoints.value[projectId].orEmpty().forEach { (file, lines) -> + byFile.put(File(sourceDir, file).absolutePath, JSONArray(lines.sorted())) + } + return byFile.toString() + } + + /** Receives state from runcode_debugger.py. */ + inner class Listener(private val projectId: String) { + @Suppress("unused") // invoked from Python + fun onState(json: String) { + val parsed = DebugState.parse(projectId, json) + _state.value = parsed + if (parsed.status == DebugStatus.FINISHED) session = null + } + } + + fun listener(projectId: String) = Listener(projectId) + + // ---------------------------------------------------------------- driving + + /** continue, step, next, return or stop. Returns an error message, or null. */ + fun command(name: String): String? { + val s = session ?: return "No debug session is running." + val reply = JSONObject(bridge.command(s.sessionId, name)) + return if (reply.optBoolean("ok")) null else reply.optString("error") + } + + /** "type: value" on success, or the error text. */ + fun evaluate(expression: String): Result { + val s = session ?: return Result.failure(IllegalStateException("No debug session is running.")) + val reply = JSONObject(bridge.evaluate(s.sessionId, expression)) + return if (reply.optBoolean("ok")) Result.success("${reply.optString("type")}: ${reply.optString("value")}") + else Result.failure(IllegalStateException(reply.optString("error"))) + } + + val isActive: Boolean get() = session != null + + /** Clears a finished run from the screen. */ + fun dismiss() { + if (_state.value.status == DebugStatus.FINISHED) _state.value = DebugState() + } + + /** + * Waits until the program pauses or ends after [since] was the current state, so a caller + * that just sent a command sees its result. Returns the state at that point, or the latest + * one after [timeoutMs]. + */ + suspend fun awaitSettled(since: DebugState, timeoutMs: Long): DebugState = + withTimeoutOrNull(timeoutMs) { + state.first { it !== since && it.status in SETTLED } + } ?: state.value + + fun toJson(s: DebugState = state.value): JSONObject { + fun vars(list: List) = JSONArray(list.map { + JSONObject().put("name", it.name).put("type", it.type).put("value", it.value) + }) + return JSONObject() + .put("status", s.status.name.lowercase()) + .put("project_id", s.projectId ?: JSONObject.NULL) + .apply { + if (s.status == DebugStatus.PAUSED) { + put("file", s.file).put("line", s.line).put("function", s.function).put("reason", s.reason) + put("stack", JSONArray(s.stack.map { + JSONObject().put("file", it.file).put("line", it.line).put("function", it.function) + })) + put("locals", vars(s.locals)).put("globals", vars(s.globals)) + } + s.outcome?.let { put("outcome", it) } + } + } + + private companion object { + val SETTLED = setOf(DebugStatus.PAUSED, DebugStatus.FINISHED, DebugStatus.IDLE) + } +} diff --git a/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt b/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt index 70d4621..6cd5e43 100644 --- a/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt +++ b/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt @@ -11,6 +11,7 @@ import com.runcode.app.security.SecretStore import com.runcode.app.system.ProcessMonitor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONObject import java.io.File import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -27,7 +28,8 @@ import java.util.concurrent.atomic.AtomicReference class PythonEngine( private val context: Context, private val secretStore: SecretStore, - private val monitor: ProcessMonitor + private val monitor: ProcessMonitor, + private val debugger: PythonDebugger? = null ) : RuntimeEngine { override val descriptor = RuntimeDescriptor( @@ -102,6 +104,10 @@ class PythonEngine( } sink.onEvent(LogLevel.SYSTEM, "Entrypoint: ${project.entryPoint} (${scriptFile.length()} bytes)") + // Non-null when this start was armed as a debug run: the breakpoints to begin with. + val debugBreakpoints = debugger?.takeLaunch(project.id, handleServiceId, File(project.projectRoot, "source")) + if (debugBreakpoints != null) sink.onEvent(LogLevel.SYSTEM, "Debugging: the script stops at breakpoints.") + val alive = AtomicBoolean(true) val reason = AtomicReference(ExitReason.RUNNING) // The Linux tid is only knowable from inside the thread itself. @@ -125,19 +131,36 @@ class PythonEngine( "fatal:the embedded Python bridge could not be loaded" } else { try { - runner.callAttr( - "run_script", - handleServiceId, - scriptFile.absolutePath, - workingDir, - envPairs.toTypedArray(), - OutputSink(sink) - ).toString() + if (debugBreakpoints != null) { + Python.getInstance().getModule(DEBUGGER_MODULE).callAttr( + "debug_script", + handleServiceId, + scriptFile.absolutePath, + workingDir, + envPairs.toTypedArray(), + OutputSink(sink), + debugger!!.listener(project.id), + debugBreakpoints + ).toString() + } else { + runner.callAttr( + "run_script", + handleServiceId, + scriptFile.absolutePath, + workingDir, + envPairs.toTypedArray(), + OutputSink(sink) + ).toString() + } } catch (e: Throwable) { sink.onEvent(LogLevel.STDERR, "${e.javaClass.simpleName}: ${e.message}") "failed:${e.javaClass.simpleName}" } } + // The Python side reports the end itself; this covers a bridge that never ran. + if (debugBreakpoints != null && debugger?.isActive == true) { + debugger.listener(project.id).onState(JSONObject().put("status", "finished").put("outcome", outcome).toString()) + } when (outcome) { "completed" -> { @@ -170,6 +193,7 @@ class PythonEngine( override val boundPort = project.network.port override val exitReason get() = reason.get() override val threadId get() = workerTid.get() + override val restartable = debugBreakpoints == null override suspend fun stop() = withContext(Dispatchers.IO) { if (!alive.get()) return@withContext @@ -216,6 +240,7 @@ class PythonEngine( companion object { private const val RUNNER_MODULE = "runcode_runner" + private const val DEBUGGER_MODULE = "runcode_debugger" private const val STOP_GRACE_MS = 3000L /** Starts the interpreter once per process. Safe to call repeatedly. */ diff --git a/app/src/main/java/com/runcode/app/runtime/RuntimeEngine.kt b/app/src/main/java/com/runcode/app/runtime/RuntimeEngine.kt index 03505bf..705cbb7 100644 --- a/app/src/main/java/com/runcode/app/runtime/RuntimeEngine.kt +++ b/app/src/main/java/com/runcode/app/runtime/RuntimeEngine.kt @@ -73,6 +73,13 @@ interface RuntimeHandle { * Null when the runtime has no single thread to point at. */ val threadId: Long? + + /** + * False for runs a person is driving, such as a debug run: restarting one would run the + * project again without its debugger, so the supervisor leaves it stopped. + */ + val restartable: Boolean get() = true + suspend fun stop() suspend fun forceKill() suspend fun checkHealth(): RuntimeHealth diff --git a/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt b/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt index 897dd7f..7f526e1 100644 --- a/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt +++ b/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt @@ -269,6 +269,20 @@ class ServiceSupervisor( val instance = _instances.value[project.id] ?: break if (instance.state == ServiceState.STOPPING || instance.state == ServiceState.STOPPED) break + if (!handle.restartable && handle.exitReason != ExitReason.STOPPED) { + // A debug run ended. Restarting it would run without the debugger. + val failed = handle.exitReason != ExitReason.COMPLETED + logManager.log(project.id, project.name, if (failed) LogLevel.WARN else LogLevel.SYSTEM, + if (failed) "Debug run ended with an error; not restarted." else "Debug run finished.") + releaseService(project.id) + updateInstance(project.id) { + it.copy(state = if (failed) ServiceState.FAILED else ServiceState.STOPPED, + lastError = if (failed) "Debug run ended with an error" else null) + } + syncSystemState() + break + } + when (handle.exitReason) { ExitReason.STOPPED -> break diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index 577a0cf..5f5eda2 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -26,6 +26,8 @@ import com.runcode.app.mcp.McpServerState import com.runcode.app.mcp.McpTunnelState import com.runcode.app.mcp.TofuHostKeys import com.runcode.app.mcp.TunnelStatus +import com.runcode.app.runtime.DebugState +import com.runcode.app.runtime.DebugStatus import com.runcode.app.runtime.PythonEngine import com.runcode.app.settings.ProjectSettings import com.runcode.app.storage.EntryPointEffect @@ -180,6 +182,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val userMessage: StateFlow = _userMessage.asStateFlow() init { + viewModelScope.launch { app.debugger.state.collect { followDebugger(it) } } loadProjects() refreshCapabilities() viewModelScope.launch { @@ -947,6 +950,72 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + // ---------------------------------------------------------------- debugger + + val debugState: StateFlow = app.debugger.state + val breakpoints: StateFlow>>> = app.debugger.breakpoints + + private val _debugEvalResult = MutableStateFlow(null) + val debugEvalResult: StateFlow = _debugEvalResult.asStateFlow() + + /** Breakpoints live on Python files under source/; the key is the path below it. */ + fun debugFileKey(tab: String?): String? = + tab?.takeIf { it.startsWith("source/") && it.endsWith(".py") }?.removePrefix("source/") + + fun toggleBreakpoint(line: Int) { + val project = _selectedProject.value ?: return + val file = debugFileKey(_activeTab.value) ?: return + app.debugger.toggleBreakpoint(project.id, file, line) + } + + /** Runs the project under the debugger; it stops at breakpoints in its Python files. */ + fun debugProject(project: Project) { + if (project.profile == ProjectProfile.STATIC_WEB) { + _userMessage.value = "Static websites have no Python to debug." + return + } + viewModelScope.launch { + if (_isDirty.value && !persistCurrentFile(announce = false)) return@launch + _debugEvalResult.value = null + app.debugger.arm(project.id) + try { + if (!app.serviceSupervisor.startProject(project)) { + _userMessage.value = "Could not start the debug run. Stop the running project first." + } + } finally { + app.debugger.disarm(project.id) + } + } + } + + fun debugCommand(name: String) { + viewModelScope.launch(Dispatchers.IO) { + app.debugger.command(name)?.let { _userMessage.value = it } + } + } + + fun debugEvaluate(expression: String) { + if (expression.isBlank()) return + viewModelScope.launch(Dispatchers.IO) { + _debugEvalResult.value = app.debugger.evaluate(expression).fold( + onSuccess = { "$expression = $it" }, + onFailure = { "$expression → ${it.message}" } + ) + } + } + + fun dismissDebug() = app.debugger.dismiss() + + /** Shows the paused line: opens its file when the debugger stops in another one. */ + private fun followDebugger(state: DebugState) { + if (state.status != DebugStatus.PAUSED) return + val project = _selectedProject.value ?: return + val file = state.file ?: return + if (state.projectId != project.id || file.startsWith("/")) return + val tab = "source/$file" + if (_activeTab.value != tab) openFile(project.id, tab) + } + fun stopProject(projectId: String) { viewModelScope.launch { app.serviceSupervisor.stopProject(projectId) diff --git a/app/src/main/java/com/runcode/app/ui/screens/CodeEditor.kt b/app/src/main/java/com/runcode/app/ui/screens/CodeEditor.kt index 113cc4f..402bff6 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/CodeEditor.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/CodeEditor.kt @@ -2,6 +2,7 @@ package com.runcode.app.ui.screens import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -24,8 +25,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange @@ -36,12 +40,14 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.runcode.app.ui.theme.AccentCyan +import com.runcode.app.ui.theme.AccentRed import com.runcode.app.ui.theme.DarkSurface import com.runcode.app.ui.theme.TextMuted import com.runcode.app.ui.theme.TextPrimary import kotlinx.coroutines.flow.StateFlow private val GUTTER_WIDTH = 44.dp +private val PausedLine = Color(0x40FFB300) private val EDITOR_PADDING = 8.dp /** @@ -61,7 +67,13 @@ fun CodeEditor( documentKey: String, content: StateFlow, onContentChange: (String) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + /** 1-based lines with a breakpoint, drawn as a dot in the gutter. */ + breakpoints: Set = emptySet(), + /** 1-based line the debugger is paused on, highlighted; null when not paused here. */ + pausedLine: Int? = null, + /** Tapping a line number; null when breakpoints are not available for this file. */ + onLineNumberTap: ((Int) -> Unit)? = null ) { val state = remember(documentKey) { TextFieldState(content.value) } val scroll = remember(documentKey) { ScrollState(0) } @@ -108,6 +120,25 @@ fun CodeEditor( } } + val currentLineStarts by rememberUpdatedState(lineStarts) + val currentLayout by rememberUpdatedState(layout) + val currentOnTap by rememberUpdatedState(onLineNumberTap) + + /** The 1-based logical line at a y position in the gutter, or null below the text. */ + fun lineAt(y: Float, padding: Float): Int? { + val result = currentLayout ?: return null + val visual = result.getLineForVerticalPosition(y - padding + scroll.value) + val offset = result.getLineStart(visual) + val starts = currentLineStarts + var low = 0 + var high = starts.size - 1 + while (low < high) { + val mid = (low + high + 1) / 2 + if (starts[mid] <= offset) low = mid else high = mid - 1 + } + return low + 1 + } + val measurer = rememberTextMeasurer(cacheSize = 512) val numberStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = TextMuted) @@ -118,6 +149,12 @@ fun CodeEditor( .fillMaxHeight() .background(DarkSurface) .clipToBounds() + .pointerInput(Unit) { + detectTapGestures { position -> + val tap = currentOnTap ?: return@detectTapGestures + lineAt(position.y, EDITOR_PADDING.toPx())?.let(tap) + } + } .drawBehind { val result = layout ?: return@drawBehind val starts = lineStarts @@ -139,7 +176,19 @@ fun CodeEditor( for (index in low until starts.size) { val visualLine = result.getLineForOffset(starts[index].coerceAtMost(textLength)) if (result.getLineTop(visualLine) + shift > size.height) break - val number = measurer.measure((index + 1).toString(), numberStyle) + val lineNumber = index + 1 + val baseline = shift + result.getLineBaseline(visualLine) + if (lineNumber == pausedLine) { + drawRect( + color = PausedLine, + topLeft = Offset(0f, shift + result.getLineTop(visualLine)), + size = Size(size.width, result.getLineBottom(visualLine) - result.getLineTop(visualLine)) + ) + } + if (lineNumber in breakpoints) { + drawCircle(color = AccentRed, radius = 4.5.dp.toPx(), center = Offset(7.dp.toPx(), baseline - 4.dp.toPx())) + } + val number = measurer.measure(lineNumber.toString(), numberStyle) drawText( textLayoutResult = number, topLeft = Offset( @@ -171,6 +220,15 @@ fun CodeEditor( modifier = Modifier .weight(1f) .fillMaxHeight() + .drawBehind { + // The paused line, across the code, under the text. + val line = pausedLine ?: return@drawBehind + val result = layout ?: return@drawBehind + val start = lineStarts.getOrNull(line - 1) ?: return@drawBehind + val visual = result.getLineForOffset(start.coerceAtMost(result.layoutInput.text.length)) + val top = EDITOR_PADDING.toPx() - scroll.value + result.getLineTop(visual) + drawRect(PausedLine, Offset(0f, top), Size(size.width, result.getLineBottom(visual) - result.getLineTop(visual))) + } .testTag("editor_text_input") ) } diff --git a/app/src/main/java/com/runcode/app/ui/screens/DebugPanel.kt b/app/src/main/java/com/runcode/app/ui/screens/DebugPanel.kt new file mode 100644 index 0000000..3cc658c --- /dev/null +++ b/app/src/main/java/com/runcode/app/ui/screens/DebugPanel.kt @@ -0,0 +1,154 @@ +package com.runcode.app.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.runcode.app.runtime.DebugState +import com.runcode.app.runtime.DebugStatus +import com.runcode.app.runtime.DebugVariable +import com.runcode.app.ui.MainViewModel +import com.runcode.app.ui.theme.AccentCyan +import com.runcode.app.ui.theme.AccentGreen +import com.runcode.app.ui.theme.AccentRed +import com.runcode.app.ui.theme.DarkSurface +import com.runcode.app.ui.theme.TextMuted +import com.runcode.app.ui.theme.TextPrimary +import com.runcode.app.ui.theme.TextSecondary + +/** Controls, variables, call stack and expression evaluation for a debug run. */ +@Composable +fun DebugPanel(viewModel: MainViewModel, state: DebugState, modifier: Modifier = Modifier) { + val evalResult by viewModel.debugEvalResult.collectAsState() + var expression by remember { mutableStateOf("") } + var showStack by remember { mutableStateOf(false) } + val paused = state.status == DebugStatus.PAUSED + + Column(modifier = modifier.background(DarkSurface).padding(horizontal = 8.dp, vertical = 4.dp)) { + Text( + text = when (state.status) { + DebugStatus.STARTING -> "Debugger starting…" + DebugStatus.RUNNING -> "Running — stops at the next breakpoint" + DebugStatus.PAUSED -> "Paused at ${state.file}:${state.line} in ${state.function}" + + if (state.reason == "breakpoint") " (breakpoint)" else "" + DebugStatus.FINISHED -> "Debug run ended: ${state.outcome?.substringBefore(':')}" + DebugStatus.IDLE -> "" + }, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = if (paused) AccentCyan else TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Labels rather than icons: the meaning of step over/into/out is not obvious from glyphs. + Row(modifier = Modifier.horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { + DebugButton("Continue", paused, AccentGreen, "debug_continue") { viewModel.debugCommand("continue") } + DebugButton("Over", paused, AccentCyan, "debug_over") { viewModel.debugCommand("next") } + DebugButton("Into", paused, AccentCyan, "debug_into") { viewModel.debugCommand("step") } + DebugButton("Out", paused, AccentCyan, "debug_out") { viewModel.debugCommand("return") } + DebugButton("Stop", state.status == DebugStatus.PAUSED || state.status == DebugStatus.RUNNING, AccentRed, "debug_stop") { + if (paused) viewModel.debugCommand("stop") else state.projectId?.let(viewModel::stopProject) + } + if (state.status == DebugStatus.FINISHED) { + TextButton(onClick = { viewModel.dismissDebug() }) { Text("Close", fontSize = 12.sp, color = TextSecondary) } + } + if (paused) { + TextButton(onClick = { showStack = !showStack }) { + Text(if (showStack) "Variables" else "Stack (${state.stack.size})", fontSize = 11.sp, color = TextSecondary) + } + } + } + + if (paused) { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 150.dp)) { + if (showStack) { + items(state.stack) { frame -> + Text("${frame.function} ${frame.file}:${frame.line}", fontFamily = FontFamily.Monospace, + fontSize = 11.sp, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } else { + if (state.locals.isNotEmpty()) item { Section("Locals") } + items(state.locals) { VariableRow(it) } + if (state.globals.isNotEmpty()) item { Section("Globals") } + items(state.globals) { VariableRow(it) } + if (state.locals.isEmpty() && state.globals.isEmpty()) item { Section("No variables yet") } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = expression, + onValueChange = { expression = it }, + placeholder = { Text("Expression, e.g. len(items)", fontSize = 11.sp) }, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + modifier = Modifier.weight(1f).testTag("debug_eval_input") + ) + TextButton(onClick = { viewModel.debugEvaluate(expression) }, enabled = expression.isNotBlank()) { + Text("Eval", color = AccentCyan) + } + } + evalResult?.let { + Text(it, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextPrimary, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + } else if (state.status == DebugStatus.RUNNING) { + Text( + "A script blocked in a C call, such as time.sleep or a network read, pauses on its next line.", + fontSize = 10.sp, + color = TextMuted + ) + } + } +} + +@Composable +private fun DebugButton( + label: String, + enabled: Boolean, + color: androidx.compose.ui.graphics.Color, + tag: String, + onClick: () -> Unit +) { + TextButton(onClick = onClick, enabled = enabled, modifier = Modifier.testTag(tag)) { + Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = if (enabled) color else TextMuted) + } +} + +@Composable +private fun Section(title: String) { + Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = TextMuted, modifier = Modifier.padding(top = 4.dp)) +} + +@Composable +private fun VariableRow(variable: DebugVariable) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(variable.name, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = AccentCyan) + Text(variable.value, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextPrimary, + maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text(variable.type, fontSize = 10.sp, color = TextMuted) + } +} diff --git a/app/src/main/java/com/runcode/app/ui/screens/EditorScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/EditorScreen.kt index d5f9d55..844b0f2 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/EditorScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/EditorScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Redo import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.PlayArrow @@ -56,6 +57,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.runcode.app.domain.models.LogLevel +import com.runcode.app.runtime.DebugStatus import com.runcode.app.ui.MainViewModel import com.runcode.app.ui.theme.AccentCyan import com.runcode.app.ui.theme.AccentGreen @@ -82,6 +84,8 @@ fun EditorScreen( val projectFiles by viewModel.projectFiles.collectAsState() val instances by viewModel.instances.collectAsState() val logs by viewModel.logs.collectAsState() + val debugState by viewModel.debugState.collectAsState() + val breakpointMap by viewModel.breakpoints.collectAsState() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -182,6 +186,15 @@ fun EditorScreen( Text("Stop", color = AccentRed) } } else { + IconButton( + onClick = { + viewModel.debugProject(currentProj) + showBottomConsole = true + }, + modifier = Modifier.size(40.dp).testTag("editor_debug_btn") + ) { + Icon(Icons.Default.BugReport, contentDescription = "Debug", tint = AccentCyan, modifier = Modifier.size(20.dp)) + } Button( onClick = { viewModel.runProject(currentProj) @@ -258,11 +271,18 @@ fun EditorScreen( .background(DarkBg) ) { if (activeTab != null) { + val debugFile = viewModel.debugFileKey(activeTab) + val projectId = project?.id CodeEditor( documentKey = activeTab ?: "", content = viewModel.editorContent, onContentChange = viewModel::updateEditorContent, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + breakpoints = debugFile?.let { breakpointMap[projectId]?.get(it) }.orEmpty(), + pausedLine = debugState.line.takeIf { + debugState.status == DebugStatus.PAUSED && debugState.projectId == projectId && debugState.file == debugFile + }, + onLineNumberTap = if (debugFile != null) viewModel::toggleBreakpoint else null ) } else { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -271,6 +291,10 @@ fun EditorScreen( } } + if (debugState.projectId == project?.id && debugState.status != DebugStatus.IDLE) { + DebugPanel(viewModel, debugState, modifier = Modifier.fillMaxWidth()) + } + // Bottom Collapsible Console Drawer if (showBottomConsole) { Column( diff --git a/app/src/main/python/runcode_debugger.py b/app/src/main/python/runcode_debugger.py index af94549..fec4d97 100644 --- a/app/src/main/python/runcode_debugger.py +++ b/app/src/main/python/runcode_debugger.py @@ -116,10 +116,14 @@ def user_exception(self, frame, exc_info): # -- pausing ------------------------------------------------------------------------- def _pause(self, frame, reason): + # Accept commands before announcing the pause: a client that reacts to the published + # state at once must not be told the program is still running. + with self.cond: + self.pending = None + self.paused = True self._publish(self._snapshot(frame, reason)) command = None with self.cond: - self.paused = True try: while True: while self.pending is None: diff --git a/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt index f98cc91..20be0c4 100644 --- a/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt @@ -38,7 +38,7 @@ class McpGitToolsTest { val changed = mutableListOf() val host = McpToolHost(app.projectStorage, app.appMetaDatabase, app.projectDatabaseManager, app.serviceSupervisor, app.logManager, app.terminalSession, { _, _ -> "" }, - app.projectSettings, { changed += it }, { "" }, { _, _ -> "" }, { git }) + app.projectSettings, { changed += it }, { "" }, { _, _ -> "" }, { git }, app.debugger) val project = app.projectStorage.createProjectFromTemplate("MCP git", ProjectProfile.PYTHON_HTTP) app.appMetaDatabase.insertOrUpdateProject(project) diff --git a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt index b1543e7..9262b72 100644 --- a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt @@ -31,7 +31,7 @@ class McpSettingsTest { runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } host = McpToolHost(app.projectStorage, app.appMetaDatabase, app.projectDatabaseManager, app.serviceSupervisor, app.logManager, app.terminalSession, { _, _ -> "unused" }, - app.projectSettings, { notifications.add(it) }, { "diagnostics report" }, { _, _ -> "backup" }, { app.git }) + app.projectSettings, { notifications.add(it) }, { "diagnostics report" }, { _, _ -> "backup" }, { app.git }, app.debugger) } @Test fun `mcp replaces plain variables but preserves secret references`() = runBlocking { diff --git a/app/src/test/java/com/runcode/app/runtime/PythonDebuggerTest.kt b/app/src/test/java/com/runcode/app/runtime/PythonDebuggerTest.kt new file mode 100644 index 0000000..fd29d6c --- /dev/null +++ b/app/src/test/java/com/runcode/app/runtime/PythonDebuggerTest.kt @@ -0,0 +1,181 @@ +package com.runcode.app.runtime + +import android.app.Application +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import kotlin.concurrent.thread + +private class FakeBridge : DebugBridge { + val calls = mutableListOf>() + var reply = """{"ok": true}""" + override fun command(sessionId: String, name: String): String { calls += listOf("command", sessionId, name); return reply } + override fun evaluate(sessionId: String, expression: String): String { + calls += listOf("evaluate", sessionId, expression) + return """{"ok": true, "type": "int", "value": "42"}""" + } + override fun setBreakpoints(sessionId: String, file: String, linesJson: String): String { + calls += listOf("breakpoints", sessionId, file, linesJson); return reply + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class PythonDebuggerTest { + + private val bridge = FakeBridge() + private val debugger = PythonDebugger(bridge) + private val source = File("/data/projects/p1/source") + + private val paused = JSONObject() + .put("status", "paused").put("reason", "breakpoint") + .put("file", "helper.py").put("line", 3).put("function", "double") + .put("stack", JSONArray().put(JSONObject().put("file", "helper.py").put("line", 3).put("function", "double")) + .put(JSONObject().put("file", "main.py").put("line", 5).put("function", ""))) + .put("locals", JSONArray().put(JSONObject().put("name", "x").put("type", "int").put("value", "2"))) + .put("globals", JSONArray()) + .toString() + + @Test fun `paused state is parsed with stack and variables`() { + val state = DebugState.parse("p1", paused) + assertEquals(DebugStatus.PAUSED, state.status) + assertEquals(Triple("helper.py", 3, "double"), Triple(state.file, state.line, state.function)) + assertEquals(listOf("double", ""), state.stack.map { it.function }) + assertEquals(DebugVariable("x", "int", "2"), state.locals.single()) + assertEquals("completed", DebugState.parse("p1", """{"status":"finished","outcome":"completed"}""").outcome) + } + + @Test fun `only an armed start becomes a debug run, with absolute breakpoint paths`() { + assertNull(debugger.takeLaunch("p1", "svc_py_p1", source)) + debugger.toggleBreakpoint("p1", "main.py", 6) + debugger.toggleBreakpoint("p1", "lib/util.py", 2) + debugger.arm("p1") + assertEquals(DebugStatus.STARTING, debugger.state.value.status) + val launch = JSONObject(debugger.takeLaunch("p1", "svc_py_p1", source)!!) + assertEquals("[6]", launch.getJSONArray(File(source, "main.py").absolutePath).toString()) + assertEquals("[2]", launch.getJSONArray(File(source, "lib/util.py").absolutePath).toString()) + // Consumed: the next start is a normal run again. + assertNull(debugger.takeLaunch("p1", "svc_py_p1", source)) + } + + @Test fun `disarming after a failed start clears the starting state`() { + debugger.arm("p1") + debugger.disarm("p1") + assertEquals(DebugStatus.IDLE, debugger.state.value.status) + assertNull(debugger.takeLaunch("p1", "svc_py_p1", source)) + } + + @Test fun `toggling twice removes the breakpoint and live sessions are updated`() { + debugger.toggleBreakpoint("p1", "main.py", 6) + assertEquals(setOf(6), debugger.breakpointsFor("p1", "main.py")) + assertTrue("no session yet, so nothing is sent", bridge.calls.isEmpty()) + + debugger.arm("p1") + debugger.takeLaunch("p1", "svc_py_p1", source) + debugger.toggleBreakpoint("p1", "main.py", 9) + assertEquals(listOf("breakpoints", "svc_py_p1", File(source, "main.py").absolutePath, "[6,9]"), bridge.calls.last()) + + debugger.toggleBreakpoint("p1", "main.py", 6) + debugger.toggleBreakpoint("p1", "main.py", 9) + assertTrue(debugger.breakpointsFor("p1", "main.py").isEmpty()) + assertTrue(debugger.breakpoints.value.isEmpty()) + } + + @Test fun `commands go to the running session and errors come back as text`() { + assertEquals("No debug session is running.", debugger.command("continue")) + debugger.arm("p1") + debugger.takeLaunch("p1", "svc_py_p1", source) + assertNull(debugger.command("next")) + assertEquals(listOf("command", "svc_py_p1", "next"), bridge.calls.last()) + bridge.reply = """{"ok": false, "error": "The program is running, not paused."}""" + assertEquals("The program is running, not paused.", debugger.command("step")) + assertEquals("int: 42", debugger.evaluate("6 * 7").getOrThrow()) + } + + @Test fun `listener updates state and the end closes the session`() { + debugger.arm("p1") + debugger.takeLaunch("p1", "svc_py_p1", source) + val listener = debugger.listener("p1") + listener.onState(paused) + assertEquals(3, debugger.state.value.line) + listener.onState("""{"status":"finished","outcome":"stopped"}""") + assertFalse(debugger.isActive) + debugger.dismiss() + assertEquals(DebugStatus.IDLE, debugger.state.value.status) + } + + @Test fun `awaitSettled returns the next pause after a command`() = runBlocking { + debugger.arm("p1") + debugger.takeLaunch("p1", "svc_py_p1", source) + val listener = debugger.listener("p1") + listener.onState(paused) + val before = debugger.state.value + thread { + Thread.sleep(100) + listener.onState("""{"status":"running"}""") + Thread.sleep(100) + listener.onState(paused.replace("\"line\":3", "\"line\":4")) + } + val next = debugger.awaitSettled(before, 5_000) + assertEquals(DebugStatus.PAUSED, next.status) + assertNotSame(before, next) + assertEquals("paused", debugger.toJson(next).getString("status")) + } + + /** The JSON the real runcode_debugger.py publishes, parsed by the Kotlin side. */ + @Test fun `parses what the python debugger actually sends`() { + val python = listOfNotNull(System.getenv("RUNCODE_TEST_PYTHON"), System.getenv("RUNCODE_BUILD_PYTHON"), "python3") + .firstOrNull { runCatching { ProcessBuilder(it, "--version").start().waitFor() == 0 }.getOrDefault(false) } + org.junit.Assume.assumeTrue("No Python 3 on this machine", python != null) + + val dir = kotlin.io.path.createTempDirectory("dbg").toFile() + val sourceDir = File(dir, "source").apply { mkdirs() } + File(sourceDir, "main.py").writeText("def area(w, h):\n result = w * h\n return result\n\nprint(area(3, 4))\n") + val driver = """ + import json, sys, threading + sys.path.insert(0, sys.argv[1]) + import runcode_debugger as d + class Sink: + def onOutput(self, level, line): pass + # The runner routes print() on the script thread into the project log, so write to + # the real stdout directly. + def emit(raw): + sys.__stdout__.write("STATE " + raw + "\n") + sys.__stdout__.flush() + class Listener: + def onState(self, raw): + state = json.loads(raw) + if state["status"] == "paused": + emit(raw) + threading.Thread(target=lambda: d.command("svc", "continue")).start() + elif state["status"] == "finished": + emit(raw) + script = sys.argv[2] + "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/source/main.py" + d.debug_script("svc", script, sys.argv[2], [], Sink(), Listener(), json.dumps({script: [3]})) + """.trimIndent() + val process = ProcessBuilder(python!!, "-c", driver, File("src/main/python").absolutePath, dir.absolutePath) + .redirectErrorStream(true).start() + val finished = process.waitFor(30, java.util.concurrent.TimeUnit.SECONDS) + if (!finished) process.destroyForcibly() + val output = process.inputStream.bufferedReader().readText() + assertTrue("debug driver did not finish: $output", finished) + val states = output.lines().filter { it.startsWith("STATE ") }.map { DebugState.parse("p1", it.removePrefix("STATE ")) } + assertEquals(output, 2, states.size) + + val pausedState = states[0] + assertEquals(DebugStatus.PAUSED, pausedState.status) + assertEquals(Triple("main.py", 3, "area"), Triple(pausedState.file, pausedState.line, pausedState.function)) + assertEquals("breakpoint", pausedState.reason) + assertEquals(listOf("area", ""), pausedState.stack.map { it.function }) + assertEquals(mapOf("w" to "3", "h" to "4", "result" to "12"), pausedState.locals.associate { it.name to it.value }) + assertEquals(DebugStatus.FINISHED, states[1].status) + assertEquals("completed", states[1].outcome) + dir.deleteRecursively() + } +} diff --git a/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt b/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt index dfac65e..5ce6332 100644 --- a/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt +++ b/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt @@ -21,7 +21,11 @@ import org.robolectric.annotation.Config class ServiceSupervisorTest { private val project = Project("test", "Test", "", ProjectProfile.PYTHON_SCRIPT, "/test", "main.py") - private class Handle(var alive: Boolean = false, var reason: ExitReason = ExitReason.CRASHED) : RuntimeHandle { + private class Handle( + var alive: Boolean = false, + var reason: ExitReason = ExitReason.CRASHED, + override val restartable: Boolean = true + ) : RuntimeHandle { override val serviceId = "test" override val projectId = "test" override val isAlive get() = alive @@ -49,6 +53,26 @@ class ServiceSupervisorTest { return ServiceSupervisor(context, RuntimeRegistry(engine, engine), LogManager(context), PortManager(), backgroundScope) } + @Test fun `a crashed debug run is not restarted, even with ALWAYS`() = runTest { + val engine = Engine(Handle(reason = ExitReason.CRASHED, restartable = false)) + val supervisor = supervisor(engine) + supervisor.startProject(project.copy(restartPolicy = RestartPolicy.ALWAYS)) + advanceTimeBy(60_000) + runCurrent() + assertEquals(1, engine.starts) + assertEquals(ServiceState.FAILED, supervisor.instances.value[project.id]?.state) + } + + @Test fun `a finished debug run stops cleanly`() = runTest { + val engine = Engine(Handle(reason = ExitReason.COMPLETED, restartable = false)) + val supervisor = supervisor(engine) + supervisor.startProject(project.copy(restartPolicy = RestartPolicy.ALWAYS)) + advanceTimeBy(60_000) + runCurrent() + assertEquals(1, engine.starts) + assertEquals(ServiceState.STOPPED, supervisor.instances.value[project.id]?.state) + } + @Test fun `crashing services stop after four automatic retries`() = runTest { val engine = Engine(Handle()) val supervisor = supervisor(engine) diff --git a/review-notes.md b/review-notes.md index b186af6..20f4ab1 100644 --- a/review-notes.md +++ b/review-notes.md @@ -2,8 +2,8 @@ ## v1.5.0 (versionCode 6) — in progress on `ccr-00c06d8d-qqvzlg` -Roadmap phases 3 (diagnostics) and 4 (backups to a chosen folder), plus a public URL for the -MCP bridge. +Roadmap phases 3 (diagnostics), 4 (backups to a chosen folder), 5 (git) and 6 (debugger), plus +a public URL for the MCP bridge. | Change | Where | | --- | --- | @@ -13,6 +13,8 @@ MCP bridge. | **Backup folder** through the Storage Access Framework, export verified by read-back hash, restore from the folder, optional daily run keeping 7 automatic copies. `backup_project` MCP tool. | `backup/`, Backups screen | | Backup manifests are built with `JSONObject`; a quote in a project name broke restore. | `BackupManager` | | `serverInfo.version` reports the app version instead of `1.0.0`. | `McpServer` | +| **Git** (phase 5) on dulwich: Git screen, clone into a project, token only sent to GitHub over HTTPS. `git_*` MCP tools. | `git/`, `runcode_git.py` | +| **Debugger** (phase 6) on bdb: breakpoints in the gutter, step over/into/out, variables, call stack, eval. Debug runs are never auto-restarted. `debug_*` MCP tools. | `runcode_debugger.py`, `runtime/PythonDebugger`, Editor | Validation in this environment (no Android device or KVM available): diff --git a/tests/test_debugger.py b/tests/test_debugger.py index dbdf147..85ab418 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -187,6 +187,22 @@ def test_evaluate_in_the_paused_frame(self): self.send("continue") self.finish(thread) + def test_a_command_sent_the_moment_a_pause_is_announced_is_accepted(self): + # The listener reacts synchronously, the way an eager client would. + replies = [] + session = self.session + + class Eager(Listener): + def onState(inner, raw): + Listener.onState(inner, raw) + if json.loads(raw)["status"] == "paused": + replies.append(json.loads(debugger.command(session, "continue"))) + + self.listener = Eager() + thread = self.start({self.script: [6]}) + self.assertEqual("completed", self.finish(thread)["outcome"]) + self.assertEqual([{"ok": True}], replies) + def test_commands_are_refused_while_running(self): self.assertFalse(json.loads(debugger.command("nobody", "continue"))["ok"]) self.assertFalse(json.loads(debugger.command(self.session, "jump"))["ok"]) From 725b1585987504ad85b1d149ec5287078b1c0592 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:26:35 +0000 Subject: [PATCH 12/15] Fix debugger eval locking and guard MCP pull and auto-backup - Debugger: run expressions without holding the condition, so the 10-second timeout holds and a stop is not blocked. Requests are queued per caller, a timed-out or stopped expression is interrupted with an async exception, and late results are dropped. A second command while one is waiting is refused. - MCP git_pull refuses while the app's editor has unsaved changes to the project, the same rule as the Git screen. - runAutoBackupIfDue is serialised with a Mutex and re-checks that a run is due under it, so two callers cannot both export. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- README.md | 3 +- .../main/java/com/runcode/app/RuncodeApp.kt | 20 +++- .../java/com/runcode/app/mcp/McpToolHost.kt | 4 +- .../main/java/com/runcode/app/mcp/McpTools.kt | 4 + .../java/com/runcode/app/ui/MainViewModel.kt | 10 ++ app/src/main/python/runcode_debugger.py | 113 ++++++++++++++---- .../com/runcode/app/mcp/McpGitToolsTest.kt | 13 +- .../com/runcode/app/ui/GitViewModelTest.kt | 3 + tests/test_debugger.py | 65 ++++++++++ 9 files changed, 203 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index c7a4cf1..88977b5 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,8 @@ Binary files and files over 512 KB do not open in the editor; they can still be - When execution reaches one, the line is highlighted, the file opens if needed, and the debug panel shows locals, user globals and the call stack. - **Continue**, **Over** (next line), **Into** (into a call), **Out** (finish the function) and - **Stop**. **Eval** evaluates an expression in the paused frame. + **Stop**. **Eval** evaluates an expression in the paused frame. An expression still running + after 10 seconds, or when Stop is pressed, is interrupted. - Stepping stays in the project's own files; library code runs without stopping. It is built on `bdb`, the base of `pdb`, and its trace hook exists only during a debug run, so diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index 0e7cfce..75e9c58 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -44,6 +44,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.File import java.security.SecureRandom @@ -117,6 +119,13 @@ class RuncodeApp : Application() { var isPythonAvailable: Boolean = false private set + /** + * The project id and path of the file the editor holds unsaved changes to, kept current by + * the view model, so the MCP bridge can refuse to rewrite files under those changes. + */ + @Volatile + var unsavedEditorFile: Pair? = null + override fun onCreate() { super.onCreate() @@ -174,7 +183,8 @@ class RuncodeApp : Application() { runDiagnostics = { diagnostics.run().toText() }, backupProject = ::backupProjectForMcp, git = { git }, - debugger = debugger + debugger = debugger, + unsavedEditorFile = { projectId -> unsavedEditorFile?.takeIf { it.first == projectId }?.second } ), onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } ) @@ -226,10 +236,14 @@ class RuncodeApp : Application() { return "Created a local backup and copied it to ${backupFolder.state.value.label} as ${stored.name}; the copy was read back and verified." } + private val autoBackupLock = Mutex() + /** Backs every project up to the chosen folder when the daily run is switched on and due. */ - suspend fun runAutoBackupIfDue() { + suspend fun runAutoBackupIfDue() = autoBackupLock.withLock { + // Checked under the lock: the hourly loop and the Backups switch can both find a run + // due, and only the first may do it. val now = System.currentTimeMillis() - if (!backupFolder.isAutoDue(now)) return + if (!backupFolder.isAutoDue(now)) return@withLock // Never throws: it runs in an endless loop and from the UI, and one failure must not // end daily backups or crash the app. val result = try { diff --git a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt index eb01083..41f7380 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt @@ -33,7 +33,9 @@ class McpToolHost( val backupProject: suspend (project: Project, toFolder: Boolean) -> String, /** Lazy, because building it touches the vault and preferences. */ val git: () -> GitManager, - val debugger: PythonDebugger + val debugger: PythonDebugger, + /** The file of [projectId] open in the app's editor with unsaved changes, if any. */ + val unsavedEditorFile: (projectId: String) -> String? = { null } ) { suspend fun projectOrNull(projectId: String): Project? = appMetaDatabase.getAllProjects().find { it.id == projectId } diff --git a/app/src/main/java/com/runcode/app/mcp/McpTools.kt b/app/src/main/java/com/runcode/app/mcp/McpTools.kt index f88e3e6..6e33d3f 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpTools.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpTools.kt @@ -683,6 +683,10 @@ object McpTools { } private fun gitPull(host: McpToolHost, projectId: String) = withRepo(host, projectId) { project -> + // The same rule as the Git screen: saving those edits later would undo the pull. + host.unsavedEditorFile(project.id)?.let { file -> + return@withRepo errorResult("$file has unsaved changes in the app's editor. Save or discard them there, then pull again.") + } val count = host.git().pull(project) host.onProjectChanged(project.id) if (count == 0) "Already up to date" else "Pulled $count new commit(s)" diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index 5f5eda2..f80b571 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.Mutex @@ -188,6 +189,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { app.projectChanges.collect { projectId -> onExternalProjectChange(projectId) } } + viewModelScope.launch { + combine(_isDirty, _selectedProject, _activeTab) { dirty, project, tab -> + if (dirty && project != null && tab != null) project.id to tab else null + }.collect { app.unsavedEditorFile = it } + } + } + + override fun onCleared() { + app.unsavedEditorFile = null } // ---------------------------------------------------------------- terminal diff --git a/app/src/main/python/runcode_debugger.py b/app/src/main/python/runcode_debugger.py index fec4d97..026b32b 100644 --- a/app/src/main/python/runcode_debugger.py +++ b/app/src/main/python/runcode_debugger.py @@ -8,7 +8,8 @@ wraps runpy in Bdb.runcall. When execution reaches a breakpoint or finishes a step, the script thread publishes its state to the Kotlin listener and waits for a command from command(), which the app calls from another thread. Evaluation runs on the paused thread itself, so expressions -see the real frame. +see the real frame. An expression that runs past the timeout, or a stop sent meanwhile, +interrupts it with an async exception, the same way the supervisor stops a script. Only the script's main thread is traced, and only files under the project root are stepped into or stopped in; library code runs at full speed. Like any trace hook it acts between Python @@ -18,6 +19,7 @@ """ import bdb +import collections import json import os import reprlib @@ -57,6 +59,17 @@ def _show(value): return "" % error.__class__.__name__ +class _EvalCancelled(BaseException): + """Raised in the paused thread to abandon an expression that is still running.""" + + +class _EvalRequest: + def __init__(self, expression): + self.expression = expression + self.result = None # JSON reply, set once + self.abandoned = False # the caller stopped waiting + + def _variables(namespace, globals_only=False): shown = [] for name, value in namespace.items(): @@ -79,8 +92,9 @@ def __init__(self, session_id, root, listener): self.root = os.path.abspath(root) + os.sep self.listener = listener self.cond = threading.Condition() - self.pending = None # (command, argument) waiting for the paused thread - self.eval_results = {} # request id -> JSON reply + self.requests = collections.deque() # ("eval", _EvalRequest) or (command, None) + self.evaluating = None # the _EvalRequest the paused thread is running + self.thread_ident = None # the script thread, for interrupting an expression self.paused = False self.stopped_by_user = False self.state = {"status": "starting"} @@ -119,25 +133,20 @@ def _pause(self, frame, reason): # Accept commands before announcing the pause: a client that reacts to the published # state at once must not be told the program is still running. with self.cond: - self.pending = None + self.requests.clear() + self.thread_ident = threading.get_ident() self.paused = True self._publish(self._snapshot(frame, reason)) - command = None - with self.cond: - try: - while True: - while self.pending is None: - # Short waits, so the async stop exception from the supervisor lands. - self.cond.wait(0.1) - command, argument = self.pending - self.pending = None - if command != "eval": - break - request_id, expression = argument - self.eval_results[request_id] = self._evaluate(frame, expression) - self.cond.notify_all() - finally: + try: + command = self._serve(frame) + finally: + with self.cond: self.paused = False + for kind, request in self.requests: + if kind == "eval": + request.result = _fail("The program resumed before the expression ran.") + self.requests.clear() + self.cond.notify_all() if command == "continue": self.set_continue() @@ -152,6 +161,48 @@ def _pause(self, frame, reason): self.set_quit() self._publish({"status": "running"}) + def _serve(self, frame): + """Runs expressions until a command arrives, and returns that command.""" + while True: + # The lock is never held while an expression runs, so a caller's timeout and a + # stop command still work while one is slow. + try: + with self.cond: + while not self.requests: + # Short waits, so the async stop exception from the supervisor lands. + self.cond.wait(0.1) + kind, request = self.requests.popleft() + if kind != "eval": + return kind + if request.abandoned: + continue + self.evaluating = request + result = self._evaluate(frame, request.expression) + with self.cond: + self.evaluating = None + request.result = result + self.cond.notify_all() + except _EvalCancelled: + # Only ever raised while an expression runs, and caught here even when it + # lands just after the expression returned. + with self.cond: + if self.evaluating is not None: + self.evaluating.result = _fail("The expression was interrupted.") + self.evaluating = None + self.cond.notify_all() + + def _interrupt_evaluation(self): + """Call with self.cond held, while self.evaluating is set.""" + set_async_exc, ctypes = runcode_runner._set_async_exc, runcode_runner.ctypes + if set_async_exc is None or self.thread_ident is None: + return False + ident = ctypes.c_ulong(self.thread_ident) + affected = set_async_exc(ident, ctypes.py_object(_EvalCancelled)) + if affected > 1: + set_async_exc(ident, None) + return False + return affected == 1 + def _evaluate(self, frame, expression): try: code = compile(expression, "", "eval") @@ -199,24 +250,34 @@ def _publish(self, state): # -- commands from other threads ----------------------------------------------------- - def send(self, command, argument=None): + def send(self, command): with self.cond: if not self.paused: return _fail("The program is running, not paused.") - self.pending = (command, argument) + if any(kind != "eval" for kind, _ in self.requests): + return _fail("Another command is already waiting to run.") + self.requests.append((command, None)) + if command == "stop" and self.evaluating is not None: + # Do not make a stop wait for a slow expression. + self._interrupt_evaluation() self.cond.notify_all() return _ok() def evaluate(self, expression): - request_id = object() + request = _EvalRequest(expression) + timeout = _EVAL_TIMEOUT_S with self.cond: if not self.paused: return _fail("Pause the program to evaluate expressions.") - self.pending = ("eval", (request_id, expression)) + self.requests.append(("eval", request)) self.cond.notify_all() - if not self.cond.wait_for(lambda: request_id in self.eval_results, _EVAL_TIMEOUT_S): - return _fail("The expression did not finish within %d seconds." % _EVAL_TIMEOUT_S) - return self.eval_results.pop(request_id) + if self.cond.wait_for(lambda: request.result is not None, timeout): + return request.result + # Late results are dropped with the request; a queued one is skipped. + request.abandoned = True + interrupted = self.evaluating is request and self._interrupt_evaluation() + return _fail("The expression did not finish within %g seconds%s." % ( + timeout, " and was interrupted" if interrupted else "")) def run(self, script_path): # Bdb.runcall starts in single-step mode and would stop on line 1. Start "continue" diff --git a/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt index 20be0c4..6314379 100644 --- a/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt @@ -36,9 +36,11 @@ class McpGitToolsTest { app.getSharedPreferences("mcp-git-test", Context.MODE_PRIVATE)) git.identity = GitIdentity("Bridge Bot", "bot@example.com") val changed = mutableListOf() + var unsaved: String? = null val host = McpToolHost(app.projectStorage, app.appMetaDatabase, app.projectDatabaseManager, app.serviceSupervisor, app.logManager, app.terminalSession, { _, _ -> "" }, - app.projectSettings, { changed += it }, { "" }, { _, _ -> "" }, { git }, app.debugger) + app.projectSettings, { changed += it }, { "" }, { _, _ -> "" }, { git }, app.debugger, + unsavedEditorFile = { unsaved }) val project = app.projectStorage.createProjectFromTemplate("MCP git", ProjectProfile.PYTHON_HTTP) app.appMetaDatabase.insertOrUpdateProject(project) @@ -68,6 +70,15 @@ class McpGitToolsTest { git.setRemote(project, remote.path) val pushed = call("git_push", JSONObject()) assertFalse(text(pushed), pushed.getBoolean("isError")) + + // Unsaved edits in the app's editor would undo a pull once saved: refused. + unsaved = "main.py" + val refused = call("git_pull", JSONObject()) + assertTrue(refused.getBoolean("isError")) + assertTrue(text(refused), text(refused).startsWith("main.py has unsaved changes")) + assertEquals("a refused pull changes nothing", listOf(project.id), changed) + + unsaved = null assertEquals("Already up to date", text(call("git_pull", JSONObject()))) } } diff --git a/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt index 845c8fe..f61b8a9 100644 --- a/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt +++ b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt @@ -61,10 +61,13 @@ class GitViewModelTest { model.updateEditorContent("unsaved change") model.gitPull() assertEquals("Save the file open in the editor first.", model.git.value.error) + // The MCP bridge sees the same unsaved file, so its git_pull refuses too. + await("unsaved file published") { app.unsavedEditorFile == project.id to model.activeTab.value } // A failing operation surfaces its reason and leaves the screen usable. model.saveCurrentFile() await("save") { !model.isDirty.value } + await("unsaved file cleared") { app.unsavedEditorFile == null } model.gitPush() await("push error") { model.git.value.busy == null && model.git.value.error != null } assertTrue(model.git.value.error!!.contains("remote")) diff --git a/tests/test_debugger.py b/tests/test_debugger.py index 85ab418..9e7307e 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -187,6 +187,71 @@ def test_evaluate_in_the_paused_frame(self): self.send("continue") self.finish(thread) + def test_a_slow_expression_times_out_and_is_interrupted(self): + thread = self.start({self.helper: [3]}) + self.listener.paused() + original = debugger._EVAL_TIMEOUT_S + debugger._EVAL_TIMEOUT_S = 0.5 + try: + started = time.monotonic() + reply = json.loads(debugger.evaluate(self.session, "sum(1 for _ in iter(int, 1))")) + self.assertLess(time.monotonic() - started, 3, "the timeout must not wait for the expression") + finally: + debugger._EVAL_TIMEOUT_S = original + self.assertFalse(reply["ok"]) + self.assertIn("interrupted", reply["error"]) + # The paused frame is still usable afterwards. + self.assertEqual("int", json.loads(debugger.evaluate(self.session, "y"))["type"]) + for _ in range(2): + self.listener.paused(after=self.send("continue")) + self.send("continue") + self.assertEqual("completed", self.finish(thread)["outcome"]) + + def test_stop_interrupts_a_running_expression(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + replies = [] + evaluating = threading.Thread(target=lambda: replies.append(json.loads( + debugger.evaluate(self.session, "sum(1 for _ in iter(int, 1))"))), daemon=True) + evaluating.start() + time.sleep(0.3) + started = time.monotonic() + self.send("stop") + self.assertEqual("stopped", self.finish(thread)["outcome"]) + self.assertLess(time.monotonic() - started, 3) + evaluating.join(5) + self.assertFalse(replies[0]["ok"]) + + def test_concurrent_evaluations_each_get_their_own_result(self): + thread = self.start({self.helper: [3]}) + self.listener.paused() + results = {} + + def run(n): + results[n] = json.loads(debugger.evaluate(self.session, "x * 100 + %d" % n)) + + workers = [threading.Thread(target=run, args=(n,)) for n in range(8)] + for worker in workers: + worker.start() + for worker in workers: + worker.join(10) + self.assertEqual({n: str(n) for n in range(8)}, {n: r["value"] for n, r in results.items()}) + self.assertFalse(json.loads(debugger.command(self.session, "jump"))["ok"]) + for _ in range(2): + self.listener.paused(after=self.send("continue")) + self.send("continue") + self.finish(thread) + + def test_a_second_command_is_refused_until_the_first_runs(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + session = debugger._sessions[self.session] + with session.cond: + # Hold the lock so the paused thread cannot take the first command yet. + self.assertTrue(json.loads(session.send("continue"))["ok"]) + self.assertFalse(json.loads(session.send("step"))["ok"]) + self.assertEqual("completed", self.finish(thread)["outcome"]) + def test_a_command_sent_the_moment_a_pause_is_announced_is_accepted(self): # The listener reacts synchronously, the way an eager client would. replies = [] From 804be063a24118df22d8c9bdec6000ff4ed235dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:32:29 +0000 Subject: [PATCH 13/15] Clear the interrupt JSch leaves after a remote forward JSch wakes the thread waiting for a tcpip-forward reply with Thread.interrupt(). When the reply arrives before that thread sleeps, the interrupt is never consumed: the next sleep in awaitUrl throws, or, in the app, the tunnel thread's lock.wait in hold() throws and ends the tunnel loop while the screen still says online. CI hit it as an InterruptedException in SshTunnelConnectorTest. setPortForwardingR now runs inside withoutStrayInterrupt, which clears an interrupt the call left behind and keeps one that was already pending. JSch's only other interrupt() targets its own reader thread. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- .../com/runcode/app/mcp/SshTunnelConnector.kt | 19 +++++++++++++++++-- .../runcode/app/mcp/SshTunnelConnectorTest.kt | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt b/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt index 41fc2bb..c9e98f4 100644 --- a/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt +++ b/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt @@ -36,7 +36,7 @@ class SshTunnelConnector(private val knownHosts: File) : TunnelConnector { // Keepalives notice a dead socket within about 45 seconds. session.setServerAliveInterval(KEEPALIVE_MS) session.setServerAliveCountMax(3) - session.setPortForwardingR(null, provider.remotePort, "127.0.0.1", localPort) + withoutStrayInterrupt { session.setPortForwardingR(null, provider.remotePort, "127.0.0.1", localPort) } val channel = session.openChannel("shell") as ChannelShell channel.setPty(false) @@ -112,8 +112,23 @@ class SshTunnelConnector(private val knownHosts: File) : TunnelConnector { } } - private companion object { + internal companion object { const val KEEPALIVE_MS = 15_000 const val MAX_BANNER = 64 * 1024 + + /** + * JSch wakes the thread waiting for a forward's reply with interrupt(). When the reply + * arrives before that thread sleeps, the interrupt is never consumed and the tunnel + * thread's next wait throws, ending the tunnel loop. Clear only an interrupt that + * [block] left behind; one that was already pending is kept. + */ + fun withoutStrayInterrupt(block: () -> T): T { + val pending = Thread.currentThread().isInterrupted + try { + return block() + } finally { + if (!pending) Thread.interrupted() + } + } } } diff --git a/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt b/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt index 2323710..802da51 100644 --- a/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt @@ -131,6 +131,25 @@ class SshTunnelConnectorTest { } } + @Test fun `an interrupt left behind by JSch is cleared, a pending one is kept`() { + // What JSch does when the forward's reply arrives before the caller sleeps. + assertEquals(7, SshTunnelConnector.withoutStrayInterrupt { Thread.currentThread().interrupt(); 7 }) + assertFalse(Thread.currentThread().isInterrupted) + + Thread.currentThread().interrupt() + SshTunnelConnector.withoutStrayInterrupt { } + assertTrue("an interrupt from before the call belongs to the caller", Thread.interrupted()) + } + + @Test fun `opening never leaves the calling thread interrupted`() { + repeat(10) { attempt -> + val opened = SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + val interrupted = Thread.interrupted() + opened.connection.close() + assertFalse("interrupt flag left set after open #$attempt", interrupted) + } + } + @Test fun `relay stopping is seen as a dead connection`() { val opened = SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000) assertTrue(shellOpened.await(5, TimeUnit.SECONDS)) From c1a65020672907ce6a60e70b5b1df6ac721b1a0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:04:31 +0000 Subject: [PATCH 14/15] Interrupt a debug expression once and keep other activities' marker - An expression gets at most one _EvalCancelled: a timeout and a stop racing for it could send two, and the second could escape _serve's handler and end the run as failed. - When an expression finishes just as it is interrupted, _serve absorbs the pending exception inside its own try, so it never reaches the program. - MainViewModel.onCleared clears the unsaved-file marker only when it is still the one this view model published. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- .../java/com/runcode/app/ui/MainViewModel.kt | 10 ++++- app/src/main/python/runcode_debugger.py | 38 ++++++++++++++++--- .../com/runcode/app/ui/GitViewModelTest.kt | 26 +++++++++++++ tests/test_debugger.py | 21 ++++++++++ 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index f80b571..277aab6 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -78,6 +78,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _isDirty = MutableStateFlow(false) val isDirty: StateFlow = _isDirty.asStateFlow() + /** What this view model last published, so it never clears another activity's marker. */ + private var publishedUnsaved: Pair? = null + // Undo / Redo history for active file private val undoStack = ArrayDeque() private val redoStack = ArrayDeque() @@ -192,12 +195,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { combine(_isDirty, _selectedProject, _activeTab) { dirty, project, tab -> if (dirty && project != null && tab != null) project.id to tab else null - }.collect { app.unsavedEditorFile = it } + }.collect { + publishedUnsaved = it + app.unsavedEditorFile = it + } } } override fun onCleared() { - app.unsavedEditorFile = null + if (publishedUnsaved != null && app.unsavedEditorFile === publishedUnsaved) app.unsavedEditorFile = null } // ---------------------------------------------------------------- terminal diff --git a/app/src/main/python/runcode_debugger.py b/app/src/main/python/runcode_debugger.py index 026b32b..74d8f27 100644 --- a/app/src/main/python/runcode_debugger.py +++ b/app/src/main/python/runcode_debugger.py @@ -68,6 +68,21 @@ def __init__(self, expression): self.expression = expression self.result = None # JSON reply, set once self.abandoned = False # the caller stopped waiting + self.interrupted = False # an _EvalCancelled was sent for it; at most one ever is + + +def _checkpoint(): + pass + + +def _absorb_interrupt(): + """ + Lets an interrupt sent for an expression that finished anyway arrive here, inside _serve's + try. CPython raises a pending async exception at the next Python-level call, so the first + iteration normally takes it; the rest are a bound, not a wait. + """ + for _ in range(1000): + _checkpoint() def _variables(namespace, globals_only=False): @@ -181,10 +196,14 @@ def _serve(self, frame): with self.cond: self.evaluating = None request.result = result + late = request.interrupted self.cond.notify_all() + if late: + # Interrupted as it finished: take the exception here, not in the program. + _absorb_interrupt() except _EvalCancelled: - # Only ever raised while an expression runs, and caught here even when it - # lands just after the expression returned. + # Sent at most once per expression, only while it runs, and absorbed above + # when it finishes first, so it never reaches the program. with self.cond: if self.evaluating is not None: self.evaluating.result = _fail("The expression was interrupted.") @@ -192,7 +211,13 @@ def _serve(self, frame): self.cond.notify_all() def _interrupt_evaluation(self): - """Call with self.cond held, while self.evaluating is set.""" + """ + Call with self.cond held. Interrupts the running expression, at most once: a timeout + and a stop for the same expression must not send two exceptions. + """ + request = self.evaluating + if request is None or request.interrupted: + return False set_async_exc, ctypes = runcode_runner._set_async_exc, runcode_runner.ctypes if set_async_exc is None or self.thread_ident is None: return False @@ -201,7 +226,8 @@ def _interrupt_evaluation(self): if affected > 1: set_async_exc(ident, None) return False - return affected == 1 + request.interrupted = affected == 1 + return request.interrupted def _evaluate(self, frame, expression): try: @@ -257,7 +283,7 @@ def send(self, command): if any(kind != "eval" for kind, _ in self.requests): return _fail("Another command is already waiting to run.") self.requests.append((command, None)) - if command == "stop" and self.evaluating is not None: + if command == "stop": # Do not make a stop wait for a slow expression. self._interrupt_evaluation() self.cond.notify_all() @@ -275,7 +301,7 @@ def evaluate(self, expression): return request.result # Late results are dropped with the request; a queued one is skipped. request.abandoned = True - interrupted = self.evaluating is request and self._interrupt_evaluation() + interrupted = self.evaluating is request and (request.interrupted or self._interrupt_evaluation()) return _fail("The expression did not finish within %g seconds%s." % ( timeout, " and was interrupted" if interrupted else "")) diff --git a/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt index f61b8a9..01f9f9a 100644 --- a/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt +++ b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt @@ -29,6 +29,32 @@ class GitViewModelTest { assertTrue("Timed out waiting for: $what", condition()) } + @Test fun `a view model clears only the unsaved-file marker it published`() { + val app = RuntimeEnvironment.getApplication() as RuncodeApp + val project = app.projectStorage.createProjectFromTemplate("VM marker", ProjectProfile.PYTHON_SCRIPT) + runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } + val model = MainViewModel(app) + await("projects") { model.projects.value.isNotEmpty() } + model.selectProject(project) + await("selection") { model.selectedProject.value?.id == project.id && model.editorContent.value.isNotEmpty() } + val onCleared = MainViewModel::class.java.getDeclaredMethod("onCleared").apply { isAccessible = true } + + model.updateEditorContent("unsaved") + await("published") { app.unsavedEditorFile?.first == project.id } + // Another activity's view model published since: not ours to clear. + val other = "another-project" to "main.py" + app.unsavedEditorFile = other + onCleared.invoke(model) + assertSame(other, app.unsavedEditorFile) + + model.saveCurrentFile() + await("saved") { !model.isDirty.value && app.unsavedEditorFile == null } + model.updateEditorContent("unsaved again") + await("published again") { app.unsavedEditorFile?.first == project.id } + onCleared.invoke(model) + assertNull("its own marker is cleared", app.unsavedEditorFile) + } + @Test fun `initialise, commit, and refuse a pull over unsaved editor changes`() { val python = HostPythonGitBackend.find() assumeTrue("No Python with dulwich", python != null) diff --git a/tests/test_debugger.py b/tests/test_debugger.py index 9e7307e..fa2d5b2 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -222,6 +222,27 @@ def test_stop_interrupts_a_running_expression(self): evaluating.join(5) self.assertFalse(replies[0]["ok"]) + def test_an_expression_is_interrupted_at_most_once(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + session = debugger._sessions[self.session] + replies = [] + evaluating = threading.Thread(target=lambda: replies.append(json.loads( + debugger.evaluate(self.session, "sum(1 for _ in iter(int, 1))"))), daemon=True) + evaluating.start() + deadline = time.monotonic() + 5 + while session.evaluating is None and time.monotonic() < deadline: + time.sleep(0.01) + with session.cond: + # A timeout and a stop racing for the same expression send one exception only; + # a second could land in _serve's handler and make the run end as failed. + self.assertTrue(session._interrupt_evaluation()) + self.assertFalse(session._interrupt_evaluation()) + self.assertTrue(json.loads(session.send("stop"))["ok"]) + self.assertEqual("stopped", self.finish(thread)["outcome"]) + evaluating.join(5) + self.assertFalse(replies[0]["ok"]) + def test_concurrent_evaluations_each_get_their_own_result(self): thread = self.start({self.helper: [3]}) self.listener.paused() From 409872854af6cb08760973217f8bac30da5030cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:20:29 +0000 Subject: [PATCH 15/15] Withdraw only this view model's unsaved-file marker on save A null emission from the collector now goes through the same ownership check as onCleared, so saving in one activity no longer clears a marker another activity's view model published since. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MVXvAMcn9mDa7UTueciB8z --- .../java/com/runcode/app/ui/MainViewModel.kt | 16 ++++++++++++---- .../java/com/runcode/app/ui/GitViewModelTest.kt | 8 ++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index 277aab6..de598e2 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -195,17 +195,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { combine(_isDirty, _selectedProject, _activeTab) { dirty, project, tab -> if (dirty && project != null && tab != null) project.id to tab else null - }.collect { - publishedUnsaved = it - app.unsavedEditorFile = it + }.collect { marker -> + if (marker == null) { + releaseUnsavedMarker() + } else { + publishedUnsaved = marker + app.unsavedEditorFile = marker + } } } } - override fun onCleared() { + /** Withdraws this view model's marker, never one another activity's view model published since. */ + private fun releaseUnsavedMarker() { if (publishedUnsaved != null && app.unsavedEditorFile === publishedUnsaved) app.unsavedEditorFile = null + publishedUnsaved = null } + override fun onCleared() = releaseUnsavedMarker() + // ---------------------------------------------------------------- terminal fun startTerminal() { diff --git a/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt index 01f9f9a..ca9d242 100644 --- a/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt +++ b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt @@ -46,9 +46,13 @@ class GitViewModelTest { app.unsavedEditorFile = other onCleared.invoke(model) assertSame(other, app.unsavedEditorFile) - + // Saving withdraws only this view model's marker, too. model.saveCurrentFile() - await("saved") { !model.isDirty.value && app.unsavedEditorFile == null } + await("saved") { !model.isDirty.value } + shadowOf(Looper.getMainLooper()).idle() // let the collector see the save + assertSame(other, app.unsavedEditorFile) + + app.unsavedEditorFile = null // the other activity saved as well model.updateEditorContent("unsaved again") await("published again") { app.unsavedEditorFile?.first == project.id } onCleared.invoke(model)