From 8029f8d5ea51050353c611a8acce5215c2c033d9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 05:01:46 +0100 Subject: [PATCH] feat(plugins): manage saved hosts through client handles --- docs/plugins.md | 6 +- .../src/plugins/catalog-sync-model.test.ts | 6 + packages/app/src/plugins/client-runtime.ts | 10 +- packages/app/src/plugins/evaluate.test.ts | 1 + packages/app/src/plugins/evaluate.ts | 2 + packages/app/src/plugins/hosts/index.test.ts | 125 +++++++- packages/app/src/plugins/hosts/index.ts | 268 ++++++++++++++++-- packages/app/src/plugins/navigation.ts | 6 +- .../src/plugins/registry-lifecycle.test.ts | 6 + .../src/plugins/registry-requirements.test.ts | 6 + packages/app/src/runtime/host-runtime.ts | 7 +- packages/plugin/src/client/hosts.ts | 91 ++++++ packages/plugin/src/client/index.ts | 14 +- plugin-examples/hosts/client/hosts.tsx | 57 +++- plugin-examples/hosts/index.client.tsx | 6 +- plugin-examples/hosts/paseo-plugin.json | 2 +- public-docs/plugins/reference.md | 17 +- 17 files changed, 577 insertions(+), 53 deletions(-) create mode 100644 packages/plugin/src/client/hosts.ts diff --git a/docs/plugins.md b/docs/plugins.md index ffae136cc6..6299c6612c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -374,10 +374,10 @@ changes both `usePaseo()` and `useRpc()` to that host's installation. An offline installation. A server handler owns an IPC-backed daemon session for the life of its subprocess. Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation. -Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation +Host handles and host-targeted clients are owned by `packages/app/src/plugins/hosts`. Its `projectHost` is the secret boundary; host target input uses the shared `parseHostTarget` grammar. Per-installation bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not resolve ownership through a mutable current-plugin global. Keep observation ownership in this module -and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host). +and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#hosts). Each subprocess gets an exclusively owned `plugin:` session. That identity is reserved from normal clients, never resumes another session, and is cleaned immediately on exit without reconnect @@ -396,7 +396,7 @@ in-memory bundles usable, and a later catalog retries persistence. When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and -query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host). +query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#hosts). Workspace panels, Command Center items, and client slash commands are client contributions. The daemon transports their compiled bundle without interpreting placement or callbacks. Panel props diff --git a/packages/app/src/plugins/catalog-sync-model.test.ts b/packages/app/src/plugins/catalog-sync-model.test.ts index 98a8a3cf37..333cf24d8d 100644 --- a/packages/app/src/plugins/catalog-sync-model.test.ts +++ b/packages/app/src/plugins/catalog-sync-model.test.ts @@ -136,8 +136,14 @@ function harness() { getSnapshot: () => null, subscribeAll: () => () => {}, subscribeHostList: () => () => {}, + renameHost: async () => {}, + setHostColor: async () => {}, + setHostBadgeDisplay: async () => {}, + removeHost: async () => {}, + removeConnection: async () => {}, }, installation.lifetime.signal, + { openSettings() {}, isManagedLocalHost: async () => false }, ), paseo: installation.paseo, i18n: installation.i18n, diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index 61e921bd0f..4ad402cd34 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,5 +1,6 @@ import { createPlayAudio, type AudioEngine } from "@/audio"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { createPluginHosts } from "./hosts"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import { @@ -26,7 +27,14 @@ export function createPluginClientRuntime( ...capabilities, i18n: installation.i18n, playAudio: createPlayAudio(audio, installation.lifetime.signal), - hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), + hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal, { + openSettings(serverId) { + createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId); + }, + async isManagedLocalHost(serverId) { + return shouldUseDesktopDaemon() && (await getDesktopDaemonStatus()).serverId === serverId; + }, + }), addComposerPill(contribution) { return pluginButtonStore.addComposerPill(installation, contribution); }, diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 1aae11f63d..dd02beca93 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -819,6 +819,7 @@ it("binds imported getters to each originating installation across delayed callb hosts: { getSnapshot: () => [], subscribe: () => () => {}, + observeHosts: () => () => {}, getPaseoClient(serverId) { calls.push(`${installation}/${serverId}`); return runtime.paseo; diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index ea37209f57..bb7d0ccee2 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -520,6 +520,8 @@ export function runPluginClientBundle( useSettings, openExternalUrl, getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId), + observeHosts: (listener: Parameters[0]) => + runtime.hosts.observeHosts(listener), useHosts: () => React.useSyncExternalStore( runtime.hosts.subscribe, diff --git a/packages/app/src/plugins/hosts/index.test.ts b/packages/app/src/plugins/hosts/index.test.ts index 287c1480eb..9dfd3fd60d 100644 --- a/packages/app/src/plugins/hosts/index.test.ts +++ b/packages/app/src/plugins/hosts/index.test.ts @@ -4,14 +4,61 @@ import { createPluginHosts, type PluginHostsSource } from "./index"; function registry() { const hosts = [ - { serverId: "a", label: "Alpha", password: "secret" }, - { serverId: "b", label: "Beta", password: "secret" }, + { + serverId: "a", + label: "Alpha", + password: "secret", + appearance: { color: "none" as const, badgeDisplay: null }, + connections: [ + { id: "one", type: "directTcp" as const, endpoint: "localhost:6767", useTls: false }, + { id: "two", type: "directSocket" as const, path: "/tmp/test" }, + ], + lifecycle: {}, + preferredConnectionId: null, + createdAt: "today", + updatedAt: "today", + }, + { + serverId: "b", + label: "Beta", + password: "secret", + appearance: { color: "none" as const, badgeDisplay: null }, + connections: [], + lifecycle: {}, + preferredConnectionId: null, + createdAt: "today", + updatedAt: "today", + }, ]; - const snapshots = new Map>>(); + const snapshots = new Map< + string, + { connectionStatus: "online" | "offline" | "connecting"; client: DaemonClient | null } + >(); const listeners = new Set<() => void>(); const source: PluginHostsSource = { getHosts: () => hosts, - getSnapshot: (id) => snapshots.get(id) ?? null, + getSnapshot: (id) => { + const value = snapshots.get(id); + return value + ? { ...value, activeConnectionId: null, lastError: null, lastOnlineAt: null } + : null; + }, + renameHost: async (id, label) => { + hosts.find((candidate) => candidate.serverId === id)!.label = label; + }, + setHostColor: async () => {}, + setHostBadgeDisplay: async () => {}, + removeHost: async (id) => { + const index = hosts.findIndex((host) => host.serverId === id); + if (index >= 0) hosts.splice(index, 1); + for (const l of listeners) l(); + }, + removeConnection: async (id, connectionId) => { + const host = hosts.find((candidate) => candidate.serverId === id)!; + host.connections = host.connections.filter((c) => c.id !== connectionId); + if (!host.connections.length) await source.removeHost(id); + else for (const l of listeners) l(); + }, subscribeAll(listener) { listeners.add(listener); return () => { @@ -26,7 +73,10 @@ function registry() { }, }; const lifetime = new AbortController(); - const runtime = createPluginHosts(source, lifetime.signal); + const runtime = createPluginHosts(source, lifetime.signal, { + openSettings() {}, + isManagedLocalHost: async () => false, + }); return { hosts, snapshots, @@ -48,7 +98,7 @@ function connection(id: string) { describe("plugin host access", () => { it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => { const h = registry(); - expect(h.runtime.getSnapshot()).toEqual([ + expect(h.runtime.getSnapshot()).toMatchObject([ { serverId: "a", label: "Alpha", status: "offline" }, { serverId: "b", label: "Beta", status: "offline" }, ]); @@ -68,6 +118,64 @@ describe("plugin host access", () => { expect(h.listeners.size).toBe(0); }); + it("keeps handles stable, protects credentials, and stops observations on unload", async () => { + const h = registry(); + const seen: unknown[] = []; + const stop = h.runtime.observeHosts((hosts) => seen.push(hosts)); + const first = h.runtime.getSnapshot()[0]; + function walk(value: unknown): void { + if (typeof value === "string") expect(value).not.toContain("secret"); + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + expect(key.toLowerCase()).not.toBe("password"); + walk(child); + } + } + walk(h.runtime.getSnapshot()); + h.hosts[0].label = "New name"; + h.publish(); + expect(h.runtime.getSnapshot()[0]).toBe(first); + expect(first.label).toBe("New name"); + expect(seen).toHaveLength(2); + h.lifetime.abort(); + h.publish(); + expect(seen).toHaveLength(2); + await expect(first.remove()).rejects.toThrow("Plugin has stopped"); + stop(); + }); + + it("redacts saved passwords from mutation errors", async () => { + const h = registry(); + h.source.renameHost = async () => { + throw new Error("Failed for secret"); + }; + await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow("secret"); + h.lifetime.abort(); + }); + + it("removes a connection and then the last host connection", async () => { + const h = registry(); + const host = h.runtime.getSnapshot()[0]; + await host.removeConnection("one"); + expect(host.connections.map((item) => item.id)).toEqual(["two"]); + await host.removeConnection("two"); + expect(h.runtime.getSnapshot().map((entry) => entry.serverId)).toEqual(["b"]); + h.lifetime.abort(); + }); + + it("rejects removal of the managed desktop daemon", async () => { + const h = registry(); + const lifetime = new AbortController(); + const runtime = createPluginHosts(h.source, lifetime.signal, { + openSettings() {}, + isManagedLocalHost: async (id) => id === "a", + }); + await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon"); + expect(runtime.getSnapshot()).toHaveLength(2); + lifetime.abort(); + h.lifetime.abort(); + }); + it("rejects unknown and disconnected targets without falling through to another host", () => { const h = registry(); h.snapshots.set("a", { connectionStatus: "online", client: connection("a") }); @@ -82,7 +190,10 @@ describe("plugin host access", () => { h.snapshots.set("b", { connectionStatus: "online", client }); h.publish(); const otherLifetime = new AbortController(); - const other = createPluginHosts(h.source, otherLifetime.signal); + const other = createPluginHosts(h.source, otherLifetime.signal, { + openSettings() {}, + isManagedLocalHost: async () => false, + }); const api = h.runtime.getPaseoClient("b"); expect(api).toBe(h.runtime.getPaseoClient("b")); expect(api).not.toBe(other.getPaseoClient("b")); diff --git a/packages/app/src/plugins/hosts/index.ts b/packages/app/src/plugins/hosts/index.ts index f3a6ace947..bd8466f8ed 100644 --- a/packages/app/src/plugins/hosts/index.ts +++ b/packages/app/src/plugins/hosts/index.ts @@ -1,31 +1,251 @@ import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginHostSummary } from "@getpaseo/plugin/client"; +import type { PluginHost, PluginHostConnection, PluginHostPatch } from "@getpaseo/plugin/client"; +import type { HostProfile } from "@/types/host-connection"; +import type { HostRuntimeSnapshot } from "@/runtime/host-runtime"; +import { + parseHostPort, + serializeConnectionUri, + serializeRelayConnectionUri, + shouldUseTlsForDefaultHostedRelay, +} from "@getpaseo/protocol/daemon-endpoints"; +import { serializeSocketHostTarget } from "@getpaseo/protocol/host-target"; +import { + DEFAULT_SSH_DAEMON_PORT, + serializeSshTransportUri, +} from "@getpaseo/protocol/ssh-transport"; +type HostSnapshot = Pick< + HostRuntimeSnapshot, + | "connectionStatus" + | "client" + | "activeConnectionId" + | "lastError" + | "authFailureReason" + | "lastOnlineAt" +>; export interface PluginHostsSource { - getHosts(): readonly { serverId: string; label: string }[]; - getSnapshot(serverId: string): { - connectionStatus: PluginHostSummary["status"]; - client: DaemonClient | null; - } | null; + getHosts(): readonly HostProfile[]; + getSnapshot(serverId: string): HostSnapshot | null; subscribeAll(listener: () => void): () => void; subscribeHostList(listener: () => void): () => void; + renameHost(serverId: string, label: string): Promise; + setHostColor(serverId: string, color: PluginHost["appearance"]["color"]): Promise; + setHostBadgeDisplay( + serverId: string, + badge: PluginHost["appearance"]["badgeDisplay"], + ): Promise; + removeHost(serverId: string): Promise; + removeConnection(serverId: string, connectionId: string): Promise; +} +type ProjectedHost = Omit; +function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost { + const connections: PluginHostConnection[] = profile.connections.map((connection) => { + switch (connection.type) { + case "directTcp": + return { + id: connection.id, + type: connection.type, + endpoint: connection.endpoint, + useTls: connection.useTls ?? false, + uri: serializeConnectionUri({ + ...parseHostPort(connection.endpoint), + useTls: connection.useTls ?? false, + }), + }; + case "directSocket": + return { + id: connection.id, + type: connection.type, + path: connection.path, + uri: serializeSocketHostTarget("unix", connection.path), + }; + case "directPipe": + return { + id: connection.id, + type: connection.type, + path: connection.path, + uri: serializeSocketHostTarget("pipe", connection.path), + }; + case "remoteSsh": + return { + id: connection.id, + type: connection.type, + host: connection.host, + ...(connection.sshPort === undefined ? {} : { sshPort: connection.sshPort }), + ...(connection.daemonPort === undefined ? {} : { daemonPort: connection.daemonPort }), + uri: serializeSshTransportUri({ + host: connection.host, + sshPort: connection.sshPort, + daemonPort: connection.daemonPort ?? DEFAULT_SSH_DAEMON_PORT, + }), + }; + case "relay": { + const useTls = + connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint); + return { + id: connection.id, + type: connection.type, + relayEndpoint: connection.relayEndpoint, + useTls, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + ...(connection.minimumSecurityMode + ? { minimumSecurityMode: connection.minimumSecurityMode } + : {}), + uri: serializeRelayConnectionUri({ + offer: { + v: 2, + serverId: profile.serverId, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + relay: { endpoint: connection.relayEndpoint, useTls }, + ...(connection.minimumSecurityMode + ? { minimumSecurityMode: connection.minimumSecurityMode } + : {}), + }, + }), + }; + } + case "routed": + return { + id: connection.id, + type: connection.type, + originServerId: connection.originServerId, + uri: null, + }; + } + }); + const error = snapshot?.lastError ?? null; + return { + serverId: profile.serverId, + label: profile.label, + status: snapshot?.connectionStatus ?? "offline", + appearance: { color: profile.appearance.color, badgeDisplay: profile.appearance.badgeDisplay }, + connections, + activeConnectionId: snapshot?.activeConnectionId ?? null, + error: error && profile.password ? error.split(profile.password).join("[redacted]") : error, + authFailureReason: snapshot?.authFailureReason ?? null, + lastOnlineAt: snapshot?.lastOnlineAt ?? null, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + }; } /** Each evaluated installation owns its borrowed APIs and registry subscriptions. */ -export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) { +export function createPluginHosts( + source: PluginHostsSource, + signal: AbortSignal, + options: { + openSettings(serverId: string): void; + isManagedLocalHost(serverId: string): Promise; + }, +) { const clients = new Map< string, { client: DaemonClient; api: PaseoApi; lifetime: AbortController } >(); const listeners = new Set<() => void>(); - let snapshot: readonly PluginHostSummary[] = []; - function readHosts(): readonly PluginHostSummary[] { - return source.getHosts().map(({ serverId, label }) => ({ - serverId, - label, - status: source.getSnapshot(serverId)?.connectionStatus ?? "offline", - })); + let snapshot: readonly PluginHost[] = []; + let serialized = ""; + const entries = new Map(); + function assertHost(id: string) { + if (signal.aborted) throw new Error("Plugin has stopped"); + if (!source.getHosts().some((host) => host.serverId === id)) + throw new Error(`Unknown Paseo host: ${id}`); + } + async function perform(id: string, action: () => Promise): Promise { + const secret = source.getHosts().find((host) => host.serverId === id)?.password; + try { + await action(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(secret ? message.split(secret).join("[redacted]") : message); + } + } + function readHosts(): { hosts: PluginHost[]; key: string } { + const projected = source + .getHosts() + .map((profile) => projectHost(profile, source.getSnapshot(profile.serverId))); + const ids = new Set(projected.map((host) => host.serverId)); + for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id); + const hosts = projected.map((data) => { + let entry = entries.get(data.serverId); + if (!entry) { + const id = data.serverId; + const read = () => entry!.data; + const handle: PluginHost = { + get serverId() { + return read().serverId; + }, + get label() { + return read().label; + }, + get status() { + return read().status; + }, + get appearance() { + return read().appearance; + }, + get connections() { + return read().connections; + }, + get activeConnectionId() { + return read().activeConnectionId; + }, + get error() { + return read().error; + }, + get authFailureReason() { + return read().authFailureReason; + }, + get lastOnlineAt() { + return read().lastOnlineAt; + }, + get createdAt() { + return read().createdAt; + }, + get updatedAt() { + return read().updatedAt; + }, + async update(patch: PluginHostPatch) { + await perform(id, async () => { + assertHost(id); + if (patch.label !== undefined) await source.renameHost(id, patch.label); + if (patch.appearance?.color !== undefined) { + assertHost(id); + await source.setHostColor(id, patch.appearance.color); + } + if (patch.appearance?.badgeDisplay !== undefined) { + assertHost(id); + await source.setHostBadgeDisplay(id, patch.appearance.badgeDisplay); + } + }); + }, + async remove() { + await perform(id, async () => { + assertHost(id); + if (await options.isManagedLocalHost(id)) + throw new Error("Cannot remove the desktop daemon host"); + assertHost(id); + await source.removeHost(id); + }); + }, + async removeConnection(connectionId) { + await perform(id, async () => { + assertHost(id); + await source.removeConnection(id, connectionId); + }); + }, + openSettings() { + assertHost(id); + options.openSettings(id); + }, + }; + entry = { data, handle }; + entries.set(id, entry); + } else entry.data = data; + return entry.handle; + }); + return { hosts, key: JSON.stringify(projected) }; } function resolve(serverId: string): DaemonClient { if (signal.aborted) throw new Error("Plugin has stopped"); @@ -49,11 +269,16 @@ export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal } } const next = readHosts(); - if (JSON.stringify(snapshot) === JSON.stringify(next)) return; - snapshot = next; + if (serialized === next.key) return; + serialized = next.key; + snapshot = next.hosts; for (const listener of listeners) listener(); } - snapshot = readHosts(); + { + const initial = readHosts(); + snapshot = initial.hosts; + serialized = initial.key; + } const unsubscribeRuntime = source.subscribeAll(refresh); const unsubscribeHosts = source.subscribeHostList(refresh); function stop() { @@ -67,6 +292,15 @@ export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal else signal.addEventListener("abort", stop, { once: true }); return { getSnapshot: () => snapshot, + observeHosts(listener: (hosts: readonly PluginHost[]) => void) { + if (signal.aborted) throw new Error("Plugin has stopped"); + const notify = () => listener(snapshot); + listeners.add(notify); + listener(snapshot); + return () => { + listeners.delete(notify); + }; + }, subscribe(listener: () => void) { if (signal.aborted) return () => {}; listeners.add(listener); diff --git a/packages/app/src/plugins/navigation.ts b/packages/app/src/plugins/navigation.ts index c1a50a98fc..58a44fd881 100644 --- a/packages/app/src/plugins/navigation.ts +++ b/packages/app/src/plugins/navigation.ts @@ -1,3 +1,4 @@ +import { buildSettingsHostRoute } from "@/utils/host-routes"; import { buildPluginSettingsRoute } from "./settings/routes"; import { router } from "expo-router"; import type { PluginPanelLocation } from "@getpaseo/plugin/client"; @@ -9,7 +10,7 @@ import type { PluginNavigation } from "./actions"; export function createPluginNavigation(input: { serverId: string; workspaceId: string | null; -}): PluginNavigation { +}): PluginNavigation & { openHostSettings(serverId: string): void } { const { serverId, workspaceId } = input; function placement(location: PluginPanelLocation) { if (location !== "explorer") return undefined; @@ -20,6 +21,9 @@ export function createPluginNavigation(input: { return { mode: "pane" as const, paneId }; } return { + openHostSettings(hostId) { + router.push(buildSettingsHostRoute(hostId)); + }, openSettings(pluginId, screenId) { router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)); }, diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index d2d02cb568..d6310bf558 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -47,8 +47,14 @@ function registry( getSnapshot: () => null, subscribeAll: () => () => {}, subscribeHostList: () => () => {}, + renameHost: async () => {}, + setHostColor: async () => {}, + setHostBadgeDisplay: async () => {}, + removeHost: async () => {}, + removeConnection: async () => {}, }, installation.lifetime.signal, + { openSettings() {}, isManagedLocalHost: async () => false }, ), paseo: installation.paseo, i18n: installation.i18n, diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index dedefe0b0d..07cdece1c3 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -22,8 +22,14 @@ function registry(version: string) { getSnapshot: () => null, subscribeAll: () => () => {}, subscribeHostList: () => () => {}, + renameHost: async () => {}, + setHostColor: async () => {}, + setHostBadgeDisplay: async () => {}, + removeHost: async () => {}, + removeConnection: async () => {}, }, installation.lifetime.signal, + { openSettings() {}, isManagedLocalHost: async () => false }, ), paseo: installation.paseo, i18n: installation.i18n, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 0c7c9f4660..9fe692d10d 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -2221,7 +2221,10 @@ export class HostRuntimeStore { })); } - async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise { + async setHostBadgeDisplay( + serverId: string, + badgeDisplay: HostBadgeDisplay | null, + ): Promise { await this.updateHostAppearance(serverId, (host) => ({ ...host, appearance: { ...host.appearance, badgeDisplay }, @@ -3034,7 +3037,7 @@ export interface HostMutations { beginLinkPairing: () => LinkPairing; renameHost: (serverId: string, label: string) => Promise; setHostColor: (serverId: string, color: HostColor) => Promise; - setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise; + setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay | null) => Promise; removeHost: (serverId: string) => Promise; removeConnection: (serverId: string, connectionId: string) => Promise; } diff --git a/packages/plugin/src/client/hosts.ts b/packages/plugin/src/client/hosts.ts new file mode 100644 index 0000000000..265cffba18 --- /dev/null +++ b/packages/plugin/src/client/hosts.ts @@ -0,0 +1,91 @@ +import type { PluginHostSummary } from "./index.js"; + +export type PluginHostStatus = PluginHostSummary["status"]; +export type PluginHostColor = + | "none" + | "violet" + | "sky" + | "emerald" + | "orange" + | "pink" + | "indigo" + | "teal" + | "red" + | "amber" + | "blue"; +export type PluginHostBadgeDisplay = "name" | "icon" | "hidden"; +export interface PluginHostAppearance { + readonly color: PluginHostColor; + readonly badgeDisplay: PluginHostBadgeDisplay | null; +} +export type PluginHostConnection = + | { + readonly id: string; + readonly type: "directTcp"; + readonly uri: string; + readonly endpoint: string; + readonly useTls: boolean; + } + | { + readonly id: string; + readonly type: "directSocket"; + readonly uri: string; + readonly path: string; + } + | { + readonly id: string; + readonly type: "directPipe"; + readonly uri: string; + readonly path: string; + } + | { + readonly id: string; + readonly type: "remoteSsh"; + readonly uri: string; + readonly host: string; + readonly sshPort?: number; + readonly daemonPort?: number; + } + | { + readonly id: string; + readonly type: "relay"; + readonly uri: string; + readonly relayEndpoint: string; + readonly useTls: boolean; + readonly daemonPublicKeyB64: string; + readonly minimumSecurityMode?: "protected"; + } + | { + readonly id: string; + readonly type: "routed"; + readonly uri: null; + readonly originServerId: string; + }; +export interface PluginHostPatch { + label?: string; + appearance?: Partial; +} +export interface PluginHost extends PluginHostSummary { + readonly appearance: PluginHostAppearance; + readonly connections: readonly PluginHostConnection[]; + readonly activeConnectionId: string | null; + readonly error: string | null; + readonly authFailureReason: "password_required" | "incorrect_password" | null; + readonly lastOnlineAt: string | null; + readonly createdAt: string; + readonly updatedAt: string; + update(patch: PluginHostPatch): Promise; + remove(): Promise; + removeConnection(connectionId: string): Promise; + openSettings(): void; +} +export interface PluginHostAddInput { + connection: string; + label?: string; +} +export type PluginHostAddResult = + | { status: "saved"; host: PluginHost } + | { status: "password_required"; reason: "password_required" | "incorrect_password" } + | { status: "cancelled" }; +export declare function useHosts(): readonly PluginHost[]; +export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index 62cf7e2dc3..7ee414308a 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -86,7 +86,19 @@ export interface PluginHostSummary { readonly status: "idle" | "connecting" | "online" | "offline" | "error"; } /** Live configured hosts. Supplied by the app's client bundle loader. */ -export declare function useHosts(): readonly PluginHostSummary[]; +export declare const useHosts: typeof import("./hosts.js").useHosts; +export declare const observeHosts: typeof import("./hosts.js").observeHosts; +export type { + PluginHost, + PluginHostStatus, + PluginHostColor, + PluginHostBadgeDisplay, + PluginHostAppearance, + PluginHostConnection, + PluginHostPatch, + PluginHostAddInput, + PluginHostAddResult, +} from "./hosts.js"; /** Borrow an online host's API under this installation's lifetime. */ export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi; /** Open an absolute HTTP(S) URL using the client platform’s external opener. */ diff --git a/plugin-examples/hosts/client/hosts.tsx b/plugin-examples/hosts/client/hosts.tsx index 096843ed9a..96b0209c0b 100644 --- a/plugin-examples/hosts/client/hosts.tsx +++ b/plugin-examples/hosts/client/hosts.tsx @@ -3,6 +3,7 @@ import { useHosts, usePaseo, type PluginSurfaceProps, + type PluginHost, } from "@getpaseo/plugin/client"; import { useCallback, useMemo, useState } from "react"; import { Pressable, Text, View } from "react-native"; @@ -33,27 +34,55 @@ export function Hosts({ theme }: PluginSurfaceProps) { const listSelected = useCallback(() => { void list(); }, [list]); - const rows = hosts.map((host) => ({ - serverId: host.serverId, - label: host.label, - status: host.status, - onPress() { - void list(host.serverId); - }, - })); return ( List selected host agents - {rows.map((host) => ( - - - {host.label}: {host.status} - - + {hosts.map((host) => ( + ))} {result} ); } + +function HostRow({ + host, + color, + list, +}: { + host: PluginHost; + color: string; + list: (serverId: string) => Promise; +}) { + const style = useMemo(() => ({ color }), [color]); + const rowStyle = useMemo(() => ({ gap: 4 }), []); + const listAgents = useCallback(() => void list(host.serverId), [host, list]); + const update = useCallback( + () => void host.update({ label: `${host.label} (build)`, appearance: { color: "sky" } }), + [host], + ); + const open = useCallback(() => host.openSettings(), [host]); + const remove = useCallback(() => void host.remove(), [host]); + return ( + + + {host.label}: {host.status} ·{" "} + {host.connections.map((connection) => connection.uri ?? "via another host").join(", ")} + + + List agents + + + Rename and color + + + Host settings + + + Remove host + + + ); +} diff --git a/plugin-examples/hosts/index.client.tsx b/plugin-examples/hosts/index.client.tsx index 76480eac00..61dd12db88 100644 --- a/plugin-examples/hosts/index.client.tsx +++ b/plugin-examples/hosts/index.client.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; +import { observeHosts } from "@getpaseo/plugin/client"; import { SidebarRow } from "@getpaseo/plugin/client/ui"; import { Hosts } from "./client/hosts"; @@ -11,5 +12,8 @@ function HostsItem({ currentScreen, openScreen }: PluginSidebarItemProps) { export default function contribute(client: PluginClientContext) { client.addScreen({ id: "main", title: "Host agents", Component: Hosts }); client.addSidebarHeaderItem({ id: "hosts", title: "Host agents", Component: HostsItem }); - return () => {}; + return observeHosts((hosts) => { + for (const host of hosts) + if (host.authFailureReason) console.log(`${host.label} requires attention`); + }); } diff --git a/plugin-examples/hosts/paseo-plugin.json b/plugin-examples/hosts/paseo-plugin.json index b642ae50ae..75ccfd513f 100644 --- a/plugin-examples/hosts/paseo-plugin.json +++ b/plugin-examples/hosts/paseo-plugin.json @@ -1 +1 @@ -{ "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } } +{ "id": "host-agents", "requirements": { "paseo": ">=0.15.0" } } diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index a5565b71c7..28dfc7825c 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2078,7 +2078,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) { The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. -### Discover hosts and target another host +### Hosts Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback to run SDK operations on one of them: @@ -2124,8 +2124,15 @@ export function HostAgents({ theme }: Pick): ReactE } ``` -`useHosts(): readonly PluginHostSummary[]` includes offline hosts and updates when hosts, -labels, or statuses change. +`useHosts(): readonly PluginHost[]` includes offline hosts and updates when projected fields change. +`observeHosts(listener)` immediately delivers the same list and returns an unsubscribe function. +Set `requirements.paseo` in the manifest to the release that ships these host capabilities. + +Each host handle provides `appearance`, `connections`, `activeConnectionId`, `error`, +`authFailureReason`, `lastOnlineAt`, `createdAt`, and `updatedAt`. Connections expose their +credential-free `uri`, or `null` for routed hosts. Call `host.update({ label, appearance })`, +`host.removeConnection(id)`, `host.remove()`, or `host.openSettings()` to manage a host. +Removing the last connection removes the host. The desktop daemon host cannot be removed by a plugin. | Summary field | Type or values | Meaning | | ------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | @@ -2150,8 +2157,8 @@ on the target daemon. Acquire the API when performing an action to use the curre You can also release individual subscriptions through the normal SDK API. -Plugins are trusted app code; cross-host access is intentional. Summaries contain no connection -URLs or credentials, and borrowed APIs provide no connection lifecycle controls. See the +Plugins see connection targets and daemon identities but cannot read saved passwords. Borrowed APIs +provide no connection lifecycle controls. See the [host agents example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts). ## Add plugin-specific backend behavior