Make local plugins manageable and debuggable (#3446)

* feat(plugins): expose Paseo API to local plugins

Plugin surfaces borrow the selected host connection while subprocess handlers receive an exclusively owned IPC session, so plugins can use normal workspace and agent operations without inventing another transport.

* feat(plugins): reload global enablement safely

Treat the global plugin switch as a live daemon setting and teach plugin automation to inspect it before acting. Enabling remains permission-gated because plugin backend code can access the daemon machine and client contributions execute inside the app.

* feat(plugins): expose plugin logs

Capture plugin stdout and stderr separately from IPC so authors can debug initialization, RPC, cleanup, and crash behavior from the app or CLI. Keep a bounded tail across lifecycle recovery while preserving structured output in the daemon log.

* fix(build): order plugin dependency builds

* test(plugins): align startup and CLI expectations
This commit is contained in:
Mohamed Boudra
2026-08-16 18:54:44 +08:00
committed by GitHub
parent d99258cdd0
commit 16120ebff1
62 changed files with 2945 additions and 202 deletions
@@ -6,7 +6,7 @@ import React, { type ReactElement } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginListItem } from "@getpaseo/protocol/messages";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostPluginsPage } from "./plugins-page";
@@ -15,6 +15,7 @@ void testI18n;
const runtime = vi.hoisted(() => ({
connected: true,
supported: true,
logsSupported: true,
client: null as DaemonClient | null,
}));
@@ -24,9 +25,37 @@ vi.mock("@/runtime/host-runtime", () => ({
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: () => runtime.supported,
useHostFeature: (_serverId: string, feature: string) =>
feature === "pluginLogs" ? runtime.logsSupported : runtime.supported,
}));
vi.mock("@/components/adaptive-modal-sheet", async () => {
const ReactModule = await vi.importActual<typeof import("react")>("react");
const actual = await vi.importActual<typeof import("@/components/adaptive-modal-sheet")>(
"@/components/adaptive-modal-sheet",
);
return {
...actual,
AdaptiveModalSheet: ({
header,
children,
onClose,
}: {
header: { title: string; actions?: React.ReactNode };
children: React.ReactNode;
onClose(): void;
}) =>
ReactModule.createElement(
"div",
{ role: "dialog" },
ReactModule.createElement("h2", null, header.title),
header.actions,
ReactModule.createElement("button", { type: "button", onClick: onClose }, "Close"),
children,
),
};
});
vi.mock("react-native-reanimated", () => ({
default: { View: "div" },
Easing: { ease: "ease", inOut: (value: unknown) => value },
@@ -79,6 +108,7 @@ function createClient() {
enablePlugin: vi.fn(async () => plugin()),
disablePlugin: vi.fn(async () => plugin(false)),
removePlugin: vi.fn(async () => undefined),
getPluginLogs: vi.fn(async (): Promise<PluginLogEntry[]> => []),
};
}
@@ -105,6 +135,7 @@ describe("HostPluginsPage", () => {
vi.stubGlobal("React", React);
runtime.connected = true;
runtime.supported = true;
runtime.logsSupported = true;
runtime.client = null;
vi.stubGlobal(
"confirm",
@@ -170,4 +201,61 @@ describe("HostPluginsPage", () => {
expect(pendingControl.getAttribute("aria-disabled")).toBe("true");
expect(client.installDirectoryPlugin).toHaveBeenCalledWith("/plugins/example", undefined);
});
it("hides the logs action when the host does not advertise support", async () => {
runtime.logsSupported = false;
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
renderPage(client);
await screen.findByText("example");
expect(screen.queryByRole("button", { name: "Logs" })).toBeNull();
});
it("opens readable stdout and stderr logs and refreshes them", async () => {
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
client.getPluginLogs.mockResolvedValue([
{
sequence: 1,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout",
message: "ready",
},
{
sequence: 2,
timestamp: "2026-08-16T12:00:01.000Z",
stream: "stderr",
message: "warning",
},
]);
renderPage(client);
fireEvent.click(await screen.findByRole("button", { name: "Logs" }));
expect(await screen.findByRole("dialog")).toBeDefined();
expect(screen.getByText("Logs: example")).toBeDefined();
expect(await screen.findByText("ready")).toBeDefined();
expect(screen.getByText("warning")).toBeDefined();
expect(screen.getByText(/stdout/)).toBeDefined();
expect(screen.getByText(/stderr/)).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(client.getPluginLogs).toHaveBeenCalledTimes(2));
});
it("shows empty and recoverable error states for plugin logs", async () => {
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
client.getPluginLogs.mockRejectedValueOnce(new Error("logs exploded"));
renderPage(client);
fireEvent.click(await screen.findByRole("button", { name: "Logs" }));
expect(await screen.findByText("Unable to load plugin logs")).toBeDefined();
expect(screen.getByText("logs exploded")).toBeDefined();
fireEvent.click(screen.getAllByRole("button", { name: "Refresh" }).at(-1)!);
await waitFor(() => expect(client.getPluginLogs).toHaveBeenCalledTimes(2));
expect(await screen.findByText("No plugin output yet")).toBeDefined();
});
});
@@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import type { PluginListItem } from "@getpaseo/protocol/messages";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
@@ -39,12 +40,16 @@ function PluginRow({
pending,
pendingAction,
onAction,
onOpenLogs,
supportsLogs,
}: {
plugin: PluginListItem;
clientError?: string;
pending: boolean;
pendingAction?: PluginRowAction;
onAction(action: PluginRowAction, plugin: PluginListItem): void;
onOpenLogs(pluginId: string): void;
supportsLogs: boolean;
}) {
const { t } = useTranslation();
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
@@ -53,6 +58,7 @@ function PluginRow({
[onAction, plugin],
);
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]);
const status = clientError ? "failed" : plugin.status;
let badgeVariant: "success" | "error" | "muted" = "muted";
if (status === "running") badgeVariant = "success";
@@ -76,6 +82,11 @@ function PluginRow({
) : null}
</View>
<View style={styles.actions}>
{supportsLogs ? (
<Button variant="outline" size="sm" onPress={openLogs} disabled={pending}>
{t("settings.plugins.logs.action")}
</Button>
) : null}
<Button variant="outline" size="sm" onPress={reload} disabled={pending || !plugin.enabled}>
{pendingAction === "reload"
? t("settings.plugins.actions.reloading")
@@ -94,11 +105,85 @@ function PluginRow({
);
}
function PluginLogsSheet({
client,
pluginId,
serverId,
onClose,
}: {
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
pluginId: string;
serverId: string;
onClose(): void;
}) {
const { t } = useTranslation();
const logs = useFetchQuery({
queryKey: ["plugin-logs", serverId, pluginId],
queryFn: () => client.getPluginLogs(pluginId),
dataShape: "list",
staleTimeMs: 0,
});
const refresh = useCallback(() => {
void logs.refetch();
}, [logs]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.plugins.logs.title", { id: pluginId }),
actions: (
<Button variant="outline" size="sm" onPress={refresh} disabled={logs.isFetching}>
{logs.isFetching
? t("settings.plugins.logs.refreshing")
: t("settings.plugins.logs.refresh")}
</Button>
),
}),
[logs.isFetching, pluginId, refresh, t],
);
let content = <Text style={styles.logsState}>{t("settings.plugins.logs.loading")}</Text>;
if (logs.isError) {
content = (
<Alert
variant="error"
title={t("settings.plugins.logs.errorTitle")}
description={errorMessage(logs.error)}
>
<Button variant="outline" size="sm" onPress={refresh}>
{t("settings.plugins.logs.refresh")}
</Button>
</Alert>
);
} else if (!logs.isLoading) {
content = logs.data?.length ? (
<View style={styles.logList}>
{logs.data.map((entry: PluginLogEntry) => (
<View key={entry.sequence} style={styles.logEntry}>
<Text style={entry.stream === "stderr" ? styles.logStderr : styles.logStdout}>
{entry.timestamp} {entry.stream}
</Text>
<Text style={styles.logMessage}>{entry.message}</Text>
</View>
))}
</View>
) : (
<Text style={styles.logsState}>{t("settings.plugins.logs.empty")}</Text>
);
}
return (
<AdaptiveModalSheet header={header} visible onClose={onClose} testID="plugin-logs-sheet">
{content}
</AdaptiveModalSheet>
);
}
export function HostPluginsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "pluginManagement");
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
const logsSupported = useHostFeature(serverId, "pluginLogs");
const { config, patchConfig } = useDaemonConfig(serverId);
const refreshQueue = useRef(Promise.resolve());
useInstalledPlugins();
@@ -107,6 +192,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
const [pluginId, setPluginId] = useState("");
const [directoryResetKey, setDirectoryResetKey] = useState(0);
const [pluginIdResetKey, setPluginIdResetKey] = useState(0);
const [logsPluginId, setLogsPluginId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>(
null,
);
@@ -223,6 +309,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
const retry = useCallback(() => {
void refetchPlugins();
}, [refetchPlugins]);
const closeLogs = useCallback(() => setLogsPluginId(null), []);
if (pageState === "offline") {
return (
@@ -265,6 +352,8 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
pending={mutation.isPending}
pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined}
onAction={action}
onOpenLogs={setLogsPluginId}
supportsLogs={logsSupported}
/>
);
})
@@ -340,6 +429,14 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
) : null}
{catalogContent}
</SettingsSection>
{client && logsPluginId ? (
<PluginLogsSheet
client={client}
pluginId={logsPluginId}
serverId={serverId}
onClose={closeLogs}
/>
) : null}
</View>
);
}
@@ -356,4 +453,32 @@ const styles = StyleSheet.create((theme) => ({
actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] },
error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.xs },
empty: { padding: theme.spacing[4], alignItems: "center" },
logsState: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
paddingVertical: theme.spacing[6],
},
logList: { gap: theme.spacing[2] },
logEntry: {
padding: theme.spacing[3],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
},
logStdout: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.xs,
},
logStderr: {
color: theme.colors.statusDanger,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.xs,
},
logMessage: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
}));