diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e72e75b..32c2a90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 teardown noise). The push-config helpers now retry exactly that error with a short bounded backoff (5 attempts, ~0.75s worst case); any other JSON-RPC error, or a genuinely unknown task id, still fails immediately/after the bound. +- **Frozen plugin install/update pips missing deps into the managed runtime (#2226).** + On the desktop app, installing (or updating) a plugin with unmet `requires_pip` + still answered with the pre-ADR-0093 refusal — "install it on a server/Docker + build instead" — even when the managed Python runtime (ADR 0094 P2) was + provisioned and one `install_deps` click away from satisfying them. The frozen + gate now routes the missing hard deps through + `install_requirements_into_managed_runtime` (with the same `install_deps` audit + trail) and proceeds; it refuses only when the runtime isn't provisioned (the + message points at `POST /api/runtime/python/install`) or the install genuinely + fails (pip's real error is surfaced). Optional-dep semantics (#1953/#2162) are + unchanged. - **Six status tones now actually theme (#2224).** Chat notes, the keybindings conflict state, and the knowledge delete-armed state referenced `--pl-color-info/warning/danger` — names the design package never defines — so their hex fallbacks rendered permanently: diff --git a/graph/plugins/installer.py b/graph/plugins/installer.py index 4b719bef..bf54114d 100644 --- a/graph/plugins/installer.py +++ b/graph/plugins/installer.py @@ -473,6 +473,41 @@ def _normalize_dist(name: str) -> str: return _norm(name) +def _frozen_install_missing_deps(pid: str, requires_pip: list[str], missing: list[str]) -> None: + """Frozen desktop, hard deps missing at install/update time: pip them into the + managed Python runtime (ADR 0094 P2) — the same target ``install_deps`` uses — + instead of the pre-ADR-0093 flat refusal (#2226). Refuses only when the runtime + isn't provisioned (naming the install route) or the install itself fails + (surfacing pip's real error).""" + # Module (not from-) imports: callables resolve through the source module at call + # time, so test monkeypatches on infra.python_runtime / runtime.python_install bind. + import infra.python_runtime as pr + import runtime.python_install as pi + + to_install = [s for s in requires_pip if _dep_pkg_name(s) in missing] + _validate_pip_specs(pid, to_install) + if pr.managed_python_exe() is None: + raise InstallError( + f"{pid!r} needs {', '.join(missing)} which isn't in the desktop runtime — " + f"provision the managed Python runtime first (POST /api/runtime/python/install " + f"or Settings ▸ Tools), then retry." + ) + try: + pi.install_requirements_into_managed_runtime(to_install) + except pi.PythonRuntimeError as exc: + _audit( + "install_deps", + {"id": pid, "deps": to_install, "targets": ["managed-runtime"]}, + "managed runtime install failed", + success=False, + ) + raise InstallError( + f"{pid!r} needs {', '.join(missing)} — installing into the managed runtime failed: {exc}" + ) from exc + _audit("install_deps", {"id": pid, "deps": to_install, "targets": ["managed-runtime"]}, "ok") + log.info("[plugins] %s: installed %d missing dep(s) into the managed runtime", pid, len(to_install)) + + def install( url: str, ref: str | None = None, *, force: bool = False, by: str = "cli", allow: list[str] | None = None ) -> dict: @@ -512,9 +547,11 @@ def install( if _is_builtin(pid): raise InstallError(f"plugin id {pid!r} is a built-in — cannot install over it.") - # Frozen runtime (desktop): no pip — a plugin can only run if its declared - # deps are already importable in the bundle. Refuse early with a clear - # message instead of a cryptic enable-time ImportError (ADR 0058 D2). + # Frozen runtime (desktop): no host pip — a missing hard dep used to be a flat + # ADR 0058 D2 refusal ("install it on a server instead"). The managed Python + # runtime (ADR 0094 P2) is a real install target now, so when it's provisioned + # the missing deps are pip'd into it right here (#2226), and we refuse only when + # the runtime is absent or that install actually fails. # OPTIONAL deps (#1953) don't gate: the plugin degrades gracefully without # them, so a missing one warns (in the summary + log) and install proceeds. warnings: list[str] = [] @@ -522,10 +559,7 @@ def install( if manifest.requires_pip: ok, missing = _deps_satisfied(manifest.requires_pip) if not ok: - raise InstallError( - f"{pid!r} needs {', '.join(missing)} which isn't in the desktop runtime — " - f"install it on a server/Docker build instead." - ) + _frozen_install_missing_deps(pid, manifest.requires_pip, missing) if manifest.optional_pip: _, soft_missing = _deps_satisfied(manifest.optional_pip) if soft_missing: diff --git a/tests/test_plugin_installer.py b/tests/test_plugin_installer.py index 3df6dc44..3320092c 100644 --- a/tests/test_plugin_installer.py +++ b/tests/test_plugin_installer.py @@ -324,6 +324,68 @@ def _refuse(reqs, **k): assert "optional dep(s) definitely_not_real_xyz aren't in the desktop runtime" in caplog.text +# ── frozen install/update gate routes deps into the managed runtime (#2226) ── + + +def test_frozen_install_pips_missing_deps_into_managed_runtime(env, monkeypatch): + """#2226: a frozen install/update with a provisioned managed runtime no longer + refuses on missing hard deps with the pre-ADR-0093 message — it pips them into + the runtime (the install_deps target) and the install proceeds.""" + repo = _make_plugin_repo(env, manifest_extra="requires_pip: [python-docx>=1.1]\n") + monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") + monkeypatch.setenv("PROTOAGENT_PLUGIN_FETCH", "git") # frozen forces archive fetch; keep the local-repo clone + monkeypatch.setattr(installer, "_managed_runtime_dists", lambda: set()) # dep not satisfied anywhere + import infra.python_runtime as pr + import runtime.python_install as pi + + monkeypatch.setattr(pr, "managed_python_exe", lambda: Path("/fake/runtime/bin/python3")) + got = {} + monkeypatch.setattr(pi, "install_requirements_into_managed_runtime", lambda reqs, **k: got.setdefault("reqs", reqs)) + summary = installer.install(str(repo)) + assert got["reqs"] == ["python-docx>=1.1"] # wheel install attempted, into the managed runtime + assert summary["id"] == "demo_ext" + assert (installer.live_plugins_dir() / "demo_ext" / "protoagent.plugin.yaml").exists() + + +def test_frozen_install_refuses_without_managed_runtime_and_names_the_install_route(env, monkeypatch): + repo = _make_plugin_repo(env, manifest_extra="requires_pip: [python-docx>=1.1]\n") + monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") + monkeypatch.setenv("PROTOAGENT_PLUGIN_FETCH", "git") + monkeypatch.setattr(installer, "_managed_runtime_dists", lambda: set()) + import infra.python_runtime as pr + import runtime.python_install as pi + + monkeypatch.setattr(pr, "managed_python_exe", lambda: None) # runtime absent + monkeypatch.setattr( + pi, "install_requirements_into_managed_runtime", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not attempt an install without a runtime")), + ) + with pytest.raises(installer.InstallError, match="POST /api/runtime/python/install"): + installer.install(str(repo)) + assert not (installer.live_plugins_dir() / "demo_ext").exists() # refused before landing code + + +def test_frozen_install_surfaces_the_real_error_when_runtime_install_fails(env, monkeypatch): + repo = _make_plugin_repo(env, manifest_extra="requires_pip: [python-docx>=1.1]\n") + monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") + monkeypatch.setenv("PROTOAGENT_PLUGIN_FETCH", "git") + monkeypatch.setattr(installer, "_managed_runtime_dists", lambda: set()) + import infra.python_runtime as pr + import runtime.python_install as pi + + monkeypatch.setattr(pr, "managed_python_exe", lambda: Path("/fake/runtime/bin/python3")) + + def _boom(reqs, **k): + raise pi.PythonInstallError( + "plugin dependency install failed: No matching distribution found for python-docx>=1.1" + ) + + monkeypatch.setattr(pi, "install_requirements_into_managed_runtime", _boom) + with pytest.raises(installer.InstallError, match="No matching distribution found"): + installer.install(str(repo)) + assert not (installer.live_plugins_dir() / "demo_ext").exists() + + def test_managed_runtime_dists_read_failure_degrades_to_empty(monkeypatch, caplog): """A broken managed-runtime read must never break dep resolution — the fallback is 'nothing installed there' (empty set), with the swallow left visible in the log.""" diff --git a/tests/test_plugin_installer_archive.py b/tests/test_plugin_installer_archive.py index 076482de..3678b5f4 100644 --- a/tests/test_plugin_installer_archive.py +++ b/tests/test_plugin_installer_archive.py @@ -149,12 +149,17 @@ def test_install_via_archive_pins_sha_and_writes_lock(env, monkeypatch): def test_frozen_dep_gate_refuses_unbundled_dep(env, monkeypatch): + """#2226: the gate refuses only when NO managed runtime is provisioned — and the + message points at the runtime install instead of 'use a server build'.""" + import infra.python_runtime as pr + monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") + monkeypatch.setattr(pr, "managed_python_exe", lambda: None) # runtime absent monkeypatch.setattr(installer, "_resolve_sha_github", lambda o, r, ref: _SHA) monkeypatch.setattr( installer, "_http_get", lambda url, **kw: _Resp(content=_tarball(requires_pip="definitely_not_real_xyz>=1")) ) - with pytest.raises(installer.InstallError, match="isn't in the desktop runtime"): + with pytest.raises(installer.InstallError, match="POST /api/runtime/python/install"): installer.install("https://github.com/acme/demo_ext") assert not (installer.live_plugins_dir() / "demo_ext").exists() # refused before landing @@ -202,8 +207,12 @@ def test_frozen_missing_optional_dep_warns_and_installs(env, monkeypatch, caplog def test_frozen_missing_hard_dep_still_refuses_with_optional_present(env, monkeypatch): - """Hard wins: a mixed manifest with a missing hard dep gets today's refusal.""" + """Hard wins: a mixed manifest with a missing hard dep still refuses when no + managed runtime is provisioned — a missing optional alone wouldn't (#2226).""" + import infra.python_runtime as pr + monkeypatch.setenv("PROTOAGENT_PLUGIN_FROZEN", "1") + monkeypatch.setattr(pr, "managed_python_exe", lambda: None) # runtime absent monkeypatch.setattr(installer, "_resolve_sha_github", lambda o, r, ref: _SHA) monkeypatch.setattr( installer,