diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md
index 223876b06..5d640fa78 100644
--- a/packages/plugin-sdk/ARCHITECTURE.md
+++ b/packages/plugin-sdk/ARCHITECTURE.md
@@ -254,8 +254,8 @@ handler)` for a socket. Core runs the same middleware in front of every plugin:
1. **Auth**, unless the path is listed in `options.public`. A public path is
audited when the router is registered and logged on every request, because
"this plugin opened a hole in auth" should be findable later. **D2** lists
- a running plugin's public HTTP paths and public socket paths in the admin
- plugin dialog (`publicRoutes` on `GET /plugins`), and audits a public
+ a running plugin's public HTTP paths and public socket paths as
+ `publicRoutes` on `GET /plugins` (admins only), and audits a public
socket when it is registered. It matches the
full path, with `:param` segments allowed, so declaring `/webhook/:token`
public cannot open `/webhook/:token/anything`. A trailing `/*` is the one
@@ -360,8 +360,22 @@ the plugin's own permissions exactly as `ctx.rbac` does.
`hidden: true` (**B14**) keeps a field out of the generic form: it is stored,
validated and encrypted like any other, but the plugin edits it in its own
UI. Remote desktop's host options are hidden fields, edited in its RDP, VNC
-and Telnet host editor tabs, so they do not appear a second time in the
-Plugins tab.
+and Telnet host editor tabs.
+
+**Where fields render.** In 2.9.0 the plugin system is invisible to users:
+there is no plugin list, enable switch or capability dialog, and nothing in
+the UI says "plugin". That UI arrives with the store in 3.0.0. Settings show
+up as features instead:
+
+- `user` fields render as an accordion section named after the plugin in the
+ User Profile panel, and `admin` fields do the same in the Admin Settings
+ panel (`src/ui/settings/FeatureSettingsSections.tsx`). `group` becomes a
+ card inside that section.
+- `host` fields get a host editor tab of their own, named after the plugin
+ (`src/ui/settings/host-feature-tabs.tsx`). `editorGroup` (`"top"` or
+ `"ssh"`, default `"top"`) and `editorOrder` place it among the other tabs.
+ A plugin that registers its own section with `registerHostEditorSection`
+ gets no generated tab, so it must draw any non-hidden host field itself.
`type: "custom"` names a component the frontend registered, for the few things
a schema cannot express (a device browser, a provider list). It is deliberately
@@ -419,8 +433,8 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
**D0** added three pieces:
-- `onValidate(scope, validator)` checks a save from the settings screen or the
- host editor before anything is written. The validator gets every value in
+- `onValidate(scope, validator)` checks a save from the profile or admin
+ panel or the host editor before anything is written. The validator gets every value in
the save (and the host id for host scope) and returns field key to message
for what is wrong. ssh-terminal checks that its image paths are absolute,
step-ca that its CA URL is https and its fingerprint a SHA-256 digest.
@@ -645,6 +659,13 @@ answers undefined while its plugin is off.
shared session") and replaces `ShellApi.openTerminalTab`, which the shell no
longer has.
+`ShellApi.openHostEditor(draft?)` opens the host editor for a new host with
+a `HostDraft` (name, ip, port, username, authType) filled in, which the user
+reviews and saves (tailscale's "Add host" on a device). `saveQuickConnect`
+saves only what core's own quick connect payload carries, so the terminal
+offers it only on a host with `quickConnectSavable`, which core sets for the
+password, key, credential, none and agent auth types.
+
**Components by id.** `app.registerComponent(id, component)` offers a
component to other plugins and core, which render it with `PluginComponent`
from `@termix/plugin-sdk/ui` (a fallback while the owner is off) or
@@ -766,7 +787,7 @@ mode are still preset-seeded localStorage keys core writes, as in 2.8.
origin, as `app.apiFor` but usable in a component. The terminal toolbar's
metrics bars use it for a host on the desktop app's remote server, and
tunnels merges the remote server's statuses through `app.apiFor("remote")`.
-- `app.onSettingsChanged(listener)`: fires after the settings screen or host
+- `app.onSettingsChanged(listener)`: fires after the profile panel, admin panel or host
editor saved one of the plugin's settings, instead of polling on focus.
- A `tab.menu` slot: entries in a tab's right-click menu, invoked with the
tab's surface handle; `when` sees `{ tab, handle }`. Session sharing adds its
@@ -1258,8 +1279,8 @@ base types and branches on no plugin type name.
in it. Its public routes are `/plugin-api/sso/start` (sends the browser to
the provider), `/callback` (GET and POST), `/backchannel-logout` and
`/config`; provider management is `/plugin-api/sso/providers`, gated on
- `sso.manage`, and renders as the custom `providers` field on the plugin's
- admin settings page. Login state (nonce, PKCE verifier, return address)
+ `sso.manage`, and renders as the custom `providers` field in the plugin's
+ Admin Settings section. Login state (nonce, PKCE verifier, return address)
lives in `ctx.kv` for ten minutes.
- The redirect URI is `/plugin-api/sso/callback`. Providers that
existed before 2.9 keep sending `/users/oidc/callback`, the URI their
@@ -1937,6 +1958,8 @@ not do.
},
],
"host": {
+ "editorGroup": "ssh", // host editor strip for the generated tab, default "top"
+ "editorOrder": 30, // position among that strip's tabs
"enableKey": "enableExample", // boolean rendered first, gating the rest
"enableLabelKey": "k",
"fields": [
@@ -2696,8 +2719,8 @@ None of the checks in this document stop that. They are not a sandbox.
`system:tls`, `ssh:connect`, `process:spawn`, `users:write`,
`auth:provide`, `device:serial`) means the plugin can reach your hosts,
your users or the server itself. Ask why it needs each one.
-- **Read the public routes.** Admin > Plugins > Permissions lists every path
- it serves without login. Each one is attack surface on your server.
+- **Read the public routes.** `publicRoutes` on `GET /plugins` lists every
+ path it serves without login. Each one is attack surface on your server.
- **Check its dependencies.** A plugin's npm dependencies run with the same
reach as the plugin.
- **Watch the audit log after installing.** Filter on `plugin:` and look
diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs
index fe9e20de6..d7ac52d68 100644
--- a/packages/plugin-sdk/cli/commands/build.mjs
+++ b/packages/plugin-sdk/cli/commands/build.mjs
@@ -54,6 +54,13 @@ export async function build({ cwd }) {
},
});
+ // Without this Node finds the host's typeless package.json, tries backend.js
+ // as CommonJS first and warns before reparsing it as ESM.
+ fs.writeFileSync(
+ path.join(outDir, "package.json"),
+ `${JSON.stringify({ type: "module" }, null, 2)}\n`,
+ );
+
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) {
await esbuild.build({
diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json
index fe41d523c..ad72eddec 100644
--- a/packages/plugin-sdk/schema/manifest.schema.json
+++ b/packages/plugin-sdk/schema/manifest.schema.json
@@ -793,6 +793,14 @@
"enableDefault": {
"type": "boolean",
"description": "What the enable switch reads before a host saves it. Off by default."
+ },
+ "editorGroup": {
+ "enum": ["top", "ssh"],
+ "description": "Host editor strip the generated tab sits in. Defaults to top."
+ },
+ "editorOrder": {
+ "type": "number",
+ "description": "Position among that strip's tabs, core ones included."
}
}
}
diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts
index f96553956..4a83ffa42 100644
--- a/packages/plugin-sdk/src/frontend.ts
+++ b/packages/plugin-sdk/src/frontend.ts
@@ -74,6 +74,15 @@ export interface ShellLayout {
}
/** What a plugin may ask of the shell. */
+/** Fields a plugin can fill in when it opens the host editor. */
+export interface HostDraft {
+ name?: string;
+ ip?: string;
+ port?: number;
+ username?: string;
+ authType?: string;
+}
+
export interface ShellApi {
openTab: (
host: PluginHostRecord | null,
@@ -88,6 +97,11 @@ export interface ShellApi {
tab: PluginTabRecord,
host: PluginHostRecord,
) => Promise;
+ /**
+ * Opens the host editor for a new host with these fields filled in. The
+ * user reviews and saves it. Absent in some shells.
+ */
+ openHostEditor?: (draft?: HostDraft) => void;
/** Opens a rail view in the left sidebar. */
openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown. */
diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts
index 2349923d7..55ae53395 100644
--- a/packages/plugin-sdk/src/manifest.ts
+++ b/packages/plugin-sdk/src/manifest.ts
@@ -271,6 +271,10 @@ export interface PluginHostSettingsContribution {
enableLabelKey?: string;
/** What the enable switch reads before a host saves it. Off by default. */
enableDefault?: boolean;
+ /** Host editor strip the generated tab sits in. Defaults to "top". */
+ editorGroup?: "top" | "ssh";
+ /** Position among that strip's tabs, core ones included. */
+ editorOrder?: number;
fields: PluginSettingsField[];
}
@@ -1086,7 +1090,14 @@ function validateSettings(settings: unknown, errors: string[]): void {
}
rejectUnknown(
host,
- ["enableKey", "enableLabelKey", "enableDefault", "fields"],
+ [
+ "enableKey",
+ "enableLabelKey",
+ "enableDefault",
+ "editorGroup",
+ "editorOrder",
+ "fields",
+ ],
`"${at}"`,
errors,
);
@@ -1104,6 +1115,20 @@ function validateSettings(settings: unknown, errors: string[]): void {
if ("enableDefault" in host && typeof host.enableDefault !== "boolean") {
errors.push(`${at}.enableDefault must be a boolean`);
}
+ if (
+ "editorGroup" in host &&
+ host.editorGroup !== "top" &&
+ host.editorGroup !== "ssh"
+ ) {
+ errors.push(`${at}.editorGroup must be "top" or "ssh"`);
+ }
+ if (
+ "editorOrder" in host &&
+ (typeof host.editorOrder !== "number" ||
+ !Number.isFinite(host.editorOrder))
+ ) {
+ errors.push(`${at}.editorOrder must be a number`);
+ }
if (!Array.isArray(host.fields)) {
errors.push(`${at}.fields must be an array`);
diff --git a/plugins/ai/locales/en.json b/plugins/ai/locales/en.json
index e146f2349..de591e668 100644
--- a/plugins/ai/locales/en.json
+++ b/plugins/ai/locales/en.json
@@ -13,12 +13,12 @@
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
- "title": "Use AI from other plugins",
- "description": "Other plugins may call the assistant on your behalf."
+ "title": "Use AI from other features",
+ "description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
- "description": "Other plugins may use the stored AI API key without seeing it."
+ "description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
@@ -98,7 +98,7 @@
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
- "shareApiKey": "Let other plugins use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
+ "shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json
index 8e136aae2..deea21f23 100644
--- a/plugins/ai/manifest.json
+++ b/plugins/ai/manifest.json
@@ -173,6 +173,8 @@
}
],
"host": {
+ "editorGroup": "ssh",
+ "editorOrder": 90,
"fields": [
{
"key": "enableAiAssistant",
diff --git a/plugins/automations/locales/en.json b/plugins/automations/locales/en.json
index 0c3bf4b79..6c637ff2a 100644
--- a/plugins/automations/locales/en.json
+++ b/plugins/automations/locales/en.json
@@ -159,7 +159,7 @@
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
- "needsPluginHint": "This automation is skipped until the plugin it needs is turned on again.",
+ "needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
diff --git a/plugins/docker/manifest.json b/plugins/docker/manifest.json
index 2a3ac916c..b6f28b353 100644
--- a/plugins/docker/manifest.json
+++ b/plugins/docker/manifest.json
@@ -53,6 +53,8 @@
],
"settings": {
"host": {
+ "editorGroup": "ssh",
+ "editorOrder": 30,
"enableKey": "enableDocker",
"enableLabelKey": "settings.host.enable.label",
"fields": [
diff --git a/plugins/file-manager/manifest.json b/plugins/file-manager/manifest.json
index 1cdef990f..40e5c66e3 100644
--- a/plugins/file-manager/manifest.json
+++ b/plugins/file-manager/manifest.json
@@ -71,6 +71,8 @@
],
"settings": {
"host": {
+ "editorGroup": "ssh",
+ "editorOrder": 60,
"enableKey": "enableFileManager",
"enableLabelKey": "settings.host.enable.label",
"enableDefault": true,
diff --git a/plugins/host-metrics/locales/en.json b/plugins/host-metrics/locales/en.json
index 59a616e28..b76e474a4 100644
--- a/plugins/host-metrics/locales/en.json
+++ b/plugins/host-metrics/locales/en.json
@@ -183,7 +183,7 @@
"wgHandshakeNever": "Never",
"wgTransfer": "Transfer"
},
- "snippetsUnavailable": "The Snippets plugin is off, so quick actions cannot run."
+ "snippetsUnavailable": "Snippets are turned off, so quick actions cannot run."
},
"metricsHistory": {
"title": "Metrics History",
diff --git a/plugins/opkssh/src/frontend/RedirectUriSetting.tsx b/plugins/opkssh/src/frontend/RedirectUriSetting.tsx
index 73ffbc014..c96a3804e 100644
--- a/plugins/opkssh/src/frontend/RedirectUriSetting.tsx
+++ b/plugins/opkssh/src/frontend/RedirectUriSetting.tsx
@@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) {
{advancedOpen && (
-
+
{fields.map(({ key, label, unit }) => {
const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key];
return (
diff --git a/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx b/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx
index 4d1873e2e..84b54329b 100644
--- a/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx
+++ b/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx
@@ -114,7 +114,7 @@ export function TerminalTabContent({
}
isQuickConnect={String(host.id).startsWith("quick-connect-")}
onSaveQuickConnect={
- shell.saveQuickConnect
+ shell.saveQuickConnect && hostRecord?.quickConnectSavable
? () => shell.saveQuickConnect!(tabRecord, hostRecord!)
: undefined
}
diff --git a/plugins/step-ca/locales/en.json b/plugins/step-ca/locales/en.json
index 61c6d3993..30af15734 100644
--- a/plugins/step-ca/locales/en.json
+++ b/plugins/step-ca/locales/en.json
@@ -4,7 +4,7 @@
},
"editor": {
"label": "Step CA",
- "description": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator in the Step CA plugin settings.",
+ "description": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator in the Step CA section of Admin Settings.",
"docs": "View docs"
},
"dialog": {
diff --git a/plugins/step-ca/src/frontend/RedirectUriSetting.tsx b/plugins/step-ca/src/frontend/RedirectUriSetting.tsx
index 5ff1a7ddf..c8b7ce053 100644
--- a/plugins/step-ca/src/frontend/RedirectUriSetting.tsx
+++ b/plugins/step-ca/src/frontend/RedirectUriSetting.tsx
@@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) {