Plugins: run inside the proot sandbox; Termux only with explicit 'termux' permission
Browse files
app/src/main/java/io/github/abrarul/modularai/plugin/PluginHost.kt
CHANGED
|
@@ -2,13 +2,18 @@ package io.github.abrarul.modularai.plugin
|
|
| 2 |
|
| 3 |
import android.content.Context
|
| 4 |
import io.github.abrarul.modularai.bridge.TermuxBridge
|
|
|
|
| 5 |
import org.json.JSONObject
|
| 6 |
import java.io.File
|
| 7 |
|
| 8 |
/**
|
| 9 |
* Plugin layout: filesDir/plugins/<id>/plugin.json + entry script.
|
| 10 |
-
* plugin.json: {"id","name","version","entry":"run.sh","permissions":["
|
| 11 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
*/
|
| 13 |
data class Plugin(
|
| 14 |
val id: String, val name: String, val version: String,
|
|
@@ -16,7 +21,7 @@ data class Plugin(
|
|
| 16 |
)
|
| 17 |
|
| 18 |
class PluginHost(private val ctx: Context) {
|
| 19 |
-
|
| 20 |
private val granted = ctx.getSharedPreferences("plugin_grants", Context.MODE_PRIVATE)
|
| 21 |
|
| 22 |
fun installed(): List<Plugin> = root.listFiles()?.mapNotNull { load(it) } ?: emptyList()
|
|
@@ -24,22 +29,46 @@ class PluginHost(private val ctx: Context) {
|
|
| 24 |
private fun load(d: File): Plugin? = try {
|
| 25 |
val j = JSONObject(File(d, "plugin.json").readText())
|
| 26 |
fun set(k: String) = j.optJSONArray(k)?.let { a -> (0 until a.length()).map { a.getString(it) }.toSet() } ?: emptySet()
|
| 27 |
-
|
| 28 |
-
|
|
|
|
| 29 |
} catch (_: Exception) { null }
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
fun grant(id: String, perms: Set<String>) = granted.edit().putStringSet(id, perms).apply()
|
| 32 |
fun isGranted(p: Plugin) = granted.getStringSet(p.id, emptySet())!!.containsAll(p.permissions)
|
| 33 |
fun remove(p: Plugin) { p.dir.deleteRecursively(); granted.edit().remove(p.id).apply() }
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
fun emit(event: String, payload: String = "") {
|
|
|
|
| 37 |
installed().filter { event in it.hooks && isGranted(it) }.forEach { p ->
|
| 38 |
-
// Entry paths must stay inside the plugin dir.
|
| 39 |
val entry = File(p.dir, p.entry).canonicalFile
|
| 40 |
-
if (!entry.path.startsWith(p.dir.canonicalPath)) return@forEach
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
}
|
| 45 |
}
|
|
|
|
| 2 |
|
| 3 |
import android.content.Context
|
| 4 |
import io.github.abrarul.modularai.bridge.TermuxBridge
|
| 5 |
+
import io.github.abrarul.modularai.proot.Proot
|
| 6 |
import org.json.JSONObject
|
| 7 |
import java.io.File
|
| 8 |
|
| 9 |
/**
|
| 10 |
* Plugin layout: filesDir/plugins/<id>/plugin.json + entry script.
|
| 11 |
+
* plugin.json: {"id","name","version","entry":"run.sh","permissions":["shell"],"hooks":["on_capture","on_model_loaded"]}
|
| 12 |
+
*
|
| 13 |
+
* Permissions (the user must grant them with `plugin grant <id>`):
|
| 14 |
+
* - shell : script runs inside the proot Linux sandbox. It sees only its own folder at /plugin.
|
| 15 |
+
* - termux : script runs in Termux with Termux's rights (NOT sandboxed). Grant only to code you trust.
|
| 16 |
+
* Hook payloads never contain note text, only event names and short metadata.
|
| 17 |
*/
|
| 18 |
data class Plugin(
|
| 19 |
val id: String, val name: String, val version: String,
|
|
|
|
| 21 |
)
|
| 22 |
|
| 23 |
class PluginHost(private val ctx: Context) {
|
| 24 |
+
val root = File(ctx.filesDir, "plugins").apply { mkdirs() }
|
| 25 |
private val granted = ctx.getSharedPreferences("plugin_grants", Context.MODE_PRIVATE)
|
| 26 |
|
| 27 |
fun installed(): List<Plugin> = root.listFiles()?.mapNotNull { load(it) } ?: emptyList()
|
|
|
|
| 29 |
private fun load(d: File): Plugin? = try {
|
| 30 |
val j = JSONObject(File(d, "plugin.json").readText())
|
| 31 |
fun set(k: String) = j.optJSONArray(k)?.let { a -> (0 until a.length()).map { a.getString(it) }.toSet() } ?: emptySet()
|
| 32 |
+
val id = j.getString("id")
|
| 33 |
+
require(id.matches(Regex("[A-Za-z0-9._-]{1,64}")))
|
| 34 |
+
Plugin(id, j.getString("name"), j.getString("version"), j.getString("entry"), set("permissions"), set("hooks"), d)
|
| 35 |
} catch (_: Exception) { null }
|
| 36 |
|
| 37 |
+
/** Creates a working example plugin the user can edit. */
|
| 38 |
+
fun createSample(id: String): Plugin {
|
| 39 |
+
require(id.matches(Regex("[A-Za-z0-9._-]{1,64}"))) { "Plugin id may contain letters, digits, . _ - only" }
|
| 40 |
+
val d = File(root, id).apply { mkdirs() }
|
| 41 |
+
File(d, "plugin.json").writeText(
|
| 42 |
+
JSONObject().put("id", id).put("name", "Sample $id").put("version", "1.0")
|
| 43 |
+
.put("entry", "run.sh").put("permissions", org.json.JSONArray().put("shell"))
|
| 44 |
+
.put("hooks", org.json.JSONArray().put("on_capture").put("on_model_loaded")).toString(2)
|
| 45 |
+
)
|
| 46 |
+
File(d, "run.sh").writeText("#!/bin/sh\necho \"\$(date) event=\$1 payload=\$2\" >> /plugin/events.log\n")
|
| 47 |
+
return load(d)!!
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
fun grant(id: String, perms: Set<String>) = granted.edit().putStringSet(id, perms).apply()
|
| 51 |
fun isGranted(p: Plugin) = granted.getStringSet(p.id, emptySet())!!.containsAll(p.permissions)
|
| 52 |
fun remove(p: Plugin) { p.dir.deleteRecursively(); granted.edit().remove(p.id).apply() }
|
| 53 |
|
| 54 |
+
private fun q(s: String) = "'" + s.replace("'", "'\\''") + "'"
|
| 55 |
+
|
| 56 |
+
/** Fires [event] for every granted plugin that subscribed to it. Never blocks the caller. */
|
| 57 |
fun emit(event: String, payload: String = "") {
|
| 58 |
+
val proot = Proot(ctx)
|
| 59 |
installed().filter { event in it.hooks && isGranted(it) }.forEach { p ->
|
|
|
|
| 60 |
val entry = File(p.dir, p.entry).canonicalFile
|
| 61 |
+
if (!entry.path.startsWith(p.dir.canonicalPath + File.separator) || !entry.isFile) return@forEach // must stay inside the plugin dir
|
| 62 |
+
val rel = entry.relativeTo(p.dir.canonicalFile).path
|
| 63 |
+
when {
|
| 64 |
+
"termux" in p.permissions && TermuxBridge.isInstalled(ctx) ->
|
| 65 |
+
TermuxBridge.run(ctx, "sh ${q(entry.path)} ${q(event)} ${q(payload)}")
|
| 66 |
+
"shell" in p.permissions && proot.binaryAvailable && proot.installed ->
|
| 67 |
+
Thread {
|
| 68 |
+
runCatching {
|
| 69 |
+
proot.exec("sh ${q("/plugin/$rel")} ${q(event)} ${q(payload)}", { }, listOf("${p.dir.canonicalPath}:/plugin"))
|
| 70 |
+
}
|
| 71 |
+
}.start()
|
| 72 |
}
|
| 73 |
}
|
| 74 |
}
|