From f721a2299c7966551eaa6e498eee25ae7334a0f8 Mon Sep 17 00:00:00 2001 From: hami9 Date: Tue, 29 Sep 2026 13:14:36 +0330 Subject: [PATCH 1/7] add project settings and fix runtime state --- .github/workflows/check.yml | 37 +++++ .gitignore | 4 + README.md | 44 ++++- app/build.gradle.kts | 7 + app/src/main/assets/templates/bot.py | 23 +++ app/src/main/assets/templates/server.py | 28 ++++ .../main/java/com/runcode/app/RuncodeApp.kt | 12 ++ .../runcode/app/database/AppMetaDatabase.kt | 8 +- .../com/runcode/app/logging/LogManager.kt | 2 +- .../java/com/runcode/app/mcp/McpToolHost.kt | 2 + .../main/java/com/runcode/app/mcp/McpTools.kt | 66 +++++++- .../com/runcode/app/runtime/PythonEngine.kt | 28 ++-- .../runcode/app/runtime/RuntimeRegistry.kt | 4 +- .../runcode/app/security/SecretRedactor.kt | 10 ++ .../com/runcode/app/security/SecretStore.kt | 24 ++- .../com/runcode/app/security/SecretVault.kt | 14 ++ .../runcode/app/settings/ProjectSettings.kt | 115 +++++++++++++ .../com/runcode/app/storage/ProjectArchive.kt | 10 +- .../com/runcode/app/storage/ProjectStorage.kt | 54 ++----- .../app/supervisor/ServiceSupervisor.kt | 63 ++++++-- .../com/runcode/app/system/CrashReporter.kt | 3 +- .../java/com/runcode/app/ui/MainViewModel.kt | 104 ++++++++---- .../app/ui/screens/ProjectSettingsDialog.kt | 152 ++++++++++++++++++ .../runcode/app/ui/screens/ProjectsScreen.kt | 18 ++- app/src/main/python/runcode_runner.py | 42 ++++- .../app/security/SecretRedactorTest.kt | 17 ++ .../app/settings/ProjectSettingsTest.kt | 148 +++++++++++++++++ .../runcode/app/storage/ProjectStorageTest.kt | 103 ++++++++++++ .../app/supervisor/ServiceSupervisorTest.kt | 105 ++++++++++++ .../com/runcode/app/ui/EditorSessionTest.kt | 71 ++++++++ tests/test_runtime.py | 109 +++++++++++++ 31 files changed, 1313 insertions(+), 114 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 app/src/main/assets/templates/bot.py create mode 100644 app/src/main/assets/templates/server.py create mode 100644 app/src/main/java/com/runcode/app/security/SecretVault.kt create mode 100644 app/src/main/java/com/runcode/app/settings/ProjectSettings.kt create mode 100644 app/src/main/java/com/runcode/app/ui/screens/ProjectSettingsDialog.kt create mode 100644 app/src/test/java/com/runcode/app/security/SecretRedactorTest.kt create mode 100644 app/src/test/java/com/runcode/app/settings/ProjectSettingsTest.kt create mode 100644 app/src/test/java/com/runcode/app/storage/ProjectStorageTest.kt create mode 100644 app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt create mode 100644 app/src/test/java/com/runcode/app/ui/EditorSessionTest.kt create mode 100644 tests/test_runtime.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..5c254b4 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,37 @@ +name: Check + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: android-actions/setup-android@v3 + - uses: gradle/actions/setup-gradle@v4 + - name: Check Python runtime + run: python -m unittest discover -s tests -v + - name: Build and test + run: | + chmod +x gradlew + RUNCODE_BUILD_PYTHON="$(command -v python)" ./gradlew testDebugUnitTest lintDebug assembleDebug --console=plain + - name: Save reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: check-reports + path: | + app/build/reports/ + app/build/test-results/ diff --git a/.gitignore b/.gitignore index b3a4fe3..46aba9e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ Thumbs.db # Release signing material keystore.properties + +# Python checks +__pycache__/ +*.pyc diff --git a/README.md b/README.md index b805b19..f9c567b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ The APK is large — CPython plus the bundled wheels, for `arm64-v8a` and `x86_6 Python scripts run as `__main__` with the project directory as the working directory, so relative paths like `data/tasks.sqlite` resolve the way they would on a desktop. +Only one Python workload (service or MCP snippet) runs at a time. CPython shares environment, +working directory and imports across threads; concurrent scripts previously mixed project data +and secrets. Static web services can still run alongside Python. Projects and MCP clients are +trusted app-level code, not isolated tenants. + ## Files **Editor → folder icon** opens the project's file manager. It shows the whole project, not @@ -63,6 +68,25 @@ 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. +## Project settings + +Open **Projects → settings icon** to edit the port, restart policy, start on boot, CPU/heap/idle +limits and environment variables. Stop the service before saving. Use **Secret** for passwords +and bot tokens: values go to the Android Keystore-backed vault, while project metadata keeps +only a reference. Leave an existing secret blank to keep it, or enter a replacement. + +For a new Telegram bot, set the secret `TELEGRAM_BOT_TOKEN`, save, run, and send `/start` to the +bot. New Python HTTP projects serve `/`, `/status` and `/api` on the configured port. Existing +project files are not rewritten when the app updates. + +CPU limits measure the service thread. The Java heap limit measures the whole app, not a +single Python service's allocations. Idle limits apply to the static web server. Python can +remain **STOPPING** while blocked in native code; another instance cannot start until it exits. + +Imported secret variables need fresh values on this device, even when reimporting an export. +User code can write sensitive data to files; mark variables correctly and inspect files before +sharing an archive. The vault cannot sanitize arbitrary project files. + ## MCP bridge **System → MCP Bridge → Start bridge.** The endpoint is JSON-RPC 2.0 over HTTP: @@ -73,13 +97,18 @@ Authorization: Bearer Content-Type: application/json ``` -Sixteen tools: `list_projects`, `get_project`, `list_files`, `read_file`, `write_file`, +Seventeen 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`. 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. +`update_project_settings` accepts optional `port`, `restart_policy`, `start_on_boot`, +`max_cpu_percent`, `max_heap_mb`, `idle_timeout_minutes`, and an `environment` object of plain +string values. Providing `environment` replaces plain variables and preserves existing secrets. +Secret variables can only be edited in the app's settings form. + ### Connecting from a computer The bridge binds loopback by default. Forward the port over adb: @@ -106,6 +135,8 @@ The bridge exposes a shell, arbitrary Python and read/write file access on the d anyone on the same Wi-Fi with the token able to run commands on the device. Prefer `adb forward` or a tunnel. - 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. - `GET /health` is the only unauthenticated route and reports nothing about the device. - The bridge lives in the app process. It holds a foreground service while online, but it does not survive the process being killed — restart it from the System screen. @@ -126,6 +157,17 @@ app/src/main/python/ runcode_runner.py stdout/stderr bridge, cooperative stop, snippet runner ``` +## Tests + +```bash +python -m unittest discover -s tests -v +./gradlew testDebugUnitTest lintDebug assembleDebug +``` + +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 +still needed for Keystore, Compose interaction and foreground-service behavior on API 36. + ## License runcode itself is MIT — see [LICENSE](LICENSE). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4061c0a..060d6f9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -84,6 +84,10 @@ android { buildConfig = true } + testOptions { + unitTests.isIncludeAndroidResources = true + } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -131,4 +135,7 @@ dependencies { implementation("androidx.compose.material:material-icons-extended") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.16.1") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/app/src/main/assets/templates/bot.py b/app/src/main/assets/templates/bot.py new file mode 100644 index 0000000..ec094d6 --- /dev/null +++ b/app/src/main/assets/templates/bot.py @@ -0,0 +1,23 @@ +"""Set TELEGRAM_BOT_TOKEN as a secret in Project settings, then run this bot.""" +import asyncio +import os + +from telegram import Update +from telegram.ext import Application, CommandHandler, ContextTypes + + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + if update.effective_message: + await update.effective_message.reply_text("Hello from runcode on Android!") + + +if __name__ == "__main__": + token = os.environ.get("TELEGRAM_BOT_TOKEN", "") + if not token or token.startswith("${SEC_"): + raise SystemExit("Set TELEGRAM_BOT_TOKEN in Project settings before running.") + # Services run on worker threads, which do not have a default asyncio loop or signals. + asyncio.set_event_loop(asyncio.new_event_loop()) + application = Application.builder().token(token).build() + application.add_handler(CommandHandler("start", start)) + print("Connecting to Telegram. Send /start to your bot.") + application.run_polling(stop_signals=None) diff --git a/app/src/main/assets/templates/server.py b/app/src/main/assets/templates/server.py new file mode 100644 index 0000000..661e82b --- /dev/null +++ b/app/src/main/assets/templates/server.py @@ -0,0 +1,28 @@ +"""A local JSON API. Port and bind address come from Project settings.""" +import json +import os +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path not in ("/", "/status", "/api"): + self.send_error(404) + return + payload = json.dumps({"service": "runcode", "status": "ok", "path": self.path}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +if __name__ == "__main__": + address = os.environ.get("RUNCODE_BIND_ADDRESS", "127.0.0.1") + port = int(os.environ.get("RUNCODE_PORT", "8080")) + with HTTPServer((address, port), Handler) as server: + # Bound waits let a pending stop reach the worker even when there is no traffic. + server.timeout = 0.5 + print(f"HTTP API listening on {address}:{port}") + while True: + server.handle_request() diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index 8f9d3e9..1698c2a 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -15,6 +15,7 @@ import com.runcode.app.runtime.PythonEngine import com.runcode.app.runtime.RuntimeRegistry import com.runcode.app.runtime.StaticWebEngine import com.runcode.app.security.SecretStore +import com.runcode.app.settings.ProjectSettingsManager import com.runcode.app.storage.ProjectArchive import com.runcode.app.storage.ProjectStorage import com.runcode.app.supervisor.ServiceSupervisor @@ -60,6 +61,8 @@ class RuncodeApp : Application() { private set lateinit var projectArchive: ProjectArchive private set + lateinit var projectSettings: ProjectSettingsManager + private set private val _projectChanges = MutableSharedFlow(extraBufferCapacity = 64) @@ -81,6 +84,7 @@ class RuncodeApp : Application() { projectStorage = ProjectStorage(this) portManager = PortManager() secretStore = SecretStore(this) + secretStore.getAllSecretKeys().forEach { secretStore.getSecret(it) } logManager = LogManager(this) appMetaDatabase = AppMetaDatabase(this) projectDatabaseManager = ProjectDatabaseManager() @@ -104,6 +108,13 @@ class RuncodeApp : Application() { portManager = portManager ) + projectSettings = ProjectSettingsManager( + secretStore, + appMetaDatabase::getProjectById, + { appMetaDatabase.insertOrUpdateProject(it) }, + serviceSupervisor::hasActiveWork + ) + mcpServer = McpServer( tools = McpToolHost( projectStorage = projectStorage, @@ -113,6 +124,7 @@ class RuncodeApp : Application() { logManager = logManager, terminalSession = terminalSession, runPython = ::runPythonSnippet, + projectSettings = projectSettings, onProjectChanged = { projectId -> _projectChanges.tryEmit(projectId) } ), onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } diff --git a/app/src/main/java/com/runcode/app/database/AppMetaDatabase.kt b/app/src/main/java/com/runcode/app/database/AppMetaDatabase.kt index 8b0f529..fabeeb0 100644 --- a/app/src/main/java/com/runcode/app/database/AppMetaDatabase.kt +++ b/app/src/main/java/com/runcode/app/database/AppMetaDatabase.kt @@ -94,7 +94,9 @@ class AppMetaDatabase(context: Context) : SQLiteOpenHelper(context, "runcode_met put("created_at", project.createdAt) put("updated_at", System.currentTimeMillis()) } - writableDatabase.insertWithOnConflict("projects", null, cv, SQLiteDatabase.CONFLICT_REPLACE) + check(writableDatabase.insertWithOnConflict("projects", null, cv, SQLiteDatabase.CONFLICT_REPLACE) != -1L) { + "Could not save project settings" + } } suspend fun getAllProjects(): List = withContext(Dispatchers.IO) { @@ -166,6 +168,10 @@ class AppMetaDatabase(context: Context) : SQLiteOpenHelper(context, "runcode_met private fun encodeEnvironment(environment: List): String { val array = JSONArray() environment.forEach { variable -> + require(!variable.isSecret || variable.value.isEmpty() || + com.runcode.app.security.SecretReferences.key(variable.value) != null) { + "Secret variables must use a vault reference" + } array.put( JSONObject() .put("key", variable.key) diff --git a/app/src/main/java/com/runcode/app/logging/LogManager.kt b/app/src/main/java/com/runcode/app/logging/LogManager.kt index ed350da..ca1d40d 100644 --- a/app/src/main/java/com/runcode/app/logging/LogManager.kt +++ b/app/src/main/java/com/runcode/app/logging/LogManager.kt @@ -105,7 +105,7 @@ class LogManager(private val context: Context) { val list = if (projectId.isNullOrEmpty()) buffer.toList() else buffer.filter { it.projectId == projectId } return buildString { list.forEach { event -> - val timeStr = timeFormat.format(Date(event.timestamp)) + val timeStr = formatTime(event.timestamp) append("[$timeStr] [${event.level}] [${event.serviceName}] ${event.message}\n") } } 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 e36b4cc..8e66c3f 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.logging.LogManager import com.runcode.app.storage.ProjectStorage +import com.runcode.app.settings.ProjectSettingsManager import com.runcode.app.supervisor.ServiceSupervisor import com.runcode.app.terminal.TerminalSession import java.io.File @@ -21,6 +22,7 @@ class McpToolHost( val logManager: LogManager, val terminalSession: TerminalSession, 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 ) { 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 7294feb..d0195e4 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,10 @@ package com.runcode.app.mcp import com.runcode.app.domain.models.Project +import com.runcode.app.domain.models.RestartPolicy +import com.runcode.app.security.SecretRedactor +import com.runcode.app.settings.EnvironmentEdit +import com.runcode.app.settings.ProjectSettings import com.runcode.app.storage.EntryPointEffect import com.runcode.app.storage.EntryPoints import com.runcode.app.storage.FileKind @@ -23,6 +27,22 @@ object McpTools { fun descriptors(): JSONArray { val tools = JSONArray() + tools.put(tool( + "update_project_settings", + "Update a stopped project's settings. environment replaces only plain variables; secrets are preserved and managed in the app.", + properties( + "project_id" to stringProp("Project id"), + "port" to intProp("Port from 1024 to 65535"), + "restart_policy" to stringProp("NEVER, ON_FAILURE or ALWAYS"), + "start_on_boot" to JSONObject().put("type", "boolean"), + "max_cpu_percent" to intProp("0 (unlimited) to 100"), + "max_heap_mb" to intProp("Process-wide Java heap limit; 0 means unlimited"), + "idle_timeout_minutes" to intProp("Static web idle timeout; 0 means unlimited"), + "environment" to JSONObject().put("type", "object") + .put("additionalProperties", JSONObject().put("type", "string")) + ), required = listOf("project_id") + )) + tools.put( tool( "list_projects", @@ -212,6 +232,7 @@ object McpTools { when (name) { "list_projects" -> listProjects(host) "get_project" -> getProject(host, args.getString("project_id")) + "update_project_settings" -> updateSettings(host, args) "list_files" -> listFiles(host, args.getString("project_id")) "read_file" -> readFile(host, args.getString("project_id"), args.getString("path")) "write_file" -> writeFile( @@ -250,6 +271,40 @@ object McpTools { // ---------------------------------------------------------------- handlers + private fun updateSettings(host: McpToolHost, args: JSONObject): JSONObject = runBlocking { + val projectId = args.getString("project_id") + val project = host.projectOrNull(projectId) ?: return@runBlocking errorResult("Project not found") + val current = ProjectSettings.from(project) + val environment = if (args.has("environment")) { + val values = args.getJSONObject("environment") + current.environment.filter { it.isSecret } + values.keys().asSequence().map { key -> + require(values.get(key) is String) { "Environment values must be strings" } + EnvironmentEdit(key, values.getString(key), false) + }.toList() + } else current.environment + fun intValue(key: String, fallback: Int): Int { + if (!args.has(key)) return fallback + val value = args.get(key) + require(value is Number && value.toDouble() == value.toInt().toDouble()) { "$key must be an integer" } + return value.toInt() + } + if (args.has("start_on_boot")) require(args.get("start_on_boot") is Boolean) { "start_on_boot must be a boolean" } + val policy = if (args.has("restart_policy")) { + RestartPolicy.entries.find { it.name == args.getString("restart_policy") } + ?: throw IllegalArgumentException("restart_policy must be NEVER, ON_FAILURE or ALWAYS") + } else current.restartPolicy + host.projectSettings.update(projectId, current.copy( + port = intValue("port", current.port), restartPolicy = policy, + startOnBoot = if (args.has("start_on_boot")) args.getBoolean("start_on_boot") else current.startOnBoot, + maxCpuPercent = intValue("max_cpu_percent", current.maxCpuPercent), + maxHeapMb = intValue("max_heap_mb", current.maxHeapMb), + idleTimeoutMinutes = intValue("idle_timeout_minutes", current.idleTimeoutMinutes), + environment = environment + ), allowSecrets = false) + host.onProjectChanged(projectId) + textResult("Project settings saved") + } + private fun listProjects(host: McpToolHost): JSONObject = runBlocking { val instances = host.serviceSupervisor.instances.value val array = JSONArray() @@ -293,6 +348,9 @@ object McpTools { .put("start_on_boot", project.startOnBoot) .put("port", project.network.port) .put("allow_lan", project.network.allowLan) + .put("max_cpu_percent", project.maxCpuPercent) + .put("max_heap_mb", project.maxHeapMb) + .put("idle_timeout_minutes", project.idleTimeoutMinutes) .put("environment", env) .put("state", instance?.state?.name ?: "STOPPED") .put("uptime_seconds", instance?.uptimeSeconds ?: 0) @@ -411,8 +469,8 @@ object McpTools { private fun stopService(host: McpToolHost, projectId: String): JSONObject = runBlocking { host.projectOrNull(projectId) ?: return@runBlocking errorResult("No project with id $projectId") - host.serviceSupervisor.stopProject(projectId) - textResult("Stopped service for $projectId") + if (host.serviceSupervisor.stopProject(projectId)) textResult("Stopped service for $projectId") + else errorResult("The runtime is still stopping; wait for its blocking call to return") } private fun serviceStatus(host: McpToolHost): JSONObject { @@ -492,12 +550,12 @@ object McpTools { private fun textResult(text: String): JSONObject = JSONObject() - .put("content", JSONArray().put(JSONObject().put("type", "text").put("text", text))) + .put("content", JSONArray().put(JSONObject().put("type", "text").put("text", SecretRedactor.redact(text)))) .put("isError", false) private fun errorResult(message: String): JSONObject = JSONObject() - .put("content", JSONArray().put(JSONObject().put("type", "text").put("text", message))) + .put("content", JSONArray().put(JSONObject().put("type", "text").put("text", SecretRedactor.redact(message)))) .put("isError", true) private fun tool( 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 a36547b..70d4621 100644 --- a/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt +++ b/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt @@ -19,7 +19,8 @@ import java.util.concurrent.atomic.AtomicReference * Runs project scripts on the CPython interpreter that Chaquopy embeds in the APK. * * Each service gets its own JVM thread; Chaquopy attaches it to the interpreter, so several - * scripts can be in flight at once under the GIL. Output is streamed line by line from the + * scripts use the same interpreter. The bridge admits one Python workload at a time because + * cwd, environment and imports are process-wide. Output is streamed line by line from the * Python side through [OutputSink] instead of being collected at the end, so a long-running * bot shows up in the console while it runs. */ @@ -71,10 +72,16 @@ class PythonEngine( if (!Python.isStarted()) { return@withContext PrepareResult.Failure("Embedded Python interpreter is not available on this device") } - val entry = File(project.projectRoot, "source/${project.entryPoint}") - if (!entry.exists()) { + val source = File(project.projectRoot, "source").canonicalFile + val entry = File(source, project.entryPoint).canonicalFile + if (!entry.path.startsWith(source.path + File.separator) || !entry.isFile) { return@withContext PrepareResult.Failure("Entrypoint script '${project.entryPoint}' does not exist") } + try { + secretStore.resolveEnvironment(project.environment) + } catch (e: IllegalArgumentException) { + return@withContext PrepareResult.Failure(e.message ?: "Configure the project's secrets first") + } PrepareResult.Success } @@ -85,7 +92,11 @@ class PythonEngine( sink.onEvent(LogLevel.SYSTEM, "Initializing Python ${pythonVersion()} for '${project.name}'...") - val envPairs = project.environment.map { "${it.key}=${secretStore.resolveValue(it.value)}" } + val environment = secretStore.resolveEnvironment(project.environment) + mapOf( + "RUNCODE_PORT" to project.network.port.toString(), + "RUNCODE_BIND_ADDRESS" to if (project.network.allowLan) "0.0.0.0" else project.network.bindAddress + ) + val envPairs = environment.map { "${it.key}=${it.value}" } if (envPairs.isNotEmpty()) { sink.onEvent(LogLevel.SYSTEM, "Loaded ${envPairs.size} environment variable(s)") } @@ -169,10 +180,9 @@ class PythonEngine( } catch (e: Exception) { sink.onEvent(LogLevel.WARN, "Could not signal the interpreter: ${e.message}") } - // The trace hook raises at the script's next executed line, so give it a - // moment before declaring the service gone. + // An async exception cannot interrupt a blocked native call. Keep ownership + // until the worker actually exits, so restart cannot duplicate the service. worker.join(STOP_GRACE_MS) - alive.set(false) } override suspend fun forceKill() = stop() @@ -192,12 +202,12 @@ class PythonEngine( override suspend fun requestStop(handle: RuntimeHandle): StopResult { handle.stop() - return StopResult.Stopped + return if (handle.isAlive) StopResult.Failed("Python is still stopping; waiting for a blocking call to return") else StopResult.Stopped } override suspend fun forceStop(handle: RuntimeHandle): StopResult { handle.forceKill() - return StopResult.Stopped + return if (handle.isAlive) StopResult.Failed("Python is still stopping") else StopResult.Stopped } override suspend fun health(handle: RuntimeHandle): RuntimeHealth { diff --git a/app/src/main/java/com/runcode/app/runtime/RuntimeRegistry.kt b/app/src/main/java/com/runcode/app/runtime/RuntimeRegistry.kt index 033b932..5bc4311 100644 --- a/app/src/main/java/com/runcode/app/runtime/RuntimeRegistry.kt +++ b/app/src/main/java/com/runcode/app/runtime/RuntimeRegistry.kt @@ -3,8 +3,8 @@ package com.runcode.app.runtime import com.runcode.app.domain.models.ProjectProfile class RuntimeRegistry( - val pythonEngine: PythonEngine, - val staticWebEngine: StaticWebEngine + val pythonEngine: RuntimeEngine, + val staticWebEngine: RuntimeEngine ) { private val engines = listOf(pythonEngine, staticWebEngine) diff --git a/app/src/main/java/com/runcode/app/security/SecretRedactor.kt b/app/src/main/java/com/runcode/app/security/SecretRedactor.kt index b45274d..cf54acf 100644 --- a/app/src/main/java/com/runcode/app/security/SecretRedactor.kt +++ b/app/src/main/java/com/runcode/app/security/SecretRedactor.kt @@ -5,6 +5,12 @@ import java.util.regex.Pattern object SecretRedactor { + private val knownSecrets = java.util.concurrent.CopyOnWriteArraySet() + + fun register(value: String) { + if (value.isNotEmpty()) knownSecrets.add(value) + } + // Common sensitive patterns: Telegram bot tokens (e.g. 123456789:ABCdef-GHIjkl...), API keys, JWT, passwords private val TELEGRAM_BOT_TOKEN = Pattern.compile("\\b(\\d{8,10}:[A-Za-z0-9_-]{35})\\b") private val GENERIC_API_KEY = Pattern.compile("(?i)\\b(api[_-]?key|secret|token|password|auth)['\"]?\\s*[:=]\\s*['\"]?([A-Za-z0-9_\\-.~!@#\$%^&*+=]{8,})['\"]?") @@ -14,6 +20,10 @@ object SecretRedactor { fun redact(input: String): String { if (input.isEmpty()) return input var sanitized = input + // Pattern matching alone misses arbitrary user passwords printed without a label. + knownSecrets.sortedByDescending { it.length }.forEach { + sanitized = sanitized.replace(it, "[REDACTED]") + } // Mask Telegram Bot tokens: keep first 4 digits, mask rest sanitized = replaceAll(TELEGRAM_BOT_TOKEN, sanitized) { matcher -> diff --git a/app/src/main/java/com/runcode/app/security/SecretStore.kt b/app/src/main/java/com/runcode/app/security/SecretStore.kt index e0c0457..1bb7667 100644 --- a/app/src/main/java/com/runcode/app/security/SecretStore.kt +++ b/app/src/main/java/com/runcode/app/security/SecretStore.kt @@ -18,25 +18,26 @@ import javax.crypto.spec.GCMParameterSpec * re-derived from anything shipped in the APK. Each value gets a fresh random IV, and a value * that cannot be encrypted is refused rather than written out in the clear. */ -class SecretStore(context: Context) { +class SecretStore(context: Context) : SecretVault { private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val secretKey: SecretKey? by lazy { loadOrCreateKey() } /** Returns true when the value was stored encrypted. Nothing is written otherwise. */ - fun setSecret(key: String, plainText: String): Boolean { + override fun setSecret(key: String, plainText: String): Boolean { val encoded = encrypt(plainText) ?: return false - prefs.edit().putString(key, encoded).apply() + if (!prefs.edit().putString(key, encoded).commit()) return false + SecretRedactor.register(plainText) return true } - fun getSecret(key: String): String? { + override fun getSecret(key: String): String? { val stored = prefs.getString(key, null) ?: return null - return decrypt(stored) + return decrypt(stored)?.also { SecretRedactor.register(it) } } - fun removeSecret(key: String) { + override fun removeSecret(key: String) { prefs.edit().remove(key).apply() } @@ -56,6 +57,17 @@ class SecretStore(context: Context) { return raw } + fun resolveEnvironment(environment: List): Map = + environment.associate { variable -> + val value = if (variable.isSecret) { + val key = SecretReferences.key(variable.value) + val secret = key?.let { getSecret(it) } + require(!secret.isNullOrEmpty()) { "Set the secret '${variable.key}' in Project settings before running" } + secret + } else variable.value + variable.key to value + } + private fun encrypt(plainText: String): String? { val key = secretKey ?: return null return try { diff --git a/app/src/main/java/com/runcode/app/security/SecretVault.kt b/app/src/main/java/com/runcode/app/security/SecretVault.kt new file mode 100644 index 0000000..d62e2f9 --- /dev/null +++ b/app/src/main/java/com/runcode/app/security/SecretVault.kt @@ -0,0 +1,14 @@ +package com.runcode.app.security + +interface SecretVault { + fun setSecret(key: String, plainText: String): Boolean + fun getSecret(key: String): String? + fun removeSecret(key: String) +} + +object SecretReferences { + private val pattern = Regex("""\$\{SEC_([A-Za-z0-9_]+)\}""") + + fun key(value: String): String? = pattern.matchEntire(value)?.groupValues?.get(1) + fun placeholder(key: String): String = "\${SEC_$key}" +} diff --git a/app/src/main/java/com/runcode/app/settings/ProjectSettings.kt b/app/src/main/java/com/runcode/app/settings/ProjectSettings.kt new file mode 100644 index 0000000..6c8bc3b --- /dev/null +++ b/app/src/main/java/com/runcode/app/settings/ProjectSettings.kt @@ -0,0 +1,115 @@ +package com.runcode.app.settings + +import com.runcode.app.domain.models.EnvironmentVariable +import com.runcode.app.domain.models.Project +import com.runcode.app.domain.models.RestartPolicy +import com.runcode.app.security.SecretReferences +import com.runcode.app.security.SecretVault +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import java.util.UUID + +/** A null secret value keeps the existing reference; plaintext never enters Project. */ +data class EnvironmentEdit(val key: String, val value: String?, val isSecret: Boolean) + +data class ProjectSettings( + val port: Int, + val restartPolicy: RestartPolicy, + val startOnBoot: Boolean, + val maxCpuPercent: Int, + val maxHeapMb: Int, + val idleTimeoutMinutes: Int, + val environment: List +) { + fun validate() { + require(port in 1024..65535) { "Port must be between 1024 and 65535" } + require(maxCpuPercent in 0..100) { "CPU limit must be between 0 and 100" } + require(maxHeapMb >= 0) { "Heap limit cannot be negative" } + require(idleTimeoutMinutes in 0..35_791_394) { "Idle timeout is out of range" } + require(environment.map { it.key }.distinct().size == environment.size) { "Environment names must be unique" } + environment.forEach { + require(Regex("[A-Za-z_][A-Za-z0-9_]*").matches(it.key)) { "Use letters, digits and underscores for environment names" } + require(it.key !in setOf("RUNCODE_PORT", "RUNCODE_BIND_ADDRESS")) { "RUNCODE_PORT and RUNCODE_BIND_ADDRESS are managed by the runtime" } + require(it.value?.contains('\u0000') != true) { "Environment values cannot contain NUL characters" } + if (!it.isSecret) { + require(it.value != null && SecretReferences.key(it.value) == null) { "Plain variables cannot reference the secret vault" } + } + } + } + + companion object { + fun from(project: Project) = ProjectSettings( + project.network.port, project.restartPolicy, project.startOnBoot, + project.maxCpuPercent, project.maxHeapMb, project.idleTimeoutMinutes, + project.environment.map { EnvironmentEdit(it.key, if (it.isSecret) null else it.value, it.isSecret) } + ) + } +} + +/** Writes new vault entries first, then commits references. A failed save keeps old secrets. */ +class ProjectSettingsManager( + private val vault: SecretVault, + private val findProject: suspend (String) -> Project?, + private val saveProject: suspend (Project) -> Unit, + private val isActive: (String) -> Boolean +) { + private val mutex = Mutex() + + suspend fun update(projectId: String, settings: ProjectSettings, allowSecrets: Boolean = true): Project = mutex.withLock { + settings.validate() + val current = requireNotNull(findProject(projectId)) { "Project no longer exists" } + check(!isActive(projectId)) { "Stop the service before changing its settings" } + if (!allowSecrets) { + val oldSecrets = current.environment.filter { it.isSecret }.map { it.key }.toSet() + val keptSecrets = settings.environment.filter { it.isSecret && it.value == null }.map { it.key }.toSet() + require(oldSecrets == keptSecrets && settings.environment.none { it.isSecret && it.value != null }) { + "Manage secret variables in the app's project settings" + } + } + // Once vault writes begin, finish the metadata commit or rollback even if the + // screen closes. Cancellation after a DB commit must not delete referenced keys. + withContext(NonCancellable) { + val newKeys = mutableListOf() + val prefix = "PROJECT_${projectId.replace('-', '_')}_" + val updated = try { + val environment = settings.environment.map { edit -> + val value = if (edit.isSecret) { + if (edit.value == null) { + val old = current.environment.find { it.key == edit.key && it.isSecret } + require(old != null) { "Enter a value for the new secret" } + require(old.value.isEmpty() || SecretReferences.key(old.value) != null) { "Replace this legacy secret to save it securely" } + old.value + } else { + require(edit.value.isNotEmpty()) { "Secret values cannot be empty" } + val key = prefix + UUID.randomUUID().toString().replace("-", "") + check(vault.setSecret(key, edit.value)) { "Could not save the secret securely. Settings were not changed." } + newKeys.add(key) + SecretReferences.placeholder(key) + } + } else requireNotNull(edit.value) + EnvironmentVariable(edit.key, value, edit.isSecret) + } + current.copy( + network = current.network.copy(port = settings.port), + restartPolicy = settings.restartPolicy, + startOnBoot = settings.startOnBoot, + maxCpuPercent = settings.maxCpuPercent, + maxHeapMb = settings.maxHeapMb, + idleTimeoutMinutes = settings.idleTimeoutMinutes, + environment = environment, + updatedAt = System.currentTimeMillis() + ).also { saveProject(it) } + } catch (error: Exception) { + newKeys.forEach { vault.removeSecret(it) } + throw error + } + val kept = updated.environment.mapNotNull { SecretReferences.key(it.value) }.toSet() + current.environment.mapNotNull { SecretReferences.key(it.value) } + .filter { it.startsWith(prefix) && it !in kept } + .forEach { vault.removeSecret(it) } + updated + } + } +} 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 1aa3d0f..d52e689 100644 --- a/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt +++ b/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt @@ -188,10 +188,18 @@ class ProjectArchive( (0 until array.length()).mapNotNull { index -> val obj = array.optJSONObject(index) ?: return@mapNotNull null val key = obj.optString("key").takeIf { it.isNotBlank() } ?: return@mapNotNull null - EnvironmentVariable(key, obj.optString("value"), obj.optBoolean("is_secret", false)) + val secret = obj.optBoolean("is_secret", false) + // An imported project must never inherit another project's vault access, + // including an exported reference brought back onto the same device. + EnvironmentVariable(key, if (secret) "" else obj.optString("value"), secret) } private fun detectEntryPoint(sourceDir: File, preferred: String?): String { + if (!preferred.isNullOrBlank()) { + val root = sourceDir.canonicalFile + val candidate = File(root, preferred).canonicalFile + require(candidate.path.startsWith(root.path + File.separator)) { "Entry point must stay inside source/" } + } preferred?.takeIf { it.isNotBlank() && File(sourceDir, it).isFile }?.let { return it } COMMON_ENTRY_POINTS.firstOrNull { File(sourceDir, it).isFile }?.let { return it } val topLevel = sourceDir.listFiles().orEmpty().filter { it.isFile }.sortedBy { it.name.lowercase() } diff --git a/app/src/main/java/com/runcode/app/storage/ProjectStorage.kt b/app/src/main/java/com/runcode/app/storage/ProjectStorage.kt index 33c8ad5..7d6541d 100644 --- a/app/src/main/java/com/runcode/app/storage/ProjectStorage.kt +++ b/app/src/main/java/com/runcode/app/storage/ProjectStorage.kt @@ -89,52 +89,13 @@ print("Batch calculations completed successfully!") ProjectProfile.TELEGRAM_BOT -> { entryPoint = "bot.py" envList.add(EnvironmentVariable("TELEGRAM_BOT_TOKEN", "\${SEC_BOT_TOKEN}", isSecret = true)) - val botCode = """ -# Telegram Bot Profile for runcode -import os -import time -import sys - -token = os.environ.get("TELEGRAM_BOT_TOKEN", "") -print("Initializing Telegram Bot Service...") -print(f"Configured Bot Token: {token}") - -if not token or token == "${'$'}{SEC_BOT_TOKEN}": - print("[WARN] No real bot token provided. Operating in Bot Emulation Mode.") - print("Add your Telegram Bot Token in Project Settings -> Environment Secrets.") - -print("Starting long-polling event loop...") - -# Emulated or real webhook / polling event loop -step = 0 -while True: - step += 1 - if step % 5 == 0: - print(f"[Bot Polling] Listening for updates... (heartbeat #{step})") - time.sleep(2) -""".trimIndent() + val botCode = context.assets.open("templates/bot.py").bufferedReader().use { it.readText() } File(sourceDir, entryPoint).writeText(botCode) } ProjectProfile.PYTHON_HTTP -> { entryPoint = "server.py" - val serverCode = """ -# Lightweight Python HTTP API for runcode -import json -import time - -port = $customPort -print(f"Starting lightweight HTTP service on port {port}...") -print("Endpoints available:") -print(" GET / - Welcome & service info") -print(" GET /status - Runtime health stats") -print(" GET /api - JSON API response") - -# Simulated / embedded HTTP listener loop -while True: - time.sleep(5) - print(f"[HTTP Monitor] Service listening on 127.0.0.1:{port} - Active") -""".trimIndent() + val serverCode = context.assets.open("templates/server.py").bufferedReader().use { it.readText() } File(sourceDir, entryPoint).writeText(serverCode) } @@ -369,9 +330,14 @@ print("Database operations complete.") fos.flush() fos.fd.sync() } - if (target.exists()) target.delete() - if (!tempFile.renameTo(target)) { - tempFile.copyTo(target, overwrite = true) + try { + try { + java.nio.file.Files.move(tempFile.toPath(), target.toPath(), + java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } catch (_: java.nio.file.AtomicMoveNotSupportedException) { + java.nio.file.Files.move(tempFile.toPath(), target.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } finally { tempFile.delete() } } 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 209033e..9e06630 100644 --- a/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt +++ b/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -34,9 +35,9 @@ class ServiceSupervisor( private val context: Context, private val runtimeRegistry: RuntimeRegistry, private val logManager: LogManager, - private val portManager: PortManager + private val portManager: PortManager, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) ) { - private val scope = CoroutineScope(Dispatchers.Default) private val mutex = Mutex() private val activeHandles = ConcurrentHashMap() // projectId -> handle @@ -56,6 +57,16 @@ class ServiceSupervisor( } suspend fun startProject(project: Project): Boolean = mutex.withLock { + if (activeHandles[project.id]?.isAlive == true) return false + supervisorJobs.remove(project.id)?.cancel() + startProjectLocked(project, 0) + } + + fun hasActiveWork(projectId: String): Boolean = + activeHandles[projectId]?.isAlive == true || supervisorJobs[projectId]?.isActive == true || + _instances.value[projectId]?.state in setOf(ServiceState.PREPARING, ServiceState.STARTING, ServiceState.RESTARTING, ServiceState.STOPPING) + + private suspend fun startProjectLocked(project: Project, restartCount: Int): Boolean { val current = _instances.value[project.id] if (current?.isRunning == true) { logManager.log(project.id, project.name, LogLevel.WARN, "Service is already running.") @@ -89,7 +100,8 @@ class ServiceSupervisor( profile = project.profile, state = ServiceState.PREPARING, port = effectiveProject.network.port, - boundAddress = effectiveProject.network.bindAddress + boundAddress = effectiveProject.network.bindAddress, + restartCount = restartCount ) } @@ -131,7 +143,7 @@ class ServiceSupervisor( // Watch this service. The original project is passed on purpose so that a restart // re-runs port resolution from scratch. - supervisorJobs[project.id] = scope.launch { superviseLifecycle(project, handle) } + supervisorJobs[project.id] = scope.launch { superviseLifecycle(project, handle, restartCount) } return true } catch (e: Throwable) { @@ -148,6 +160,8 @@ class ServiceSupervisor( } suspend fun stopProject(projectId: String): Boolean = mutex.withLock { + // A restart backoff has no handle but still has a live job to cancel. + supervisorJobs.remove(projectId)?.cancel() val handle = activeHandles[projectId] ?: run { updateInstance(projectId) { it.copy(state = ServiceState.STOPPED) } syncSystemState() @@ -159,8 +173,6 @@ class ServiceSupervisor( updateInstance(projectId) { it.copy(state = ServiceState.STOPPING) } - supervisorJobs.remove(projectId)?.cancel() - val profile = activeProfiles[projectId] ?: _instances.value[projectId]?.profile if (profile != null) { runtimeRegistry.getEngineForProfile(profile).requestStop(handle) @@ -170,6 +182,22 @@ class ServiceSupervisor( handle.stop() } + if (handle.isAlive) { + updateInstance(projectId) { it.copy(state = ServiceState.STOPPING, lastError = "Waiting for the runtime to exit") } + supervisorJobs[projectId] = scope.launch { + while (handle.isAlive) delay(250) + mutex.withLock { + if (activeHandles[projectId] === handle) { + releaseService(projectId) + updateInstance(projectId) { it.copy(state = ServiceState.STOPPED, lastError = null) } + syncSystemState() + } + } + } + syncSystemState() + return false + } + activeHandles.remove(projectId) activeProfiles.remove(projectId) portManager.releaseServicePorts(projectId) @@ -182,13 +210,13 @@ class ServiceSupervisor( } suspend fun restartProject(project: Project): Boolean { - stopProject(project.id) - delay(300) + if (!stopProject(project.id)) return false return startProject(project) } - private suspend fun superviseLifecycle(project: Project, handle: RuntimeHandle) { - var consecutiveCrashes = 0 + private suspend fun superviseLifecycle(project: Project, handle: RuntimeHandle, restartCount: Int) { + var consecutiveCrashes = restartCount + val thisJob = currentCoroutineContext()[Job] var consecutiveOverLimit = 0 while (true) { @@ -248,6 +276,13 @@ class ServiceSupervisor( releaseService(project.id) updateInstance(project.id) { it.copy(state = ServiceState.STOPPED, lastError = null) } syncSystemState() + if (project.restartPolicy == RestartPolicy.ALWAYS) { + updateInstance(project.id) { it.copy(state = ServiceState.RESTARTING) } + delay(1500) + mutex.withLock { + if (supervisorJobs[project.id] === thisJob) startProjectLocked(project, 0) + } + } break } @@ -292,7 +327,9 @@ class ServiceSupervisor( syncSystemState() delay(backoffMs) - startProject(project) + mutex.withLock { + if (supervisorJobs[project.id] === thisJob) startProjectLocked(project, consecutiveCrashes) + } } else { if (consecutiveCrashes > MAX_RESTARTS) { logManager.log( @@ -371,7 +408,9 @@ class ServiceSupervisor( /** Brings the wake lock and the foreground notification back in line with live work. */ private fun syncSystemState() { - val runningCount = _instances.value.values.count { it.isRunning } + val runningCount = _instances.value.values.count { + it.isRunning || it.state == ServiceState.STOPPING || it.state == ServiceState.RESTARTING + } val bridgeHeld = externalHold.get() syncWakeLock(runningCount > 0 || bridgeHeld) syncForegroundService(runningCount, bridgeHeld) diff --git a/app/src/main/java/com/runcode/app/system/CrashReporter.kt b/app/src/main/java/com/runcode/app/system/CrashReporter.kt index 78585f1..32bbf0d 100644 --- a/app/src/main/java/com/runcode/app/system/CrashReporter.kt +++ b/app/src/main/java/com/runcode/app/system/CrashReporter.kt @@ -2,6 +2,7 @@ package com.runcode.app.system import android.content.Context import android.os.Build +import com.runcode.app.security.SecretRedactor import java.io.File import java.io.PrintWriter import java.io.StringWriter @@ -64,7 +65,7 @@ object CrashReporter { val file = file(context) file.parentFile?.mkdirs() - file.writeText(report) + file.writeText(SecretRedactor.redact(report)) } private fun file(context: Context): File = 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 81807ee..fb186e4 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -16,6 +16,7 @@ import com.runcode.app.domain.models.RuntimeInstance import com.runcode.app.domain.models.TableInfo import com.runcode.app.mcp.McpServerState import com.runcode.app.runtime.PythonEngine +import com.runcode.app.settings.ProjectSettings import com.runcode.app.storage.EntryPointEffect import com.runcode.app.storage.EntryPoints import com.runcode.app.storage.FileKind @@ -28,11 +29,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.File class MainViewModel(application: Application) : AndroidViewModel(application) { private val app = application as RuncodeApp + private val editorMutex = Mutex() // Projects private val _projects = MutableStateFlow>(emptyList()) @@ -140,8 +144,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun startTerminal() { val project = _selectedProject.value val dir = project?.let { File(it.projectRoot) } ?: app.filesDir - val env = project?.environment?.associate { it.key to app.secretStore.resolveValue(it.value) } ?: emptyMap() - app.terminalSession.start(dir, env) + try { + val env = project?.let { app.secretStore.resolveEnvironment(it.environment) } ?: emptyMap() + app.terminalSession.start(dir, env) + } catch (e: IllegalArgumentException) { + _userMessage.value = e.message + } } fun sendTerminalCommand(command: String) = app.terminalSession.send(command) @@ -205,14 +213,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } fun selectProject(project: Project) { - _selectedProject.value = project - refreshProjectFiles(project.id) - refreshDatabases(project) - refreshBackups(project.id) + viewModelScope.launch { + editorMutex.withLock { + if (_selectedProject.value?.id == project.id) return@withLock + if (_isDirty.value && !persistCurrentFile(announce = false)) return@withLock + clearEditor() + _selectedProject.value = project + refreshProjectFiles(project.id) + refreshDatabases(project) + refreshBackups(project.id) + openFileLocked(project.id, "source/${project.entryPoint}") + } + } + } - // Set default open file to entrypoint - val entry = "source/${project.entryPoint}" - openFile(project.id, entry) + private fun clearEditor() { + _openTabs.value = emptyList() + _activeTab.value = null + _editorContent.value = "" + _isDirty.value = false + undoStack.clear() + redoStack.clear() } fun refreshProjectFiles(projectId: String) { @@ -224,32 +245,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun openFile(projectId: String, relativePath: String) { viewModelScope.launch { + editorMutex.withLock { openFileLocked(projectId, relativePath) } + } + } + + private suspend fun openFileLocked(projectId: String, relativePath: String) { + if (_selectedProject.value?.id != projectId) return + if (_activeTab.value == relativePath && _isDirty.value) return try { val info = withContext(Dispatchers.IO) { app.projectStorage.inspect(projectId, relativePath, EDITOR_MAX_BYTES) } val name = File(relativePath).name when (info.kind) { - FileKind.DIRECTORY -> return@launch + FileKind.DIRECTORY -> return FileKind.BINARY -> { _userMessage.value = "$name is a binary file (${ProjectStorage.formatSize(info.size)}) and can't be " + "edited as text. Use ⋮ → Save a copy to get it out." - return@launch + return } FileKind.TOO_LARGE -> { _userMessage.value = "$name is ${ProjectStorage.formatSize(info.size)}, too large for the editor " + "(limit ${ProjectStorage.formatSize(EDITOR_MAX_BYTES)}). Use ⋮ → Save a copy instead." - return@launch + return } FileKind.TEXT, FileKind.MISSING -> Unit } - // Switching tabs used to drop unsaved edits silently. Save them instead. - if (_isDirty.value && _activeTab.value != relativePath) { - persistCurrentFile(announce = false) - } - val content = withContext(Dispatchers.IO) { app.projectStorage.readFile(projectId, relativePath) } + if (_selectedProject.value?.id != projectId) return + if (_isDirty.value) { + if (_activeTab.value == relativePath || !persistCurrentFile(announce = false)) return + } val currentTabs = _openTabs.value.toMutableList() if (!currentTabs.contains(relativePath)) { currentTabs.add(relativePath) @@ -263,14 +290,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } catch (e: Exception) { _userMessage.value = "Failed to open file: ${e.message}" } - } } fun closeTab(tab: String) { viewModelScope.launch { // Closing the tab you are typing in should not throw the typing away. - if (_isDirty.value && _activeTab.value == tab) persistCurrentFile(announce = false) - removeTab(tab) + editorMutex.withLock { + if (_isDirty.value && _activeTab.value == tab && !persistCurrentFile(announce = false)) return@withLock + removeTab(tab) + } } } @@ -329,16 +357,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * Writes the editor buffer to disk and only returns once it is there, so callers that are * about to execute the file cannot race ahead of the save. */ - private suspend fun persistCurrentFile(announce: Boolean) { - val project = _selectedProject.value ?: return - val tab = _activeTab.value ?: return - try { + private suspend fun persistCurrentFile(announce: Boolean): Boolean { + val project = _selectedProject.value ?: return !_isDirty.value + val tab = _activeTab.value ?: return !_isDirty.value + return try { app.projectStorage.writeFileAtomically(project.id, tab, _editorContent.value) _isDirty.value = false if (announce) _userMessage.value = "Saved ${File(tab).name}" refreshProjectFiles(project.id) + true } catch (e: Exception) { _userMessage.value = "Save error: ${e.message}" + false } } @@ -590,6 +620,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { if (_selectedProject.value?.id == project.id) _selectedProject.value = project } + suspend fun updateProjectSettings(projectId: String, settings: ProjectSettings): String? = try { + val updated = withContext(Dispatchers.IO) { app.projectSettings.update(projectId, settings) } + _projects.value = _projects.value.map { if (it.id == projectId) updated else it } + if (_selectedProject.value?.id == projectId) _selectedProject.value = updated + _userMessage.value = "Project settings saved" + null + } catch (e: Exception) { + com.runcode.app.security.SecretRedactor.redact(e.message ?: "Could not save settings") + } + /** Something outside the UI (the MCP bridge) changed a project's files or settings. */ private suspend fun onExternalProjectChange(projectId: String) { val list = app.appMetaDatabase.getAllProjects() @@ -607,7 +647,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val info = app.projectStorage.inspect(projectId, tab, EDITOR_MAX_BYTES) if (info.kind == FileKind.TEXT) app.projectStorage.readFile(projectId, tab) else null } ?: return - if (onDisk != _editorContent.value && !_isDirty.value && _activeTab.value == tab) { + if (onDisk != _editorContent.value && !_isDirty.value && _activeTab.value == tab && + _selectedProject.value?.id == projectId) { _editorContent.value = onDisk undoStack.clear() redoStack.clear() @@ -652,9 +693,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun deleteProject(project: Project) { viewModelScope.launch { - app.serviceSupervisor.stopProject(project.id) + if (!app.serviceSupervisor.stopProject(project.id)) { + _userMessage.value = "The service is still stopping. Try deleting it after it exits." + return@launch + } app.projectStorage.deleteProject(project.id) app.appMetaDatabase.deleteProject(project.id) + val secretPrefix = "PROJECT_${project.id.replace('-', '_')}_" + app.secretStore.getAllSecretKeys().filter { it.startsWith(secretPrefix) }.forEach { app.secretStore.removeSecret(it) } + if (_selectedProject.value?.id == project.id) { + clearEditor() + _selectedProject.value = null + } loadProjects() _userMessage.value = "Project '${project.name}' deleted." } @@ -664,7 +714,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun runProject(project: Project) { viewModelScope.launch { if (_isDirty.value) { - persistCurrentFile(announce = false) + if (!persistCurrentFile(announce = false)) return@launch } val started = app.serviceSupervisor.startProject(project) if (!started) { diff --git a/app/src/main/java/com/runcode/app/ui/screens/ProjectSettingsDialog.kt b/app/src/main/java/com/runcode/app/ui/screens/ProjectSettingsDialog.kt new file mode 100644 index 0000000..c658df3 --- /dev/null +++ b/app/src/main/java/com/runcode/app/ui/screens/ProjectSettingsDialog.kt @@ -0,0 +1,152 @@ +package com.runcode.app.ui.screens + +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.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.runcode.app.domain.models.Project +import com.runcode.app.domain.models.RestartPolicy +import com.runcode.app.settings.EnvironmentEdit +import com.runcode.app.settings.ProjectSettings +import com.runcode.app.ui.MainViewModel +import kotlinx.coroutines.launch + +@Composable +fun ProjectSettingsDialog(project: Project, viewModel: MainViewModel, onDismiss: () -> Unit) { + var port by remember { mutableStateOf(project.network.port.toString()) } + var cpu by remember { mutableStateOf(project.maxCpuPercent.toString()) } + var heap by remember { mutableStateOf(project.maxHeapMb.toString()) } + var idle by remember { mutableStateOf(project.idleTimeoutMinutes.toString()) } + var policy by remember { mutableStateOf(project.restartPolicy) } + var boot by remember { mutableStateOf(project.startOnBoot) } + var environment by remember { mutableStateOf(ProjectSettings.from(project).environment) } + var policyExpanded by remember { mutableStateOf(false) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = { if (!saving) onDismiss() }, + properties = DialogProperties(securePolicy = androidx.compose.ui.window.SecureFlagPolicy.SecureOn), + title = { Text("Project settings · ${project.name}") }, + text = { + Column(Modifier.heightIn(max = 520.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Stop the service before saving. New settings apply on its next run.") + SettingsNumber("Port", port, !saving) { port = it } + Column { + TextButton(onClick = { policyExpanded = true }, enabled = !saving) { + Text("Restart: ${policy.name}") + } + DropdownMenu(expanded = policyExpanded, onDismissRequest = { policyExpanded = false }) { + RestartPolicy.entries.forEach { candidate -> + DropdownMenuItem(text = { Text(candidate.name) }, onClick = { + policy = candidate + policyExpanded = false + }) + } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = boot, onCheckedChange = { boot = it }, enabled = !saving) + Text("Start on device boot") + } + Text("Limits: 0 means unlimited. CPU measures the service thread; heap is shared by the whole app. Idle timeout applies to static websites.") + SettingsNumber("CPU limit (%)", cpu, !saving) { cpu = it } + SettingsNumber("App Java heap limit (MB)", heap, !saving) { heap = it } + SettingsNumber("Static web idle timeout (minutes)", idle, !saving) { idle = it } + HorizontalDivider() + Text("Environment variables", style = MaterialTheme.typography.titleMedium) + Text("Secret values are encrypted on this device. Leave an existing secret blank to keep it; enter a value to replace it.") + environment.forEachIndexed { index, item -> + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedTextField( + value = item.key, + onValueChange = { key -> environment = environment.toMutableList().also { it[index] = item.copy(key = key) } }, + label = { Text("Name") }, singleLine = true, enabled = !saving, + readOnly = item.isSecret && item.value == null, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = item.value ?: "", + onValueChange = { value -> environment = environment.toMutableList().also { + it[index] = item.copy(value = if (item.isSecret && value.isEmpty() && + project.environment.any { old -> old.isSecret && old.key == item.key }) null else value) + } }, + label = { Text(if (item.isSecret && item.value == null) "Secret (keep or replace)" else "Value") }, + visualTransformation = if (item.isSecret) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions(keyboardType = if (item.isSecret) KeyboardType.Password else KeyboardType.Text), + singleLine = true, enabled = !saving, modifier = Modifier.fillMaxWidth() + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = item.isSecret, enabled = !saving, onCheckedChange = { secret -> + environment = environment.toMutableList().also { it[index] = item.copy(isSecret = secret, value = "") } + }) + Text("Secret", Modifier.weight(1f)) + TextButton(enabled = !saving, onClick = { environment = environment.filterIndexed { i, _ -> i != index } }) { + Text("Remove") + } + } + } + } + TextButton(enabled = !saving, onClick = { environment = environment + EnvironmentEdit("", "", false) }) { + Text("Add variable") + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 4.dp)) } + } + }, + confirmButton = { + Button(enabled = !saving, onClick = { + val numbers = listOf(port, cpu, heap, idle).map { it.toIntOrNull() } + if (numbers.any { it == null }) { + error = "Enter whole numbers for the port and limits (0 disables a limit)" + } else { + saving = true + scope.launch { + error = viewModel.updateProjectSettings(project.id, ProjectSettings( + numbers[0]!!, policy, boot, numbers[1]!!, numbers[2]!!, numbers[3]!!, environment + )) + saving = false + if (error == null) onDismiss() + } + } + }) { Text(if (saving) "Saving…" else "Save") } + }, + dismissButton = { TextButton(enabled = !saving, onClick = onDismiss) { Text("Cancel") } } + ) +} + +@Composable +private fun SettingsNumber(label: String, value: String, enabled: Boolean, onChange: (String) -> Unit) { + OutlinedTextField(value = value, onValueChange = onChange, label = { Text(label) }, + singleLine = true, enabled = enabled, modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) +} diff --git a/app/src/main/java/com/runcode/app/ui/screens/ProjectsScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/ProjectsScreen.kt index 6572012..046c2bd 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/ProjectsScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/ProjectsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material.icons.filled.SettingsBackupRestore import androidx.compose.material.icons.filled.Stop @@ -85,6 +86,7 @@ fun ProjectsScreen( var searchQuery by remember { mutableStateOf("") } var showCreateDialog by remember { mutableStateOf(false) } var projectToDelete by remember { mutableStateOf(null) } + var projectToConfigure by remember { mutableStateOf(null) } // Any .zip: a runcode export comes back with its settings, anything else becomes a Python project. val importProject = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> @@ -166,6 +168,12 @@ fun ProjectsScreen( items(filteredProjects) { project -> val inst = instances[project.id] val isRunning = inst?.isRunning == true + val isBusy = inst?.state in setOf( + com.runcode.app.domain.models.ServiceState.PREPARING, + com.runcode.app.domain.models.ServiceState.STARTING, + com.runcode.app.domain.models.ServiceState.RESTARTING, + com.runcode.app.domain.models.ServiceState.STOPPING + ) Card( modifier = Modifier @@ -215,13 +223,14 @@ fun ProjectsScreen( } else { Button( onClick = { viewModel.runProject(project) }, + enabled = !isBusy, colors = ButtonDefaults.buttonColors(containerColor = AccentGreen), shape = RoundedCornerShape(8.dp), modifier = Modifier.testTag("project_run_${project.id}") ) { Icon(Icons.Default.PlayArrow, contentDescription = "Run", tint = Color.Black, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Run", color = Color.Black, fontWeight = FontWeight.Bold) + Text(if (isBusy) "Waiting" else "Run", color = Color.Black, fontWeight = FontWeight.Bold) } } } @@ -245,6 +254,9 @@ fun ProjectsScreen( ) Row { + IconButton(onClick = { projectToConfigure = project }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Settings, contentDescription = "Project settings", tint = TextSecondary, modifier = Modifier.size(18.dp)) + } IconButton( onClick = { viewModel.selectProject(project) @@ -274,6 +286,10 @@ fun ProjectsScreen( } } + projectToConfigure?.let { project -> + ProjectSettingsDialog(project, viewModel) { projectToConfigure = null } + } + // New Project Dialog if (showCreateDialog) { var newName by remember { mutableStateOf("") } diff --git a/app/src/main/python/runcode_runner.py b/app/src/main/python/runcode_runner.py index c481485..bed1bd9 100644 --- a/app/src/main/python/runcode_runner.py +++ b/app/src/main/python/runcode_runner.py @@ -33,6 +33,7 @@ _set_async_exc = None _lock = threading.Lock() +_execution_lock = threading.Lock() _threads = {} # service_id -> thread ident _stop_flags = {} # service_id -> True, only consulted by the fallback tracer @@ -123,6 +124,8 @@ def request_stop(service_id): exception when that call returns, not instantly. """ with _lock: + if _stop_flags.get(service_id, False): + return "pending" ident = _threads.get(service_id) _stop_flags[service_id] = True @@ -154,6 +157,17 @@ def tracer(frame, event, arg): def run_script(service_id, script_path, working_dir, env_pairs, sink): + # 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) + finally: + _execution_lock.release() + + +def _run_script_locked(service_id, script_path, working_dir, env_pairs, sink): """ Execute `script_path` as __main__. @@ -161,12 +175,10 @@ def run_script(service_id, script_path, working_dir, env_pairs, sink): does not have to interpret Python exceptions itself. """ ident = threading.get_ident() - with _lock: - _threads[service_id] = ident - _stop_flags.pop(service_id, None) - previous_cwd = os.getcwd() previous_argv = list(sys.argv) + previous_path = list(sys.path) + previous_environment = {} # Per-thread routing, so concurrent services never steal each other's output. _stdout_dispatcher.register(ident, sink) @@ -175,9 +187,16 @@ def run_script(service_id, script_path, working_dir, env_pairs, sink): using_fallback = _set_async_exc is None try: + with _lock: + _threads[service_id] = ident + stop_requested = _stop_flags.get(service_id, False) + if stop_requested: + raise ScriptInterrupt() for pair in env_pairs: key, _, value = pair.partition("=") if key: + if key not in previous_environment: + previous_environment[key] = os.environ.get(key) os.environ[key] = value if working_dir and os.path.isdir(working_dir): @@ -225,7 +244,13 @@ def run_script(service_id, script_path, working_dir, env_pairs, sink): finally: _stdout_dispatcher.unregister(ident) _stderr_dispatcher.unregister(ident) + for key, value in previous_environment.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value sys.argv = previous_argv + sys.path[:] = previous_path try: os.chdir(previous_cwd) except Exception: @@ -236,6 +261,15 @@ def run_script(service_id, script_path, working_dir, env_pairs, sink): def run_snippet(code, working_dir): + if not _execution_lock.acquire(blocking=False): + return "Another Python task is active. Stop it before running a snippet." + try: + return _run_snippet_locked(code, working_dir) + finally: + _execution_lock.release() + + +def _run_snippet_locked(code, working_dir): """ Execute a one-off snippet and return everything it printed. diff --git a/app/src/test/java/com/runcode/app/security/SecretRedactorTest.kt b/app/src/test/java/com/runcode/app/security/SecretRedactorTest.kt new file mode 100644 index 0000000..5802e74 --- /dev/null +++ b/app/src/test/java/com/runcode/app/security/SecretRedactorTest.kt @@ -0,0 +1,17 @@ +package com.runcode.app.security + +import org.junit.Assert.* +import org.junit.Test + +class SecretRedactorTest { + @Test fun `registered values are redacted without labels`() { + SecretRedactor.register("arbitrary test value & x") + assertEquals("before [REDACTED] after", SecretRedactor.redact("before arbitrary test value & x after")) + } + + @Test fun `overlapping values and replacement characters stay hidden`() { + SecretRedactor.register("literal${'$'}value\\test") + SecretRedactor.register("literal${'$'}value\\test-longer") + assertEquals("[REDACTED]", SecretRedactor.redact("literal${'$'}value\\test-longer")) + } +} diff --git a/app/src/test/java/com/runcode/app/settings/ProjectSettingsTest.kt b/app/src/test/java/com/runcode/app/settings/ProjectSettingsTest.kt new file mode 100644 index 0000000..5a05902 --- /dev/null +++ b/app/src/test/java/com/runcode/app/settings/ProjectSettingsTest.kt @@ -0,0 +1,148 @@ +package com.runcode.app.settings + +import com.runcode.app.domain.models.EnvironmentVariable +import com.runcode.app.domain.models.Project +import com.runcode.app.domain.models.ProjectProfile +import com.runcode.app.security.SecretReferences +import com.runcode.app.security.SecretVault +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import org.junit.Assert.* +import org.junit.Test + +class ProjectSettingsTest { + private var stored = Project("p1", "Project", "", ProjectProfile.PYTHON_SCRIPT, "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/projects/p1", "main.py") + private val secrets = mutableMapOf() + private var vaultFails = false + private var databaseFails = false + private var active = false + private var afterSave: (suspend () -> Unit)? = null + private val manager = ProjectSettingsManager(object : SecretVault { + override fun setSecret(key: String, plainText: String): Boolean { + if (vaultFails) return false + secrets[key] = plainText + return true + } + override fun getSecret(key: String) = secrets[key] + override fun removeSecret(key: String) { secrets.remove(key) } + }, { stored }, { + check(!databaseFails) { "Database unavailable" } + stored = it + afterSave?.invoke() + }, { active }) + + private fun settings(vararg edits: EnvironmentEdit) = ProjectSettings.from(stored).copy(environment = edits.toList()) + + @Test fun `secret plaintext never enters project metadata`() = runBlocking { + manager.update("p1", settings(EnvironmentEdit("TOKEN", "test-secret-value", true))) + val variable = stored.environment.single() + assertNotEquals("test-secret-value", variable.value) + assertEquals("test-secret-value", secrets[SecretReferences.key(variable.value)]) + } + + @Test fun `blank existing secret edit keeps its reference`() = runBlocking { + manager.update("p1", settings(EnvironmentEdit("TOKEN", "original-value", true))) + val before = stored.environment + manager.update("p1", ProjectSettings.from(stored).copy(port = 9090)) + assertEquals(before, stored.environment) + assertEquals(9090, stored.network.port) + } + + @Test fun `replacement removes only the old project secret`() = runBlocking { + secrets["MCP_BRIDGE_TOKEN"] = "bridge-value" + manager.update("p1", settings(EnvironmentEdit("TOKEN", "old-value", true))) + val oldKey = SecretReferences.key(stored.environment.single().value) + manager.update("p1", settings(EnvironmentEdit("TOKEN", "new-value", true))) + assertFalse(secrets.containsKey(oldKey)) + assertEquals("bridge-value", secrets["MCP_BRIDGE_TOKEN"]) + assertTrue(secrets.containsValue("new-value")) + } + + @Test fun `database failure rolls back newly written secrets`() = runBlocking { + manager.update("p1", settings(EnvironmentEdit("TOKEN", "old-value", true))) + val before = stored + val oldSecrets = secrets.toMap() + databaseFails = true + assertThrows(IllegalStateException::class.java) { + runBlocking { manager.update("p1", settings(EnvironmentEdit("TOKEN", "new-value", true))) } + } + assertEquals(before, stored) + assertEquals(oldSecrets, secrets) + } + + @Test fun `vault failure does not save plaintext or settings`() { + vaultFails = true + assertThrows(IllegalStateException::class.java) { + runBlocking { manager.update("p1", settings(EnvironmentEdit("TOKEN", "secret-value", true))) } + } + assertTrue(stored.environment.isEmpty()) + assertTrue(secrets.isEmpty()) + } + + @Test fun `mcp cannot replace or remove secrets`() = runBlocking { + manager.update("p1", settings(EnvironmentEdit("TOKEN", "secret-value", true))) + listOf(settings(), settings(EnvironmentEdit("TOKEN", "plain", false)), + settings(EnvironmentEdit("TOKEN", "new-secret", true))).forEach { candidate -> + assertThrows(IllegalArgumentException::class.java) { + runBlocking { manager.update("p1", candidate, allowSecrets = false) } + } + } + manager.update("p1", ProjectSettings.from(stored).copy(port = 9091), allowSecrets = false) + assertEquals(9091, stored.network.port) + } + + @Test fun `mcp cannot add a secret reference as plain text`() { + assertThrows(IllegalArgumentException::class.java) { + runBlocking { manager.update("p1", settings(EnvironmentEdit("TOKEN", "\${SEC_MCP_BRIDGE_TOKEN}", false)), false) } + } + } + + @Test fun `invalid numbers duplicate keys and reserved variables are rejected`() { + listOf(settings().copy(port = 0), settings().copy(port = 65536), + settings().copy(maxCpuPercent = 101), settings().copy(maxHeapMb = -1), + settings().copy(idleTimeoutMinutes = Int.MAX_VALUE), + settings(EnvironmentEdit("A", "1", false), EnvironmentEdit("A", "2", false)), + settings(EnvironmentEdit("1INVALID", "1", false)), + settings(EnvironmentEdit("RUNCODE_PORT", "5", false)), + settings(EnvironmentEdit("OK", "nul\u0000value", false))).forEach { candidate -> + assertThrows(IllegalArgumentException::class.java) { candidate.validate() } + } + } + + @Test fun `active service settings cannot change`() { + active = true + assertThrows(IllegalStateException::class.java) { + runBlocking { manager.update("p1", settings().copy(port = 9000)) } + } + assertEquals(8080, stored.network.port) + } + + @Test fun `removing a variable deletes its owned secret`() = runBlocking { + manager.update("p1", settings(EnvironmentEdit("TOKEN", "secret-value", true))) + manager.update("p1", settings()) + assertTrue(secrets.isEmpty()) + assertTrue(stored.environment.isEmpty()) + } + + @Test fun `new secret without a value is rejected`() { + assertThrows(IllegalArgumentException::class.java) { + runBlocking { manager.update("p1", settings(EnvironmentEdit("TOKEN", null, true))) } + } + } + + @Test fun `cancellation after metadata commit keeps referenced vault entries`() = runBlocking { + val committed = CompletableDeferred() + val release = CompletableDeferred() + afterSave = { + committed.complete(Unit) + release.await() + } + val save = launch { manager.update("p1", settings(EnvironmentEdit("TOKEN", "saved-value", true))) } + committed.await() + save.cancel() + release.complete(Unit) + save.join() + assertEquals("saved-value", secrets[SecretReferences.key(stored.environment.single().value)]) + } +} diff --git a/app/src/test/java/com/runcode/app/storage/ProjectStorageTest.kt b/app/src/test/java/com/runcode/app/storage/ProjectStorageTest.kt new file mode 100644 index 0000000..9d8c4f2 --- /dev/null +++ b/app/src/test/java/com/runcode/app/storage/ProjectStorageTest.kt @@ -0,0 +1,103 @@ +package com.runcode.app.storage + +import android.app.Application +import com.runcode.app.database.AppMetaDatabase +import com.runcode.app.domain.models.EnvironmentVariable +import com.runcode.app.domain.models.ProjectProfile +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.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class ProjectStorageTest { + private lateinit var storage: ProjectStorage + private lateinit var archive: ProjectArchive + + @Before fun setup() { + storage = ProjectStorage(RuntimeEnvironment.getApplication()) + archive = ProjectArchive(RuntimeEnvironment.getApplication(), storage) + } + + private fun zip(vararg entries: Pair): ByteArrayInputStream { + val bytes = ByteArrayOutputStream() + ZipOutputStream(bytes).use { output -> entries.forEach { (path, text) -> + output.putNextEntry(ZipEntry(path)) + output.write(text.toByteArray()) + output.closeEntry() + } } + return ByteArrayInputStream(bytes.toByteArray()) + } + + @Test fun `reject traversal and sibling paths`() { + storage.initializeProjectDirectories("a") + listOf("../b/data.txt", "source/../../outside", "../a-other/file").forEach { + assertThrows(SecurityException::class.java) { storage.resolveInProject("a", it) } + } + } + + @Test fun `layout directories cannot be removed or renamed`() { + storage.initializeProjectDirectories("layout") + ProjectStorage.STRUCTURAL_DIRS.forEach { + assertThrows(SecurityException::class.java) { storage.deleteFile("layout", it) } + assertThrows(SecurityException::class.java) { storage.movePath("layout", it, "other") } + } + } + + @Test fun `archive rejects a traversing entry point`() { + val manifest = """{"format":"runcode-project","entry_point":"../../outside.py"}""" + assertThrows(IllegalArgumentException::class.java) { + archive.import(zip("runcode.json" to manifest, "source/main.py" to "print(1)"), "bad") + } + } + + @Test fun `archive drops plaintext secrets and foreign references`() { + val manifest = """{"format":"runcode-project","environment":[ + {"key":"TOKEN","value":"plaintext-test-value","is_secret":true}, + {"key":"OTHER","value":"${'$'}{SEC_MCP_BRIDGE_TOKEN}","is_secret":true} + ]}""" + val project = archive.import(zip("runcode.json" to manifest, "source/main.py" to "print(1)"), "imported") + assertTrue(project.environment.all { it.isSecret && it.value.isEmpty() }) + assertFalse(project.startOnBoot) + } + + @Test fun `archive rejects zip slip`() { + assertThrows(SecurityException::class.java) { + archive.import(zip("../../outside.py" to "print(1)"), "bad") + } + } + + @Test fun `metadata rejects secret plaintext`() { + val project = storage.createProjectFromTemplate("meta", ProjectProfile.PYTHON_SCRIPT) + AppMetaDatabase(RuntimeEnvironment.getApplication()).use { db -> + assertThrows(IllegalArgumentException::class.java) { + runBlocking { db.insertOrUpdateProject(project.copy(environment = listOf(EnvironmentVariable("TOKEN", "plaintext", true)))) } + } + } + } + + @Test fun `entry point follows folder moves without matching sibling names`() { + val project = storage.createProjectFromTemplate("entry", ProjectProfile.PYTHON_SCRIPT).copy(entryPoint = "pkg/main.py") + val moved = EntryPoints.follow(project, "source/pkg", "source/new") as EntryPointEffect.Moved + assertEquals("new/main.py", moved.project.entryPoint) + assertEquals(EntryPointEffect.Unaffected, EntryPoints.follow(project, "source/p", null)) + assertEquals(EntryPointEffect.Lost, EntryPoints.follow(project, "source/pkg", null)) + } + + @Test fun `new service templates execute real libraries`() { + val bot = storage.createProjectFromTemplate("bot", ProjectProfile.TELEGRAM_BOT) + assertTrue(File(bot.projectRoot, "source/bot.py").readText().contains("application.run_polling")) + val api = storage.createProjectFromTemplate("api", ProjectProfile.PYTHON_HTTP) + assertTrue(File(api.projectRoot, "source/server.py").readText().contains("HTTPServer((address, port)")) + } +} diff --git a/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt b/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt new file mode 100644 index 0000000..63c4aad --- /dev/null +++ b/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt @@ -0,0 +1,105 @@ +package com.runcode.app.supervisor + +import android.app.Application +import android.content.Context +import com.runcode.app.domain.models.* +import com.runcode.app.logging.LogManager +import com.runcode.app.network.PortManager +import com.runcode.app.runtime.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +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 + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +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 { + override val serviceId = "test" + override val projectId = "test" + override val isAlive get() = alive + override val boundPort = 8080 + override val exitReason get() = reason + override val threadId: Long? = null + override suspend fun stop() { reason = ExitReason.STOPPED } + override suspend fun forceKill() = stop() + override suspend fun checkHealth() = RuntimeHealth(alive, 0, 0.0, "test") + } + + private class Engine(val handle: Handle) : RuntimeEngine { + var starts = 0 + override val descriptor = RuntimeDescriptor("test", "Test", "1", listOf(ProjectProfile.PYTHON_SCRIPT), false, true) + override suspend fun checkCompatibility(context: Context) = CompatibilityReport(true, "ok", "") + override suspend fun prepare(project: Project) = PrepareResult.Success + override suspend fun start(project: Project, sink: RuntimeEventSink): RuntimeHandle { starts++; return handle } + override suspend fun requestStop(handle: RuntimeHandle): StopResult { handle.stop(); return StopResult.Stopped } + override suspend fun forceStop(handle: RuntimeHandle) = requestStop(handle) + override suspend fun health(handle: RuntimeHandle) = handle.checkHealth() + } + + private fun TestScope.supervisor(engine: Engine): ServiceSupervisor { + val context = RuntimeEnvironment.getApplication() + return ServiceSupervisor(context, RuntimeRegistry(engine, engine), LogManager(context), PortManager(), backgroundScope) + } + + @Test fun `crashing services stop after four automatic retries`() = runTest { + val engine = Engine(Handle()) + val supervisor = supervisor(engine) + supervisor.startProject(project) + advanceTimeBy(60_000) + runCurrent() + assertEquals(5, engine.starts) + assertEquals(ServiceState.FAILED, supervisor.instances.value[project.id]?.state) + assertEquals(4, supervisor.instances.value[project.id]?.restartCount) + } + + @Test fun `stop during backoff cancels the pending restart`() = runTest { + val engine = Engine(Handle()) + val supervisor = supervisor(engine) + supervisor.startProject(project) + advanceTimeBy(3_000) + runCurrent() + assertEquals(ServiceState.RESTARTING, supervisor.instances.value[project.id]?.state) + assertTrue(supervisor.stopProject(project.id)) + advanceTimeBy(30_000) + runCurrent() + assertEquals(1, engine.starts) + assertEquals(ServiceState.STOPPED, supervisor.instances.value[project.id]?.state) + } + + @Test fun `blocked workers stay owned until they actually exit`() = runTest { + val handle = Handle(alive = true, reason = ExitReason.RUNNING) + val engine = Engine(handle) + val supervisor = supervisor(engine) + supervisor.startProject(project) + assertFalse(supervisor.stopProject(project.id)) + assertFalse(supervisor.startProject(project)) + assertTrue(supervisor.hasActiveWork(project.id)) + assertEquals(ServiceState.STOPPING, supervisor.instances.value[project.id]?.state) + handle.alive = false + advanceTimeBy(500) + runCurrent() + assertEquals(ServiceState.STOPPED, supervisor.instances.value[project.id]?.state) + assertEquals(1, engine.starts) + } + + @Test fun `always restarts successful scripts but on failure does not`() = runTest { + val engine = Engine(Handle(reason = ExitReason.COMPLETED)) + val supervisor = supervisor(engine) + supervisor.startProject(project) + advanceTimeBy(5_000) + runCurrent() + assertEquals(1, engine.starts) + supervisor.startProject(project.copy(restartPolicy = RestartPolicy.ALWAYS)) + advanceTimeBy(5_000) + runCurrent() + assertEquals(3, engine.starts) + } +} diff --git a/app/src/test/java/com/runcode/app/ui/EditorSessionTest.kt b/app/src/test/java/com/runcode/app/ui/EditorSessionTest.kt new file mode 100644 index 0000000..c74f305 --- /dev/null +++ b/app/src/test/java/com/runcode/app/ui/EditorSessionTest.kt @@ -0,0 +1,71 @@ +package com.runcode.app.ui + +import android.os.Looper +import com.runcode.app.RuncodeApp +import com.runcode.app.domain.models.Project +import com.runcode.app.domain.models.ProjectProfile +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +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 +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class EditorSessionTest { + private fun await(condition: () -> Boolean) { + val deadline = System.nanoTime() + 10_000_000_000 + while (!condition() && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + assertTrue("Editor operation did not finish", condition()) + } + + private fun create(app: RuncodeApp, name: String): Project { + val project = app.projectStorage.createProjectFromTemplate(name, ProjectProfile.PYTHON_SCRIPT) + app.projectStorage.writeFileAtomically(project.id, "source/main.py", name) + runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } + return project + } + + @Test fun `switching projects saves to the old owner and clears its tabs`() { + val app = RuntimeEnvironment.getApplication() + val a = create(app, "project-a") + val b = create(app, "project-b") + val model = MainViewModel(app) + await { model.projects.value.isNotEmpty() } + model.selectProject(a) + await { model.selectedProject.value?.id == a.id && model.editorContent.value == "project-a" } + model.updateEditorContent("unsaved-a") + model.selectProject(b) + await { model.selectedProject.value?.id == b.id && model.editorContent.value == "project-b" } + assertEquals("unsaved-a", app.projectStorage.readFile(a.id, "source/main.py")) + assertEquals("project-b", app.projectStorage.readFile(b.id, "source/main.py")) + assertEquals(listOf("source/main.py"), model.openTabs.value) + assertFalse(model.isDirty.value) + } + + @Test fun `failed save prevents project switch and keeps the buffer`() { + val app = RuntimeEnvironment.getApplication() + val a = create(app, "save-failure-a") + val b = create(app, "save-failure-b") + val model = MainViewModel(app) + await { model.projects.value.isNotEmpty() } + model.selectProject(a) + await { model.editorContent.value == "save-failure-a" } + model.updateEditorContent("must-survive") + val entry = File(a.projectRoot, "source/main.py") + assertTrue(entry.delete()) + assertTrue(entry.mkdir()) + model.selectProject(b) + await { model.userMessage.value?.startsWith("Save error:") == true } + assertEquals(a.id, model.selectedProject.value?.id) + assertEquals("must-survive", model.editorContent.value) + assertTrue(model.isDirty.value) + } +} diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..3459c58 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,109 @@ +"""Host-side regression checks for the bridge and the shipped HTTP template.""" +import importlib.util +import io +import os +from pathlib import Path +import socket +import sys +import tempfile +import threading +import time +import unittest +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "app/src/main/python")) +import runcode_runner as runner + + +class Sink: + def __init__(self): + self.lines = [] + + def onOutput(self, level, line): + self.lines.append((level, line)) + + +class RuntimeTest(unittest.TestCase): + def test_environment_is_restored_after_success_and_failure(self): + key = "RUNCODE_TEST_SECRET" + for source in ("import os\nprint(os.environ['RUNCODE_TEST_SECRET'])", "raise ValueError('test')"): + with tempfile.TemporaryDirectory() as directory: + script = Path(directory, "main.py") + script.write_text(source) + original = os.environ.get(key) + sink = Sink() + runner.run_script("env-test", str(script), directory, [key + "=test-only-value"], sink) + self.assertEqual(original, os.environ.get(key)) + + def test_cpu_loop_can_be_stopped(self): + with tempfile.TemporaryDirectory() as directory: + script = Path(directory, "main.py") + script.write_text("print('ready')\nwhile True:\n pass\n") + sink = Sink() + outcome = [] + worker = threading.Thread(target=lambda: outcome.append(runner.run_script("stop-test", str(script), directory, [], sink)), daemon=True) + worker.start() + deadline = time.monotonic() + 5 + while not sink.lines and time.monotonic() < deadline: + time.sleep(0.01) + self.assertTrue(sink.lines) + runner.request_stop("stop-test") + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual(["stopped"], outcome) + + def test_concurrent_scripts_and_snippets_cannot_share_environment(self): + with tempfile.TemporaryDirectory() as directory: + script = Path(directory, "main.py") + script.write_text("import time\nprint('ready')\nwhile True:\n time.sleep(0.05)\n") + sink, outcome = Sink(), [] + worker = threading.Thread(target=lambda: outcome.append(runner.run_script("owner", str(script), directory, ["RUNCODE_TEST_SECRET=owned-value"], sink)), daemon=True) + worker.start() + try: + deadline = time.monotonic() + 5 + while not sink.lines and time.monotonic() < deadline: + time.sleep(0.01) + self.assertTrue(sink.lines) + second = runner.run_script("other", str(script), directory, [], Sink()) + self.assertTrue(second.startswith("fatal:Another Python task")) + snippet = runner.run_snippet("import os; print(os.environ.get('RUNCODE_TEST_SECRET'))", directory) + self.assertNotIn("owned-value", snippet) + self.assertIn("Another Python task", snippet) + finally: + runner.request_stop("owner") + worker.join(5) + self.assertEqual(["stopped"], outcome) + self.assertNotIn("RUNCODE_TEST_SECRET", os.environ) + + def test_stop_requested_before_registration_is_not_lost(self): + with tempfile.TemporaryDirectory() as directory: + script = Path(directory, "main.py") + script.write_text("raise AssertionError('should not execute')") + runner.request_stop("early-stop") + self.assertEqual("stopped", runner.run_script("early-stop", str(script), directory, [], Sink())) + + def test_http_template_listens_and_stops_without_new_traffic(self): + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + script = ROOT / "app/src/main/assets/templates/server.py" + sink, outcome = Sink(), [] + worker = threading.Thread(target=lambda: outcome.append(runner.run_script("http-test", str(script), str(script.parent), [f"RUNCODE_PORT={port}", "RUNCODE_BIND_ADDRESS=127.0.0.1"], sink)), daemon=True) + worker.start() + try: + deadline = time.monotonic() + 5 + while not any("listening" in line for _, line in sink.lines) and time.monotonic() < deadline: + time.sleep(0.01) + with urllib.request.urlopen(f"http://127.0.0.1:{port}/status", timeout=2) as response: + self.assertEqual(200, response.status) + self.assertIn(b'"status": "ok"', response.read()) + finally: + runner.request_stop("http-test") + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual(["stopped"], outcome) + + +if __name__ == "__main__": + unittest.main() From 0b3dec0fc5b7a2123b557e65846c128234cbb137 Mon Sep 17 00:00:00 2001 From: hami9 Date: Tue, 29 Sep 2026 13:18:11 +0330 Subject: [PATCH 2/7] fix Android checks --- .github/workflows/check.yml | 3 + .../com/runcode/app/mcp/McpSettingsTest.kt | 64 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5c254b4..997692b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -20,6 +20,9 @@ jobs: with: python-version: '3.12' - uses: android-actions/setup-android@v3 + with: + packages: 'platform-tools platforms;android-36 build-tools;36.0.0' + log-accepted-android-sdk-licenses: false - uses: gradle/actions/setup-gradle@v4 - name: Check Python runtime run: python -m unittest discover -s tests -v diff --git a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt new file mode 100644 index 0000000..bcfa432 --- /dev/null +++ b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt @@ -0,0 +1,64 @@ +package com.runcode.app.mcp + +import com.runcode.app.RuncodeApp +import com.runcode.app.domain.models.EnvironmentVariable +import com.runcode.app.domain.models.Project +import com.runcode.app.domain.models.ProjectProfile +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +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 + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class McpSettingsTest { + private lateinit var app: RuncodeApp + private lateinit var host: McpToolHost + private lateinit var project: Project + private val notifications = mutableListOf() + + @Before fun setup() { + app = RuntimeEnvironment.getApplication() + project = app.projectStorage.createProjectFromTemplate("MCP settings", ProjectProfile.PYTHON_SCRIPT).copy( + environment = listOf(EnvironmentVariable("TOKEN", "\${SEC_PROJECT_test_reference}", true), + EnvironmentVariable("OLD", "old", false)) + ) + 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) }) + } + + @Test fun `mcp replaces plain variables but preserves secret references`() = runBlocking { + val result = McpTools.call(host, "update_project_settings", JSONObject() + .put("project_id", project.id).put("port", 9123) + .put("environment", JSONObject().put("NEW", "hello"))) + assertFalse(result.getBoolean("isError")) + val updated = app.appMetaDatabase.getProjectById(project.id)!! + assertEquals(9123, updated.network.port) + assertEquals(project.environment.first(), updated.environment.first()) + assertEquals(listOf("TOKEN", "NEW"), updated.environment.map { it.key }) + assertEquals(listOf(project.id), notifications) + val detail = McpTools.call(host, "get_project", JSONObject().put("project_id", project.id)) + assertFalse(detail.toString().contains("PROJECT_test_reference")) + } + + @Test fun `mcp cannot overwrite a secret by reusing its name`() { + val result = McpTools.call(host, "update_project_settings", JSONObject() + .put("project_id", project.id).put("environment", JSONObject().put("TOKEN", "replacement"))) + assertTrue(result.getBoolean("isError")) + assertTrue(notifications.isEmpty()) + } + + @Test fun `mcp rejects fractional ports instead of truncating them`() { + val result = McpTools.call(host, "update_project_settings", JSONObject() + .put("project_id", project.id).put("port", 8080.5)) + assertTrue(result.getBoolean("isError")) + assertTrue(notifications.isEmpty()) + } +} From af43d77a8129df348d2d8d28646113b4fce8df6f Mon Sep 17 00:00:00 2001 From: hami9 Date: Tue, 29 Sep 2026 13:24:50 +0330 Subject: [PATCH 3/7] fix lifecycle checks --- .github/workflows/check.yml | 7 +++ README.md | 2 +- .../runcode/app/settings/ProjectSettings.kt | 10 ++-- .../com/runcode/app/storage/ProjectArchive.kt | 4 +- .../app/supervisor/ServiceSupervisor.kt | 6 ++- .../com/runcode/app/mcp/McpSettingsTest.kt | 2 +- .../app/supervisor/ServiceSupervisorTest.kt | 16 +++++- .../com/runcode/app/ui/EditorSessionTest.kt | 4 +- review-notes.md | 49 +++++++++++++++++++ 9 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 review-notes.md diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 997692b..c307fca 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -2,6 +2,7 @@ name: Check on: push: + branches: [main] pull_request: permissions: @@ -38,3 +39,9 @@ jobs: path: | app/build/reports/ app/build/test-results/ + - name: Save debug APK + if: success() + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/app-debug.apk diff --git a/README.md b/README.md index f9c567b..e401262 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ just `source/`, so whatever a script writes into `data/` is visible too. GitHub "Download ZIP" becomes a Python project with its entry point detected. Imports are checked for paths that escape the project and capped in size; imported projects never start on boot until you turn that on. -- Exports never contain secrets: a secret environment variable is exported only as its +- Secret environment values are excluded from the manifest: a secret variable is exported only as its `${SEC_...}` reference, and the vault stays on the device. Binary files and files over 512 KB do not open in the editor; they can still be saved out. diff --git a/app/src/main/java/com/runcode/app/settings/ProjectSettings.kt b/app/src/main/java/com/runcode/app/settings/ProjectSettings.kt index 6c8bc3b..ee3d123 100644 --- a/app/src/main/java/com/runcode/app/settings/ProjectSettings.kt +++ b/app/src/main/java/com/runcode/app/settings/ProjectSettings.kt @@ -70,9 +70,12 @@ class ProjectSettingsManager( } // Once vault writes begin, finish the metadata commit or rollback even if the // screen closes. Cancellation after a DB commit must not delete referenced keys. - withContext(NonCancellable) { + withContext(NonCancellable) { persist(current, settings) } + } + + private suspend fun persist(current: Project, settings: ProjectSettings): Project { val newKeys = mutableListOf() - val prefix = "PROJECT_${projectId.replace('-', '_')}_" + val prefix = "PROJECT_${current.id.replace('-', '_')}_" val updated = try { val environment = settings.environment.map { edit -> val value = if (edit.isSecret) { @@ -109,7 +112,6 @@ class ProjectSettingsManager( current.environment.mapNotNull { SecretReferences.key(it.value) } .filter { it.startsWith(prefix) && it !in kept } .forEach { vault.removeSecret(it) } - updated - } + return updated } } 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 d52e689..68f9528 100644 --- a/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt +++ b/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt @@ -27,8 +27,8 @@ import java.util.zip.ZipOutputStream * "Download ZIP", a folder zipped on a laptop — is imported as a new Python project with * its contents as the source code. * - * Unlike a backup (.rcpkg) this is meant to leave the device, so it never contains a secret: - * the vault stays behind, and a secret variable is exported only as its `${SEC_...}` reference. + * The vault stays on the device, and a secret variable is exported only as its `${SEC_...}` + * reference. Project files are copied unchanged and may contain user-written sensitive data. */ class ProjectArchive( private val context: Context, 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 9e06630..897dd7f 100644 --- a/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt +++ b/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt @@ -57,7 +57,9 @@ class ServiceSupervisor( } suspend fun startProject(project: Project): Boolean = mutex.withLock { - if (activeHandles[project.id]?.isAlive == true) return false + // The lifecycle watcher still owns an exited worker until it records the outcome. + // Cancelling it here would leave RUNNING state with no worker and no watcher. + if (activeHandles.containsKey(project.id)) return false supervisorJobs.remove(project.id)?.cancel() startProjectLocked(project, 0) } @@ -452,7 +454,7 @@ class ServiceSupervisor( } suspend fun stopAll() { - activeHandles.keys().toList().forEach { projectId -> + (activeHandles.keys + supervisorJobs.keys).toSet().forEach { projectId -> stopProject(projectId) } } 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 bcfa432..5a1b889 100644 --- a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt @@ -23,7 +23,7 @@ class McpSettingsTest { private val notifications = mutableListOf() @Before fun setup() { - app = RuntimeEnvironment.getApplication() + app = RuntimeEnvironment.getApplication() as RuncodeApp project = app.projectStorage.createProjectFromTemplate("MCP settings", ProjectProfile.PYTHON_SCRIPT).copy( environment = listOf(EnvironmentVariable("TOKEN", "\${SEC_PROJECT_test_reference}", true), EnvironmentVariable("OLD", "old", false)) 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 63c4aad..dfac65e 100644 --- a/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt +++ b/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt @@ -45,7 +45,7 @@ class ServiceSupervisorTest { } private fun TestScope.supervisor(engine: Engine): ServiceSupervisor { - val context = RuntimeEnvironment.getApplication() + val context = RuntimeEnvironment.getApplication() return ServiceSupervisor(context, RuntimeRegistry(engine, engine), LogManager(context), PortManager(), backgroundScope) } @@ -53,6 +53,7 @@ class ServiceSupervisorTest { val engine = Engine(Handle()) val supervisor = supervisor(engine) supervisor.startProject(project) + assertFalse(supervisor.startProject(project)) advanceTimeBy(60_000) runCurrent() assertEquals(5, engine.starts) @@ -90,6 +91,19 @@ class ServiceSupervisorTest { assertEquals(1, engine.starts) } + @Test fun `stop all includes services waiting to restart`() = runTest { + val engine = Engine(Handle()) + val supervisor = supervisor(engine) + supervisor.startProject(project) + advanceTimeBy(3_000) + runCurrent() + supervisor.stopAll() + advanceTimeBy(30_000) + runCurrent() + assertEquals(1, engine.starts) + assertEquals(ServiceState.STOPPED, supervisor.instances.value[project.id]?.state) + } + @Test fun `always restarts successful scripts but on failure does not`() = runTest { val engine = Engine(Handle(reason = ExitReason.COMPLETED)) val supervisor = supervisor(engine) diff --git a/app/src/test/java/com/runcode/app/ui/EditorSessionTest.kt b/app/src/test/java/com/runcode/app/ui/EditorSessionTest.kt index c74f305..66f33d7 100644 --- a/app/src/test/java/com/runcode/app/ui/EditorSessionTest.kt +++ b/app/src/test/java/com/runcode/app/ui/EditorSessionTest.kt @@ -34,7 +34,7 @@ class EditorSessionTest { } @Test fun `switching projects saves to the old owner and clears its tabs`() { - val app = RuntimeEnvironment.getApplication() + val app = RuntimeEnvironment.getApplication() as RuncodeApp val a = create(app, "project-a") val b = create(app, "project-b") val model = MainViewModel(app) @@ -51,7 +51,7 @@ class EditorSessionTest { } @Test fun `failed save prevents project switch and keeps the buffer`() { - val app = RuntimeEnvironment.getApplication() + val app = RuntimeEnvironment.getApplication() as RuncodeApp val a = create(app, "save-failure-a") val b = create(app, "save-failure-b") val model = MainViewModel(app) diff --git a/review-notes.md b/review-notes.md new file mode 100644 index 0000000..2f05ea4 --- /dev/null +++ b/review-notes.md @@ -0,0 +1,49 @@ +# Review notes + +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. + +## Fixes + +| Problem | Change | +| --- | --- | +| Switching projects could save an old buffer to the new project or discard it. | Save before changing ownership, serialize editor transitions, clear old tabs and keep the buffer when saving fails. | +| Each restart reset the crash counter. Stop during backoff did not cancel the pending restart. | Carry the retry count across starts, cancel pending jobs and include them in Stop All. | +| Python was marked stopped after three seconds even when its thread was blocked. | Keep the handle and STOPPING state until the worker exits; refuse duplicate starts and premature deletion. | +| Python services and snippets shared working directory and environment variables. | Admit one Python workload at a time and restore environment, arguments, import paths and working directory after execution. Static web services remain concurrent. | +| Imported manifests could select an entry point outside source/ or reuse another project's vault reference. | Validate the entry point and require fresh values for imported secrets. | +| Telegram and Python HTTP templates printed simulated activity. | Ship an actual Telegram polling bot and an HTTP JSON server for new projects. Existing scripts remain unchanged. | +| Replacing an editor file deleted the old file before the replacement was ready. | Use a filesystem replacement operation without pre-deleting the target. | + +## Project settings + +- Projects has a settings dialog for environment variables, port, restart policy, boot startup + and CPU/heap/idle limits. A service must be stopped before saving. +- Secret values go to the Keystore-backed vault under separate project keys. Metadata stores + references only. Failed metadata saves roll back new vault entries; cancellation during a + commit cannot remove referenced entries. Replacing or removing a secret cleans up its old key. +- Existing secrets are never loaded into form fields. The dialog uses Android's secure window + flag. Known vault values are redacted from logs, crash reports and MCP text results. +- `update_project_settings` updates plain variables and settings. It preserves existing secrets + and rejects attempts to replace them. Changes notify the app's project view. + +## Validation + +- Five Python regression tests pass locally and on GitHub Actions, including an actual loopback + HTTP request, cancellation, early stop and concurrent-workload rejection. +- Android compilation, lint and JVM/Robolectric tests: in progress. +- API 36 UI, Android Keystore and physical-device validation: pending; no device was connected. +- Signed release: not created. This change remains a draft PR. + +## Remaining work + +- Complete the device checks before calling the settings phase released: save and replace a + secret, restart the app, run a project, verify redacted logs, test settings through MCP and + exercise editor switching and delayed stops. +- A real Telegram round trip needs a user-managed bot token and has not been tested. +- Multiple Python services need separate runtime processes. The embedded interpreter still + shares imported modules and app privileges; project scripts and MCP clients are trusted code, + not isolated tenants. Child threads started by user scripts are not isolated by the run gate. +- Exported project files are copied as written. The vault is excluded, but source/data files + can still contain secrets placed there by user code. +- Diagnostics, SAF backups, on-device Git and the Python debugger remain later roadmap phases. From cd42b1f34d7d34ca25a30b9fb8b77f516a9c07a1 Mon Sep 17 00:00:00 2001 From: hami9 Date: Tue, 29 Sep 2026 19:21:29 +0330 Subject: [PATCH 4/7] fix database test --- .../test/java/com/runcode/app/storage/ProjectStorageTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/runcode/app/storage/ProjectStorageTest.kt b/app/src/test/java/com/runcode/app/storage/ProjectStorageTest.kt index 9d8c4f2..48111e0 100644 --- a/app/src/test/java/com/runcode/app/storage/ProjectStorageTest.kt +++ b/app/src/test/java/com/runcode/app/storage/ProjectStorageTest.kt @@ -79,10 +79,13 @@ class ProjectStorageTest { @Test fun `metadata rejects secret plaintext`() { val project = storage.createProjectFromTemplate("meta", ProjectProfile.PYTHON_SCRIPT) - AppMetaDatabase(RuntimeEnvironment.getApplication()).use { db -> + val db = AppMetaDatabase(RuntimeEnvironment.getApplication()) + try { assertThrows(IllegalArgumentException::class.java) { runBlocking { db.insertOrUpdateProject(project.copy(environment = listOf(EnvironmentVariable("TOKEN", "plaintext", true)))) } } + } finally { + db.close() } } From af2da7f705fed5f01f57fd037685f488d3ddd660 Mon Sep 17 00:00:00 2001 From: hami9 Date: Tue, 29 Sep 2026 19:28:55 +0330 Subject: [PATCH 5/7] fix editor indentation --- .../java/com/runcode/app/ui/MainViewModel.kt | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 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 fb186e4..a324781 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -252,44 +252,44 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private suspend fun openFileLocked(projectId: String, relativePath: String) { if (_selectedProject.value?.id != projectId) return if (_activeTab.value == relativePath && _isDirty.value) return - try { - val info = withContext(Dispatchers.IO) { - app.projectStorage.inspect(projectId, relativePath, EDITOR_MAX_BYTES) + try { + val info = withContext(Dispatchers.IO) { + app.projectStorage.inspect(projectId, relativePath, EDITOR_MAX_BYTES) + } + val name = File(relativePath).name + when (info.kind) { + FileKind.DIRECTORY -> return + FileKind.BINARY -> { + _userMessage.value = "$name is a binary file (${ProjectStorage.formatSize(info.size)}) and can't be " + + "edited as text. Use ⋮ → Save a copy to get it out." + return } - val name = File(relativePath).name - when (info.kind) { - FileKind.DIRECTORY -> return - FileKind.BINARY -> { - _userMessage.value = "$name is a binary file (${ProjectStorage.formatSize(info.size)}) and can't be " + - "edited as text. Use ⋮ → Save a copy to get it out." - return - } - FileKind.TOO_LARGE -> { - _userMessage.value = "$name is ${ProjectStorage.formatSize(info.size)}, too large for the editor " + - "(limit ${ProjectStorage.formatSize(EDITOR_MAX_BYTES)}). Use ⋮ → Save a copy instead." - return - } - FileKind.TEXT, FileKind.MISSING -> Unit + FileKind.TOO_LARGE -> { + _userMessage.value = "$name is ${ProjectStorage.formatSize(info.size)}, too large for the editor " + + "(limit ${ProjectStorage.formatSize(EDITOR_MAX_BYTES)}). Use ⋮ → Save a copy instead." + return } + FileKind.TEXT, FileKind.MISSING -> Unit + } - val content = withContext(Dispatchers.IO) { app.projectStorage.readFile(projectId, relativePath) } - if (_selectedProject.value?.id != projectId) return - if (_isDirty.value) { - if (_activeTab.value == relativePath || !persistCurrentFile(announce = false)) return - } - val currentTabs = _openTabs.value.toMutableList() - if (!currentTabs.contains(relativePath)) { - currentTabs.add(relativePath) - _openTabs.value = currentTabs - } - _activeTab.value = relativePath - _editorContent.value = content - _isDirty.value = false - undoStack.clear() - redoStack.clear() - } catch (e: Exception) { - _userMessage.value = "Failed to open file: ${e.message}" + val content = withContext(Dispatchers.IO) { app.projectStorage.readFile(projectId, relativePath) } + if (_selectedProject.value?.id != projectId) return + if (_isDirty.value) { + if (_activeTab.value == relativePath || !persistCurrentFile(announce = false)) return } + val currentTabs = _openTabs.value.toMutableList() + if (!currentTabs.contains(relativePath)) { + currentTabs.add(relativePath) + _openTabs.value = currentTabs + } + _activeTab.value = relativePath + _editorContent.value = content + _isDirty.value = false + undoStack.clear() + redoStack.clear() + } catch (e: Exception) { + _userMessage.value = "Failed to open file: ${e.message}" + } } fun closeTab(tab: String) { From e29a4a71b91b5668380e7bfd15b8c0dc79d8e163 Mon Sep 17 00:00:00 2001 From: hami9 Date: Tue, 29 Sep 2026 19:32:15 +0330 Subject: [PATCH 6/7] update review notes --- review-notes.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/review-notes.md b/review-notes.md index 2f05ea4..e308a53 100644 --- a/review-notes.md +++ b/review-notes.md @@ -31,9 +31,15 @@ secrets and MCP, then implements the project-settings step from the supplied roa - Five Python regression tests pass locally and on GitHub Actions, including an actual loopback HTTP request, cancellation, early stop and concurrent-workload rejection. -- Android compilation, lint and JVM/Robolectric tests: in progress. +- All 32 JVM/Robolectric regression tests pass. They cover settings and vault rollback, + editor ownership, restart and stop behavior, archive boundaries, redaction and MCP settings. +- `testDebugUnitTest lintDebug assembleDebug` passes locally on code commit `af2da7f`. + Lint reports zero errors and 38 warnings, mainly dependency updates and unused resources. +- The debug APK is built and its Android Debug signature is verified. Its SHA-256 is + `79ec58edc23d6025d525ef6015dab79027e82e3c9580eb9409b091675481c44a`. + Local output: `app/build/outputs/apk/debug/app-debug.apk`. - API 36 UI, Android Keystore and physical-device validation: pending; no device was connected. -- Signed release: not created. This change remains a draft PR. +- Release-signed build: not created. This change remains [draft PR #1](https://github.com/hami9/runcode/pull/1). ## Remaining work From 1d419db45114dc4f27b707ffb3845a2436f61638 Mon Sep 17 00:00:00 2001 From: hami9 Date: Tue, 29 Sep 2026 19:38:45 +0330 Subject: [PATCH 7/7] prepare v1.4.0 --- app/build.gradle.kts | 4 ++-- review-notes.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 060d6f9..981e4ed 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 = 4 - versionName = "1.3.0" + versionCode = 5 + versionName = "1.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/review-notes.md b/review-notes.md index e308a53..9c2a07e 100644 --- a/review-notes.md +++ b/review-notes.md @@ -39,7 +39,8 @@ secrets and MCP, then implements the project-settings step from the supplied roa `79ec58edc23d6025d525ef6015dab79027e82e3c9580eb9409b091675481c44a`. Local output: `app/build/outputs/apk/debug/app-debug.apk`. - API 36 UI, Android Keystore and physical-device validation: pending; no device was connected. -- Release-signed build: not created. This change remains [draft PR #1](https://github.com/hami9/runcode/pull/1). +- These results describe the review build in [PR #1](https://github.com/hami9/runcode/pull/1). + Release build validation is recorded separately in the GitHub release notes. ## Remaining work