Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
48 changes: 41 additions & 7 deletions graph/plugins/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -512,20 +547,19 @@ 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] = []
if _frozen_like():
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:
Expand Down
62 changes: 62 additions & 0 deletions tests/test_plugin_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
13 changes: 11 additions & 2 deletions tests/test_plugin_installer_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
Loading