diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7134c6..5fba9f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,13 +181,13 @@ jobs: - name: Build Android toolchain image run: docker build --file app/Dockerfile.build --tag rish-mcp-android-ci app - - name: Unit test and assemble debug APK + - name: Lint, unit test, and assemble debug APK run: >- docker run --rm --volume "${GITHUB_WORKSPACE}/app:/work" --workdir /work rish-mcp-android-ci - gradle --no-daemon testDebugUnitTest assembleDebug + gradle --no-daemon lintDebug testDebugUnitTest assembleDebug - name: Verify APK output run: test -f app/app/build/outputs/apk/debug/app-debug.apk diff --git a/README.md b/README.md index 77a3d92..477a6fb 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,10 @@ single **outbound** WebSocket to a relay on a public hostname you control; AIs call the relay's MCP endpoint. ``` - 상시 WS (일반 기기) -┌─────────┐ MCP ┌──────────────────┐ ◀───────────────── ┌──────────────┐ -│ AI │──HTTPS─▶│ Go relay + MCP │ │ Android 앱 │ -│(Claude) │ ◀───────│ 서버 │──FCM 웨이크업──────▶│ (저사양 기기) │ -└─────────┘ └──────────────────┘ (Google FCM 경유) └──────────────┘ +┌─────────┐ MCP ┌──────────────────┐ ◀── outbound WS ── ┌──────────────────┐ +│ AI │──HTTPS─▶│ Go relay + MCP │ │ Android app │ +│(Claude) │ ◀───────│ server │── exec / result ──▶│ Shizuku → ADB fb │ +└─────────┘ └──────────────────┘ └──────────────────┘ │ │ 버전/체크섬 조회, APK 배포 ▼ @@ -35,28 +34,29 @@ call the relay's MCP endpoint. > A signed, real-device-verified rewrite APK has not been published yet. Until > one is available, build the Android app from this checkout; see > [Release channels](docs/RELEASES.md) for the versioning boundary and gates. +> The current source version is **1.0.0** (Android `versionCode` 10000), but that is +> a source milestone—not an assertion that a signed `agent-v1.0.0` release is +> already available. ## Why rewrite -The old agent depended on [Shizuku](https://shizuku.rikka.app/) — a separate -app the user had to install, understand, and grant permission to, which also -meant devices that didn't support or know about Shizuku couldn't use rish-mcp -at all. See [`plan.md`](plan.md) for the full rationale (Shizuku dependency, -Wear OS performance, server code quality, no official version endpoint). +The old agent required [Shizuku](https://shizuku.rikka.app/) and had no +fallback. The rewrite first moved to an on-device ADB client; 1.0 combines the +two: Shizuku is the preferred owner-authorized backend, while paired ADB keeps +the app usable when Shizuku is absent or stopped. The relay was independently +rewritten in Go for a smaller, testable trust boundary. ## What's different this time -- **No Shizuku.** The Android app pairs with its own `adbd` directly — - wireless-debugging pairing on Android 11+, a PC+`adb tcpip` bridge below - that. See [`docs/DESIGN.md` §3.1](docs/DESIGN.md#31-셸-접근-페어링-shizuku-대체). +- **Two shell backends.** Shizuku is preferred after an explicit permission + grant. Paired on-device ADB is the automatic fallback — wireless-debugging + pairing on Android 11+, or a PC+`adb tcpip` bridge below that. - **Go relay**, not Node/TS — same MCP tool contracts (`run_shell`, `list_devices`), same WS relay protocol, same OAuth model, rewritten for concurrency/memory efficiency and a single static binary. -- **Hybrid connection model** (planned): normal phones/tablets keep an - always-on WebSocket; low-spec devices (Wear OS) are meant to move to an - FCM-wake + short session model instead. **Not implemented yet** — it needs - a Firebase project this repo doesn't have configured. Every device - currently uses the always-on path. +- **One honest connection model.** Every device currently keeps an outbound + WebSocket. The unused Firebase stub and SDK were removed; FCM wake will only + return if both the relay sender and a real Firebase project are implemented. ## Status @@ -64,9 +64,9 @@ Wear OS performance, server code quality, no official version endpoint). |---|---| | Go relay (`server/cmd/relay`) — MCP tools, WS relay, static bearer + OAuth | ✅ built, tested | | Official version server (`server/cmd/publicserver`) | ✅ built, tested | -| Android `AdbShellClient` (ADB pairing, shell exec) | ✅ built, tested (unit-testable parts only — no device to pair against in this environment) | -| `ConnectionManager` / `AgentService` / `MainActivity` (pairing UI) | ✅ built, compiles — **not verified against a real device** | -| Low-spec hybrid connection + FCM wake | ⛔ blocked — needs a Firebase project (see `docs/DESIGN.md` §7) | +| Android Shizuku + ADB fallback backends | ✅ built, router/policy tested — **not verified against a real device** | +| `ConnectionManager` / `AgentService` / `MainActivity` | ✅ duplicate-reconnect and command-overload guards; Docker build tested | +| Low-spec push wake | not shipped — no dead Firebase dependency or misleading stub in the APK | | Docker packaging for the Go binaries | ✅ `server/Dockerfile` (`--target relay` / `--target publicserver`) | | docker-compose / reverse-proxy deploy config | ✅ `docker-compose.yml` (Traefik/Dokploy) | | Signed rewrite APK release | ⛔ not published — legacy releases are incompatible | @@ -78,9 +78,9 @@ Wear OS performance, server code quality, no official version endpoint). bearer or OAuth for AIs, shared token for the device. - `server/cmd/publicserver` — Go. Separate, secret-free binary: reports the current agent version and serves the APK. No route to the relay. -- `app/` — Android (Kotlin). One installable APK: pairs with the device's own - `adbd` to run commands as shell uid, a foreground service holds the - outbound WS, auto-starts on boot. +- `app/` — Android (Kotlin). One installable APK: runs commands as shell uid + through Shizuku when authorized, otherwise through a paired local `adbd`; + a foreground service holds the outbound WS and auto-starts on boot. ## Quick start: local Android build and setup @@ -111,7 +111,7 @@ docker build --target publicserver -t rishmcp-public server # Android unit tests + debug APK (run from the repository root) docker build -t rishmcp-android-build -f app/Dockerfile.build app docker run --rm -v "$PWD/app:/work" -w /work rishmcp-android-build \ - gradle --no-daemon testDebugUnitTest assembleDebug + gradle --no-daemon lintDebug testDebugUnitTest assembleDebug # output: app/app/build/outputs/apk/debug/app-debug.apk ``` @@ -155,8 +155,8 @@ Same tool surface as before — this part of the contract didn't change: } ``` -- `list_devices()` — connected devices, agent version, connection age, and - pending-command count. +- `list_devices()` — connected devices, active shell backend, agent version, + connection age, and pending-command count. - `run_shell({cmd, deviceId?, timeoutMs?})` — run a command as shell uid; returns stdout, stderr, exit code. @@ -171,6 +171,10 @@ Full tool reference, OAuth flow, and the WS relay protocol are documented in inbound connections. - No root is required or used — shell access is uid 2000, same ceiling as `adb shell`. +- Shizuku access is optional and only becomes active after the device owner + grants this app permission; otherwise the paired ADB backend is used. +- Root-mode Shizuku is deliberately rejected. The agent binds only when the + Shizuku server reports uid 2000, preserving the documented shell ceiling. - Scope is the **owner's own device** for personal automation, same as before — see `plan.md`'s explicit "multi-tenant 아님" non-goal. diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index d8e5e33..57b0349 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -12,20 +12,20 @@ android { applicationId = "kr.scin.rishmcp" minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "0.1.0" + versionCode = 10000 + versionName = "1.0.0" } buildFeatures { + aidl = true buildConfig = true } - // Sideloaded personal app — skip the release lint gate (see before/app for - // the original rationale; it also tries to auto-install SDK bits into a - // read-only image SDK dir under the Docker build). + // Official signing remains a separate release gate, but debug lint errors + // must fail local/CI builds. lint { checkReleaseBuilds = false - abortOnError = false + abortOnError = true } compileOptions { @@ -47,29 +47,14 @@ dependencies { implementation("com.google.android.material:material:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") - // On-device ADB client (pairing + connect + shell), replacing Shizuku. - // See docs/DESIGN.md §2.1 and §3.1. + // Shell backends. Shizuku is preferred when the owner granted access; + // on-device ADB remains available as a no-Shizuku fallback. + implementation("dev.rikka.shizuku:api:13.1.5") + implementation("dev.rikka.shizuku:provider:13.1.5") implementation("com.github.MuntashirAkon:libadb-android:3.1.1") // Self-signed X.509 cert generation for the ADB auth key pair (no AOSP // sun.security.x509 classes on Android otherwise); used by AdbShellClient. implementation("com.github.MuntashirAkon:sun-security-android:1.1") - // Low-spec device wake path (docs/DESIGN.md §3.2, roadmap step 4). - // Harmless to depend on ahead of time: FcmWakeReceiver only does - // anything once a real google-services.json makes the plugin below - // active and Firebase actually initializes. - // Note: firebase-messaging-ktx is deprecated (its Kotlin extensions were - // merged into the base artifact) and has no version mapping in recent - // BoM releases — use firebase-messaging directly. - implementation(platform("com.google.firebase:firebase-bom:34.17.0")) - implementation("com.google.firebase:firebase-messaging") - testImplementation("junit:junit:4.13.2") } - -// Only apply Google Services once a real config file exists, so the build -// doesn't break before a Firebase project is wired up (docs/DESIGN.md §7). -// Swap for `google-services.json.example` locally to see what's expected. -if (file("google-services.json").exists()) { - apply(plugin = "com.google.gms.google-services") -} diff --git a/app/app/google-services.json.example b/app/app/google-services.json.example deleted file mode 100644 index b1a6a6b..0000000 --- a/app/app/google-services.json.example +++ /dev/null @@ -1,29 +0,0 @@ -{ - "project_info": { - "project_number": "000000000000", - "project_id": "your-firebase-project-id", - "storage_bucket": "your-firebase-project-id.appspot.com" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", - "android_client_info": { - "package_name": "kr.scin.rishmcp" - } - }, - "oauth_client": [], - "api_key": [ - { - "current_key": "REPLACE_WITH_YOUR_FIREBASE_API_KEY" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [] - } - } - } - ], - "configuration_version": "1" -} diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index 76a2e45..815b951 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -1,10 +1,9 @@ - + - - + @@ -16,8 +15,24 @@ + + + + + + + + - - - - - - + + diff --git a/app/app/src/main/aidl/kr/scin/rishmcp/IUserService.aidl b/app/app/src/main/aidl/kr/scin/rishmcp/IUserService.aidl new file mode 100644 index 0000000..eeed00b --- /dev/null +++ b/app/app/src/main/aidl/kr/scin/rishmcp/IUserService.aidl @@ -0,0 +1,9 @@ +package kr.scin.rishmcp; + +interface IUserService { + // Reserved by Shizuku for stopping a UserService process. + void destroy() = 16777114; + + // Runs `sh -c ` as uid 2000 and returns a JSON ShellResult. + String exec(String cmd, long timeoutMs) = 1; +} diff --git a/app/app/src/main/java/kr/scin/rishmcp/AdbShellClient.kt b/app/app/src/main/java/kr/scin/rishmcp/AdbShellClient.kt index 6401078..5144be6 100644 --- a/app/app/src/main/java/kr/scin/rishmcp/AdbShellClient.kt +++ b/app/app/src/main/java/kr/scin/rishmcp/AdbShellClient.kt @@ -33,16 +33,17 @@ import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.PrivateKey import java.security.SecureRandom +import java.security.interfaces.RSAPrivateCrtKey +import java.security.interfaces.RSAPublicKey import java.security.cert.Certificate import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate import java.security.spec.PKCS8EncodedKeySpec import java.util.Date -import java.util.Random import java.util.concurrent.atomic.AtomicBoolean /** - * On-device ADB shell client: replaces the old Shizuku UserService binding - * (before/app's ShellUserService.kt + IUserService.aidl). Wraps + * On-device ADB shell client used when Shizuku is unavailable. Wraps * libadb-android for wireless-debugging pairing and the standard ADB * connect/auth handshake, and layers [ShellV2Protocol] on top of its raw * stream API to get separated stdout/stderr and an exit code, which @@ -60,7 +61,7 @@ class AdbShellClient private constructor(context: Context) : AbsAdbConnectionMan setApi(Build.VERSION.SDK_INT) val existingKey = readPrivateKeyFromFile(context) val existingCert = readCertificateFromFile(context) - if (existingKey != null && existingCert != null) { + if (existingKey != null && existingCert != null && keyMatches(existingKey, existingCert)) { privateKey = existingKey certificate = existingCert } else { @@ -146,23 +147,27 @@ class AdbShellClient private constructor(context: Context) : AbsAdbConnectionMan // --- key/cert file persistence (ported from libadb-android's sample // AdbConnectionManager.java; see docs/DESIGN.md §3.1) --- - private fun readPrivateKeyFromFile(context: Context): PrivateKey? { + private fun readPrivateKeyFromFile(context: Context): PrivateKey? = runCatching { val file = File(context.filesDir, "adb_private.key") - if (!file.exists()) return null + if (!file.exists()) return@runCatching null val bytes = file.readBytes() val keyFactory = KeyFactory.getInstance("RSA") - return keyFactory.generatePrivate(PKCS8EncodedKeySpec(bytes)) - } + keyFactory.generatePrivate(PKCS8EncodedKeySpec(bytes)) + }.getOrNull() private fun writePrivateKeyToFile(context: Context, key: PrivateKey) { File(context.filesDir, "adb_private.key").writeBytes(key.encoded) } - private fun readCertificateFromFile(context: Context): Certificate? { + private fun readCertificateFromFile(context: Context): Certificate? = runCatching { val file = File(context.filesDir, "adb_cert.pem") - if (!file.exists()) return null - return FileInputStream(file).use { CertificateFactory.getInstance("X.509").generateCertificate(it) } - } + if (!file.exists()) return@runCatching null + val certificate = FileInputStream(file).use { + CertificateFactory.getInstance("X.509").generateCertificate(it) + } + (certificate as? X509Certificate)?.checkValidity() + certificate + }.getOrNull() private fun writeCertificateToFile(context: Context, certificate: Certificate) { val file = File(context.filesDir, "adb_cert.pem") @@ -176,16 +181,23 @@ class AdbShellClient private constructor(context: Context) : AbsAdbConnectionMan } } + private fun keyMatches(key: PrivateKey, certificate: Certificate): Boolean { + val privateRsa = key as? RSAPrivateCrtKey ?: return false + val publicRsa = certificate.publicKey as? RSAPublicKey ?: return false + return privateRsa.modulus == publicRsa.modulus && + privateRsa.publicExponent == publicRsa.publicExponent + } + private fun generateKeyAndCert(): Pair { val keyPairGenerator = KeyPairGenerator.getInstance("RSA") - keyPairGenerator.initialize(2048, SecureRandom.getInstance("SHA1PRNG")) + keyPairGenerator.initialize(2048, SecureRandom()) val keyPair = keyPairGenerator.generateKeyPair() val publicKey = keyPair.public val privateKey = keyPair.private val algorithmName = "SHA512withRSA" val notBefore = Date() - val notAfter = Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000L) + val notAfter = Date(System.currentTimeMillis() + CERT_VALIDITY_MS) val x500Name = X500Name("CN=rish-mcp") val extensions = CertificateExtensions() @@ -197,7 +209,7 @@ class AdbShellClient private constructor(context: Context) : AbsAdbConnectionMan val certInfo = X509CertInfo() certInfo.set("version", CertificateVersion(2)) - certInfo.set("serialNumber", CertificateSerialNumber(Random().nextInt() and Int.MAX_VALUE)) + certInfo.set("serialNumber", CertificateSerialNumber(SecureRandom().nextInt() and Int.MAX_VALUE)) certInfo.set("algorithmID", CertificateAlgorithmId(AlgorithmId.get(algorithmName))) certInfo.set("subject", CertificateSubjectName(x500Name)) certInfo.set("key", CertificateX509Key(publicKey)) @@ -209,6 +221,8 @@ class AdbShellClient private constructor(context: Context) : AbsAdbConnectionMan certImpl.sign(privateKey, algorithmName) return privateKey to certImpl } + + private const val CERT_VALIDITY_MS = 10L * 365 * 24 * 60 * 60 * 1000 } } @@ -219,4 +233,14 @@ data class ShellResult( val stderr: String, val truncated: Boolean, val durationMs: Long, -) +) { + companion object { + fun unavailable(detail: String) = ShellResult( + code = -1, + stdout = "", + stderr = detail, + truncated = false, + durationMs = 0, + ) + } +} diff --git a/app/app/src/main/java/kr/scin/rishmcp/AgentService.kt b/app/app/src/main/java/kr/scin/rishmcp/AgentService.kt index d833ee5..e2672d6 100644 --- a/app/app/src/main/java/kr/scin/rishmcp/AgentService.kt +++ b/app/app/src/main/java/kr/scin/rishmcp/AgentService.kt @@ -3,20 +3,16 @@ package kr.scin.rishmcp import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder +import android.util.Log /** * Always-on foreground service. Holds the relay connection (via - * [ConnectionManager]) and the ADB shell session (via [AdbShellClient]). - * - * This used to own the WebSocket and the Shizuku UserService binding - * directly; that logic now lives in ConnectionManager/AdbShellClient, so - * this class stays a thin lifecycle + notification shell (docs/DESIGN.md - * §2.1: "역할 동일, AdbShellClient/ConnectionManager 사용하도록 내부 배선만 - * 교체"). + * [ConnectionManager]) and the Shizuku/ADB shell backends. */ class AgentService : Service() { @@ -28,9 +24,15 @@ class AgentService : Service() { super.onCreate() AgentState.serviceRunning = true startForeground(NOTIF_ID, buildNotification("starting…")) + val adbClient = runCatching { AdbShellClient.getInstance(this) } + .onFailure { + Log.e(TAG, "ADB backend initialization failed; Shizuku remains available", it) + AgentState.lastEvent = "ADB unavailable: ${it.message}" + } + .getOrNull() connectionManager = ConnectionManager( context = this, - shellClient = AdbShellClient.getInstance(this), + adbShellClient = adbClient, onStateChanged = ::updateNotif, ) connectionManager.start() @@ -68,10 +70,18 @@ class AgentService : Service() { val channel = NotificationChannel(CHANNEL, "rish-mcp agent", NotificationManager.IMPORTANCE_LOW) channel.setShowBadge(false) nm.createNotificationChannel(channel) + val openApp = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) return Notification.Builder(this, CHANNEL) .setContentTitle(if (DeviceProfile.isWatch(this)) "rish-mcp watch agent" else "rish-mcp agent") .setContentText(text) .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setContentIntent(openApp) + .setOnlyAlertOnce(true) .setOngoing(true) .build() } @@ -79,6 +89,7 @@ class AgentService : Service() { companion object { private const val CHANNEL = "rishmcp-agent" private const val NOTIF_ID = 42 + private const val TAG = "rishmcp-service" fun start(ctx: Context, reconnect: Boolean = false) { val intent = Intent(ctx, AgentService::class.java).putExtra("reconnect", reconnect) diff --git a/app/app/src/main/java/kr/scin/rishmcp/AgentState.kt b/app/app/src/main/java/kr/scin/rishmcp/AgentState.kt index 48a0996..750e6ee 100644 --- a/app/app/src/main/java/kr/scin/rishmcp/AgentState.kt +++ b/app/app/src/main/java/kr/scin/rishmcp/AgentState.kt @@ -5,7 +5,8 @@ object AgentState { enum class Conn { IDLE, CONNECTING, CONNECTED, DISCONNECTED } @Volatile var conn: Conn = Conn.IDLE - @Volatile var shell: String = "?" // "connected", "not paired", "connecting…", "connect failed" + @Volatile var shell: String = "?" + @Volatile var activeBackend: String = "none" // "shizuku", "adb", or "none" @Volatile var network: String = "?" // "wifi", "cellular", "other", "none" @Volatile var lastEvent: String = "" // short human note (last error / transition) @Volatile var connectedSince: Long = 0L diff --git a/app/app/src/main/java/kr/scin/rishmcp/CommandPolicy.kt b/app/app/src/main/java/kr/scin/rishmcp/CommandPolicy.kt new file mode 100644 index 0000000..fba54c9 --- /dev/null +++ b/app/app/src/main/java/kr/scin/rishmcp/CommandPolicy.kt @@ -0,0 +1,22 @@ +package kr.scin.rishmcp + +/** Agent-side limits that mirror (and defend independently of) the relay. */ +object CommandPolicy { + const val MAX_COMMAND_CHARS = 64 * 1024 + const val MAX_REQUEST_ID_CHARS = 256 + const val MAX_FRAME_CHARS = MAX_COMMAND_CHARS + 4 * 1024 + const val MIN_TIMEOUT_MS = 1_000L + const val MAX_TIMEOUT_MS = 600_000L + + fun validationError(requestId: String, command: String): String? = when { + requestId.isBlank() -> "reqId is blank" + requestId.length > MAX_REQUEST_ID_CHARS -> + "reqId too long (${requestId.length} > $MAX_REQUEST_ID_CHARS)" + command.isBlank() -> "cmd is blank" + command.length > MAX_COMMAND_CHARS -> + "cmd too long (${command.length} > $MAX_COMMAND_CHARS)" + else -> null + } + + fun clampTimeout(timeoutMs: Long): Long = timeoutMs.coerceIn(MIN_TIMEOUT_MS, MAX_TIMEOUT_MS) +} diff --git a/app/app/src/main/java/kr/scin/rishmcp/ConnectionManager.kt b/app/app/src/main/java/kr/scin/rishmcp/ConnectionManager.kt index d7ebfbc..44f6d13 100644 --- a/app/app/src/main/java/kr/scin/rishmcp/ConnectionManager.kt +++ b/app/app/src/main/java/kr/scin/rishmcp/ConnectionManager.kt @@ -13,8 +13,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import kr.scin.rishmcp.Prefs.adbHost -import kr.scin.rishmcp.Prefs.adbPort import kr.scin.rishmcp.Prefs.deviceToken import kr.scin.rishmcp.Prefs.relayUrl import okhttp3.OkHttpClient @@ -23,27 +21,28 @@ import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import org.json.JSONObject +import java.util.concurrent.Semaphore import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong /** - * Owns the relay WebSocket and routes "exec" frames to [AdbShellClient]. + * Owns the relay WebSocket and routes "exec" frames to the preferred shell + * backend (Shizuku first, on-device ADB as fallback). * Split out of AgentService so the service stays a thin foreground-service * shell (docs/DESIGN.md §2.1: "AdbShellClient/ConnectionManager 사용하도록 * 내부 배선만 교체"). * - * Every device kind currently uses the same always-on WebSocket, same as the - * old Shizuku agent. The low-spec/watch path in docs/DESIGN.md §3.2 — FCM - * wake + a short-lived session instead of an always-on socket — is roadmap - * step 4 and needs a Firebase project this app doesn't have wired up yet; - * this is where that branch goes once it exists. + * Every device kind currently uses the same always-on WebSocket. Watches use + * longer ping/heartbeat intervals through [DeviceProfile]. */ class ConnectionManager( private val context: Context, - private val shellClient: AdbShellClient, + adbShellClient: AdbShellClient?, private val onStateChanged: () -> Unit, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val shellBackends = ShellBackendManager(context, scope, adbShellClient, ::onShellStateChanged) private val main = Handler(Looper.getMainLooper()) private val http by lazy { OkHttpClient.Builder() @@ -56,7 +55,10 @@ class ConnectionManager( @Volatile private var ws: WebSocket? = null @Volatile private var stopped = false @Volatile private var connectedNetHandle = 0L + @Volatile private var publishedBackend: String? = null private val backoffMs = AtomicLong(1000) + private val reconnectScheduled = AtomicBoolean(false) + private val commandSlots = Semaphore(MAX_CONCURRENT_COMMANDS) /** * Monotonic generation counter for relay sockets. connectRelay() bumps it @@ -76,7 +78,7 @@ class ConnectionManager( fun start() { stopped = false registerNetworkCallback() - ensureShellConnected() + shellBackends.start() connectRelay() main.postDelayed(heartbeat, DeviceProfile.heartbeatMs(context)) } @@ -84,8 +86,13 @@ class ConnectionManager( fun stop() { stopped = true main.removeCallbacksAndMessages(null) - runCatching { ws?.close(1000, "service stopping") } + reconnectScheduled.set(false) + epochGate.next() + val closing = ws + ws = null + runCatching { closing?.close(1000, "service stopping") } runCatching { connectivity.unregisterNetworkCallback(netCallback) } + shellBackends.stop() scope.cancel() } @@ -93,7 +100,10 @@ class ConnectionManager( fun forceReconnect(reason: String) { if (stopped) return AgentState.lastEvent = "reconnect: $reason" + publishedBackend = null backoffMs.set(1000) + main.removeCallbacks(reconnect) + reconnectScheduled.set(false) // Bump the epoch BEFORE tearing down the socket: any callback the old // socket still fires (onFailure from the cancel, onClosed from the // close) then self-ignores, so it can neither schedule a duplicate @@ -101,38 +111,15 @@ class ConnectionManager( epochGate.next() runCatching { ws?.cancel() } ws = null - ensureShellConnected() + shellBackends.ensureConnected() connectRelay() } - // --- ADB shell connection ------------------------------------------------- - - private fun ensureShellConnected() { - if (shellClient.isConnected) return - val host = context.adbHost - val port = context.adbPort - if (port <= 0) { - AgentState.shell = "not paired" - onStateChanged() - return - } - scope.launch { - AgentState.shell = "connecting…" - onStateChanged() - AgentState.shell = try { - if (shellClient.connectDevice(host, port)) "connected" else "connect failed" - } catch (e: Throwable) { - Log.w(TAG, "adb connect failed", e) - "connect error: ${e.message}" - } - onStateChanged() - } - } - // --- relay WebSocket -------------------------------------------------------- private fun connectRelay() { if (stopped) return + if (ws != null) return val url = context.relayUrl val token = context.deviceToken if (url.isBlank() || token.isBlank()) { @@ -141,35 +128,28 @@ class ConnectionManager( onStateChanged() return } - val wsBase = when { - url.startsWith("ws") -> url - url.startsWith("http") -> "ws" + url.substring(4) - else -> "wss://$url" - } - val full = buildString { - append(wsBase) - append(if (wsBase.contains("?")) "&" else "?") - append("token=").append(token) - append("&deviceId=").append(Prefs.deviceId(context)) - append("&name=").append(Build.MODEL.replace(" ", "_")) - append("&sdk=").append(Build.VERSION.SDK_INT) - append("&kind=").append(DeviceProfile.kind(context)) - // Reported so the relay can flag agents older than the build it ships. - append("&ver=").append(BuildConfig.VERSION_NAME) - append("&vc=").append(BuildConfig.VERSION_CODE) + val full = buildRelayUrl(url, token) + if (full == null) { + AgentState.conn = AgentState.Conn.IDLE + AgentState.lastEvent = "invalid relay URL" + onStateChanged() + return } AgentState.conn = AgentState.Conn.CONNECTING onStateChanged() val epoch = epochGate.next() + publishedBackend = null ws = http.newWebSocket(Request.Builder().url(full).build(), listener(epoch)) } + private val reconnect = Runnable { + reconnectScheduled.set(false) + if (!stopped && ws == null && AgentState.conn != AgentState.Conn.CONNECTED) connectRelay() + } + private fun scheduleReconnect() { - if (stopped) return - main.postDelayed( - { if (AgentState.conn != AgentState.Conn.CONNECTED) connectRelay() }, - backoffMs.get(), - ) + if (stopped || !reconnectScheduled.compareAndSet(false, true)) return + main.postDelayed(reconnect, backoffMs.get()) backoffMs.set((backoffMs.get() * 2).coerceAtMost(30_000)) } @@ -185,39 +165,38 @@ class ConnectionManager( AgentState.connectedSince = System.currentTimeMillis() AgentState.lastEvent = "connected" onStateChanged() + publishBackend(webSocket) } override fun onMessage(webSocket: WebSocket, text: String) { if (stale(webSocket)) return + if (text.length > CommandPolicy.MAX_FRAME_CHARS) { + Log.w(TAG, "oversized agent frame ignored (${text.length} chars)") + return + } val msg = try { JSONObject(text) } catch (_: Throwable) { return } if (msg.optString("type") != "exec") return val reqId = msg.optString("reqId") val cmd = msg.optString("cmd") - val timeoutMs = msg.optLong("timeoutMs", 60_000) - // Validate cmd against relay-side limits (maxCmdLen = 64 KiB in - // the Go relay). An empty or oversized cmd is rejected before it - // reaches the shell, which avoids a pointless ADB stream open and - // mirrors the server's own validation. - if (cmd.isBlank()) { - sendError(webSocket, reqId, "cmd is blank") + val timeoutMs = CommandPolicy.clampTimeout(msg.optLong("timeoutMs", 60_000)) + CommandPolicy.validationError(reqId, cmd)?.let { detail -> + sendError(webSocket, reqId, detail) return } - if (cmd.length > MAX_CMD_LEN) { - sendError(webSocket, reqId, "cmd too long (${cmd.length} > $MAX_CMD_LEN)") + if (!commandSlots.tryAcquire()) { + sendError(webSocket, reqId, "agent is busy ($MAX_CONCURRENT_COMMANDS commands already running)") return } scope.launch { val result = try { - shellClient.exec(cmd, timeoutMs) - } catch (e: Throwable) { - Log.w(TAG, "exec failed", e) - ShellResult( - code = -1, - stdout = "", - stderr = "exec error: ${e.message}", - truncated = false, - durationMs = 0, - ) + try { + shellBackends.exec(cmd, timeoutMs) + } catch (e: Throwable) { + Log.w(TAG, "exec failed", e) + ShellResult.unavailable("exec error: ${e.message}") + } + } finally { + commandSlots.release() } AgentState.commandsRun++ AgentState.lastCommandAt = System.currentTimeMillis() @@ -252,6 +231,7 @@ class ConnectionManager( override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { if (stale(webSocket)) return + ws = null AgentState.conn = AgentState.Conn.DISCONNECTED AgentState.lastEvent = "disconnected: ${t.message}" onStateChanged() @@ -260,6 +240,7 @@ class ConnectionManager( override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { if (stale(webSocket)) return + ws = null AgentState.conn = AgentState.Conn.DISCONNECTED AgentState.lastEvent = "closed: $reason" onStateChanged() @@ -314,16 +295,51 @@ class ConnectionManager( private val heartbeat = object : Runnable { override fun run() { if (stopped) return - ensureShellConnected() - if (AgentState.conn != AgentState.Conn.CONNECTED && AgentState.conn != AgentState.Conn.CONNECTING) { + shellBackends.ensureConnected() + if (ws == null && AgentState.conn != AgentState.Conn.CONNECTED && + AgentState.conn != AgentState.Conn.CONNECTING + ) { + main.removeCallbacks(reconnect) + reconnectScheduled.set(false) connectRelay() } main.postDelayed(this, DeviceProfile.heartbeatMs(context)) } } + private fun buildRelayUrl(rawUrl: String, token: String) = RelayUrlPolicy.withAgentQuery( + rawUrl, + mapOf( + "token" to token, + "deviceId" to Prefs.deviceId(context), + "name" to Build.MODEL, + "sdk" to Build.VERSION.SDK_INT.toString(), + "kind" to DeviceProfile.kind(context), + "ver" to BuildConfig.VERSION_NAME, + "vc" to BuildConfig.VERSION_CODE.toString(), + "backend" to (shellBackends.activeKind?.wireName ?: "none"), + ), + ) + + private fun onShellStateChanged() { + onStateChanged() + ws?.let(::publishBackend) + } + + private fun publishBackend(webSocket: WebSocket) { + val backend = shellBackends.activeKind?.wireName ?: "none" + if (publishedBackend == backend) return + val sent = webSocket.send( + JSONObject() + .put("type", "status") + .put("backend", backend) + .toString(), + ) + if (sent) publishedBackend = backend + } + companion object { private const val TAG = "rishmcp" - private const val MAX_CMD_LEN = 64 * 1024 // 64 KiB, symmetric with relay maxCmdLen + private const val MAX_CONCURRENT_COMMANDS = 4 } } diff --git a/app/app/src/main/java/kr/scin/rishmcp/FcmWakeReceiver.kt b/app/app/src/main/java/kr/scin/rishmcp/FcmWakeReceiver.kt deleted file mode 100644 index accb74e..0000000 --- a/app/app/src/main/java/kr/scin/rishmcp/FcmWakeReceiver.kt +++ /dev/null @@ -1,55 +0,0 @@ -package kr.scin.rishmcp - -import android.util.Log -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -/** - * Low-spec device wake path (docs/DESIGN.md §3.2, roadmap step 4): when the - * relay has a queued command for a device that isn't holding an always-on - * WebSocket, it's meant to wake it via FCM instead of paying the battery - * cost of staying connected. This is the receiving end — starting - * AgentService for a short on-demand session on wake. - * - * Inert without a real Firebase project (docs/DESIGN.md §7): - * FirebaseMessagingService is only instantiated once google-services.json - * exists and the conditional plugin in app/build.gradle.kts actually - * initializes Firebase. - * - * Not yet wired even once that exists: - * - The relay has no endpoint to receive the token onNewToken produces, and - * no internal/fcm package to send wake pushes with — see the TODOs below. - * - ConnectionManager doesn't yet branch low-spec devices onto an on-demand - * connection instead of the always-on WS; it still just uses AgentService - * the same way for every device kind. - * This class is the shape the receiving side should have once those land. - */ -class FcmWakeReceiver : FirebaseMessagingService() { - - private val scope = CoroutineScope(Dispatchers.Default) - - override fun onNewToken(token: String) { - super.onNewToken(token) - // TODO(docs/DESIGN.md §3.2): report this token to the relay so it - // can address this device for a wake push. No relay-side endpoint - // exists yet to receive it (needs an internal/fcm package + a - // registration route alongside /agent). - Log.i(TAG, "new FCM token (not yet reported to relay)") - } - - override fun onMessageReceived(message: RemoteMessage) { - super.onMessageReceived(message) - if (message.data["type"] != "wake") return - Log.i(TAG, "wake push received; starting a short on-demand session") - scope.launch { - AgentService.start(applicationContext, reconnect = true) - } - } - - companion object { - private const val TAG = "rishmcp-fcm" - } -} diff --git a/app/app/src/main/java/kr/scin/rishmcp/MainActivity.kt b/app/app/src/main/java/kr/scin/rishmcp/MainActivity.kt index 5aa5a84..b274d0b 100644 --- a/app/app/src/main/java/kr/scin/rishmcp/MainActivity.kt +++ b/app/app/src/main/java/kr/scin/rishmcp/MainActivity.kt @@ -1,7 +1,6 @@ package kr.scin.rishmcp import android.Manifest -import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -20,12 +19,12 @@ import kr.scin.rishmcp.Prefs.adbPort import kr.scin.rishmcp.Prefs.deviceToken import kr.scin.rishmcp.Prefs.enabled import kr.scin.rishmcp.Prefs.relayUrl +import rikka.shizuku.Shizuku /** - * Provisioning UI. Replaces the old "Grant Shizuku" flow with ADB pairing: - * on Android 11+, wireless-debugging pairing (a code the user reads off - * Settings); below that, just the port from the PC+adb tcpip bridge - * (docs/DESIGN.md §3.1). Also handles headless `am start` provisioning. + * Provisioning UI for the preferred Shizuku backend and the on-device ADB + * fallback. Headless `am start` provisioning is isolated in + * [ProvisioningActivity]. */ class MainActivity : AppCompatActivity() { @@ -44,6 +43,19 @@ class MainActivity : AppCompatActivity() { private lateinit var pairingPortField: TextInputEditText private lateinit var pairingCodeField: TextInputEditText private lateinit var connectPortField: TextInputEditText + private lateinit var shizukuButton: MaterialButton + + private val shizukuPermissionListener = Shizuku.OnRequestPermissionResultListener { requestCode, result -> + if (requestCode != SHIZUKU_PERMISSION_REQUEST) return@OnRequestPermissionResultListener + // Shizuku does not promise that binder callbacks arrive on the main + // thread. Keep Toast/view updates and service interaction on it. + runOnUiThread { + val granted = result == PackageManager.PERMISSION_GRANTED + toast(if (granted) "Shizuku permission granted" else "Shizuku permission denied") + if (granted && AgentState.serviceRunning) AgentService.start(this, reconnect = true) + render() + } + } private val ui = Handler(Looper.getMainLooper()) private val ticker = object : Runnable { @@ -69,9 +81,10 @@ class MainActivity : AppCompatActivity() { pairingPortField = findViewById(R.id.pairingPortField) pairingCodeField = findViewById(R.id.pairingCodeField) connectPortField = findViewById(R.id.connectPortField) + shizukuButton = findViewById(R.id.btnShizuku) findViewById(R.id.subtitle).text = - if (DeviceProfile.isWatch(this)) "Wear OS · ADB shell → MCP" else "ADB shell → MCP agent" + if (DeviceProfile.isWatch(this)) "Wear OS · Shizuku / ADB → MCP" else "Shizuku / ADB shell → MCP" relayField.setText(relayUrl) tokenField.setText(deviceToken) @@ -87,6 +100,7 @@ class MainActivity : AppCompatActivity() { "Android 11 미만: PC에서 adb로 'adb tcpip '를 1회 실행한 뒤, 그 포트만 아래에 입력하세요" } + shizukuButton.setOnClickListener { requestShizuku() } findViewById(R.id.btnPair).setOnClickListener { pairAdb() } findViewById(R.id.btnSaveAdbPort).setOnClickListener { saveAdbPort() } findViewById(R.id.btnStart).setOnClickListener { saveAndStart() } @@ -95,69 +109,64 @@ class MainActivity : AppCompatActivity() { } findViewById(R.id.btnTest).setOnClickListener { runTest() } + Shizuku.addRequestPermissionResultListener(shizukuPermissionListener) maybeRequestNotifications() - handleProvisioning(intent) - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - setIntent(intent) - handleProvisioning(intent) } override fun onResume() { super.onResume(); ui.post(ticker) } override fun onPause() { super.onPause(); ui.removeCallbacks(ticker) } - /** - * Headless provisioning from a shell: - * am start -n kr.scin.rishmcp/.MainActivity \ - * --es relay wss://mcp.example.com/agent --es token \ - * --ei adbPort --ez autostart true - */ - private fun handleProvisioning(intent: Intent?) { - intent ?: return - if (!isShellProvisioningCaller(intent)) return - - var changed = false - intent.getStringExtra("relay")?.let { relayUrl = it; relayField.setText(it); changed = true } - intent.getStringExtra("token")?.let { deviceToken = it; tokenField.setText(it); changed = true } - if (intent.hasExtra("adbPort")) { - val port = intent.getIntExtra("adbPort", 0) - if (port > 0) { - adbPort = port - connectPortField.setText(port.toString()) - changed = true + override fun onDestroy() { + runCatching { Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener) } + super.onDestroy() + } + + private fun requestShizuku() { + val running = runCatching { Shizuku.pingBinder() }.getOrDefault(false) + if (!running) { + val launch = packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE) + if (launch != null) { + startActivity(launch) + toast("Start Shizuku, then return to rish-mcp") + } else { + toast("Install and start Shizuku, or use ADB fallback") } + return } - if (intent.getBooleanExtra("autostart", false)) { - enabled = true - AgentService.start(this, reconnect = true) - toast("provisioned & started") - } else if (changed) { - toast("config received") + if (runCatching { Shizuku.isPreV11() }.getOrDefault(true)) { + toast("Shizuku server API v11+ is required") + return } - render() + if (runCatching { Shizuku.getUid() }.getOrDefault(-1) != Process.SHELL_UID) { + toast("Root-mode Shizuku is rejected; start Shizuku in ADB mode") + return + } + val granted = runCatching { + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + }.getOrDefault(false) + if (granted) { + if (AgentState.serviceRunning) AgentService.start(this, reconnect = true) + toast("Shizuku is ready") + return + } + if (runCatching { Shizuku.shouldShowRequestPermissionRationale() }.getOrDefault(false)) { + toast("Grant rish-mcp from Shizuku's authorized applications screen") + startShizukuManager() + return + } + runCatching { Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST) } + .onFailure { toast("Unable to request Shizuku permission: ${it.message}") } } - /** - * Only the adb shell (uid 2000) may use the unattended provisioning - * extras. The launcher start carries no extras and is always allowed; - * `am start` from any other app is ignored. `getLaunchedFromUid()` is - * available from API 1 and returns: - * - `-1` when launched from the launcher (no extras → ignored below) - * - `Process.SHELL_UID` (2000) when launched from `adb shell am start` - * - any other uid when launched from a third-party app (rejected) - */ - private fun isShellProvisioningCaller(intent: Intent): Boolean { - if (intent.extras == null) return false - return getLaunchedFromUid() == Process.SHELL_UID + private fun startShizukuManager() { + packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE)?.let(::startActivity) } private fun pairAdb() { val port = pairingPortField.text.toString().trim().toIntOrNull() val code = pairingCodeField.text.toString().trim() - if (port == null || port <= 0 || code.isBlank()) { - toast("pairing port와 code를 입력하세요") + if (port == null || port !in VALID_PORTS || !PAIRING_CODE.matches(code)) { + toast("1–65535 포트와 6자리 pairing code를 입력하세요") return } lifecycleScope.launch { @@ -176,8 +185,8 @@ class MainActivity : AppCompatActivity() { private fun saveAdbPort() { val port = connectPortField.text.toString().trim().toIntOrNull() - if (port == null || port <= 0) { - toast("포트를 입력하세요") + if (port == null || port !in VALID_PORTS) { + toast("1–65535 범위의 포트를 입력하세요") return } adbPort = port @@ -187,8 +196,18 @@ class MainActivity : AppCompatActivity() { } private fun saveAndStart() { - relayUrl = relayField.text.toString().trim() - deviceToken = tokenField.text.toString().trim() + val relay = relayField.text.toString().trim() + val token = tokenField.text.toString().trim() + if (RelayUrlPolicy.parse(relay) == null) { + toast("올바른 relay URL을 입력하세요") + return + } + if (token.isEmpty()) { + toast("device token을 입력하세요") + return + } + relayUrl = relay + deviceToken = token enabled = true AgentService.start(this, reconnect = true) toast("agent started") @@ -219,7 +238,15 @@ class MainActivity : AppCompatActivity() { uptime.text = if (s.conn == AgentState.Conn.CONNECTED && s.connectedSince > 0) "up ${fmtDuration(System.currentTimeMillis() - s.connectedSince)}" else "" - rowShell.text = "ADB shell: ${s.shell}" + rowShell.text = "Shell: ${s.shell}" + val shizuku = shizukuStatus() + shizukuButton.text = when (shizuku) { + "ready" -> "Shizuku ready" + "permission needed" -> "Grant Shizuku" + "root mode rejected" -> "Use Shizuku ADB mode" + else -> "Open Shizuku" + } + shizukuButton.isEnabled = shizuku != "ready" rowNetwork.text = "Network: ${s.network}" rowDevice.text = "Device: ${Build.MODEL} · ${Prefs.deviceId(this)}" rowStats.text = "Commands: ${s.commandsRun}" + @@ -245,4 +272,23 @@ class MainActivity : AppCompatActivity() { } private fun toast(s: String) = Toast.makeText(this, s, Toast.LENGTH_SHORT).show() + + private fun shizukuStatus(): String { + if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) return "not running" + if (runCatching { Shizuku.isPreV11() }.getOrDefault(true)) return "server API too old" + if (runCatching { Shizuku.getUid() }.getOrDefault(-1) != Process.SHELL_UID) { + return "root mode rejected" + } + return if (runCatching { + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + }.getOrDefault(false) + ) "ready" else "permission needed" + } + + companion object { + private const val SHIZUKU_PERMISSION_REQUEST = 1001 + private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + private val VALID_PORTS = 1..65_535 + private val PAIRING_CODE = Regex("^[0-9]{6}$") + } } diff --git a/app/app/src/main/java/kr/scin/rishmcp/ProvisioningActivity.kt b/app/app/src/main/java/kr/scin/rishmcp/ProvisioningActivity.kt new file mode 100644 index 0000000..ca04e75 --- /dev/null +++ b/app/app/src/main/java/kr/scin/rishmcp/ProvisioningActivity.kt @@ -0,0 +1,54 @@ +package kr.scin.rishmcp + +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.os.Bundle +import kr.scin.rishmcp.Prefs.adbPort +import kr.scin.rishmcp.Prefs.deviceToken +import kr.scin.rishmcp.Prefs.enabled +import kr.scin.rishmcp.Prefs.relayUrl + +/** + * DUMP-protected entry point for `adb shell am start` provisioning. Keeping + * this separate means the launcher-exported MainActivity never consumes + * configuration extras from arbitrary apps. + */ +class ProvisioningActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + getLaunchedFromUid() != SHELL_UID + ) { + finish() + return + } + + intent.getStringExtra("relay") + ?.trim() + ?.takeIf { RelayUrlPolicy.parse(it) != null } + ?.let { relayUrl = it } + intent.getStringExtra("token") + ?.trim() + ?.takeIf(String::isNotEmpty) + ?.let { deviceToken = it } + if (intent.hasExtra("adbPort")) { + intent.getIntExtra("adbPort", 0).takeIf { it in VALID_PORTS }?.let { adbPort = it } + } + if (intent.getBooleanExtra("autostart", false)) { + enabled = true + AgentService.start(this, reconnect = true) + } + + startActivity( + Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + finish() + } + + companion object { + private const val SHELL_UID = 2000 + private val VALID_PORTS = 1..65_535 + } +} diff --git a/app/app/src/main/java/kr/scin/rishmcp/RelayUrlPolicy.kt b/app/app/src/main/java/kr/scin/rishmcp/RelayUrlPolicy.kt new file mode 100644 index 0000000..5b14525 --- /dev/null +++ b/app/app/src/main/java/kr/scin/rishmcp/RelayUrlPolicy.kt @@ -0,0 +1,27 @@ +package kr.scin.rishmcp + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** Normalizes owner-supplied relay URLs and safely replaces agent query data. */ +object RelayUrlPolicy { + fun parse(rawUrl: String): HttpUrl? { + val raw = rawUrl.trim() + if (raw.isEmpty()) return null + val normalized = when { + raw.startsWith("wss://", ignoreCase = true) -> "https://${raw.substring(6)}" + raw.startsWith("ws://", ignoreCase = true) -> "http://${raw.substring(5)}" + raw.startsWith("https://", ignoreCase = true) || + raw.startsWith("http://", ignoreCase = true) -> raw + "://" !in raw -> "https://$raw" + else -> return null + } + return normalized.toHttpUrlOrNull() + } + + fun withAgentQuery(rawUrl: String, values: Map): HttpUrl? { + val builder = parse(rawUrl)?.newBuilder() ?: return null + values.forEach { (name, value) -> builder.setQueryParameter(name, value) } + return builder.build() + } +} diff --git a/app/app/src/main/java/kr/scin/rishmcp/ShellBackend.kt b/app/app/src/main/java/kr/scin/rishmcp/ShellBackend.kt new file mode 100644 index 0000000..8a8af04 --- /dev/null +++ b/app/app/src/main/java/kr/scin/rishmcp/ShellBackend.kt @@ -0,0 +1,37 @@ +package kr.scin.rishmcp + +/** A command executor that provides adb-shell-equivalent privileges. */ +interface ShellBackend { + val kind: Kind + val isReady: Boolean + + suspend fun exec(cmd: String, timeoutMs: Long): ShellResult + + enum class Kind(val wireName: String) { + SHIZUKU("shizuku"), + ADB("adb"), + } +} + +/** + * Chooses a backend for one command. The choice is deliberately sticky for + * that invocation: if the selected backend dies after dispatch, the command + * is not replayed on the other backend because shell commands are not + * necessarily idempotent. + */ +class ShellBackendRouter( + private val shizuku: ShellBackend, + private val adb: ShellBackend, +) { + fun active(): ShellBackend? = when { + shizuku.isReady -> shizuku + adb.isReady -> adb + else -> null + } + + suspend fun exec(cmd: String, timeoutMs: Long): ShellResult { + val selected = active() + ?: return ShellResult.unavailable("no shell backend is ready; start/grant Shizuku or connect ADB") + return selected.exec(cmd, timeoutMs) + } +} diff --git a/app/app/src/main/java/kr/scin/rishmcp/ShellBackendManager.kt b/app/app/src/main/java/kr/scin/rishmcp/ShellBackendManager.kt new file mode 100644 index 0000000..723d83c --- /dev/null +++ b/app/app/src/main/java/kr/scin/rishmcp/ShellBackendManager.kt @@ -0,0 +1,103 @@ +package kr.scin.rishmcp + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kr.scin.rishmcp.Prefs.adbHost +import kr.scin.rishmcp.Prefs.adbPort +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Keeps both shell transports healthy. Shizuku is preferred because it avoids + * a loopback adbd port and survives wireless-debugging port changes; ADB is a + * transparent fallback when Shizuku is absent, stopped, or not authorized. + */ +class ShellBackendManager( + private val context: Context, + private val scope: CoroutineScope, + private val adbClient: AdbShellClient?, + private val onStateChanged: () -> Unit, +) { + private val shizukuClient = ShizukuShellClient(context, ::refreshStatus) + private val adbConnecting = AtomicBoolean(false) + private val adbBackend = object : ShellBackend { + override val kind = ShellBackend.Kind.ADB + override val isReady: Boolean get() = adbClient?.isConnected == true + override suspend fun exec(cmd: String, timeoutMs: Long) = + adbClient?.exec(cmd, timeoutMs) ?: ShellResult.unavailable("ADB backend failed to initialize") + } + private val router = ShellBackendRouter(shizukuClient, adbBackend) + + val activeKind: ShellBackend.Kind? + get() = router.active()?.kind + + fun start() { + shizukuClient.start() + ensureConnected() + } + + fun stop() { + shizukuClient.stop() + refreshStatus() + } + + fun ensureConnected() { + shizukuClient.ensureConnected() + if (!shizukuClient.isReady) ensureAdbConnected() + refreshStatus() + } + + suspend fun exec(cmd: String, timeoutMs: Long): ShellResult { + val selected = router.active() + ?: return ShellResult.unavailable( + "no shell backend is ready; start/grant Shizuku or connect ADB", + ) + AgentState.activeBackend = selected.kind.wireName + return selected.exec(cmd, timeoutMs) + } + + private fun ensureAdbConnected() { + val adb = adbClient ?: return + if (adb.isConnected || !adbConnecting.compareAndSet(false, true)) return + val host = context.adbHost + val port = context.adbPort + if (port <= 0) { + adbConnecting.set(false) + refreshStatus() + return + } + scope.launch { + refreshStatus("ADB connecting…") + try { + adb.connectDevice(host, port) + } catch (error: Throwable) { + Log.w(TAG, "ADB connect failed", error) + AgentState.lastEvent = "ADB connect error: ${error.message}" + } finally { + adbConnecting.set(false) + refreshStatus() + } + } + } + + private fun refreshStatus(override: String? = null) { + val active = router.active()?.kind + AgentState.activeBackend = active?.wireName ?: "none" + AgentState.shell = override ?: when (active) { + ShellBackend.Kind.SHIZUKU -> "Shizuku connected" + ShellBackend.Kind.ADB -> "ADB connected · Shizuku ${shizukuClient.state.label}" + null -> when { + adbConnecting.get() -> "ADB connecting… · Shizuku ${shizukuClient.state.label}" + adbClient == null -> "Shizuku ${shizukuClient.state.label} · ADB unavailable" + context.adbPort <= 0 -> "Shizuku ${shizukuClient.state.label} · ADB not paired" + else -> "Shizuku ${shizukuClient.state.label} · ADB disconnected" + } + } + onStateChanged() + } + + companion object { + private const val TAG = "rishmcp-shell" + } +} diff --git a/app/app/src/main/java/kr/scin/rishmcp/ShellUserService.kt b/app/app/src/main/java/kr/scin/rishmcp/ShellUserService.kt new file mode 100644 index 0000000..6e60157 --- /dev/null +++ b/app/app/src/main/java/kr/scin/rishmcp/ShellUserService.kt @@ -0,0 +1,91 @@ +package kr.scin.rishmcp + +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.nio.charset.StandardCharsets +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** Runs in the uid-2000 process created by Shizuku. */ +class ShellUserService : IUserService.Stub { + constructor() + + @Suppress("UNUSED_PARAMETER") + constructor(context: android.content.Context) + + override fun destroy() { + System.exit(0) + } + + override fun exec(cmd: String, timeoutMs: Long): String { + val startedAt = System.currentTimeMillis() + val safeTimeoutMs = timeoutMs.coerceIn(MIN_TIMEOUT_MS, MAX_TIMEOUT_MS) + var process: Process? = null + val readers = Executors.newFixedThreadPool(2) + return try { + process = ProcessBuilder("sh", "-c", cmd) + .redirectErrorStream(false) + .start() + process.outputStream.close() + + val stdout = readers.submit { drain(process.inputStream) } + val stderr = readers.submit { drain(process.errorStream) } + val finished = process.waitFor(safeTimeoutMs, TimeUnit.MILLISECONDS) + if (!finished) { + process.destroyForcibly() + process.waitFor(REAP_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + val out = stdout.get(READER_TIMEOUT_MS, TimeUnit.MILLISECONDS) + val err = stderr.get(READER_TIMEOUT_MS, TimeUnit.MILLISECONDS) + JSONObject() + .put("code", if (finished) process.exitValue() else -1) + .put("stdout", out.text) + .put("stderr", err.text) + .put("truncated", out.truncated || err.truncated || !finished) + .put("durationMs", System.currentTimeMillis() - startedAt) + .toString() + } catch (error: Throwable) { + process?.destroyForcibly() + if (error is InterruptedException) Thread.currentThread().interrupt() + JSONObject() + .put("code", -1) + .put("stdout", "") + .put("stderr", error.toString()) + .put("truncated", false) + .put("durationMs", System.currentTimeMillis() - startedAt) + .toString() + } finally { + readers.shutdownNow() + } + } + + private fun drain(stream: InputStream): DrainResult { + val buffer = ByteArray(8192) + // Most commands produce tiny output; grow on demand instead of + // reserving 256 KiB for each of stdout/stderr on every invocation. + val retained = ByteArrayOutputStream(8192) + var truncated = false + stream.use { input -> + while (true) { + val count = input.read(buffer) + if (count < 0) break + val remaining = MAX_BYTES - retained.size() + if (remaining > 0) retained.write(buffer, 0, minOf(count, remaining)) + if (count > remaining) truncated = true + } + } + return DrainResult(retained.toString(StandardCharsets.UTF_8.name()), truncated) + } + + private data class DrainResult(val text: String, val truncated: Boolean) + + companion object { + private const val MAX_BYTES = 256 * 1024 + private const val MIN_TIMEOUT_MS = 1_000L + private const val MAX_TIMEOUT_MS = 600_000L + private const val REAP_TIMEOUT_MS = 2_000L + private const val READER_TIMEOUT_MS = 2_000L + } +} diff --git a/app/app/src/main/java/kr/scin/rishmcp/ShizukuShellClient.kt b/app/app/src/main/java/kr/scin/rishmcp/ShizukuShellClient.kt new file mode 100644 index 0000000..3f75ebb --- /dev/null +++ b/app/app/src/main/java/kr/scin/rishmcp/ShizukuShellClient.kt @@ -0,0 +1,147 @@ +package kr.scin.rishmcp + +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.IBinder +import android.os.Process +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import rikka.shizuku.Shizuku + +/** Optional shell backend backed by a Shizuku UserService. */ +class ShizukuShellClient( + context: Context, + private val onStateChanged: () -> Unit, +) : ShellBackend { + enum class State(val label: String) { + STOPPED("stopped"), + NOT_RUNNING("not running"), + PERMISSION_REQUIRED("permission needed"), + UNSUPPORTED("server API too old"), + ROOT_REJECTED("root mode rejected"), + BINDING("binding…"), + CONNECTED("connected"), + ERROR("error"), + } + + private val appContext = context.applicationContext + private val userServiceArgs = Shizuku.UserServiceArgs( + ComponentName(appContext.packageName, ShellUserService::class.java.name), + ) + .daemon(false) + .tag("rish-mcp-shell-v1") + .processNameSuffix("shell") + .debuggable(BuildConfig.DEBUG) + .version(BuildConfig.VERSION_CODE) + + @Volatile private var service: IUserService? = null + @Volatile private var started = false + @Volatile var state: State = State.STOPPED + private set + + override val kind = ShellBackend.Kind.SHIZUKU + override val isReady: Boolean + get() = service != null && state == State.CONNECTED + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + service = IUserService.Stub.asInterface(binder) + updateState(State.CONNECTED) + Log.i(TAG, "Shizuku UserService connected") + } + + override fun onServiceDisconnected(name: ComponentName) { + service = null + updateState(State.ERROR) + Log.w(TAG, "Shizuku UserService disconnected") + } + } + + private val binderReceived = Shizuku.OnBinderReceivedListener { ensureConnected() } + private val binderDead = Shizuku.OnBinderDeadListener { + service = null + updateState(State.NOT_RUNNING) + } + + fun start() { + if (started) return + started = true + Shizuku.addBinderReceivedListenerSticky(binderReceived) + Shizuku.addBinderDeadListener(binderDead) + ensureConnected() + } + + fun stop() { + if (!started) return + started = false + runCatching { Shizuku.removeBinderReceivedListener(binderReceived) } + runCatching { Shizuku.removeBinderDeadListener(binderDead) } + runCatching { Shizuku.unbindUserService(userServiceArgs, serviceConnection, true) } + service = null + updateState(State.STOPPED) + } + + fun ensureConnected() { + if (!started || state == State.BINDING || isReady) return + val binderAlive = runCatching { Shizuku.pingBinder() }.getOrDefault(false) + if (!binderAlive) { + updateState(State.NOT_RUNNING) + return + } + if (runCatching { Shizuku.isPreV11() }.getOrDefault(true)) { + updateState(State.UNSUPPORTED) + return + } + val serverUid = runCatching { Shizuku.getUid() }.getOrDefault(-1) + if (serverUid != Process.SHELL_UID) { + updateState(State.ROOT_REJECTED) + return + } + val permissionGranted = runCatching { + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + }.getOrDefault(false) + if (!permissionGranted) { + updateState(State.PERMISSION_REQUIRED) + return + } + updateState(State.BINDING) + runCatching { Shizuku.bindUserService(userServiceArgs, serviceConnection) } + .onFailure { + Log.w(TAG, "Shizuku UserService bind failed", it) + updateState(State.ERROR) + } + } + + override suspend fun exec(cmd: String, timeoutMs: Long): ShellResult = withContext(Dispatchers.IO) { + val remote = service ?: return@withContext ShellResult.unavailable("Shizuku UserService is not connected") + try { + val value = JSONObject(remote.exec(cmd, timeoutMs)) + ShellResult( + code = value.optInt("code", -1), + stdout = value.optString("stdout"), + stderr = value.optString("stderr"), + truncated = value.optBoolean("truncated"), + durationMs = value.optLong("durationMs"), + ) + } catch (error: Throwable) { + Log.w(TAG, "Shizuku command failed", error) + service = null + updateState(State.ERROR) + ShellResult.unavailable("Shizuku command failed: ${error.message}") + } + } + + private fun updateState(next: State) { + if (state == next) return + state = next + onStateChanged() + } + + companion object { + private const val TAG = "rishmcp-shizuku" + } +} diff --git a/app/app/src/main/res/layout-watch/activity_main.xml b/app/app/src/main/res/layout-watch/activity_main.xml index 6ed3cbc..321c3ac 100644 --- a/app/app/src/main/res/layout-watch/activity_main.xml +++ b/app/app/src/main/res/layout-watch/activity_main.xml @@ -77,7 +77,7 @@ android:layout_height="wrap_content" android:alpha="0.7" android:text="" - android:textSize="10sp" /> + android:textSize="11sp" /> + android:textSize="11sp" /> - + + + + + + android:textSize="11sp" /> - + + + + + + diff --git a/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index a8a8fa5..0000000 --- a/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/app/src/main/res/values/colors.xml b/app/app/src/main/res/values/colors.xml index 3047090..c430f61 100644 --- a/app/app/src/main/res/values/colors.xml +++ b/app/app/src/main/res/values/colors.xml @@ -4,6 +4,5 @@ #123A2C #2ECC71 #F5A623 - #E74C3C #9AA0A6 diff --git a/app/app/src/main/res/xml/backup_rules.xml b/app/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..ce0b324 --- /dev/null +++ b/app/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/app/src/main/res/xml/data_extraction_rules.xml b/app/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..224515d --- /dev/null +++ b/app/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/app/src/test/java/kr/scin/rishmcp/CommandPolicyTest.kt b/app/app/src/test/java/kr/scin/rishmcp/CommandPolicyTest.kt new file mode 100644 index 0000000..977c628 --- /dev/null +++ b/app/app/src/test/java/kr/scin/rishmcp/CommandPolicyTest.kt @@ -0,0 +1,39 @@ +package kr.scin.rishmcp + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CommandPolicyTest { + @Test + fun `valid command passes unchanged`() { + assertNull(CommandPolicy.validationError("request-1", "id")) + assertEquals(60_000L, CommandPolicy.clampTimeout(60_000)) + } + + @Test + fun `blank and oversized identifiers are rejected`() { + assertEquals("reqId is blank", CommandPolicy.validationError("", "id")) + val oversized = "x".repeat(CommandPolicy.MAX_REQUEST_ID_CHARS + 1) + assertEquals( + "reqId too long (257 > 256)", + CommandPolicy.validationError(oversized, "id"), + ) + } + + @Test + fun `blank and oversized commands are rejected`() { + assertEquals("cmd is blank", CommandPolicy.validationError("1", " ")) + val oversized = "x".repeat(CommandPolicy.MAX_COMMAND_CHARS + 1) + assertEquals( + "cmd too long (65537 > 65536)", + CommandPolicy.validationError("1", oversized), + ) + } + + @Test + fun `timeouts are clamped to the supported execution window`() { + assertEquals(CommandPolicy.MIN_TIMEOUT_MS, CommandPolicy.clampTimeout(-1)) + assertEquals(CommandPolicy.MAX_TIMEOUT_MS, CommandPolicy.clampTimeout(Long.MAX_VALUE)) + } +} diff --git a/app/app/src/test/java/kr/scin/rishmcp/RelayUrlPolicyTest.kt b/app/app/src/test/java/kr/scin/rishmcp/RelayUrlPolicyTest.kt new file mode 100644 index 0000000..d02983b --- /dev/null +++ b/app/app/src/test/java/kr/scin/rishmcp/RelayUrlPolicyTest.kt @@ -0,0 +1,34 @@ +package kr.scin.rishmcp + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RelayUrlPolicyTest { + @Test + fun `websocket and bare relay addresses normalize for OkHttp`() { + assertEquals("https://relay.example/agent", RelayUrlPolicy.parse("wss://relay.example/agent").toString()) + assertEquals("http://relay.example/agent", RelayUrlPolicy.parse("ws://relay.example/agent").toString()) + assertEquals("https://relay.example/agent", RelayUrlPolicy.parse("relay.example/agent").toString()) + } + + @Test + fun `unsupported and malformed schemes are rejected`() { + assertNull(RelayUrlPolicy.parse("")) + assertNull(RelayUrlPolicy.parse("ftp://relay.example/agent")) + assertNull(RelayUrlPolicy.parse("wss://")) + } + + @Test + fun `agent query replaces hostile duplicates and encodes values`() { + val url = RelayUrlPolicy.withAgentQuery( + "wss://relay.example/agent?token=attacker&keep=yes", + mapOf("token" to "owner&secret", "name" to "Pixel 9/Pro"), + )!! + + assertEquals("owner&secret", url.queryParameter("token")) + assertEquals(1, url.queryParameterValues("token").size) + assertEquals("Pixel 9/Pro", url.queryParameter("name")) + assertEquals("yes", url.queryParameter("keep")) + } +} diff --git a/app/app/src/test/java/kr/scin/rishmcp/ShellBackendRouterTest.kt b/app/app/src/test/java/kr/scin/rishmcp/ShellBackendRouterTest.kt new file mode 100644 index 0000000..ea8b793 --- /dev/null +++ b/app/app/src/test/java/kr/scin/rishmcp/ShellBackendRouterTest.kt @@ -0,0 +1,75 @@ +package kr.scin.rishmcp + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class ShellBackendRouterTest { + @Test + fun `Shizuku is preferred when both backends are ready`() { + val shizuku = FakeBackend(ShellBackend.Kind.SHIZUKU, ready = true) + val adb = FakeBackend(ShellBackend.Kind.ADB, ready = true) + + val active = ShellBackendRouter(shizuku, adb).active() + + assertSame(shizuku, active) + } + + @Test + fun `ADB is used when Shizuku is unavailable`() = runBlocking { + val shizuku = FakeBackend(ShellBackend.Kind.SHIZUKU, ready = false) + val adb = FakeBackend(ShellBackend.Kind.ADB, ready = true) + + val result = ShellBackendRouter(shizuku, adb).exec("id", 1_000) + + assertEquals("adb", result.stdout) + assertEquals(0, shizuku.executions) + assertEquals(1, adb.executions) + } + + @Test + fun `failed Shizuku command is never replayed on ADB`() = runBlocking { + val shizuku = FakeBackend( + ShellBackend.Kind.SHIZUKU, + ready = true, + result = ShellResult.unavailable("binder died"), + ) + val adb = FakeBackend(ShellBackend.Kind.ADB, ready = true) + + val result = ShellBackendRouter(shizuku, adb).exec("touch /data/local/tmp/once", 1_000) + + assertEquals("binder died", result.stderr) + assertEquals(1, shizuku.executions) + assertEquals(0, adb.executions) + } + + @Test + fun `no ready backend returns a bounded error`() = runBlocking { + val router = ShellBackendRouter( + FakeBackend(ShellBackend.Kind.SHIZUKU, ready = false), + FakeBackend(ShellBackend.Kind.ADB, ready = false), + ) + + assertNull(router.active()) + val result = router.exec("id", 1_000) + assertEquals(-1, result.code) + assertEquals("", result.stdout) + assertEquals(false, result.truncated) + } + + private class FakeBackend( + override val kind: ShellBackend.Kind, + ready: Boolean, + private val result: ShellResult = ShellResult(0, kind.wireName, "", false, 1), + ) : ShellBackend { + override val isReady = ready + var executions = 0 + + override suspend fun exec(cmd: String, timeoutMs: Long): ShellResult { + executions++ + return result + } + } +} diff --git a/app/app/src/test/java/kr/scin/rishmcp/VersionTest.kt b/app/app/src/test/java/kr/scin/rishmcp/VersionTest.kt new file mode 100644 index 0000000..4ecb932 --- /dev/null +++ b/app/app/src/test/java/kr/scin/rishmcp/VersionTest.kt @@ -0,0 +1,13 @@ +package kr.scin.rishmcp + +import org.junit.Assert.assertEquals +import org.junit.Test + +class VersionTest { + + @Test + fun `build config exposes the 1_0 release identity`() { + assertEquals("1.0.0", BuildConfig.VERSION_NAME) + assertEquals(10000, BuildConfig.VERSION_CODE) + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8d959f..37f7fdf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,4 @@ plugins { id("com.android.application") version "8.6.1" apply false id("org.jetbrains.kotlin.android") version "2.0.20" apply false - // Applied conditionally in app/build.gradle.kts, only once a real - // google-services.json exists (docs/DESIGN.md §3.2 FCM wake path). - id("com.google.gms.google-services") version "4.5.0" apply false } diff --git a/cli/README.md b/cli/README.md index c6b05f3..72e6fcd 100644 --- a/cli/README.md +++ b/cli/README.md @@ -10,7 +10,7 @@ npx rish-mcp-setup --server= The empty `--server=` value forces a local APK build. This is currently required because GitHub releases through `v0.5.0` contain the legacy -Shizuku-based application, not the no-Shizuku rewrite. No global install, Go +legacy application, not the Go rewrite's isolated `agent-v` channel. No global install, Go toolchain, or host Android SDK is required, but local APK builds require Docker. ## What it does @@ -22,7 +22,7 @@ An arrow-key menu with four options: 3. **Start a relay server** — runs `server/cmd/relay` from a local checkout if you have one (via `go run` or a local Docker build), or falls back to pulling the prebuilt `ghcr.io/turin-dev/rish-mcp-relay` image if you don't. 4. **Exit** -One thing it deliberately does **not** do: drive the Android 11+ wireless-pairing handshake itself. That happens on-device, inside the app — the whole point of rish-mcp not needing Shizuku or a PC in the loop. A PC's `adb` is only load-bearing for installing the APK and for the pre-Android-11 `adb tcpip` bridge; this tool covers exactly those two things, then hands off to the app's own pairing screen. +One thing it deliberately does **not** do: drive the Android 11+ wireless-pairing handshake itself. That happens on-device inside the app. The app now prefers an explicitly authorized, ADB-mode Shizuku service and keeps on-device ADB as its fallback. A PC's `adb` is only load-bearing for installing the APK and for the pre-Android-11 `adb tcpip` bridge; this tool covers those steps, then hands off to the app's Shell access screen. ## Options diff --git a/cli/index.js b/cli/index.js index 0662f9e..18f79ef 100644 --- a/cli/index.js +++ b/cli/index.js @@ -13,7 +13,6 @@ import { emitKeypressEvents } from "node:readline"; import { stdin, stdout, exit, platform } from "node:process"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, chmodSync, rmSync, readdirSync, readFileSync } from "node:fs"; -import { copyFile as fsCopyFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { randomBytes } from "node:crypto"; @@ -289,29 +288,12 @@ function findRepoRoot() { } } -async function ensureGoogleServicesJSON(appDir) { - const target = path.join(appDir, "app", "google-services.json"); - if (existsSync(target)) { - console.log(good("google-services.json found -- FCM wake path will be built in")); - return; - } - console.log(dim("No app/app/google-services.json -- building without the FCM low-spec wake path.")); - console.log(dim("(Regular devices work fine without it; this only affects Wear OS-style wake.)")); - if (!(await promptYesNo("Do you have your own Firebase project's google-services.json to add?", false))) return; - const src = await prompt("Path to it:"); - if (!src) return; - await fsCopyFile(src, target); - console.log(good("copied -- FCM wake path will be built in")); -} - async function buildLocally() { if (!which("docker")) { throw new Error("docker not found -- install Docker Desktop, or pass --server to download a prebuilt APK instead"); } const repoRoot = findRepoRoot(); const appDir = path.join(repoRoot, "app"); - await ensureGoogleServicesJSON(appDir); - console.log(dim("docker build -t rishmcp-android-build -f Dockerfile.build " + appDir)); const build = spawnSync("docker", ["build", "-t", "rishmcp-android-build", "-f", "Dockerfile.build", "."], { cwd: appDir, @@ -512,12 +494,12 @@ async function runDeviceSetup(serverURL) { step(5, "configure the app"); console.log(dim("These get sent to the app as launch extras, not baked into the build --")); - console.log(dim("see docs/USAGE.md §3.3 (headless provisioning).")); + console.log(dim("see docs/USAGE.md §3.4 (headless provisioning).")); const relayURL = await prompt("Relay URL (e.g. wss://mcp.example.com/agent):"); const deviceToken = await prompt("Device token:"); if (relayURL && deviceToken) { const amArgs = [ - "shell", "am", "start", "-n", "kr.scin.rishmcp/.MainActivity", + "shell", "am", "start", "-n", "kr.scin.rishmcp/.ProvisioningActivity", "--es", "relay", relayURL, "--es", "token", deviceToken, "--ez", "autostart", "true", @@ -533,11 +515,14 @@ async function runDeviceSetup(serverURL) { console.log(dim("skipped -- you can fill these in from the app's Configuration card instead")); } - step(6, "pair the app"); + step(6, "authorize shell access"); + console.log("Recommended: start Shizuku in ADB mode, then tap Grant Shizuku in the"); + console.log("app's Shell access card. rish-mcp deliberately rejects root-mode Shizuku."); + console.log("If you prefer the built-in ADB fallback:"); if (is11Plus) { console.log("On the phone: Settings → Developer options → Wireless debugging → Pair device"); - console.log('with pairing code. Enter that port + 6-digit code in the app\'s "ADB shell'); - console.log('access" card, tap Pair. Then note the (different) port on the main Wireless'); + console.log('with pairing code. Enter that port + 6-digit code in the app\'s "Shell access"'); + console.log('card, tap Pair. Then note the (different) port on the main Wireless'); console.log('debugging screen, enter it under "Connect port", tap Save port.'); } else { console.log(`The bridge port (${bridgePort}) is already listening and, if you filled in step 5,`); diff --git a/cli/package-lock.json b/cli/package-lock.json index e020001..0fd8745 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "rish-mcp-setup", - "version": "0.8.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rish-mcp-setup", - "version": "0.8.1", + "version": "1.0.0", "license": "MIT", "dependencies": { "adm-zip": "^0.6.0" diff --git a/cli/package.json b/cli/package.json index 862b80b..8ec4d78 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "rish-mcp-setup", - "version": "0.8.1", + "version": "1.0.0", "description": "Interactive installer for the rish-mcp Android agent -- adb, pairing, APK, install.", "keywords": [ "rish-mcp", diff --git a/cli/test/version.test.js b/cli/test/version.test.js new file mode 100644 index 0000000..06ced7e --- /dev/null +++ b/cli/test/version.test.js @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; + +const execFileAsync = promisify(execFile); +const entrypoint = fileURLToPath(new URL("../index.js", import.meta.url)); + +test("--version reports the package's 1.0 release identity", async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, [entrypoint, "--version"]); + + assert.equal(stdout.trim(), "1.0.0"); + assert.equal(stderr, ""); +}); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2c2814e..e7428f0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -12,11 +12,10 @@ AI(Claude 등)가 가상머신이 아니라 사용자의 실제 Android 기기 개인용 도구이며, 관리자를 두는 멀티테넌트 서비스가 아니다. ``` - 상시 WS (일반 기기) -┌─────────┐ MCP ┌──────────────────┐ ◀───────────────── ┌──────────────┐ -│ AI │──HTTPS─▶│ Go relay + MCP │ │ Android 앱 │ -│(Claude) │ ◀───────│ 서버 │──FCM 웨이크업──────▶│ (저사양 기기) │ -└─────────┘ └──────────────────┘ (Google FCM 경유) └──────────────┘ +┌─────────┐ MCP ┌──────────────────┐ ◀── outbound WS ── ┌──────────────────┐ +│ AI │──HTTPS─▶│ Go relay + MCP │ │ Android 앱 │ +│(Claude) │ ◀───────│ 서버 │── exec / result ──▶│ Shizuku → ADB fb │ +└─────────┘ └──────────────────┘ └──────────────────┘ │ │ 버전/체크섬 조회, APK 배포 ▼ @@ -26,8 +25,9 @@ AI(Claude 등)가 가상머신이 아니라 사용자의 실제 Android 기기 ``` - 기기는 항상 **아웃바운드**로만 연결한다 (CGNAT 뒤에서도 동작, 인바운드 노출 없음). -- 일반 폰/태블릿은 상시 WebSocket 연결, WearOS 등 저사양 기기는 FCM으로 깨워서 - 짧게 연결하는 하이브리드 모델을 쓴다 (§3.2). +- 현재 모든 폼팩터가 상시 WebSocket을 사용한다. 구현되지 않은 FCM 수신 스텁과 SDK는 + APK에서 제거했으며, 실제 relay 발신 경로와 Firebase 프로젝트가 준비되기 전에는 + push-wake 기능이 있다고 표시하지 않는다. - relay는 개인이 셀프호스팅한다. 공식 서버는 버전 정보와 APK 배포만 담당하고 relay 기능은 포함하지 않는다 (`plan.md` 비목표: multi-tenant 아님). @@ -38,16 +38,15 @@ AI(Claude 등)가 가상머신이 아니라 사용자의 실제 Android 기기 | 모듈 | 상태 | 역할 | |---|---|---| -| `AdbShellClient.kt` | **신규** | 온디바이스 ADB 프로토콜 클라이언트. `ShellUserService.kt`(Shizuku AIDL 바인딩)를 대체. Android 11+는 무선 디버깅 페어링, 11 미만은 USB-tcpip 브리지로 셸(uid 2000) 권한 확보 | -| `ConnectionManager.kt` | **신규** | 기기 종류에 따라 상시 WS 유지 / FCM 웨이크업+폴백 폴링 중 라우팅 | -| `FcmWakeReceiver.kt` | **신규** | 저사양 기기에서 FCM 푸시 수신 → 짧은 WS 세션 시작 | -| `AgentService.kt` | 유지 | 포그라운드 서비스로 연결을 유지·감독 (역할 동일, `AdbShellClient`/`ConnectionManager` 사용하도록 내부 배선만 교체) | +| `ShizukuShellClient.kt` / `ShellUserService.kt` | **1.0** | 명시적 권한 승인 후 Shizuku UserService를 uid 2000으로 바인딩하는 우선 백엔드 | +| `AdbShellClient.kt` | **1.0 폴백** | 온디바이스 ADB 프로토콜 클라이언트. Android 11+ 무선 페어링, 11 미만 USB-tcpip 브리지 | +| `ShellBackendManager.kt` | **1.0** | Shizuku 우선/ADB 폴백 선택, 중복 ADB 연결 방지, 실행 중인 명령의 백엔드 재시도 금지 | +| `ConnectionManager.kt` | **신규** | 상시 WS, 단일 재연결 게이트, 네트워크 전환, 최대 4개 동시 명령 및 입력 제한 | +| `AgentService.kt` | 유지 | 포그라운드 서비스로 연결과 두 셸 백엔드를 유지·감독 | | `BootReceiver.kt` | 유지 | 부팅 시 자동 시작 | | `DeviceProfile.kt` | 유지 | 기기 종류(`android`/`watch`)·SDK·앱 버전 리포팅 | | `Prefs.kt` | 유지 | relay URL/토큰 등 로컬 설정 저장 | -| `MainActivity.kt` | 유지 | 프로비저닝 UI + `am start` extras(`relay`/`token`/`autostart`) 처리. 완전 무탭은 더 이상 목표가 아니므로(`plan.md` 비목표), 최초 1회 페어링 확인 화면이 추가됨 | - -`ShellUserService.kt`, Shizuku 관련 AIDL(`IUserService.aidl`)은 제거 대상이다. +| `MainActivity.kt` | 유지 | Shizuku 권한 + ADB 페어링 UI, 검증된 `am start` extras(`relay`/`token`/`adbPort`/`autostart`) 처리 | ### 2.2 Go relay + MCP 서버 @@ -73,14 +72,21 @@ MCP Go SDK가 비공식/미성숙이므로 `internal/mcp`는 JSON-RPC 메시지 --- ## 3. 핵심 플로우 -### 3.1 셸 접근 페어링 (Shizuku 대체) +### 3.1 셸 백엔드 선택 + +1. Shizuku binder가 실행 중이고 앱 권한이 승인되었으면 UserService를 바인딩해 우선 사용한다. + 단, Shizuku server uid가 정확히 2000일 때만 허용하며 root(uid 0)는 거부한다. +2. Shizuku가 없거나 중지/미승인 상태이면 이미 페어링된 온디바이스 ADB를 사용한다. +3. 명령을 전달한 뒤 binder/ADB가 끊겨도 다른 백엔드에서 같은 명령을 자동 재실행하지 + 않는다. `pm`, `settings put`, 파일 쓰기 같은 비멱등 명령의 중복 실행을 막기 위해서다. + +**ADB: Android 11 이상** -**Android 11 이상** 1. 사용자가 설정 > 개발자 옵션 > 무선 디버깅을 켜고 페어링 코드를 확인 2. rish-mcp 앱에 그 코드를 1회 입력 → `AdbShellClient`가 페어링 완료 3. 이후 앱이 자동으로 재연결·재프로비저닝 (재부팅 후에도 페어링 정보는 유지됨) -**Android 11 미만** +**ADB: Android 11 미만** 1. 무선 페어링 API 자체가 없으므로, PC + `adb`로 최초 1회 `adb tcpip`를 실행해 기기의 adbd를 TCP 리스닝 모드로 전환 (단순 충전 케이블 연결로는 불가) 2. 이후 앱이 `127.0.0.1:`로 자체 접속을 유지 @@ -89,11 +95,12 @@ MCP Go SDK가 비공식/미성숙이므로 `internal/mcp`는 JSON-RPC 메시지 ### 3.2 연결 모델 -- **일반 폰/태블릿**: 상시 WebSocket 연결 유지, ping 25초 주기 (기존과 동일) -- **저사양 기기(WearOS 등)**: 평소엔 연결을 끊어 두고, relay가 명령을 받으면 FCM으로 - 기기를 깨움 → 기기가 짧게 WS 연결해서 명령 실행·결과 반환 후 즉시 종료 - - FCM 전달 실패에 대비해 주기적 폴링을 폴백으로 유지 (주기는 **TBD**, 구현 시 확정) - - Wear OS 3+ 는 대부분 GMS를 탑재하므로 FCM 적용 가능을 전제로 함 +- 모든 기기는 foreground service에서 상시 outbound WebSocket을 유지한다. +- 핸드헬드는 20초, watch는 60초 ping을 사용하며 heartbeat는 각각 30초/90초다. +- epoch gate와 단일 지연 재연결 플래그가 죽은 소켓 callback, heartbeat, 네트워크 전환이 + 동시에 새 소켓을 만드는 것을 막는다. +- 앱은 한 번에 최대 4개 명령만 실행하고, 64 KiB 명령/256자 request id/600초 timeout + 제한을 relay와 독립적으로 다시 적용한다. ### 3.3 명령 실행 (`run_shell` / `list_devices`) @@ -106,12 +113,12 @@ run_shell({ cmd: string, deviceId?: string, timeoutMs?: number }) (isError = exit code !== 0) list_devices() - → [{ id, name, kind, sdk, agentVersion, agentVersionCode, + → [{ id, name, kind, sdk, agentVersion, agentVersionCode, shellBackend, connectedForMs, pending }] ``` -저사양 기기 경로에서도 응답 shape은 동일하다 — 다만 FCM 웨이크업 때문에 첫 명령의 -지연 시간이 상시 연결 기기보다 클 수 있다. +`shellBackend`는 현재 `shizuku`, `adb`, 또는 `unknown`이며 `status` 프레임으로 +연결 중에도 갱신된다. --- ## 4. API/프로토콜 명세 @@ -128,12 +135,14 @@ list_devices() // 기기 → relay { "type": "result", "reqId": "", "code": 0, "stdout": "...", "stderr": "", "truncated": false, "durationMs": 127 } + +// 활성 셸 백엔드가 바뀔 때 기기 → relay +{ "type": "status", "backend": "shizuku" } ``` -연결 쿼리 파라미터(`token`, `deviceId`, `name`, `sdk`, `kind`, `ver`, `vc`)와 keepalive -정책(일반 25초 / 기존 watch 60초 방식)은 일반 상시 연결 기기에 그대로 적용한다. 저사양 -기기는 FCM 웨이크업 이후 같은 프레임으로 짧은 세션만 수행하고 ping 루프 자체를 돌리지 -않는다. +연결 쿼리 파라미터는 `token`, `deviceId`, `name`, `sdk`, `kind`, `ver`, `vc`, `backend`다. +앱은 OkHttp `HttpUrl`로 값을 인코딩하고 기존 동명 쿼리를 교체해 토큰/기기명에 `&`, 공백 +등이 있어도 파라미터 경계가 깨지지 않게 한다. ### OAuth @@ -158,15 +167,17 @@ GET /agent.apk → APK 바이너리 (무토큰, IP당 rate limit) - `AI_TOKEN`은 AI 클라이언트용 마스터 키, `DEVICE_TOKEN`은 기기가 relay에 등록할 때 쓰는 공유 비밀 — 역할과 회전 방식 모두 기존과 동일하게 유지 - root 권한은 요구하지 않는다 (`plan.md` 비목표) — 셸 권한은 여전히 uid 2000 수준 +- root 모드 Shizuku도 사용하지 않는다. `Shizuku.getUid()`가 2000이 아니면 bind하지 않고 + ADB 폴백으로 전환한다. --- ## 6. 리소스·성능 목표 | 항목 | 목표 | 비고 | |---|---|---| -| 저사양 기기 유휴 배터리 소모 | 시간당 2~3% 이내 | 기존 Shizuku 방식(핑 25s/60s) 대비 개선 | -| 저사양 기기 유휴 메모리 | <50MB | | -| 저사양 기기 유휴 CPU | 웨이크업/폴링 순간에만 짧게 사용, 그 외 0% | | +| 저사양 기기 유휴 배터리 소모 | 실기기 측정 전 목표 미확정 | watch ping 60s / heartbeat 90s 적용 | +| 저사양 기기 유휴 메모리 | <50MB 목표 | Firebase SDK 제거, 실기기 계측 필요 | +| 셸 실행 동시성 | 최대 4 | 무제한 coroutine/process 생성 방지 | | 서버 동시 접속·명령 처리 지연 | 기존 Node/TS 대비 확실한 개선 | 정량 벤치마크는 구현 후 별도 측정 | --- @@ -177,15 +188,11 @@ GET /agent.apk → APK 바이너리 (무토큰, IP당 rate limit) (구현 완료: `internal/mcp`, `internal/oauth`) - **Android 11 미만 USB 페어링**: PC + adb가 실제로 필요하고, 재부팅 후 유지 여부는 ROM에 따라 다름 — 실기기 검증 전까지는 가정으로 취급 -- **FCM 하이브리드 연결이 통째로 보류 상태**: Firebase 프로젝트가 없어 §3.2/§8의 - 저사양 기기 웨이크업 경로를 구현할 수 없음. 재개하려면: (a) Firebase 프로젝트 - 생성, (b) `google-services.json`을 앱에 추가 + FCM SDK 의존성, (c) relay가 FCM - 발신 크리덴셜(서비스 계정 키)로 기기를 깨우는 서버측 로직, (d) - `ConnectionManager`에 이미 표시해둔 자리에 `FcmWakeReceiver.kt` 구현. 그 전까지 - 모든 기기가 상시 WS를 씀 -- **Android 앱은 실기기 미검증**: `AdbShellClient`/`ConnectionManager`/ - `MainActivity`는 컴파일·유닛 테스트(순수 로직 부분만)는 통과했지만, 실제 무선 - 페어링·연결·명령 실행은 이 개발 환경에 연결된 Android 기기가 없어 검증하지 못함 +- **Push wake 미제공**: relay sender/Firebase 프로젝트 없이 수신 클래스만 두는 것은 + 기능이 아니므로 SDK와 스텁을 제거했다. 다시 도입할 때는 등록 API, 토큰 회전/폐기, + 발신 인증정보 보관, 전달 실패 폴백, 실제 watch 배터리 계측을 한 변경으로 구현해야 한다. +- **Android 앱은 실기기 미검증**: Shizuku bind/권한, ADB 무선 페어링, fallback 전환, + 실제 명령 실행은 Docker 컴파일과 순수 로직 유닛 테스트만으로 증명되지 않는다. - **Go 서버 배포 구성 완료**: `server/Dockerfile`로 두 바이너리 이미지 빌드, `docker-compose.yml`(repo 루트)로 Traefik/Dokploy 배포 구성 완료. 컨테이너는 `read_only: true` + `tmpfs` + non-root `USER appuser`(uid 10001)로 하드닝됨 @@ -194,13 +201,12 @@ GET /agent.apk → APK 바이너리 (무토큰, IP당 rate limit) ## 8. 구현 로드맵 (제안 순서) 1. ✅ Go relay 골격 + MCP 툴 2개(`run_shell`, `list_devices`) + 정적 bearer 인증 -2. ✅ 앱 `AdbShellClient` (Android 11+ 무선 페어링 경로 우선) — libadb-android 기반, - `ConnectionManager`/`AgentService`/`MainActivity` 페어링 UI까지 배선 완료. - 실기기 검증은 아직 +2. ✅ Shizuku 우선 + `AdbShellClient` 폴백 — 권한 UI, AIDL UserService, + 중복 실행 없는 router, 포트/URL/명령 검증까지 배선. 실기기 검증은 아직 3. ✅ OAuth 레이어 이식 — `internal/oauth`, `/mcp`이 정적 bearer와 OAuth access token을 병행 수용 -4. ⛔ 저사양 기기 하이브리드 연결(`ConnectionManager` + FCM) — **보류** (§7 참고, - Firebase 프로젝트 필요) +4. ⏸ push-wake 연결 — 불완전 FCM SDK/스텁 제거. relay 발신 경로와 실기기 계측을 + 포함할 수 있을 때만 재개 5. ✅ 공식 버전 서버 — `cmd/publicserver` (`/healthz`, `/api/version/release`, `/agent.apk`), GitHub 릴리즈 폴링/캐싱(`internal/release`) 6. ✅ (코드 기준) Android 11 미만 USB 경로 — `MainActivity`/`Prefs.adbPort`가 이미 diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 6351490..0294fde 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -6,9 +6,9 @@ not share an implicit "latest APK" channel. ## Legacy releases GitHub tags `v0.2.0`, `v0.3.0`, and `v0.5.0` contain the previous -Shizuku-based Android agent and Node/TypeScript server. They are retained for +legacy Android agent and Node/TypeScript server. They are retained for existing users and historical reference, but they are not compatible release -artifacts for the current no-Shizuku rewrite. +artifacts for the current Go rewrite and its isolated `agent-v` channel. ## Rewrite agent releases @@ -46,7 +46,11 @@ The npm package `rish-mcp-setup` has its own independent semantic version. A CLI package version is not an Android agent version and must not be used to select an APK. -No signed rewrite APK has been published yet. +The current rewrite source identifies itself as `1.0.0`: the Android agent uses +`versionName 1.0.0` with monotonic `versionCode 10000`, the MCP server reports +`1.0.0`, and the npm setup CLI package is `1.0.0`. This source-version bump does +not publish an artifact. No signed rewrite APK has been published yet, and the +`agent-v1.0.0` tag remains reserved until every publication gate below passes. ## Publication gates @@ -57,6 +61,8 @@ An `agent-vX.Y.Z` release is ready only after all of the following are recorded: 3. The APK is signed with the official key and its signature is verified. 4. The tag suffix and APK `versionName` agree, and the APK `versionCode` is strictly greater than the most recently published rewrite agent. + Source releases use `MAJOR*10000 + MINOR*100 + PATCH`; 1.0.0 is therefore + 10000, safely above the legacy v0.5.0 package's code 5. 5. The `.apk` asset is no larger than 128 MiB, and its filename, SHA-256 checksum, and release metadata agree. 6. Pairing, relay connection, `list_devices`, and `run_shell` are exercised on diff --git a/docs/USAGE.md b/docs/USAGE.md index 7413280..03d0c9b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1,12 +1,12 @@ # rish-mcp — usage guide Companion to the [README](../README.md) and [`docs/DESIGN.md`](DESIGN.md). -This covers deploying the two Go binaries, pairing the Android agent without -Shizuku, the MCP tool reference, the OAuth flow, and the WS relay protocol. +This covers deploying the two Go binaries, authorizing the Android agent's +Shizuku/ADB shell backends, the MCP tool reference, OAuth, and the WS protocol. - [1. How it fits together](#1-how-it-fits-together) - [2. Deploy the relay](#2-deploy-the-relay) -- [3. Pair the Android agent](#3-pair-the-android-agent) +- [3. Authorize the Android agent](#3-authorize-the-android-agent) - [4. Connect an AI client](#4-connect-an-ai-client) - [5. Tool reference](#5-tool-reference) - [6. OAuth 2.0 reference](#6-oauth-20-reference) @@ -22,7 +22,7 @@ Shizuku, the MCP tool reference, the OAuth flow, and the WS relay protocol. ``` ┌─────────┐ MCP run_shell ┌──────────────────────┐ WS (outbound) ┌──────────────┐ │ AI │ ──HTTPS+auth────▶ │ Go relay + MCP │ ◀── phone dials ──│ phone APK │ -│(Claude) │ ◀── stdout/code── │ (server/cmd/relay) │ ── exec cmd ─────▶│ AdbShellClient│ +│(Claude) │ ◀── stdout/code── │ (server/cmd/relay) │ ── exec cmd ─────▶│Shizuku/ADB │ └─────────┘ └──────────────────────┘ └──────────────┘ ``` @@ -32,8 +32,8 @@ Shizuku, the MCP tool reference, the OAuth flow, and the WS relay protocol. `adb shell`. Root-only operations do not work. - **Output is capped at 256 KB per stream** (stdout/stderr) on the phone; overflow sets a `truncated` flag rather than erroring. -- The old design's Shizuku dependency is gone: the phone pairs with its own - `adbd` directly (§3), not through a separately-installed app. +- Shizuku is preferred after an explicit owner permission grant. A paired + on-device ADB connection is used automatically when Shizuku is unavailable. --- @@ -114,17 +114,34 @@ curl -s https://mcp.example.com/healthz --- -## 3. Pair the Android agent +## 3. Authorize the Android agent -No Shizuku app to install. The APK pairs directly with the phone's own -`adbd`. See [`docs/DESIGN.md` §3.1](DESIGN.md#31-셸-접근-페어링-shizuku-대체) -for the full rationale; this is the practical walkthrough. +The app supports two uid-2000 shell transports. Shizuku is recommended, +especially on Wear OS and Android versions where wireless-debugging ports +change after reboot. ADB remains a fully supported fallback. -### 3.1 Android 11+ (wireless debugging pairing) +### 3.1 Shizuku (recommended) + +1. Install and start [Shizuku](https://shizuku.rikka.app/) using its normal + wireless-debugging, USB, or rooted-device instructions. +2. Open rish-mcp and tap **Grant Shizuku** in the **Shell access** card. +3. Accept Shizuku's permission prompt, fill in **Relay URL** / **Device + token**, then tap **Save & Start**. + +The foreground service binds a Shizuku UserService running as shell uid 2000. +If Shizuku later stops, rish-mcp uses an already-paired ADB backend for new +commands. A command that was already dispatched is never automatically +replayed on another backend. + +rish-mcp deliberately rejects a Shizuku server running as root (uid 0). Start +Shizuku in normal ADB mode; the product's security contract is shell uid 2000, +not opportunistic root escalation. + +### 3.2 Android 11+ ADB fallback (wireless debugging pairing) 1. On the phone: **Settings → Developer options → Wireless debugging → Pair device with pairing code**. Note the port and 6-digit code shown. -2. In the rish-mcp app's **ADB shell access** card, enter that port + code, +2. In the rish-mcp app's **Shell access** card, enter that port + code, tap **Pair**. 3. Go back to the main **Wireless debugging** screen and note the port shown there (different from the pairing port — this one persists across @@ -132,7 +149,7 @@ for the full rationale; this is the practical walkthrough. 4. Fill in **Relay URL** / **Device token** in the Configuration card, tap **Save & Start**. -### 3.2 Android 11 미만 (USB + `adb tcpip` bridge) +### 3.3 Android 11 미만 ADB fallback (USB + `adb tcpip` bridge) Wireless pairing doesn't exist before Android 11. Instead: @@ -147,19 +164,23 @@ reboot, requiring the PC+`adb tcpip` step to be repeated. This is a documented, accepted limitation (`docs/DESIGN.md` §7), not something the app works around. -### 3.3 Headless provisioning +### 3.4 Headless provisioning The `am start` extras still work, now including `adbPort`: ```bash -adb shell am start -n kr.scin.rishmcp/.MainActivity \ +adb shell am start -n kr.scin.rishmcp/.ProvisioningActivity \ --es relay wss://mcp.example.com/agent --es token \ --ei adbPort --ez autostart true ``` -Pairing itself (entering the wireless pairing code) still needs a tap on the -device the first time — see `docs/DESIGN.md`'s explicit non-goal: full -headless/no-tap install is no longer a target now that Shizuku is gone. +ADB wireless pairing still needs a tap on the device the first time. A device +where the owner already granted rish-mcp in Shizuku can use that backend +without configuring `adbPort`. + +The dedicated activity is protected by Android's privileged `DUMP` permission, +which adb shell holds. The normal launcher activity ignores extras; +on Android 14+ the app also checks that the launching uid is exactly 2000. --- @@ -186,7 +207,8 @@ that stayed fixed across the rewrite. ```json [ { "id": "android-1a2b3c4d", "name": "SM-S911N", "kind": "android", - "sdk": "36", "agentVersion": "0.1.0", "agentVersionCode": 1, + "sdk": "36", "agentVersion": "1.0.0", "agentVersionCode": 10000, + "shellBackend": "shizuku", "connectedForMs": 84213, "pending": 0 } ] ``` @@ -239,7 +261,7 @@ the consent page — paste `AI_TOKEN` once. ```bash curl -s https://dl.example.com/api/version/release -# {"versionName":"0.1.0","versionCode":1,"tag":"agent-v0.1.0", +# {"versionName":"1.0.0","versionCode":10000,"tag":"agent-v1.0.0", # "sizeBytes":...,"sha256":"...","modifiedAt":"...","download":"/agent.apk"} curl -sO https://dl.example.com/agent.apk # no token needed @@ -249,7 +271,7 @@ It lists stable GitHub releases in the configured channel and selects the highest semantic version, rather than trusting GitHub creation order or the repository-wide `latest` release. A channel tag must be exactly `RELEASE_TAG_PREFIX` + `MAJOR.MINOR.PATCH`; the default prefix is `agent-v`, so -a rewrite release is tagged, for example, `agent-v0.1.0`. This separate channel +a rewrite release is tagged, for example, `agent-v1.0.0`. This separate channel intentionally excludes historical `v0.2`–`v0.5` releases, which contain the legacy Shizuku app rather than the rewrite agent. @@ -278,7 +300,10 @@ has no route to the relay and holds no tokens or device information. | Symptom | Likely cause / fix | |---|---| -| `healthz` shows `"devices":0` | Phone agent not connected. Check the app's ADB shell status row and relay/token config. | +| `healthz` shows `"devices":0` | Phone agent not connected. Check the app's Shell status row and relay/token config. | +| App shows `Shizuku permission needed` | Start Shizuku, tap **Grant Shizuku**, and approve the owner permission prompt. | +| App shows `Shizuku not running` | Start Shizuku again; the foreground service rebinds automatically. A configured ADB backend remains available as fallback. | +| App shows `root mode rejected` | Restart Shizuku in ADB mode. rish-mcp intentionally refuses a uid-0 UserService. | | `run_shell` → `no device is connected to the relay` | Same as above — WS dropped, or never connected. | | `run_shell` → `multiple devices connected; pass deviceId` | Call `list_devices` and pass the right `deviceId`. | | App shows `ADB shell: pairing failed` | Wrong pairing port/code, or the pairing window expired — re-open Wireless debugging pairing and retry. | @@ -290,7 +315,8 @@ has no route to the relay and holds no tokens or device information. ## Appendix A: WS relay protocol -Unchanged from the original design (`server/internal/relay`): +The command/result contract is unchanged; 1.0 adds a metadata-only status +frame so `list_devices` follows backend transitions without reconnecting: ```json // relay → device @@ -299,10 +325,13 @@ Unchanged from the original design (`server/internal/relay`): // device → relay { "type": "result", "reqId": "", "code": 0, "stdout": "...", "stderr": "", "truncated": false, "durationMs": 127 } + +// device → relay when the active backend changes +{ "type": "status", "backend": "shizuku" } ``` Connection query params on `GET /agent`: `token`, `deviceId`, `name`, `sdk`, -`kind` (`android` or `watch`), `ver`, `vc`. Ping interval: 25s general / 60s +`kind` (`android` or `watch`), `ver`, `vc`, `backend`. Ping interval: 25s general / 60s `kind=watch`; a device missing ~2.5 ping cycles is dropped. ## Appendix B: environment variables diff --git a/docs/testing-coverage-notes.md b/docs/testing-coverage-notes.md index 0f2d3a0..72f0b34 100644 --- a/docs/testing-coverage-notes.md +++ b/docs/testing-coverage-notes.md @@ -48,7 +48,6 @@ | `acquireAPK` | **86.7%** | 로컬 빌드 fallback + 다운로드 오류 | | `buildLocally` | **93.1%** | docker build 실패 / gradle 실패 / 성공 / no build output / empty outDir | | `ensureGoogleServicesJSON` | **100.0%** | 존재/비존재+비대화형 + 대화형 reject + empty path + copy success + copy fail | -| `copyFile` | 100.0% | | | `findRepoRoot` | 90.0% | | ### 테스트 기법 diff --git a/server/cmd/relay/main.go b/server/cmd/relay/main.go index 8d74856..464b919 100644 --- a/server/cmd/relay/main.go +++ b/server/cmd/relay/main.go @@ -25,6 +25,8 @@ import ( "github.com/turin-dev/rish-mcp/server/internal/relay" ) +const relayVersion = "1.0.0" + func main() { cfg, err := loadConfigFromEnv() if err != nil { @@ -134,7 +136,7 @@ func newMux( // contracts (name, args, response text shape) are carried over unchanged // from the old TS server — see docs/DESIGN.md §3.3. func buildMCPServer(reg *relay.Registry, defaultTimeout, maxTimeout time.Duration) *mcp.Server { - s := mcp.NewServer("rish-mcp", "0.1.0") + s := mcp.NewServer("rish-mcp", relayVersion) s.RegisterTool(mcp.Tool{ Name: "run_shell", diff --git a/server/cmd/relay/main_test.go b/server/cmd/relay/main_test.go index 5d5a65b..1f36791 100644 --- a/server/cmd/relay/main_test.go +++ b/server/cmd/relay/main_test.go @@ -19,6 +19,13 @@ func testOAuthProvider() *oauth.Provider { return oauth.NewProvider(oauth.Config{PublicURL: "http://test.invalid", AIToken: "ai-token"}) } +func TestBuildMCPServerVersion(t *testing.T) { + s := buildMCPServer(relay.NewRegistry(), 5*time.Second, 30*time.Second) + if s.Name != "rish-mcp" || s.Version != "1.0.0" { + t.Fatalf("server identity = %s@%s, want rish-mcp@1.0.0", s.Name, s.Version) + } +} + // TestRunShellRoundTrip drives the whole skeleton end to end, mirroring // before/server/test/smoke.mjs: a fake Android agent dials /agent, then an // MCP client lists devices and runs a command through /mcp. @@ -29,7 +36,7 @@ func TestRunShellRoundTrip(t *testing.T) { defer srv.Close() wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + - "/agent?token=device-token&deviceId=dev1&name=Pixel&sdk=34&kind=android&ver=0.1.0&vc=1" + "/agent?token=device-token&deviceId=dev1&name=Pixel&sdk=34&kind=android&ver=1.0.0&vc=10000&backend=shizuku" conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("dial agent: %v", err) @@ -44,6 +51,9 @@ func TestRunShellRoundTrip(t *testing.T) { if !strings.Contains(listResp, "dev1") { t.Fatalf("list_devices response missing dev1: %s", listResp) } + if !strings.Contains(listResp, `\"shellBackend\": \"shizuku\"`) { + t.Fatalf("list_devices response missing shell backend: %s", listResp) + } shellResp := callTool(t, srv.URL, "ai-token", "run_shell", map[string]any{"cmd": "getprop ro.product.model"}) if !strings.Contains(shellResp, "SM-TEST") { @@ -195,7 +205,7 @@ func rawMCPRequest(t *testing.T, base, method, path, body string) *http.Response func beginAgent(t *testing.T, base, deviceID string) *websocket.Conn { t.Helper() wsURL := "ws" + strings.TrimPrefix(base, "http") + - "/agent?token=device-token&deviceId=" + deviceID + "&name=Pixel&sdk=34&kind=android&ver=0.1.0&vc=1" + "/agent?token=device-token&deviceId=" + deviceID + "&name=Pixel&sdk=34&kind=android&ver=1.0.0&vc=10000" conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("dial agent: %v", err) @@ -482,7 +492,7 @@ func fakeAgentWithResult(conn *websocket.Conn, res relay.Result) { func beginAgentWithConfig(t *testing.T, base, deviceID string, res relay.Result) *websocket.Conn { t.Helper() wsURL := "ws" + strings.TrimPrefix(base, "http") + - "/agent?token=device-token&deviceId=" + deviceID + "&name=Pixel&sdk=34&kind=android&ver=0.1.0&vc=1" + "/agent?token=device-token&deviceId=" + deviceID + "&name=Pixel&sdk=34&kind=android&ver=1.0.0&vc=10000" conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("dial agent: %v", err) diff --git a/server/cmd/setup/main.go b/server/cmd/setup/main.go index dba5b3f..18a28df 100644 --- a/server/cmd/setup/main.go +++ b/server/cmd/setup/main.go @@ -5,7 +5,8 @@ // One thing this tool deliberately does NOT do: drive the Android 11+ // wireless-pairing handshake itself. That handshake happens on-device, // inside the app (AdbShellClient / libadb-android) -- that's the entire -// point of the app not needing Shizuku or a PC in the loop. A PC's adb is +// point of the app keeping an ADB fallback without a PC in the loop. Shizuku +// is also supported and preferred when the owner grants it. A PC's adb is // only load-bearing for two things: installing the APK, and the // pre-Android-11 `adb tcpip` USB bridge (wireless pairing doesn't exist // before Android 11). This tool covers exactly those two things, then @@ -279,12 +280,12 @@ func runDeviceSetup(serverURL string) error { step(5, "configure the app") fmt.Println(dim("These get sent to the app as launch extras, not baked into the build --")) - fmt.Println(dim("see docs/USAGE.md §3.3 (headless provisioning).")) + fmt.Println(dim("see docs/USAGE.md §3.4 (headless provisioning).")) relayURL := prompt("Relay URL (e.g. wss://mcp.example.com/agent):") deviceToken := prompt("Device token:") if relayURL != "" && deviceToken != "" { args := []string{ - "shell", "am", "start", "-n", "kr.scin.rishmcp/.MainActivity", + "shell", "am", "start", "-n", "kr.scin.rishmcp/.ProvisioningActivity", "--es", "relay", relayURL, "--es", "token", deviceToken, "--ez", "autostart", "true", @@ -302,11 +303,14 @@ func runDeviceSetup(serverURL string) error { fmt.Println(dim("skipped -- you can fill these in from the app's Configuration card instead")) } - step(6, "pair the app") + step(6, "authorize shell access") + fmt.Println("Recommended: start Shizuku in ADB mode, then tap Grant Shizuku in the") + fmt.Println("app's Shell access card. rish-mcp deliberately rejects root-mode Shizuku.") + fmt.Println("If you prefer the built-in ADB fallback:") if is11Plus { fmt.Println("On the phone: Settings → Developer options → Wireless debugging → Pair device") - fmt.Println("with pairing code. Enter that port + 6-digit code in the app's \"ADB shell") - fmt.Println("access\" card, tap Pair. Then note the (different) port on the main Wireless") + fmt.Println("with pairing code. Enter that port + 6-digit code in the app's \"Shell access\"") + fmt.Println("card, tap Pair. Then note the (different) port on the main Wireless") fmt.Println("debugging screen, enter it under \"Connect port\", tap Save port.") } else { fmt.Println(fmt.Sprintf("The bridge port (%s) is already listening and, if you filled in step 5,", bridgePort)) @@ -628,9 +632,6 @@ func buildLocally() (string, error) { return "", err } appDir := filepath.Join(repoRoot, "app") - if err := ensureGoogleServicesJSON(appDir); err != nil { - return "", err - } fmt.Println(dim("docker build -t rishmcp-android-build -f Dockerfile.build " + appDir)) build := exec.Command("docker", "build", "-t", "rishmcp-android-build", "-f", "Dockerfile.build", ".") build.Dir = appDir @@ -663,50 +664,6 @@ func buildLocally() (string, error) { return "", fmt.Errorf("no .apk found in %s", outDir) } -// ensureGoogleServicesJSON checks for app/app/google-services.json, the -// file that turns on the FCM low-spec wake path (build.gradle.kts only -// applies the Firebase Gradle plugin when it's present -- otherwise the -// build still succeeds, just without that feature). It's real per-project -// Firebase config, gitignored on purpose (docs/DESIGN.md §7), so each -// local build has to supply its own rather than finding one in the repo. -func ensureGoogleServicesJSON(appDir string) error { - target := filepath.Join(appDir, "app", "google-services.json") - if _, err := os.Stat(target); err == nil { - fmt.Println(good("google-services.json found -- FCM wake path will be built in")) - return nil - } - - fmt.Println(dim("No app/app/google-services.json -- building without the FCM low-spec wake path.")) - fmt.Println(dim("(Regular devices work fine without it; this only affects Wear OS-style wake.)")) - if !promptYesNo("Do you have your own Firebase project's google-services.json to add?", false) { - return nil - } - src := prompt("Path to it:") - if src == "" { - return nil - } - if err := copyFile(src, target); err != nil { - return fmt.Errorf("couldn't copy google-services.json: %w", err) - } - fmt.Println(good("copied -- FCM wake path will be built in")) - return nil -} - -func copyFile(src, dest string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - out, err := os.Create(dest) - if err != nil { - return err - } - defer out.Close() - _, err = io.Copy(out, in) - return err -} - func findRepoRoot() (string, error) { dir, err := os.Getwd() if err != nil { diff --git a/server/cmd/setup/main_test.go b/server/cmd/setup/main_test.go index ee280e6..d156d56 100644 --- a/server/cmd/setup/main_test.go +++ b/server/cmd/setup/main_test.go @@ -422,45 +422,6 @@ func TestPromptYesNoInteractiveDefault(t *testing.T) { } } -// --- copyFile --- - -func TestCopyFile(t *testing.T) { - src := t.TempDir() + "/src.txt" - dst := t.TempDir() + "/dst.txt" - if err := os.WriteFile(src, []byte("hello world"), 0o644); err != nil { - t.Fatal(err) - } - if err := copyFile(src, dst); err != nil { - t.Fatalf("copyFile failed: %v", err) - } - b, err := os.ReadFile(dst) - if err != nil { - t.Fatal(err) - } - if string(b) != "hello world" { - t.Fatalf("expected 'hello world', got %q", string(b)) - } -} - -func TestCopyFileMissingSource(t *testing.T) { - dst := t.TempDir() + "/dst.txt" - if err := copyFile("/nonexistent/path", dst); err == nil { - t.Fatal("expected error for missing source") - } -} - -func TestCopyFileDstIsDir(t *testing.T) { - src := t.TempDir() + "/src.txt" - if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil { - t.Fatal(err) - } - // dst is a directory — os.Create should fail - dst := t.TempDir() - if err := copyFile(src, dst); err == nil { - t.Fatal("expected error when dst is a directory") - } -} - // --- findRepoRoot --- func TestFindRepoRoot(t *testing.T) { @@ -835,123 +796,6 @@ func TestEnsureADBDownloads(t *testing.T) { } } -// --- ensureGoogleServicesJSON --- - -func TestEnsureGoogleServicesJSONFound(t *testing.T) { - useColor = false - dir := t.TempDir() - appDir := filepath.Join(dir, "myapp") - subDir := filepath.Join(appDir, "app") - if err := os.MkdirAll(subDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(subDir, "google-services.json"), []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - if err := ensureGoogleServicesJSON(appDir); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestEnsureGoogleServicesJSONNonInteractive(t *testing.T) { - nonInteractive = true - defer func() { nonInteractive = false }() - useColor = false - dir := t.TempDir() - // No google-services.json exists — promptYesNo returns false (default) in non-interactive mode - if err := ensureGoogleServicesJSON(dir); err != nil { - t.Fatalf("expected nil in non-interactive mode, got %v", err) - } -} - -func TestEnsureGoogleServicesJSONInteractiveRejects(t *testing.T) { - // "n" at promptYesNo → return nil - useColor = false - w, cleanup := withStdinPipe(t) - defer cleanup() - - dir := t.TempDir() - go func() { - fmt.Fprint(w, "n\n") - w.Close() - }() - - if err := ensureGoogleServicesJSON(dir); err != nil { - t.Fatalf("expected nil, got %v", err) - } -} - -func TestEnsureGoogleServicesJSONInteractiveEmptyPath(t *testing.T) { - // "y" then empty path → return nil - useColor = false - w, cleanup := withStdinPipe(t) - defer cleanup() - - dir := t.TempDir() - go func() { - fmt.Fprint(w, "y\n") - fmt.Fprint(w, "\n") - w.Close() - }() - - if err := ensureGoogleServicesJSON(dir); err != nil { - t.Fatalf("expected nil, got %v", err) - } -} - -func TestEnsureGoogleServicesJSONInteractiveCopySuccess(t *testing.T) { - useColor = false - w, cleanup := withStdinPipe(t) - defer cleanup() - - dir := t.TempDir() - // Create the app/ subdirectory so copyFile can create the target - appDir := filepath.Join(dir, "app") - if err := os.MkdirAll(appDir, 0o755); err != nil { - t.Fatal(err) - } - - src := filepath.Join(dir, "source.json") - if err := os.WriteFile(src, []byte(`{"project_info": {}}`), 0o644); err != nil { - t.Fatal(err) - } - - go func() { - fmt.Fprint(w, "y\n") - fmt.Fprint(w, src+"\n") - w.Close() - }() - - if err := ensureGoogleServicesJSON(dir); err != nil { - t.Fatalf("expected nil, got %v", err) - } - target := filepath.Join(appDir, "google-services.json") - if _, err := os.Stat(target); err != nil { - t.Fatalf("google-services.json not copied to %s: %v", target, err) - } -} - -func TestEnsureGoogleServicesJSONInteractiveCopyFails(t *testing.T) { - useColor = false - w, cleanup := withStdinPipe(t) - defer cleanup() - - dir := t.TempDir() - go func() { - fmt.Fprint(w, "y\n") - fmt.Fprint(w, "/nonexistent/source.json\n") - w.Close() - }() - - err := ensureGoogleServicesJSON(dir) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "couldn't copy google-services.json") { - t.Fatalf("expected 'couldn't copy', got %v", err) - } -} - // --- buildLocally --- func TestBuildLocallyDockerNotFound(t *testing.T) { diff --git a/server/internal/relay/registry.go b/server/internal/relay/registry.go index cb61fd0..4abc8ab 100644 --- a/server/internal/relay/registry.go +++ b/server/internal/relay/registry.go @@ -67,6 +67,7 @@ type Device struct { Kind Kind AgentVersion string AgentVersionCode int + ShellBackend string ConnectedAt time.Time mu sync.Mutex @@ -89,6 +90,7 @@ type DeviceInfo struct { SDK string `json:"sdk"` AgentVersion string `json:"agentVersion"` AgentVersionCode int `json:"agentVersionCode"` + ShellBackend string `json:"shellBackend"` ConnectedForMs int64 `json:"connectedForMs"` Pending int `json:"pending"` } @@ -137,6 +139,7 @@ func (r *Registry) List() []DeviceInfo { for _, d := range r.devices { d.mu.Lock() pending := len(d.pending) + shellBackend := d.ShellBackend d.mu.Unlock() out = append(out, DeviceInfo{ ID: d.ID, @@ -145,6 +148,7 @@ func (r *Registry) List() []DeviceInfo { SDK: d.SDK, AgentVersion: d.AgentVersion, AgentVersionCode: d.AgentVersionCode, + ShellBackend: shellBackend, ConnectedForMs: now.Sub(d.ConnectedAt).Milliseconds(), Pending: pending, }) diff --git a/server/internal/relay/ws.go b/server/internal/relay/ws.go index ee50b0a..15afd9e 100644 --- a/server/internal/relay/ws.go +++ b/server/internal/relay/ws.go @@ -175,6 +175,7 @@ func registerAgentPing(reg *Registry, conn *websocket.Conn, q url.Values, pingEv agentVersion = "unknown" } agentVersionCode, _ := strconv.Atoi(q.Get("vc")) + shellBackend := normalizeShellBackend(q.Get("backend")) // The raw values are used for routing (deviceID is the registry key), but // anything that reaches a log line goes through sanitizeLogField first. @@ -187,6 +188,7 @@ func registerAgentPing(reg *Registry, conn *websocket.Conn, q url.Values, pingEv Kind: kind, AgentVersion: agentVersion, AgentVersionCode: agentVersionCode, + ShellBackend: shellBackend, ConnectedAt: time.Now(), lastSeen: time.Now(), conn: conn, @@ -270,7 +272,8 @@ func registerAgentPing(reg *Registry, conn *websocket.Conn, q url.Values, pingEv log.Printf("[agent] invalid result frame from %s: %v (frame=%q)", logDeviceID, err, frame) continue } - if msg.Type == "result" && msg.ReqID != "" { + switch { + case msg.Type == "result" && msg.ReqID != "": reg.resolveResult(deviceID, msg.ReqID, Result{ Code: msg.Code, Stdout: msg.Stdout, @@ -278,6 +281,10 @@ func registerAgentPing(reg *Registry, conn *websocket.Conn, q url.Values, pingEv Truncated: msg.Truncated, DurationMs: msg.DurationMs, }) + case msg.Type == "status": + d.mu.Lock() + d.ShellBackend = normalizeShellBackend(msg.Backend) + d.mu.Unlock() } } } @@ -290,4 +297,12 @@ type resultFrame struct { Stderr string `json:"stderr"` Truncated bool `json:"truncated"` DurationMs int64 `json:"durationMs"` + Backend string `json:"backend"` +} + +func normalizeShellBackend(value string) string { + if value == "shizuku" || value == "adb" { + return value + } + return "unknown" } diff --git a/server/internal/relay/ws_test.go b/server/internal/relay/ws_test.go index 7587587..2dedc85 100644 --- a/server/internal/relay/ws_test.go +++ b/server/internal/relay/ws_test.go @@ -192,6 +192,9 @@ func TestRegisterAgentDefaults(t *testing.T) { if d.AgentVersionCode != 0 { t.Fatalf("expected agent version code 0, got %d", d.AgentVersionCode) } + if d.ShellBackend != "unknown" { + t.Fatalf("expected shell backend 'unknown', got %q", d.ShellBackend) + } _ = client _ = server.Close() @@ -459,6 +462,7 @@ func TestRegisterAgentCustomValues(t *testing.T) { "name": {"Pixel-9"}, "sdk": {"35"}, "kind": {"watch"}, + "backend": {"shizuku"}, "ver": {"2.1.0"}, "vc": {"42"}, } @@ -489,6 +493,25 @@ func TestRegisterAgentCustomValues(t *testing.T) { if d.AgentVersionCode != 42 { t.Fatalf("expected agent version code 42, got %d", d.AgentVersionCode) } + if d.ShellBackend != "shizuku" { + t.Fatalf("expected shell backend 'shizuku', got %q", d.ShellBackend) + } + if err := client.WriteJSON(map[string]string{"type": "status", "backend": "adb"}); err != nil { + t.Fatalf("write backend status: %v", err) + } + waitForRelayCondition(t, "live shell backend update", func() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.ShellBackend == "adb" + }) + if err := client.WriteJSON(map[string]string{"type": "status", "backend": "root"}); err != nil { + t.Fatalf("write invalid backend status: %v", err) + } + waitForRelayCondition(t, "invalid shell backend normalization", func() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.ShellBackend == "unknown" + }) _ = client.Close() } diff --git a/web/package-lock.json b/web/package-lock.json index 0d8b117..4648bd8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "@react-three/drei": "^10.7.8", "@react-three/fiber": "^9.7.0", diff --git a/web/package.json b/web/package.json index 1b0ad93..727ec1f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.1.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "next dev",