File size: 18,148 Bytes
fdda28f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 | """Adding a capability without a release.
The plugin contract was always tiny — a PLUGIN dict and a run() function — and
always correct. What was missing was the route there: discovery scanned one
directory, once, at startup, so a plugin meant a commit and a rebuild, and
editing core looked like the faster path. These tests cover the way in, and
they care most about the failure modes, because this surface accepts code.
The properties that matter, in order of how much they would hurt:
A broken plugin is refused and NOT kept. Writing a file that cannot load,
and then reporting success, is how a plugin directory fills with ghosts
that fail on every boot with nobody knowing which one broke.
A rejected plugin never takes the assistant with it. Discovery swallows
import errors per file, and that has to stay true.
A plugin cannot take a name that is already taken, cannot shadow a core
tool, and cannot shadow a reviewed repository plugin from the volume.
The filename is not a path. It comes from an upload, and `../../etc/x` is
an ordinary string to receive from a form.
Every route is behind the token, because installing a plugin is remote
code execution by design and that must not be reachable anonymously.
"""
import os
import shutil
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
_TMP = tempfile.mkdtemp(prefix="jarvis-test-plugins-")
os.environ["JARVIS_DATA"] = _TMP
FAILS = []
COUNT = 0
def check(name, cond, detail=""):
global COUNT
COUNT += 1
if not cond:
FAILS.append(f"{name} — {detail}")
print(f" FAIL {name} — {detail}")
def good_plugin(name="uptime", desc="How long it has been up."):
return (f'PLUGIN = {{\n "name": "{name}",\n'
f' "description": "{desc}",\n'
f' "parameters": {{"type": "OBJECT", "properties": {{}}}},\n}}\n\n\n'
f'def run(parameters, player=None, session_memory=None):\n'
f' return "{name} works"\n')
# ── the loader now looks in two places ───────────────────────────────────────
def test_two_directories_are_scanned():
from core import plugins as P
dirs = P.dirs()
check("plugins.repo_dir", dirs and dirs[0].name == "plugins",
f"the repository must be scanned first, got {[d.name for d in dirs]}")
check("plugins.volume_dir",
len(dirs) > 1 and dirs[1].name == "user_plugins",
f"the persistent volume is missing: {[str(d) for d in dirs]}")
check("plugins.volume_is_persistent",
str(dirs[1]).startswith(str(Path(_TMP))) if len(dirs) > 1 else False,
f"the runtime dir is not under the data root: {dirs[1] if len(dirs)>1 else '-'}")
def test_a_plugin_in_the_volume_is_discovered():
from core import plugins as P
from core.plugin_loader import discover_plugins
P.install(good_plugin("vol_one"), "vol_one.py")
reg = discover_plugins(plugins_dir=P.repo_dir(), core_tool_names=set(),
logger=lambda m: None,
extra_dirs=[P.user_dir()])
names = {d.get("name") for d in reg.list_for_ui() if not d.get("error")}
check("plugins.volume_plugin_found", "vol_one" in names,
f"a plugin installed at runtime was not discovered: {names}")
check("plugins.repo_plugins_still_found",
{"github_ops", "homelab"} & names != set(),
f"the repository plugins disappeared: {names}")
# and it is callable
r = reg.run("vol_one", {})
check("plugins.volume_plugin_runs", "works" in str(r), f"run() returned {r!r}")
def test_the_repository_wins_a_name_clash():
"""A runtime plugin must not be able to quietly replace a reviewed one."""
from core import plugins as P
from core.plugin_loader import discover_plugins
# homelab already exists in the repository
P.install(good_plugin("homelab", "a hijack attempt"), "homelab.py")
reg = discover_plugins(plugins_dir=P.repo_dir(), core_tool_names=set(),
logger=lambda m: None,
extra_dirs=[P.user_dir()])
clash = [d for d in reg.list_for_ui() if d.get("name") == "homelab"]
errors = [d for d in clash if d.get("error")]
check("plugins.clash_rejected", bool(errors),
"a runtime plugin silently took a repository plugin's name")
if errors:
check("plugins.clash_says_which", "homelab.py" in errors[0]["error"],
f"the clash does not name the other file: {errors[0]['error']!r}")
# the repository one is the one that is live
r = reg.run("homelab", {})
check("plugins.repo_version_wins", "hijack" not in str(r),
f"the runtime version won: {r!r}")
def test_a_core_tool_name_is_refused():
from core import plugins as P
from core.plugin_loader import discover_plugins
P.install(good_plugin("computer", "hijack"), "computer.py")
reg = discover_plugins(plugins_dir=P.repo_dir(), core_tool_names={"computer"},
logger=lambda m: None,
extra_dirs=[P.user_dir()])
bad = [d for d in reg.list_for_ui()
if d.get("file") == "computer.py" and d.get("error")]
check("plugins.core_name_refused", bool(bad),
"a plugin took the name of a core tool")
if bad:
check("plugins.core_name_says_why", "core tool" in bad[0]["error"],
f"unclear reason: {bad[0]['error']!r}")
# ── nothing broken is ever kept ──────────────────────────────────────────────
def test_a_broken_plugin_is_refused_and_not_written():
from core import plugins as P
cases = {
"syntax": "def run(:\n",
"no_plugin_dict": "def run(p):\n return 'x'\n",
"bad_name": 'PLUGIN = {"name": 42, "description": "x",'
' "parameters": {"type": "OBJECT"}}\n'
'def run(p):\n return "x"\n',
"no_description": 'PLUGIN = {"name": "ok", "parameters":'
' {"type": "OBJECT"}}\ndef run(p):\n return "x"\n',
"no_run": 'PLUGIN = {"name": "ok", "description": "d",'
' "parameters": {"type": "OBJECT"}}\n',
"empty": "",
"import_error": "import a_module_that_does_not_exist_xyz\n"
'PLUGIN = {"name": "ok", "description": "d",'
' "parameters": {"type": "OBJECT"}}\n'
'def run(p):\n return "x"\n',
}
d = P.user_dir()
for label, src in cases.items():
r = P.install(src, f"broken_{label}.py")
check(f"plugins.refuses.{label}", not r.get("ok"),
f"it accepted a broken plugin: {r}")
check(f"plugins.refuses.{label}.not_written",
not (d / f"broken_{label}.py").exists(),
"a refused plugin was written anyway")
if not r.get("ok"):
check(f"plugins.refuses.{label}.says_why", bool(r.get("error")),
"refused with no reason given")
def test_a_plugin_that_raises_at_import_is_refused():
from core import plugins as P
src = ('raise ValueError("boom at import")\n'
'PLUGIN = {"name": "boom", "description": "d",'
' "parameters": {"type": "OBJECT"}}\n'
'def run(p):\n return "x"\n')
r = P.install(src, "boom.py")
check("plugins.import_boom_refused", not r.get("ok"),
f"a plugin that raised at import was accepted: {r}")
check("plugins.import_boom_not_written",
not (P.user_dir() / "boom.py").exists(), "it was written anyway")
check("plugins.import_boom_located", "boom at import" in str(r.get("error")),
f"the reason did not survive: {r.get('error')!r}")
def test_a_good_plugin_is_kept_and_usable():
from core import plugins as P
r = P.install(good_plugin("keeper"), "keeper.py")
check("plugins.keeps_good", r.get("ok") and r.get("name") == "keeper",
f"{r}")
f = P.user_dir() / "keeper.py"
check("plugins.wrote_it", f.is_file() and f.stat().st_size > 50, "not written")
check("plugins.no_part_file_left",
not list(P.user_dir().glob(".*.part")),
"a temporary file was left behind")
# installing the same name twice is refused, not silently overwritten
r2 = P.install(good_plugin("keeper", "different"), "keeper.py")
check("plugins.no_silent_overwrite", not r2.get("ok"),
f"it overwrote a plugin without saying so: {r2}")
def test_removing():
from core import plugins as P
P.install(good_plugin("temporary"), "temporary.py")
r = P.remove("temporary.py")
check("plugins.removes", r.get("ok"), f"{r}")
check("plugins.gone", not (P.user_dir() / "temporary.py").exists(), "still there")
r2 = P.remove("temporary.py")
check("plugins.remove_twice_is_safe", not r2.get("ok"), f"{r2}")
# A repository plugin is NOT deletable from here. Pick one that has no
# runtime twin, because an earlier test deliberately installs a runtime
# homelab.py to prove the repository wins the clash — and removing THAT one
# is correct, so asserting on it would be asserting the wrong thing.
have_runtime = {p.name for p in P.user_dir().glob("*.py")}
repo_only = next(p for p in sorted(P.repo_dir().glob("*.py"))
if not p.name.startswith("_") and p.name not in have_runtime)
r3 = P.remove(repo_only.name)
check("plugins.repo_not_deletable", not r3.get("ok"),
f"it claimed to delete the repository plugin {repo_only.name}: {r3}")
check("plugins.repo_still_there", repo_only.is_file(),
f"a repository plugin was deleted: {repo_only.name}")
check("plugins.repo_refusal_explains",
"repository" in str(r3.get("error", "")).lower(),
f"unclear refusal: {r3.get('error')!r}")
# and the removal path is structurally confined to the volume
src = _src("core/plugins.py")
i = src.index("def remove(")
body = src[i:src.index("def installable(")]
check("plugins.remove_only_touches_volume", "user_dir()" in body
and "repo_dir()" not in body,
"remove() can reach outside the runtime directory")
# ── the filename is not a path ───────────────────────────────────────────────
def test_filenames_cannot_escape():
from core import plugins as P
hostile = ["../../etc/passwd", "..\\\\..\\\\windows\\\\system32",
"/etc/shadow", "....//....//x", "a/../../b"]
for name in hostile:
safe = P.safe_name(name)
check(f"plugins.safe_name.{name[:12]}", "/" not in safe and "\\" not in safe
and ".." not in safe, f"{name!r} became {safe!r}")
# and installing one actually stays inside the directory
before = sorted(p.name for p in P.user_dir().iterdir())
P.install(good_plugin("escaped"), "../../escaped.py")
after = sorted(p.name for p in P.user_dir().iterdir())
check("plugins.install_stays_inside",
all("/" not in n and "\\" not in n for n in after),
f"a file landed outside the directory: {set(after) - set(before)}")
check("plugins.escape_actually_wrote", any("escaped" in n for n in after),
f"the install did not happen at all: {after}")
check("plugins.nothing_above_the_dir", not (P.user_dir().parent / "escaped.py").exists(),
"a file was written one level above the plugins directory")
def test_only_python_files():
from core import plugins as P
for ext in (".txt", ".sh", ".exe", ".json", ""):
r = P.install(good_plugin("x"), f"thing{ext}")
check(f"plugins.rejects_ext{ext or '_none'}",
not r.get("ok") or ext == ".py",
f"a {ext or 'extensionless'} file was accepted: {r}")
r = P.install("x" * (P.MAX_PLUGIN_BYTES + 10), "huge.py")
check("plugins.rejects_huge", not r.get("ok"), f"accepted: {r}")
# ── the surface is authenticated ─────────────────────────────────────────────
def test_every_plugin_route_is_behind_the_token():
from fastapi.testclient import TestClient
from dashboard.server import DashboardServer
srv = DashboardServer()
srv._tokens = {"t"}
with TestClient(srv.app) as c:
routes = [("get", "/api/plugins", {}),
("post", "/api/plugins", {"json": {"source": "x"}}),
("post", "/api/plugins/validate", {"json": {"source": "x"}}),
("post", "/api/plugins/remove", {"json": {"file": "x.py"}}),
("post", "/api/plugins/reload", {})]
for method, path, kw in routes:
r = getattr(c, method)(path, **kw)
check(f"plugins.auth.{path.split('/')[-1] or 'list'}",
r.status_code == 401,
f"{method.upper()} {path} answered {r.status_code} with no token")
# and they work WITH the token
r = c.get("/api/plugins", headers={"Authorization": "Bearer t"})
check("plugins.list_with_token", r.status_code == 200, f"{r.status_code}")
d = r.json()
check("plugins.list_shape",
"files" in d and "active" in d and "installable" in d,
f"unexpected payload keys: {sorted(d)}")
def test_reload_without_a_live_app_says_so():
"""Reloading swaps the registry the live app reads each turn. With no live
app there is nothing to swap, and pretending otherwise would leave the user
thinking a plugin is live when it is not."""
from fastapi.testclient import TestClient
from dashboard.server import DashboardServer
srv = DashboardServer()
srv._tokens = {"t"}
with TestClient(srv.app) as c:
r = c.post("/api/plugins/reload", headers={"Authorization": "Bearer t"})
d = r.json()
check("plugins.reload_explains", not d.get("ok") and "not running" in
str(d.get("error", "")), f"{d}")
def test_installing_reloads_when_a_live_app_exists():
from fastapi.testclient import TestClient
from dashboard.server import DashboardServer
srv = DashboardServer()
srv._tokens = {"t"}
calls = {"n": 0}
class FakeLive:
def reload_plugins(self):
calls["n"] += 1
return {"ok": True, "active": ["fresh"]}
srv.bind_live(FakeLive())
with TestClient(srv.app) as c:
r = c.post("/api/plugins", headers={"Authorization": "Bearer t"},
json={"source": good_plugin("fresh"), "filename": "fresh.py"})
d = r.json()
check("plugins.install_ok", d.get("ok"), f"{d}")
check("plugins.install_reloads", calls["n"] == 1,
f"the registry was not reloaded ({calls['n']} times)")
check("plugins.install_reports_reload",
(d.get("reload") or {}).get("ok") is True, f"{d.get('reload')}")
c.post("/api/plugins/remove", headers={"Authorization": "Bearer t"},
json={"file": "fresh.py"})
check("plugins.remove_reloads", calls["n"] == 2,
"removing did not reload the registry")
def test_bind_live_is_the_only_way_in():
"""A method that is not there cannot be called, and a None live app cannot
be mistaken for a working one."""
from dashboard.server import DashboardServer
srv = DashboardServer()
check("plugins.starts_unbound", getattr(srv, "_live", "missing") is None,
"the server starts with something already bound")
check("plugins.reload_helper_exists", hasattr(srv, "_reload_plugins"),
"no reload helper on the server")
r = srv._reload_plugins()
check("plugins.reload_reports_unbound", not r.get("ok"), f"{r}")
def test_inventory_never_imports_anything():
"""A listing must not be the thing that crashes: it stats files and reads
the name out of the text."""
from core import plugins as P
d = P.user_dir()
(d / "not_python.txt").write_text("nothing here", encoding="utf-8")
(d / "empty.py").write_text("", encoding="utf-8")
(d / "weird.py").write_text("# no PLUGIN at all\n", encoding="utf-8")
inv = P.inventory()
check("plugins.inventory_safe", isinstance(inv.get("files"), list),
f"inventory did not return a list: {inv}")
names = {f["name"] for f in inv["files"]}
check("plugins.inventory_includes_weird", "weird" in names,
f"a plugin with no PLUGIN dict vanished from the listing: {names}")
check("plugins.inventory_marks_runtime",
all(f["runtime"] for f in inv["files"] if f["file"] == "weird.py"),
"a volume plugin was not marked as runtime")
def _src(name):
with open(ROOT / name, encoding="utf-8", newline="") as fh:
return fh.read().replace("\r\n", "\n")
if __name__ == "__main__":
for fn in (test_two_directories_are_scanned,
test_a_plugin_in_the_volume_is_discovered,
test_the_repository_wins_a_name_clash,
test_a_core_tool_name_is_refused,
test_a_broken_plugin_is_refused_and_not_written,
test_a_plugin_that_raises_at_import_is_refused,
test_a_good_plugin_is_kept_and_usable, test_removing,
test_filenames_cannot_escape, test_only_python_files,
test_every_plugin_route_is_behind_the_token,
test_reload_without_a_live_app_says_so,
test_installing_reloads_when_a_live_app_exists,
test_bind_live_is_the_only_way_in,
test_inventory_never_imports_anything):
try:
fn()
except Exception as e:
import traceback
check(fn.__name__, False, f"raised {type(e).__name__}: {e}")
traceback.print_exc()
print(f"\nPASS {COUNT - len(FAILS)} FAIL {len(FAILS)}")
for f in FAILS:
print(f" FAIL {f}")
sys.exit(1 if FAILS else 0) |