From 5bea1a3874bef6725e7a64279e4bf66e0b24983a Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Wed, 29 Jul 2026 13:19:35 -0500 Subject: [PATCH] =?UTF-8?q?fix(packaging):=20the=20name=20is=20claimed=20?= =?UTF-8?q?=E2=80=94=20close=20ASVS=2015.2.4=20for=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit messagefoundry-webconsole 0.2.15 published to PyPI 2026-07-29 by the first webconsole-v* release, over Trusted Publishing (OIDC, no API token). Verified: pypi.org returns 200, one file, 0.2.15; the run's release-webconsole job succeeded with the publish step RUN (not skipped) while release and release-harness correctly skipped on the tag prefix. This is the half that actually closes the cell. The earlier commit corrected our install instructions, which removed OUR CONTRIBUTION to the dependency-confusion risk but could not remove the risk: the name stayed free for anyone to register, and a squatted sdist executes its build backend during `pip install`, before any engine process exists. Claiming the name is what forecloses the substitution. Docs flipped back to real index installs (README, INSTALL-GUIDE, SERVICE, USER-GUIDE, MENTAL-MODEL, packaging README), and api/app.py's serve_ui RuntimeError now prints a command that works. The guard is UPDATED, not deleted: - _UNPUBLISHED_DISTRIBUTIONS is now empty, which is the goal state rather than a disabled check. To stop it rotting back to vacuous, test_a_new_distribution_must_be_classified requires every distribution under packaging/ to be classified published or unpublished, so a NEW packaging// that is neither fails the build. That is precisely the window this exposure lived in: a distribution existed, shipped docs named it, nobody had claimed it. - The guard-the-guard was exercising the path-vs-index discrimination using the REAL name, so three of its six cases inverted the moment the name was claimed — it would have failed for being right. Repointed at a synthetic never-to-exist name, with an assertion that the probe name stays fictional, plus new cases asserting a PUBLISHED name is not flagged (claiming a name is what makes a bare-name instruction safe, so the detector must say so). Two mutations red: leave a packaged distribution unclassified; reinstate the non-existent [webconsole] extra in shipped code. 9579 passed. The two failures are environmental and reproduce without this change: no .venv in this worktree (stale 0.3.0 install vs 0.3.2 source), and main's #43 moved the gate source while the installed user-scope snapshot is stale (needs install-gate.ps1 re-run). --- README.md | 2 +- docs/INSTALL-GUIDE.md | 5 +- docs/MENTAL-MODEL.md | 2 +- docs/SERVICE.md | 2 +- docs/USER-GUIDE.md | 2 +- messagefoundry/api/app.py | 3 +- packaging/messagefoundry-webconsole/README.md | 12 +-- tests/test_install_instruction_provenance.py | 79 ++++++++++++++++--- 8 files changed, 81 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b527315b..b5dc87c5 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ deliberate — replace `` with the current release shown at the top of needs (each is opt-in and lazy-imported): ```bash -pip install -e packaging/messagefoundry-webconsole # the browser web console (/ui) — the operator UI; most operators want this (source tree: not on PyPI yet) +pip install "messagefoundry-webconsole==" # the browser web console (/ui) — the operator UI; most operators want this pip install "messagefoundry[postgres]==" # PostgreSQL store backend (production server DB) pip install "messagefoundry[sqlserver]==" # SQL Server store backend (+ OS-level ODBC Driver 18) pip install "messagefoundry[sftp]==" # SFTP transport for the REMOTEFILE connector diff --git a/docs/INSTALL-GUIDE.md b/docs/INSTALL-GUIDE.md index d047ccbd..494aab7b 100644 --- a/docs/INSTALL-GUIDE.md +++ b/docs/INSTALL-GUIDE.md @@ -249,10 +249,7 @@ It ships as a separate, version-matched wheel (`messagefoundry-webconsole`) that in-process; install it alongside the engine and turn on `[api].serve_ui`: ```powershell -pip install -e packaging/messagefoundry-webconsole # the /ui web console, into the same venv -# NOTE: the console is NOT on PyPI yet — install it from the source tree, as above. Installing it -# by bare name from an index would resolve an UNCLAIMED distribution, i.e. whatever a third party -# has uploaded under that name, with its build backend executing at install time (ASVS 15.2.4). +pip install "messagefoundry-webconsole==0.2.15" # the /ui web console, into the same venv # then set [api].serve_ui = true in your service settings and (re)start the engine ``` diff --git a/docs/MENTAL-MODEL.md b/docs/MENTAL-MODEL.md index e5f2c481..35655497 100644 --- a/docs/MENTAL-MODEL.md +++ b/docs/MENTAL-MODEL.md @@ -340,7 +340,7 @@ Keep the message store on a fast *local* disk, not a network share — the stage ## 13. Deployment & operations -- **Install:** the supported production artifact is the signed, version-pinned PyPI wheel (pip install "messagefoundry==0.1.0"); then messagefoundry init scaffolds your own config repo (ADR 0017). Extras are opt-in: \[postgres\], \[sqlserver\], \[harness\] (the PySide6 test harness), \[sftp\]. The `/ui` web console installs alongside as the separate `messagefoundry-webconsole` distribution — **from the source tree** (`pip install -e packaging/messagefoundry-webconsole`) until the release phase publishes it; the name is not yet claimed on PyPI. +- **Install:** the supported production artifact is the signed, version-pinned PyPI wheel (pip install "messagefoundry==0.1.0"); then messagefoundry init scaffolds your own config repo (ADR 0017). Extras are opt-in: \[postgres\], \[sqlserver\], \[harness\] (the PySide6 test harness), \[sftp\]. The `/ui` web console installs alongside as the separate `messagefoundry-webconsole` distribution, published to PyPI on its own `webconsole-v*` cadence. - **Run headless:** python -m messagefoundry serve --config samples/config --db ./messagefoundry.db --env dev — API on http://127.0.0.1:8765 (GET /connections, /messages, /stats, WS /ws/stats). diff --git a/docs/SERVICE.md b/docs/SERVICE.md index bb523c3a..7a6be31b 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -374,7 +374,7 @@ same-origin at `/ui` (not part of the service runtime — a separate, version-ma mounts in-process): ```powershell -pip install -e packaging/messagefoundry-webconsole # into the engine venv (not on PyPI yet) +pip install "messagefoundry-webconsole" # into the engine venv # set [api].serve_ui = true in the service settings, then (re)start the service ``` diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 84af5b35..fb26a2c5 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -110,7 +110,7 @@ pip install -e ".[dicom]" # DICOM C-STORE SCP + codec — headers/SR only pip install -e ".[otel]" # OpenTelemetry/OTLP export seam (the /metrics endpoint itself needs no extra) ``` -(For a deployment wheel, the same extras apply: `pip install "messagefoundry[harness]==0.1.0"`, and the web console installs from the source tree with `pip install -e packaging/messagefoundry-webconsole` — it is **not published to an index yet**, so installing it by bare name would resolve an unclaimed distribution.) SQLite is the zero-dependency default — you need no extra to run the sample config. +(For a deployment wheel, the same extras apply: `pip install "messagefoundry[harness]==0.1.0"`, and the web console installs as its own wheel `pip install "messagefoundry-webconsole==0.2.15"`.) SQLite is the zero-dependency default — you need no extra to run the sample config. ### 3. Run the engine headless (dev) diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index 685a2326..2ceb9356 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -4942,8 +4942,7 @@ async def _reauthorize() -> Identity | None: # install, which is what actually works today and resolves no index at all. raise RuntimeError( "serve_ui requires the web console, which is not installed. It ships as a separate " - "distribution: install it from the source tree with " - "`pip install -e packaging/messagefoundry-webconsole`, or set [api].serve_ui=false " + "distribution: `pip install messagefoundry-webconsole`, or set [api].serve_ui=false " "to run JSON-only." ) from exc diff --git a/packaging/messagefoundry-webconsole/README.md b/packaging/messagefoundry-webconsole/README.md index bebe63e4..dd7c9921 100644 --- a/packaging/messagefoundry-webconsole/README.md +++ b/packaging/messagefoundry-webconsole/README.md @@ -16,13 +16,15 @@ surface (`security`/`models`/`auth_models`/`_ui_seam`), `messagefoundry.auth`, a ## Install ``` -pip install -e packaging/messagefoundry-webconsole # from the source tree, alongside the engine +pip install messagefoundry-webconsole # alongside the engine +# or from a checkout, for development: +pip install -e packaging/messagefoundry-webconsole ``` -> **Not on PyPI yet.** This distribution has never been published, so its name is **unclaimed**. -> Installing it by bare name from an index would resolve whatever a third party has uploaded under -> that name — and an sdist executes its build backend during `pip install`, before any engine process -> exists. Install from the source tree until the name is registered (ASVS 15.2.4). +> The distribution name is **registered on PyPI** and published only by this repository's +> `release.yml` over PyPI Trusted Publishing (OIDC, no API token), on its own `webconsole-v*` tag. +> Claiming the name is what forecloses the dependency-confusion substitution an unclaimed name invites +> (ASVS 15.2.4). A plain `pip install messagefoundry` stays byte-identical: with the console absent and `serve_ui` default-off, the JSON API is unchanged; `serve_ui=true` without the console fails LOUD at startup. diff --git a/tests/test_install_instruction_provenance.py b/tests/test_install_instruction_provenance.py index fd43df78..1e8c72e0 100644 --- a/tests/test_install_instruction_provenance.py +++ b/tests/test_install_instruction_provenance.py @@ -36,10 +36,23 @@ _ROOT = Path(__file__).resolve().parent.parent #: Distributions this repository builds that are **not yet published to any index**. Until a name is -#: claimed, no shipped text may tell a user to install it by bare name. Remove an entry here the day -#: the name is registered -- and note that reserving it is sufficient; an empty project cannot be -#: squatted. -_UNPUBLISHED_DISTRIBUTIONS = frozenset({"messagefoundry-webconsole"}) +#: claimed, no shipped text may tell a user to install it by bare name. +#: +#: **Currently empty, and that is the goal state, not a disabled guard.** All three distributions this +#: repo builds are registered on PyPI (`messagefoundry`, `messagefoundry-harness`, +#: `messagefoundry-webconsole` -- the last claimed 2026-07-29 by the first `webconsole-v*` release, +#: closing the ASVS 15.2.4 exposure where README.md instructed an index install of an unclaimed name). +#: An empty set means "nothing unpublished is being advertised", which is exactly what should hold. +#: +#: The set exists for the NEXT distribution: add its name here the moment `packaging//` lands and +#: before any doc references it, then remove it once the first release claims the name. +#: `test_a_new_distribution_must_be_classified` fails if a packaged distribution is neither listed here +#: nor demonstrably published, so this cannot silently rot back to vacuous. +_UNPUBLISHED_DISTRIBUTIONS: frozenset[str] = frozenset() + +#: Distribution names registered on PyPI and published by this repo's release workflow. Listing one +#: here asserts the name is CLAIMED -- the property that makes a bare-name install instruction safe. +_PUBLISHED_DISTRIBUTIONS = frozenset({"messagefoundry-harness", "messagefoundry-webconsole"}) #: Files whose install commands are shipped to, or executed by, someone other than a maintainer. #: CI workflows and internal handoffs are excluded deliberately: they install from the source tree by @@ -156,27 +169,71 @@ def test_the_unpublished_list_does_not_rot() -> None: ) +def test_a_new_distribution_must_be_classified() -> None: + """Stops the guard rotting back to vacuous now that the unpublished set is empty. + + Every distribution under `packaging/` must be either (a) listed in `_UNPUBLISHED_DISTRIBUTIONS`, + so shipped text may not instruct an index install of it, or (b) known-published, so it may. A NEW + `packaging//` that is neither fails here -- which is the window the ASVS 15.2.4 exposure + lived in: a distribution existed, docs referenced it by name, and nobody had claimed the name. + + Mutation: add a `packaging//` directory without touching this file. Red: named below. + """ + packaged = { + p.name for p in (_ROOT / "packaging").iterdir() if p.is_dir() and not p.name.startswith(".") + } + unclassified = sorted(packaged - _UNPUBLISHED_DISTRIBUTIONS - _PUBLISHED_DISTRIBUTIONS) + assert not unclassified, ( + f"packaged distribution(s) classified as neither unpublished nor published: {unclassified}. " + f"Add each to _UNPUBLISHED_DISTRIBUTIONS (and keep docs on a path install) until its name is " + f"registered, then move it to _PUBLISHED_DISTRIBUTIONS. An unclaimed name that shipped docs " + f"reference by name is the dependency-confusion window (ASVS 15.2.4)." + ) + + +#: A name that will never be published, used ONLY to exercise the detector. The parametrized cases +#: below used the real `messagefoundry-webconsole`, so the day that name was claimed on PyPI three of +#: them inverted and the guard-the-guard failed for being RIGHT. The discrimination under test is +#: path-vs-index, which has nothing to do with which names happen to be published. +_SYNTHETIC_UNPUBLISHED = "messagefoundry-notreal" + + @pytest.mark.parametrize( ("line", "flagged"), [ - ('pip install "messagefoundry-webconsole==0.1.0"', True), - ("pip install messagefoundry-webconsole", True), - ("uv pip install messagefoundry-webconsole", True), - ("pip install -e packaging/messagefoundry-webconsole", False), - ("uv pip install --system -e packaging/messagefoundry-webconsole", False), + (f'pip install "{_SYNTHETIC_UNPUBLISHED}==0.1.0"', True), + (f"pip install {_SYNTHETIC_UNPUBLISHED}", True), + (f"uv pip install {_SYNTHETIC_UNPUBLISHED}", True), + (f"pip install -e packaging/{_SYNTHETIC_UNPUBLISHED}", False), + (f"uv pip install --system -e packaging/{_SYNTHETIC_UNPUBLISHED}", False), ('pip install "messagefoundry==0.1.0"', False), + # A PUBLISHED name must NOT be flagged, whatever form it takes -- the whole point of claiming + # a name is that a bare-name install of it becomes safe to instruct. + ('pip install "messagefoundry-webconsole==0.2.15"', False), + ("pip install messagefoundry-webconsole", False), ], ) def test_the_detector_separates_index_installs_from_path_installs(line: str, flagged: bool) -> None: """Guard-the-guard, and the reason it is worth having: the whole value of the check above is the path-vs-index distinction. A detector that flagged both would be turned off; one that flagged neither would be decorative. This pins the boundary in both directions, with no file I/O, so it - keeps working wherever the suite runs.""" + keeps working wherever the suite runs -- and against a SYNTHETIC unpublished name, so it does not + invert the day a real name is claimed.""" + unpublished = _UNPUBLISHED_DISTRIBUTIONS | {_SYNTHETIC_UNPUBLISHED} hits = [ m for m in _BARE_NAME_INSTALL.finditer(line) if "-e" not in m["flags"] and "--editable" not in m["flags"] - and m["name"].lower().replace("_", "-") in _UNPUBLISHED_DISTRIBUTIONS + and m["name"].lower().replace("_", "-") in unpublished ] assert bool(hits) is flagged, f"{line!r}: expected flagged={flagged}, got {bool(hits)}" + + +def test_the_synthetic_probe_name_is_not_a_real_distribution() -> None: + """The probe above is only meaningful while its name is fictional. If a `packaging/` directory ever + uses it, the parametrized expectations silently stop testing what they claim to.""" + packaged = {p.name for p in (_ROOT / "packaging").iterdir() if p.is_dir()} + assert _SYNTHETIC_UNPUBLISHED not in packaged, ( + f"{_SYNTHETIC_UNPUBLISHED!r} is now a real distribution — pick another fictional probe name" + )