Abrarul8541 commited on
Commit
feed8b2
·
verified ·
1 Parent(s): 47e70c0

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":["net","shell"],"hooks":["on_capture","on_model_loaded"]}
11
- * A plugin only runs hooks for permissions the user has explicitly granted.
 
 
 
 
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
- private val root = File(ctx.filesDir, "plugins").apply { mkdirs() }
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
- Plugin(j.getString("id"), j.getString("name"), j.getString("version"),
28
- j.getString("entry"), set("permissions"), set("hooks"), d)
 
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
- /** Fires [event]; payload is passed as the first script argument. */
 
 
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
- if ("shell" in p.permissions) {
42
- TermuxBridge.run(ctx, "sh '${entry.path}' '$event' \"\$1\"", arrayOf("_", payload))
 
 
 
 
 
 
 
 
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
  }