feat: finish the ai plugin (B18)

This commit is contained in:
LukeGus committed 2026-09-24 13:43:26 -05:00
1 parent 6b4d1432ab
commit 21937a6b02
193 files changed
+23429 -4244

No files matched your search

+53 -6
View File
@@ -457,6 +457,7 @@ boundary and Suspense.
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
@@ -767,6 +768,29 @@ 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.
**AI assistant (B18)** imports nothing from core. It adopts `ai_providers`,
`ai_conversations`, `ai_messages` and `ai_proposals` (the migrations keep the
conversation foreign keys by hand), keeps provider keys in `ctx.secrets`,
reaches providers through `ctx.fetch` with the admin's private endpoint list
as `allowPrivateHosts`, and runs approved commands through
`ctx.ssh.withConnection`. Hosts come from `ctx.hosts` (proposals use
`create`, `update` and `delete`, after checking the approving user's core
`hosts.*` permission), channels from `ctx.notify.channels()`, and everything
else from optional services: `snippets.access`, `fleets.access`,
`automations.access`, `workspaces.saved`, `network-topology.graph`,
`terminal.history` and `homepage.items` v1 (`list()`, which the homepage
plugin provides from B19). A tool names the service it needs, and a tool
whose service has no running provider is not offered to the model at all. B18
made the write methods of `snippets.access` and `fleets.access` check the
same permission their routes do (`snippets.create`/`edit`/`delete`,
`fleets.manage`), since a proposal runs as the approving user. Its settings
are the admin `globallyEnabled` and `privateEndpoints`, the user `enabled`
and `allowReadOnlyCommands` plus a custom `providers` field, and the host
`enableAiAssistant`, which decides whether its `terminal.toolbar` button and
`terminal.sidePanel` appear. `ai-settings-migration.ts` moves the 2.8 values
and keys. The shell knows nothing about it: availability is the plugin being
enabled, `ai.use`, and those settings.
#### Host status is core
The status dot in the host list, the dashboard and the homepage widget is
@@ -1380,6 +1404,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
@@ -1389,7 +1414,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
@@ -1400,7 +1425,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `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` | **B17** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other
@@ -1609,6 +1634,28 @@ notification)` delivers to the ones among them that are theirs and
passes the admin allowlist it reads through `ctx.settings.readCore`
(`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`).
**B18** added three members for the AI assistant:
- `ctx.secrets.get(key)` / `.set(key, value)` / `.delete(key)` store a
plugin's own secrets for the acting user, next to the `offer` side of
`ctx.secrets`. Rows live in `plugin_settings` under the `secret` scope
(`scope_id` the user id), encrypted with `encryptSystemSecret`, so they go
with the plugin on uninstall and with the user when `UserRepository.delete`
removes that user's rows. The settings service is manifest-driven and never
reads them. All three need `secrets:own` and an actor; `set` and `delete`
are audited without the value, `get` only checks the capability, like
`ctx.db.persist`, because a plugin reads its keys on every request that
uses them. The AI assistant keeps each provider's key under
`provider:<id>` instead of in its providers table.
- `ctx.fetch` takes a `signal`. It aborts the request and a streamed body;
`timeoutMs` still only covers the wait for the headers, so a long model
stream is not cut.
- `ctx.hosts.delete(hostId)` deletes a host the actor owns through core's
own delete path (`hosts/delete-host.ts`, shared with the host editor's
route: access grants, activity, plugin settings, the tombstone and the
`host.deleted` event). Needs `hosts:write`, and the actor needs the core
`hosts.delete` permission. False for a host that is missing or not theirs.
`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
@@ -2139,8 +2186,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), host-metrics (B16) and automations
(B17), which import
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17) and ai (B18), 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
@@ -2168,8 +2215,8 @@ 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 | 64 files | D1 |
| A plugin importing core by relative path | Warning | 12 files | D1 |
| A plugin frontend importing core through `@/` | Warning | 55 files | D1 |
| A plugin importing core by relative path | Warning | 4 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
+28 -7
View File
@@ -374,10 +374,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
brings `enable_docker` across, the same gap every earlier column move left
there: it writes host rows only and never plugin settings. Owner: D1, with
the generic host-settings export and import.
- **B15 (docker), for B18:** the AI assistant's `list_hosts`/`get_host`
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.
- **B17 (automations), for B20:** the wake-on-lan step calls the optional
`wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
@@ -408,9 +404,34 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
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.
- **B18 (ai), for B19:** the assistant's `list_homepage_items` tool calls
the optional `homepage.items` v1 service, `list(): Promise<Array<{ id,
typeId, title }>>`, as the acting user. Until B19 provides it the tool is
not offered to the model. Owner: B19.
- **B18 (ai):** the "allow read-only diagnostic commands" user setting only
adds a line to the system prompt. No tool runs a command directly;
`tools/command-allowlist.ts` (`isReadOnlyCommand`) is tested but unused, so
every command is still a proposal. Either add the direct tool or drop the
setting. Owner: D0.
- **B18 (ai):** provider requests now go through `ctx.fetch`, which has no
outbound proxy support. Before, an admin-allowlisted private provider went
through `fetchWithProxy` (public ones never did). Decide whether
`ctx.fetch` should honour the proxy settings. Owner: D0.
- **B18:** `scripts/generate-dialect-schema.cjs` decides varchar or text by
column name across every table, so removing the ai indexes on
`created_at`, `updated_at` and `label` turned those columns into `text`
everywhere in `schema.pg.ts` and `schema.mysql.ts`. drizzle-kit then wrote
about a hundred type changes, which B18 left out of
`drizzle/postgres/0048` and `drizzle/mysql/0047` (both a no-op), so real
databases keep `varchar(255)` while the snapshots say `text`. Key the
generator per table, regenerate, and check `verify:dialect`. Owner: D0.
- **B18:** saving a plugin setting from the Settings screen tells nobody. The
ai frontend re-reads its status on `termix:plugins-changed` and on window
focus (throttled to 30 seconds), so an admin turning it on shows up on the
next focus. A settings-changed hook on the app object would be cleaner.
Owner: D1.
- **B18:** tunnels builds its own SSE URL off the axios base instead of the
new `app.fetch`. Owner: D1.
## Manual checks after 2.9.0
+23
View File
@@ -219,6 +219,19 @@ export interface PluginServices {
}
export interface PluginSecrets {
/**
* The acting user's own copy of a secret this plugin stored, decrypted, or
* null when nothing is stored. Needs secrets:own and an actor. Not audited:
* a plugin reads its keys on every request that uses them.
*/
get: (key: string) => Promise<string | null>;
/**
* Stores a secret for the acting user, encrypted at rest. Null clears it.
* Needs secrets:own and an actor. Audited without the value.
*/
set: (key: string, value: string | null) => Promise<void>;
/** Removes the acting user's secret. Needs secrets:own. Audited. */
delete: (key: string) => Promise<void>;
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
@@ -580,6 +593,14 @@ export interface PluginHosts {
hostId: number,
patch: PluginHostUpdateInput,
) => Promise<PluginHostRecord | null>;
/**
* Deletes a host the acting user owns, the same way the host editor's
* delete does: its access grants, activity and plugin settings go with it
* and remote sync gets a tombstone. Needs hosts:write, and the acting user
* needs the core hosts.delete permission. False when the host does not
* exist or is not theirs.
*/
delete: (hostId: number) => Promise<boolean>;
/**
* Every host the acting user owns, decrypted and wide (PluginHostRecord,
* not the narrow list() summary). For a plugin that scans its own hosts in
@@ -1317,6 +1338,8 @@ export interface PluginFetchInit {
* private is refused, and redirects are never followed.
*/
allowPrivateHosts?: readonly string[];
/** Aborts the request, and a streamed body, when the caller gives up. */
signal?: AbortSignal;
}
/**
+5
View File
@@ -600,6 +600,11 @@ export interface TermixApp extends TermixAppInfo {
* desktop app's connected remote server, anything else is `api`.
*/
apiFor: (origin?: unknown) => PluginApiClient;
/**
* A raw fetch on /plugin-api/<id>/<path> with core's auth, for a response
* read as a stream (server-sent events), which `api` cannot do.
*/
fetch: (path: string, init?: RequestInit) => Promise<Response>;
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
wsUrl: (
path: string,
+37
View File
@@ -185,6 +185,8 @@ export interface FakePluginContext {
channelIds: number[];
notification: PluginNotification;
}>;
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */
fetches: Array<{ url: string; init?: PluginFetchInit }>;
/** Every ctx.audit.record entry, in order. */
@@ -290,6 +292,7 @@ export function createFakeContext(
const credentialReads: FakePluginContext["credentialReads"] = [];
const notifications: FakePluginContext["notifications"] = [];
const fetches: FakePluginContext["fetches"] = [];
const secretStore = new Map<string, string>();
const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
@@ -491,6 +494,14 @@ export function createFakeContext(
},
secrets: {
get: async (key) => secretStore.get(`${actor ?? ""}:${key}`) ?? null,
set: async (key, value) => {
if (value === null) secretStore.delete(`${actor ?? ""}:${key}`);
else secretStore.set(`${actor ?? ""}:${key}`, value);
},
delete: async (key) => {
secretStore.delete(`${actor ?? ""}:${key}`);
},
offer: () => {},
withdraw: () => false,
getShared: async () => null,
@@ -629,6 +640,11 @@ export function createFakeContext(
hostRecordsById.set(hostId, updated);
return updated;
},
delete: async (hostId: number): Promise<boolean> => {
const existed =
hostRecordsById.delete(hostId) || hostsById.delete(hostId);
return existed;
},
listOwned: async (): Promise<PluginHostRecord[]> => [
...hostRecordsById.values(),
],
@@ -836,6 +852,7 @@ export function createFakeContext(
credentialReads,
notifications,
fetches,
secretStore,
audits,
activities,
trackedSessions,
@@ -1076,6 +1093,10 @@ export function createMockCtx(
require("hosts:write");
return ctx.hosts.update(hostId, patch);
},
delete: async (hostId) => {
require("hosts:write");
return ctx.hosts.delete(hostId);
},
listOwned: async () => {
require("hosts:write");
return ctx.hosts.listOwned();
@@ -1220,6 +1241,22 @@ export function createMockCtx(
return ctx.fetch(url, init);
},
secrets: {
...ctx.secrets,
get: async (key) => {
require("secrets:own");
return ctx.secrets.get(key);
},
set: async (key, value) => {
require("secrets:own");
return ctx.secrets.set(key, value);
},
delete: async (key) => {
require("secrets:own");
return ctx.secrets.delete(key);
},
},
capabilities: {
has: async (capability) => granted.has(capability),
require: async (capability) => {