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