mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: finish the ai plugin (B18)
This commit is contained in:
1 parent
6b4d1432ab
commit
21937a6b02
193 files changed
+23429
-4244
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user