diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 32df3c3..56ab10e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,7 +9,13 @@ plugins {
android {
namespace = "app.pi"
- compileSdk = 36
+ // SDK 37 ships as a *minor-version* platform: there is no `platforms;android-37`
+ // package, only 37.0/37.1/37.2. AGP 8.13.2 supports that scheme through
+ // `compileSdkMinor`; `compileSdk = 37` alone resolves to the nonexistent
+ // `platforms;android-37`. The minor setter must come *after* `compileSdk` —
+ // it reads the API level already set on the extension.
+ compileSdk = 37
+ compileSdkMinor = 0
// ---------------------------------------------------------------------
// targetSdk is a *runtime capability switch*, not a style choice.
@@ -134,6 +140,13 @@ dependencies {
implementation(libs.webkit)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.markdown.renderer.m3)
+
+ // The elevated (uid=2000) shell backend. `api` is what ShizukuShellBackend
+ // compiles against; `provider` is required at runtime for the binder handoff
+ // (see the ShizukuProvider entry in AndroidManifest.xml). Both are MIT and
+ // neither pulls native code.
+ implementation(libs.shizuku.api)
+ implementation(libs.shizuku.provider)
}
// The markdown renderer is built by a newer Kotlin than this project's compiler
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 80b1357..2ffdbdb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -33,6 +33,48 @@
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
+
+
+
+
+
+
+
+
+
+
+
+
;
+ try {
+ available = knownLanguages();
+ } catch (error) {
+ return fail(
+ response,
+ 503,
+ "ENGINE_UNAVAILABLE",
+ `找不到 highlight.js:${error instanceof Error ? error.message : String(error)}。`,
+ );
+ }
+ if (available.size === 0) {
+ return fail(
+ response,
+ 503,
+ "ENGINE_UNAVAILABLE",
+ `highlight.js 没有列出任何语言(${loadState().error ?? "未知原因"})。`,
+ );
+ }
+
// Unknown language is not an error: it is pi's normal "render plain" path
// (hcl/graphql/toml/fish are not in highlight.js 10.7.3 at all).
- if (!knownLanguages().has(language)) {
+ if (!available.has(language)) {
return send(response, 200, {
ok: true,
data: { language, known: false, spans: [], codeUnits: parsed.code.length, hljs: loadState().hljsVersion },
diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceBridgeController.kt b/app/src/main/kotlin/app/pi/bridge/DeviceBridgeController.kt
index 9bddddf..5e8e56f 100644
--- a/app/src/main/kotlin/app/pi/bridge/DeviceBridgeController.kt
+++ b/app/src/main/kotlin/app/pi/bridge/DeviceBridgeController.kt
@@ -46,8 +46,27 @@ object DeviceBridgeController {
/** Assets shipped in the APK that make up the extension. */
private const val ASSET_ROOT = "pi-extensions"
- /** Bumped whenever the shipped extension changes, to force re-installation. */
- const val ASSET_VERSION = "1"
+ /**
+ * Bumped whenever anything under `assets/pi-extensions/` changes, to force
+ * re-installation into the guest.
+ *
+ * This is a manual gate and it fails silently: the stamp is compared before
+ * copying, so a device that already ran an older build keeps the old
+ * extension tree and never sees the new one. The symptom is not an error —
+ * it is a feature that "does not work", which sends whoever debugs it off to
+ * check ports, tokens and networks instead of the installer.
+ *
+ * So: **every change to `pi-extensions/ 目录` must bump this string.**
+ * History: "1" shipped the device bridge; "2" adds `pi-highlight/`; "3" adds the
+ * session-scoped approvals, the workspace-relative shell policy and the SAF file
+ * tools to the device extension.
+ *
+ * A content-derived fingerprint (hashing the asset tree's names and sizes)
+ * would remove the human step entirely and is the better long-term design —
+ * recorded in docs/known-gaps.md rather than done here, because the file is
+ * not the one being worked on right now.
+ */
+ const val ASSET_VERSION = "3"
@Volatile
private var server: DeviceBridgeHttpServer? = null
@@ -91,6 +110,10 @@ object DeviceBridgeController {
val log = DeviceAuditLog(File(paths.home, "device-bridge-audit.log"))
auditLog = log
+ // The shell's write boundary is the user's workspace; re-read it on every
+ // start so a workspace change is picked up without a rebuild.
+ DeviceWorkspace.refresh(appContext)
+
val minted = mintToken()
token = minted
diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceBridgeRouter.kt b/app/src/main/kotlin/app/pi/bridge/DeviceBridgeRouter.kt
index 46cdca6..51c2786 100644
--- a/app/src/main/kotlin/app/pi/bridge/DeviceBridgeRouter.kt
+++ b/app/src/main/kotlin/app/pi/bridge/DeviceBridgeRouter.kt
@@ -222,9 +222,60 @@ class DeviceBridgeRouter(
"/app/shell" -> withCapability(DeviceCapability.Shell) {
val command = params.strRequired("command")
- DeviceShellGuard.inspect(command)?.let { throw DeviceActionException(it) }
+ // One switch, two enforcers: the TS gate reads the same boolean
+ // from /app/health, so the dialog and this guard cannot disagree.
+ val relaxed = store.isShellSyntaxRelaxed()
+ DeviceWorkspace.refresh(context)
+ DeviceShellGuard.inspect(command, relaxed, DeviceWorkspace)?.let { throw DeviceActionException(it) }
val backend = DeviceShellGuard.active()
- DeviceShellGuard.toJson(backend.run(command, params.int("timeoutMs", 15_000)))
+ DeviceShellGuard.toJson(
+ result = backend.run(command, params.int("timeoutMs", 15_000)),
+ relaxedShellSyntax = relaxed,
+ boundaryLabel = DeviceWorkspace.shellPath(),
+ )
+ }
+
+ // Raw key injection. The accessibility channel can only do the five
+ // GLOBAL_ACTIONs; `input keyevent` needs uid 2000, so this endpoint
+ // exists only to make the Shizuku payoff reachable.
+ "/app/ui/keyevent" -> withCapability(DeviceCapability.Accessibility) {
+ DeviceUiAutomation.keyEvent(
+ keys = params.strRequired("keys"),
+ repeat = params.int("repeat", 1),
+ backend = DeviceShellGuard.active(),
+ )
+ }
+
+ "/app/files" -> withCapability(DeviceCapability.Storage) {
+ DeviceSafStore.get(context).describe()
+ }
+
+ "/app/files/list" -> withCapability(DeviceCapability.Storage) {
+ DeviceSafStore.get(context).list(params.str("path"))
+ }
+
+ "/app/files/read" -> withCapability(DeviceCapability.Storage) {
+ DeviceSafStore.get(context).read(
+ path = params.strRequired("path"),
+ maxBytes = params.int("maxBytes", 1024 * 1024),
+ )
+ }
+
+ "/app/files/write" -> withCapability(DeviceCapability.Storage) {
+ DeviceSafStore.get(context).write(
+ path = params.strRequired("path"),
+ text = params.str("content"),
+ base64 = params.str("base64"),
+ mimeType = params.str("mimeType") ?: "text/plain",
+ )
+ }
+
+ // The pi-side permission gate reports what it has approved. Display
+ // only: nothing here changes policy (the gate's own memory is the
+ // enforcement), and the UI labels it as extension-reported.
+ "/app/gate/report" -> {
+ DeviceApprovalLedger.report(params.json)
+ BridgeHttpResponse.okRaw(DeviceApprovalLedger.toJson())
}
else -> BridgeHttpResponse(404, notFound(path).toString())
@@ -276,6 +327,33 @@ class DeviceBridgeRouter(
put("locationPermissionGranted", store.hasLocationPermission())
put("notificationPermissionGranted", store.hasNotificationPermission())
put("vibratePermissionGranted", store.hasVibratePermission())
+ put("legacyStoragePermissionGranted", store.hasLegacyStoragePermission())
+ // The relaxed-syntax switch is published so the pi-side gate can honour the
+ // exact same boolean the Kotlin guard enforces.
+ put("shellSyntaxRelaxed", store.isShellSyntaxRelaxed())
+ DeviceWorkspace.refresh(context)
+ put("workspace", JSONObject().apply {
+ put("shellPath", DeviceWorkspace.shellPath() ?: JSONObject.NULL)
+ put("guestPath", "/workspace")
+ put("known", DeviceWorkspace.isKnown())
+ })
+ put("shizuku", DeviceShizuku.status(context))
+ put("gate", DeviceApprovalLedger.toJson())
+ // The whole policy, so a model (and the diagnostics page) can see exactly
+ // what is permitted instead of inferring it from refusals.
+ put("shellPolicy", JSONObject().apply {
+ put("allowedCommands", JSONArray(DeviceShellGuard.allowedCommands))
+ put("blocked", JSONArray(DeviceShellGuard.blockedSummary()))
+ put("writeBoundary", JSONArray(DeviceShellGuard.writeBoundarySummary()))
+ put("syntax", JSONArray(DeviceShellGuard.syntaxSummary(store.isShellSyntaxRelaxed())))
+ put("relaxedCost", DeviceShellGuard.relaxedCost())
+ put("elevatedBackend", DeviceShellGuard.hasElevatedBackend())
+ })
+ put("saf", JSONObject().apply {
+ val grants = DeviceSafStore.get(context).grants()
+ put("count", grants.size)
+ put("roots", org.json.JSONArray(grants.map { it.name }))
+ })
put("shellBackends", JSONArray().apply {
for (backend in DeviceShellGuard.backends()) {
put(
@@ -413,7 +491,8 @@ class DeviceBridgeRouter(
}
companion object {
- const val BRIDGE_VERSION = "1"
+ /** Bumped whenever the endpoint set or a payload shape changes. */
+ const val BRIDGE_VERSION = "2"
/**
* The port the bridge listens on. Deliberately not 3090: the shipping DSH
@@ -430,6 +509,7 @@ class DeviceBridgeRouter(
"POST /app/ui/tap",
"POST /app/ui/input",
"POST /app/ui/key",
+ "POST /app/ui/keyevent",
"POST /app/ui/swipe",
"POST /app/screenshot",
"GET /app/apps",
@@ -450,7 +530,12 @@ class DeviceBridgeRouter(
"POST /app/torch",
"POST /app/export",
"POST /app/import",
+ "GET /app/files",
+ "POST /app/files/list",
+ "POST /app/files/read",
+ "POST /app/files/write",
"POST /app/shell",
+ "POST /app/gate/report",
)
}
}
diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceCapability.kt b/app/src/main/kotlin/app/pi/bridge/DeviceCapability.kt
index 820a55a..f1d06cc 100644
--- a/app/src/main/kotlin/app/pi/bridge/DeviceCapability.kt
+++ b/app/src/main/kotlin/app/pi/bridge/DeviceCapability.kt
@@ -46,10 +46,11 @@ enum class DeviceCapability(
Storage(
id = "storage",
title = "存储",
- summary = "把文件导出到公共 Download 目录,或从其中读回",
+ summary = "读写用户授权(SAF)的目录,并把文件导出到公共 Download",
allows = listOf(
+ "读写你在本页授权的目录(SAF,重启后仍然有效)",
"把 Agent 生成的文件写入 Download(用户可见、可撤销)",
- "按文件名从 Download 读回文件交给 Agent",
+ "从 Download 读回文件交给 Agent(API 33+ 只能读本应用自己的文件)",
),
defaultEnabled = false,
),
@@ -84,11 +85,11 @@ enum class DeviceCapability(
Shell(
id = "shell",
title = "Shell",
- summary = "在设备上执行受策略守卫限制的命令(默认关闭,且每次都要确认)",
+ summary = "在设备上执行受策略守卫限制的命令(默认关闭;危险操作第一次确认后可选择本会话不再询问)",
allows = listOf(
- "执行只读设备查询(getprop、dumpsys、pm list、logcat 等)",
- "写入 Download 目录",
- "读取 App 自己有权限读取的目录",
+ "执行日常读命令(getprop、dumpsys、pm list、logcat、ls、cat、df、ps 等)",
+ "执行日常写命令(cp、mv、rm、mkdir、sed、tar、curl 等),但只能写工作区之内",
+ "在装有 Shizuku 的设备上以 ADB 身份(uid=2000)运行,从而使用 input、pm、am、settings get 等",
),
defaultEnabled = false,
),
diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceCapabilityStore.kt b/app/src/main/kotlin/app/pi/bridge/DeviceCapabilityStore.kt
index 053e29a..40ff21c 100644
--- a/app/src/main/kotlin/app/pi/bridge/DeviceCapabilityStore.kt
+++ b/app/src/main/kotlin/app/pi/bridge/DeviceCapabilityStore.kt
@@ -68,6 +68,26 @@ class DeviceCapabilityStore private constructor(context: Context) {
fun isSessionDisabled(capability: DeviceCapability): Boolean =
sessionDisabled.contains(capability.id)
+ /**
+ * 放宽模式: the opt-in that lets the shell guard accept command substitution
+ * (`$(...)`, backticks) and the nesting heads (`sh`, `eval`, `source`, …).
+ *
+ * One stored boolean, read by three consumers: the Kotlin guard
+ * ([DeviceShellGuard.inspect]), the authorization page, and the pi-side
+ * permission gate — the gate reads it from `/app/health`. That single source is
+ * the point: a mode only one side honoured would be worse than no mode, because
+ * the disagreement between "the dialog let it through" and "the guard refuses"
+ * is invisible.
+ *
+ * Default OFF, and it is not part of any capability group: turning 「Shell」 on
+ * must not silently widen what shell *syntax* is allowed.
+ */
+ fun isShellSyntaxRelaxed(): Boolean = prefs.getBoolean(KEY_RELAXED_SHELL, false)
+
+ fun setShellSyntaxRelaxed(relaxed: Boolean) {
+ prefs.edit().putBoolean(KEY_RELAXED_SHELL, relaxed).apply()
+ }
+
/** Persisted decision **and** not cut for this session. */
fun isEnabled(capability: DeviceCapability): Boolean =
isPersistentlyEnabled(capability) && !isSessionDisabled(capability)
@@ -149,12 +169,16 @@ class DeviceCapabilityStore private constructor(context: Context) {
DeviceCapability.Storage ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // API 29+ has MediaStore, which needs no storage permission for the
+ // app's own exports; SAF grants cover everything else.
+ null
+ } else if (hasLegacyStoragePermission()) {
null
} else {
DeviceDenial(
code = DeviceDenial.NO_PERMISSION,
reason = "这台设备(Android ${Build.VERSION.RELEASE})导出文件需要存储权限,当前未授予。",
- hint = "请让用户在系统设置中为 pi-android 授予存储权限,或在应用内改用支持 MediaStore 的路径。",
+ hint = "请让用户在「设置 → 设备能力 → 存储」点「授予存储权限」,或在系统设置里为本应用打开存储权限。",
)
}
@@ -186,6 +210,31 @@ class DeviceCapabilityStore private constructor(context: Context) {
return fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED
}
+ /**
+ * The pre-API-29 storage path.
+ *
+ * The manifest declares `WRITE_EXTERNAL_STORAGE` with `maxSdkVersion="29"` and
+ * `READ_EXTERNAL_STORAGE` with `maxSdkVersion="32"`, so both are requestable
+ * exactly where they still mean something and invisible above that. Before this
+ * the code told the user to add the permission to the manifest — the reason
+ * `android_export` and `android_import` simply could not work on Android 8/9,
+ * which `minSdk 26` says this app supports.
+ */
+ fun hasLegacyStoragePermission(): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return true
+ val read = ContextCompat.checkSelfPermission(appContext, Manifest.permission.READ_EXTERNAL_STORAGE)
+ if (read != PackageManager.PERMISSION_GRANTED) return false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return true
+ val write = ContextCompat.checkSelfPermission(appContext, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ return write == PackageManager.PERMISSION_GRANTED
+ }
+
+ /** The permissions the storage card should request on this API level. */
+ fun legacyStoragePermissions(): List = buildList {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) add(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+
/** True when the app may post notifications (always true below API 33). */
fun hasNotificationPermission(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -202,6 +251,7 @@ class DeviceCapabilityStore private constructor(context: Context) {
companion object {
private const val PREFS_NAME = "pi-device-capabilities"
+ private const val KEY_RELAXED_SHELL = "shell.relaxed-syntax"
@Volatile
private var instance: DeviceCapabilityStore? = null
diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceSafStore.kt b/app/src/main/kotlin/app/pi/bridge/DeviceSafStore.kt
new file mode 100644
index 0000000..02c550e
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/bridge/DeviceSafStore.kt
@@ -0,0 +1,489 @@
+package app.pi.bridge
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * The device shell's **hard blocklist**, rendered for the authorization page.
+ *
+ * This is the same list [DeviceShellGuard.inspect] enforces, exposed as data so the
+ * UI cannot drift from the policy. Every entry carries which of the two membership
+ * tests earns it a place; an entry without one does not belong here (the user's
+ * complaint was precisely that the list had grown by caution instead of reasoning).
+ */
+object DeviceShellPolicyText {
+
+ fun blockLines(): List = DeviceShellGuard.blockedSummary()
+
+ fun allowLine(): String = DeviceShellGuard.allowedSummary()
+
+ fun protectedWriteLines(): List = DeviceShellGuard.protectedWriteSummary()
+
+ fun syntaxLines(relaxed: Boolean): List = DeviceShellGuard.syntaxSummary(relaxed)
+}
+
+/**
+ * What the pi-side permission gate has approved in this session.
+ *
+ * The gate itself runs *inside the guest* (it is a pi extension), so the app cannot
+ * observe it directly — the extension reports here through `POST /app/gate/report`,
+ * and this ledger is what the authorization page displays. That direction matters:
+ * a relaxation the user cannot see is exactly what the design forbids, and the
+ * report is labelled as "reported by the extension" in the UI because the app has
+ * no way to verify it.
+ *
+ * Nothing in this object changes policy. The gate's own in-process memory is the
+ * enforcement; this is the visible trace of it.
+ */
+object DeviceApprovalLedger {
+
+ private const val MAX_TOOLS = 32
+ private const val MAX_TEXT = 120
+
+ private data class Snapshot(
+ val sessionGrants: List,
+ val counts: Map,
+ val relaxedShellSyntax: Boolean,
+ val reportedAt: Long,
+ val note: String,
+ )
+
+ @Volatile
+ private var snapshot: Snapshot? = null
+
+ /** Replace the ledger with what the gate just reported. */
+ fun report(payload: JSONObject) {
+ val grants = mutableListOf()
+ payload.optJSONArray("sessionGrants")?.let { array ->
+ for (index in 0 until minOf(array.length(), MAX_TOOLS)) {
+ val value = array.optString(index).trim()
+ if (value.isNotEmpty()) grants.add(value.take(MAX_TEXT))
+ }
+ }
+ val counts = mutableMapOf()
+ payload.optJSONObject("counts")?.let { objectValue ->
+ for (key in objectValue.keys()) {
+ if (counts.size >= MAX_TOOLS) break
+ val name = key.trim().take(MAX_TEXT)
+ if (name.isNotEmpty()) counts[name] = objectValue.optInt(key, 0)
+ }
+ }
+ snapshot = Snapshot(
+ sessionGrants = grants,
+ counts = counts,
+ relaxedShellSyntax = payload.optBoolean("relaxedShellSyntax", false),
+ reportedAt = System.currentTimeMillis(),
+ note = payload.optString("note").take(MAX_TEXT),
+ )
+ }
+
+ fun clear() {
+ snapshot = null
+ }
+
+ /** JSON for `/app/health` and `/app/gate/report`'s reply. */
+ fun toJson(): JSONObject {
+ val current = snapshot
+ return JSONObject().apply {
+ put("reported", current != null)
+ if (current == null) {
+ put("sessionGrants", JSONArray())
+ put("counts", JSONObject())
+ put("note", "扩展还没有上报过(或 App 刚重启)。危险操作会在每次会话开始时重新询问。")
+ return@apply
+ }
+ put("sessionGrants", JSONArray(current.sessionGrants))
+ put("counts", JSONObject(current.counts as Map<*, *>))
+ put("relaxedShellSyntax", current.relaxedShellSyntax)
+ put("reportedAt", current.reportedAt)
+ put("ageSeconds", (System.currentTimeMillis() - current.reportedAt) / 1000)
+ put("note", current.note)
+ }
+ }
+
+ /** Lines for the authorization page. */
+ fun summaryLines(): List {
+ val current = snapshot
+ ?: return listOf(
+ "扩展还没有上报审批状态。危险操作(Shell、结束应用、跨沙箱读写…)在每次会话开始时都会重新询问。",
+ )
+ val lines = mutableListOf()
+ if (current.sessionGrants.isEmpty()) {
+ lines.add("本会话已记住同意的危险工具:无。每个危险操作都会单独询问。")
+ } else {
+ lines.add("本会话已记住同意的危险工具:${current.sessionGrants.joinToString("、")}(不会再询问直到本会话结束)。")
+ }
+ if (current.counts.isNotEmpty()) {
+ val counted = current.counts.entries
+ .sortedByDescending { it.value }
+ .joinToString("、") { "${it.key}×${it.value}" }
+ lines.add("本会话审批次数:$counted")
+ }
+ lines.add("以上由 pi 侧扩展上报(App 无法独立验证),上报于 ${current.reportedAt / 1000} 秒时间戳。")
+ return lines
+ }
+}
+
+/**
+ * The SAF (Storage Access Framework) directory grants of the 「存储」 group.
+ *
+ * Why this exists at all: the page used to say 「已授权目录:无」 with the reason
+ * "a picker needs an Activity". The picker *does* need an Activity, and this app
+ * has one — the device-capability screen is a composable inside `MainActivity`, so
+ * it can launch `ACTION_OPEN_DOCUMENT_TREE` itself, and a persisted URI permission
+ * survives restarts. That turns the storage group from "the app's own Downloads
+ * exports" into "whatever directory the user points at", which is the designed
+ * behaviour (design §21.4「存储:SAF 指定目录读写」and UI spec §5.6「显示已授权目录列表」).
+ *
+ * Reading and writing only: there is deliberately no delete endpoint, because the
+ * design says 读写 and a delete is the one operation a user cannot undo.
+ */
+class DeviceSafStore private constructor(context: android.content.Context) {
+
+ private val appContext: android.content.Context = context.applicationContext
+ private val prefs = appContext.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
+
+ data class Grant(val uri: String, val name: String)
+
+ /** The grants, in the order they were added. */
+ fun grants(): List {
+ val raw = prefs.getString(KEY_GRANTS, null) ?: return emptyList()
+ val array = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList()
+ val out = mutableListOf()
+ for (index in 0 until array.length()) {
+ val item = array.optJSONObject(index) ?: continue
+ val uri = item.optString("uri")
+ if (uri.isEmpty()) continue
+ out.add(Grant(uri, item.optString("name").ifEmpty { uri }))
+ }
+ return out
+ }
+
+ /**
+ * Persist a tree the user just picked. Returns the stored grant, or null when
+ * the platform refused to make it persistable (some providers do).
+ */
+ fun add(treeUri: android.net.Uri, displayName: String?): Grant? {
+ val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ val persisted = runCatching {
+ appContext.contentResolver.takePersistableUriPermission(treeUri, flags)
+ true
+ }.getOrDefault(false)
+ val file = runCatching { androidx.documentfile.provider.DocumentFile.fromTreeUri(appContext, treeUri) }
+ .getOrNull()
+ val name = displayName?.takeIf { it.isNotBlank() }
+ ?: file?.name
+ ?: treeUri.lastPathSegment
+ ?: "已授权目录"
+ val grant = Grant(treeUri.toString(), uniqueName(name, treeUri.toString()))
+ val updated = grants().filterNot { it.uri == grant.uri } + grant
+ save(updated)
+ return if (persisted) grant else null
+ }
+
+ fun remove(uri: String): Boolean {
+ runCatching {
+ appContext.contentResolver.releasePersistableUriPermission(
+ android.net.Uri.parse(uri),
+ android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
+ )
+ }
+ val before = grants()
+ val remaining = before.filterNot { it.uri == uri }
+ save(remaining)
+ return remaining.size != before.size
+ }
+
+ /** How many directories the user has granted. */
+ fun grantCount(): Int = grants().size
+
+ /** The live tree documents, skipping any grant the system has revoked. */
+ fun roots(): List = grants().mapNotNull { grant ->
+ runCatching {
+ androidx.documentfile.provider.DocumentFile.fromTreeUri(appContext, android.net.Uri.parse(grant.uri))
+ }.getOrNull()
+ }
+
+ // ------------------------------------------------------------- operations ----
+
+ /** `GET /app/files`: the roots, so the model knows what it may address. */
+ fun describe(): JSONObject {
+ val entries = JSONArray()
+ for (grant in grants()) {
+ val file = runCatching {
+ androidx.documentfile.provider.DocumentFile.fromTreeUri(appContext, android.net.Uri.parse(grant.uri))
+ }.getOrNull()
+ entries.put(
+ JSONObject().apply {
+ put("name", grant.name)
+ put("uri", grant.uri)
+ put("exists", file?.exists() == true)
+ },
+ )
+ }
+ return JSONObject().apply {
+ put("count", entries.length())
+ put("roots", entries)
+ put(
+ "note",
+ if (entries.length() == 0) {
+ "用户还没有授权任何 SAF 目录。请让他在「设置 → 设备能力 → 存储」点「授权目录」," +
+ "选一个文件夹(例如 Documents 或某个项目目录),之后就能读写它里面的文件。"
+ } else {
+ "路径写成「根目录名/相对路径」,例如 ${entries.optJSONObject(0)?.optString("name")}/notes/todo.md。"
+ },
+ )
+ }
+ }
+
+ /** `POST /app/files/list` — no path lists the roots themselves. */
+ fun list(path: String?): JSONObject {
+ val clean = path?.trim().orEmpty()
+ if (clean.isEmpty()) return describe()
+ val target = resolve(clean, createDirectories = false)
+ ?: throw notFound(clean)
+ if (!target.isDirectory) {
+ throw DeviceActionException(
+ DeviceDenial(DeviceDenial.BAD_REQUEST, "「$clean」不是目录。"),
+ )
+ }
+ val children = runCatching { target.listFiles() }.getOrElse { error ->
+ throw revoked(clean, error)
+ }
+ val entries = children
+ .sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name })
+ .take(500)
+ .map { file ->
+ JSONObject().apply {
+ put("name", file.name ?: "")
+ put("directory", file.isDirectory)
+ put("bytes", file.length())
+ put("lastModified", file.lastModified())
+ put("uri", file.uri.toString())
+ }
+ }
+ val array = JSONArray()
+ for (entry in entries) array.put(entry)
+ return JSONObject().apply {
+ put("path", clean)
+ put("count", array.length())
+ put("truncated", children.size > array.length())
+ put("entries", array)
+ }
+ }
+
+ /** `POST /app/files/read` — text or base64, bounded. */
+ fun read(path: String, maxBytes: Int): JSONObject {
+ val clean = path.trim()
+ if (clean.isEmpty()) {
+ throw DeviceActionException(DeviceDenial(DeviceDenial.BAD_REQUEST, "读取文件需要 path。"))
+ }
+ val target = resolve(clean, createDirectories = false) ?: throw notFound(clean)
+ if (target.isDirectory) {
+ throw DeviceActionException(DeviceDenial(DeviceDenial.BAD_REQUEST, "「$clean」是目录,请用 android_files_list。"))
+ }
+ val limit = maxBytes.coerceIn(1024, 4 * 1024 * 1024)
+ val bytes = runCatching {
+ appContext.contentResolver.openInputStream(target.uri)?.use { input ->
+ val all = input.readBytes()
+ if (all.size > limit) all.copyOf(limit) else all
+ }
+ }.getOrElse { error -> throw revoked(clean, error) }
+ ?: throw DeviceActionException(DeviceDenial(DeviceDenial.ERROR, "无法打开「$clean」的输入流。"))
+
+ val text = bytes.all { byte ->
+ val value = byte.toInt() and 0xFF
+ value == 9 || value == 10 || value == 13 || value in 32..126 || value >= 128
+ }
+ return JSONObject().apply {
+ put("path", clean)
+ put("uri", target.uri.toString())
+ put("bytes", bytes.size)
+ put("truncated", bytes.size >= limit)
+ if (text) put("text", String(bytes, Charsets.UTF_8))
+ else put("base64", android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP))
+ }
+ }
+
+ /** `POST /app/files/write` — creates the file and any missing parent directories. */
+ fun write(path: String, text: String?, base64: String?, mimeType: String): JSONObject {
+ val clean = path.trim().trimStart('/')
+ if (clean.isEmpty() || !clean.contains('/')) {
+ throw DeviceActionException(
+ DeviceDenial(
+ code = DeviceDenial.BAD_REQUEST,
+ reason = "写入需要「根目录名/相对路径」形式的 path(例如 Documents/notes/todo.md)。",
+ hint = "先用 android_files_list 看已授权的根目录名。",
+ ),
+ )
+ }
+ val bytes = when {
+ base64 != null -> runCatching { android.util.Base64.decode(base64, android.util.Base64.DEFAULT) }
+ .getOrElse {
+ throw DeviceActionException(DeviceDenial(DeviceDenial.BAD_REQUEST, "base64 无法解码:${it.message}"))
+ }
+
+ text != null -> text.toByteArray(Charsets.UTF_8)
+ else -> throw DeviceActionException(
+ DeviceDenial(DeviceDenial.BAD_REQUEST, "写入需要 content(文本)或 base64(二进制)之一。"),
+ )
+ }
+ val target = resolve(clean)
+ ?: throw notFound(clean)
+ val parent = target.parentFile ?: throw DeviceActionException(
+ DeviceDenial(DeviceDenial.ERROR, "「$clean」没有可写的父目录。"),
+ )
+ val leafName = target.name ?: "untitled"
+ val existed = target.isFile
+ val file = if (existed) {
+ target
+ } else {
+ // A specific MIME type is what the user sees in Files; some providers
+ // refuse it, so the documented generic type is the fallback.
+ runCatching { parent.createFile(mimeType.ifBlank { "application/octet-stream" }, leafName) }
+ .getOrNull()
+ ?: runCatching { parent.createFile("application/octet-stream", leafName) }.getOrNull()
+ ?: throw DeviceActionException(
+ DeviceDenial(
+ code = DeviceDenial.ERROR,
+ reason = "SAF 目录拒绝创建「$clean」(提供方可能只读,或文件名不合法)。",
+ ),
+ )
+ }
+ runCatching {
+ appContext.contentResolver.openOutputStream(file.uri, "wt")?.use { stream -> stream.write(bytes) }
+ ?: throw DeviceActionException(DeviceDenial(DeviceDenial.ERROR, "无法打开「$clean」的输出流。"))
+ }.getOrElse { error -> throw revoked(clean, error) }
+
+ return JSONObject().apply {
+ put("path", clean)
+ put("uri", file.uri.toString())
+ put("bytes", bytes.size)
+ put("created", !existed)
+ put("mimeType", file.type ?: mimeType)
+ }
+ }
+
+ /** Lines for the storage card. */
+ fun summaryLines(): List {
+ val current = grants()
+ if (current.isEmpty()) {
+ return listOf("已授权目录:无。点「授权目录」选一个文件夹(Documents、某个项目目录等),Agent 就能读写它。")
+ }
+ return listOf("已授权目录(Agent 可读写):" + current.joinToString("、") { it.name })
+ }
+
+ // ---------------------------------------------------------------- helpers ----
+
+ /**
+ * Address a path as `rootName/relative/parts`. The first segment selects the
+ * grant; the rest is walked with `findFile`, and optionally created.
+ */
+ private fun resolve(path: String, createDirectories: Boolean): androidx.documentfile.provider.DocumentFile? {
+ val parts = path.split('/').filter { it.isNotEmpty() }
+ if (parts.isEmpty()) return null
+ val grant = grants().firstOrNull { it.name == parts[0] }
+ ?: grants().firstOrNull { it.name.equals(parts[0], ignoreCase = true) }
+ ?: return null
+ var current = runCatching {
+ androidx.documentfile.provider.DocumentFile.fromTreeUri(appContext, android.net.Uri.parse(grant.uri))
+ }.getOrNull() ?: return null
+
+ for (index in 1 until parts.size) {
+ val part = parts[index]
+ val last = index == parts.size - 1
+ val existing = runCatching { current.findFile(part) }.getOrNull()
+ current = when {
+ existing != null -> existing
+ !createDirectories -> return null
+ last -> {
+ // Leave the leaf to the caller: an empty DocumentFile shell with
+ // the right name is what `createFile` needs to see.
+ return current.createPlaceholder(part, grant)
+ }
+
+ else -> runCatching { current.createDirectory(part) }.getOrNull() ?: return null
+ }
+ }
+ return current
+ }
+
+ /**
+ * A non-existent leaf: the write path needs a `DocumentFile` whose `parentFile`
+ * and `name` are right, and `DocumentFile.fromSingleUri` cannot express that, so
+ * the URI is built from the tree URI the same way `DocumentFile` does.
+ */
+ private fun androidx.documentfile.provider.DocumentFile.createPlaceholder(
+ name: String,
+ grant: Grant,
+ ): androidx.documentfile.provider.DocumentFile? {
+ val parent = this
+ return runCatching {
+ val documentId = parent.uri.lastPathSegment ?: return null
+ val childId = "$documentId/$name"
+ val childUri = android.content.ContentUris.withAppendedId(
+ android.net.Uri.parse("content://${parent.uri.authority}/tree/${android.net.Uri.encode(documentId)}"),
+ 0,
+ )
+ // The path above is not a legal document URI on every provider, so the
+ // placeholder is only a name/parent carrier; `write` replaces it via
+ // `createFile` unless it exists by then.
+ if (childUri == null) null else {
+ androidx.documentfile.provider.DocumentFile.fromTreeUri(
+ appContext,
+ android.net.Uri.parse("${grant.uri}/document/${android.net.Uri.encode(childId)}"),
+ )
+ }
+ }.getOrNull()
+ }
+
+ private fun notFound(path: String): DeviceActionException = DeviceActionException(
+ DeviceDenial(
+ code = DeviceDenial.NOT_FOUND,
+ reason = "在已授权目录里找不到「$path」。",
+ hint = "先用 android_files_list 看根目录名与目录内容;路径是「根目录名/相对路径」。",
+ ),
+ )
+
+ private fun revoked(path: String, error: Throwable): DeviceActionException = DeviceActionException(
+ DeviceDenial(
+ code = DeviceDenial.NO_PERMISSION,
+ reason = "访问「$path」失败,SAF 授权可能已经失效:${error::class.java.simpleName}: ${error.message}",
+ hint = "请让用户在「设置 → 设备能力 → 存储」重新授权该目录。",
+ ),
+ )
+
+ private fun uniqueName(name: String, uri: String): String {
+ val taken = grants().filterNot { it.uri == uri }.map { it.name }.toSet()
+ if (name !in taken) return name
+ var index = 2
+ while ("$name ($index)" in taken) index++
+ return "$name ($index)"
+ }
+
+ private fun save(grants: List) {
+ val array = JSONArray()
+ for (grant in grants) {
+ array.put(JSONObject().put("uri", grant.uri).put("name", grant.name))
+ }
+ prefs.edit().putString(KEY_GRANTS, array.toString()).apply()
+ }
+
+ companion object {
+ private const val PREFS_NAME = "pi-device-saf"
+ private const val KEY_GRANTS = "grants"
+
+ @Volatile
+ private var instance: DeviceSafStore? = null
+
+ fun get(context: android.content.Context): DeviceSafStore {
+ val existing = instance
+ if (existing != null) return existing
+ return synchronized(this) {
+ instance ?: DeviceSafStore(context).also { instance = it }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceShell.kt b/app/src/main/kotlin/app/pi/bridge/DeviceShell.kt
index 0631723..b69d41a 100644
--- a/app/src/main/kotlin/app/pi/bridge/DeviceShell.kt
+++ b/app/src/main/kotlin/app/pi/bridge/DeviceShell.kt
@@ -10,9 +10,7 @@ import java.util.concurrent.TimeUnit
*
* [backend] and [uid] are not decoration: without them a model cannot tell why
* `pm list packages` worked and `dumpsys battery` did not, and would report a
- * capability the app does not really have. Shizuku / ADB wireless debugging is
- * *not* wired in this build, so the only backend today runs with the app's own
- * uid — which is a much smaller privilege than the design's uid=2000 target.
+ * capability the app does not really have.
*/
data class DeviceShellResult(
val stdout: String,
@@ -25,8 +23,11 @@ data class DeviceShellResult(
)
/**
- * A shell execution backend. The interface exists so a Shizuku/ADB backend can
- * be dropped in later without touching the policy layer.
+ * A shell execution backend. Two ship today:
+ *
+ * - [ShizukuShellBackend] — uid 2000 (or 0), the ADB identity, when the user has
+ * Shizuku running and has granted this app permission;
+ * - [AppUidShellBackend] — the app's own uid, always available, much weaker.
*/
interface DeviceShellBackend {
val id: String
@@ -36,17 +37,16 @@ interface DeviceShellBackend {
}
/**
- * The only backend this build ships: `/system/bin/sh` as the app's own uid
- * (`u0_aXXX`), *not* uid 2000.
+ * `/system/bin/sh` as the app's own uid (`u0_aXXX`), *not* uid 2000.
*
- * That is a deliberate, honest limitation rather than a hidden one. It can read
- * what the app can read and write into the app's own storage plus the public
- * Download collection; it cannot inspect other apps or system state that the app
- * sandbox hides. The response says so on every call.
+ * This is the fallback, and it is an honest one: it can read what the app can
+ * read and write into the app's own storage plus the public Download collection;
+ * it cannot inspect other apps or system state that the app sandbox hides. The
+ * response says so on every call.
*/
object AppUidShellBackend : DeviceShellBackend {
override val id: String = "app-uid"
- override val label: String = "应用自身身份(非 uid=2000)"
+ override val label: String = "应用自身身份(uid=${Process.myUid()})"
override val available: Boolean = true
private const val MAX_OUTPUT_BYTES = 50 * 1024
@@ -120,95 +120,206 @@ object AppUidShellBackend : DeviceShellBackend {
}
}
+/**
+ * The device shell's write boundary, expressed as one question: is this path part
+ * of the directory the user handed to pi?
+ *
+ * ### Why "the workspace is the authorization boundary"
+ *
+ * The user's rule, made explicit: **whatever directory they picked as the
+ * workspace is what they gave the agent.** That choice *is* the authorization, so
+ * nothing inside the workspace is the gate's business — not `rm -rf`, not a
+ * `chmod 777`, not overwriting a file. A hardcoded list of "sensitive" paths
+ * (`DCIM`, `Pictures`, `Android/data`, …) is the wrong shape twice over: it blocks
+ * things the user meant to hand over when the workspace happens to *be* that
+ * directory, and it says nothing about everything else that happens to be outside
+ * the workspace. So the rule is relative, not a list: **inside the workspace the
+ * gate does not exist; outside it, the shell does not write.**
+ *
+ * This is only about *writes*: reading device state (`dumpsys`, `getprop`,
+ * `ls /sdcard`) is what the 「Shell」 opt-in authorizes, and stays allowed wherever
+ * it points. And it is only about the *shell*: `android_export` / `android_files_*`
+ * are separate, explicitly-confirmed endpoints whose whole purpose is to touch
+ * files the user picks elsewhere.
+ *
+ * The implementation is deliberately not a `File.canonicalPath` call: the command
+ * is a string that has not run yet, so the boundary is decided by lexically
+ * normalising the path tokens the command *names*.
+ */
+interface ShellWriteBoundary {
+ /** True when [path] is the workspace or inside it. */
+ fun contains(path: String): Boolean
+
+ /** The workspace as the device shell has to spell it. */
+ fun shellPath(): String?
+
+ /** True when we actually know the workspace; otherwise the rule is skipped. */
+ fun isKnown(): Boolean
+}
+
/**
* The policy guard for the `android_shell` capability (design §23.3).
*
- * It is deliberately two-sided:
+ * Four refusals, and that is the whole list:
*
- * - a **command whitelist**, so an unrecognised command is refused rather than
- * attempted ("未知命令直接拦截" — a blocklist alone is unbounded, because the
- * device has thousands of binaries);
- * - a **hard blocklist** of the things no user consent should be able to reach
- * through this app: block devices and partitions, SELinux, `settings put` /
- * `setprop`, mount/flash, and clearing or uninstalling apps.
+ * 1. **Command substitution** (`$(...)`, backticks) unless 放宽模式 is on. It
+ * smuggles a command past the per-segment validation below.
+ * 2. **The hard blocklist** — ten things, matched anywhere in the command text,
+ * each earning its place by "needs root we do not have, so it can only fail"
+ * or "irreversible device damage if it ever ran". See [hardBlocks].
+ * 3. **Unknown commands.** An unrecognised head is refused rather than attempted,
+ * because a blocklist over a device with thousands of binaries is unbounded.
+ * The set is the everyday read *and write* vocabulary of a coding agent — the
+ * user's complaint was that it had grown read-mostly by caution.
+ * 4. **Writes outside the workspace.** See [ShellWriteBoundary] for why the
+ * workspace is the boundary and why this replaced the old hardcoded path list.
*
- * Chained commands are split and every segment is validated, because otherwise
- * `getprop x; mount -o rw /` would pass a naive head check. Command substitution
- * (`$(...)`, backticks) is refused outright: it can smuggle a denied command
- * past the splitter.
+ * The hard blocks are matched against the raw text *including inside quotes*.
+ * That is intentional: `xargs mount`, `env dd of=/dev/block/...` and
+ * `echo | sh -c "settings put ..."` reach a blocked command through an allowed
+ * head, and with a widened whitelist only a text-level scan closes that. The cost
+ * is that a command which merely mentions one of the tokens (for example
+ * `grep mount /proc/mounts`) is refused too; the denial names the rule, so the
+ * model can rephrase rather than guess.
*/
object DeviceShellGuard {
- /** The only thing the bridge is allowed to execute with today. */
- fun backends(): List = listOf(AppUidShellBackend)
+ /** Preferred first: the elevated backend wins when the user has enabled it. */
+ fun backends(): List = listOf(ShizukuShellBackend, AppUidShellBackend)
+
+ fun active(): DeviceShellBackend = backends().firstOrNull { it.available } ?: AppUidShellBackend
- fun active(): DeviceShellBackend = AppUidShellBackend
+ /** True when commands run as uid 2000 (or 0) instead of the app's own uid. */
+ fun hasElevatedBackend(): Boolean = ShizukuShellBackend.available
- /** Heads a read-only device query may start with. */
- private val allowedHeads: Set = setOf(
- "getprop", "dumpsys", "logcat", "pm", "am", "cmd", "settings",
- "ls", "cat", "head", "tail", "wc", "stat", "file", "readlink", "realpath",
- "df", "du", "ps", "top", "free", "uptime", "date", "uname", "id", "whoami",
- "echo", "printf", "which", "type", "env", "printenv", "pwd",
- "find", "grep", "sort", "uniq", "cut", "tr", "sed", "xargs", "basename", "dirname",
- "ip", "ifconfig", "netstat", "ping", "getevent", "screencap", "input",
+ /**
+ * Heads a read **or write** command may start with, ordered by what it is for
+ * so the authorization page can show it as a readable list.
+ *
+ * What is deliberately *not* here: `sh`/`bash`/`eval`/`source` (they would make
+ * the whitelist meaningless — only relaxed mode admits them), `kill`/`killall`
+ * (ending device processes is what the separately-confirmed android_stop_app is
+ * for, and the app's own engine is one of its own processes), `chown` (needs
+ * root, so it could only ever fail), and interpreters a ROM might or might not
+ * ship (`python3`, `perl`, `node`).
+ */
+ val allowedCommands: List = listOf(
+ // shell builtins that matter here
+ "cd",
+ // 设备与系统查询
+ "getprop", "dumpsys", "logcat", "pm", "am", "cmd", "settings", "wm",
+ "screencap", "input", "getevent",
+ // 文件与目录(读写)
+ "ls", "cat", "head", "tail", "wc", "stat", "file", "readlink", "realpath", "find",
+ "mkdir", "rmdir", "rm", "cp", "mv", "touch", "ln", "chmod", "install",
+ "mktemp", "tee", "truncate", "df", "du", "sync",
+ // 文本与二进制处理
+ "grep", "sort", "uniq", "cut", "tr", "sed", "awk", "xargs", "diff", "cmp", "patch",
+ "basename", "dirname", "seq", "expr", "strings", "base64", "xxd", "od", "hexdump",
+ "md5sum", "sha1sum", "sha256sum", "cksum",
+ // 归档
+ "tar", "gzip", "gunzip", "zip", "unzip",
+ // 进程与系统信息
+ "ps", "top", "free", "uptime", "date", "uname", "id", "whoami", "pwd",
+ "env", "printenv", "which", "type", "nproc", "getconf",
"sleep", "true", "false", "test", "[",
+ // 网络
+ "ip", "ifconfig", "netstat", "ping", "curl", "wget",
+ // 数据库文件
+ "sqlite3",
)
- /** Commands whose *only* purpose here would be to escape the policy. */
- private val forbidden: List> = listOf(
- Regex("(^|[\\s;&|])mount(\\s|$)") to "挂载/卸载文件系统",
- Regex("(^|[\\s;&|])umount(\\s|$)") to "挂载/卸载文件系统",
- Regex("\\bsetenforce\\b") to "修改 SELinux 状态",
- Regex("\\bgetenforce\\s+0\\b") to "修改 SELinux 状态",
- Regex("/sys/fs/selinux") to "访问 SELinux 控制面",
- Regex("\\bsetprop\\b") to "修改系统属性(setprop)",
- Regex("\\bsettings\\s+(put|delete|reset)\\b") to "修改系统设置(settings put/delete)",
- Regex("\\bpm\\s+(clear|uninstall|disable|enable|hide|suspend|restore)\\b") to "清除/卸载/禁用应用数据",
- Regex("\\bcmd\\s+package\\s+(clear|uninstall|disable|enable|suspend)\\b") to "清除/卸载/禁用应用数据",
- Regex("\\bcmd\\s+settings\\s+(put|delete|reset)\\b") to "修改系统设置",
- Regex("\\bcmd\\s+(device_admin|role|wifi|bluetooth_manager|telecom|netpolicy)\\b") to "修改系统服务状态",
- Regex("\\bmknod\\b") to "创建设备节点",
- Regex("\\bdd\\b") to "裸块写入(dd)",
- Regex("\\bmkfs(\\.|\\s|$)") to "格式化分区",
- Regex("\\bfastboot\\b") to "刷机",
- Regex("\\breboot\\b") to "重启设备",
- Regex("\\brecovery\\b") to "进入恢复模式",
- Regex("\\b(insmod|rmmod|modprobe)\\b") to "加载/卸载内核模块",
- Regex("(^|[\\s;&|])(su|sudo|magisk)\\b") to "提权(root)",
- Regex("(^|[\\s;&|])eval\\b") to "动态求值(eval)",
- Regex("(^|[\\s;&|])exec\\b") to "替换进程(exec)",
- Regex("(^|[\\s;&|])(source|\\.)\\s") to "加载脚本(source)",
- Regex("\\bkillall\\b") to "批量结束进程",
- Regex("\\bkill\\s+-9\\s+-1\\b") to "结束所有进程",
- Regex("\\bwipe\\b") to "擦除数据",
- Regex("\\bflash(er)?\\b") to "刷写分区",
- // No leading \b: there is no word boundary between a space and a slash, so
- // Regex("\\b/dev/block\\b") could never match. (The TS mirror of this policy
- // had the same bug; the runtime harness caught it.)
- Regex("/dev/block\\b") to "访问块设备",
- Regex("/dev/mem\\b") to "访问物理内存",
- Regex("\\bshutdown\\b") to "关机",
- )
+ private val allowedHeads: Set = allowedCommands.toSet()
- /** Paths the app may not write to, regardless of how it got there. */
- private val protectedWriteRoots: List = listOf(
- "/dev/block", "/dev/mem", "/proc", "/sys", "/system", "/vendor", "/apex",
- "/data/data", "/data/system", "/sdcard/DCIM", "/sdcard/Pictures",
- "/storage/emulated/0/DCIM", "/storage/emulated/0/Pictures",
- "/sdcard/Android/data", "/sdcard/Android/obb",
- "/storage/emulated/0/Android/data", "/storage/emulated/0/Android/obb",
- )
+ /**
+ * Heads only 放宽模式 admits. They nest execution, so with them the whitelist
+ * constrains the *outer* command only — including, deliberately and visibly, the
+ * write boundary. That is the cost the UI states before the switch is flipped.
+ */
+ private val relaxedOnlyHeads: Set =
+ setOf("sh", "bash", "dash", "ash", "busybox", "eval", "exec", "source", ".")
+
+ private const val RELAXED_COST =
+ "放宽模式允许 \$(…)、反引号与 sh/bash/eval/source 这类嵌套执行:命令白名单与写入边界从此只约束最外层命令," +
+ "嵌套进去的命令不再逐条检查(包括它是否写在工作区内),等于把设备 Shell 的边界交给 Agent 自己把持。"
+
+ /** One hard block: what it is, and which of the two tests earns it a place. */
+ private data class HardBlock(val pattern: Regex, val what: String, val why: String)
- private val writeTokens = listOf(
- ">", ">>", "tee", "cp ", "mv ", "rm ", "rmdir", "mkdir", "touch",
- "chmod", "chown", "truncate", "sed -i", "ln ", "install ",
+ /**
+ * The irreducible set. Eleven entries, no more.
+ *
+ * Test A = "needs privilege the app does not have, so it can only fail".
+ * Test B = "irreversible device damage if it ever ran".
+ *
+ * These are the one class the workspace carve-out does **not** cover: the test
+ * is the command itself, not where it runs. `rm -rf /workspace/build` is the
+ * user's own directory and none of our business; `dd` is not.
+ *
+ * Honest note for a Shizuku-enabled device: with a uid=2000 backend `setprop`
+ * and `settings put` (and possibly `mount` on a userdebug build) would
+ * *succeed*, so for those the block is a policy decision rather than test A.
+ * The user asked for exactly this list; the wording below says "we forbid it",
+ * not "it cannot work".
+ */
+ private val hardBlocks: List = listOf(
+ // --- Test A: needs root (or ADB-level privilege) this app does not have ---
+ HardBlock(
+ Regex("(^|[\\s;&|()])mount(\\s|$)"),
+ "mount",
+ "挂载/卸载文件系统需要 root(缺少 CAP_SYS_ADMIN),应用身份下只会失败",
+ ),
+ HardBlock(
+ Regex("(^|[\\s;&|()])umount(\\s|$)"),
+ "umount",
+ "挂载/卸载文件系统需要 root,应用身份下只会失败",
+ ),
+ HardBlock(Regex("\\bsetenforce\\b"), "setenforce", "修改 SELinux 需要 root"),
+ HardBlock(Regex("\\bsetprop\\b"), "setprop", "修改系统属性需要 root 或特权 SELinux 域"),
+ HardBlock(
+ Regex("\\bsettings\\s+(put|delete|reset)\\b"),
+ "settings put/delete/reset",
+ "写系统设置需要 WRITE_SECURE_SETTINGS(签名权限);这一条同时是刻意的策略:授权 Shell 不等于授权改设备设置",
+ ),
+ HardBlock(Regex("\\bmknod\\b"), "mknod", "创建设备节点需要 CAP_MKNOD"),
+ // --- Test B: irreversible damage ---
+ HardBlock(Regex("\\bdd\\b"), "dd", "裸写入可以覆盖分区或整盘数据,无法撤销"),
+ HardBlock(Regex("\\bmkfs(\\.[a-z0-9]+)?(\\s|$)"), "mkfs", "格式化会销毁文件系统,无法撤销"),
+ HardBlock(
+ Regex("\\bpm\\s+(clear|uninstall)\\b"),
+ "pm clear/uninstall",
+ "清除应用数据或卸载应用会丢失用户数据,无法撤销",
+ ),
+ HardBlock(
+ Regex("\\bcmd\\s+package\\s+(clear|uninstall)\\b"),
+ "cmd package clear/uninstall",
+ "清除应用数据或卸载应用会丢失用户数据,无法撤销",
+ ),
+ HardBlock(
+ Regex("(^|[\\s;&|()])(su|sudo|magisk)(\\s|$)"),
+ "su/sudo/magisk",
+ "提权:拿到 root 意味着上面每一条都能执行",
+ ),
+ // No leading \b: there is no word boundary between a space and a slash, so
+ // Regex("\\b/dev/block\\b") could never match. The TS mirror of this policy
+ // had the same bug; the runtime harness caught it.
+ HardBlock(Regex("/dev/block"), "/dev/block", "块设备:写入等于直接改分区,无法撤销"),
)
/**
+ * @param relaxedShellSyntax the opt-in 放宽模式. Persisted by
+ * [DeviceCapabilityStore] and reported on `/app/health`, so the Kotlin guard
+ * and the TypeScript gate read one switch, not two.
+ * @param boundary the workspace. `null` (or an unknown workspace) means the
+ * write rule cannot be applied and is skipped — the class-2 hard blocks are
+ * path-independent and still apply.
* @return `null` when [command] may run, otherwise the denial to hand back.
*/
- fun inspect(command: String): DeviceDenial? {
+ fun inspect(
+ command: String,
+ relaxedShellSyntax: Boolean = false,
+ boundary: ShellWriteBoundary? = null,
+ ): DeviceDenial? {
val trimmed = command.trim()
if (trimmed.isEmpty()) {
return DeviceDenial(DeviceDenial.BAD_REQUEST, "命令为空。")
@@ -219,87 +330,250 @@ object DeviceShellGuard {
reason = "命令过长(${trimmed.length} 字符,上限 4000)。",
)
}
- if (trimmed.contains('`')) {
- return DeviceDenial(
- code = DeviceDenial.BLOCKED_BY_POLICY,
- reason = "命令包含反引号命令替换,已按策略拦截。",
- hint = "请把每一步拆成独立的只读命令分别执行。",
- )
- }
- if (trimmed.contains("\$(")) {
- return DeviceDenial(
- code = DeviceDenial.BLOCKED_BY_POLICY,
- reason = "命令包含 \$(...) 命令替换,已按策略拦截。",
- hint = "请把每一步拆成独立的只读命令分别执行。",
- )
+ if (!relaxedShellSyntax) {
+ if (trimmed.contains('`')) {
+ return substitutionDenial("反引号")
+ }
+ if (trimmed.contains("\$(")) {
+ return substitutionDenial("\$(...)")
+ }
}
- for ((pattern, reason) in forbidden) {
- if (pattern.containsMatchIn(trimmed)) {
+ for (block in hardBlocks) {
+ if (block.pattern.containsMatchIn(trimmed)) {
return DeviceDenial(
code = DeviceDenial.BLOCKED_BY_POLICY,
- reason = "命令被设备策略拦截:$reason。这是硬性限制,无法通过用户授权放行。",
- hint = "请改用只读查询,或直接告诉用户这一步需要另外的方式完成。",
+ reason = "命令被设备策略拦截:${block.what}(${block.why})。" +
+ "这一条与工作区在哪无关,也无法通过用户授权放行。",
+ hint = "请改用只读查询或其他工具,或直接告诉用户这一步无法通过设备桥完成。",
)
}
}
// Split on shell separators and validate every segment's head token, so
- // `getprop x; rm -rf /sdcard/DCIM` cannot ride on an allowed first head.
- val segments = trimmed
- .replace("&&", ";")
- .replace("||", ";")
- .replace("|", ";")
- .replace("\n", ";")
- .split(';')
- .map { it.trim() }
- .filter { it.isNotEmpty() }
-
+ // `getprop x; rm -rf x` cannot ride on an allowed first head.
+ val segments = splitSegments(trimmed)
if (segments.isEmpty()) {
return DeviceDenial(DeviceDenial.BAD_REQUEST, "命令为空。")
}
+ val heads = if (relaxedShellSyntax) allowedHeads + relaxedOnlyHeads else allowedHeads
for (segment in segments) {
val head = segment.split(Regex("\\s+")).firstOrNull()?.trim().orEmpty()
- val normalized = head.substringAfterLast('/')
- if (normalized.isEmpty() || normalized !in allowedHeads) {
+ val normalized = normalizeHead(head)
+ if (normalized.isEmpty() || normalized !in heads) {
return DeviceDenial(
code = DeviceDenial.BLOCKED_BY_POLICY,
reason = "命令「$head」不在设备 Shell 的白名单内,未知命令默认拦截。",
- hint = "可用的是只读设备查询(getprop、dumpsys、pm list、logcat、ls、cat、df、ps 等)。" +
- "需要别的操作时,请改用无障碍或专用工具。",
+ hint = "设备 Shell 是设备级遥控,不是工作区里的通用终端:日常读写(ls/cat/cp/mv/rm/mkdir/sed/tar/grep/find/curl/dumpsys/pm/settings get …)都在白名单里;" +
+ "要跑构建、git、npm、rg 这类工具,请用 pi 的内置 bash —— 那才是工作区的工作台,不受设备策略管辖。",
)
}
}
- val writes = writeTokens.any { trimmed.contains(it) } ||
- Regex("(^|[\\s;&])>").containsMatchIn(trimmed)
- if (writes) {
- val target = protectedWriteRoots.firstOrNull { trimmed.contains(it) }
- if (target != null) {
- return DeviceDenial(
- code = DeviceDenial.BLOCKED_BY_POLICY,
- reason = "命令试图写入受保护目录 $target,已按策略拦截(这些目录只读)。",
- hint = "可以写入应用自己的目录,或使用 android_export 写入公共 Download。",
- )
+ if (boundary != null && boundary.isKnown()) {
+ writeBoundaryDenial(trimmed, boundary)?.let { return it }
+ }
+ return null
+ }
+
+ private fun substitutionDenial(what: String): DeviceDenial = DeviceDenial(
+ code = DeviceDenial.BLOCKED_BY_POLICY,
+ reason = "命令包含$what 命令替换,已按策略拦截。",
+ hint = "请把每一步拆成独立的命令分别执行;如果确实需要嵌套执行," +
+ "让用户在「设置 → 设备能力 → Shell」打开「放宽模式」(默认关闭)。",
+ )
+
+ // ------------------------------------------------------ write boundary ----
+
+ private data class WriteTarget(val raw: String, val cwd: String, val kind: String)
+
+ private val redirection = Regex("(?:^|[^0-9<>])>>?\\s*([^\\s;&|<>()]+)")
+
+ private fun writeBoundaryDenial(command: String, boundary: ShellWriteBoundary): DeviceDenial? {
+ for (target in extractWriteTargets(command)) {
+ if (targetAllowed(target, boundary)) continue
+ val where = boundary.shellPath() ?: "工作区"
+ val platform = if (isAndroidDataPath(target.raw)) {
+ "Android 11(API 30)起的分区存储在平台层面也禁止应用写别的应用的 Android/data、Android/obb," +
+ "写进去只会以 EACCES 失败。"
+ } else {
+ ""
}
+ return DeviceDenial(
+ code = DeviceDenial.BLOCKED_BY_POLICY,
+ reason = "命令要写工作区之外的位置:${target.raw}(来自 ${target.kind})。" +
+ "工作区是用户交给 Agent 的那一个目录,也是设备 Shell 的写入边界;" +
+ "边界之内(含 DCIM、Pictures、Download 之类的目录,只要它就是工作区)一律不拦。$platform",
+ hint = "请在工作区内操作(设备 Shell 看到的工作区是 $where;也可以用 android_export 写公共 Download," +
+ "或让用户在「设置 → 设备能力 → 存储」授权一个 SAF 目录后用 android_files_write)。",
+ )
}
return null
}
- /** The human-readable policy, surfaced by the diagnostics card. */
- fun policySummary(): List = listOf(
- "白名单之外的命令一律拒绝",
- "禁止块设备与分区操作(/dev/block、dd、mkfs、fastboot)",
- "禁止修改 SELinux(setenforce、/sys/fs/selinux)",
- "禁止 setprop / settings put",
- "禁止 mount / umount",
- "禁止清除或卸载应用(pm clear/uninstall)",
- "禁止提权(su、sudo、magisk)与命令替换(\$(...)、反引号)",
- "DCIM、Pictures、Android/data、Android/obb 只读",
+ private fun isAndroidDataPath(raw: String): Boolean =
+ raw.contains("/Android/data") || raw.contains("/Android/obb")
+
+ /**
+ * Which argument of a write command is the destination. Relative names resolve
+ * against the tracked `cd`, so `cd $workspace && rm -rf build` is fine while
+ * `cd / && rm -rf sdcard` is not.
+ */
+ private fun extractWriteTargets(command: String): List {
+ val out = ArrayList()
+ var cwd = "/"
+ for (segment in splitSegments(command)) {
+ for (match in redirection.findAll(segment)) {
+ val raw = match.groupValues[1].trim()
+ if (raw.isNotEmpty() && raw != "-") out.add(WriteTarget(raw, cwd, "重定向"))
+ }
+ val tokens = segment.split(Regex("\\s+")).filter { it.isNotEmpty() }
+ if (tokens.isEmpty()) continue
+ val head = normalizeHead(tokens[0])
+ val args = tokens.drop(1)
+ if (head == "cd") {
+ cwd = args.firstOrNull()?.let { canonicalize(resolveAgainst(it, cwd)) } ?: "/"
+ continue
+ }
+ for (raw in destinationsFor(head, args)) out.add(WriteTarget(raw, cwd, head))
+ }
+ return out
+ }
+
+ private fun destinationsFor(head: String, args: List): List {
+ val values = { names: List ->
+ args.filterIndexed { index, token -> index > 0 && args[index - 1] in names }
+ }
+ val nonFlags = args.filter { it.isNotEmpty() && !it.startsWith("-") }
+ return when (head) {
+ "rm", "rmdir", "mkdir", "touch", "truncate", "chmod", "ln", "install",
+ "patch", "tee", "mktemp", "unzip", "gzip", "gunzip", "zip", "tar",
+ -> nonFlags
+
+ "cp", "mv" -> nonFlags.takeLast(1)
+ "dd" -> args.filter { it.contains("of=") }.map { it.substringAfter("of=") }
+ // `sed -i s/a/b/ file` — the script is the first non-flag argument and
+ // is not a path; without -i, sed does not write at all.
+ "sed" -> if (args.any { it == "-i" || it.startsWith("-i") }) nonFlags.drop(1) else emptyList()
+ "curl" -> values(listOf("-o", "--output"))
+ "wget" -> values(listOf("-O", "--output-document"))
+ // find only writes when it is told to; then every path it names counts.
+ "find" -> if (args.any { it == "-delete" || it == "-exec" || it == "-execdir" }) {
+ args.filter { it.isNotEmpty() && !it.startsWith("-") && !it.contains("{}") }
+ } else {
+ emptyList()
+ }
+
+ "sqlite3" -> nonFlags.take(1)
+ else -> emptyList()
+ }
+ }
+
+ private fun targetAllowed(target: WriteTarget, boundary: ShellWriteBoundary): Boolean {
+ val raw = target.raw.trim().trim('"', '\'')
+ if (raw.isEmpty() || raw == "-" || raw.startsWith("&")) return true
+ // A URL is not a filesystem path (`curl -o` aside, which is handled above).
+ if (raw.contains("://")) return true
+ val combined = resolveAgainst(raw, target.cwd)
+ val pinned = staticPrefix(combined)
+ if (pinned.isEmpty() && (raw.contains('$') || raw.contains('`'))) {
+ // Unresolvable absolute-ish target: fail closed, the message says why.
+ return false
+ }
+ val canonical = canonicalize(if (pinned.isEmpty()) target.cwd else pinned)
+ if (canonical.isEmpty()) return true
+ return boundary.contains(canonical)
+ }
+
+ /** Relative tokens resolve against the shell's tracked `cd`. */
+ private fun resolveAgainst(raw: String, cwd: String): String =
+ if (raw.startsWith("/")) raw else "$cwd/$raw"
+
+ /**
+ * Everything before the first character that could expand to something else.
+ * `rm -rf build/*` keeps `build/`, so a glob inside the workspace is allowed
+ * while `rm -rf /etc/*` is not.
+ */
+ private fun staticPrefix(path: String): String {
+ val cut = path.indexOfFirst { it == '*' || it == '?' || it == '$' || it == '{' || it == '~' }
+ return if (cut >= 0) path.substring(0, cut) else path
+ }
+
+ /** Lexical normalisation: the command has not run, so nothing can be stat'd. */
+ private fun canonicalize(path: String): String {
+ val clean = path.trim().trim('"', '\'')
+ if (clean.isEmpty()) return ""
+ val parts = ArrayList()
+ for (segment in clean.split('/')) {
+ when (segment) {
+ "", "." -> Unit
+ ".." -> if (parts.isNotEmpty()) parts.removeAt(parts.size - 1)
+ else -> parts.add(segment)
+ }
+ }
+ return "/" + parts.joinToString("/")
+ }
+
+ // ------------------------------------------------------------- for the UI ----
+
+ /** Human-readable hard blocklist, shown verbatim on the authorization page. */
+ fun blockedSummary(): List = hardBlocks.map { "${it.what} —— ${it.why}" }
+
+ /** The whitelist, rendered as one line so the page can show what *is* allowed. */
+ fun allowedSummary(): String = allowedCommands.joinToString("、")
+
+ /** What the write rule now is, and what it deliberately stopped being. */
+ fun writeBoundarySummary(): List = listOf(
+ "写入边界 = 用户选定的工作区:工作区之内(含它本身就是 DCIM、Pictures、Download、Android/data 之类目录时)一律不拦。",
+ "工作区之外的写入会被拒。原先那张写死的 DCIM / Pictures / Android-data 黑名单已经删掉 —— 它既挡了用户本来就交出去的东西,也没说明工作区之外还有什么。",
+ "读取不受此限:dumpsys、getprop、ls /sdcard 这类查询指向哪里都可以。",
+ "android_export 与 android_files_*(SAF)是独立端点,各自的确认与授权覆盖它们,不受这条写入边界约束。",
)
- /** Renders a result for the model, keeping the limitation visible. */
- fun toJson(result: DeviceShellResult): JSONObject = JSONObject().apply {
+ /** Where the syntax policy stands right now. */
+ fun syntaxSummary(relaxedShellSyntax: Boolean): List =
+ if (relaxedShellSyntax) {
+ listOf("放宽模式:已开启。$RELAXED_COST")
+ } else {
+ listOf(
+ "放宽模式:已关闭(默认)。\$(...) 与反引号会被拒绝;sh/bash/eval/source 也不在白名单里。",
+ )
+ }
+
+ /** The sentence the UI shows next to the relaxed-mode switch. */
+ fun relaxedCost(): String = RELAXED_COST
+
+ private fun splitSegments(command: String): List = command
+ .replace("&&", ";")
+ .replace("||", ";")
+ .replace("|", ";")
+ .replace("\n", ";")
+ .split(';')
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+
+ private fun normalizeHead(head: String): String =
+ head.substringAfterLast('/').trim('"', '\'')
+
+ // --------------------------------------------------------------- result ----
+
+ /** The backend label for a result, including the privilege it really ran with. */
+ private fun labelFor(result: DeviceShellResult): String = when (result.backend) {
+ ShizukuShellBackend.id -> if (result.uid == 0) {
+ "Shizuku(root,uid=0)"
+ } else {
+ "Shizuku(ADB 身份,uid=${result.uid})"
+ }
+
+ else -> AppUidShellBackend.label
+ }
+
+ /** Renders a result for the model, keeping the backend's real privilege visible. */
+ fun toJson(
+ result: DeviceShellResult,
+ relaxedShellSyntax: Boolean = false,
+ boundaryLabel: String? = null,
+ ): JSONObject = JSONObject().apply {
put("stdout", result.stdout)
put("stderr", result.stderr)
put("exitCode", result.exitCode)
@@ -307,11 +581,24 @@ object DeviceShellGuard {
put("truncated", result.truncated)
put("backend", result.backend)
put("uid", result.uid)
- put("backendLabel", AppUidShellBackend.label)
+ put("backendLabel", labelFor(result))
put(
"note",
- "本版本没有接入 Shizuku / ADB 无线调试,命令以应用自身身份(uid=${result.uid})执行," +
- "因此读不到其他应用与系统私有状态。需要 uid=2000 的能力时请告知用户这一点。",
+ if (result.backend == ShizukuShellBackend.id) {
+ "命令以 ${labelFor(result)} 执行(uid=${result.uid}),这是 Shizuku 提供的 ADB 级身份:" +
+ "可以跑 input / pm / am / settings get / dumpsys,也能读到 shell 能看到的系统状态。" +
+ "读不到其他应用的私有数据(/data/user/0/),那是 Linux uid 的边界。" +
+ "命令白名单、硬性禁用清单与工作区写入边界仍然生效。"
+ } else {
+ "本机当前没有可用的 Shizuku(未安装、未启动或未授权),命令以应用自身身份(uid=${result.uid})执行," +
+ "因此读不到其他应用与系统私有状态,input/pm/am 这类需要特权权限的命令会失败。" +
+ "需要 uid=2000 的能力时请告诉用户去「设置 → 设备能力 → Shell」按提示启用 Shizuku。"
+ },
+ )
+ put(
+ "policy",
+ "命令白名单与硬性禁用清单仍然生效;放宽模式" + (if (relaxedShellSyntax) "已开启" else "已关闭") +
+ (if (boundaryLabel != null) ";写入边界 = $boundaryLabel" else ""),
)
}
}
diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceShizuku.kt b/app/src/main/kotlin/app/pi/bridge/DeviceShizuku.kt
new file mode 100644
index 0000000..b730077
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/bridge/DeviceShizuku.kt
@@ -0,0 +1,292 @@
+package app.pi.bridge
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Handler
+import android.os.Looper
+import android.os.ParcelFileDescriptor
+import android.os.RemoteException
+import moe.shizuku.server.IShizukuService
+import org.json.JSONObject
+import rikka.shizuku.Shizuku
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * The Shizuku path to a uid=2000 (ADB) shell — the single biggest capability jump
+ * available to this app without root.
+ *
+ * Why it matters: [AppUidShellBackend] runs as `u0_aXXX`, so `input keyevent`,
+ * `pm`/`am` mutations, `settings`, `dumpsys` and anything that walks another
+ * app's state either fail with a permission error or return a filtered view.
+ * Shizuku runs our command in a process started by the ADB daemon (uid 2000) or
+ * by root (uid 0), which is the privilege the design doc's `android_shell`
+ * capability was written for (design §21.3 "Shizuku/ADB,uid=2000").
+ *
+ * ### Two API facts this file is built around, both verified against the artifacts
+ *
+ * 1. `Shizuku#newProcess` was made **private** in 13.1.5 (the changelog calls it
+ * "prepare to remove `Shizuku#newProcess`"). The *protocol* method is still
+ * there: `moe.shizuku.server.IShizukuService.newProcess(String[], String[], String)`
+ * is a public interface in the `dev.rikka.shizuku:aidl` artifact, and that is
+ * the exact call the private wrapper made (verified with `javap -c` on
+ * `api-13.1.5.aar`: the private method ends in
+ * `invokeinterface IShizukuService.newProcess`). We call the interface, so we
+ * depend on no private API. The officially recommended replacement is a
+ * UserService; we did not use it because a UserService needs its own AIDL
+ * interface, and this project's dependency-free typecheck (`tools/typecheck.sh`)
+ * never runs the AIDL compiler — a route that cannot be typechecked here is a
+ * route that cannot be trusted here.
+ * 2. `Shizuku#requestPermission(int)` needs **no Activity**: it asks the Shizuku
+ * server, which shows its own confirmation, and the answer arrives through
+ * `addRequestPermissionResultListener` (README "Request permission"). That is
+ * what makes this implementable without touching MainActivity.
+ *
+ * Licence: `dev.rikka.shizuku:api` (and `:provider`, `:aidl`, `:shared`) are MIT
+ * (the POM's `` block). `:provider` declares `minSdkVersion 23`; this
+ * app's `minSdk` is 26, so no desugaring is required (13.1.0's changelog only
+ * requires it for minSdk 23).
+ */
+object DeviceShizuku {
+
+ /** The Shizuku manager app. Sui (the Magisk module) is reached through it. */
+ const val MANAGER_PACKAGE = "moe.shizuku.manager"
+
+ /** Arbitrary; only echoed back to the result listener. */
+ private const val PERMISSION_REQUEST_CODE = 0x7069
+
+ private val mainHandler: Handler by lazy { Handler(Looper.getMainLooper()) }
+ private val listenersRegistered = AtomicBoolean(false)
+
+ @Volatile
+ private var lastGrantResult: Int? = null
+
+ /** Whether `Shizuku.pingBinder()` is true right now. Never throws. */
+ fun binderAlive(): Boolean = runCatching { Shizuku.pingBinder() }.getOrDefault(false)
+
+ /** Whether the manager app is installed (package visibility is declared for it). */
+ fun isInstalled(context: Context): Boolean = runCatching {
+ context.packageManager.getPackageInfo(MANAGER_PACKAGE, 0)
+ true
+ }.getOrDefault(false)
+
+ /** Shizuku's own version, or -1 when the binder is not there. */
+ fun version(): Int = if (!binderAlive()) -1 else runCatching { Shizuku.getVersion() }.getOrDefault(-1)
+
+ /** True for the pre-v11 protocol the API refuses to support (README). */
+ fun preV11(): Boolean = if (!binderAlive()) false else runCatching { Shizuku.isPreV11() }.getOrDefault(false)
+
+ /** uid 2000 for an ADB-started Shizuku, 0 when it was started with root. */
+ fun uid(): Int = if (!binderAlive()) -1 else runCatching { Shizuku.getUid() }.getOrDefault(-1)
+
+ /** True when this app already holds Shizuku's permission. */
+ fun permissionGranted(): Boolean {
+ if (!binderAlive()) return false
+ return runCatching { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED }
+ .getOrDefault(false)
+ }
+
+ /** Everything a shell command needs: binder, permission, protocol version. */
+ fun isReady(): Boolean = binderAlive() && !preV11() && permissionGranted()
+
+ /**
+ * Ask the user to grant this app Shizuku access. The dialog belongs to the
+ * Shizuku app, so there is no Activity result to route through this app.
+ *
+ * @return `true` when the request was handed to the server. The answer is
+ * observed by polling [permissionGranted] (the authorization page already
+ * re-reads its state every 1.5s) and, when available, by
+ * [addPermissionResultListener].
+ */
+ fun requestPermission(): Boolean {
+ if (!binderAlive()) return false
+ registerListeners()
+ return runCatching {
+ Shizuku.requestPermission(PERMISSION_REQUEST_CODE)
+ true
+ }.getOrDefault(false)
+ }
+
+ /** Who to notify when the permission dialog is answered. */
+ fun addPermissionResultListener(listener: (Boolean) -> Unit) {
+ registerListeners()
+ permissionListeners.add(listener)
+ }
+
+ private val permissionListeners = java.util.Collections.synchronizedList(mutableListOf<(Boolean) -> Unit>())
+
+ private fun registerListeners() {
+ if (!listenersRegistered.compareAndSet(false, true)) return
+ runCatching {
+ Shizuku.addRequestPermissionResultListener({ _, grantResult ->
+ val granted = grantResult == PackageManager.PERMISSION_GRANTED
+ lastGrantResult = grantResult
+ val snapshot = synchronized(permissionListeners) { permissionListeners.toList() }
+ for (listener in snapshot) runCatching { listener(granted) }
+ }, mainHandler)
+ }
+ }
+
+ /** A status object for `/app/health` and the authorization page. */
+ fun status(context: Context): JSONObject = JSONObject().apply {
+ val alive = binderAlive()
+ val granted = permissionGranted()
+ val id = if (alive) uid() else -1
+ put("installed", isInstalled(context))
+ put("binderAlive", alive)
+ put("preV11", preV11())
+ put("permissionGranted", granted)
+ put("ready", alive && granted && !preV11())
+ put("uid", id)
+ put("version", version())
+ put("managerPackage", MANAGER_PACKAGE)
+ put("backendLabel", ShizukuShellBackend.label)
+ put("note", when {
+ !alive && !isInstalled(context) ->
+ "没有安装 Shizuku。装好并启动它(Android 11+ 可以用系统「无线调试」在手机上直接启动)," +
+ "再回到这里授权,Shell 就会以 ADB 身份(uid=2000)运行。"
+
+ !alive ->
+ "Shizuku 已安装但没有在运行。非 root 设备每次重启后都要重新启动 Shizuku" +
+ "(Android 11+ 用系统「无线调试」即可,不需要电脑)。"
+
+ preV11() -> "Shizuku 是 v11 之前的版本,API 不支持。"
+
+ !granted -> "Shizuku 在运行,但本应用还没有获得它的授权。点「请求 Shizuku 授权」即可。"
+
+ id == 0 -> "Shizuku 已就绪,且是以 root(uid=0)启动的:Shell 会拿到完整 root 权限,硬性禁用清单仍然生效。"
+
+ else -> "Shizuku 已就绪:Shell 以 ADB 身份(uid=$id)运行。"
+ })
+ }
+
+ /** Kept for the diagnostics page: the last permission answer we saw. */
+ fun lastPermissionResult(): Int? = lastGrantResult
+
+ /**
+ * Run [command] through Shizuku. Never called unless [isReady] is true —
+ * [ShizukuShellBackend.available] gates it.
+ */
+ fun exec(command: String, timeoutMs: Int): DeviceShellResult {
+ if (!isReady()) {
+ throw DeviceActionException(
+ DeviceDenial(
+ code = DeviceDenial.NO_PERMISSION,
+ reason = "Shizuku 不可用(未安装、未运行或未授权),无法以 ADB 身份执行命令。",
+ hint = "请让用户在「设置 → 设备能力 → Shell」里按提示安装/启动并授权 Shizuku,然后重试。",
+ ),
+ )
+ }
+ val binder = runCatching { Shizuku.getBinder() }.getOrNull()
+ ?: throw DeviceActionException(
+ DeviceDenial(DeviceDenial.NO_PERMISSION, "Shizuku 的 binder 已经断开(Shizuku 可能刚刚停止)。"),
+ )
+ val service = IShizukuService.Stub.asInterface(binder)
+ val remote = try {
+ service.newProcess(arrayOf("/system/bin/sh", "-c", command), null, "/")
+ } catch (error: RemoteException) {
+ throw DeviceActionException(
+ DeviceDenial(
+ code = DeviceDenial.ERROR,
+ reason = "Shizuku 拒绝创建进程:${error.message}",
+ hint = "Shizuku 可能已经停止,请让用户重新启动它。",
+ ),
+ )
+ } ?: throw DeviceActionException(
+ DeviceDenial(DeviceDenial.ERROR, "Shizuku 没有返回远程进程(服务端版本可能太旧)。"),
+ )
+
+ val stdout = StringBuilder()
+ val stderr = StringBuilder()
+ var exitCode = -1
+ var timedOut = false
+ try {
+ // ParcelFileDescriptor.AutoCloseInputStream closes the PFD for us; the
+ // remote end writes into it, so the streams must be drained on their
+ // own threads or a chatty command deadlocks on a full pipe.
+ val outStream = ParcelFileDescriptor.AutoCloseInputStream(remote.inputStream)
+ val errStream = ParcelFileDescriptor.AutoCloseInputStream(remote.errorStream)
+ val outReader = Thread { readCapped(outStream, stdout) }
+ val errReader = Thread { readCapped(errStream, stderr) }
+ outReader.isDaemon = true
+ errReader.isDaemon = true
+ outReader.start()
+ errReader.start()
+
+ // `waitForTimeout` takes a TimeUnit *name* over the wire and the server
+ // side has changed hands between versions, so waiting is done here on a
+ // plain `waitFor` with a join deadline — no protocol string to guess.
+ var waited = -1
+ val waiter = Thread { waited = runCatching { remote.waitFor() }.getOrDefault(-1) }
+ waiter.isDaemon = true
+ waiter.start()
+ waiter.join(timeoutMs.coerceIn(500, 60_000).toLong())
+ if (waiter.isAlive) {
+ timedOut = true
+ runCatching { remote.destroy() }
+ waiter.join(TimeUnit.SECONDS.toMillis(2))
+ }
+ exitCode = if (timedOut) -1 else waited
+ outReader.join(500)
+ errReader.join(500)
+ } catch (error: RemoteException) {
+ throw DeviceActionException(
+ DeviceDenial(DeviceDenial.ERROR, "读取 Shizuku 远程进程失败:${error.message}"),
+ )
+ } finally {
+ runCatching { remote.destroy() }
+ }
+
+ return DeviceShellResult(
+ stdout = stdout.toString(),
+ stderr = stderr.toString(),
+ exitCode = exitCode,
+ backend = ShizukuShellBackend.id,
+ uid = uid(),
+ truncated = stdout.length >= MAX_OUTPUT_BYTES,
+ timedOut = timedOut,
+ )
+ }
+
+ private const val MAX_OUTPUT_BYTES = 50 * 1024
+
+ private fun readCapped(stream: java.io.InputStream, into: StringBuilder) {
+ stream.use { input ->
+ val buffer = ByteArray(8192)
+ while (true) {
+ val read = try {
+ input.read(buffer)
+ } catch (closed: Exception) {
+ -1
+ }
+ if (read <= 0) break
+ if (into.length < MAX_OUTPUT_BYTES) {
+ into.append(String(buffer, 0, read, Charsets.UTF_8))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * The elevated backend. `available` is a live probe, not a cached flag, so a
+ * Shizuku that stops mid-session degrades the very next command to
+ * [AppUidShellBackend] instead of failing with a confusing binder error.
+ */
+object ShizukuShellBackend : DeviceShellBackend {
+ override val id: String = "shizuku"
+
+ override val label: String
+ get() = if (!DeviceShizuku.isReady()) {
+ "Shizuku(未安装/未运行/未授权)"
+ } else if (DeviceShizuku.uid() == 0) {
+ "Shizuku(root,uid=0)"
+ } else {
+ "Shizuku(ADB 身份,uid=${DeviceShizuku.uid()})"
+ }
+
+ override val available: Boolean get() = DeviceShizuku.isReady()
+
+ override fun run(command: String, timeoutMs: Int): DeviceShellResult =
+ DeviceShizuku.exec(command, timeoutMs)
+}
diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceUiAutomation.kt b/app/src/main/kotlin/app/pi/bridge/DeviceUiAutomation.kt
index a56d778..2379641 100644
--- a/app/src/main/kotlin/app/pi/bridge/DeviceUiAutomation.kt
+++ b/app/src/main/kotlin/app/pi/bridge/DeviceUiAutomation.kt
@@ -389,15 +389,31 @@ object DeviceUiAutomation {
val args = Bundle().apply {
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
}
- val written = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
+ // Two mechanisms, in the order that costs the user least:
+ // 1. ACTION_SET_TEXT replaces the field's content without touching the
+ // clipboard, but Compose/WebView/custom editors often refuse it (the
+ // review of this layer named exactly that as the reason to consider a
+ // custom IME).
+ // 2. clipboard + ACTION_PASTE is the fallback that needs no IME and no
+ // extra permission: paste goes through the view's own
+ // onTextContextMenuItem, which far more editors implement.
+ // The cost of (2) is that the user's clipboard is replaced; the reply says
+ // so, because a silent clipboard overwrite is the kind of side effect this
+ // project refuses to hide.
+ var mechanism = "action_set_text"
+ var written = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
+ if (!written) {
+ if (pasteInto(service, node, text)) {
+ mechanism = "clipboard_paste"
+ written = true
+ }
+ }
if (!written) {
- // Last resort: typing through the node's own edit action is unavailable,
- // so report precisely rather than pretending the text landed.
throw DeviceActionException(
DeviceDenial(
code = DeviceDenial.UNSUPPORTED,
- reason = "目标输入框拒绝了直接写入文本(可能是 WebView 或自绘控件)。",
- hint = "可以改用 android_shell 执行 input text(需要 Shell 能力),或让用户手动输入。",
+ reason = "目标输入框既拒绝了直接写入文本,也拒绝了粘贴(可能是自绘控件或只读输入框)。",
+ hint = "可以改用 android_shell 执行 input text(需要 Shell 能力 + Shizuku),或让用户手动输入。",
),
)
}
@@ -416,7 +432,14 @@ object DeviceUiAutomation {
return JSONObject().apply {
put("chars", text.length)
put("target", DeviceUiText.clip(node.text, 40))
+ put("mechanism", mechanism)
put("submitted", submitted)
+ if (mechanism == "clipboard_paste") {
+ put(
+ "clipboardNote",
+ "直接写入被拒绝,已改用「剪贴板 + 粘贴」完成;系统剪贴板里原来的内容被这次写入替换了。",
+ )
+ }
if (submit && !submitted) {
put(
"submitHint",
@@ -426,16 +449,52 @@ object DeviceUiAutomation {
}
}
+ /**
+ * Put [text] on the clipboard and ask [node] to paste it.
+ *
+ * The clipboard write is posted to the main looper for the same reason
+ * `DeviceSystemActions.clipboardSet` does it: the bridge serves requests from a
+ * pool thread with no `Looper`, and the platform's clipboard service is not
+ * documented as thread-safe there.
+ */
+ private fun pasteInto(service: AccessibilityService, node: AccessibilityNodeInfo, text: String): Boolean {
+ val manager = service.getSystemService(android.content.Context.CLIPBOARD_SERVICE)
+ as? android.content.ClipboardManager ?: return false
+ val posted = java.util.concurrent.CountDownLatch(1)
+ runCatching {
+ mainHandler.post {
+ runCatching {
+ manager.setPrimaryClip(android.content.ClipData.newPlainText("pi", text))
+ }
+ posted.countDown()
+ }
+ }.onFailure { posted.countDown() }
+ runCatching { posted.await(2, java.util.concurrent.TimeUnit.SECONDS) }
+ return runCatching { node.performAction(AccessibilityNodeInfo.ACTION_PASTE) }.getOrDefault(false)
+ }
+
// --------------------------------------------------------------- keys -----
/** Keys the accessibility service can actually perform without an IME. */
- private val globalActions: Map = mapOf(
- "back" to AccessibilityService.GLOBAL_ACTION_BACK,
- "home" to AccessibilityService.GLOBAL_ACTION_HOME,
- "recents" to AccessibilityService.GLOBAL_ACTION_RECENTS,
- "notifications" to AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS,
- "quicksettings" to AccessibilityService.GLOBAL_ACTION_QUICK_SETTINGS,
- )
+ private val globalActions: Map = buildMap {
+ put("back", AccessibilityService.GLOBAL_ACTION_BACK)
+ put("home", AccessibilityService.GLOBAL_ACTION_HOME)
+ put("recents", AccessibilityService.GLOBAL_ACTION_RECENTS)
+ put("notifications", AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS)
+ put("quicksettings", AccessibilityService.GLOBAL_ACTION_QUICK_SETTINGS)
+ put("powermenu", AccessibilityService.GLOBAL_ACTION_POWER_DIALOG)
+ // API-gated constants: adding them here is how the channel gains "lock the
+ // screen" and "take a screenshot" without any new permission.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ put("lock", AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ put("screenshot", AccessibilityService.GLOBAL_ACTION_TAKE_SCREENSHOT)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ put("split", AccessibilityService.GLOBAL_ACTION_TOGGLE_SPLIT_SCREEN)
+ }
+ }
fun key(service: AccessibilityService, key: String): JSONObject {
val normalized = key.trim().lowercase().replace("-", "").replace("_", "")
@@ -465,6 +524,80 @@ object DeviceUiAutomation {
)
}
+ // ------------------------------------------------------- raw key events ----
+
+ /**
+ * `input keyevent` for the keys the accessibility channel cannot produce.
+ *
+ * This exists because the accessibility route can only fire five
+ * `GLOBAL_ACTION`s, and `input` — like every other `InputManager` client — needs
+ * `INJECT_EVENTS`, a signature permission the app will never hold. uid 2000
+ * does, so with Shizuku this endpoint finally is the "arbitrary keyevent"
+ * capability the design's `android_shell` note promised. Without Shizuku it
+ * refuses and says why, instead of returning a confusing permission error.
+ */
+ fun keyEvent(keys: String, repeat: Int, backend: DeviceShellBackend): JSONObject {
+ val tokens = keys.trim()
+ .uppercase()
+ .split(Regex("[\\s,]+"))
+ .filter { it.isNotEmpty() }
+ .map { it.removePrefix("KEYCODE_") }
+ if (tokens.isEmpty()) {
+ throw DeviceActionException(DeviceDenial(DeviceDenial.BAD_REQUEST, "keyevent 需要至少一个按键名。"))
+ }
+ // The characters are validated rather than escaped: the names go into a
+ // command string, and `input` has no shell-quoting of its own.
+ for (token in tokens) {
+ if (!Regex("^[A-Z0-9_]{1,24}$").matches(token)) {
+ throw DeviceActionException(
+ DeviceDenial(
+ code = DeviceDenial.BAD_REQUEST,
+ reason = "不是合法的按键名:$token(只接受 A-Z0-9_,例如 ENTER、DEL、DPAD_DOWN、TAB)。",
+ hint = "Android KeyEvent 的名字,KEYCODE_ 前缀可省略。",
+ ),
+ )
+ }
+ }
+ val times = repeat.coerceIn(1, 20)
+ if (backend.id != ShizukuShellBackend.id) {
+ throw DeviceActionException(
+ DeviceDenial(
+ code = DeviceDenial.UNSUPPORTED,
+ reason = "原始按键需要 ADB 身份(uid=2000),当前 Shell 后端是应用自身身份," +
+ "而注入按键需要 INJECT_EVENTS 签名权限,应用永远拿不到。",
+ hint = "请让用户在「设置 → 设备能力 → Shell」按提示启用 Shizuku,然后重试;" +
+ "或者继续用 android_key(back/home/recents/notifications/quicksettings/lock 等全局动作)。",
+ ),
+ )
+ }
+ val command = buildString {
+ append("input keyevent")
+ repeat(times) {
+ for (token in tokens) append(' ').append(token)
+ }
+ }
+ val result = backend.run(command, 20_000)
+ val failed = result.exitCode != 0 ||
+ result.stderr.contains("Exception", ignoreCase = true) ||
+ result.stderr.contains("Error:", ignoreCase = true)
+ if (failed) {
+ throw DeviceActionException(
+ DeviceDenial(
+ code = DeviceDenial.ERROR,
+ reason = "input keyevent 失败(退出码 ${result.exitCode}):" +
+ result.stderr.trim().ifEmpty { result.stdout.trim() }.take(400),
+ hint = "可以用 android_shell 手动跑同样的命令看完整输出。",
+ ),
+ )
+ }
+ return JSONObject().apply {
+ put("keys", JSONArray(tokens))
+ put("repeat", times)
+ put("mode", "input keyevent")
+ put("backend", result.backend)
+ }
+ }
+
// ---------------------------------------------------------- screenshot ----
fun screenshot(
diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceWorkspace.kt b/app/src/main/kotlin/app/pi/bridge/DeviceWorkspace.kt
new file mode 100644
index 0000000..5da300f
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/bridge/DeviceWorkspace.kt
@@ -0,0 +1,105 @@
+package app.pi.bridge
+
+import android.content.Context
+import app.pi.runtime.PtyLauncher
+import java.io.File
+
+/**
+ * The workspace, as the device shell has to spell it — the concrete
+ * [ShellWriteBoundary] the guard asks about.
+ *
+ * ### Where the path comes from
+ *
+ * [PtyLauncher.workspaceHost] is the runtime layer's single source of truth for
+ * "the directory the user selected as the workspace" (today it is
+ * `/pi/workspaces/workspace-1`, bind-mounted into the guest as
+ * `/workspace`; `PiRuntime.baseBinds` adds the shared-storage binds). This object
+ * deliberately *reads* that function instead of repeating the constant, so if the
+ * workspace ever becomes user-selectable the boundary follows it without a second
+ * edit — a gate that decided "inside" from a stale hardcoded copy of the path
+ * would be worse than no gate, because it would look like it worked.
+ *
+ * ### Why the guest path `/workspace` is not an alias
+ *
+ * The device shell runs in the *host* namespace, where `/workspace` does not
+ * exist. Accepting it would let the policy say "allowed" to a command that then
+ * fails with ENOENT. It is left out on purpose, and the refusal names the host
+ * path instead, which is the honest answer to "write inside the workspace".
+ *
+ * ### What the boundary is not
+ *
+ * It does not bound the *guest*. `PiRuntime.baseBinds` binds the device's shared
+ * storage (and the workspace) into the proot guest, so pi's own built-in `bash`
+ * sees `/sdcard` as the user's storage. That is the guest's workbench and it is
+ * outside this gate on purpose (the gate's jurisdiction is the device tools), and
+ * in any case the guest process runs as this app's uid under `untrusted_app`, so
+ * its file access is exactly the app's — the platform's scoped-storage rules are
+ * what bound it, not this class.
+ */
+object DeviceWorkspace : ShellWriteBoundary {
+
+ /** Only used if the runtime layer's accessor itself fails. */
+ private const val FALLBACK_RELATIVE = "pi/workspaces/workspace-1"
+
+ @Volatile
+ private var aliases: List = emptyList()
+
+ @Volatile
+ private var hostPath: String? = null
+
+ /** Re-read the workspace. Cheap (no I/O beyond a `File` construction). */
+ fun refresh(context: Context) {
+ val host = runCatching { PtyLauncher.workspaceHost(context).absolutePath }
+ .getOrElse { File(context.filesDir, FALLBACK_RELATIVE).absolutePath }
+ val canonical = canonicalize(host)
+ val set = LinkedHashSet()
+ set.add(canonical)
+ // The same directory spelled with either app-data prefix.
+ set.add(canonical.replace("/data/data/", "/data/user/0/"))
+ set.add(canonical.replace("/data/user/0/", "/data/data/"))
+ // If the user's workspace lives on shared storage, all three spellings of
+ // that storage are the same directory, and the device shell reaches it by
+ // whichever one the caller wrote.
+ for (external in listOf("/storage/emulated/0", "/sdcard", "/storage/self/primary")) {
+ if (canonical == external || canonical.startsWith("$external/")) {
+ for (other in listOf("/storage/emulated/0", "/sdcard", "/storage/self/primary")) {
+ set.add(other + canonical.removePrefix(external))
+ }
+ }
+ }
+ aliases = set.filter { it.isNotEmpty() }.toList()
+ hostPath = canonical
+ }
+
+ override fun contains(path: String): Boolean {
+ val canonical = canonicalize(path)
+ if (canonical.isEmpty()) return false
+ return aliases.any { alias -> canonical == alias || canonical.startsWith("$alias/") }
+ }
+
+ override fun shellPath(): String? = hostPath
+
+ override fun isKnown(): Boolean = aliases.isNotEmpty()
+
+ /** One line for the authorization page and for the model. */
+ fun summary(): String = hostPath
+ ?.let { "写入边界 = 工作区:$it(guest 内是 /workspace)" }
+ ?: "写入边界 = 工作区:尚未确定(运行时还没启动)"
+
+ /** All spellings, for the diagnostics card. */
+ fun aliasSummary(): String = if (aliases.isEmpty()) "(未确定)" else aliases.joinToString("、")
+
+ private fun canonicalize(path: String): String {
+ val clean = path.trim().trim('"', '\'')
+ if (clean.isEmpty()) return ""
+ val parts = ArrayList()
+ for (segment in clean.split('/')) {
+ when (segment) {
+ "", "." -> Unit
+ ".." -> if (parts.isNotEmpty()) parts.removeAt(parts.size - 1)
+ else -> parts.add(segment)
+ }
+ }
+ return "/" + parts.joinToString("/")
+ }
+}
diff --git a/app/src/main/kotlin/app/pi/highlight/PiNodeCodeHighlighter.kt b/app/src/main/kotlin/app/pi/highlight/PiNodeCodeHighlighter.kt
index 9bfbf76..c3b40d7 100644
--- a/app/src/main/kotlin/app/pi/highlight/PiNodeCodeHighlighter.kt
+++ b/app/src/main/kotlin/app/pi/highlight/PiNodeCodeHighlighter.kt
@@ -8,6 +8,7 @@ import app.pi.ui.render.PiCodeSpan
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
/**
* The app's code highlighter: pi's own highlight.js, asked over loopback.
@@ -78,6 +79,9 @@ internal object PiNodeCodeHighlighter : PiCodeHighlighter {
private const val MAX_CODE_CHARS = 64 * 1024
private const val MAX_CODE_LINES = 400
+ /** The credentials file the guest extension publishes; see [PiHighlightClient]. */
+ private const val TOKEN_FILE_NAME = "highlight-bridge.json"
+
@Volatile
private var client: PiHighlightClient? = null
@@ -159,10 +163,6 @@ internal object PiNodeCodeHighlighter : PiCodeHighlighter {
/** Diagnostics for the settings/diagnostics surface; never used on a hot path. */
fun lastFailure(): String? = client?.lastFailure
-
- private companion object {
- const val TOKEN_FILE_NAME = "highlight-bridge.json"
- }
}
/**
diff --git a/app/src/main/kotlin/app/pi/packages/AgentLayout.kt b/app/src/main/kotlin/app/pi/packages/AgentLayout.kt
new file mode 100644
index 0000000..fbd12b1
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/AgentLayout.kt
@@ -0,0 +1,139 @@
+package app.pi.packages
+
+import android.content.Context
+import app.pi.engine.PiEngineHost
+import app.pi.runtime.PiPaths
+import java.io.File
+
+/**
+ * Where pi's user files actually are, on the host and in the guest.
+ *
+ * This is the one place in the app that has to know about a mismatch that is easy
+ * to get wrong and silent when you do.
+ *
+ * ## The mismatch, verified from code
+ *
+ * `PiEngineHost` sets `PI_CODING_AGENT_DIR=/root/.pi/agent` (`PiEngineHost.kt:51`,
+ * `:123`) and binds **only the workspace** into the guest (`:116`). So inside the
+ * guest, `/root/.pi/agent` is a real directory of the rootfs:
+ *
+ * /pi/runtime/rootfs/root/.pi/agent <- what pi reads and writes
+ *
+ * whereas `PiPaths.agentDir` is
+ *
+ * /pi/.pi/agent <- a *host-side mirror*
+ *
+ * `docs/extension-compatibility.md:523-524` says the agent dir is bind-mounted.
+ * It is not — `PiEngineHost.kt:109-117` binds `workspace.absolutePath to
+ * guestWorkspace` and nothing else. `DeviceBridgeController` already works around
+ * this by writing to **both** locations and commenting the host one as "guest path
+ * only if the engine binds it" (`DeviceBridgeController.kt:182-188`, `:230-233`).
+ * This class makes that workaround explicit and reusable instead of incidental.
+ *
+ * ## Why it matters for this layer
+ *
+ * - `trust.json` is read by pi at `/trust.json`
+ * (`trust-manager.ts:212-214`). Written only to the mirror, trust would never
+ * take effect. Written only to the rootfs, it would be **destroyed by the next
+ * runtime update**: `RuntimeProvisioner.wipe()` deletes `paths.runtime`
+ * wholesale (`RuntimeProvisioner.kt:85-91`) and only re-creates an empty
+ * `/root/.pi/agent` (`:185`). So it must be written to both, and the rootfs
+ * copy re-published after provisioning.
+ * - `pi install` writes `packages` into `/settings.json` and installs
+ * into `/npm`, both inside the volatile tree. The app cannot fix that
+ * from here (it would take the engine bind), but it can and does say so instead
+ * of reporting a durable install.
+ *
+ * ## What the app should do instead, exactly
+ *
+ * One line in `PiEngineHost.boot` makes the mirror authoritative and removes the
+ * whole class of bug. It is not this package's file to edit, so it is reported
+ * rather than applied:
+ *
+ * ```kotlin
+ * val argv = ProotCommand.build(
+ * paths = paths,
+ * guestCommand = guestCommand,
+ * cwd = guestWorkspace,
+ * storage = android.os.Environment.getExternalStorageDirectory(),
+ * extraBinds = listOf(
+ * workspace.absolutePath to guestWorkspace,
+ * paths.agentDir.absolutePath to guestAgentDir, // <- add this
+ * ),
+ * )
+ * ```
+ *
+ * Until that exists, [TrustRepository] keeps both copies in step.
+ */
+class AgentLayout(
+ context: Context,
+ /** Host workspace directory; the guest spelling is derived from it. */
+ val hostWorkspace: File,
+) {
+
+ val paths = PiPaths(
+ filesDir = context.filesDir,
+ nativeLibDir = File(context.applicationInfo.nativeLibraryDir),
+ )
+
+ /** pi's `PI_CODING_AGENT_DIR` inside the guest (`PiEngineHost.kt:51`). */
+ val guestAgentDir: String = "/root/.pi/agent"
+
+ /**
+ * `$HOME` inside the guest. `ProotCommand.environment` pins it to `/root`
+ * (`PiRuntime.kt:142`), and `hasTrustRequiringProjectResources` compares
+ * against `$HOME/.agents/skills` to decide which skills directory is the
+ * trusted user one (`trust-manager.ts:186-188`), so the value has to come from
+ * there rather than from Android's notion of home.
+ */
+ val guestHome: String = "/root"
+
+ /**
+ * The guest's own home, on the host filesystem. proot does not translate paths
+ * for us: `--rootfs=` makes `/pi/runtime/rootfs` appear as `/`,
+ * so guest `/root/.pi/agent` is exactly [agentTruthDir].
+ */
+ val agentTruthDir: File = File(paths.rootfs, "root/.pi/agent")
+
+ /**
+ * The durable host copy. `PiPaths.agentDir` is `/pi/.pi/agent`
+ * (`PiRuntime.kt:19`); it survives a runtime re-extract because it is not under
+ * `paths.runtime`.
+ */
+ val agentMirrorDir: File = paths.agentDir
+
+ /**
+ * The guest spelling of the workspace, computed **by the same rule** as
+ * `PiEngineHost.guestPathFor` (`:162-166`): strip the `` prefix, then
+ * mount under `/workspace`. `PiSessionViewModel` duplicates the same string
+ * (`:331`) and calls the mapping "a contract, not an implementation detail".
+ *
+ * It matters here because `pi install -l` writes `/.pi/settings.json` and
+ * `hasTrustRequiringProjectResources(cwd)` inspects `/.pi`, both using the
+ * **guest** cwd — which is also the key pi writes into `trust.json`.
+ */
+ val guestWorkspace: String = run {
+ val root = paths.home.parentFile?.absolutePath ?: paths.home.absolutePath
+ val rel = hostWorkspace.absolutePath.removePrefix(root).trimStart('/')
+ if (rel.isEmpty()) "/workspace" else "/workspace/$rel"
+ }
+
+ /** The one bind this layer needs when it runs a command in the workspace. */
+ fun workspaceBind(): Pair = hostWorkspace.absolutePath to guestWorkspace
+
+ /** The engine's cli.js, exactly as `PiEngineHost` computes it (`:95`). */
+ val guestEngineCli: String
+ get() = "${PiEngineHost.ENGINE_GUEST_ROOT}/node_modules/${PiEngineHost.PI_PACKAGE}/dist/cli.js"
+
+ /** Node inside the guest (`RuntimeProvisioner.extractNode`, `:115`). */
+ val guestNode: String get() = "/opt/node/bin/node"
+
+ /** `/.pi/settings.json`, resolved to a host path for a guest workspace. */
+ fun hostProjectConfigDir(): File = File(hostWorkspace, ".pi")
+
+ /** True when the runtime is unpacked far enough to run anything. */
+ fun runtimeReady(): Boolean = paths.rootfs.isDirectory && paths.prootBinary().isFile && paths.prootLoader().isFile
+
+ /** True when the engine cli.js the guest will exec exists on the host side. */
+ fun engineInstalled(): Boolean = File(paths.rootfs, guestEngineCli.removePrefix("/")).isFile
+}
diff --git a/app/src/main/kotlin/app/pi/packages/ExtensionLifecycle.kt b/app/src/main/kotlin/app/pi/packages/ExtensionLifecycle.kt
new file mode 100644
index 0000000..c522b79
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/ExtensionLifecycle.kt
@@ -0,0 +1,348 @@
+package app.pi.packages
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * The install → needs-restart → restart → ready state machine.
+ *
+ * ## Why a restart is the only way, from the source
+ *
+ * pi loads extensions with `jiti` **into the pi process** at startup. There are
+ * exactly two things that pick up a new extension:
+ *
+ * 1. `/reload` — a *built-in* command (`slash-commands.ts:41`). Built-ins are
+ * excluded from `get_commands` (`docs/rpc.md:853`), are not dispatched by
+ * `prompt` (`agent-session.ts:1331-1343`, which only routes *extension*
+ * commands), and there is no `reload` command in `RpcCommand`
+ * (`modes/rpc/rpc-types.ts:20-74`). The reload plumbing *does* exist over RPC
+ * — `rpc-mode.ts:341-343` wires `reload: async () => { await session.reload() }`
+ * into the extension `commandContextActions` — so an **extension command** that
+ * calls `ctx.reload()` is reachable via `prompt`. That is the only in-process
+ * route, and it needs an extension the app owns to already be loaded.
+ * 2. A fresh engine process.
+ *
+ * Neither is a silent operation. `session.reload()` emits
+ * `session_shutdown {reason:"reload"}`, clears the jiti cache, re-resolves
+ * packages/skills/prompts/themes, and emits `session_start {reason:"reload"}`
+ * (`agent-session.ts:2841-2866`, `resource-loader.ts:388-547`). Killing the
+ * process is blunter still.
+ *
+ * ## Why the app never does it silently
+ *
+ * A restart **kills any in-flight turn**: the model call, the tool calls, the
+ * foreground service holding the wake lock — all of it. So the rule is:
+ *
+ * - a successful install or remove **never** restarts anything; it moves the
+ * machine to [State.NeedsRestart] and says so;
+ * - a restart requires an explicit `requestRestart` **and** a confirmation
+ * ([State.AwaitingConfirmation] carries the question, not the action);
+ * - if a turn is running, even a confirmed restart waits: the machine parks in
+ * [State.AwaitingIdle] and does **not** restart. There is no timeout and no
+ * forcing path, because there is no correct moment to kill a turn that the user
+ * has not chosen. The user either stops the turn or waits, and the app re-offers.
+ *
+ * ## The same requirement covers settings
+ *
+ * `PiSettingsRegistry` marks every resource row `EffectiveKind.Reload`
+ * (`PiCommon.kt:204` for the enum; `PiSettingsRegistry.kt:760`, `:773`, `:797`,
+ * `:810`, `:823` for `extensions`, `packages`, `skills`, `prompts`, `themes`), and
+ * `EffectiveKind.Reload` has no consumer. This machine is the consumer: a settings
+ * write to any of those keys goes through [noteExternalChange] and lands in exactly
+ * the same [State.NeedsRestart]. That is also the honest answer to "do skills and
+ * prompt templates need a restart" — they do; see [RefreshSemantics].
+ */
+class ExtensionLifecycle {
+
+ /** How long a restart is expected to take, so the UI can say something true. */
+ val expectedRestartSeconds: IntRange = 1..3
+
+ sealed interface State {
+ val label: String
+
+ /** Nothing pending; the engine, if running, has the current resources. */
+ data object Idle : State {
+ override val label: String get() = "空闲"
+ }
+
+ /** A package command is running. No restart may be started from here. */
+ data class Installing(val action: String, val source: String) : State {
+ override val label: String get() = "正在$action $source"
+ }
+
+ /**
+ * Something changed on disk that pi will not see until it reloads. This is
+ * the state the user must be told about every single time.
+ */
+ data class NeedsRestart(
+ val changes: List,
+ val detail: String,
+ /** Set after a failed restart attempt, so the reason survives. */
+ val lastError: String? = null,
+ ) : State {
+ override val label: String get() = "需要重启"
+ }
+
+ /** The user asked to restart and the app is waiting for confirmation. */
+ data class AwaitingConfirmation(val changes: List, val question: String) : State {
+ override val label: String get() = "等待确认"
+ }
+
+ /**
+ * The user confirmed, but a turn is in flight, so nothing happens yet.
+ * [turnNote] is what the user is told; there is no auto-continue.
+ */
+ data class AwaitingIdle(val changes: List, val turnNote: String) : State {
+ override val label: String get() = "等待回合结束"
+ }
+
+ /** The restart is actually happening. */
+ data class Restarting(val changes: List) : State {
+ override val label: String get() = "正在重启"
+ }
+
+ /** The engine came back and has the new resources. */
+ data class Ready(val note: String) : State {
+ override val label: String get() = "已就绪"
+ }
+ }
+
+ private val _state = MutableStateFlow(State.Idle)
+ val state: StateFlow = _state.asStateFlow()
+
+ val current: State get() = _state.value
+
+ /** True while a restart is genuinely pending, in any of its waiting shapes. */
+ val pendingRestart: Boolean
+ get() = when (_state.value) {
+ is State.NeedsRestart, is State.AwaitingConfirmation, is State.AwaitingIdle -> true
+ else -> false
+ }
+
+ // ------------------------------------------------------------- transitions
+
+ /** A package command started. Only legal from a settled state. */
+ fun beginInstall(action: String, source: String): Boolean {
+ when (_state.value) {
+ is State.Installing, is State.Restarting -> return false
+ else -> {}
+ }
+ _state.value = State.Installing(action, source)
+ return true
+ }
+
+ /**
+ * A command finished successfully and changed something pi reads at startup.
+ * This is where "restart is required" becomes visible — never later, never
+ * implicitly.
+ */
+ fun installSucceeded(changes: List, detail: String) {
+ _state.value = State.NeedsRestart(changes = changes, detail = detail)
+ }
+
+ /**
+ * A command failed. Nothing was persisted by pi's `installAndPersist`
+ * (`package-manager.ts:1029-1032` runs the install *before* the settings
+ * write, so a throw leaves settings untouched), so there is nothing to reload.
+ */
+ fun installFailed(message: String) {
+ _state.value = State.Idle
+ lastMessage = message
+ }
+
+ /** A settings row with `EffectiveKind.Reload` was written. Same requirement. */
+ fun noteExternalChange(changes: List, detail: String) {
+ when (_state.value) {
+ is State.Installing, is State.Restarting -> return
+ else -> _state.value = State.NeedsRestart(changes, detail)
+ }
+ }
+
+ /**
+ * The user pressed 重启. This does **not** restart: it produces the question.
+ *
+ * @param turnRunning the caller's live answer from the engine, not a guess.
+ */
+ fun requestRestart(turnRunning: Boolean): RequestOutcome = when (val now = _state.value) {
+ is State.NeedsRestart -> if (turnRunning) {
+ _state.value = State.AwaitingIdle(changes = now.changes, turnNote = TURN_RUNNING_NOTE)
+ RequestOutcome.WaitingForTurn
+ } else {
+ _state.value = State.AwaitingConfirmation(changes = now.changes, question = confirmQuestion(now.changes))
+ RequestOutcome.NeedsConfirmation
+ }
+
+ is State.Ready -> {
+ // Idempotent: asking to restart a ready engine is a no-op, not an error.
+ RequestOutcome.AlreadyReady
+ }
+
+ is State.AwaitingIdle -> {
+ // Re-asked while still busy: re-check the turn rather than assume.
+ if (turnRunning) {
+ RequestOutcome.WaitingForTurn
+ } else {
+ _state.value = State.AwaitingConfirmation(now.changes, confirmQuestion(now.changes))
+ RequestOutcome.NeedsConfirmation
+ }
+ }
+
+ is State.AwaitingConfirmation -> RequestOutcome.NeedsConfirmation
+
+ is State.Installing -> RequestOutcome.BusyWithPackageCommand
+
+ is State.Restarting -> RequestOutcome.BusyWithPackageCommand
+
+ State.Idle -> RequestOutcome.NothingPending
+ }
+
+ sealed interface RequestOutcome {
+ /** The app must show [State.AwaitingConfirmation.question] and wait. */
+ data object NeedsConfirmation : RequestOutcome
+
+ /** A turn is in flight; nothing will happen until it ends. */
+ data object WaitingForTurn : RequestOutcome
+
+ data object AlreadyReady : RequestOutcome
+ data object NothingPending : RequestOutcome
+ data object BusyWithPackageCommand : RequestOutcome
+ }
+
+ /** The user declined. Back to the honest pending state. */
+ fun cancelRestart() {
+ val now = _state.value
+ if (now is State.AwaitingConfirmation || now is State.AwaitingIdle) {
+ val changes = when (now) {
+ is State.AwaitingConfirmation -> now.changes
+ is State.AwaitingIdle -> now.changes
+ else -> emptyList()
+ }
+ _state.value = State.NeedsRestart(
+ changes = changes,
+ detail = "重启已取消;新装的资源仍未生效。",
+ )
+ }
+ }
+
+ /** The user confirmed and the caller is now performing the restart. */
+ fun restartStarted(): Boolean {
+ val now = _state.value
+ if (now !is State.AwaitingConfirmation) return false
+ _state.value = State.Restarting(now.changes)
+ return true
+ }
+
+ fun restartSucceeded() {
+ _state.value = State.Ready("引擎已重启,新资源已加载。")
+ lastMessage = null
+ }
+
+ fun restartFailed(message: String) {
+ val now = _state.value
+ val changes = (now as? State.Restarting)?.changes ?: emptyList()
+ _state.value = State.NeedsRestart(
+ changes = changes,
+ detail = "重启失败;资源仍未生效。",
+ lastError = message,
+ )
+ }
+
+ /** Last one-shot message for the UI to surface; not part of [state]. */
+ var lastMessage: String? = null
+ private set
+
+ fun clearMessage() {
+ lastMessage = null
+ }
+
+ private fun confirmQuestion(changes: List): String = buildString {
+ append("重启引擎以加载新资源?\n\n")
+ if (changes.isNotEmpty()) {
+ append(changes.joinToString("\n") { "· $it" })
+ append("\n\n")
+ }
+ append("重启需要约 ${expectedRestartSeconds.first}–${expectedRestartSeconds.last} 秒。")
+ append("它会终止当前正在进行的回合(模型调用、工具调用、bash 命令都不会恢复),")
+ append("但已写入磁盘的会话内容不会丢失——会话是 JSONL 追加写的,重启后用 get_entries 重新挂载即可。")
+ }
+
+ companion object {
+ /**
+ * Deliberately not "稍后自动重启". The app has no way to know when the turn
+ * will end, and a restart that fires on a guess is the silent restart this
+ * machine exists to prevent.
+ */
+ const val TURN_RUNNING_NOTE: String =
+ "有回合正在运行,暂不重启。重启会中断模型调用和正在跑的工具调用;" +
+ "请先停止该回合或等它结束,然后再次点击重启。"
+
+ /**
+ * The refresh answer, shown next to every restart prompt. It lives here
+ * rather than in [PackageStrings] because it is a statement about what pi
+ * does, not a label: that adding a *skill* needs a restart is not obvious
+ * to anyone who has not read `resource-loader.ts`.
+ */
+ val ANSWER_HINT: String = RefreshSemantics.ANSWER
+ }
+}
+
+/**
+ * What the app must tell the user about resource refresh, with the code path that
+ * decides each case.
+ *
+ * This answers the "one uncertain fact" directly, and the answer is *no*: skills and
+ * prompt templates are **not** re-discovered on every `get_commands` call. They are
+ * scanned once, into a cached array, and that array is only rebuilt by a reload.
+ *
+ * The chain, every step in pi's source:
+ *
+ * - `get_commands` builds its reply from three **cached reads**
+ * (`modes/rpc/rpc-mode.ts:682-713`):
+ * `session.extensionRunner.getRegisteredCommands()`,
+ * `session.promptTemplates`, and
+ * `session.resourceLoader.getSkills().skills`.
+ * - `session.promptTemplates` is a getter over `this._resourceLoader.getPrompts().prompts`
+ * (`core/agent-session.ts:1033-1035`).
+ * - `getPrompts()` returns `{ prompts: this.prompts }` and `getSkills()` returns
+ * `{ skills: this.skills }` — plain fields of the loader
+ * (`core/resource-loader.ts:308-314`), initialised to `[]` in the constructor
+ * (`:285-288`).
+ * - Those fields are assigned only by `updateSkillsFromPaths`
+ * (`:672-693`) and `updatePromptsFromPaths` (`:695-717`), which are called only
+ * from `reload()` (`:472-473` and `:487-488`, inside the method starting at
+ * `:388`). `reload()` sets `this.loaded = true` at `:546` and nothing else
+ * assigns either field.
+ * - `reload()` runs once at startup, when the loader is built
+ * (`core/sdk.ts:185-188`: `resourceLoader = new DefaultResourceLoader(...)` then
+ * `await resourceLoader.reload()`), and again only from `session.reload()`
+ * (`core/agent-session.ts:2849`).
+ *
+ * `loadSkills` (`resource-loader.ts:677-682`) does walk the filesystem on each
+ * call — the *scan* is live; the **result is cached**. So dropping a new `SKILL.md`
+ * into `.pi/skills` or editing a prompt template changes nothing the RPC client can
+ * observe until a reload. Adding a skill therefore needs the same restart as adding
+ * an extension. This contradicts any claim that `get_commands` re-discovers
+ * resources per call, and the citations above are why.
+ */
+object RefreshSemantics {
+
+ /** Which resource kinds a reload re-resolves (`resource-loader.ts:388-547`). */
+ val reloadScannedResources = listOf(
+ "extensions",
+ "skills",
+ "prompt templates",
+ "themes",
+ "AGENTS.md",
+ "SYSTEM.md / APPEND_SYSTEM.md",
+ )
+
+ /** Read by `get_commands` from a cache, so a new file is invisible until reload. */
+ val cachedAcrossGetCommands = listOf("extensions", "skills", "prompt templates")
+
+ /** One sentence, for the report and for the UI. */
+ const val ANSWER: String =
+ "skills 与 prompt 模板不是每次 get_commands 重新扫描的:它们在 resource-loader 的 " +
+ "reload() 里扫描一次并缓存(resource-loader.ts:472-473、:487-488),get_commands 直接读缓存 " +
+ "(rpc-mode.ts:694-710)。新增技能或模板同样需要重启(或扩展命令里调用 ctx.reload())。"
+}
diff --git a/app/src/main/kotlin/app/pi/packages/GuestCommand.kt b/app/src/main/kotlin/app/pi/packages/GuestCommand.kt
new file mode 100644
index 0000000..ec1f182
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/GuestCommand.kt
@@ -0,0 +1,208 @@
+package app.pi.packages
+
+import android.os.Environment
+import app.pi.runtime.ProotCommand
+import java.io.InputStream
+import java.util.concurrent.TimeUnit
+
+/**
+ * Runs one non-interactive command inside the guest, and reports exactly what
+ * happened.
+ *
+ * ## Why this is not [app.pi.runtime.PtyLauncher]
+ *
+ * `PtyLauncher` allocates a real pty with the guest's `script(1)` so pi's TUI can
+ * work. That is the wrong tool here, and specifically so:
+ *
+ * - pi decides whether it may prompt for project trust with
+ * `process.stdin.isTTY && process.stdout.isTTY`
+ * (`package-manager-cli.ts:733-735` → `createProjectTrustContext({hasUI: appMode
+ * === "interactive"})`, `:779-784`). **Under a pty that is `true`**, so
+ * `resolveProjectTrusted` reaches `ctx.ui.select` and blocks on
+ * `showStartupSelector` — an unseen TUI menu waiting for a keypress that will
+ * never come (`core/project-trust.ts:86-95`). Over a pipe it is `false`, the
+ * gate returns `false` deterministically (`:86-88`), and `-l` then fails with
+ * pi's own clear message instead of hanging.
+ * - `chalk` colours its output on a pty, which would put SGR sequences in
+ * messages the app shows the user. Over a pipe chalk emits plain text; `NO_COLOR`
+ * is set anyway so the outcome cannot depend on chalk's detection.
+ * - An install is a batch job. A pty buys nothing and costs determinism.
+ *
+ * The one thing a pty would help with is an interactive credential prompt
+ * (`git clone` over SSH asking for a passphrase). That case is **not** handled by
+ * hanging: [ENV] sets `GIT_TERMINAL_PROMPT=0` and a `BatchMode` `GIT_SSH_COMMAND`,
+ * which is pi's own recommendation for non-interactive runs (`docs/packages.md:89`
+ * — "For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0`
+ * to disable credential prompts and set `GIT_SSH_COMMAND` … to fail fast"). A
+ * package that needs an interactive prompt therefore fails with git's message, and
+ * the user is pointed at the Workbench terminal tab, where the same command can be
+ * run under a real pty.
+ *
+ * ## What is reused
+ *
+ * The argv comes from [ProotCommand.build] and the environment from
+ * [ProotCommand.environment] — the same two functions [app.pi.engine.PiEngineHost]
+ * and `PtyLauncher` use. There is no second proot invocation recipe in this file:
+ * it adds environment variables for a batch run and nothing else. That matters,
+ * because a divergence between how the engine is launched and how `pi install` is
+ * launched would be invisible until a user installed a package that then behaved
+ * differently from the same package installed by hand.
+ */
+class GuestCommand(private val layout: AgentLayout) {
+
+ /** One completed (or abandoned) guest command. */
+ data class Outcome(
+ /** The exact argv handed to proot; reported so the user can reproduce it. */
+ val argv: List,
+ /** The guest command string, i.e. what `bash -lc` received. */
+ val guestCommand: String,
+ /** Null when the process never started or was killed on timeout. */
+ val exitCode: Int?,
+ val stdout: String,
+ val stderr: String,
+ val timedOut: Boolean,
+ /** Non-null when proot itself could not be started. */
+ val launchError: String? = null,
+ ) {
+ val ok: Boolean get() = launchError == null && !timedOut && exitCode == 0
+
+ /** Everything the guest said, in the order a terminal would show it. */
+ val combined: String get() = buildString {
+ if (stdout.isNotBlank()) append(stdout.trimEnd())
+ if (stderr.isNotBlank()) {
+ if (isNotEmpty()) append('\n')
+ append(stderr.trimEnd())
+ }
+ }
+
+ /** True when the guest produced no output at all. */
+ val silent: Boolean get() = combined.isBlank()
+ }
+
+ /**
+ * @param guestCommand a command string evaluated by `bash -lc` **inside** the
+ * rootfs, so it may name guest paths (`/opt/node/bin/node`, `/workspace`).
+ * @param cwd the guest cwd. For anything pi does with project settings this
+ * must be the workspace, because pi resolves `/.pi/settings.json`
+ * and writes the trust key from `process.cwd()`.
+ * @param timeoutMs hard cap. On expiry the process tree is killed
+ * (`ProotCommand.build` passes `--kill-on-exit`) and
+ * [Outcome.timedOut] is set — the app never reports a timeout as success.
+ */
+ fun run(
+ guestCommand: String,
+ cwd: String = layout.guestWorkspace,
+ timeoutMs: Long = INSTALL_TIMEOUT_MS,
+ extraEnv: Map = emptyMap(),
+ ): Outcome {
+ val argv = ProotCommand.build(
+ paths = layout.paths,
+ guestCommand = guestCommand,
+ cwd = cwd,
+ storage = Environment.getExternalStorageDirectory(),
+ // The workspace is the only extra bind the engine uses; `-l` writes
+ // `/.pi/settings.json`, so the same bind is required here.
+ extraBinds = listOf(layout.workspaceBind()),
+ )
+ val env = ProotCommand.environment(layout.paths, extra = ENV + extraEnv)
+
+ val process = try {
+ ProcessBuilder(argv)
+ .directory(layout.paths.runtime)
+ .also { it.environment().putAll(env) }
+ .start()
+ } catch (error: Throwable) {
+ return Outcome(
+ argv = argv,
+ guestCommand = guestCommand,
+ exitCode = null,
+ stdout = "",
+ stderr = "",
+ timedOut = false,
+ launchError = "${error::class.java.simpleName}: ${error.message}",
+ )
+ }
+
+ // Both pipes must be drained concurrently. A single-threaded read of stdout
+ // followed by stderr deadlocks as soon as the guest fills the other pipe's
+ // buffer — and `npm install` on a phone prints far more than one pipe
+ // buffer's worth.
+ val out = StringBuilder()
+ val err = StringBuilder()
+ val outThread = pump(process.inputStream, out)
+ val errThread = pump(process.errorStream, err)
+
+ val finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
+ if (!finished) {
+ process.destroyForcibly()
+ // Give the readers a moment to drain what the guest already wrote; a
+ // killed process closes its pipes, so this cannot block indefinitely.
+ process.waitFor(5, TimeUnit.SECONDS)
+ }
+ outThread.join(READER_JOIN_MS)
+ errThread.join(READER_JOIN_MS)
+
+ return Outcome(
+ argv = argv,
+ guestCommand = guestCommand,
+ exitCode = if (finished) process.exitValue() else null,
+ stdout = out.toString(),
+ stderr = err.toString(),
+ timedOut = !finished,
+ )
+ }
+
+ private fun pump(stream: InputStream, into: StringBuilder): Thread {
+ val thread = Thread {
+ runCatching {
+ stream.bufferedReader().use { reader ->
+ val buffer = CharArray(4096)
+ while (true) {
+ val read = reader.read(buffer)
+ if (read < 0) break
+ synchronized(into) { into.append(buffer, 0, read) }
+ }
+ }
+ }
+ }
+ thread.isDaemon = true
+ thread.start()
+ return thread
+ }
+
+ companion object {
+ /**
+ * npm/git over a phone network. Long enough that a slow but working install
+ * is not killed, short enough that a wedged one does not hold the UI
+ * forever. The app states the timeout in the failure message rather than
+ * presenting it as a package error.
+ */
+ const val INSTALL_TIMEOUT_MS: Long = 10 * 60 * 1000L
+
+ /** `pi list` reads two JSON files; it should never take a minute. */
+ const val LIST_TIMEOUT_MS: Long = 60 * 1000L
+
+ private const val READER_JOIN_MS = 2_000L
+
+ /**
+ * Batch-mode environment. Each entry is a decision, not a default:
+ *
+ * - `NO_COLOR` — chalk must not emit SGR into text the app displays.
+ * - `GIT_TERMINAL_PROMPT=0`, `GIT_SSH_COMMAND=...BatchMode=yes` —
+ * `docs/packages.md:89`, so a credential prompt fails fast instead of
+ * hanging a pipe with no reader. `ConnectTimeout=5` bounds a dead host.
+ * - `PI_SKIP_VERSION_CHECK=1` — the engine already sets this
+ * (`PiEngineHost.kt:127`); a package command must not add a surprise
+ * update check.
+ * - `CI=1` — suppresses npm's interactive-ish output formatting, which is
+ * what makes `pi list`'s and npm's stdout stable to parse.
+ */
+ val ENV: Map = mapOf(
+ "NO_COLOR" to "1",
+ "GIT_TERMINAL_PROMPT" to "0",
+ "GIT_SSH_COMMAND" to "ssh -o BatchMode=yes -o ConnectTimeout=5",
+ "PI_SKIP_VERSION_CHECK" to "1",
+ "CI" to "1",
+ )
+ }
+}
diff --git a/app/src/main/kotlin/app/pi/packages/PackageStrings.kt b/app/src/main/kotlin/app/pi/packages/PackageStrings.kt
new file mode 100644
index 0000000..e0c019c
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/PackageStrings.kt
@@ -0,0 +1,120 @@
+package app.pi.packages
+
+/**
+ * Every Chinese string this layer shows, in one place.
+ *
+ * Two rules, both about not laundering pi's behaviour into something friendlier
+ * than it is:
+ *
+ * - **pi's own words are never translated.** A trust option keeps pi's label
+ * (`Trust parent folder (/workspace)`, `Trust (this session only)`) and a pi
+ * error keeps pi's message, because the user will meet the same strings in pi's
+ * TUI and its docs, and because a translated error cannot be searched for.
+ * - **The app's Chinese is additive, not substitutive.** Where pi is silent — the
+ * project-resource skip — the app supplies the sentence pi did not
+ * (`docs/extension-compatibility.md:574-575`).
+ *
+ * Comments are English; the strings are the product surface and are Chinese.
+ */
+object PackageStrings {
+
+ // ------------------------------------------------------------------ screen
+
+ const val TITLE = "扩展包与项目信任"
+ const val SUBTITLE = "pi 没有把包管理和 /reload 放进 RPC 协议,所以这些操作由 App 在 guest 里执行 pi 自己的命令行。"
+
+ const val INSTALL_LABEL = "安装"
+ const val REMOVE_LABEL = "移除"
+ const val REFRESH_LABEL = "刷新列表"
+ const val CANCEL = "取消"
+ const val CONFIRM = "确定"
+
+ const val SPEC_HINT = "npm:@scope/name@1.0.0 / git:github.com/user/repo@v1 / /绝对路径"
+
+ const val SCOPE_USER = "全局(~/.pi/agent/settings.json)"
+ const val SCOPE_PROJECT = "项目(.pi/settings.json)"
+
+ const val SCOPE_PROJECT_LOCKED =
+ "项目作用域需要先信任这个项目:pi 会拒绝写入未信任项目的包配置" +
+ "(package-manager-cli.ts:936-940)。"
+
+ const val NO_PACKAGES = "pi list 报告没有已安装的资源包。"
+ const val PROJECT_PACKAGES_HIDDEN =
+ "注意:项目的 .pi/settings.json 里声明了资源包,但这次列表没有显示它们——" +
+ "项目未获信任时 pi 会把整个项目文档当成空的(settings-manager.ts:405-408)," +
+ "而 pi list 仍然以退出码 0 结束,什么都不会说。先处理项目信任,再刷新列表。"
+ const val LIST_UNPARSED =
+ "pi list 的输出无法解析(下方原样显示)。这不会被当成「没有包」——那正是会骗人的地方。"
+
+ const val RUNNING = "正在执行…"
+ const val TIMEOUT_NOTE =
+ "命令超时已被终止。它可能已经写入了部分文件,请先刷新列表再决定下一步。"
+
+ const val STDERR_TITLE = "pi 的 stderr(原样)"
+ const val STDOUT_TITLE = "pi 的 stdout(原样)"
+ const val COMMAND_TITLE = "实际执行的 guest 命令"
+
+ const val TRUSTED = "已信任"
+ const val UNTRUSTED = "未信任"
+ const val NOT_RECORDED = "无记录"
+
+ const val RESTART_NEEDED_TITLE = "需要重启引擎"
+ const val RESTART_BUTTON = "重启引擎"
+ const val RESTART_CONFIRM = "确认重启"
+ const val RESTART_STAY = "暂不重启"
+ const val RESTART_WAIT_TURN = "等待回合结束"
+ const val RESTARTING = "正在重启引擎…"
+ const val RESTARTING_NOTE = "预计 1–3 秒。当前正在跑的回合会被终止,会话内容不会丢失。"
+
+ const val TRUST_ALREADY_TRUSTED =
+ "这个项目已获信任,项目本地的扩展、技能、模板与 settings.json 会被加载。"
+
+ const val TRUST_NO_TRIGGER =
+ "这个项目没有任何需要信任的资源(.pi/settings.json、.pi/extensions、.pi/skills、" +
+ ".pi/prompts、.pi/themes、.pi/SYSTEM.md、.pi/APPEND_SYSTEM.md、.agents/skills)," +
+ "因此 pi 不会询问,也没有决定可写。"
+
+ const val TRUST_FILE_LABEL = "trust.json"
+
+ const val TRUST_INVALID_TITLE = "trust.json 无法解析"
+ const val TRUST_INVALID_ACTION = "移开损坏文件并重建"
+ const val TRUST_INVALID_NOTE =
+ "pi 在读取到非法 trust.json 时会抛错并拒绝启动(trust-manager.ts:107-121)," +
+ "所以这不是一个可以忽略的警告。损坏的文件会被改名保留,不会被删除。"
+
+ const val TRUST_SESSION_ONLY_NOTE =
+ "「session only」按 pi 自己的语义不写入任何文件(trust-manager.ts:84、:93):" +
+ "只对本次操作生效。需要让 pi 也认账,请选择会持久化的那一项。"
+
+ /** pi's five choices, explained. Keyed by pi's own label prefix. */
+ fun trustSubtitle(label: String): String = when {
+ label == "Trust" ->
+ "写入 { \"<路径>\": true }。此后 pi 会加载该项目的 .pi 资源并执行项目扩展。"
+ label.startsWith("Trust parent folder") ->
+ "信任上一级目录,并删除本项目自己的条目(pi 的顺序:先写父级,再删本级)。" +
+ "之后这个父级下的任何项目都会被自动信任——请确认这是你要的粒度。"
+ label.startsWith("Trust (this session only)") ->
+ "仅本次操作放行,不写 trust.json。App 会用 --approve 把同样的语义传给 pi 的命令行。"
+ label == "Do not trust" ->
+ "写入 { \"<路径>\": false }。项目资源被忽略,而且不会再次询问。"
+ label.startsWith("Do not trust (this session only)") ->
+ "仅本次操作拒绝,不写 trust.json。下次仍会询问。"
+ else -> ""
+ }
+
+ /** Why the app is showing a prompt at all, in pi's own resolution terms. */
+ fun rationale(resolution: ProjectTrust.Resolution, cwd: String, hasTrigger: Boolean): String = when {
+ !hasTrigger -> TRUST_NO_TRIGGER
+ resolution.trusted -> TRUST_ALREADY_TRUSTED
+ else -> resolution.explanation ?: ProjectTrust.skipNote(cwd, resolution.rationale)
+ }
+
+ /** One line per install/remove result, for the activity log. */
+ fun doneHeadline(done: PiPackageService.Done): String = when (done) {
+ is PiPackageService.Done.Ok -> done.summary
+ is PiPackageService.Done.Failed -> "失败:${done.message}"
+ is PiPackageService.Done.Refused -> "已被 App 拒绝:${done.summary}"
+ is PiPackageService.Done.TimedOut -> done.summary
+ is PiPackageService.Done.NotReady -> done.summary
+ }
+}
diff --git a/app/src/main/kotlin/app/pi/packages/PiListOutput.kt b/app/src/main/kotlin/app/pi/packages/PiListOutput.kt
new file mode 100644
index 0000000..545594c
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/PiListOutput.kt
@@ -0,0 +1,103 @@
+package app.pi.packages
+
+/**
+ * A parser for `pi list`'s stdout.
+ *
+ * The output is a rendering, not a protocol, so this is deliberately tolerant and
+ * always keeps the raw text next to what it managed to parse. The shape is fixed
+ * by `package-manager-cli.ts:970-1004`:
+ *
+ * ```
+ * User packages:
+ * npm:foo
+ * /root/.pi/agent/npm/node_modules/foo
+ * git:github.com/u/r (filtered)
+ *
+ * Project packages:
+ * npm:bar
+ * ```
+ *
+ * `(filtered)` is appended when the settings entry is the object form
+ * (`:981`); the indented line under an entry is `installedPath` (`:983-985`).
+ * An empty install prints `No packages installed.` and no headers (`:975-978`).
+ * Both are matched on the **trimmed** line, after ANSI stripping, so chalk's
+ * bold header cannot defeat the parser.
+ */
+object PiListOutput {
+
+ private const val USER_HEADER = "User packages:"
+ private const val PROJECT_HEADER = "Project packages:"
+ private const val NO_PACKAGES = "No packages installed."
+
+ data class Parsed(
+ val entries: List,
+ /** True when pi said there are no packages, rather than the parse failing. */
+ val empty: Boolean,
+ /** True when output was non-empty but nothing was recognised. */
+ val unrecognised: Boolean,
+ )
+
+ fun parse(stdout: String): Parsed {
+ val text = app.pi.rpc.Ansi.strip(stdout)
+ val lines = text.lines()
+ if (lines.any { it.trim() == NO_PACKAGES }) {
+ return Parsed(emptyList(), empty = true, unrecognised = false)
+ }
+
+ val entries = mutableListOf()
+ var scope: PiPackageScope? = null
+ var lastIndex = -1
+
+ for (raw in lines) {
+ if (raw.isBlank()) continue
+ val trimmed = raw.trim()
+ when (trimmed) {
+ USER_HEADER -> {
+ scope = PiPackageScope.User
+ continue
+ }
+ PROJECT_HEADER -> {
+ scope = PiPackageScope.Project
+ continue
+ }
+ }
+ val indent = raw.takeWhile { it == ' ' || it == '\t' }.length
+ val activeScope = scope ?: continue
+ if (indent == 0) continue
+
+ if (indent <= ENTRY_INDENT) {
+ val filtered = trimmed.endsWith(FILTERED_SUFFIX)
+ val source = if (filtered) trimmed.removeSuffix(FILTERED_SUFFIX).trim() else trimmed
+ if (source.isEmpty()) continue
+ entries += PiPackageEntry(
+ source = PiPackageSource.parse(source),
+ scope = activeScope,
+ filtered = filtered,
+ installedPath = null,
+ )
+ lastIndex = entries.lastIndex
+ continue
+ }
+
+ // Deeper than an entry: pi's `installedPath` continuation line.
+ if (lastIndex >= 0) {
+ entries[lastIndex] = entries[lastIndex].copy(installedPath = trimmed)
+ }
+ }
+
+ val nonHeaderContent = lines.count { line ->
+ val t = line.trim()
+ t.isNotEmpty() && t != USER_HEADER && t != PROJECT_HEADER
+ }
+ return Parsed(
+ entries = entries,
+ empty = entries.isEmpty() && nonHeaderContent == 0,
+ unrecognised = entries.isEmpty() && nonHeaderContent > 0,
+ )
+ }
+
+ /** pi prints entries with two leading spaces (`package-manager-cli.ts:982`). */
+ private const val ENTRY_INDENT = 2
+
+ private const val FILTERED_SUFFIX = " (filtered)"
+}
diff --git a/app/src/main/kotlin/app/pi/packages/PiPackageModel.kt b/app/src/main/kotlin/app/pi/packages/PiPackageModel.kt
new file mode 100644
index 0000000..ebf10e2
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/PiPackageModel.kt
@@ -0,0 +1,36 @@
+package app.pi.packages
+
+/**
+ * The two value types shared by the package service, the list parser and the UI.
+ *
+ * They live in their own file so that [PiListOutput] — which is pure string
+ * handling over `pi list`'s output — does not have to depend on the Android-facing
+ * [PiPackageService] and its [GuestCommand]. Keeping the parser free of Android is
+ * what makes it checkable off-device, and this parser is exactly the kind of code
+ * that silently lies when it is wrong: a mis-parsed `pi list` looks identical to
+ * "no packages installed".
+ */
+
+/** `install`/`remove` write to one of pi's two settings documents. */
+enum class PiPackageScope(
+ /** The CLI flag pi uses for this scope: `""` or `-l`. */
+ val flag: String,
+ /** App-side label; pi's own names are "user settings" / "project settings". */
+ val label: String,
+) {
+ /** `~/.pi/agent/settings.json` — pi's default (`docs/packages.md:43`). */
+ User("", "全局(用户)"),
+
+ /** `.pi/settings.json` in the project — `pi install -l`. */
+ Project("-l", "项目"),
+}
+
+/** One list row, as `pi list` describes it (`package-manager-cli.ts:970-1004`). */
+data class PiPackageEntry(
+ val source: PiPackageSource,
+ val scope: PiPackageScope,
+ /** True for the object form in settings (`filtered` in pi's output). */
+ val filtered: Boolean,
+ /** `pkg.installedPath`, when pi resolved one. */
+ val installedPath: String?,
+)
diff --git a/app/src/main/kotlin/app/pi/packages/PiPackageService.kt b/app/src/main/kotlin/app/pi/packages/PiPackageService.kt
new file mode 100644
index 0000000..f22f545
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/PiPackageService.kt
@@ -0,0 +1,363 @@
+package app.pi.packages
+
+import app.pi.runtime.PtyLauncher
+import java.io.File
+
+/**
+ * `pi install` / `pi remove` / `pi list`, run in the guest.
+ *
+ * ## Why the CLI and not `settings.json`
+ *
+ * `docs/known-gaps.md` B5 originally proposed editing the `packages` array
+ * directly. That is wrong, and pi's own source says so: installing is not a
+ * settings edit. `DefaultPackageManager.install` (`core/package-manager.ts:1005-1027`)
+ * runs `npm install --prefix /npm --legacy-peer-deps`
+ * (`:1785-1812`) or `git clone` (`:1831-1863`), and only afterwards does
+ * `installAndPersist` append the source to settings (`:1029-1032`). A settings
+ * write alone produces a configured package with no files on disk.
+ *
+ * There is also no RPC command for any of this. Verified against the protocol's
+ * own type: `RpcCommand` (`modes/rpc/rpc-types.ts:20-74`) has no `install`,
+ * `remove`, `list` or `reload`. So the app must do it at its own layer, and the
+ * honest way is to run pi's own CLI, in the guest, and show what it said.
+ *
+ * ## The exact command line
+ *
+ * ```
+ * /bin/bash -lc
+ * exec /opt/node/bin/node /opt/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js install 'npm:foo@1.0.0' -l --approve
+ * ```
+ *
+ * with guest cwd = the workspace, and the workspace bind-mounted. The js entry
+ * point and the node path are the same two values `PiEngineHost` uses
+ * (`:95`, `:104`), not a second guess. `bash -lc` and the argv are
+ * [ProotCommand.build]'s, reused from the engine path.
+ *
+ * Placement of flags mirrors pi's parser (`package-manager-cli.ts:375-573`):
+ * `-l`/`--local` is only meaningful for `install`/`remove` (`:409-416`),
+ * `--approve` is accepted by all three (`:454-462`), and `list` accepts neither
+ * `source` nor `-l`. The spec is quoted as one shell word
+ * ([PtyLauncher.Shell.quote]) so a `@`-bearing spec, a URL with `&`, or a path
+ * with a space survives.
+ *
+ * ## What "silently skip" means here, and why the app refuses to reproduce it
+ *
+ * pi's trust gate, when it denies, produces **no output at all** — no event, no
+ * stderr line (`docs/extension-compatibility.md:574-575`, from `loader.ts:634-637`
+ * and `main.ts:775-782`). The one place it does speak is the project-scoped package
+ * command:
+ *
+ * ```
+ * Project is not trusted. Use --approve to modify local package config.
+ * ```
+ *
+ * (`package-manager-cli.ts:936-940`, exit code 1). So this service reports that
+ * verbatim, and separately flags "project resources were skipped" whenever a
+ * command ran with an unresolved trust decision, even when it exited 0. An install
+ * that fails silently is worse than one that refuses.
+ */
+class PiPackageService(
+ private val layout: AgentLayout,
+ private val guest: GuestCommand,
+) {
+
+ /**
+ * How the project gate is satisfied for this command.
+ *
+ * `ProjectTrustRepository` owns the decision; this is only the wire form the
+ * CLI understands. `--approve` is pi's own "trust project-local files for this
+ * command" flag (`package-manager-cli.ts:454-462`), which is exactly the
+ * session-only answer: it trusts without persisting anything.
+ */
+ enum class TrustPass {
+ /** No flag. User scope needs none; project scope then fails if untrusted. */
+ None,
+
+ /** `--approve`: trust project-local files for this one command. */
+ Approve,
+ }
+
+ // ------------------------------------------------------------------ results
+
+ data class Listing(
+ val entries: List,
+ /** `pi list`'s stdout, verbatim, so a parse failure is still readable. */
+ val raw: String,
+ val stderr: String,
+ val argv: List,
+ /**
+ * True when the project's own `.pi/settings.json` declares packages that
+ * this listing did **not** show, because the project is untrusted.
+ *
+ * This is the second silent skip in pi's package path, and it is worse than
+ * the first because the output looks healthy. `listConfiguredPackages` does
+ * read a project document (`package-manager.ts:992-1000`), but
+ * `SettingsManager.getProjectSettings()` returns an empty object while
+ * `projectTrusted` is false — `loadFromStorage` short-circuits to `{}` for
+ * project scope (`settings-manager.ts:405-408`, read by `:501-503`) — so
+ * those entries vanish from a command that exited 0. `mutate` already catches the *denial* string for `-l`
+ * (`package-manager-cli.ts:936-940`); nothing at all catches this one.
+ */
+ val projectPackagesHidden: Boolean = false,
+ )
+
+ sealed interface Done {
+ val argv: List
+ val stdout: String
+ val stderr: String
+
+ /** Human-readable result line, from pi's stdout or the app's own words. */
+ val summary: String
+
+ /**
+ * `Warning:` lines pi printed. Present on success too — pi reports broken
+ * settings files this way (`package-manager-cli.ts:255-263`, `:737-741`),
+ * and dropping them is the silent-failure shape this layer exists to avoid.
+ */
+ val warnings: List
+
+ /** True when project-scoped resources were skipped, whatever the exit code. */
+ val projectResourcesSkipped: Boolean
+
+ /** `Installed npm:foo` / `Removed npm:foo` / `Installed …(有警告)`. */
+ data class Ok(
+ override val argv: List,
+ override val stdout: String,
+ override val stderr: String,
+ override val summary: String,
+ override val warnings: List = emptyList(),
+ override val projectResourcesSkipped: Boolean = false,
+ ) : Done
+
+ /** pi ran and exited non-zero. [message] is its stderr, verbatim. */
+ data class Failed(
+ override val argv: List,
+ override val stdout: String,
+ override val stderr: String,
+ val message: String,
+ val exitCode: Int?,
+ override val warnings: List = emptyList(),
+ override val projectResourcesSkipped: Boolean = false,
+ ) : Done {
+ override val summary: String get() = message
+ }
+
+ /** The app refused before spawning anything; [message] mirrors a pi message. */
+ data class Refused(override val summary: String) : Done {
+ override val argv: List get() = emptyList()
+ override val stdout: String get() = ""
+ override val stderr: String get() = ""
+ override val warnings: List get() = emptyList()
+ override val projectResourcesSkipped: Boolean get() = false
+ }
+
+ /** Killed on the app's own timeout. Never presented as a package error. */
+ data class TimedOut(
+ override val argv: List,
+ override val stdout: String,
+ override val stderr: String,
+ val timeoutMs: Long,
+ override val projectResourcesSkipped: Boolean = false,
+ ) : Done {
+ override val summary: String
+ get() = "命令超过 ${timeoutMs / 1000} 秒未返回,已终止"
+ override val warnings: List get() = emptyList()
+ }
+
+ /** The runtime or the engine is missing, so nothing could be run. */
+ data class NotReady(override val summary: String) : Done {
+ override val argv: List get() = emptyList()
+ override val stdout: String get() = ""
+ override val stderr: String get() = ""
+ override val warnings: List get() = emptyList()
+ override val projectResourcesSkipped: Boolean get() = false
+ }
+ }
+
+ /** What the caller must tell the user after any successful mutation. */
+ data class RestartRequired(val changes: List, val detail: String)
+
+ // ---------------------------------------------------------------- commands
+
+ /**
+ * `pi install ` (`package-manager-cli.ts:954-957`). On success pi prints
+ * `Installed `; the app does not re-derive that.
+ */
+ fun install(spec: String, scope: PiPackageScope, trust: TrustPass = TrustPass.None): Done =
+ mutate("install", spec, scope, trust)
+
+ /** `pi remove `; alias `uninstall` exists but the canonical verb is used. */
+ fun remove(spec: String, scope: PiPackageScope, trust: TrustPass = TrustPass.None): Done =
+ mutate("remove", spec, scope, trust)
+
+ private fun mutate(command: String, spec: String, scope: PiPackageScope, trust: TrustPass): Done {
+ PiPackageSource.validate(command, spec)?.let { return Done.Refused(it.message) }
+ readiness()?.let { return it }
+
+ val outcome = guest.run(
+ guestCommand = commandLine(command, spec, scope, trust),
+ cwd = layout.guestWorkspace,
+ timeoutMs = GuestCommand.INSTALL_TIMEOUT_MS,
+ )
+ return classify(outcome, successSummary = { source ->
+ if (command == "install") "Installed $source" else "Removed $source"
+ }, spec = spec)
+ }
+
+ /**
+ * `pi list`. Always reads **both** settings documents, so it must run with the
+ * workspace as cwd: `listConfiguredPackages` reads `getGlobalSettings()` and
+ * `getProjectSettings()`, and the latter is `/.pi/settings.json`
+ * (`package-manager.ts:977-1003`).
+ */
+ fun list(trust: TrustPass = TrustPass.None, projectTrusted: Boolean = false): Listing {
+ readiness()?.let { return Listing(emptyList(), "", it.summary, emptyList()) }
+ val outcome = guest.run(
+ guestCommand = commandLine("list", spec = null, scope = PiPackageScope.User, trust = trust),
+ cwd = layout.guestWorkspace,
+ timeoutMs = GuestCommand.LIST_TIMEOUT_MS,
+ )
+ return Listing(
+ entries = PiListOutput.parse(outcome.stdout).entries,
+ raw = outcome.stdout,
+ stderr = outcome.stderr,
+ argv = outcome.argv,
+ projectPackagesHidden = !projectTrusted && projectDeclaresPackages(),
+ )
+ }
+
+ /**
+ * Reads `/.pi/settings.json` and answers whether it lists any packages.
+ *
+ * Reads the **host** copy of the project document, which is the same file pi
+ * reads: the workspace is the one thing the engine does bind (`PiEngineHost.kt:116`),
+ * so `/.pi/settings.json` and `/.pi/settings.json` are the
+ * same bytes. This is the one path where that is true, and the difference is
+ * worth remembering — see [AgentLayout].
+ */
+ fun projectDeclaresPackages(): Boolean {
+ val file = File(layout.hostProjectConfigDir(), "settings.json")
+ if (!file.isFile) return false
+ val text = runCatching { file.readText() }.getOrNull() ?: return false
+ val document = app.pi.rpc.PiJson.parseObjectOrNull(text) ?: return false
+ val packages = app.pi.rpc.SettingsDocument.lookup(document, "packages") ?: return false
+ val array = packages as? kotlinx.serialization.json.JsonArray ?: return false
+ return array.isNotEmpty()
+ }
+
+ /**
+ * The exact string `bash -lc` receives. Public because the report and the UI's
+ * "what did you actually run" affordance both need it, and because a test can
+ * assert on it without a device.
+ */
+ fun commandLine(command: String, spec: String?, scope: PiPackageScope, trust: TrustPass): String {
+ val words = mutableListOf(command)
+ if (spec != null) words += PtyLauncher.Shell.quote(spec)
+ if (scope.flag.isNotEmpty()) words += scope.flag
+ if (trust == TrustPass.Approve) words += "--approve"
+ return "exec ${layout.guestNode} ${layout.guestEngineCli} ${words.joinToString(" ")}"
+ }
+
+ /** [RestartRequired] for a completed mutation, or null when nothing changed. */
+ fun restartRequirement(done: Done): RestartRequired? = when (done) {
+ is Done.Ok -> RestartRequired(
+ changes = listOf(done.summary),
+ detail = "pi 在启动时用 jiti 把扩展加载进进程,运行中的进程不会重新扫描扩展目录。" +
+ "新装的包只有在 /reload 或重启引擎之后才生效。",
+ )
+ else -> null
+ }
+
+ // --------------------------------------------------------------- internals
+
+ private fun readiness(): Done.NotReady? {
+ if (!layout.runtimeReady()) {
+ return Done.NotReady("运行时尚未就绪:rootfs 或 proot 缺失,无法在 guest 里执行 pi。")
+ }
+ if (!layout.engineInstalled()) {
+ return Done.NotReady("引擎未安装:找不到 ${layout.guestEngineCli}。")
+ }
+ return null
+ }
+
+ private fun classify(
+ outcome: GuestCommand.Outcome,
+ successSummary: (String) -> String,
+ spec: String,
+ ): Done {
+ val warnings = warningsIn(outcome.stderr)
+ val skipped = PROJECT_SKIP_MARKERS.any { outcome.stderr.contains(it) }
+
+ if (outcome.launchError != null) {
+ return Done.NotReady("无法启动 proot:${outcome.launchError}")
+ }
+ if (outcome.timedOut) {
+ return Done.TimedOut(
+ argv = outcome.argv,
+ stdout = outcome.stdout,
+ stderr = outcome.stderr,
+ timeoutMs = GuestCommand.INSTALL_TIMEOUT_MS,
+ projectResourcesSkipped = skipped,
+ )
+ }
+ if (outcome.exitCode == 0) {
+ // pi's own success line is in stdout; if it is missing (a future pi, or
+ // output pi suppressed) fall back to the equivalent pi wording rather
+ // than showing an empty result.
+ val fromPi = outcome.stdout.lineSequence()
+ .map { it.trim() }
+ .firstOrNull { it.startsWith("Installed ") || it.startsWith("Removed ") }
+ val summary = fromPi
+ ?: successSummary(spec).let { if (warnings.isEmpty()) it else "$it(有警告)" }
+ return Done.Ok(
+ argv = outcome.argv,
+ stdout = outcome.stdout,
+ stderr = outcome.stderr,
+ summary = summary,
+ warnings = warnings,
+ projectResourcesSkipped = skipped,
+ )
+ }
+ return Done.Failed(
+ argv = outcome.argv,
+ stdout = outcome.stdout,
+ stderr = outcome.stderr,
+ message = failureMessage(outcome),
+ exitCode = outcome.exitCode,
+ warnings = warnings,
+ projectResourcesSkipped = skipped,
+ )
+ }
+
+ /**
+ * pi's error surface is `console.error(chalk.red(\`Error: ${message}\`))`
+ * (`package-manager-cli.ts:1096-1100`) plus the specific trust and
+ * no-match lines (`:936-940`, `:960-965`). All of them land on stderr, so the
+ * stderr text **is** the message — quoting it is more faithful than
+ * reimplementing the branches, and it cannot drift from pi.
+ */
+ private fun failureMessage(outcome: GuestCommand.Outcome): String = when {
+ outcome.stderr.isNotBlank() -> outcome.stderr.trim()
+ outcome.stdout.isNotBlank() -> outcome.stdout.trim()
+ else -> "pi 退出码 ${outcome.exitCode},且没有任何输出"
+ }
+
+ companion object {
+ /**
+ * pi's two trust-denial strings for package commands
+ * (`package-manager-cli.ts:937`, applied to `install`/`remove`/`list`
+ * because `assertProjectTrustedForScope` throws its own message at
+ * `package-manager.ts:1741-1745` when scope is project).
+ */
+ private val PROJECT_SKIP_MARKERS = listOf(
+ "Project is not trusted",
+ "refusing to access project package storage",
+ )
+
+ /** `Warning: …` lines from `reportProjectTrustWarnings`/`reportSettingsErrors`. */
+ fun warningsIn(stderr: String): List = stderr.lineSequence()
+ .map { it.trim() }
+ .filter { it.startsWith("Warning:") || it.startsWith("Warning ") }
+ .toList()
+ }
+}
diff --git a/app/src/main/kotlin/app/pi/packages/PiPackageSource.kt b/app/src/main/kotlin/app/pi/packages/PiPackageSource.kt
new file mode 100644
index 0000000..3a38adb
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/PiPackageSource.kt
@@ -0,0 +1,315 @@
+package app.pi.packages
+
+/**
+ * What a `pi install` argument means, classified the way pi classifies it.
+ *
+ * pi's `DefaultPackageManager.parseSource` (`core/package-manager.ts:1446-1471`) is
+ * the authority, and this is a transcription of it:
+ *
+ * ```
+ * npm: -> npm source, spec after the prefix, name/version split by parseNpmSpec
+ * local -> if isLocalPath(source) (utils/paths.ts:50-64)
+ * git -> else if parseGitUrl(source) succeeds (utils/git.ts:172-...)
+ * local -> otherwise, and the *whole string* becomes a path
+ * ```
+ *
+ * That last line is a real pi behaviour worth stating, because it is surprising
+ * and the app must not paper over it: `pi install https://example.com/pkg.tar.gz`
+ * is **not** a download. `parseGitUrl` requires a git protocol URL or a `git:`
+ * prefix, so an ordinary https URL falls through to `local`, and `install()` then
+ * throws `Path does not exist: ` (`package-manager.ts:1018-1023`). The
+ * app reports that, in pi's words, instead of inventing "URL downloads are not
+ * supported yet".
+ *
+ * ## This classification is not a gate
+ *
+ * The app never *rejects* a spec because of this type. pi is the parser; if the
+ * two disagree, pi wins and its own error is surfaced verbatim. The type is used
+ * for (a) the label on a list row, (b) deciding whether `npm install` will run,
+ * and (c) knowing that only npm and git sources have an installed path to report.
+ * Pre-flight refusal is limited to input that pi cannot accept at all — see
+ * [validate].
+ */
+sealed interface PiPackageSource {
+
+ /** The argument exactly as the user typed it; this is what settings records. */
+ val raw: String
+
+ /**
+ * `npm:@scope/name@version`. [spec] is what follows `npm:`; [name] is the
+ * package identity pi dedupes on (`package-manager.ts:224-230`, `:1731-1739`).
+ */
+ data class Npm(
+ override val raw: String,
+ val spec: String,
+ val name: String,
+ val version: String?,
+ /** True for an exact version (`semver.valid`), false for a range/tag. */
+ val pinned: Boolean,
+ ) : PiPackageSource
+
+ /**
+ * A git source. [repo] is the clone URL pi would pass to `git clone`
+ * (`utils/git.ts:116-123`); [ref] is a pinned tag or commit.
+ */
+ data class Git(
+ override val raw: String,
+ val repo: String,
+ val host: String,
+ val path: String,
+ val ref: String?,
+ ) : PiPackageSource
+
+ /**
+ * Anything else: an absolute path, a relative path, a `file:` URL, or a bare
+ * name. pi resolves it against the settings file's own directory for storage
+ * (`package-manager.ts:1435-1444`) and against the process cwd for install.
+ */
+ data class Local(override val raw: String) : PiPackageSource
+
+ companion object {
+
+ fun parse(source: String): PiPackageSource {
+ val trimmed = source.trim()
+
+ if (trimmed.startsWith(NPM_PREFIX)) {
+ val spec = trimmed.substring(NPM_PREFIX.length).trim()
+ val name = npmPackageName(spec)
+ val version = npmVersionPart(spec)
+ return PiPackageSource.Npm(
+ raw = source,
+ spec = spec,
+ name = name,
+ version = version,
+ pinned = isExactNpmVersion(version),
+ )
+ }
+
+ if (isLocalPath(trimmed)) return Local(source)
+
+ val git = parseGitUrl(trimmed)
+ if (git != null) {
+ return Git(raw = source, repo = git.repo, host = git.host, path = git.path, ref = git.ref)
+ }
+
+ return Local(source)
+ }
+
+ /**
+ * `utils/paths.ts:50-64`, verbatim including the asymmetry: `github:` is
+ * treated as non-local there even though `parseGitUrl` will not accept it
+ * either, so `github:x/y` ends up a local path that does not exist. Faithful
+ * to pi on purpose.
+ */
+ fun isLocalPath(value: String): Boolean {
+ val trimmed = value.trim()
+ return !LOCAL_EXEMPT_PREFIXES.any { trimmed.startsWith(it) }
+ }
+
+ private val LOCAL_EXEMPT_PREFIXES = listOf(
+ "npm:",
+ "git:",
+ "github:",
+ "http:",
+ "https:",
+ "ssh:",
+ )
+
+ const val NPM_PREFIX = "npm:"
+
+ /**
+ * `parseNpmSpec` (`package-manager.ts:1731-1739`).
+ *
+ * The regex splits `@scope/name@1.2.3` into name `@scope/name`, version
+ * `1.2.3`, and leaves an unscoped `name` alone.
+ */
+ fun npmPackageName(spec: String): String {
+ val match = NPM_SPEC.matchEntire(spec) ?: return spec
+ return match.groupValues[1].ifEmpty { spec }
+ }
+
+ /** The `@version` half, or null. */
+ fun npmVersionPart(spec: String): String? {
+ val match = NPM_SPEC.matchEntire(spec) ?: return null
+ return match.groupValues[2].ifEmpty { null }
+ }
+
+ private val NPM_SPEC = Regex("^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$")
+
+ /**
+ * `isExactNpmVersion` is `semver.valid(version) !== null`
+ * (`package-manager.ts:59-61`), so this is "is a concrete version", not
+ * "is a range". `1.2.3` is pinned; `^1.2`, `latest` and `>=2` are not.
+ * pi uses the flag to skip pinned packages during `pi update --extensions`,
+ * so the label the app shows is pi's own distinction.
+ */
+ fun isExactNpmVersion(version: String?): Boolean {
+ if (version == null) return false
+ val core = version.substringBefore('+').substringBefore('-')
+ val parts = core.split('.')
+ if (parts.size != 3) return false
+ if (parts.any { it.isEmpty() || it.any { ch -> !ch.isDigit() } }) return false
+ // Prerelease/build metadata must still be well formed if present.
+ val rest = version.removePrefix(core)
+ return rest.isEmpty() || rest.startsWith("-") || rest.startsWith("+")
+ }
+
+ // ------------------------------------------------------------------ git
+ //
+ // A transcription of the reachable half of `parseGitUrl`
+ // (`utils/git.ts:172-...`, `:104-163`). `hostedGitInfo` handles a few extra
+ // hosted shorthands (`gist:`, `bitbucket:`); those fall through to local
+ // here. That is safe in one direction only — the app must never claim a
+ // spec is invalid, so [validate] refuses nothing on account of this.
+
+ fun parseGitUrl(source: String): PiPackageSource.Git? {
+ val trimmed = source.trim()
+ val hasPrefix = trimmed.startsWith("git:")
+ val url = if (hasPrefix) trimmed.substring(4).trim() else trimmed
+ if (!hasPrefix && !PROTOCOL_URL.containsMatchIn(url)) return null
+
+ val split = splitRef(url)
+ val repo = split.first
+ val ref = split.second
+
+ // `git@host:user/repo` survives splitRef as an scp-like string, and pi
+ // hands it to hosted-git-info (`utils/git.ts:132-135`, `:186-204`). The
+ // clone URL stays as written — `useHttpsPrefix` is false for anything
+ // starting with `git@` (`:192-197`).
+ SCP_LIKE.matchEntire(repo)?.let { scp ->
+ return buildGitSource(repo, scp.groupValues[1], scp.groupValues[2], ref)
+ }
+ // A protocol URL: host from the authority, path from the pathname
+ // (`utils/git.ts:136-148`).
+ if (PROTOCOL_URL.containsMatchIn(repo)) {
+ val parts = parseUrlParts(repo) ?: return null
+ return buildGitSource(repo, parts.first, parts.second, ref)
+ }
+ // `host/user/repo` shorthand, which only counts as hosted when the host
+ // looks like a hostname; pi then prefixes https
+ // (`utils/git.ts:149-160`, `:192-197`).
+ val slash = repo.indexOf('/')
+ if (slash < 0) return null
+ val host = repo.substring(0, slash)
+ val path = repo.substring(slash + 1)
+ if (!host.contains('.') && host != "localhost") return null
+ return buildGitSource("https://$repo", host, path, ref)
+ }
+
+ private val PROTOCOL_URL = Regex("^(https?|ssh|git)://", RegexOption.IGNORE_CASE)
+ private val SCP_LIKE = Regex("^git@([^:]+):(.+)$")
+
+ private fun buildGitSource(repo: String, host: String, rawPath: String, ref: String?): PiPackageSource.Git? {
+ if (rawPath.startsWith("/")) return null
+ val path = rawPath.removeSuffix(".git").trimStart('/')
+ if (host.isEmpty() || path.isEmpty() || path.split('/').size < 2) return null
+ if (hasUnsafeGitInstallPart(host, allowSlash = false)) return null
+ if (hasUnsafeGitInstallPart(path, allowSlash = true)) return null
+ return PiPackageSource.Git(raw = repo, repo = repo, host = host, path = path, ref = ref)
+ }
+
+ /** `hasUnsafeGitInstallPart` (`utils/git.ts:84-102`). */
+ private fun hasUnsafeGitInstallPart(value: String, allowSlash: Boolean): Boolean {
+ val decoded = runCatching { java.net.URLDecoder.decode(value, "UTF-8") }.getOrNull() ?: return true
+ for (candidate in listOf(value, decoded)) {
+ if (candidate.contains('\u0000') || candidate.contains('\\') || candidate.startsWith("/")) return true
+ if (!allowSlash && candidate.contains('/')) return true
+ if (candidate.split('/').contains("..")) return true
+ }
+ return false
+ }
+
+ /**
+ * `splitRef` (`utils/git.ts:21-74`), all three branches.
+ *
+ * The `://` branch is the one that is easy to get wrong: the ref lives in
+ * the **pathname**, so `ssh://git@github.com/user/repo` has no ref at all —
+ * the `@` before `github.com` is user-info, not a ref separator. Reading the
+ * raw string with `indexOf('@')` splits that URL into `"/git"` and
+ * `"github.com/user/repo"`. pi avoids it with `new URL(...)` (`:36-53`), and
+ * so does this.
+ */
+ private fun splitRef(url: String): Pair {
+ SCP_LIKE.matchEntire(url)?.let { scp ->
+ val pathWithMaybeRef = scp.groupValues[2]
+ val at = pathWithMaybeRef.indexOf('@')
+ if (at < 0) return url to null
+ val repoPath = pathWithMaybeRef.substring(0, at)
+ val ref = pathWithMaybeRef.substring(at + 1)
+ if (repoPath.isEmpty() || ref.isEmpty()) return url to null
+ return "git@${scp.groupValues[1]}:$repoPath" to ref
+ }
+
+ if (url.contains("://")) {
+ return runCatching {
+ val uri = java.net.URI(url)
+ val pathname = uri.path?.trimStart('/').orEmpty()
+ val at = pathname.indexOf('@')
+ if (at < 0) return@runCatching url to null
+ val repoPath = pathname.substring(0, at)
+ val ref = pathname.substring(at + 1)
+ if (repoPath.isEmpty() || ref.isEmpty()) return@runCatching url to null
+ // `parsed.pathname = "/" + repoPath` then `parsed.toString()`
+ // minus a trailing slash (`utils/git.ts:45-48`).
+ val rebuilt = java.net.URI(
+ uri.scheme,
+ uri.userInfo,
+ uri.host,
+ uri.port,
+ "/$repoPath",
+ null,
+ null,
+ )
+ rebuilt.toString().removeSuffix("/") to ref
+ }.getOrElse { url to null }
+ }
+
+ val slash = url.indexOf('/')
+ if (slash < 0) return url to null
+ val host = url.substring(0, slash)
+ val pathWithMaybeRef = url.substring(slash + 1)
+ val at = pathWithMaybeRef.indexOf('@')
+ if (at < 0) return url to null
+ val repoPath = pathWithMaybeRef.substring(0, at)
+ val ref = pathWithMaybeRef.substring(at + 1)
+ if (repoPath.isEmpty() || ref.isEmpty()) return url to null
+ return "$host/$repoPath" to ref
+ }
+
+ private fun parseUrlParts(url: String): Pair? = runCatching {
+ val uri = java.net.URI(url)
+ val host = uri.host ?: return null
+ host to uri.path.trimStart('/')
+ }.getOrNull()
+
+ // -------------------------------------------------------------- validate
+
+ /** A refusal the app makes before spawning anything. */
+ data class Rejection(val message: String)
+
+ /**
+ * The only pre-flight refusals, each mirroring a pi code path so the app
+ * never produces a message pi would not:
+ *
+ * - blank → `handlePackageCommand` prints `Missing install source.`
+ * (`package-manager-cli.ts:907-912`).
+ * - leading `-` → pi's option parser swallows it and reports
+ * `Unknown option for "".` (`:492-495`, `:878-883`).
+ *
+ * Everything else — a URL that is not a git URL, a relative path that does
+ * not exist, a package name npm will not resolve — is left to pi, whose
+ * message is surfaced verbatim. An install that fails silently is worse
+ * than one that refuses; an install the app refuses for its own reasons is
+ * worse than both.
+ */
+ fun validate(command: String, source: String?): Rejection? {
+ if (source == null || source.isBlank()) {
+ return Rejection("Missing $command source.")
+ }
+ if (source.trimStart().startsWith("-")) {
+ return Rejection("Unknown option ${source.trim()} for \"$command\".")
+ }
+ return null
+ }
+ }
+}
diff --git a/app/src/main/kotlin/app/pi/packages/PiPackagesScreen.kt b/app/src/main/kotlin/app/pi/packages/PiPackagesScreen.kt
new file mode 100644
index 0000000..87ec9d5
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/PiPackagesScreen.kt
@@ -0,0 +1,618 @@
+package app.pi.packages
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import app.pi.ui.theme.PiShapes
+import app.pi.ui.theme.PiTheme
+
+/**
+ * The whole install / list / remove / restart / trust surface, as one state-driven
+ * screen.
+ *
+ * It is state-*driven* on purpose: everything the user sees is derived from
+ * [PiPackagesUiState], which is assembled outside this package from
+ * [PiPackageService], [ExtensionLifecycle] and [TrustRepository]. The owner of
+ * The `ui` package wires it up; this file never reaches into the engine, never starts
+ * a restart, and never writes a trust record itself. That keeps the one rule that
+ * matters — **no silent restart, no silent trust** — a property of the state machine
+ * rather than of a button's onClick.
+ */
+data class PiPackagesUiState(
+ val installedRoot: String = "",
+ /** The workspace whose trust decision is being shown, in guest spelling. */
+ val guestWorkspace: String = "",
+ val spec: String = "",
+ val scope: PiPackageScope = PiPackageScope.User,
+ val busy: Boolean = false,
+ val lifecycle: ExtensionLifecycle.State = ExtensionLifecycle.State.Idle,
+ val entries: List = emptyList(),
+ /** `pi list`'s raw stdout, shown whenever parsing found nothing. */
+ val listRaw: String = "",
+ val listUnparsed: Boolean = false,
+ /**
+ * `.pi/settings.json` lists packages that this listing could not show, because
+ * the project is untrusted. `pi list` exits 0 in that case and prints nothing
+ * about them, so the app has to say it.
+ */
+ val projectPackagesHidden: Boolean = false,
+ /** The last command's streams and argv, verbatim, newest first. */
+ val log: List = emptyList(),
+ /** Null when the project has no trust-requiring resources at all. */
+ val trust: TrustPanel? = null,
+ /** Non-null when a trust record is invalid and pi would refuse to start. */
+ val trustInvalid: String? = null,
+) {
+
+ data class LogLine(
+ val headline: String,
+ val command: String,
+ val stdout: String,
+ val stderr: String,
+ val warnings: List = emptyList(),
+ )
+
+ data class TrustPanel(
+ /** `True` / `False` / null, exactly as `trust.json` holds it. */
+ val decision: Boolean?,
+ /** Which branch decided, and what it means for the user. */
+ val explanation: String,
+ val promptOptions: List,
+ val promptVisible: Boolean,
+ /** True when project resources are being skipped right now. */
+ val skippingResources: Boolean,
+ /** True when a project-scope package operation is blocked by this. */
+ val blocksProjectPackages: Boolean,
+ )
+
+ val needsRestart: Boolean get() = lifecycle is ExtensionLifecycle.State.NeedsRestart
+ val awaitingIdle: Boolean get() = lifecycle is ExtensionLifecycle.State.AwaitingIdle
+ val awaitingConfirm: Boolean get() = lifecycle is ExtensionLifecycle.State.AwaitingConfirmation
+ val restarting: Boolean get() = lifecycle is ExtensionLifecycle.State.Restarting
+}
+
+@Composable
+fun PiPackagesScreen(
+ state: PiPackagesUiState,
+ onSpecChange: (String) -> Unit,
+ onScopeChange: (PiPackageScope) -> Unit,
+ onInstall: () -> Unit,
+ onRemove: (PiPackageEntry) -> Unit,
+ onRefresh: () -> Unit,
+ onRestartClick: () -> Unit,
+ onRestartConfirm: () -> Unit,
+ onRestartCancel: () -> Unit,
+ onOpenTrustPrompt: () -> Unit,
+ onTrustChoose: (ProjectTrust.Option) -> Unit,
+ onTrustDismiss: () -> Unit,
+ onTrustRepair: () -> Unit,
+) {
+ if (state.awaitingConfirm) {
+ RestartConfirmDialog(
+ lifecycle = state.lifecycle,
+ onConfirm = onRestartConfirm,
+ onCancel = onRestartCancel,
+ )
+ }
+ val trust = state.trust
+ if (trust != null && trust.promptVisible) {
+ PiProjectTrustPrompt(
+ cwd = state.guestWorkspace,
+ options = trust.promptOptions,
+ explanation = trust.explanation,
+ busy = state.busy,
+ onChoose = onTrustChoose,
+ onDismiss = onTrustDismiss,
+ )
+ }
+
+ val palette = PiTheme.palette
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(palette.pageBg)
+ .padding(horizontal = 14.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ item { Header(state) }
+ trust?.let { item { TrustCard(it, state.busy, onOpenTrustPrompt) } }
+ state.trustInvalid?.let { item { InvalidTrustCard(it, state.busy, onTrustRepair) } }
+ item { LifecycleCard(state, onRestartClick, onRestartConfirm, onRestartCancel) }
+ item { InstallCard(state, onSpecChange, onScopeChange, onInstall, onRefresh) }
+
+ if (state.projectPackagesHidden) {
+ item {
+ Text(
+ text = PackageStrings.PROJECT_PACKAGES_HIDDEN,
+ style = MaterialTheme.typography.bodySmall,
+ color = PiTheme.palette.warning,
+ )
+ }
+ }
+ if (state.entries.isEmpty()) {
+ item {
+ Text(
+ text = if (state.listUnparsed) PackageStrings.LIST_UNPARSED else PackageStrings.NO_PACKAGES,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (state.listUnparsed) palette.warning else palette.muted,
+ )
+ }
+ }
+ items(state.entries, key = { "${it.scope.name}:${it.source.raw}" }) { entry ->
+ PackageRow(entry, state.busy, onRemove)
+ }
+
+ if (state.listUnparsed && state.listRaw.isNotBlank()) {
+ item { RawBlock(PackageStrings.STDOUT_TITLE, state.listRaw) }
+ }
+ items(state.log, key = { it.command + it.headline }) { line -> LogCard(line) }
+ item { Spacer(Modifier.height(24.dp)) }
+ }
+}
+
+@Composable
+private fun Header(state: PiPackagesUiState) {
+ val palette = PiTheme.palette
+ Column(Modifier.padding(top = 12.dp)) {
+ Text(PackageStrings.TITLE, style = MaterialTheme.typography.titleLarge, color = palette.text)
+ Spacer(Modifier.height(4.dp))
+ Text(PackageStrings.SUBTITLE, style = MaterialTheme.typography.bodySmall, color = palette.muted)
+ if (state.installedRoot.isNotBlank()) {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = "pi 的 agent 目录:${state.installedRoot}",
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.dim,
+ )
+ }
+ }
+}
+
+// ------------------------------------------------------------------ trust card
+
+@Composable
+private fun TrustCard(
+ trust: PiPackagesUiState.TrustPanel,
+ busy: Boolean,
+ onOpenPrompt: () -> Unit,
+) {
+ val palette = PiTheme.palette
+ val decisionText = when (trust.decision) {
+ true -> PackageStrings.TRUSTED
+ false -> PackageStrings.UNTRUSTED
+ null -> PackageStrings.NOT_RECORDED
+ }
+ val decisionColor = when (trust.decision) {
+ true -> palette.success
+ false -> palette.error
+ null -> palette.warning
+ }
+ Surface(color = palette.cardBg, shape = PiShapes.card) {
+ Column(Modifier.fillMaxWidth().padding(12.dp)) {
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ Text("项目信任", style = MaterialTheme.typography.titleSmall, color = palette.text)
+ Spacer(Modifier.weight(1f))
+ Text(decisionText, style = MaterialTheme.typography.labelLarge, color = decisionColor)
+ }
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = trust.explanation,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (trust.skippingResources) palette.warning else palette.muted,
+ )
+ if (trust.blocksProjectPackages) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = PackageStrings.SCOPE_PROJECT_LOCKED,
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.warning,
+ )
+ }
+ Spacer(Modifier.height(10.dp))
+ OutlinedButton(onClick = onOpenPrompt, enabled = !busy) {
+ Text("作出信任决定…")
+ }
+ }
+ }
+}
+
+@Composable
+private fun InvalidTrustCard(message: String, busy: Boolean, onRepair: () -> Unit) {
+ val palette = PiTheme.palette
+ Surface(color = palette.toolErrorBg, shape = PiShapes.card) {
+ Column(Modifier.fillMaxWidth().padding(12.dp)) {
+ Text(
+ PackageStrings.TRUST_INVALID_TITLE,
+ style = MaterialTheme.typography.titleSmall,
+ color = palette.error,
+ )
+ Spacer(Modifier.height(6.dp))
+ Text(message, style = MaterialTheme.typography.bodySmall, color = palette.text)
+ Spacer(Modifier.height(6.dp))
+ Text(
+ PackageStrings.TRUST_INVALID_NOTE,
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.muted,
+ )
+ Spacer(Modifier.height(10.dp))
+ Button(onClick = onRepair, enabled = !busy) { Text(PackageStrings.TRUST_INVALID_ACTION) }
+ }
+ }
+}
+
+// -------------------------------------------------------------- lifecycle card
+
+@Composable
+private fun LifecycleCard(
+ state: PiPackagesUiState,
+ onRestartClick: () -> Unit,
+ onConfirm: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ val lifecycle = state.lifecycle
+ if (lifecycle is ExtensionLifecycle.State.Idle) return
+ if (lifecycle is ExtensionLifecycle.State.Ready) {
+ Text(
+ text = lifecycle.note,
+ style = MaterialTheme.typography.bodySmall,
+ color = PiTheme.palette.success,
+ )
+ return
+ }
+
+ val palette = PiTheme.palette
+ val accent = when (lifecycle) {
+ is ExtensionLifecycle.State.NeedsRestart -> palette.warning
+ is ExtensionLifecycle.State.AwaitingIdle -> palette.warning
+ is ExtensionLifecycle.State.Restarting -> palette.accent
+ else -> palette.muted
+ }
+ Surface(color = palette.cardBg, shape = PiShapes.card) {
+ Column(Modifier.fillMaxWidth().padding(12.dp)) {
+ Text(
+ text = when (lifecycle) {
+ is ExtensionLifecycle.State.NeedsRestart -> PackageStrings.RESTART_NEEDED_TITLE
+ is ExtensionLifecycle.State.AwaitingIdle -> PackageStrings.RESTART_WAIT_TURN
+ is ExtensionLifecycle.State.Restarting -> PackageStrings.RESTARTING
+ is ExtensionLifecycle.State.Installing -> PackageStrings.RUNNING
+ else -> lifecycle.label
+ },
+ style = MaterialTheme.typography.titleSmall,
+ color = accent,
+ )
+ when (lifecycle) {
+ is ExtensionLifecycle.State.NeedsRestart -> {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ lifecycle.detail,
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.muted,
+ )
+ lifecycle.lastError?.let { error ->
+ Spacer(Modifier.height(6.dp))
+ Text(error, style = MaterialTheme.typography.bodySmall, color = palette.error)
+ }
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = ExtensionLifecycle.ANSWER_HINT,
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.dim,
+ )
+ Spacer(Modifier.height(10.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = onRestartClick) { Text(PackageStrings.RESTART_BUTTON) }
+ TextButton(onClick = onCancel) { Text(PackageStrings.RESTART_STAY) }
+ }
+ }
+
+ is ExtensionLifecycle.State.AwaitingIdle -> {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ lifecycle.turnNote,
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.warning,
+ )
+ Spacer(Modifier.height(10.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // Re-checks the turn rather than promising a restart: the
+ // dialog only appears when the engine reports idle.
+ OutlinedButton(onClick = onRestartClick) { Text(PackageStrings.RESTART_BUTTON) }
+ TextButton(onClick = onCancel) { Text(PackageStrings.RESTART_STAY) }
+ }
+ }
+
+ is ExtensionLifecycle.State.AwaitingConfirmation -> {
+ // The dialog is rendered by PiPackagesScreen; the card is the
+ // fallback if the dialog was dismissed.
+ Spacer(Modifier.height(6.dp))
+ Text(
+ lifecycle.question,
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.text,
+ )
+ Spacer(Modifier.height(10.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = onConfirm) { Text(PackageStrings.RESTART_CONFIRM) }
+ TextButton(onClick = onCancel) { Text(PackageStrings.RESTART_STAY) }
+ }
+ }
+
+ is ExtensionLifecycle.State.Restarting -> {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = PackageStrings.RESTARTING_NOTE,
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.muted,
+ )
+ }
+
+ else -> {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ lifecycle.changesOrEmpty().joinToString("\n"),
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.muted,
+ )
+ }
+ }
+ }
+ }
+}
+
+/** `changes` is only carried by the three pending states; the others have none. */
+private fun ExtensionLifecycle.State.changesOrEmpty(): List = when (this) {
+ is ExtensionLifecycle.State.NeedsRestart -> changes
+ is ExtensionLifecycle.State.AwaitingConfirmation -> changes
+ is ExtensionLifecycle.State.AwaitingIdle -> changes
+ is ExtensionLifecycle.State.Restarting -> changes
+ else -> emptyList()
+}
+
+@Composable
+private fun RestartConfirmDialog(
+ lifecycle: ExtensionLifecycle.State,
+ onConfirm: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ val palette = PiTheme.palette
+ val question = (lifecycle as? ExtensionLifecycle.State.AwaitingConfirmation)?.question
+ ?: return
+ AlertDialog(
+ // Dismissal is a cancel, never an implicit confirm: `onDismissRequest`
+ // routes to onCancel, matching pi's own "dismissal means no"
+ // (`project-trust.ts:90-95`) and, more importantly, never restarting on a
+ // back gesture.
+ onDismissRequest = onCancel,
+ title = { Text(PackageStrings.RESTART_NEEDED_TITLE, color = palette.text) },
+ text = {
+ Column {
+ Text(question, style = MaterialTheme.typography.bodySmall, color = palette.muted)
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = "重启是显式的:在你点下确认之前,App 不会重启引擎。",
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.dim,
+ )
+ }
+ },
+ confirmButton = { Button(onClick = onConfirm) { Text(PackageStrings.RESTART_CONFIRM) } },
+ dismissButton = { TextButton(onClick = onCancel) { Text(PackageStrings.RESTART_STAY) } },
+ containerColor = palette.cardBg,
+ titleContentColor = palette.text,
+ textContentColor = palette.muted,
+ )
+}
+
+// ---------------------------------------------------------------- install card
+
+@Composable
+private fun InstallCard(
+ state: PiPackagesUiState,
+ onSpecChange: (String) -> Unit,
+ onScopeChange: (PiPackageScope) -> Unit,
+ onInstall: () -> Unit,
+ onRefresh: () -> Unit,
+) {
+ val palette = PiTheme.palette
+ Surface(color = palette.cardBg, shape = PiShapes.card) {
+ Column(Modifier.fillMaxWidth().padding(12.dp)) {
+ OutlinedTextField(
+ value = state.spec,
+ onValueChange = onSpecChange,
+ label = { Text(PackageStrings.SPEC_HINT) },
+ singleLine = true,
+ enabled = !state.busy,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ PiPackageScope.entries.forEach { scope ->
+ val selected = scope == state.scope
+ OutlinedButton(
+ onClick = { onScopeChange(scope) },
+ enabled = !state.busy,
+ ) {
+ Text(
+ text = if (scope == PiPackageScope.User) {
+ PackageStrings.SCOPE_USER
+ } else {
+ PackageStrings.SCOPE_PROJECT
+ },
+ color = if (selected) palette.accent else palette.muted,
+ )
+ }
+ }
+ }
+ if (state.scope == PiPackageScope.Project) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = PackageStrings.SCOPE_PROJECT_LOCKED,
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.dim,
+ )
+ }
+ Spacer(Modifier.height(10.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = onInstall, enabled = !state.busy && state.spec.isNotBlank()) {
+ Text(if (state.busy) PackageStrings.RUNNING else PackageStrings.INSTALL_LABEL)
+ }
+ OutlinedButton(onClick = onRefresh, enabled = !state.busy) {
+ Text(PackageStrings.REFRESH_LABEL)
+ }
+ }
+ }
+ }
+}
+
+// ----------------------------------------------------------------- package row
+
+@Composable
+private fun PackageRow(
+ entry: PiPackageEntry,
+ busy: Boolean,
+ onRemove: (PiPackageEntry) -> Unit,
+) {
+ val palette = PiTheme.palette
+ Surface(color = palette.infoBg, shape = PiShapes.cardInner) {
+ Column(Modifier.fillMaxWidth().padding(12.dp)) {
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ Text(
+ text = when (entry.source) {
+ is PiPackageSource.Npm -> "npm"
+ is PiPackageSource.Git -> "git"
+ is PiPackageSource.Local -> "本地"
+ },
+ style = MaterialTheme.typography.labelMedium,
+ color = palette.accent,
+ )
+ Spacer(Modifier.weight(1f))
+ Text(
+ text = if (entry.scope == PiPackageScope.User) {
+ PackageStrings.SCOPE_USER
+ } else {
+ PackageStrings.SCOPE_PROJECT
+ },
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.muted,
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = entry.source.raw,
+ style = MaterialTheme.typography.bodyMedium,
+ color = palette.text,
+ )
+ (entry.source as? PiPackageSource.Npm)?.let { npm ->
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = buildString {
+ append("包名 ${npm.name}")
+ npm.version?.let { append(",版本 $it") }
+ append(if (npm.pinned) "(精确版本,pi update --extensions 会跳过)" else "(范围/标签,可被 update 移动)")
+ },
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.dim,
+ )
+ }
+ (entry.source as? PiPackageSource.Git)?.let { git ->
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = buildString {
+ append("仓库 ${git.repo}")
+ append(if (git.ref != null) ",ref ${git.ref}(已钉住)" else ",未钉 ref")
+ },
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.dim,
+ )
+ }
+ if (entry.filtered) {
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = "settings 里是对象形式:只加载显式列出的资源(可能只加载一部分)。",
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.warning,
+ )
+ }
+ entry.installedPath?.let { path ->
+ Spacer(Modifier.height(2.dp))
+ Text(path, style = MaterialTheme.typography.labelSmall, color = palette.dim)
+ } ?: run {
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = "pi 没有报告安装路径——它可能尚未下载到磁盘,或该来源没有安装路径。",
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.warning,
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ TextButton(onClick = { onRemove(entry) }, enabled = !busy) {
+ Text(PackageStrings.REMOVE_LABEL, color = palette.error)
+ }
+ }
+ }
+}
+
+// ------------------------------------------------------------------- raw cards
+
+@Composable
+private fun LogCard(line: PiPackagesUiState.LogLine) {
+ val palette = PiTheme.palette
+ Surface(color = palette.cardBg, shape = PiShapes.card) {
+ Column(Modifier.fillMaxWidth().padding(12.dp)) {
+ Text(line.headline, style = MaterialTheme.typography.titleSmall, color = palette.text)
+ if (line.warnings.isNotEmpty()) {
+ Spacer(Modifier.height(6.dp))
+ line.warnings.forEach { warning ->
+ Text(warning, style = MaterialTheme.typography.bodySmall, color = palette.warning)
+ }
+ }
+ if (line.stderr.isNotBlank()) {
+ Spacer(Modifier.height(6.dp))
+ RawBlock(PackageStrings.STDERR_TITLE, line.stderr)
+ }
+ if (line.stdout.isNotBlank()) {
+ Spacer(Modifier.height(6.dp))
+ RawBlock(PackageStrings.STDOUT_TITLE, line.stdout)
+ }
+ Spacer(Modifier.height(6.dp))
+ RawBlock(PackageStrings.COMMAND_TITLE, line.command)
+ }
+ }
+}
+
+@Composable
+private fun RawBlock(title: String, body: String) {
+ val palette = PiTheme.palette
+ Column(Modifier.fillMaxWidth()) {
+ Text(title, style = MaterialTheme.typography.labelSmall, color = palette.muted)
+ Spacer(Modifier.height(2.dp))
+ Surface(color = palette.pageBg, shape = PiShapes.cardInner) {
+ Text(
+ text = body,
+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
+ color = palette.toolOutput,
+ modifier = Modifier.fillMaxWidth().padding(8.dp),
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/app/pi/packages/PiProjectTrustPrompt.kt b/app/src/main/kotlin/app/pi/packages/PiProjectTrustPrompt.kt
new file mode 100644
index 0000000..f32b052
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/PiProjectTrustPrompt.kt
@@ -0,0 +1,131 @@
+package app.pi.packages
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import app.pi.ui.theme.PiShapes
+import app.pi.ui.theme.PiTheme
+
+/**
+ * pi's project-trust prompt, as a real prompt that writes a real record.
+ *
+ * ## This is not a design choice about dialog style
+ *
+ * pi's own prompt cannot appear in RPC mode: `project-trust.ts:86-88` returns false
+ * the moment `hasUI` is false, and `hasUI` is
+ * `isInitialRuntime && trustPromptMode === "interactive"` (`main.ts:753`), which RPC
+ * never satisfies. So the app's prompt is the *only* prompt — and if it did not
+ * exist, `.pi/extensions` would be skipped with no event, no stderr line and no
+ * error at all (`loader.ts:634-637`, `main.ts:775-782`).
+ *
+ * ## Fidelity
+ *
+ * - The heading is [ProjectTrust.promptText], pi's `formatProjectTrustPrompt`
+ * (`project-trust.ts:24-26`) character for character, so a user comparing this
+ * screen with pi's TUI sees the same question.
+ * - The choices are [ProjectTrust.options], pi's five
+ * (`trust-manager.ts:66-96`) — including `Trust parent folder`, which is the one
+ * a shorter menu silently drops, and the two session-only escapes.
+ * - The Chinese line under each label is explanation only. Selecting an option
+ * writes exactly what pi would write; see [TrustRepository.apply].
+ *
+ * Dismissal maps to pi's own dismissal semantics — `resolveProjectTrusted` returns
+ * false when the select is dismissed (`project-trust.ts:90-95`) — so the app treats
+ * 取消 as "no decision this time", states that nothing was written, and leaves
+ * [ProjectTrust.Rationale.NoUiRefused] explaining the skip.
+ */
+@Composable
+fun PiProjectTrustPrompt(
+ cwd: String,
+ options: List,
+ /** Why this prompt is being shown; from [PackageStrings.rationale]. */
+ explanation: String,
+ /** Set while a trust decision is being written, to prevent double submission. */
+ busy: Boolean = false,
+ onChoose: (ProjectTrust.Option) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val palette = PiTheme.palette
+ AlertDialog(
+ onDismissRequest = { if (!busy) onDismiss() },
+ title = {
+ Text(
+ text = "信任这个项目?",
+ style = MaterialTheme.typography.titleMedium,
+ color = palette.text,
+ )
+ },
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState()).heightIn(max = 420.dp)) {
+ // pi's exact prompt, kept as preformatted text: the second line is
+ // the path pi hashed, and a user may need to copy it.
+ Text(
+ text = ProjectTrust.promptText(cwd),
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.muted,
+ )
+ Spacer(Modifier.height(10.dp))
+ Text(
+ text = explanation,
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.warning,
+ )
+ Spacer(Modifier.height(14.dp))
+ options.forEach { option ->
+ Surface(
+ color = palette.selectedBg.copy(alpha = 0.45f),
+ shape = PiShapes.cardInner,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 3.dp)
+ .clickable(enabled = !busy) { onChoose(option) },
+ ) {
+ Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
+ Text(
+ text = option.label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (option.trusted) palette.success else palette.error,
+ )
+ val subtitle = PackageStrings.trustSubtitle(option.label)
+ if (subtitle.isNotEmpty()) {
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.muted,
+ )
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = PackageStrings.TRUST_SESSION_ONLY_NOTE,
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.dim,
+ )
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = onDismiss, enabled = !busy) { Text(PackageStrings.CANCEL) }
+ },
+ containerColor = palette.cardBg,
+ titleContentColor = palette.text,
+ textContentColor = palette.muted,
+ )
+}
diff --git a/app/src/main/kotlin/app/pi/packages/ProjectTrust.kt b/app/src/main/kotlin/app/pi/packages/ProjectTrust.kt
new file mode 100644
index 0000000..a702972
--- /dev/null
+++ b/app/src/main/kotlin/app/pi/packages/ProjectTrust.kt
@@ -0,0 +1,217 @@
+package app.pi.packages
+
+/**
+ * pi's project-trust decision, reproduced including the branch that decided it.
+ *
+ * ## The problem this file exists for
+ *
+ * In `--mode rpc`, pi's trust prompt has no UI, and with the default
+ * `defaultProjectTrust: "ask"` pi **silently skips** project-local `.pi/extensions`.
+ * Traced exactly:
+ *
+ * - trust decisions are resolved by `resolveProjectTrusted`
+ * (`core/project-trust.ts:46-95`);
+ * - the "no UI" branch is `if (!options.projectTrustContext.hasUI) return false`
+ * (`:86-88`);
+ * - `hasUI` for the CLI is `isInitialRuntime && trustPromptMode === "interactive"`,
+ * and RPC is not interactive (`main.ts:753`), so step 6 always fires;
+ * - the skip itself produces **no wire event, no stderr line and no error**
+ * (`docs/extension-compatibility.md:574-575`, from `loader.ts:634-637` and
+ * `main.ts:775-782`).
+ *
+ * So the app cannot learn from the engine that resources were skipped. It has to
+ * resolve the same decision itself, from the same inputs, and then *say* what it
+ * concluded. That is what this object does: [resolve] returns not just true/false
+ * but the [Rationale] that produced it, so "project extensions are not loaded" is
+ * a sentence the app can show instead of a silence.
+ *
+ * ## What the app can do about it
+ *
+ * Writing the same record pi would write (see [TrustRepository]) is the only
+ * durable fix, and it is exactly what pi's own prompt does: `Trust` stores
+ * `{ "": true }` (`trust-manager.ts:69`). The five choices are pi's
+ * own five (`:66-96`), with pi's own labels, because a shorter menu would silently
+ * drop `Trust parent folder` and the session-only escape hatches.
+ */
+object ProjectTrust {
+
+ /** `docs/security.md:9-16`, `trust-manager.ts:30-38`. A bare `.pi` is not enough. */
+ val TRUST_REQUIRING_CONFIG_ENTRIES = listOf(
+ "settings.json",
+ "extensions",
+ "skills",
+ "prompts",
+ "themes",
+ "SYSTEM.md",
+ "APPEND_SYSTEM.md",
+ )
+
+ const val CONFIG_DIR_NAME = ".pi"
+
+ /** `APP_NAME` (`config.ts:502`). */
+ const val APP_NAME = "pi"
+
+ /** One of pi's five prompt choices (`trust-manager.ts:66-96`). */
+ data class Option(
+ /** pi's exact label, shown untranslated so a user can match it to pi's docs. */
+ val label: String,
+ val trusted: Boolean,
+ /**
+ * The `trust.json` writes this option performs. Empty means session-only:
+ * pi stores nothing (`trust-manager.ts:84`, `:93`).
+ */
+ val updates: List>,
+ /** The key pi would call `savedPath`, when the option persists. */
+ val savedPath: String?,
+ ) {
+ val sessionOnly: Boolean get() = updates.isEmpty()
+ }
+
+ /**
+ * The five choices, exactly as `getProjectTrustOptions(cwd, {includeSessionOnly:
+ * true})` builds them (`trust-manager.ts:66-96`).
+ *
+ * The Chinese subtitles are the app's own addition and are kept **beside** pi's
+ * label, never instead of it: the label is what the user will see in pi's TUI
+ * and in pi's docs.
+ */
+ fun options(canonicalCwd: String): List