From fc22c56a8bcd3f8c4831a54d8332643d803c66b5 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:09:06 +0000 Subject: [PATCH] Bundle the python tool's CPython in Linux packages The AppDir stages the hash-pinned x86_64 runtime into usr/share/maple-agent/python, where the app's package resolver looks, before the runtime audit is recorded. The audit gives its ELF files their own branch: the host loader and glibc libraries, $ORIGIN paths that stay inside the runtime, and the 2.31 glibc floor. It records them as `python_runtime_elves` (audit schema 4). The Docker check imports the interpreter's native modules on the stock Ubuntu images. Every prepared runtime now leaves out Tcl/Tk, tkinter, IDLE and turtledemo. The worker has no use for Tk GUIs, and Python Build Standalone's Linux Tcl/Tk libraries carry an absolute build-host RPATH (/tools/deps/lib) that the audit rightly refuses. The Nix package copies the locked nixpkgs python314, which matches the pinned 3.14.7, into share/maple-agent/python (#897's derivation). Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/docs/python-code-mode.md | 8 ++++ apps/maple-agent/flake.nix | 36 ++++++++++++++++++ .../scripts/linux-release-appimage.py | 14 +++++++ apps/maple-agent/scripts/prepare-python.py | 23 +++++++++++- .../scripts/test_prepare_python.py | 37 +++++++++++++++++++ 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/apps/maple-agent/docs/python-code-mode.md b/apps/maple-agent/docs/python-code-mode.md index c940ede89..8c18e5c3e 100644 --- a/apps/maple-agent/docs/python-code-mode.md +++ b/apps/maple-agent/docs/python-code-mode.md @@ -124,3 +124,11 @@ entitlements in `app/packaging/macos-python-entitlements.plist`: library validation is off for it, so native extensions of wheels the tool installs can load. `just debug-app` bundles it the same way with ad hoc signatures and runs the worker smoke from inside the bundle. + +Linux bundles stage it into `usr/share/maple-agent/python` when the AppDir is +built. The Nix package copies the locked nixpkgs `python314` (the same version +as the pin) into `share/maple-agent/python`. + +Every runtime leaves out Tcl/Tk, tkinter, IDLE and turtledemo: the worker has no +use for Tk GUIs, and Python Build Standalone's Linux Tcl/Tk libraries carry an +absolute build-host library path. diff --git a/apps/maple-agent/flake.nix b/apps/maple-agent/flake.nix index ee3814b4c..44fe0d42b 100644 --- a/apps/maple-agent/flake.nix +++ b/apps/maple-agent/flake.nix @@ -30,6 +30,34 @@ ]; forAllSystems = nixpkgs.lib.genAttrs supportedSystems; forPackageSystems = nixpkgs.lib.genAttrs packageSystems; + # The python tool's CPython for the Nix package. The store reference in + # runtime.json retains exactly this interpreter and its closure; the + # version must equal scripts/python-runtime.json's. + mkPythonRuntime = + pkgs: + let + python = pkgs.python314; + version = python.version; + manifest = { + protocol_version = 1; + implementation = "cpython"; + inherit version; + distribution = "nix-${python.name}-${builtins.baseNameOf (toString python)}"; + executable = "${python}/bin/python3.14"; + worker = "worker.py"; + }; + in + assert version == (builtins.fromJSON (builtins.readFile ./scripts/python-runtime.json)).version; + pkgs.runCommand "maple-python-${version}" { nativeBuildInputs = [ python ]; } '' + runtime="$out/share/maple-agent/python" + mkdir -p "$runtime/licenses" + cp ${./crates/maple-code-mode/python/worker.py} "$runtime/worker.py" + cat > "$runtime/runtime.json" <<'JSON' + ${builtins.toJSON manifest} + JSON + tar -xOf ${python.src} Python-${version}/LICENSE > "$runtime/licenses/Python-LICENSE.txt" + ${python}/bin/python3.14 -I -B -c 'import sys, sysconfig, ssl, sqlite3, ctypes, zlib, bz2, lzma; assert sys.version_info[:3] == tuple(map(int, "${version}".split("."))); assert not sysconfig.get_config_var("Py_GIL_DISABLED")' + ''; in { packages = forPackageSystems ( @@ -68,8 +96,10 @@ fontconfig freetype ] ++ linuxRuntimeInputs; + pythonRuntime = mkPythonRuntime pkgs; in { + python-runtime = pythonRuntime; default = rustPlatform.buildRustPackage { pname = "maple-agent"; version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version; @@ -134,6 +164,12 @@ # Keep the package derivation focused on producing the release binary. doCheck = false; + # The python tool finds its CPython beside the binary's bin/. + postInstall = '' + mkdir -p "$out/share/maple-agent" + cp -R ${pythonRuntime}/share/maple-agent/python "$out/share/maple-agent/python" + ''; + # GPUI loads the Wayland and Vulkan libraries at runtime, so they are # not discovered by ELF dependency scanning. Prefer the NixOS GPU # driver link and retain Mesa as a portable fallback elsewhere. diff --git a/apps/maple-agent/scripts/linux-release-appimage.py b/apps/maple-agent/scripts/linux-release-appimage.py index e710873c9..49dca76b5 100755 --- a/apps/maple-agent/scripts/linux-release-appimage.py +++ b/apps/maple-agent/scripts/linux-release-appimage.py @@ -20,6 +20,10 @@ LOADER = "ld-linux-x86-64.so.2" +# The python tool's CPython (scripts/prepare-python.py), found beside the +# payload by the app's package resolver. +PYTHON_RUNTIME = "usr/share/maple-agent/python" +PYTHON_TARGET = "x86_64-unknown-linux-gnu" # These ABI providers must come from the host, including when loading the # host's graphics drivers. Never copy a build machine's libc into the bundle. HOST_LIBRARIES = frozenset({ @@ -105,6 +109,15 @@ def needed(path): return run("patchelf", "--print-needed", path).stdout.splitlines() +def stage_python(appdir, component): + """Stage the python tool's CPython from its hash pin, copying hard links.""" + with tempfile.TemporaryDirectory(prefix="maple-python-", dir=appdir.parent) as temporary: + runtime = Path(temporary) / "python" + subprocess.run([sys.executable, str(component / "scripts/prepare-python.py"), "--distribution", "pbs", + "--target", PYTHON_TARGET, "--destination", str(runtime)], check=True) + shutil.copytree(runtime, appdir / PYTHON_RUNTIME, symlinks=True) + + def library_name(name): if not re.fullmatch(r"lib[A-Za-z0-9_+.\-]+\.so(?:\.[A-Za-z0-9_.\-]+)*", name): raise PackagingError(f"Invalid runtime library SONAME: {name}") @@ -360,6 +373,7 @@ def main(): appdir.mkdir() stage_payload(appdir, binary) stage_metadata(appdir, component, metadata) + stage_python(appdir, component) normalize_public_permissions(appdir) for path in appdir.rglob("*"): os.utime(path, (epoch, epoch), follow_symlinks=False) diff --git a/apps/maple-agent/scripts/prepare-python.py b/apps/maple-agent/scripts/prepare-python.py index 00b30f390..7ef8f7e0f 100644 --- a/apps/maple-agent/scripts/prepare-python.py +++ b/apps/maple-agent/scripts/prepare-python.py @@ -18,6 +18,16 @@ PINS = Path(__file__).with_name("python-runtime.json") RECEIPT = ".prepared.json" LICENSES = Path(__file__).with_name("python-licenses") +# The worker has no use for Tk GUIs, and Python Build Standalone's Linux Tcl/Tk +# libraries carry an absolute build-host RPATH (/tools/deps/lib). Every bundle +# leaves Tcl/Tk, tkinter, IDLE and turtledemo out. +PRUNED = ( + "bin/idle3*", "lib/libtcl9*", "lib/tcl9*", "lib/tk9*", "lib/itcl*", "lib/thread3*", + "lib/python3.*/tkinter", "lib/python3.*/idlelib", "lib/python3.*/turtledemo", + "lib/python3.*/lib-dynload/_tkinter.*", + "tcl", "Lib/tkinter", "Lib/idlelib", "Lib/turtledemo", "DLLs/_tkinter.pyd", "DLLs/tcl*.dll", + "DLLs/tk*.dll", "Scripts/idle*", +) def digest(path): @@ -122,6 +132,16 @@ def safe_extract(archive, destination): return destination / "python" +def prune(runtime): + """Remove PRUNED paths; symlinks are removed, never followed.""" + for pattern in PRUNED: + for path in sorted(runtime.glob(pattern)): + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + + def inventory(root): files = {} for path in sorted(root.rglob("*")): @@ -179,7 +199,7 @@ def prepare_pbs(target, destination, cache, worker, offline=False): "executable": asset["executable"], "worker": "worker.py", } - identity = {"manifest": manifest, "archive_sha256": asset["sha256"], "worker_sha256": digest(worker), "licenses": inventory(LICENSES)} + identity = {"manifest": manifest, "archive_sha256": asset["sha256"], "worker_sha256": digest(worker), "licenses": inventory(LICENSES), "pruned": list(PRUNED)} destination.parent.mkdir(parents=True, exist_ok=True) with preparation_lock(destination.with_name(destination.name + ".lock")): if reusable(destination, identity): @@ -188,6 +208,7 @@ def prepare_pbs(target, destination, cache, worker, offline=False): archive = download(asset, cache, offline) with tempfile.TemporaryDirectory(prefix=".python-stage-", dir=destination.parent) as temporary: runtime = safe_extract(archive, Path(temporary)) + prune(runtime) if not (runtime / asset["executable"]).is_file(): raise ValueError(f"Python archive lacks {asset['executable']}") if not list(runtime.rglob("*LICENSE*")) and not list(runtime.rglob("*license*")): diff --git a/apps/maple-agent/scripts/test_prepare_python.py b/apps/maple-agent/scripts/test_prepare_python.py index 1bc2cdfae..c60c95226 100644 --- a/apps/maple-agent/scripts/test_prepare_python.py +++ b/apps/maple-agent/scripts/test_prepare_python.py @@ -95,6 +95,43 @@ def test_verified_cache_and_stage_reused_corruption_repaired_offline(self): prepare.prepare_pbs("test", destination, cache, worker, True) self.assertEqual((destination / "worker.py").read_text(), "updated worker") + def test_tk_and_idle_are_left_out_of_every_runtime(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = self.archive(root, [ + ("python/bin/python3.14", "binary", False), + ("python/bin/idle3.14", "idle", False), + ("python/bin/idle3", "idle3.14", True), + ("python/LICENSE.txt", "license", False), + ("python/lib/libtcl9.0.so", "tcl", False), + ("python/lib/tcl9.0/init.tcl", "tcl", False), + ("python/lib/tk9.0/tk.tcl", "tk", False), + ("python/lib/python3.14/tkinter/__init__.py", "tkinter", False), + ("python/lib/python3.14/idlelib/idle.py", "idle", False), + ("python/lib/python3.14/lib-dynload/_tkinter.cpython-314-x86_64-linux-gnu.so", "ext", False), + ("python/lib/python3.14/lib-dynload/_ssl.cpython-314-x86_64-linux-gnu.so", "ssl", False), + ("python/lib/python3.14/json/__init__.py", "json", False), + ]) + archive_hash = hashlib.sha256(archive.read_bytes()).hexdigest() + cache = root / "cache" + cache.mkdir() + archive.rename(cache / f"{archive_hash}.tar.gz") + worker = root / "worker.py" + worker.write_text("worker") + pins = root / "pins.json" + pins.write_text(json.dumps({"implementation": "cpython", "version": "3.14.7", "release": "20260929", "targets": {"test": {"sha256": archive_hash, "executable": "bin/python3.14", "url": "unused"}}})) + destination = root / "runtime" + with patch.object(prepare, "PINS", pins), patch.object(prepare.urllib.request, "urlopen", side_effect=AssertionError("network forbidden")): + prepare.prepare_pbs("test", destination, cache, worker, True) + kept = sorted(path.relative_to(destination).as_posix() for path in destination.rglob("*") + if path.is_file() and not path.name.startswith(".")) + self.assertIn("lib/python3.14/lib-dynload/_ssl.cpython-314-x86_64-linux-gnu.so", kept) + self.assertIn("lib/python3.14/json/__init__.py", kept) + # The pinned upstream notice set stays complete, including Tcl's. + for name in (name for name in kept if not name.startswith("licenses/")): + self.assertNotRegex(name, r"tcl|tk9|tkinter|idle") + self.assertFalse((destination / "bin/idle3").is_symlink()) + def test_failed_publication_restores_previous_tree(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary)