feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
This commit is contained in:
LukeGus committed 2026-09-21 08:28:50 -05:00
1 parent e040b12045
commit c94a453f1e
43 files changed
+2426 -38

No files matched your search

+12
View File
@@ -217,6 +217,7 @@ import {
serializeSplitTabs,
type PersistedSplitTab,
} from "@/shell/splitTabUtils";
import { isTabTypeAvailable, refreshPluginState } from "@/shell/pluginLoader";
export { buildHostTree } from "@/sidebar/build-host-tree";
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
@@ -458,6 +459,12 @@ export function AppShell({
.catch(() => setIsAdmin(false));
}, []);
// Plugin state gates which tab types can be opened, so it has to land before
// a saved session is restored. A failure leaves everything enabled.
useEffect(() => {
refreshPluginState().catch(() => {});
}, []);
const handleDismissDonationModal = useCallback(() => {
setShowDonationModal(false);
dismissDonationModal().catch(() => {});
@@ -1657,6 +1664,11 @@ export function AppShell({
},
options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) {
// A tab type owned by a disabled plugin cannot be opened, including from a
// restored session. Guarded here rather than at each call site because
// every path into a tab goes through this one.
if (!isTabTypeAvailable(type)) return null;
if (!restore && !options?.forceNewTab) {
const existing = tabsRef.current.find(
(t) =>
+38
View File
@@ -0,0 +1,38 @@
import { rbacApi } from "@/main-axios";
export interface PluginTabContribution {
id: string;
titleKey: string;
icon: string;
openFrom: string[];
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
}
export interface PluginSummary {
id: string;
name: string;
version: string;
tier: string;
source: string;
enabled: boolean;
runtimeState: string;
lastError: string | null;
contributes: PluginContributions | null;
}
export async function getPlugins(): Promise<PluginSummary[]> {
const response = await rbacApi.get("/plugins");
return Array.isArray(response.data) ? response.data : [];
}
export async function setPluginEnabled(
pluginId: string,
enabled: boolean,
): Promise<void> {
await rbacApi.patch(`/plugins/${encodeURIComponent(pluginId)}/state`, {
enabled,
});
}
+8 -1
View File
@@ -3977,7 +3977,14 @@
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
"updateAiEnabledFailed": "Could not update the AI setting",
"updateAiEndpointsFailed": "Could not update the allowed hosts"
"updateAiEndpointsFailed": "Could not update the allowed hosts",
"plugins": "Plugins",
"pluginsNone": "No plugins installed.",
"pluginsLoadFailed": "Failed to load plugins",
"pluginToggleFailed": "Failed to change the plugin",
"pluginBuiltIn": "BUILT IN",
"pluginEnabled": "{{name}} enabled",
"pluginDisabled": "{{name}} disabled"
},
"newUi": {
"sidebar": {
+7 -5
View File
@@ -46,6 +46,7 @@ import { canEditHost } from "@/sidebar/host-permissions";
import { RAIL_ITEMS, RAIL_UTILITY_ITEMS } from "@/sidebar/rail-items";
import { useAiAvailability } from "@/hooks/use-ai-availability";
import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx";
import { isTabTypeAvailable } from "@/shell/pluginLoader";
interface CommandPaletteProps {
isOpen: boolean;
@@ -87,11 +88,12 @@ function getSshActions(host: Host): {
}[] {
const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
return [
host.enableTerminal !== false && {
type: "terminal",
icon: Terminal,
label: "Terminal",
},
host.enableTerminal !== false &&
isTabTypeAvailable("terminal") && {
type: "terminal",
icon: Terminal,
label: "Terminal",
},
host.enableFileManager && {
type: "files",
icon: FolderSearch,
+87
View File
@@ -0,0 +1,87 @@
/**
* Applies backend plugin state to the shell.
*
* Two kinds of contributed surface exist, and they are removed differently:
*
* - A tab that only exists because a plugin registered it goes away with
* unregisterTabComponent / unregisterRailItem.
* - A tab that ships as a built-in code path but is OWNED by a plugin (the
* terminal) cannot, because it was never registered. Callers ask
* isTabTypeAvailable() before offering it instead.
*
* The terminal is in the second group because its tab takes around a dozen
* props (host, split-pane state, file-manager and editor callbacks) that the
* generic { tab } plugin surface has no way to supply. Rewiring that was a
* bigger regression risk to a working terminal than gating the entry points.
*/
import { unregisterRailItem } from "@/sidebar/rail-items";
import { unregisterTabComponent } from "@/shell/tabUtils";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
/**
* Built-in tab types owned by a plugin, keyed by plugin id. The terminal has
* no rail item: it opens from a host, the command palette or quick connect.
*/
const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
"ssh-terminal": ["terminal"],
};
let pluginEnabled = new Map<string, boolean>();
let disabledTabTypes = new Set<string>();
export function applyPluginState(plugins: PluginSummary[]): void {
const nextEnabled = new Map<string, boolean>();
const nextDisabledTabs = new Set<string>();
for (const plugin of plugins) {
nextEnabled.set(plugin.id, plugin.enabled);
const builtInTabs = BUILT_IN_TABS_BY_PLUGIN[plugin.id];
if (builtInTabs) {
if (!plugin.enabled) {
for (const tabId of builtInTabs) nextDisabledTabs.add(tabId);
}
continue;
}
// Plugin-registered surfaces: only removal happens here. Registration is
// the plugin's own frontend entry being imported.
if (!plugin.enabled) {
for (const tab of plugin.contributes?.tabs ?? []) {
unregisterRailItem(tab.id);
unregisterTabComponent(tab.id);
}
}
}
pluginEnabled = nextEnabled;
disabledTabTypes = nextDisabledTabs;
}
export async function refreshPluginState(): Promise<PluginSummary[]> {
const plugins = await getPlugins();
applyPluginState(plugins);
return plugins;
}
/**
* Whether a built-in tab type may still be opened. Call sites that offer a
* connection action (host item, command palette, quick connect) check this so
* a disabled plugin stops offering the tab it owns.
*/
export function isTabTypeAvailable(tabType: string): boolean {
return !disabledTabTypes.has(tabType);
}
export function isPluginEnabled(pluginId: string): boolean {
// Unknown means not yet loaded; assume enabled so the UI does not blank out
// a working feature while the first request is in flight.
return pluginEnabled.get(pluginId) ?? true;
}
/** Test seam. */
export function resetPluginState(): void {
pluginEnabled = new Map();
disabledTabTypes = new Set();
}
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Puzzle } from "lucide-react";
import { toast } from "sonner";
import { SettingRow } from "@/components/section-card";
import {
getPlugins,
setPluginEnabled,
type PluginSummary,
} from "@/api/plugins-api";
import { refreshPluginState } from "@/shell/pluginLoader";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
export function AdminPluginsSection({
open,
onToggle,
}: {
open: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
try {
setPlugins(await getPlugins());
} catch {
toast.error(t("admin.pluginsLoadFailed"));
} finally {
setLoaded(true);
}
}, [t]);
useEffect(() => {
if (open && !loaded) void load();
}, [open, loaded, load]);
const toggle = async (plugin: PluginSummary) => {
setBusy(plugin.id);
try {
await setPluginEnabled(plugin.id, !plugin.enabled);
await load();
// Re-read state so the shell picks the change up without a reload.
await refreshPluginState();
toast.success(
plugin.enabled
? t("admin.pluginDisabled", { name: plugin.name })
: t("admin.pluginEnabled", { name: plugin.name }),
);
} catch {
toast.error(t("admin.pluginToggleFailed"));
} finally {
setBusy(null);
}
};
return (
<AccordionSection
label={t("admin.plugins")}
icon={<Puzzle className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{plugins.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{loaded ? t("admin.pluginsNone") : t("common.loading")}
</p>
) : (
plugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
badge={
plugin.tier === "first-party"
? t("admin.pluginBuiltIn")
: undefined
}
description={
plugin.lastError
? plugin.lastError
: `v${plugin.version} · ${plugin.runtimeState}`
}
>
<AdminToggle
on={plugin.enabled}
onToggle={() => void toggle(plugin)}
disabled={busy === plugin.id}
/>
</SettingRow>
))
)}
</AccordionSection>
);
}
+6
View File
@@ -94,6 +94,7 @@ import {
type UserRole,
} from "@/main-axios";
import { type AdminSection, type Host } from "@/types/ui-types";
import { AdminPluginsSection } from "./AdminPluginsSection";
import {
AdminRolesSection,
AdminSessionsSection,
@@ -1496,6 +1497,11 @@ export function AdminSettingsPanel({
users={users}
/>
<AdminPluginsSection
open={openSections.has("plugins")}
onToggle={() => toggle("plugins")}
/>
<AdminTouchInputSection
open={openSections.has("touch-input")}
onToggle={() => toggle("touch-input")}
+3 -1
View File
@@ -88,6 +88,7 @@ import {
} from "@/lib/local-adaptive-preferences";
import type { WebEndpoint } from "@/types/index";
import { openWebEndpointExternally } from "@/api/web-endpoint-api";
import { isTabTypeAvailable } from "@/shell/pluginLoader";
export function statusCheckEnabled(host: Host): boolean {
return host.statsConfig?.statusCheckEnabled !== false;
@@ -149,7 +150,8 @@ export function getSshActions(host: Host): {
const connectionActions = [
host.enableSsh &&
host.enableTerminal && {
host.enableTerminal &&
isTabTypeAvailable("terminal") && {
type: "terminal" as TabType,
icon: Terminal,
label: "Terminal",
+147
View File
@@ -0,0 +1,147 @@
/**
* Disabling a plugin has to remove the surfaces it owns, and disabling one
* plugin must not touch another's.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { Puzzle } from "lucide-react";
import {
applyPluginState,
isPluginEnabled,
isTabTypeAvailable,
resetPluginState,
} from "@/shell/pluginLoader";
import {
registerRailItem,
unregisterRailItem,
visibleRailItems,
} from "@/sidebar/rail-items";
import type { PluginSummary } from "@/api/plugins-api";
vi.mock("@/api/plugins-api", () => ({
getPlugins: vi.fn().mockResolvedValue([]),
setPluginEnabled: vi.fn(),
}));
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "ssh-terminal",
name: "SSH Terminal",
version: "1.0.0",
tier: "first-party",
source: "bundled",
enabled: true,
runtimeState: "active",
lastError: null,
contributes: {
tabs: [
{
id: "terminal",
titleKey: "nav.terminal",
icon: "SquareTerminal",
openFrom: ["host-context-menu", "palette"],
},
],
},
...overrides,
};
}
describe("plugin state applied to the shell", () => {
afterEach(() => {
resetPluginState();
unregisterRailItem("other-plugin-tab");
});
it("allows terminal tabs while the plugin is enabled", () => {
applyPluginState([plugin({ enabled: true })]);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("blocks terminal tabs when the plugin is disabled", () => {
applyPluginState([plugin({ enabled: false })]);
expect(isTabTypeAvailable("terminal")).toBe(false);
});
it("allows them again when the plugin is re-enabled", () => {
applyPluginState([plugin({ enabled: false })]);
expect(isTabTypeAvailable("terminal")).toBe(false);
applyPluginState([plugin({ enabled: true })]);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("leaves unrelated tab types alone", () => {
applyPluginState([plugin({ enabled: false })]);
// Disabling the terminal must not take the rest of the app with it.
for (const type of ["files", "docker", "tunnel", "rdp", "host-metrics"]) {
expect(isTabTypeAvailable(type)).toBe(true);
}
});
it("leaves another plugin's rail item alone when the terminal is disabled", () => {
registerRailItem({
id: "other-plugin-tab",
icon: Puzzle,
labelKey: "nav.otherPlugin",
kind: "tab",
});
applyPluginState([
plugin({ enabled: false }),
plugin({
id: "other-plugin",
name: "Other",
tier: "community",
source: "registry",
enabled: true,
contributes: null,
}),
]);
expect(isTabTypeAvailable("terminal")).toBe(false);
// The core ask: disabling one plugin must not disturb another.
expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab");
});
it("unregisters a plugin-registered tab when that plugin is disabled", () => {
registerRailItem({
id: "other-plugin-tab",
icon: Puzzle,
labelKey: "nav.otherPlugin",
kind: "tab",
});
expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab");
applyPluginState([
plugin({
id: "other-plugin",
name: "Other",
enabled: false,
contributes: {
tabs: [
{
id: "other-plugin-tab",
titleKey: "nav.otherPlugin",
icon: "Puzzle",
openFrom: ["rail"],
},
],
},
}),
]);
expect(visibleRailItems().map((i) => i.id)).not.toContain(
"other-plugin-tab",
);
});
it("reports enablement, defaulting to true before the first load", () => {
expect(isPluginEnabled("ssh-terminal")).toBe(true);
applyPluginState([plugin({ enabled: false })]);
expect(isPluginEnabled("ssh-terminal")).toBe(false);
expect(isPluginEnabled("never-heard-of-it")).toBe(true);
});
});