Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Check

on:
push:
branches: [main]
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
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
- 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/
- name: Save debug APK
if: success()
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/app-debug.apk
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ Thumbs.db

# Release signing material
keystore.properties

# Python checks
__pycache__/
*.pyc
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,11 +63,30 @@ 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.

## 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:
Expand All @@ -73,13 +97,18 @@ Authorization: Bearer <token from the System screen>
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:
Expand All @@ -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 `<secret>`, 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.
Expand All @@ -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).
Expand Down
11 changes: 9 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -84,6 +84,10 @@ android {
buildConfig = true
}

testOptions {
unitTests.isIncludeAndroidResources = true
}

packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
Expand Down Expand Up @@ -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")
}
23 changes: 23 additions & 0 deletions app/src/main/assets/templates/bot.py
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 28 additions & 0 deletions app/src/main/assets/templates/server.py
Original file line number Diff line number Diff line change
@@ -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()
12 changes: 12 additions & 0 deletions app/src/main/java/com/runcode/app/RuncodeApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>(extraBufferCapacity = 64)

Expand All @@ -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()
Expand All @@ -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,
Expand All @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Project> = withContext(Dispatchers.IO) {
Expand Down Expand Up @@ -166,6 +168,10 @@ class AppMetaDatabase(context: Context) : SQLiteOpenHelper(context, "runcode_met
private fun encodeEnvironment(environment: List<EnvironmentVariable>): 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)
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/runcode/app/logging/LogManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/runcode/app/mcp/McpToolHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
) {
Expand Down
Loading
Loading