diff --git a/.claude/plans/2026-05-08-adguard-plugin-headless.md b/.claude/plans/2026-05-08-adguard-plugin-headless.md new file mode 100644 index 0000000..0f07611 --- /dev/null +++ b/.claude/plans/2026-05-08-adguard-plugin-headless.md @@ -0,0 +1,129 @@ +# AdGuard plugin (headless) + +## Context + +curfew already has the plugin runtime (discovery, instantiation, dispatch, safety-net resync, audit on failure) and the manual lock surface. What's missing is the first real plugin: AdGuard Home, which sinkholes a kid's DNS by setting per-MAC AdGuard client `upstreams` to `0.0.0.0`. PLAN.md calls this out as the first in-core plugin — it stress-tests discovery and the resync path. + +The user has a working ~400-line standalone Python script (the seed of the project) that does the same thing against a YAML inventory. We're not porting that script; we're rebuilding the same behavior on top of curfew's plugin SDK and Devices table. + +A GUI question came up — plugins eventually want their own pages (e.g. an AdGuard tab with a "synced devices" table and push/pull buttons). We're **not** building the plugin UI surface in this work. We're capturing the recommended direction (schema-driven, declarative plugin actions/panels rendered by the existing SPA) as ADR-017 so it's not re-litigated, then shipping AdGuard headless. Operators interact via API and CLI; lock state changes drive AdGuard automatically. + +## Outcome + +After this work: + +1. The kernel ships ADR-017 documenting the future plugin-UI direction. +2. `Scope` carries the user's devices, so plugins can act on MAC granularity (one-time SDK extension; every future plugin benefits). +3. `plugins/adguard/` is a working, headless plugin: assign it once with AdGuard's URL + creds, lock a kid, kid's MACs get sinkholed; unlock, sinkhole clears. + +## Approach + +Two PRs, in order. Each is small and reviewable on its own. + +### PR 1 — `Scope.devices` SDK extension + +**Why first**: AdGuard fundamentally works at MAC granularity. Today `Scope` is `(user, locked, reasons)` — no path for `reconcile()` to learn the user's MACs without reaching into the curfew DB. The fix is a non-breaking field add (the SDK doc explicitly calls out adding fields to `Scope` as non-breaking). Smart-plug, router-acl, tailscale all need this same data; we're not building it just for AdGuard. + +**Changes**: + +- **`core/curfew/plugin.py`** — add a `DeviceRef` dataclass (`slug`, `type`, `os`, `mac: list[str]`, `managed`) and a `devices: list[DeviceRef] = field(default_factory=list)` field on `Scope`. Add to module docstring. Re-export `DeviceRef`. +- **`core/curfew/plugin_runtime.py`** — `dispatch_for_user`: after evaluating user_scope, query `Device` rows where `owner_id == user.id`, build `DeviceRef` list, pass into `Scope`. `safety_net_resync`: same. The query is small (one extra `select(Device)` per dispatched user); no batching needed at homelab scale. +- **Tests**: + - Unit test in `core/tests/test_plugin_runtime.py` asserting `Scope.devices` is populated when the user has Devices, empty list when they don't. + - Update `plugins/reftest-plugin/plugin.py` to optionally write the device-slug list alongside the username in the sentinel (or add a second sentinel for devices). Existing acceptance tests in `api/tests/test_plugins_reconcile.py` extend with one new assertion that `reconcile` sees the user's devices. +- **No model change.** `Device.mac` is already `list[str]`; we read it as-is. No IP field is needed (AdGuard accepts MAC-only client IDs). +- **No `PluginAssignment.users` change.** AdGuard will be assigned with `users=["*"]` typically, but the field stays — smart-plug-per-room scenarios still need it. + +### PR 2 — `plugins/adguard/` plugin + +**Files**: +- `plugins/adguard/manifest.toml` +- `plugins/adguard/plugin.py` +- `plugins/adguard/tests/test_plugin.py` (or wherever the test lives — see below) + +**Config (Pydantic, in `plugin.py`)**: +```python +class Config(BaseModel): + url: str # e.g. http://adguard.lan + username: str + password_env: str # env var holding AdGuard admin password (PLAN.md secret convention) + sinkhole_upstream: str = "0.0.0.0" # configurable; default matches the original script +``` + +**HTTP**: `httpx.AsyncClient` with Basic auth — already a curfew dep, so no `requirements.txt` quirk (which the loader currently can't auto-install per ADR-013's shared-env trade-off). Plugin reads `os.environ[config.password_env]` in `__init__` and constructs the client there. + +**Reconcile behavior** (following the original script's mechanics): +1. Filter `scope.devices` to managed devices with non-empty `mac`. Skip+log others (no warning row in the audit log; this is normal for adult/shared devices). +2. `GET /control/clients`. Build a `{mac.lower(): client}` index. +3. **Block leg (`scope.locked = True`)**: for each remaining device: + - If a client exists matching any of the device's MACs: if its `upstreams != [sinkhole_upstream]`, `POST /control/clients/update` with `data.upstreams = [sinkhole_upstream]` (idempotent: skip if already correct). + - If no client matches: `POST /control/clients/add` with `name=device.slug`, `ids=device.mac`, `upstreams=[sinkhole_upstream]`, `use_global_settings=True`, `use_global_blocked_services=True`, `filtering_enabled=False`. (This is the auto-register-on-first-lock path; a separate operator-driven `push` for unmanaged devices comes later, after the plugin actions/panels surface from ADR-017 lands.) +4. **Unblock leg (`scope.locked = False`)**: for each remaining device: + - If a client exists with `upstreams == [sinkhole_upstream]`, `POST /control/clients/update` with `data.upstreams = []`. Idempotent. + - If no client exists, no-op (don't auto-register on the unblock leg — there's nothing to clear). +5. Return `ReconcileResult.ok()` on success. Catch `httpx.HTTPError` / non-2xx and return `ReconcileResult.error(msg)` with a short, redacted message (no creds in the message). The runtime audits the failure as `plugin.reconcile_failed`. + +**Endpoints used** (all under `${url}/control/`): +- `GET /clients` — list +- `POST /clients/add` — `{name, ids, upstreams, use_global_settings, use_global_blocked_services, filtering_enabled}` +- `POST /clients/update` — `{name: , data: }` + +**Tests**: unit tests with `respx` mocking `httpx.AsyncClient` (or hand-rolled mock transport — `respx` would need to be added as a dev dep, check first). Cover: +- Block leg: device with MAC → POST `/clients/add` with sinkhole upstream when client missing; POST `/clients/update` when client exists with wrong upstream; no call when already sinkholed (idempotent). +- Unblock leg: POST `/clients/update` to clear upstream when sinkholed; no-op when client missing or already cleared. +- Devices with empty `mac` are skipped silently. +- Unmanaged devices in `scope.devices` are ignored. +- AdGuard 5xx → `ReconcileResult.error` (not exception). +- Multi-MAC device: one `add` call, all MACs included in `ids`. + +Test file location: prefer `plugins/adguard/tests/test_plugin.py` to keep it co-located, mirroring the convention used elsewhere (verify against repo first; if there's no precedent, fall back to `core/tests/test_adguard_plugin.py`). + +**No CLI changes.** `curfew plugin assign adguard --config '{...}'` already works through the generic plugin assignment surface; no plugin-specific CLI in this PR. + +### Side-quest: ADR-017 (schema-driven plugin UI) + +Append a new ADR to `docs/DECISIONS.md` capturing: +- **Decision**: future plugin GUI extensions are schema-driven. Plugins declare *actions* (named methods + result types) and *panels* (typed read-only data) in Python; runtime exposes them at `POST /v1/plugins/{type}/{instance_id}/actions/{name}` and `GET /v1/plugins/{type}/{instance_id}/panels/{name}`. The SPA discovers them via `GET /v1/plugins/types` and renders generic widgets (buttons for actions, tables/key-value lists for panels). Plugin authors write zero JS. +- **Rejected**: plugin-shipped JS bundles (Backstage / VS Code style) — too much coupling for homelab scale; plugin authors shouldn't need to learn React. Server-rendered HTML islands embedded in the SPA — inherits MPA downsides without escaping the SPA. Going back to MPA — doesn't fix the problem; you'd still need a contribution mechanism. +- **Trade-off accepted**: plugin authors are constrained to the widgets the SPA implements. New UI shapes require coordinated SPA + SDK work. For the realistic plugin set (AdGuard, smart-plug, router-acl, tailscale), the widget set is small (one table panel + a couple of action buttons each). +- **Status**: not implemented. AdGuard, smart-plug etc. ship headless until this lands; operators use CLI/API. +- **Why a kernel commitment**: deciding the contribution shape now prevents the alternative (plugin-shipped JS) from accidentally creeping in via the first plugin author who needs a button. + +This ADR can land in either PR — it's small and self-contained. Putting it in PR 1 keeps PR 2 focused on AdGuard. + +## Critical files + +- `core/curfew/plugin.py` — `Scope`, new `DeviceRef` +- `core/curfew/plugin_runtime.py` — `dispatch_for_user`, `safety_net_resync` populate `Scope.devices` +- `core/curfew/models.py` — `Device` (read-only reference) +- `plugins/reftest-plugin/plugin.py` — extend to assert `scope.devices` +- `plugins/adguard/manifest.toml` — new +- `plugins/adguard/plugin.py` — new +- `plugins/adguard/tests/test_plugin.py` — new +- `docs/DECISIONS.md` — append ADR-017 +- `core/tests/test_plugin_runtime.py` — assert `Scope.devices` populated +- `api/tests/test_plugins_reconcile.py` — assert reftest sees devices + +## Verification + +**Per-PR `just check`**: the full battery (lint + mypy + tests + web tests + build) must pass on each PR independently. + +**Live smoke (PR 2, end-to-end against a real AdGuard Home)**: +1. Stand up AdGuard Home locally (docker; admin user + password set). +2. Start curfew with `ADGUARD_PASSWORD=...` exported. +3. `curl POST /v1/plugins '{"type":"adguard","config":{"url":"http://localhost:8080","username":"admin","password_env":"ADGUARD_PASSWORD"}}'` +4. Create a managed user `kid1`, add a device with a MAC. +5. `POST /v1/users/kid1/lock` → confirm in AdGuard's "Clients" UI that a client appears for that MAC with upstream `0.0.0.0`. Confirm DNS resolution from a client with that MAC is sinkholed. +6. `PATCH /v1/users/kid1 {"managed": false}` (exercises the unmanaged-implies-unlocked invariant from PR #27 too) → AdGuard client's upstream returns to `[]`. +7. Audit log shows `plugin.reconcile_failed` only if AdGuard was unreachable; otherwise silent. + +If a real AdGuard isn't handy, skip the live smoke; unit tests with mocked httpx are the substantive coverage. + +## Out of scope (named so we don't drift) + +- Plugin actions/panels SDK and SPA generic plugin renderer (ADR-017's implementation; separate, larger work). +- `curfew adguard push` bulk-sync command (waits for the plugin actions surface). +- AdGuard "Allowed/Disallowed clients" management — we use per-client upstream sinkholing only, matching the original script. +- IP-based AdGuard client IDs — `Device` doesn't store IPs; MAC-only is sufficient. +- Cascade-deleting AdGuard clients when a curfew Device is removed. Operator cleans those up out of band (or via the future `push`/`purge` actions). +- Crash-isolating plugins from curfew-core (ADR-013 trade-off; unchanged here). diff --git a/api/tests/test_plugins_reconcile.py b/api/tests/test_plugins_reconcile.py index e3dcf22..202dbae 100644 --- a/api/tests/test_plugins_reconcile.py +++ b/api/tests/test_plugins_reconcile.py @@ -145,6 +145,54 @@ def test_failed_reconcile_audited_but_lock_succeeds( assert rows[0].payload["user"] == "kid1" +def test_lock_dispatches_scope_devices( + client_with_repo_plugins: TestClient, auth: dict[str, str], tmp_path: Path +) -> None: + """``Scope.devices`` reaches the plugin end-to-end via the lock route. + + Two devices are seeded for kid1 with distinct MACs; the reftest + plugin records the comma-separated device-slug list it received in + ``Scope.devices`` to a second sentinel. + """ + sentinel = tmp_path / "sentinel" + devices_sentinel = tmp_path / "devices" + + r = client_with_repo_plugins.post( + "/v1/users", + json={"username": "kid1", "password": "test1234", "role": "member"}, + headers=auth, + ) + assert r.status_code == 201, r.text + user_id = r.json()["id"] + + for slug, mac in [("kid1-laptop", "aa:bb:cc:dd:ee:01"), ("kid1-phone", "aa:bb:cc:dd:ee:02")]: + r = client_with_repo_plugins.post( + "/v1/devices", + json={"slug": slug, "type": "pc", "os": "windows", "mac": [mac], "owner_id": user_id}, + headers=auth, + ) + assert r.status_code == 201, r.text + + r = client_with_repo_plugins.post( + "/v1/plugins", + json={ + "type": "reftest_plugin", + "config": { + "sentinel_path": str(sentinel), + "devices_sentinel_path": str(devices_sentinel), + }, + "users": ["kid1"], + }, + headers=auth, + ) + assert r.status_code == 201, r.text + + r = client_with_repo_plugins.post("/v1/users/kid1/lock", headers=auth) + assert r.status_code == 200 + assert sentinel.read_text() == "kid1" + assert devices_sentinel.read_text() == "kid1-laptop,kid1-phone" + + def test_assignments_hydrate_on_startup( client_with_repo_plugins: TestClient, auth: dict[str, str], tmp_path: Path ) -> None: diff --git a/core/curfew/plugin.py b/core/curfew/plugin.py index c01c956..265758f 100644 --- a/core/curfew/plugin.py +++ b/core/curfew/plugin.py @@ -10,11 +10,13 @@ - ``Plugin`` — the base class. - ``Scope`` — what gets passed into ``reconcile``: which user, are they - locked, and what reasons fired. ``target_apps`` is deliberately *not* in - ``Scope`` yet; PLAN.md's per-rule user config (and target_apps with it) is - deferred until the first concrete agent or plugin needs it. Adding fields - to ``Scope`` later is non-breaking — existing plugins keep working since - they just don't read the new fields. + locked, what reasons fired, and the user's devices. ``target_apps`` is + deliberately *not* in ``Scope`` yet; PLAN.md's per-rule user config (and + target_apps with it) is deferred until the first concrete agent or plugin + needs it. Adding fields to ``Scope`` later is non-breaking — existing + plugins keep working since they just don't read the new fields. +- ``DeviceRef`` — the read-only view of a device the kernel passes into + ``Scope.devices``. Frozen so plugins can't mutate it accidentally. - ``ReconcileResult`` — the return shape with ``ok()`` and ``error(msg)`` helpers so the plugin author doesn't construct it directly. - ``PluginManifest`` — what the loader parses from each plugin's @@ -31,9 +33,29 @@ class by the manifest's ``config_schema`` name and validates assignment from dataclasses import dataclass, field from enum import StrEnum +from curfew.models import DeviceOS, DeviceType from curfew.schemas import Reason +@dataclass(frozen=True, slots=True) +class DeviceRef: + """One of the user's devices, passed into ``Scope.devices``. + + A read-only projection of the ``devices`` table. Plugins that act at + MAC granularity (``adguard``, ``router-acl``, ``tailscale-acl``) read + ``mac``; plugins that act at device-slug granularity (``smart-plug``) + read ``slug``. ``managed`` is included so plugins can skip + explicitly-unmanaged devices in mixed scopes (rare today, but the + field is on the row and it's free to expose). + """ + + slug: str + type: DeviceType + os: DeviceOS + mac: list[str] + managed: bool + + @dataclass(frozen=True, slots=True) class Scope: """What ``Plugin.reconcile`` receives. @@ -47,6 +69,7 @@ class Scope: user: str locked: bool reasons: list[Reason] = field(default_factory=list) + devices: list[DeviceRef] = field(default_factory=list) class ReconcileStatus(StrEnum): diff --git a/core/curfew/plugin_runtime.py b/core/curfew/plugin_runtime.py index e4f3de2..566bd5b 100644 --- a/core/curfew/plugin_runtime.py +++ b/core/curfew/plugin_runtime.py @@ -35,9 +35,9 @@ from sqlmodel import Session, select from curfew.audit import record_audit -from curfew.models import AuditTargetKind, User +from curfew.models import AuditTargetKind, Device, User from curfew.models import Settings as SettingsRow -from curfew.plugin import Plugin, ReconcileResult, Scope +from curfew.plugin import DeviceRef, Plugin, ReconcileResult, Scope from curfew.plugin_loader import PluginRegistry from curfew.rules import user_scope @@ -222,7 +222,13 @@ async def dispatch_for_user(self, username: str) -> None: with self._session() as session: status = user_scope.evaluate(session, username) - scope = Scope(user=username, locked=status.locked, reasons=list(status.reasons)) + devices = self._load_devices(session, username) + scope = Scope( + user=username, + locked=status.locked, + reasons=list(status.reasons), + devices=devices, + ) await asyncio.gather(*[self._run_one(live, scope) for live in governing]) @@ -257,6 +263,31 @@ async def safety_net_resync(self) -> None: def _governs(live: AssignedInstance, username: str) -> bool: return "*" in live.users or username in live.users + @staticmethod + def _load_devices(session: Session, username: str) -> list[DeviceRef]: + """Return ``DeviceRef``s for the user's devices, ordered by slug. + + Empty list when the user has none, or when the user row is gone + (the dispatch was scheduled before a delete; reconcile becomes a + no-op-on-empty-devices for that user). + """ + user = session.exec(select(User).where(User.username == username)).first() + if user is None or user.id is None: + return [] + rows = session.exec( + select(Device).where(Device.owner_id == user.id).order_by(Device.slug) + ).all() + return [ + DeviceRef( + slug=d.slug, + type=d.type, + os=d.os, + mac=list(d.mac), + managed=d.managed, + ) + for d in rows + ] + async def _run_one(self, live: AssignedInstance, scope: Scope) -> None: """Call one plugin's ``reconcile`` with a timeout; audit failures.""" timeout = self._read_timeout() diff --git a/core/tests/test_plugin_runtime.py b/core/tests/test_plugin_runtime.py index 79223d5..3f4ed13 100644 --- a/core/tests/test_plugin_runtime.py +++ b/core/tests/test_plugin_runtime.py @@ -19,7 +19,7 @@ from typing import Any, ClassVar import pytest -from curfew.models import AuditLog, User, UserLock, UserRole +from curfew.models import AuditLog, Device, DeviceOS, DeviceType, User, UserLock, UserRole from curfew.models import Settings as SettingsRow from curfew.plugin import Plugin, ReconcileResult, Scope from curfew.plugin_loader import PluginManifest, PluginRegistry, PluginType @@ -175,6 +175,31 @@ def _seed_user( s.commit() +def _seed_device( + engine: Engine, + *, + owner: str, + slug: str, + mac: list[str], + type_: DeviceType = DeviceType.PC, + os_: DeviceOS = DeviceOS.WINDOWS, + managed: bool = True, +) -> None: + with Session(engine) as s: + u = s.exec(select(User).where(User.username == owner)).one() + s.add( + Device( + slug=slug, + owner_id=u.id, + type=type_, + os=os_, + mac=mac, + managed=managed, + ) + ) + s.commit() + + # --- build_instance ---------------------------------------------------------- @@ -404,6 +429,86 @@ async def go() -> None: assert len(_Recorder.calls) == 1 +def test_dispatch_populates_scope_devices( + runtime: PluginRuntime, engine: Engine, kernel_rules: None +) -> None: + """``Scope.devices`` carries the user's devices, ordered by slug.""" + _seed_user(engine, "kid1", locked=True) + _seed_device(engine, owner="kid1", slug="kid1-phone", mac=["aa:bb:cc:dd:ee:01"]) + _seed_device( + engine, + owner="kid1", + slug="kid1-laptop", + mac=["aa:bb:cc:dd:ee:02", "aa:bb:cc:dd:ee:03"], + type_=DeviceType.PC, + os_=DeviceOS.WINDOWS, + ) + + async def go() -> None: + await runtime.register( + type_name="recorder", + instance_id="default", + instance=runtime.build_instance("recorder", {}), + config={}, + users=["*"], + ) + await runtime.dispatch_for_user("kid1") + + asyncio.run(go()) + + scope = _Recorder.calls[0] + assert [d.slug for d in scope.devices] == ["kid1-laptop", "kid1-phone"] + laptop = scope.devices[0] + assert laptop.mac == ["aa:bb:cc:dd:ee:02", "aa:bb:cc:dd:ee:03"] + assert laptop.type == DeviceType.PC + assert laptop.os == DeviceOS.WINDOWS + assert laptop.managed is True + + +def test_dispatch_devices_empty_when_user_has_none( + runtime: PluginRuntime, engine: Engine, kernel_rules: None +) -> None: + _seed_user(engine, "kid1") + + async def go() -> None: + await runtime.register( + type_name="recorder", + instance_id="default", + instance=runtime.build_instance("recorder", {}), + config={}, + users=["*"], + ) + await runtime.dispatch_for_user("kid1") + + asyncio.run(go()) + + assert _Recorder.calls[0].devices == [] + + +def test_dispatch_devices_excludes_other_users( + runtime: PluginRuntime, engine: Engine, kernel_rules: None +) -> None: + """A device owned by kid2 must not show up in kid1's scope.""" + _seed_user(engine, "kid1") + _seed_user(engine, "kid2") + _seed_device(engine, owner="kid1", slug="kid1-pc", mac=["aa:bb:cc:dd:ee:01"]) + _seed_device(engine, owner="kid2", slug="kid2-pc", mac=["aa:bb:cc:dd:ee:02"]) + + async def go() -> None: + await runtime.register( + type_name="recorder", + instance_id="default", + instance=runtime.build_instance("recorder", {}), + config={}, + users=["*"], + ) + await runtime.dispatch_for_user("kid1") + + asyncio.run(go()) + + assert [d.slug for d in _Recorder.calls[0].devices] == ["kid1-pc"] + + def test_dispatch_skips_disabled( runtime: PluginRuntime, engine: Engine, kernel_rules: None ) -> None: diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 22cb3a2..4d1625e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -373,3 +373,39 @@ Each Python deliverable owns a `pyproject.toml`. The root `pyproject.toml` is a - Static-export means the frontend deployment is just files: served by FastAPI itself (`StaticFiles` mount) or any static host. Simpler ops, but no edge SSR / dynamic-per-request serving — none of which V1 needs. - Members have no UI. If a member ever needs an "am I locked? why?" view, that's a separate audience and a separate slice (likely a tiny per-device kiosk page, not an extension of this admin app). +## ADR-018: Plugin GUI extensions are schema-driven, not plugin-shipped JS + +**Decided (direction; not yet implemented):** when plugins eventually contribute to the operator UI, they do so by declaring *actions* (named methods returning typed result models) and *panels* (typed read-only data) in Python. The kernel exposes them at `POST /v1/plugins/{type}/{instance_id}/actions/{name}` and `GET /v1/plugins/{type}/{instance_id}/panels/{name}`. The SPA discovers what each plugin contributes via `GET /v1/plugins/types` and renders a generic plugin page per type — buttons for actions, tables / key-value lists for panels, all driven by the typed shapes. Plugin authors write zero JS / TSX. + +A first concrete sketch (illustrative, not a contract): + +```python +class AdGuardPlugin(Plugin): + @plugin_action(label="Push devices to AdGuard") + async def push(self) -> PushResult: ... + + @plugin_panel(label="Synced devices") + async def synced_devices(self) -> list[SyncedDeviceRow]: ... +``` + +**Status:** **not implemented.** The first crop of plugins (`adguard`, `smart-plug`, `router-acl`, `tailscale-acl`) ships headless — operators interact via API and CLI, lock state changes drive the plugins automatically. This ADR exists so the alternative (plugin-shipped JS bundles) doesn't accidentally creep in via the first plugin author who needs a button. + +**Rejected:** + +- **Plugin-shipped JS bundles** (Backstage / VS Code style). Most powerful, also most coupling: plugin authors have to learn the SPA's framework, redeploy when the SPA's framework changes, and the SPA has to sandbox third-party JS. For a homelab tool with a Python-only plugin audience, the cost dwarfs the benefit. +- **Server-rendered HTML islands embedded in the SPA.** Inherits the worst of both worlds — plugins still ship templates / HTML, and the SPA still has to render them. Doesn't simplify anything. +- **Going back to MPA / Jinja templates** (revisiting ADR-017's frontend choice). Doesn't actually fix the problem; even an MPA needs a contribution mechanism, and the answer would still look like schema-driven actions + panels. Throwing away the SPA to solve a plugin-extension problem is the wrong shape. +- **No plugin UI at all, ever.** Reasonable for the kernel slice; not workable long-term once plugins have operator-relevant views (synced devices, last reconcile result, push/purge buttons). + +**Trade-offs accepted:** + +- Plugin authors are constrained to the widget set the SPA implements. Adding a new widget shape (chart, log tail, multi-select) requires coordinated kernel-SDK + SPA work. For the realistic plugin set the widget set is small (one table panel + a couple of buttons each), so this is a deliberate limit, not an oversight. +- The kernel-SDK and SPA evolve together for plugin UI changes. Plugin authors get stability in exchange — once a panel/action shape exists, every plugin can use it without per-plugin SPA work. +- The plugin-action surface adds a second method-dispatch axis to plugins beyond `reconcile`. Action implementations need to be careful about idempotency and timeouts the same way `reconcile` does — but the runtime can reuse the same audit + timeout machinery. + +**Why a kernel commitment now (the ADR, not the implementation):** + +- The first plugin (`adguard`) ships headless. The first operator request after that will be "can I have a push button?" — and the temptation to just ship a JS file from the plugin is real. This ADR prevents that path from being taken in a hurry. +- Smart-plug, router-acl, and tailscale-acl all want similar UI shapes (a table of governed devices, a button or two). Picking schema-driven now means the contribution shape is uniform across them when the implementation lands. +- The decision is independent of when the implementation happens. Capturing it as an ADR is cheap; capturing it after three plugins have each invented their own UI is expensive. + diff --git a/plugins/reftest-plugin/plugin.py b/plugins/reftest-plugin/plugin.py index 65b9e08..ea8f6f6 100644 --- a/plugins/reftest-plugin/plugin.py +++ b/plugins/reftest-plugin/plugin.py @@ -28,13 +28,20 @@ class Config(BaseModel): - """Where to write the sentinel. + """Where to write the sentinels. - Tests typically point this at ``tmp_path / "sentinel"``; live smoke - tests use something like ``/tmp/curfew-reftest-sentinel``. + Tests typically point ``sentinel_path`` at ``tmp_path / "sentinel"``; + live smoke tests use something like ``/tmp/curfew-reftest-sentinel``. + + ``devices_sentinel_path`` is an opt-in second sentinel that records + the comma-separated list of device slugs the kernel passed in + ``Scope.devices``. Used by the kernel acceptance test for the + ``Scope.devices`` extension; left ``None`` for the simpler tests + that just assert lock state propagated. """ sentinel_path: str + devices_sentinel_path: str | None = None class ReftestPlugin(Plugin): @@ -47,4 +54,12 @@ async def reconcile(self, scope: Scope) -> ReconcileResult: path.write_text(scope.user) else: path.unlink(missing_ok=True) + + if self.config.devices_sentinel_path is not None: + dpath = Path(self.config.devices_sentinel_path) + if scope.locked: + dpath.write_text(",".join(d.slug for d in scope.devices)) + else: + dpath.unlink(missing_ok=True) + return ReconcileResult.ok()