diff --git a/adb_handler.py b/adb_handler.py index b860e92..d4aa779 100644 --- a/adb_handler.py +++ b/adb_handler.py @@ -125,11 +125,17 @@ def _resolve_serial(adb_exe: str, port: Optional[int], runner: Runner) -> str: def _ensure_su_policy(adb_exe: str, serial: str, runner: Runner) -> None: - """Persist a permanent 'allow' Superuser policy for the shell uid (2000) so - magiskd stops prompting -- and, if the prompt isn't tapped fast enough, - auto-DENYING -- ``su`` during a flash. The very first ``su`` here may still - pop the grant once; after it's recorded, every later flash is automatic - (this is the "flip auto-grant before flashing" step, done for you). + """Re-affirm the permanent 'allow' Superuser policy for the shell uid (2000) + so magiskd never prompts -- and, on a missed prompt, auto-DENIES -- ``su`` + during a flash. + + The real fix lives offline: the installer plants a Magisk ``service.d`` + script (magisk_system) that runs as root at every boot and sets this same + policy, so a fresh instance grants the shell from first boot with no tap and + a missed prompt can never lock it out. This online call is a silent + belt-and-suspenders re-affirm for instances that already have su; it can't + bootstrap a shell that's currently denied (it would need the very su it's + being denied), which is exactly why the grant is planted offline. Best-effort: any failure is swallowed so it never aborts the flash itself. """ @@ -164,9 +170,10 @@ def _p(msg): _p("Connecting to the instance...") serial = _resolve_serial(adb_exe, port, runner) - # Pre-authorize shell root so the flash isn't auto-denied. The first flash - # of a fresh install may still prompt once (tap Grant); after that it sticks. - _p("Authorizing root (tap Grant in the instance if a prompt appears)...") + # Re-affirm shell root so the flash isn't auto-denied. On a tool-installed + # instance the offline service.d grant already did this at boot, so no prompt + # appears; this is a silent belt-and-suspenders confirm. + _p("Confirming ADB root access...") _ensure_su_policy(adb_exe, serial, runner) tmp = "/data/local/tmp/" + name diff --git a/constants.py b/constants.py index c3870ea..0617304 100644 --- a/constants.py +++ b/constants.py @@ -101,7 +101,7 @@ def parse_version(s): PROCESS_POST_KILL_WAIT_S = 2 -APP_VERSION = "3.2.0" +APP_VERSION = "3.3.0" APP_ID = f"RobThePCGuy.BlueStacksRootGUI.{APP_VERSION}" APP_NAME = "BlueStacks Root GUI" ICON_FILENAME = "favicon.ico" diff --git a/magisk_system.py b/magisk_system.py index 060908d..881d48e 100644 --- a/magisk_system.py +++ b/magisk_system.py @@ -66,6 +66,26 @@ # that are data, not executables -- everything else in the DATABIN is 0755. _DATABIN_DATA_FILES = ("stub.apk", "kernel.keyblock", "kernel_data_key.vbprivk") +# service.d auto-grant. Magisk runs /data/adb/service.d/*.sh as root at +# late_start (every boot). We plant a tiny script that force-allows the ADB +# shell (uid 2000) in Magisk's policy DB, so a su prompt the user never sees +# can't auto-DENY -- and permanently lock out -- ADB module flashing. The shell +# itself can't set this (it needs the su it's being denied); service.d runs as +# root, so it can. Scoped to the shell uid; apps still prompt. Live-proven on +# BlueStacks A13: survives a cold boot, re-grants silently, no tap. +_SERVICE_D = "/adb/service.d" # ext4 path inside Data.vhdx (fs root == guest /data) +_ADB_GRANT_SCRIPT = "00-bsrgui-adbgrant.sh" +_ADB_GRANT_BODY = ( + "#!/system/bin/sh\n" + "# BlueStacks-Root-GUI: force-allow the ADB shell (uid 2000) at boot so\n" + "# module flashes over ADB are never auto-denied (the missed-prompt -> deny\n" + "# trap). Runs as root via Magisk service.d; scoped to the shell uid only.\n" + "for i in 1 2 3 4 5 6 7 8 9 10; do\n" + ' magisk --sqlite "REPLACE INTO policies (uid,policy,until,logging,notification) VALUES(2000,2,0,0,0)" && break\n' + " sleep 2\n" + "done\n" +) + def _databin_mode(basename: str) -> str: """debugfs ``sif mode`` for a DATABIN file: scripts/binaries are 0755, the @@ -261,6 +281,35 @@ def _databin_extra_commands(extras: dict[str, str]) -> list[str]: return cmds +def _grant_script_tempfile() -> str: + """Write the service.d auto-grant script to a temp file (LF endings for the + guest shell) and return its path. The caller removes it after staging.""" + fd, path = tempfile.mkstemp(suffix="-" + _ADB_GRANT_SCRIPT) + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + f.write(_ADB_GRANT_BODY) + return path + + +def _service_d_grant_commands(script_hostpath: str, dir_exists: bool) -> list[str]: + """Pure debugfs commands to install the ADB-shell auto-grant script into + ``/data/adb/service.d`` root-owned + executable (0755). Creates service.d + first on a fresh (never-booted-with-Magisk) instance that lacks it. ``write`` + won't overwrite, so a leftover copy is ``rm``'d first (a harmless "not found" + when absent, same pattern as the bootanim.rc.gz rewrite).""" + dst = "%s/%s" % (_SERVICE_D, _ADB_GRANT_SCRIPT) + cmds: list[str] = [] + if not dir_exists: + cmds += ["mkdir %s" % _SERVICE_D, "sif %s mode 040700" % _SERVICE_D, + "sif %s uid 0" % _SERVICE_D, "sif %s gid 0" % _SERVICE_D, + "ea_set %s security.selinux %s" % (_SERVICE_D, _SELINUX_CTX)] + cmds += ["cd %s" % _SERVICE_D, + "rm %s" % _ADB_GRANT_SCRIPT, # write won't overwrite; harmless if absent + "write %s %s" % (_dq(_cygpath(script_hostpath)), _ADB_GRANT_SCRIPT), # quoted src, bare dest + "sif %s mode 0100755" % dst, "sif %s uid 0" % dst, "sif %s gid 0" % dst, + "ea_set %s security.selinux %s" % (dst, _SELINUX_CTX)] + return cmds + + def _stat_is_regular_root(st: str, want_mode: str) -> bool: """True if a debugfs stat shows a regular file, root-owned, at ``want_mode`` (e.g. ``"0755"``).""" @@ -375,33 +424,46 @@ def _p(msg: str) -> None: env = _es._tool_env() total = len(tools) + len(extras) + grant_script = _grant_script_tempfile() # service.d ADB auto-grant; removed below _p("Attaching Data.vhdx (staging Magisk binaries)...") - with _es._Attached(vhdx) as att: - dev = att.device - _p("Writing %d DATABIN files into %s..." % (total, _DATABIN)) - script = (_clean_dir_commands(dev, _DATABIN, env) - + _write_commands(tools) + _databin_extra_commands(extras)) - out = _es._run_script(dev, script, env) - try: - bad = _verify_staged(dev, tools, env, extras) - if bad: - raise RuntimeError( - "staging incomplete -- not correctly written: %s (debugfs: %s)" - % (", ".join(sorted(bad)), _errtail(out))) - _p("Verifying filesystem (e2fsck)...") - if not _es._fsck_ok(dev, env): - raise RuntimeError("e2fsck reported errors after staging") - except Exception: - _p("Staging failed -- rolling back /data/adb/magisk...") + try: + with _es._Attached(vhdx) as att: + dev = att.device + _p("Writing %d DATABIN files into %s..." % (total, _DATABIN)) + svc_exists = "Inode:" in _es._stat_path(dev, _SERVICE_D, env) + script = (_clean_dir_commands(dev, _DATABIN, env) + + _write_commands(tools) + _databin_extra_commands(extras) + + _service_d_grant_commands(grant_script, svc_exists)) + out = _es._run_script(dev, script, env) try: - _es._run_script(dev, _clean_dir_commands(dev, _DATABIN, env), env) + bad = _verify_staged(dev, tools, env, extras) + grant_st = _es._stat_path(dev, "%s/%s" % (_SERVICE_D, _ADB_GRANT_SCRIPT), env) + if not _stat_is_regular_root(grant_st, "0755"): + bad.append("service.d/%s" % _ADB_GRANT_SCRIPT) + if bad: + raise RuntimeError( + "staging incomplete -- not correctly written: %s (debugfs: %s)" + % (", ".join(sorted(bad)), _errtail(out))) + _p("Verifying filesystem (e2fsck)...") + if not _es._fsck_ok(dev, env): + raise RuntimeError("e2fsck reported errors after staging") except Exception: - logger.exception("rollback cleanup also failed") - raise + _p("Staging failed -- rolling back /data/adb/magisk...") + try: + _es._run_script(dev, _clean_dir_commands(dev, _DATABIN, env) + + ["rm %s/%s" % (_SERVICE_D, _ADB_GRANT_SCRIPT)], env) + except Exception: + logger.exception("rollback cleanup also failed") + raise + finally: + try: + os.unlink(grant_script) + except OSError: + pass _write_manifest(instance_dir, ["databin"]) gate = "busybox + util_functions.sh" if "util_functions.sh" in extras else "busybox gate" - return ["Staged %d DATABIN files into /data/adb/magisk (all verified, %s OK)" - % (total, gate)] + return ["Staged %d DATABIN files into /data/adb/magisk (+ ADB auto-grant, all " + "verified, %s OK)" % (total, gate)] def unstage_databin(instance_dir: str, progress=None) -> list[str]: @@ -418,7 +480,8 @@ def _p(msg: str) -> None: _p("Attaching Data.vhdx (removing Magisk binaries)...") with _es._Attached(vhdx) as att: dev = att.device - _es._run_script(dev, _clean_dir_commands(dev, _DATABIN, env), env) # enumerate-based removal + _es._run_script(dev, _clean_dir_commands(dev, _DATABIN, env) + + ["rm %s/%s" % (_SERVICE_D, _ADB_GRANT_SCRIPT)], env) # DATABIN + auto-grant if "Inode:" in _es._stat_path(dev, _DATABIN, env): raise RuntimeError("failed to remove %s" % _DATABIN) if not _es._fsck_ok(dev, env): diff --git a/tests/test_magisk_system.py b/tests/test_magisk_system.py index 65e26f1..42729c6 100644 --- a/tests/test_magisk_system.py +++ b/tests/test_magisk_system.py @@ -144,6 +144,44 @@ def test_databin_extra_commands_writes_scripts_and_chromeos_subdir(): assert "sif %s/kernel.keyblock mode 0100644" % cs in cmds # key -> data +def test_service_d_grant_commands_creates_dir_when_absent(): + cmds = ms._service_d_grant_commands(r"C:\w\00-bsrgui-adbgrant.sh", dir_exists=False) + sd = ms._SERVICE_D + dst = "%s/%s" % (sd, ms._ADB_GRANT_SCRIPT) + # fresh instance: service.d is created 0700 root before the script write + assert "mkdir %s" % sd in cmds + assert "sif %s mode 040700" % sd in cmds + assert cmds.index("mkdir %s" % sd) < cmds.index("cd %s" % sd) + # rm-before-write (write won't overwrite), quoted src + bare dest, 0755 root + assert cmds.index("rm %s" % ms._ADB_GRANT_SCRIPT) < cmds.index( + 'write "/cygdrive/c/w/00-bsrgui-adbgrant.sh" %s' % ms._ADB_GRANT_SCRIPT) + assert "sif %s mode 0100755" % dst in cmds + assert "sif %s uid 0" % dst in cmds and "sif %s gid 0" % dst in cmds + assert "ea_set %s security.selinux %s" % (dst, ms._SELINUX_CTX) in cmds + + +def test_service_d_grant_commands_skips_mkdir_when_present(): + cmds = ms._service_d_grant_commands(r"C:\w\00-bsrgui-adbgrant.sh", dir_exists=True) + # booted-once instance already has service.d: don't recreate it + assert not any(c.startswith("mkdir ") for c in cmds) + assert "cd %s" % ms._SERVICE_D in cmds + assert any(c.startswith("write ") and c.endswith(ms._ADB_GRANT_SCRIPT) for c in cmds) + + +def test_grant_script_tempfile_is_lf_and_grants_shell(): + path = ms._grant_script_tempfile() + try: + raw = open(path, "rb").read() + assert b"\r\n" not in raw, "guest sh needs LF endings, not CRLF" + assert raw.startswith(b"#!/system/bin/sh") + text = raw.decode("utf-8") + # force-allow (policy 2) the shell uid (2000), forever (until 0) + assert "REPLACE INTO policies" in text + assert "VALUES(2000,2,0,0,0)" in text + finally: + os.unlink(path) + + def test_verify_staged_checks_extras_mode_and_owner(monkeypatch): stats = { "busybox": "Inode: 5 Type: regular Mode: 0755\nUser: 0 Group: 0",