feat: finish the automations plugin (B17)

This commit is contained in:
LukeGus committed 2026-09-24 13:05:44 -05:00
1 parent 993a91c899
commit 6b4d1432ab
96 files changed
+25106 -5077

No files matched your search

+66 -13
View File
@@ -598,7 +598,10 @@ The `docker` and `hostMetrics` areas are still declared in core's
status for a host, from the shell's own polling, or null outside the shell.
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
`ConnectionStage` type, for remote desktop's display socket and its remote
host lookup. `useConnectionDefaults` now carries terminal defaults only; the
host lookup. **B17** added `NotificationChannelDialog` and the channel API
(`getNotificationChannels`, `deleteNotificationChannel`,
`testNotificationChannel`, the `NotificationChannel` type): channels are core,
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the
RDP half became remote desktop's user settings. Publishing its
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
@@ -732,6 +735,38 @@ the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
`TabOptions`/`TabHandle` are gone. Core's active connections list gets the
sessions shared with a user from the `sessions.sharedWithMe` action.
**Automations (B17)** imports nothing from core. It reaches snippets
(`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
(`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1,
`wake(hostId)`, which B20 provides) as services, notification channels through
`ctx.notify`, the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and
`ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run
executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's.
The scheduler tick is `ctx.schedule.every`, and triggers listen through
`ctx.events` to `host.status`, `host.login`, `internal.event`,
`plugin.host-metrics.snapshot`, `plugin.host-metrics.health-check`,
`plugin.tunnels.tunnel_disconnected` and its own
`plugin.automations.automation_failed`, which replaced the core
`automation_failed` internal event.
A definition's trigger and steps say which of those optional plugins it needs
(`requirements.ts`). An automation whose provider is not running is skipped:
the run is recorded as `skipped` with "Needs the <plugin> plugin", the reason
is logged, and the engine carries on with the next one. The editor asks
`GET /editor-options` for the running providers and only offers the triggers
and steps they allow, and the list marks such an automation "Needs <plugin>".
It provides `automations.access` v1 (`list`, `get`, `create`, `run`, gated
on `automations.view`, with `create` and `run` also checking their own
permissions) for the AI assistant, which lists automations as an optional
dependency. It adopts `automations` and the five `automation_*` tables; the
migrations keep the legacy links between them and to `notification_channels`
by hand. Core's `hosts/automation-events.ts` became `hosts/internal-events.ts`
(`emitInternalEvent`), since it never was specific to automations. Webhooks
live at `/plugin-api/automations/webhook/<token>`; the 2.8 path
`/automations/webhook/<token>` is rewritten to it in both nginx configs,
because external systems hold that URL.
#### Host status is core
The status dot in the host list, the dashboard and the homepage widget is
@@ -1358,14 +1393,14 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | B |
| `ctx.fetch` | `network:outbound` | **B17** |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other
@@ -1555,6 +1590,25 @@ per-host timers spreads out. Needs no capability.
the debounced save write it, for frequent low-value writes such as metrics
samples; the default still saves before returning.
**B17** added `ctx.notify` and `ctx.fetch`:
- `ctx.notify.channels()` lists the acting user's notification channels
(id, name, type, enabled, never the config), and `send(channelIds,
notification)` delivers to the ones among them that are theirs and
enabled, returning `{ delivered, failures }`. Both need `notify:send` and
an actor, and are audited (`plugin_notify_channels`, `plugin_notify_send`).
Channels stay core (`notification_channels`, its routes and
`NotificationChannelDialog`); delivery is `deliverNotification` in
`utils/notification-sender.ts`, which keeps the channel's private-network
opt-in plus the admin allowlist.
- `ctx.fetch(url, { method, headers, body, timeoutMs, allowPrivateHosts })`
goes through `safeOutboundFetch`: http and https only, no embedded
credentials, DNS pinned, no redirects, and private or loopback addresses
refused unless the exact host is in `allowPrivateHosts`. Needs
`network:outbound`, audited as `plugin_fetch`. Automations' HTTP step
passes the admin allowlist it reads through `ctx.settings.readCore`
(`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`).
`ctx.hosts.create` (**B14**) hands the created row to every plugin's
`hostImportNormalizer`, the same as a bulk import row, so a plugin creating a
host can set another plugin's host settings (proxmox marks an imported
@@ -2085,7 +2139,8 @@ Then, with the app running:
## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15) and host-metrics (B16), which import
(B2), remote-desktop (B14), docker (B15), host-metrics (B16) and automations
(B17), which import
nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
@@ -2113,9 +2168,9 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 70 files | D1 |
| A plugin importing core by relative path | Warning | 24 files | D1 |
| A plugin importing another plugin's source | Warning | 1 file | B18 |
| A plugin frontend importing core through `@/` | Warning | 64 files | D1 |
| A plugin importing core by relative path | Warning | 12 files | D1 |
| A plugin importing another plugin's source | Warning | 0 files | - |
A warning does not fail a build, so the counts are held by
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
@@ -2131,12 +2186,10 @@ rule cannot carry both severities on the same files.
Known specifics:
- **TODO(B18):** `plugins/ai/src/backend/tools/executor.ts` imports
`automations`' `routes.js` directly. It is declared as a hard `dependencies`
entry so the loader starts automations first and marks `ai` blocked if
automations is disabled. In B18 `ai` calls automations through an
`automations` service as an `optionalDependency` and the direct import goes
away.
- **B17** removed the last plugin-to-plugin import: ai's proposal executor
and automation read tools call automations through the `automations.access`
service, and automations is an optional dependency of ai rather than a hard
one.
- **B16** moved host-metrics onto the SDK: it imports nothing from core or
automations. Automations reaches it through the `host-metrics.viewers`
service and its `plugin.host-metrics.*` events, and core reaches it through
+44 -20
View File
@@ -334,16 +334,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
plugins through `tsconfig.plugins*.json` and passes. Owner: D0.
- **B16 (host-metrics):** automations still sets up its headless viewers
through `setViewerRegistry` in `plugins/automations/src/backend/headless-viewer.ts`,
which nothing calls any more, so hosts an automation watches are only
sampled while somebody has them open. Switch it to the
`host-metrics.viewers` service (`register(hostId)` as the automation's
owner, `heartbeat`, `unregister`) and make host-metrics an optional
dependency again. Its triggers already listen to the final topics
(`plugin.host-metrics.snapshot`, `plugin.host-metrics.health-check`, core
`host.status`, `host.login`), but through core's bus by relative import
instead of `ctx.events`. Owner: B17.
- **B16 (host-metrics):** Termix-Mobile read `statsConfig` on each host
(`statusCheckEnabled`, `enabledWidgets`) and the host status from the
metrics API. The host payload now carries `statusCheckEnabled` and
@@ -367,12 +357,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`widgets/network-collector.ts`) to compute rates. The poller clears them on
deactivate and per host, so nothing leaks, but they are still module state
rather than per-activation. Owner: D1.
- **Pre-existing, found in B16:** automations
`tests/backend/tunnels.test.ts` fails "fails the step cleanly while the
tunnels plugin is off": it expects "tunnels plugin is not available" and
gets "tunnels.start is not a function". Same cause as the tunnels line
above: since B12 `ctx.services.get` hands back an empty handle instead of
throwing, so the step has to check `typeof start`. Owner: B17 or D0.
- **B15 (docker), for D2:** the console socket `/plugin-ws/docker/console`
is a public route with `optionalAuth`. The handler refuses a socket with no
signed-in user or without `docker.use` and checks the data key per connect
@@ -394,10 +378,39 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
tools still report `enableDocker` from the host row, which no longer has
the column, so it always reads null. Read `pluginSettings.docker` or ask the
`docker.containers` service instead. Owner: B18.
- **B15 (docker), for B17:** automations' `docker-watcher.ts` still reads its
own rules through core's automation repository and types by relative
import; only the Docker side is on the SDK (`docker.containers` for the
step, `docker.events` for the trigger). Owner: B17.
- **B17 (automations), for B20:** the wake-on-lan step calls the optional
`wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
automation's owner; the provider resolves the host's MAC and checks access
itself. Until B20 provides it, the step is offered nowhere and an existing
one is skipped with "Needs the wake-on-lan plugin". Owner: B20.
- **B17 (automations), for D2:** the ctx table says `ctx.events.on` needs
`events:core` for core topics, but the runtime only checks `emit`.
docker, file-manager, host-metrics, session-recording and snippets
subscribe to core topics (`host.*`, `user.*`) without declaring it.
Either gate `on` and add `events:core` to those five, or change the doc to
say subscribing is ungated. Owner: D2.
- **B17 (automations):** the 2.8 webhook URL `/automations/webhook/<token>`
is kept only by a rewrite in `docker/nginx.conf` and
`docker/nginx-https.conf`. An install that serves the backend without
nginx (the desktop app's embedded backend, a hand-rolled proxy) answers
404 there. Decide whether that matters enough for a generic legacy-path
alias in the plugin HTTP layer. Owner: D0.
- **B17 (automations):** there are two notification senders now:
`deliverNotification` (ctx.notify, per-channel private opt-in, throws) and
the older `sendWebhook`/`sendNtfy`/`sendDiscord` that the channel "test"
route in `notification-channels-routes.ts` still uses (always applies the
allowlist, retries once). Point the test route at `deliverNotification`
and delete the old ones. Owner: D1.
- **B17 (automations):** the scheduler no longer skips a user whose data key
cannot be resolved (`DataCrypto.canUserAccessData` has no SDK equivalent).
With system-wrapped DEKs that check was effectively always true; if it
ever is not, the run now fails on its first SSH step instead of being
skipped silently. Owner: none unless a background "is this user unlocked"
check is added to the SDK.
- **B17 (automations):** AI's `list_notification_channels` read tool still reads
notification channels through core's repository by relative import; it
can use `ctx.notify.channels()` now. Owner: B18.
## Manual checks after 2.9.0
@@ -444,3 +457,14 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
with no stored password asks for credentials; the homepage Docker widget;
an automation Docker step and a `docker_event` trigger; disable Docker with
a console open, then enable it and open another.
- Automations: on a 2.8 database the automations, their run history and
their channel links are there; a schedule fires; a webhook fires on both
`/plugin-api/automations/webhook/<token>` and the old
`/automations/webhook/<token>`; a metric threshold on a host nobody has
open still fires (headless viewers); a `docker_event` trigger and a Docker
step; a notify step to webhook, ntfy and Discord channels, and to a LAN
ntfy with the private opt-in plus the admin allowlist; disable Docker and
the automation shows "Needs docker", its scheduled runs are recorded as
skipped and the others keep running, then enable it again; the AI
assistant lists and creates automations, and says they are unavailable
with automations off.
+78
View File
@@ -1255,6 +1255,80 @@ export interface PluginAudit {
}) => Promise<void>;
}
/** A notification channel the acting user set up. Its config never leaves core. */
export interface PluginNotificationChannel {
id: number;
name: string;
/** "webhook", "ntfy" or "discord". */
type: string;
enabled: boolean;
}
export interface PluginNotification {
title: string;
body: string;
severity?: "info" | "warning" | "critical";
/**
* Extra fields a webhook receiver gets alongside the message. `sourceId`
* and `sourceName` name what sent it (an automation, a rule).
*/
context?: {
hostId?: number;
hostName?: string;
sourceId?: number | string;
sourceName?: string;
triggerType?: string;
value?: unknown;
threshold?: unknown;
};
}
export interface PluginNotifyResult {
delivered: number;
failures: Array<{ channelId: number; name: string; error: string }>;
}
/**
* Core's notification channels, as the acting user. Needs notify:send, and
* refuses a call with no actor: channels belong to a user.
*/
export interface PluginNotify {
/** The acting user's channels, without their config. */
channels: () => Promise<PluginNotificationChannel[]>;
/**
* Sends to the listed channels the acting user owns. Disabled channels and
* ids the user does not own are skipped. One channel failing does not stop
* the others.
*/
send: (
channelIds: number[],
notification: PluginNotification,
) => Promise<PluginNotifyResult>;
}
export interface PluginFetchInit {
method?: string;
headers?: Record<string, string>;
body?: string;
/** Aborts the request after this long. Defaults to 30 seconds. */
timeoutMs?: number;
/**
* Exact private or loopback hosts this request may reach. Everything else
* private is refused, and redirects are never followed.
*/
allowPrivateHosts?: readonly string[];
}
/**
* Outbound HTTP through core's SSRF guard: http and https only, no embedded
* credentials, DNS pinned, private addresses refused unless listed. Needs
* network:outbound.
*/
export type PluginFetch = (
url: string,
init?: PluginFetchInit,
) => Promise<Response>;
/**
* A generic capability check, for a privileged action that has no dedicated
* ctx method to wrap it: a plugin bundling its own native dependency to touch
@@ -1325,6 +1399,10 @@ export interface PluginContext {
readonly audit: PluginAudit;
/** Timers that stop on deactivate. */
readonly schedule: PluginSchedule;
/** Core's notification channels. Needs notify:send. */
readonly notify: PluginNotify;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch;
/**
* Runs `fn` with `userId` as the acting user, for background work that has
+66 -1
View File
@@ -56,6 +56,9 @@ import type {
PluginNativeRdpRequest,
PluginProtocolTarget,
PluginHostStatusEntry,
PluginNotification,
PluginNotificationChannel,
PluginFetchInit,
} from "./backend.js";
/** A timer registered through ctx.schedule, run by hand with runScheduled. */
@@ -108,6 +111,13 @@ export interface FakeContextOptions {
desktopAvailable?: boolean;
/** What ctx.hosts.status.get and check answer, by host id. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
* Answers ctx.fetch. Without it every fetch rejects, so a test never
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
}
export interface FakeAuthRegistrations {
@@ -169,6 +179,14 @@ export interface FakePluginContext {
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.notify.send call, with the actor it ran as. */
notifications: Array<{
actor: string | undefined;
channelIds: number[];
notification: PluginNotification;
}>;
/** Every ctx.fetch call, in order. */
fetches: Array<{ url: string; init?: PluginFetchInit }>;
/** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */
@@ -270,6 +288,8 @@ export function createFakeContext(
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const credentialReads: FakePluginContext["credentialReads"] = [];
const notifications: FakePluginContext["notifications"] = [];
const fetches: FakePluginContext["fetches"] = [];
const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
@@ -766,6 +786,23 @@ export function createFakeContext(
},
},
notify: {
channels: async () => options.notificationChannels ?? [],
send: async (channelIds, notification) => {
notifications.push({ actor, channelIds, notification });
const selected = (options.notificationChannels ?? []).filter(
(channel) => channelIds.includes(channel.id) && channel.enabled,
);
return { delivered: selected.length, failures: [] };
},
},
fetch: async (url, init) => {
fetches.push({ url, init });
if (!options.fetch) throw new Error("ctx.fetch is not stubbed");
return options.fetch(url, init);
},
asUser: async (userId, fn) => {
const previous = actor;
actor = userId;
@@ -797,6 +834,8 @@ export function createFakeContext(
desktopWindows,
nativeRdpLaunches,
credentialReads,
notifications,
fetches,
audits,
activities,
trackedSessions,
@@ -852,6 +891,13 @@ export interface MockContextOptions {
desktopAvailable?: boolean;
/** What ctx.hosts.status answers. See FakeContextOptions. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
* Answers ctx.fetch. Without it every fetch rejects, so a test never
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
}
export interface MockPluginContext extends FakePluginContext {
@@ -866,7 +912,8 @@ export interface MockPluginContext extends FakePluginContext {
* The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, and
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify, network:outbound on ctx.fetch, and
* auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
@@ -891,6 +938,8 @@ export function createMockCtx(
protocolTargets: options.protocolTargets,
desktopAvailable: options.desktopAvailable,
hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels,
fetch: options.fetch,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1155,6 +1204,22 @@ export function createMockCtx(
},
},
notify: {
channels: async () => {
require("notify:send");
return ctx.notify.channels();
},
send: async (channelIds, notification) => {
require("notify:send");
return ctx.notify.send(channelIds, notification);
},
},
fetch: async (url, init) => {
require("network:outbound");
return ctx.fetch(url, init);
},
capabilities: {
has: async (capability) => granted.has(capability),
require: async (capability) => {