feat: plugins only see their own host settings in the browser

This commit is contained in:
LukeGus committed 2026-09-29 13:38:11 -05:00
1 parent 50d43e29fb
commit fb9c168b6b
17 files changed
+488 -52

No files matched your search

+49 -12
View File
@@ -51,11 +51,20 @@ import {
registerSecondFactor,
registerSshAuthEditor,
} from "./auth-registry";
import { pluginHostBridge, resolvePluginPermission } from "./bridge";
import { shell, tabsApi } from "./shell-bridge";
import {
pluginHostBridge,
resolvePluginPermission,
toPluginHostRecord,
} from "./bridge";
import { shell, shellHost, tabsApi } from "./shell-bridge";
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
import { isElectron } from "@/lib/electron";
import { withPluginScope, withIconBoundary, guardCallback } from "./scope";
import {
scopeHostArgs,
scopeHostCallback,
scopeHostFields,
} from "./host-scope";
import { manifestDeclares, type ViewKind } from "./view-ownership";
import { pluginKey } from "@/lib/plugin-i18n";
import { hasPermission } from "@/hooks/use-permissions";
@@ -98,6 +107,11 @@ export function createPluginApp(
const key = (value: string) => pluginKey(pluginId, value);
// Every host a plugin callback receives carries only this plugin's settings.
const hostCallback = <F extends (...args: never[]) => unknown>(
fn: F | undefined,
): F | undefined => scopeHostCallback(fn, pluginId);
const requireDeclared = (kind: ViewKind, id: string, what: string) => {
if (!manifestDeclares(contributes, kind, id)) {
const field =
@@ -221,17 +235,29 @@ export function createPluginApp(
titleKey: key(action.titleKey),
pluginId,
icon: withIconBoundary(pluginId, action.icon as never) as never,
when: action.when as never,
run: action.run as never,
when: hostCallback(action.when) as never,
run: hostCallback(action.run) as never,
// Called while the host list renders, so a throw must not escape.
label: guardCallback(
pluginId,
action.label as ((...args: unknown[]) => unknown) | undefined,
hostCallback(
action.label as ((...args: unknown[]) => unknown) | undefined,
),
undefined,
) as never,
items: guardCallback(
pluginId,
action.items as ((...args: unknown[]) => unknown) | undefined,
action.items
? (...args: unknown[]) =>
(
action.items as (
...a: unknown[]
) => { run: (...a: never[]) => unknown }[]
)(...scopeHostArgs(args, pluginId))?.map((item) => ({
...item,
run: hostCallback(item.run),
}))
: undefined,
undefined,
) as never,
}),
@@ -256,7 +282,7 @@ export function createPluginApp(
registerHostBadge({
id: badge.id,
pluginId,
when: badge.when as never,
when: hostCallback(badge.when) as never,
component: scoped(badge.component) as never,
}),
);
@@ -268,8 +294,8 @@ export function createPluginApp(
...item,
titleKey: key(item.titleKey),
pluginId,
when: item.when as never,
run: item.run as never,
when: hostCallback(item.when) as never,
run: hostCallback(item.run) as never,
}),
);
},
@@ -280,8 +306,8 @@ export function createPluginApp(
...entry,
titleKey: key(entry.titleKey),
pluginId,
when: entry.when as never,
run: entry.run as never,
when: hostCallback(entry.when) as never,
run: hostCallback(entry.run) as never,
}),
);
},
@@ -373,6 +399,13 @@ export function createPluginApp(
);
},
listHosts: () => pluginHostBridge.core.listHosts(pluginId),
getHost(hostId) {
const host = shellHost(hostId);
return host ? toPluginHostRecord(host, pluginId) : undefined;
},
registerSettingsComponent(componentId, component) {
return track(
registerSettingsComponent(pluginId, componentId, scoped(component)),
@@ -381,7 +414,7 @@ export function createPluginApp(
registerAction(id, handler, options = {}) {
return track(
registerAction(id, handler, {
registerAction(id, hostCallback(handler), {
permission: options.permission
? resolvePluginPermission(pluginId, options.permission)
: undefined,
@@ -405,6 +438,10 @@ export function createPluginApp(
component: contribution.component
? scoped(contribution.component)
: undefined,
when: contribution.when
? (context: Record<string, unknown>) =>
contribution.when!(scopeHostFields(context, pluginId))
: undefined,
pluginId,
}),
);
+24 -6
View File
@@ -191,6 +191,17 @@ export function toHostRecord(host: ShellHost): PluginHostRecord {
};
}
/** The host as one plugin sees it: only that plugin's settings. */
export function toPluginHostRecord(
host: ShellHost,
pluginId: string | null,
): PluginHostRecord {
const record = toHostRecord(host);
const own = pluginId ? host.pluginSettings?.[pluginId] : undefined;
record.pluginSettings = own && pluginId ? { [pluginId]: own } : {};
return record;
}
export const pluginHostBridge: PluginHostBridge = {
usePluginId() {
const pluginId = usePluginScope();
@@ -257,19 +268,24 @@ export const pluginHostBridge: PluginHostBridge = {
},
useHost(hostId) {
const pluginId = usePluginScope();
const { hosts } = useShellHosts();
return useMemo(() => {
if (hostId === undefined || hostId === null) return null;
const found = hosts.find((host) => host.id === String(hostId));
return found ? toHostRecord(found) : null;
}, [hosts, hostId]);
return found ? toPluginHostRecord(found, pluginId) : null;
}, [hosts, hostId, pluginId]);
},
useHosts() {
const pluginId = usePluginScope();
const { hosts, loaded } = useShellHosts();
return useMemo(
() => ({ hosts: hosts.map(toHostRecord), loaded }),
[hosts, loaded],
() => ({
hosts: hosts.map((host) => toPluginHostRecord(host, pluginId)),
loaded,
}),
[hosts, loaded, pluginId],
);
},
@@ -410,8 +426,10 @@ export const pluginHostBridge: PluginHostBridge = {
setClientPreference: (name, value) => {
void setCookie(name, value, 365);
},
listHosts: async () =>
(await getSSHHosts({ includeStatus: false })).map(toHostRecord),
listHosts: async (pluginId) =>
(await getSSHHosts({ includeStatus: false })).map((host) =>
toPluginHostRecord(host, pluginId ?? null),
),
listCredentials: async () => {
const raw = await getCredentials();
const list = Array.isArray(raw)
+84
View File
@@ -0,0 +1,84 @@
import { shellHost } from "./shell-bridge";
type SettingsBag = Record<string, Record<string, unknown> | undefined>;
type HostLike = { id: string | number; pluginSettings?: SettingsBag };
/**
* A plugin sees only its own host settings. Another plugin's settings reach
* it through that plugin's actions or services, never by reading the host,
* so a plugin can rename a setting without breaking its neighbours.
*
* Settings come from the shell's own copy of the host when it has one, so a
* host one plugin hands another (through an action or a tab) is re-read for
* the receiver instead of arriving with the sender's slice.
*/
export function isHostLike(value: unknown): value is HostLike {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
if (!("id" in value)) return false;
return "pluginSettings" in value || ("ip" in value && "port" in value);
}
const cache = new WeakMap<
object,
Map<string, { source: SettingsBag | undefined; scoped: object }>
>();
export function scopeHost<T>(value: T, pluginId: string): T {
if (!isHostLike(value)) return value;
const full = (shellHost(value.id)?.pluginSettings ?? value.pluginSettings) as
SettingsBag | undefined;
let perPlugin = cache.get(value);
const hit = perPlugin?.get(pluginId);
// Same object in, same object out, so a plugin's effects keyed on the
// host don't re-run on every render.
if (hit && hit.source === full) return hit.scoped as T;
const own = full?.[pluginId];
const scoped = { ...value, pluginSettings: own ? { [pluginId]: own } : {} };
if (!perPlugin) {
perPlugin = new Map();
cache.set(value, perPlugin);
}
perPlugin.set(pluginId, { source: full, scoped });
return scoped as T;
}
/**
* Scopes each argument that is a host, and the `host` / `sshHost` fields of
* an argument that is a request object such as `{ host, appTheme }`.
*/
export function scopeHostArgs<A extends unknown[]>(
args: A,
pluginId: string,
): A {
return args.map((arg) =>
isHostLike(arg) ? scopeHost(arg, pluginId) : scopeHostFields(arg, pluginId),
) as A;
}
// The prop and field names hosts travel under between core and plugins.
const HOST_FIELDS = ["host", "sshHost", "hostConfig"] as const;
/** Scopes the host fields of a props, context or request object. */
export function scopeHostFields<T>(value: T, pluginId: string): T {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = value as Record<string, unknown>;
let next: Record<string, unknown> | null = null;
for (const field of HOST_FIELDS) {
if (!(field in record)) continue;
const scoped = scopeHost(record[field], pluginId);
if (scoped === record[field]) continue;
next ??= { ...record };
next[field] = scoped;
}
return (next ?? value) as T;
}
/** Wraps a plugin callback so any host it is called with is scoped. */
export function scopeHostCallback<F extends (...args: never[]) => unknown>(
fn: F | undefined,
pluginId: string,
): F | undefined {
if (!fn) return fn;
return ((...args: never[]) =>
fn(...(scopeHostArgs(args as unknown[], pluginId) as never[]))) as F;
}
+2 -1
View File
@@ -8,6 +8,7 @@ import {
type ErrorInfo,
type ReactNode,
} from "react";
import { scopeHostFields } from "./host-scope";
import { useTranslation } from "react-i18next";
import { getPluginRecord } from "./plugin-store";
@@ -103,7 +104,7 @@ export function withPluginScope<P extends object>(
function Scoped(props: P) {
return (
<PluginScope pluginId={pluginId}>
<Inner {...props} />
<Inner {...scopeHostFields(props, pluginId)} />
</PluginScope>
);
}
+9 -1
View File
@@ -86,15 +86,23 @@ const noop = () => {};
*/
export function withShellHost(host: Host | null): Host | null {
if (!host) return host;
const own = hosts.find((entry) => String(entry.id) === String(host.id));
const own = shellHost(host.id);
if (!own || own === host) return host;
const merged: Record<string, unknown> = { ...own };
for (const [key, value] of Object.entries(host)) {
if (value !== undefined) merged[key] = value;
}
// A plugin only ever holds its own slice of the settings.
merged.pluginSettings = own.pluginSettings;
return merged as Host;
}
/** The shell's own copy of a saved host, with every plugin's settings. */
export function shellHost(id: string | number | undefined): Host | undefined {
if (id === undefined || id === null) return undefined;
return hosts.find((entry) => String(entry.id) === String(id));
}
/** Forwards to the mounted shell; a no-op before it mounts or after logout. */
export const shell: TabShellCallbacks = {
openTab: (host, ...rest) => callbacks?.openTab(withShellHost(host), ...rest),
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import { Puzzle } from "lucide-react";
import {
invokeAction,
useHost,
type PluginHostRecord,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
scopeHost,
scopeHostArgs,
scopeHostFields,
} from "@/plugin-host/host-scope";
import { setShellHosts, withShellHost } from "@/plugin-host/shell-bridge";
import { resetActionRegistry } from "@/shell/action-registry";
import { resetHostContributions } from "@/sidebar/host-contributions";
import { resetPanels } from "@/shell/panel-registry";
import { resetTabTypes } from "@/shell/tab-registry";
import { resetRegisteredRailItems } from "@/sidebar/rail-items";
import { resetPluginStore } from "@/plugin-host/plugin-store";
import type { Host } from "@/types/ui-types";
const SETTINGS = {
fixture: { enableFixture: true },
other: { secretKnob: "hands off" },
};
const saved = {
id: "7",
name: "web-01",
ip: "10.0.0.7",
port: 22,
pluginSettings: SETTINGS,
} as unknown as Host;
afterEach(() => {
setShellHosts([]);
});
describe("scopeHost", () => {
it("keeps only the plugin's own settings", () => {
const scoped = scopeHost(saved, "fixture");
expect(scoped.pluginSettings).toEqual({ fixture: SETTINGS.fixture });
expect(scoped.name).toBe("web-01");
expect(saved.pluginSettings).toEqual(SETTINGS);
});
it("gives a plugin with no settings on the host an empty bag", () => {
expect(scopeHost(saved, "nobody").pluginSettings).toEqual({});
});
it("re-reads a host another plugin handed over from the shell's copy", () => {
setShellHosts([saved]);
const fromOther = scopeHost({ ...saved }, "other");
expect(fromOther.pluginSettings).toEqual({ other: SETTINGS.other });
expect(scopeHost(fromOther, "fixture").pluginSettings).toEqual({
fixture: SETTINGS.fixture,
});
});
it("returns the same object for the same host, so effects stay put", () => {
expect(scopeHost(saved, "fixture")).toBe(scopeHost(saved, "fixture"));
expect(scopeHost(saved, "fixture")).not.toBe(scopeHost(saved, "other"));
});
it("passes anything that is not a host through untouched", () => {
const shellApi = { openTab: () => {} };
expect(scopeHost(shellApi, "fixture")).toBe(shellApi);
expect(scopeHost("7", "fixture")).toBe("7");
expect(scopeHost(null, "fixture")).toBeNull();
const list = [saved];
expect(scopeHostFields(list, "fixture")).toBe(list);
});
it("scopes host fields of props and request objects", () => {
const props = { host: saved, sshHost: saved, hostConfig: saved, n: 1 };
const scoped = scopeHostFields(props, "fixture");
expect(scoped.host.pluginSettings).toEqual({ fixture: SETTINGS.fixture });
expect(scoped.sshHost.pluginSettings).toEqual({
fixture: SETTINGS.fixture,
});
expect(scoped.hostConfig.pluginSettings).toEqual({
fixture: SETTINGS.fixture,
});
expect(scoped.n).toBe(1);
const noHost = { appTheme: "dark" };
expect(scopeHostFields(noHost, "fixture")).toBe(noHost);
const [request] = scopeHostArgs([{ host: saved, appTheme: "dark" }], "x");
expect((request as { host: Host }).host.pluginSettings).toEqual({});
});
it("lets a tab opened from a plugin's host keep every plugin's settings", () => {
setShellHosts([saved]);
const merged = withShellHost(scopeHost(saved, "other"));
expect(merged?.pluginSettings).toEqual(SETTINGS);
});
});
const MANIFEST: Partial<PluginManifest> = {
id: "fixture",
name: "Fixture",
contributes: {
tabs: [
{ id: "fixture-tab", titleKey: "title", icon: "Puzzle", openFrom: [] },
],
panels: [{ id: "fixture-panel", titleKey: "title" }],
},
};
let seen: Record<string, unknown> = {};
function HostProbe() {
const host = useHost("7");
return <span>{JSON.stringify(host?.pluginSettings ?? null)}</span>;
}
function activate(app: TermixApp) {
app.registerPanel("fixture-panel", HostProbe);
app.registerTab("fixture-tab", ({ host }) => (
<span>{JSON.stringify(host?.pluginSettings ?? null)}</span>
));
app.registerHostAction({
id: "fixture-open",
titleKey: "title",
icon: Puzzle,
kind: "open",
when: (host) => {
seen.when = host.pluginSettings;
return true;
},
});
app.registerAction("fixture.inspect", ((
host: PluginHostRecord,
request: { host: PluginHostRecord },
) => {
seen.action = host.pluginSettings;
seen.request = request.host.pluginSettings;
}) as never);
}
let rendered: RenderedPluginApp | null = null;
async function mount() {
rendered = await renderWithApp(
{ activate },
{
manifest: MANIFEST,
locales: { title: "Fixture" },
hosts: [saved as unknown as Record<string, unknown>],
},
);
return rendered;
}
describe("host settings a plugin sees", () => {
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
seen = {};
resetActionRegistry();
resetHostContributions();
resetPanels();
resetTabTypes();
resetRegisteredRailItems();
resetPluginStore();
});
const own = JSON.stringify({ fixture: SETTINGS.fixture });
it("useHost carries only the plugin's own settings", async () => {
const app = await mount();
app.renderPanel("fixture-panel");
expect(await screen.findByText(own)).toBeTruthy();
});
it("a tab's host prop carries only the plugin's own settings", async () => {
const app = await mount();
app.renderTab("fixture-tab", { host: saved });
expect(await screen.findByText(own)).toBeTruthy();
});
it("app.getHost is scoped too", async () => {
const { app } = await mount();
expect(app.getHost(7)?.pluginSettings).toEqual({
fixture: SETTINGS.fixture,
});
expect(app.getHost(99)).toBeUndefined();
});
it("host action callbacks get the plugin's slice", async () => {
const app = await mount();
app.registered.hostActionsFor(saved);
expect(seen.when).toEqual({ fixture: SETTINGS.fixture });
});
it("an action handler gets a host another plugin passed, re-read for it", async () => {
await mount();
const fromOther = scopeHost(saved, "other");
await invokeAction("fixture.inspect", fromOther, { host: fromOther });
expect(seen.action).toEqual({ fixture: SETTINGS.fixture });
expect(seen.request).toEqual({ fixture: SETTINGS.fixture });
});
});