From 42b562fcf312ddceb78438b5daea49d4c9b465c8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 9 Oct 2026 18:19:06 +0200 Subject: feat(packaging): the Store package declares what the NSIS scripts do A test-signed install of the MSIX build, in the WindowsApps folder a Store install uses, showed that every script-made piece of the NSIS model breaks there, because each names the install folder and every update deletes it: the firewall rules went stale, the PATH entries piled up pointing at deleted folders, and the Startup-folder .vbs was refused ("Permission denied") right after sign-in. The network capabilities the manifest declared covered nothing: they make rules for sandboxed apps only, and a listener in the package still got the Windows firewall prompt. The package's own startup task was on by default, started the node whatever mode the Node page said, and ran the console executable, whose window stopped the node when closed. The package now declares what Windows then creates at install, carries across updates and removes with the app, all without an administrator prompt (each measured on the real install, through an update and a reboot): - firewall rules for the node, in a custom manifest template, since only a package-level element can hold them; - the startup task, off by default, running meshbay-nodew.exe, a new build of the daemon without a console; - an execution alias for meshbay-node.exe, so the app adds no PATH entry. The node's CLI switches the startup task (platform.startup_task, ctypes over the WinRT ABI): Windows gives the package's identity to the executables in it, not to a powershell.exe the app starts, which got "Element not found". `meshbay-node autostart install | remove | status` therefore works in the Store package from the app and a terminal alike; the app caches the answer, since the Node page polls. Starting at boot stays the .exe installer's: the Store package offers no service mode, and the CLI refuses `service install` there. Process listings count both image names. The Node page's status poll cleared the message of a refused action within five seconds; the two errors are kept apart now (all Windows builds). Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-node/tests/test_packaging_win.py | 220 ++++++++++++++------- packages/meshbay-node/tests/test_platform.py | 2 +- .../tests/test_windows_node_lifecycle.py | 106 +++++++++- 3 files changed, 258 insertions(+), 70 deletions(-) (limited to 'packages/meshbay-node/tests') diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index b5f6191..6f6388e 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -1263,35 +1263,41 @@ def test_create_group_page_falls_back_when_no_node_is_bundled(): # ------------------------------------------------------------------------ -# The "MSIX" target: same feature set as Full, packaged for Microsoft Store -# submission instead of NSIS. Unlike Light, this target keeps the node -# runtime and both service scripts -- what changes is packaging format, not -# what ships. +# The "MSIX" target: the bundled node, packaged for Microsoft Store +# submission instead of NSIS. What the NSIS build does with scripts (firewall +# rules, a sign-in launcher, a PATH entry) this package declares in its +# manifest, because everything a script writes names the versioned install +# folder each Store update deletes. At boot is the .exe installer's alone. # Weak, text-reading evidence throughout, same reasoning as the rest of # this file: there is no electron-builder/appx runner here either. # ------------------------------------------------------------------------ -def test_msix_config_is_standalone_and_keeps_the_full_bundle(): +def _yml_from_entries(yml: str) -> list[str]: + """The `from:` of every extraResources entry: directives, not the prose + around them, which names the very scripts it explains are absent.""" + return [ln.split("from:", 1)[1].strip() for ln in yml.splitlines() + if ln.strip().startswith("- from:")] + + +def test_msix_config_is_standalone_and_ships_the_node_not_the_scripts(): """ - Unlike Light, MSIX ships the same node-runtime/ffmpeg/service scripts as - Full -- an AppX install never elevating is not a reason to drop the - daemon, only to change how its two elevated operations get triggered - (see the two tests below). --config still - means this file is read alone (app-builder-lib's getConfig), so it - cannot silently inherit Full's package.json build.nsis or any signing - config meant for NSIS. + --config means this file is read alone (app-builder-lib's getConfig), so + it cannot silently inherit Full's package.json build.nsis or any signing + config meant for NSIS. It ships the node runtime and none of the scripts + whose output names the install folder: on a real Store install each of + them was stale after the next update. """ assert MSIX_YML.exists(), f"{MSIX_YML} is missing" yml = MSIX_YML.read_text(encoding="utf-8") - assert "appId: org.meshbay.client" in yml assert "target: appx" in yml assert "output: dist-msix" in yml - assert "node-runtime" in yml - assert "service.ps1" in yml - assert "service-mode.ps1" in yml - assert "firewall.ps1" in yml + sources = _yml_from_entries(yml) + assert "node-runtime" in sources + for script in ("firewall.ps1", "service.ps1", "service-mode.ps1", "ensure-node-path.ps1"): + assert not any(src.endswith(script) for src in sources), ( + f"{script} must not ship in the Store package (see this test's docstring)") def test_msix_identity_matches_the_partner_center_reservation(): @@ -1333,51 +1339,100 @@ def test_msix_declares_no_csc_on_purpose(): assert forbidden not in yml -def test_msix_declares_the_network_capabilities_firewall_ps1_would_add(): +MSIX_MANIFEST_XML = CLIENT / "build" / "appx-manifest.xml" + + +def test_msix_declares_the_firewall_rules_of_both_node_executables(): """ - Matches firewall.ps1's own rules, which are `-Profile Any` (private AND - public network). Whether Windows actually auto-exempts a full-trust - packaged app on the strength of these declarations is unverified until - sideloaded, but the declaration itself must at least match what the - elevated NSIS path grants today, or - an MSIX install would be silently narrower than Full/Light. + Declared rules are created at install without an administrator prompt, + follow the executable's versioned path on every update and go with the + package (all three measured on a real install). The capabilities + internetClientServer / privateNetworkClientServer were here before and + covered nothing: they make rules for sandboxed apps, which a full-trust + process is not, and a listener got the Windows prompt regardless. """ yml = MSIX_YML.read_text(encoding="utf-8") - caps_block = yml.split("capabilities:", 1)[1].split("customExtensionsPath", 1)[0] - assert "internetClientServer" in caps_block - assert "privateNetworkClientServer" in caps_block + assert "customManifestPath: build/appx-manifest.xml" in yml + assert MSIX_MANIFEST_XML.exists(), f"{MSIX_MANIFEST_XML} is missing" + xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8") + package_level = xml.split("", 1)[1] + for exe in ("meshbay-node.exe", "meshbay-nodew.exe"): + block = package_level.split(f'Executable="app\\resources\\node-runtime\\{exe}"', 1) + assert len(block) == 2, f"no firewall rules for {exe}" + rules = block[1].split("", 1)[0] + for proto in ("TCP", "UDP"): + assert f'Direction="in" IPProtocol="{proto}" Profile="all"' in rules, (exe, proto) + assert 'Category="windows.firewallRules"' in package_level + assert 'xmlns:desktop2="http://schemas.microsoft.com/appx/manifest/desktop/windows10/2"' in xml + + +def test_msix_manifest_keeps_every_macro_of_electron_builders_template(): + """AppxTarget.js fills the custom template the way it fills its own. One + dropped from ours would leave a field electron-builder computes (identity, + version, languages) out; one it does not know fails the build, comments + included ("Macro macros is not defined", from a comment that said so).""" + xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8") + stock = CLIENT / "node_modules" / "app-builder-lib" / "templates" / "appx" / "appxmanifest.xml" + if not stock.exists(): + pytest.skip("electron-builder is not installed (npm ci in packages/meshbay-client)") + macros = set(re.findall(r"\$\{([a-zA-Z0-9]+)\}", stock.read_text(encoding="utf-8"))) + assert macros == set(re.findall(r"\$\{([a-zA-Z0-9]+)\}", xml)) + +def test_msix_manifest_fragments_are_valid_xml_comments_included(): + """makeappx refuses a manifest with "--" inside a comment ('>' expected), + and only at the very end of a build.""" + for path in (MSIX_MANIFEST_XML, MSIX_EXTENSIONS_XML): + for comment in re.findall(r"", path.read_text(encoding="utf-8"), re.S): + assert "--" not in comment, (path.name, comment[:60]) -def test_msix_startup_task_targets_the_node_not_the_electron_shell(): + +def test_msix_startup_task_is_the_windowless_node_and_off_by_default(): """ - addAutoLaunchExtension's built-in windows.startupTask (app-builder-lib's - AppxTarget.js) always targets the package's own main executable -- the - Electron shell -- which is not what "starts at sign in" means today - (main.js's WIN_STARTUP_VBS launches meshbay-node.exe directly, keeping - the daemon running whether or not the UI is ever opened). This config - must NOT use addAutoLaunchExtension for that reason, and must instead - supply its own extension via customExtensionsPath pointing at the node - binary's in-package path -- app\\resources\\node-runtime\\meshbay-node.exe, - derived from AppxTarget.js's own `"app\\\\" + appOutDir-relative path` - mapping (build() in that file), which is not the same prefix - process.resourcesPath resolves to at runtime and easy to get wrong. + Windows runs a startup task's executable as is: the console build opened + a window whose close button stopped the node, so the task runs + meshbay-nodew.exe. Off by default -- it was on, and started the node at + every sign-in whatever mode the Node page said; the app switches it now. + Not addAutoLaunchExtension, which always starts the Electron shell. """ yml = MSIX_YML.read_text(encoding="utf-8") - # The comment explaining *why* addAutoLaunchExtension is not used - # necessarily names it -- check the directive, not the prose (the same - # "parse directives, not text" mistake CLAUDE.md's engineering lessons - # already record for a differently-shaped bug). lines = [ln.strip() for ln in yml.splitlines()] - assert not any(ln.startswith("addAutoLaunchExtension:") for ln in lines), ( - "must not set addAutoLaunchExtension -- it always targets the " - "Electron shell, not the node binary (see this test's docstring)") + assert not any(ln.startswith("addAutoLaunchExtension:") for ln in lines) assert "customExtensionsPath: build/appx-extensions.xml" in yml + ext = MSIX_EXTENSIONS_XML.read_text(encoding="utf-8") + task = ext.split('Category="windows.startupTask"', 1)[1].split("", 1)[0] + assert 'Executable="app\\resources\\node-runtime\\meshbay-nodew.exe"' in task + assert 'TaskId="MeshBayNodeStartup"' in task and 'Enabled="false"' in task + # The id the CLI asks Windows for. + from meshbay_node import platform as plat + assert 'TaskId="' + plat.STARTUP_TASK_ID + '"' in task + - assert MSIX_EXTENSIONS_XML.exists(), f"{MSIX_EXTENSIONS_XML} is missing" +def test_msix_gives_the_cli_an_execution_alias_and_the_windows_it_needs(): + """`meshbay-node` in a terminal: an alias keeps one path across versions, + where the PATH entry the app used to add named the install folder. + Aliases need Windows 10 1709; the declared minimum is 1809.""" ext = MSIX_EXTENSIONS_XML.read_text(encoding="utf-8") - assert 'Category="windows.startupTask"' in ext - assert 'Executable="app\\resources\\node-runtime\\meshbay-node.exe"' in ext - assert 'EntryPoint="Windows.FullTrustApplication"' in ext + alias = ext.split('Category="windows.appExecutionAlias"', 1)[1] + assert 'Executable="app\\resources\\node-runtime\\meshbay-node.exe"' in alias + assert 'Alias="meshbay-node.exe"' in alias + yml = MSIX_YML.read_text(encoding="utf-8") + assert "minVersion: 10.0.17763.0" in yml + xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8") + assert 'xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"' in xml + + +def test_the_node_runtime_has_a_windowless_daemon_beside_the_cli(): + spec = (WIN / "meshbay-node.spec").read_text(encoding="utf-8") + windowless = spec.split('name="meshbay-nodew"', 1) + assert len(windowless) == 2, "meshbay-node.spec must build meshbay-nodew.exe" + assert "console=False" in windowless[1].split(")", 1)[0] + coll = spec.split("COLLECT(", 1)[1].split(")", 1)[0] + assert "exe_windowless" in coll, "both executables share one _internal/" + assert '"meshbay-nodew.exe"' in (WIN / "build-node-runtime.ps1").read_text(encoding="utf-8") + # Started with no stdio at all, it must not die on a library's write. + entry = (WIN / "node-entry.py").read_text(encoding="utf-8") + assert "os.devnull" in entry and entry.index("os.devnull") < entry.index("import main") def test_msix_ships_the_four_required_tile_images(): @@ -1430,21 +1485,49 @@ def test_build_win_msix_points_electron_builder_at_the_system_sdk(): assert "makeappx.exe" in src -# ── main.js needs nothing new for this target ────────────────────────────── -# -# Unlike Light (which needed hasBundledNode/winCanElevateServiceMode/the -# create-group gate because the bundle itself is smaller), MSIX ships -# everything Full does, and the two elevation paths it cannot get from an -# installer already exist independently of installer.nsh: -# firewall.ps1's per-first-use Windows prompt (no admin needed for that -# fallback -- see firewall.ps1's own header) and main.js's -# winElevateServiceMode(), driven from the Node page ("the other door", -# already exercised by test_can_elevate_checks_service_mode_ps1_actually_ -# exists above) rather than from setup. There is deliberately no -# MSIX-specific test here pinning main.js: the Light-target tests above -# already pin that winCanElevateServiceMode() checks for service-mode.ps1's -# presence generically, which is exactly what makes it work for a third -# packaged target without being told about it. +# ── main.js in the Store package ────────────────────────────────────────── + +def test_main_js_switches_the_startup_task_in_the_store_package(): + """In the Store package "at sign-in" is the package's startup task, read + and switched through the node's CLI, which has the package's identity + where a powershell.exe the app started had none ("Element not found"). + Never the Startup-folder .vbs, whose path every update deletes.""" + src = MAIN_JS.read_text(encoding="utf-8") + assert "const WIN_STORE = process.platform === 'win32' && Boolean(process.windowsStore);" in src + task_fn = _fn_body(src, "async function winStartupTask(sub)") + assert "winNodeCli(['autostart', sub])" in task_fn + assert "powershell" not in task_fn.lower() + assert "STARTUP_TASK_TTL_MS" in _fn_body(src, "async function winSigninEnabled()"), ( + "the Node page polls; each uncached answer is a process") + signin = _fn_body(src, "async function winSigninEnabled()") + assert "if (!WIN_STORE) return winAutostartInstalled();" in signin + assert "winStartupTask('status')" in signin + assert "await winSigninEnabled()" in _fn_body(src, "async function winStartupMode()") + handler = src.split("handle('node:autostart'", 1)[1].split("\n });\n", 1)[0] + assert "winStartupTask('install')" in handler and "winStartupTask('remove')" in handler + # Every reader of the sign-in state asks the same function. + callers = [ln for ln in src.splitlines() if "winAutostartInstalled()" in ln + and not ln.strip().startswith("//") and "function winAutostartInstalled" not in ln] + assert len(callers) == 1, callers + + +def test_main_js_offers_no_service_mode_and_adds_no_path_entry_in_the_store_package(): + src = MAIN_JS.read_text(encoding="utf-8") + assert "!WIN_STORE" in _fn_body(src, "function winCanElevateServiceMode()") + status = _fn_body(src, "async function nodeServiceStatus()") + assert "store: WIN_STORE" in status + path_fn = src.split("function winEnsureNodeOnPath()", 1)[1].split("\n }", 1)[0] + assert "if (WIN_STORE) return;" in path_fn + page = (HUB_STATIC / "node-page.js").read_text(encoding="utf-8") + assert "info.store" in page and "node.startup_mode_service_store_hint" in page + + +def test_main_js_counts_the_windowless_daemon_as_a_node(): + """A node started at sign-in by the startup task is meshbay-nodew.exe: + Stop, the status and Quit must see it as they see meshbay-node.exe.""" + src = MAIN_JS.read_text(encoding="utf-8") + pids = _fn_body(src, "function winNodePids()") + assert "'IMAGENAME eq meshbay-node*'" in pids # ------------------------------------------------------------------------ @@ -1479,13 +1562,14 @@ def test_ensure_node_path_script_is_idempotent_and_unelevated(): "installer.nsh's own SendMessage") -def test_ensure_node_path_shipped_to_full_and_msix_not_light(): +def test_ensure_node_path_shipped_to_full_only(): pkg = _pkg() full_yml = json.dumps(pkg["build"]) assert "ensure-node-path.ps1" in full_yml - msix_yml = MSIX_YML.read_text(encoding="utf-8") - assert "ensure-node-path.ps1" in msix_yml + msix_sources = _yml_from_entries(MSIX_YML.read_text(encoding="utf-8")) + assert not any(src.endswith("ensure-node-path.ps1") for src in msix_sources), ( + "the Store package has an execution alias instead") light_yml = LIGHT_YML.read_text(encoding="utf-8") assert "ensure-node-path.ps1" not in light_yml, ( diff --git a/packages/meshbay-node/tests/test_platform.py b/packages/meshbay-node/tests/test_platform.py index 4805d8a..d9ce75c 100644 --- a/packages/meshbay-node/tests/test_platform.py +++ b/packages/meshbay-node/tests/test_platform.py @@ -301,7 +301,7 @@ def test_the_forced_stop_really_spares_its_caller(tmp_path): script = textwrap.dedent(f""" import sys; sys.path.insert(0, {str(src)!r}) from meshbay_node import platform as p - p.NODE_IMAGE = {image!r} + p.NODE_IMAGES = ({image!r},) p.autostart_end() print("survived") """) diff --git a/packages/meshbay-node/tests/test_windows_node_lifecycle.py b/packages/meshbay-node/tests/test_windows_node_lifecycle.py index 241f583..b85b6ea 100644 --- a/packages/meshbay-node/tests/test_windows_node_lifecycle.py +++ b/packages/meshbay-node/tests/test_windows_node_lifecycle.py @@ -173,7 +173,7 @@ def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch): shutil.copy(r"C:\Windows\System32\PING.EXE", exe) proc = subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - monkeypatch.setattr(plat, "NODE_IMAGE", "mbpidtest.exe") + monkeypatch.setattr(plat, "NODE_IMAGES", ("mbpidtest.exe",)) try: assert plat.node_pids() == [proc.pid] plat.kill_pid(proc.pid) @@ -184,6 +184,110 @@ def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch): proc.kill() +@pytest.mark.skipif(sys.platform != "win32", reason="lists and kills real processes") +def test_node_pids_counts_the_windowless_daemon_too(tmp_path, monkeypatch): + """The Store package's startup task runs meshbay-nodew.exe: a Stop that + looked for meshbay-node.exe alone would call that node gone.""" + import shutil + procs = [] + for name in ("mbpidtest.exe", "mbpidtestw.exe"): + shutil.copy(r"C:\Windows\System32\PING.EXE", tmp_path / name) + procs.append(subprocess.Popen([str(tmp_path / name), "-n", "300", "127.0.0.1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + monkeypatch.setattr(plat, "NODE_IMAGES", ("mbpidtest.exe", "mbpidtestw.exe")) + try: + assert sorted(plat.node_pids()) == sorted(p.pid for p in procs) + finally: + for p in procs: + p.kill() + assert set(plat.NODE_IMAGES) == {"mbpidtest.exe", "mbpidtestw.exe"} + monkeypatch.undo() + assert plat.NODE_IMAGES == ("meshbay-node.exe", "meshbay-nodew.exe") + + +def _current_package_family() -> str: + import ctypes + length = ctypes.c_uint32(0) + k32 = ctypes.windll.kernel32 + if k32.GetCurrentPackageFamilyName(ctypes.byref(length), None) != 122: + return "" + buf = ctypes.create_unicode_buffer(length.value) + k32.GetCurrentPackageFamilyName(ctypes.byref(length), buf) + return buf.value + + +@pytest.mark.skipif(sys.platform != "win32", reason="package identity is Windows'") +def test_only_meshbays_own_package_counts_as_the_store_install(monkeypatch): + """A terminal inside another packaged application (an IDE or an agent + from the Store) gives its processes that application's identity, and the + test suite itself may run in one: that is not MeshBay's package.""" + assert plat.in_store_package() is False + family = _current_package_family() + if family: + monkeypatch.setattr(plat, "STORE_PACKAGE_FAMILY_PREFIX", family.split("_")[0] + "_") + assert plat.in_store_package() is True + + +def test_the_store_package_switches_its_startup_task_not_a_launcher(monkeypatch, capsys): + """There a Startup-folder launcher or a boot task would name the versioned + WindowsApps path each update deletes (and right after sign-in Windows + refused the launcher that path): `autostart` switches the package's + startup task, and says so on a first line the desktop app reads.""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(plat, "in_store_package", lambda: True) + monkeypatch.setattr(plat, "autostart_install", lambda *a: pytest.fail("wrote a launcher")) + monkeypatch.setattr(plat, "autostart_remove", lambda *a: pytest.fail("removed a launcher")) + monkeypatch.setattr(plat, "service_install", lambda *a: pytest.fail("made a boot task")) + monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""}) + asked = [] + answers = {"enable": "Enabled", "disable": "Disabled", "query": "Disabled"} + monkeypatch.setattr(plat, "startup_task", lambda a: asked.append(a) or answers[a]) + + class Args: + config = None + subcommand = "install" + for sub, action, state in (("install", "enable", "Enabled"), ("remove", "disable", "Disabled"), + ("status", "query", "Disabled")): + Args.subcommand = sub + lifecycle.autostart(Args) + assert asked[-1] == action + assert capsys.readouterr().out.splitlines()[0] == f"startup task {state}" + + # Switched off by the user in Windows Settings: theirs to switch back on. + answers["enable"] = "DisabledByUser" + Args.subcommand = "install" + with pytest.raises(SystemExit) as e: + lifecycle.autostart(Args) + assert e.value.code == 1 + assert "Settings > Apps > Startup" in capsys.readouterr().out + + # At boot is the .exe installer's. + with pytest.raises(SystemExit) as e: + lifecycle.service(Args) + assert e.value.code == 1 + assert ".exe" in capsys.readouterr().out + + +def test_a_refused_action_stays_on_the_node_page_until_the_next_action(): + """The status poll (every five seconds) cleared the one error state there + was, so a refusal vanished before it was read.""" + page = _js(STATIC / "node-page.js") + panel = page.split("function NodeServicePanel(", 1)[1].split("\nfunction ", 1)[0] + refresh = panel.split("const refresh = useCallback(", 1)[1].split("}, []);", 1)[0] + act = panel.split("const act = useCallback(", 1)[1].split("}, [", 1)[0] + assert "setActErr" not in refresh and "setPollErr('')" in refresh + assert "setActErr(platform.bridgeMessage(e))" in act + assert "const err = actErr || pollErr;" in panel + + +def test_the_startup_task_is_refused_outside_the_store_package(monkeypatch): + monkeypatch.setattr(plat, "in_store_package", lambda: False) + with pytest.raises(RuntimeError, match="Microsoft Store package"): + plat.startup_task("query") + with pytest.raises(ValueError): + plat.startup_task("toggle") + + # ── the desktop application: what it says, and when it sets the node up ────── def test_the_node_page_asks_the_node_not_only_the_task(): -- cgit v1.2.3