From b4ec86bec0b99007c463451c25f7906f42f25cd0 Mon Sep 17 00:00:00 2001 From: Kasper Junge Date: Sun, 11 Oct 2026 08:25:16 +0200 Subject: [PATCH] Bundle backend wheels instead of editable CI source paths --- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/README.md | 2 +- desktop/VERSION | 2 +- .../native/Sources/UmbodCore/Backend.swift | 2 +- desktop/scripts/bundle-python.sh | 4 +- desktop/scripts/install-smoke.py | 1 + desktop/scripts/ui-smoke.sh | 2 +- desktop/scripts/verify-python-bundle.py | 45 +++++++++++++++++++ desktop/tests/test_python_bundle.py | 37 +++++++++++++++ 10 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 desktop/scripts/verify-python-bundle.py create mode 100644 desktop/tests/test_python_bundle.py diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 498421f..90f2823 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2356,7 +2356,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "umbod-gateway" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "axum", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index ee21321..0141d56 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "umbod-gateway" -version = "0.2.0" +version = "0.2.1" edition = "2024" [lib] diff --git a/desktop/README.md b/desktop/README.md index 3adfa6a..c69c654 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -61,4 +61,4 @@ OAuth supports native public clients with loopback callbacks, discovery, PKCE an The [upstream Windows run](https://github.com/kasperjunge/umbod-desktop/actions/runs/37907078176) is historical source evidence supplied for this import. It is **not** a CI result for this target repository or these integration changes. -Release packaging collects dependency inventories and notices through `scripts/dependency-notices.py`; pinned supplemental sources are documented in `licenses/README.md`. `scripts/install-smoke.py` installs the Mac DMG into disposable storage and verifies the packaged connector/agent flow, native window lifecycle, and signature integrity before and after execution. It never uses the login Keychain. +Release packaging collects dependency inventories and notices through `scripts/dependency-notices.py`; pinned supplemental sources are documented in `licenses/README.md`. `scripts/verify-python-bundle.py` rejects editable installs and imports outside the bundled interpreter before signing and again after installation. `scripts/install-smoke.py` installs the Mac DMG into disposable storage and verifies the packaged connector/agent flow, native window lifecycle, and signature integrity before and after execution. It never uses the login Keychain. diff --git a/desktop/VERSION b/desktop/VERSION index 0ea3a94..0c62199 100644 --- a/desktop/VERSION +++ b/desktop/VERSION @@ -1 +1 @@ -0.2.0 +0.2.1 diff --git a/desktop/native/Sources/UmbodCore/Backend.swift b/desktop/native/Sources/UmbodCore/Backend.swift index d5652b8..dedab51 100644 --- a/desktop/native/Sources/UmbodCore/Backend.swift +++ b/desktop/native/Sources/UmbodCore/Backend.swift @@ -88,7 +88,7 @@ import Darwin guard let ready = startupResult, let management = ready["management"] as? String, let gateway = ready["gateway"] as? String, Self.isLoopbackAddress(management), Self.isLoopbackAddress(gateway) else { - if child.isRunning { child.terminate() }; stop(); throw ContractError.message("Gateway could not start. Check Keychain access and configuration, then retry.") + if child.isRunning { child.terminate() }; stop(); throw ContractError.message("The bundled backend could not start. Restart it in Settings; if this continues, reinstall the latest Umbod release.") } guard let startupToken = ready["admin_token"] as? String else { throw ContractError.message("Missing backend authentication") } token = startupToken diff --git a/desktop/scripts/bundle-python.sh b/desktop/scripts/bundle-python.sh index 3818671..85b93fb 100755 --- a/desktop/scripts/bundle-python.sh +++ b/desktop/scripts/bundle-python.sh @@ -12,8 +12,10 @@ printf '%s %s\n' '1a98bbe7e63547d2f764bc9119f6d604dfb333f21afab5f4885dc362b4f3a tar -xzf "$ARCHIVE" -C "$DEST" printf '20260310\n' > "$DEST/python/BUILD" uv export --quiet --project "$ROOT/desktop/python" --frozen --no-dev --no-editable --no-hashes --output-file "$DEST/requirements.txt" -(cd "$ROOT/desktop/python" && uv pip install --python "$DEST/python/bin/python3" --reinstall-package umbod-desktop --reinstall-package umbod-core --reinstall-package umbod --break-system-packages --requirements "$DEST/requirements.txt") +# Export covers the full lock; development tool.uv.sources must not restore editable installs. +(cd "$ROOT/desktop/python" && uv pip install --python "$DEST/python/bin/python3" --reinstall-package umbod-desktop --reinstall-package umbod-core --reinstall-package umbod --break-system-packages --no-editable --no-sources --requirements "$DEST/requirements.txt") rm "$DEST/requirements.txt" +"$DEST/python/bin/python3" -I -B "$ROOT/desktop/scripts/verify-python-bundle.py" "$DEST/python/bin/python3" -I -B "$ROOT/desktop/scripts/dependency-notices.py" python "$DEST" # Compile before signing; runtime must not mutate the signed bundle. "$DEST/python/bin/python3" -m compileall -q -s "$DEST/python" -p /umbod-runtime "$DEST/python/lib" diff --git a/desktop/scripts/install-smoke.py b/desktop/scripts/install-smoke.py index f48e9df..9686d51 100644 --- a/desktop/scripts/install-smoke.py +++ b/desktop/scripts/install-smoke.py @@ -21,6 +21,7 @@ def main(): finally: subprocess.run(["hdiutil", "detach", str(mount)], check=True) subprocess.run(["codesign", "--verify", "--deep", "--strict", str(app)], check=True) + subprocess.run([str(app / "Contents/Resources/python/bin/python3"), "-I", "-B", str(desktop / "scripts/verify-python-bundle.py")], check=True) environment = {**os.environ, "UMBOD_TEST_APP": str(app)} # Swift drives the tests; every backend and agent process uses the installed interpreter. subprocess.run(["swift", "run", "--package-path", str(desktop / "native"), "UmbodTests"], env=environment, check=True) diff --git a/desktop/scripts/ui-smoke.sh b/desktop/scripts/ui-smoke.sh index fa81923..61f41ae 100755 --- a/desktop/scripts/ui-smoke.sh +++ b/desktop/scripts/ui-smoke.sh @@ -4,5 +4,5 @@ APP="${1:-dist/Umbod.app}" test -x "$APP/Contents/MacOS/Umbod" test -x "$APP/Contents/MacOS/umbod-desktop" test -x "$APP/Contents/Resources/python/bin/python3" -env -i PATH=/usr/bin:/bin "$APP/Contents/Resources/python/bin/python3" -I -B -c 'import umbod_desktop, umbod.rest.main, umbod.mcp.public_app' +env -i PATH=/usr/bin:/bin "$APP/Contents/Resources/python/bin/python3" -I -B "$(dirname "$0")/verify-python-bundle.py" /usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist" diff --git a/desktop/scripts/verify-python-bundle.py b/desktop/scripts/verify-python-bundle.py new file mode 100644 index 0000000..73e876f --- /dev/null +++ b/desktop/scripts/verify-python-bundle.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Run with the bundled Python (-I -B) to reject checkout-dependent builds.""" +import importlib +import importlib.metadata +import json +from pathlib import Path +import sys + + +def require_bundled(path, prefix): + if not Path(path).resolve().is_relative_to(prefix.resolve()): + raise ValueError(f"Python bundle references an external path: {path}") + + +def verify_installation(prefix, paths, distributions): + for path in paths: + require_bundled(path, prefix) + for distribution in distributions: + direct_url = distribution.read_text("direct_url.json") + if direct_url and json.loads(direct_url).get("dir_info", {}).get("editable"): + raise ValueError(f"Editable distribution in Python bundle: {distribution.metadata['Name']}") + # Nonexistent checkout paths are silently omitted from sys.path by site.py. + # Inspect them too, so a broken release fails even on a clean build machine. + for pth in prefix.glob("lib/python*/site-packages/*.pth"): + for line in pth.read_text().splitlines(): + line = line.strip() + if line and not line.startswith(("#", "import ", "import\t")): + require_bundled(pth.parent / line, prefix) + + +def main(): + prefix = Path(sys.prefix) + verify_installation(prefix, sys.path, importlib.metadata.distributions()) + # Exercise the real CLI import graph as well as REST/MCP entry points. + for name in ("umbod_desktop.main", "umbod.core", "umbod.rest.main", "umbod.mcp.public_app", "umbod_sdk"): + module = importlib.import_module(name) + if module.__file__: + require_bundled(module.__file__, prefix) + for path in getattr(module, "__path__", ()): + require_bundled(path, prefix) + print("PASS: bundled Python imports without editable or external source paths") + + +if __name__ == "__main__": + main() diff --git a/desktop/tests/test_python_bundle.py b/desktop/tests/test_python_bundle.py new file mode 100644 index 0000000..a6f7bb7 --- /dev/null +++ b/desktop/tests/test_python_bundle.py @@ -0,0 +1,37 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/verify-python-bundle.py" +spec = importlib.util.spec_from_file_location("verify_python_bundle", SCRIPT) +bundle = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bundle) + + +class PythonBundleTests(unittest.TestCase): + def test_accepts_bundled_paths(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + bundle.verify_installation(root, [root / "lib/python314.zip"], []) + + def test_rejects_checkout_on_import_path(self): + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(ValueError, "external path"): + bundle.verify_installation(Path(directory), [SCRIPT.parent], []) + + def test_rejects_editable_distribution_even_with_import_hook(self): + distribution = Mock(metadata={"Name": "umbod-core"}) + distribution.read_text.return_value = '{"dir_info": {"editable": true}}' + with self.assertRaisesRegex(ValueError, "Editable distribution"): + bundle.verify_installation(SCRIPT.parent, [], [distribution]) + + def test_rejects_pth_pointing_to_missing_ci_checkout(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + site = root / "lib/python3.14/site-packages" + site.mkdir(parents=True) + (site / "_umbod_core.pth").write_text("/Users/runner/work/umbod/umbod/umbod/api/src\n") + with self.assertRaisesRegex(ValueError, "external path"): + bundle.verify_installation(root, [], [])