From f92cce9fd2a8db13581ef23d4f4f627e9da1eebb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 05:11:58 +0100 Subject: [PATCH] feat(plugins): add saved hosts from connection targets --- .../src/plugins/catalog-sync-model.test.ts | 1 + packages/app/src/plugins/evaluate.test.ts | 18 +- packages/app/src/plugins/evaluate.ts | 2 + packages/app/src/plugins/hosts/index.test.ts | 57 ++++ packages/app/src/plugins/hosts/index.ts | 60 ++++- .../src/plugins/registry-lifecycle.test.ts | 1 + .../src/plugins/registry-requirements.test.ts | 1 + packages/app/src/runtime/host-confirmation.ts | 34 ++- packages/app/src/runtime/host-runtime.test.ts | 254 +++++++++++++++++- packages/app/src/runtime/host-runtime.ts | 121 ++++++++- packages/plugin/src/boundaries.test.ts | 12 + packages/plugin/src/client/hosts.ts | 7 + packages/plugin/src/client/index.ts | 1 + packages/protocol/src/host-target.test.ts | 22 ++ plugin-examples/hosts/client/hosts.tsx | 25 +- public-docs/plugins/reference.md | 37 ++- 16 files changed, 620 insertions(+), 33 deletions(-) diff --git a/packages/app/src/plugins/catalog-sync-model.test.ts b/packages/app/src/plugins/catalog-sync-model.test.ts index 333cf24d8d..1c46f7b6f4 100644 --- a/packages/app/src/plugins/catalog-sync-model.test.ts +++ b/packages/app/src/plugins/catalog-sync-model.test.ts @@ -132,6 +132,7 @@ function harness() { return { hosts: createPluginHosts( { + addHost: async () => ({ status: "cancelled" }), getHosts: () => [], getSnapshot: () => null, subscribeAll: () => () => {}, diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index dd02beca93..f1bb69373a 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -819,7 +819,14 @@ it("binds imported getters to each originating installation across delayed callb hosts: { getSnapshot: () => [], subscribe: () => () => {}, - observeHosts: () => () => {}, + observeHosts: () => { + calls.push(`${installation}/observe`); + return () => {}; + }, + addHost: async (input) => { + calls.push(`${installation}/add:${input.connection}`); + return { status: "cancelled" }; + }, getPaseoClient(serverId) { calls.push(`${installation}/${serverId}`); return runtime.paseo; @@ -827,11 +834,12 @@ it("binds imported getters to each originating installation across delayed callb }, }); const source = bundle(` - const { getPaseoClient } = require("@getpaseo/plugin/client"); + const { getPaseoClient, observeHosts, addHost } = require("@getpaseo/plugin/client"); getPaseoClient("entry-host"); + observeHosts(() => {}); plugin.addCommandCenterItem({ id: "read", title: "Read", icon: "Server", context: "global", - onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); }, + onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); await addHost({ connection: "example:6767" }); }, }); `); const first = runPluginClientBundle("same-id", source, hostRuntime("first")); @@ -841,9 +849,13 @@ it("binds imported getters to each originating installation across delayed callb await second.commandCenterItems[0].onSelect({} as never); expect(calls).toEqual([ "first/entry-host", + "first/observe", "second/entry-host", + "second/observe", "first/target-host", + "first/add:example:6767", "second/target-host", + "second/add:example:6767", ]); await first.cleanup(); await second.cleanup(); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index bb7d0ccee2..1bddae2772 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), + addHost: (input: Parameters[0]) => + runtime.hosts.addHost(input), observeHosts: (listener: Parameters[0]) => runtime.hosts.observeHosts(listener), useHosts: () => diff --git a/packages/app/src/plugins/hosts/index.test.ts b/packages/app/src/plugins/hosts/index.test.ts index 9dfd3fd60d..4d438275da 100644 --- a/packages/app/src/plugins/hosts/index.test.ts +++ b/packages/app/src/plugins/hosts/index.test.ts @@ -36,6 +36,7 @@ function registry() { >(); const listeners = new Set<() => void>(); const source: PluginHostsSource = { + addHost: async () => ({ status: "cancelled" }), getHosts: () => hosts, getSnapshot: (id) => { const value = snapshots.get(id); @@ -153,6 +154,62 @@ describe("plugin host access", () => { h.lifetime.abort(); }); + it("returns the projected handle after adding a host", async () => { + const h = registry(); + h.source.addHost = async () => ({ status: "saved", serverId: "a" }); + const result = await h.runtime.addHost({ connection: "tcp://example:6767?password=secret" }); + expect(result.status).toBe("saved"); + if (result.status !== "saved") throw new Error("expected saved host"); + expect(result.host).toBe(h.runtime.getSnapshot()[0]); + expect(JSON.stringify(result)).not.toContain("secret"); + h.lifetime.abort(); + await expect(h.runtime.addHost({ connection: "example:6767" })).rejects.toThrow( + "Plugin has stopped", + ); + }); + + it("passes cancellation and authentication results without exposing credentials", async () => { + const h = registry(); + for (const result of [ + { status: "cancelled" as const }, + { status: "password_required" as const, reason: "incorrect_password" as const }, + ]) { + h.source.addHost = async () => result; + const returned = await h.runtime.addHost({ + connection: "tcp://example:6767?password=secret", + }); + expect(returned).toEqual(result); + for (const [key, value] of Object.entries(returned)) { + expect(key).not.toBe("password"); + expect(value).not.toBe("secret"); + } + } + h.lifetime.abort(); + }); + + it("redacts encoded saved passwords in mutation errors", async () => { + const h = registry(); + h.hosts[0].password = "two words"; + h.source.renameHost = async () => { + throw new Error("failed for two%20words"); + }; + await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow( + "two%20words", + ); + h.lifetime.abort(); + }); + + it("never exposes supplied passwords in add errors", async () => { + const h = registry(); + h.source.addHost = async () => { + throw new Error("secret was rejected"); + }; + await expect( + h.runtime.addHost({ connection: "tcp://example:6767?password=secret" }), + ).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]; diff --git a/packages/app/src/plugins/hosts/index.ts b/packages/app/src/plugins/hosts/index.ts index bd8466f8ed..579ad303e1 100644 --- a/packages/app/src/plugins/hosts/index.ts +++ b/packages/app/src/plugins/hosts/index.ts @@ -1,6 +1,12 @@ import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginHost, PluginHostConnection, PluginHostPatch } from "@getpaseo/plugin/client"; +import type { + PluginHost, + PluginHostConnection, + PluginHostPatch, + PluginHostAddInput, + PluginHostAddResult, +} from "@getpaseo/plugin/client"; import type { HostProfile } from "@/types/host-connection"; import type { HostRuntimeSnapshot } from "@/runtime/host-runtime"; import { @@ -9,7 +15,7 @@ import { serializeRelayConnectionUri, shouldUseTlsForDefaultHostedRelay, } from "@getpaseo/protocol/daemon-endpoints"; -import { serializeSocketHostTarget } from "@getpaseo/protocol/host-target"; +import { parseHostTarget, serializeSocketHostTarget } from "@getpaseo/protocol/host-target"; import { DEFAULT_SSH_DAEMON_PORT, serializeSshTransportUri, @@ -25,6 +31,14 @@ type HostSnapshot = Pick< | "lastOnlineAt" >; export interface PluginHostsSource { + addHost( + input: PluginHostAddInput, + signal?: AbortSignal, + ): Promise< + | { status: "saved"; serverId: string } + | { status: "password_required"; reason: "password_required" | "incorrect_password" } + | { status: "cancelled" } + >; getHosts(): readonly HostProfile[]; getSnapshot(serverId: string): HostSnapshot | null; subscribeAll(listener: () => void): () => void; @@ -38,6 +52,17 @@ export interface PluginHostsSource { removeHost(serverId: string): Promise; removeConnection(serverId: string, connectionId: string): Promise; } +function redactSecret(message: string, secret: string | undefined): string { + if (!secret) return message; + for (const value of [ + secret, + encodeURIComponent(secret), + new URLSearchParams({ password: secret }).toString().slice(9), + ]) { + message = message.split(value).join("[redacted]"); + } + return message; +} type ProjectedHost = Omit; function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost { const connections: PluginHostConnection[] = profile.connections.map((connection) => { @@ -122,7 +147,7 @@ function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): Proje 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, + error: error ? redactSecret(error, profile.password) : null, authFailureReason: snapshot?.authFailureReason ?? null, lastOnlineAt: snapshot?.lastOnlineAt ?? null, createdAt: profile.createdAt, @@ -158,7 +183,9 @@ export function createPluginHosts( await action(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(secret ? message.split(secret).join("[redacted]") : message); + // A cause would expose the original error, including any password. + // oxlint-disable-next-line preserve-caught-error + throw new Error(redactSecret(message, secret)); } } function readHosts(): { hosts: PluginHost[]; key: string } { @@ -292,6 +319,31 @@ export function createPluginHosts( else signal.addEventListener("abort", stop, { once: true }); return { getSnapshot: () => snapshot, + async addHost(input: PluginHostAddInput): Promise { + if (signal.aborted) throw new Error("Plugin has stopped"); + let result: Awaited>; + try { + result = await source.addHost(input, signal); + } catch (error) { + let secret: string | undefined; + try { + secret = parseHostTarget(input.connection).password; + } catch { + /* Invalid input has no parsed password. */ + } + const message = redactSecret( + error instanceof Error ? error.message : String(error), + secret, + ); + // The original error can contain the supplied credential. + // oxlint-disable-next-line preserve-caught-error + throw new Error(message); + } + if (result.status !== "saved") return result; + const host = snapshot.find((item) => item.serverId === result.serverId); + if (!host) throw new Error(`Unknown Paseo host: ${result.serverId}`); + return { status: "saved", host }; + }, observeHosts(listener: (hosts: readonly PluginHost[]) => void) { if (signal.aborted) throw new Error("Plugin has stopped"); const notify = () => listener(snapshot); diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index d6310bf558..9399bb7b8b 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -43,6 +43,7 @@ function registry( return { hosts: createPluginHosts( { + addHost: async () => ({ status: "cancelled" }), getHosts: () => [], getSnapshot: () => null, subscribeAll: () => () => {}, diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 07cdece1c3..f3c8742d1f 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -18,6 +18,7 @@ function registry(version: string) { return { hosts: createPluginHosts( { + addHost: async () => ({ status: "cancelled" }), getHosts: () => [], getSnapshot: () => null, subscribeAll: () => () => {}, diff --git a/packages/app/src/runtime/host-confirmation.ts b/packages/app/src/runtime/host-confirmation.ts index 8fac1031ff..7e0a292bec 100644 --- a/packages/app/src/runtime/host-confirmation.ts +++ b/packages/app/src/runtime/host-confirmation.ts @@ -55,24 +55,38 @@ export class HostConfirmations { } /** Resolves true without asking when the link matches a saved host exactly. */ - confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise { + confirmLink( + offer: ConnectionOffer, + savedHosts: HostProfile[], + signal?: AbortSignal, + ): Promise { const connection = relayConnectionFromOffer(offer); const savedHost = savedHosts.find((host) => host.serverId === offer.serverId); if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true); - return this.ask({ - id: this.nextRequestId++, - serverId: offer.serverId, - keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64), - relayEndpoint: connection.relayEndpoint, - kind: savedHost ? "changedConnection" : "newHost", - }); + if (signal?.aborted) return Promise.resolve(false); + return this.ask( + { + id: this.nextRequestId++, + serverId: offer.serverId, + keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64), + relayEndpoint: connection.relayEndpoint, + kind: savedHost ? "changedConnection" : "newHost", + }, + signal, + ); } - private ask(request: HostConfirmationRequest): Promise { + private ask(request: HostConfirmationRequest, signal?: AbortSignal): Promise { // One question at a time: a newer one cancels the unanswered one. this.pending?.resolve(false); return new Promise((resolve) => { - this.pending = { request, resolve }; + const cancel = () => this.answer(request.id, false); + const finish = (approved: boolean) => { + signal?.removeEventListener("abort", cancel); + resolve(approved); + }; + this.pending = { request, resolve: finish }; + signal?.addEventListener("abort", cancel, { once: true }); this.emit(); }); } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 33e4f1a316..1daccf2c5c 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -16,7 +16,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import type { HostConnection, HostProfile } from "@/types/host-connection"; -import { defaultHostAppearance } from "@/hosts/appearance"; +import { defaultHostAppearance, selectHostBadges } from "@/hosts/appearance"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; @@ -33,6 +33,7 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; import { subscriptionFixture } from "./subscription-fixture"; import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential"; import type { HostConfirmationRequest } from "./host-confirmation"; +import { createPluginHosts } from "@/plugins/hosts"; it("requests the managed connection credential through desktop main without a web hint", async () => { const requests: string[] = []; @@ -4778,3 +4779,254 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => { expect(store.getHosts()).toHaveLength(0); }); }); + +describe("adding hosts from plugin connection targets", () => { + it("does not save an unauthenticated target and retries with the password", async () => { + const store = createPairingStore({ password: "secret", serverIdForHost: () => "srv_direct" }); + const requests = answerHostConfirmations(store, true); + expect(await store.addHost({ connection: "example.com:6767" })).toEqual({ + status: "password_required", + reason: "password_required", + }); + expect(store.getHosts()).toHaveLength(0); + expect( + await store.addHost({ connection: "tcp://example.com:6767?password=secret", label: "Build" }), + ).toEqual({ status: "saved", serverId: "srv_direct" }); + expect(store.getHosts()[0]).toMatchObject({ + label: "Build", + password: "secret", + connections: [{ type: "directTcp", endpoint: "example.com:6767" }], + }); + expect(requests).toHaveLength(0); + await waitForHostOnline(store, "srv_direct"); + store.syncHosts([]); + }); + it("asks before saving a relay target and respects rejection", async () => { + const uri = "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true"; + const rejected = createPairingStore(); + const requests = answerHostConfirmations(rejected, false); + expect(await rejected.addHost({ connection: uri })).toEqual({ status: "cancelled" }); + expect(requests).toHaveLength(1); + expect(rejected.getHosts()).toHaveLength(0); + const accepted = createPairingStore(); + const acceptedRequests = answerHostConfirmations(accepted, true); + expect(await accepted.addHost({ connection: uri, label: "Relay" })).toEqual({ + status: "saved", + serverId: "srv_offer", + }); + expect(acceptedRequests).toHaveLength(1); + expect(accepted.getHosts()[0]).toMatchObject({ + label: "Relay", + connections: [{ type: "relay" }], + }); + accepted.syncHosts([]); + }); + it.each([ + ["ssh://deploy@build:2222?daemonPort=7777", "remoteSsh"], + ["unix:///tmp/paseo.sock", "directSocket"], + ["pipe://mypipe", "directPipe"], + ["wss://example.com/ws", "directTcp"], + ])("probes and saves %s", async (connection, type) => { + const store = createPairingStore({ serverIdForHost: () => "srv_target" }); + expect(await store.addHost({ connection })).toEqual({ + status: "saved", + serverId: "srv_target", + }); + expect(store.getHosts()[0].connections[0].type).toBe(type); + store.syncHosts([]); + }); + it("shares host changes between plugin handles, host settings, and sidebar badges", async () => { + const store = createPairingStore({ + serverIdForHost: (host) => { + const connection = host.connections[0]; + return connection?.type === "directTcp" ? connection.endpoint : ""; + }, + }); + await store.addHost({ connection: "first:6767", label: "First" }); + await store.addHost({ connection: "second:6767", label: "Second" }); + await waitForHostOnline(store, "first:6767"); + await waitForHostOnline(store, "second:6767"); + const lifetime = new AbortController(); + const plugin = createPluginHosts(store, lifetime.signal, { + openSettings() {}, + isManagedLocalHost: async () => false, + }); + expect( + plugin.getSnapshot().map((host) => [host.label, host.status, host.connections[0].uri]), + ).toEqual([ + ["First", "online", "tcp://first:6767"], + ["Second", "online", "tcp://second:6767"], + ]); + await plugin.getSnapshot()[0].update({ label: "Build", appearance: { color: "sky" } }); + expect(store.getHosts()[0]).toMatchObject({ label: "Build", appearance: { color: "sky" } }); + expect( + selectHostBadges({ hosts: store.getHosts(), localServerId: null, enabled: true }).get( + "first:6767", + ), + ).toMatchObject({ label: "Build", color: "sky" }); + lifetime.abort(); + store.syncHosts([]); + }); + + it.each([ + "tcp://example.com:6767?ssl=true", + "ssh://deploy@build:2222?daemonPort=7777", + "unix:///tmp/paseo.sock", + "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true", + ])("recreates the projected connection on another device: %s", async (connection) => { + const first = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" }); + answerHostConfirmations(first, true); + const result = await first.addHost({ connection }); + if (result.status !== "saved") throw new Error("expected saved host"); + const lifetime = new AbortController(); + const plugin = createPluginHosts(first, lifetime.signal, { + openSettings() {}, + isManagedLocalHost: async () => false, + }); + const uri = plugin.getSnapshot().find((host) => host.serverId === result.serverId) + ?.connections[0].uri; + if (!uri) throw new Error("expected connection URI"); + const second = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" }); + answerHostConfirmations(second, true); + expect(await second.addHost({ connection: uri })).toEqual({ + status: "saved", + serverId: result.serverId, + }); + expect(second.getHosts()[0].connections).toEqual(first.getHosts()[0].connections); + lifetime.abort(); + first.syncHosts([]); + second.syncHosts([]); + }); + + it("does not save an in-flight addition after its plugin stops", async () => { + const started = createDeferred(); + const probe = createDeferred<{ + client: DaemonClient; + serverId: string; + hostname: string | null; + }>(); + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async () => { + started.resolve(undefined); + return probe.promise; + }, + getClientId: async () => "test", + readInitialConnectionHint: () => null, + }, + }); + const lifetime = new AbortController(); + const plugin = createPluginHosts(store, lifetime.signal, { + openSettings() {}, + isManagedLocalHost: async () => false, + }); + const adding = plugin.addHost({ connection: "example.com:6767" }); + await started.promise; + lifetime.abort(); + probe.resolve({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: "srv", + hostname: null, + }); + await expect(adding).rejects.toThrow("Plugin has stopped"); + expect(store.getHosts()).toHaveLength(0); + store.syncHosts([]); + }); + + it("reports a stopped plugin when an aborted probe rejects authentication", async () => { + const started = createDeferred(); + const probe = createDeferred<{ + client: DaemonClient; + serverId: string; + hostname: string | null; + }>(); + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async () => { + started.resolve(undefined); + return probe.promise; + }, + getClientId: async () => "test", + readInitialConnectionHint: () => null, + }, + }); + const lifetime = new AbortController(); + const plugin = createPluginHosts(store, lifetime.signal, { + openSettings() {}, + isManagedLocalHost: async () => false, + }); + const adding = plugin.addHost({ connection: "example.com:6767" }); + await started.promise; + lifetime.abort(); + probe.reject(new DaemonAuthenticationError("password_required")); + await expect(adding).rejects.toThrow("Plugin has stopped"); + expect(store.getHosts()).toHaveLength(0); + store.syncHosts([]); + }); + + it("dismisses an unanswered confirmation when the plugin stops", async () => { + const store = createPairingStore(); + const lifetime = new AbortController(); + const plugin = createPluginHosts(store, lifetime.signal, { + openSettings() {}, + isManagedLocalHost: async () => false, + }); + const pending = createDeferred(); + const unsubscribe = store.subscribeHostConfirmation(() => { + if (store.getPendingHostConfirmation()) pending.resolve(undefined); + }); + const adding = plugin.addHost({ + connection: "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true", + }); + await pending.promise; + lifetime.abort(); + await expect(adding).rejects.toThrow("Plugin has stopped"); + expect(store.getPendingHostConfirmation()).toBeNull(); + expect(store.getHosts()).toHaveLength(0); + unsubscribe(); + store.syncHosts([]); + }); + + it("returns incorrect_password without saving and redacts failed probe errors", async () => { + let failure: "auth" | "network" = "auth"; + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async () => { + if (failure === "auth") throw new DaemonAuthenticationError("incorrect_password"); + throw new Error("connection failed for secret"); + }, + getClientId: async () => "test", + readInitialConnectionHint: () => null, + }, + }); + expect(await store.addHost({ connection: "tcp://example.com:6767?password=secret" })).toEqual({ + status: "password_required", + reason: "incorrect_password", + }); + expect(store.getHosts()).toHaveLength(0); + failure = "network"; + await expect( + store.addHost({ connection: "tcp://example.com:6767?password=secret" }), + ).rejects.not.toThrow("secret"); + expect(store.getHosts()).toHaveLength(0); + store.syncHosts([]); + }); + + it("rejects websocket paths and query options the app cannot represent", async () => { + const store = createPairingStore(); + await expect(store.addHost({ connection: "wss://example.com/custom" })).rejects.toThrow( + "unsupported", + ); + await expect( + store.addHost({ connection: "wss://example.com/ws?token=secret" }), + ).rejects.toThrow("unsupported"); + expect(store.getHosts()).toHaveLength(0); + store.syncHosts([]); + }); +}); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 9fe692d10d..d7992e885b 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -3,6 +3,7 @@ import { watchdog } from "@/watchdog"; import { createHostChannelTransport } from "@getpaseo/client/internal/hosts"; import type { OwnedSubscription } from "@getpaseo/client"; import type { HostDirectory } from "@getpaseo/protocol/hosts"; +import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target"; import { useSyncExternalStore, useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; @@ -1612,6 +1613,48 @@ interface AgentDirectoryRefreshInput { page?: FetchAgentsOptions["page"]; } +function directHostTarget(target: HostTarget, input: string): HostConnection { + let connection: HostConnection; + if (target.kind === "ssh") { + connection = createRemoteSshHostConnection(target.ssh); + } else { + if (/^wss?:\/\//.test(input.trim())) { + const url = new URL(target.url); + if (url.pathname !== "/ws" || url.search) + throw new Error("WebSocket path or query is unsupported by the app"); + } + switch (target.connection.type) { + case "directTcp": { + const endpoint = normalizeHostPort(target.connection.endpoint); + connection = { + id: `direct:${endpoint}`, + type: "directTcp", + endpoint, + useTls: target.connection.useTls, + }; + break; + } + case "directSocket": + connection = { + id: `socket:${target.connection.path}`, + type: "directSocket", + path: target.connection.path, + }; + break; + case "directPipe": + connection = { + id: `pipe:${target.connection.path}`, + type: "directPipe", + path: target.connection.path, + }; + break; + case "relay": + throw new Error("Unexpected relay target"); + } + } + return connection; +} + export class HostRuntimeStore { private controllers = new Map(); private hostObservations = new Map< @@ -1964,6 +2007,66 @@ export class HostRuntimeStore { ); } + /** Add a saved host using the shared connection grammar. */ + async addHost( + input: { + connection: string; + label?: string; + }, + signal?: AbortSignal, + ): Promise< + | { status: "saved"; serverId: string } + | { status: "password_required"; reason: "password_required" | "incorrect_password" } + | { status: "cancelled" } + > { + const target = parseHostTarget(input.connection); + await this.loadRegistry(); + try { + if (signal?.aborted) throw new Error("Plugin has stopped"); + if (target.kind === "socket" && target.connection.type === "relay") { + const approved = await this.confirmLink(target.connection.offer, signal); + if (signal?.aborted) throw new Error("Plugin has stopped"); + if (!approved) return { status: "cancelled" }; + const result = await this.probeAndUpsertOffer( + target.connection.offer, + target.password, + input.label, + signal, + ); + return { status: "saved", serverId: result.serverId }; + } + const connection = directHostTarget(target, input.connection); + const result = await this.probeAndUpsertConnection({ + connection, + password: target.password, + label: input.label, + signal, + }); + return { status: "saved", serverId: result.serverId }; + } catch (error) { + if (signal?.aborted) { + // The failed probe can include a credential; do not attach it as a cause. + // oxlint-disable-next-line preserve-caught-error + throw new Error("Plugin has stopped"); + } + const reason = getConnectionAuthFailureReason(error); + if (reason) return { status: "password_required", reason }; + let message = error instanceof Error ? error.message : String(error); + if (target.password) { + for (const value of [ + target.password, + encodeURIComponent(target.password), + new URLSearchParams({ password: target.password }).toString().slice(9), + ]) { + if (value) message = message.split(value).join("[redacted]"); + } + } + // The original error can contain a credential; do not expose it as a cause. + // oxlint-disable-next-line preserve-caught-error + throw new Error(message); + } + } + async upsertDirectConnection(input: { serverId: string; endpoint: string; @@ -1993,6 +2096,7 @@ export class HostRuntimeStore { password?: string; label?: string; timeoutMs?: number; + signal?: AbortSignal; }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { if (input.connection.type === "relay") { throw new Error("Cannot probe a relay connection without a server id."); @@ -2013,6 +2117,10 @@ export class HostRuntimeStore { connection: input.connection, ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), }); + if (input.signal?.aborted) { + await client.close().catch(() => undefined); + throw new Error("Plugin has stopped"); + } const profile = await this.upsertHostConnection({ serverId, label: input.label ?? hostname ?? undefined, @@ -2062,9 +2170,9 @@ export class HostRuntimeStore { // A pairing link (a URL, a QR code, or a pasted link) asks the user to // confirm when it adds a new host or changes a saved host's key, relay, or // TLS setting. The answer needs the saved hosts, so it waits for them to load. - private async confirmLink(offer: ConnectionOffer): Promise { + private async confirmLink(offer: ConnectionOffer, signal?: AbortSignal): Promise { await this.loadRegistry(); - return this.hostConfirmations.confirmLink(offer, this.hosts); + return this.hostConfirmations.confirmLink(offer, this.hosts, signal); } getPendingHostConfirmation(): HostConfirmationRequest | null { @@ -2144,6 +2252,8 @@ export class HostRuntimeStore { private async probeAndUpsertOffer( offer: ConnectionOffer, password: string | undefined, + label?: string, + signal?: AbortSignal, ): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { const connection = relayConnectionFromOffer(offer); const probeHost: HostProfile = { @@ -2159,7 +2269,12 @@ export class HostRuntimeStore { }; const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection }); await client.close().catch(() => undefined); - const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password); + if (signal?.aborted) throw new Error("Plugin has stopped"); + const profile = await this.upsertConnectionFromOffer( + offer, + label ?? hostname ?? undefined, + password, + ); return { profile, serverId: offer.serverId, hostname }; } diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index b0834956d6..4dc42b2781 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -92,6 +92,18 @@ function typeErrors(code: string): string[] { .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); } describe("plugin SDK import boundaries", () => { + it("exposes host handles and object-shaped additions from the client entry", () => { + expect( + typeErrors(` + import { addHost, observeHosts, useHosts, type PluginHost } from "@getpaseo/plugin/client"; + const hosts: readonly PluginHost[] = useHosts(); + const stop = observeHosts((value) => { void value[0]?.openSettings(); }); + void addHost({ connection: "localhost:6767", label: "Local" }); + void hosts; stop(); + `), + ).toEqual([]); + }); + it("publishes process capabilities without their Windows implementation helpers", () => { expect(Object.keys(server).sort()).toEqual([ "defineAgentTool", diff --git a/packages/plugin/src/client/hosts.ts b/packages/plugin/src/client/hosts.ts index 265cffba18..74b35c5130 100644 --- a/packages/plugin/src/client/hosts.ts +++ b/packages/plugin/src/client/hosts.ts @@ -16,8 +16,10 @@ export type PluginHostColor = export type PluginHostBadgeDisplay = "name" | "icon" | "hidden"; export interface PluginHostAppearance { readonly color: PluginHostColor; + /** Null uses the automatic local or remote default. */ readonly badgeDisplay: PluginHostBadgeDisplay | null; } +/** Saved connection settings without credentials. Routed connections have no URI. */ export type PluginHostConnection = | { readonly id: string; @@ -75,11 +77,14 @@ export interface PluginHost extends PluginHostSummary { readonly createdAt: string; readonly updatedAt: string; update(patch: PluginHostPatch): Promise; + /** Removes the host; throws for the desktop daemon host. */ remove(): Promise; + /** Removing the last connection removes the host. */ removeConnection(connectionId: string): Promise; openSettings(): void; } export interface PluginHostAddInput { + /** Shared host connection grammar; may contain a password for the probe. */ connection: string; label?: string; } @@ -89,3 +94,5 @@ export type PluginHostAddResult = | { status: "cancelled" }; export declare function useHosts(): readonly PluginHost[]; export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void; + +export declare function addHost(input: PluginHostAddInput): Promise; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index 7ee414308a..15a6540021 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -88,6 +88,7 @@ export interface PluginHostSummary { /** Live configured hosts. Supplied by the app's client bundle loader. */ export declare const useHosts: typeof import("./hosts.js").useHosts; export declare const observeHosts: typeof import("./hosts.js").observeHosts; +export declare const addHost: typeof import("./hosts.js").addHost; export type { PluginHost, PluginHostStatus, diff --git a/packages/protocol/src/host-target.test.ts b/packages/protocol/src/host-target.test.ts index de76050a73..2033a9cf16 100644 --- a/packages/protocol/src/host-target.test.ts +++ b/packages/protocol/src/host-target.test.ts @@ -2,6 +2,11 @@ import { expect, test } from "vitest"; import { parseHostTarget } from "./host-target.js"; import { serializeSshTransportUri } from "./ssh-transport.js"; import { serializeSocketHostTarget } from "./host-target.js"; +import { + parseConnectionUri, + serializeConnectionUri, + serializeRelayConnectionUri, +} from "./daemon-endpoints.js"; test("SSH and local socket targets round trip through their serializers", () => { const ssh = parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777"); @@ -16,6 +21,23 @@ test("SSH and local socket targets round trip through their serializers", () => } }); +test("TCP and relay targets round trip through credential-free serializers", () => { + const tcp = parseConnectionUri("tcp://example.com:6767?ssl=true&password=secret"); + const tcpUri = serializeConnectionUri(tcp); + expect(tcpUri).not.toContain("secret"); + expect(parseHostTarget(tcpUri)).toMatchObject({ + connection: { type: "directTcp", endpoint: "example.com:6767", useTls: true }, + }); + const relay = parseHostTarget( + "relay://relay.example:443/server?key=public&ssl=true&password=secret", + ); + if (relay.kind !== "socket" || relay.connection.type !== "relay") + throw new Error("expected relay"); + const relayUri = serializeRelayConnectionUri({ offer: relay.connection.offer }); + expect(relayUri).not.toContain("secret"); + expect(parseHostTarget(relayUri)).toMatchObject({ connection: relay.connection }); +}); + test.each([ ["example.com:6767", { type: "directTcp", endpoint: "example.com:6767", useTls: false }], [ diff --git a/plugin-examples/hosts/client/hosts.tsx b/plugin-examples/hosts/client/hosts.tsx index 96b0209c0b..0b7dbf9f1b 100644 --- a/plugin-examples/hosts/client/hosts.tsx +++ b/plugin-examples/hosts/client/hosts.tsx @@ -1,4 +1,5 @@ import { + addHost, getPaseoClient, useHosts, usePaseo, @@ -6,12 +7,26 @@ import { type PluginHost, } from "@getpaseo/plugin/client"; import { useCallback, useMemo, useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, TextInput, View } from "react-native"; export function Hosts({ theme }: PluginSurfaceProps) { const hosts = useHosts(); const selected = usePaseo(); const [result, setResult] = useState(""); + const [connection, setConnection] = useState(""); + const add = useCallback(async () => { + try { + const outcome = await addHost({ connection }); + if (outcome.status === "saved") setResult(`Added ${outcome.host.label}`); + else if (outcome.status === "cancelled") setResult("Host addition cancelled"); + else setResult("Add ?password= and retry"); + } catch (error) { + setResult(error instanceof Error ? error.message : String(error)); + } + }, [connection]); + const addPressed = useCallback(() => { + void add(); + }, [add]); const list = useCallback( async (serverId?: string) => { try { @@ -36,6 +51,14 @@ export function Hosts({ theme }: PluginSurfaceProps) { }, [list]); return ( + + + Add host + List selected host agents diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 28dfc7825c..64cef009ad 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2128,17 +2128,32 @@ export function HostAgents({ theme }: Pick): ReactE `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. +| Host field | Meaning | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `serverId` | ID to pass to `getPaseoClient`. | +| `label` | Display name. | +| `status` | `idle`, `connecting`, `online`, `offline`, or `error`. SDK calls require `online`. | +| `appearance` | `color` and `badgeDisplay`; `null` badge display uses the local or remote default. | +| `connections` | Saved connections with an ID, type, credential-free `uri`, and type-specific settings. Routed connections have a null URI. | +| `activeConnectionId` | ID of the active connection, or null. | +| `error` | Current connection error, or null. | +| `authFailureReason` | `password_required`, `incorrect_password`, or null. | +| `lastOnlineAt`, `createdAt`, `updatedAt` | ISO timestamps; `lastOnlineAt` can be null. | -| Summary field | Type or values | Meaning | -| ------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| `serverId` | `string` | ID to pass to `getPaseoClient`. | -| `label` | `string` | Host's display name. | -| `status` | `"idle"`, `"connecting"`, `"online"`, `"offline"`, `"error"` | Current app connection status. SDK calls require `"online"`. | +Call `host.update({ label, appearance })`, `host.removeConnection(id)`, `host.remove()`, +or `host.openSettings()` to manage a host. Updating `appearance.badgeDisplay` to null restores +the automatic default. Removing the last connection removes the host. The desktop daemon host +cannot be removed by a plugin. + +Use `addHost({ connection, label? })` with the [shared connection grammar](../cli.md#connected-hosts). +A connection may include `?password=`. The password is used for the probe and saved locally; plugins +cannot read it back. Relay targets and pairing links ask the user to confirm. + +| Result | Meaning | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `{ status: "saved", host }` | The host or connection was saved. | +| `{ status: "password_required", reason }` | Nothing was saved; supply a password and retry. The reason is `password_required` or `incorrect_password`. | +| `{ status: "cancelled" }` | The user declined the confirmation. | `getPaseoClient(serverId: string): PaseoApi` borrows the host's authenticated app connection. Call it in client entry code or callbacks; it opens no socket and does not require the plugin @@ -2152,7 +2167,7 @@ on the target daemon. Acquire the API when performing an action to use the curre | Connection settings change or the app switches connections, including automatic failover | The old API is released. Call `getPaseoClient(serverId)` again and recreate subscriptions. | | Host is removed | Its API is released; the removed ID is unknown. | | `client.dispose()` | Releases that API and its observations. A later getter call returns a fresh API over the app connection. Disposing the old API again leaves the new API usable. | -| Originating plugin unloads | All its borrowed APIs and observations are released, including those targeting other hosts. Retained handles cannot outlive the installation. | +| Originating plugin unloads | Borrowed APIs and host observations are released. Later host handle calls throw `Plugin has stopped`. | | Surface host selection changes | `usePaseo()` follows the selected host. An explicitly acquired API keeps its original target. | You can also release individual subscriptions through the normal SDK API.