From 74bb3e76e408484f0777f203cba6c86e3d3bbc58 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 19 Oct 2026 08:21:51 +0200 Subject: [PATCH 01/12] feat(hosts): connect to saved hosts through a daemon --- CLAUDE.md | 1 + docs/architecture.md | 3 + docs/connected-hosts.md | 54 ++ docs/permissions.md | 4 + package-lock.json | 1 + .../app/e2e/browser/connected-hosts.spec.ts | 66 ++ .../e2e/support/helpers/connected-hosts.ts | 114 +++ packages/app/src/connected-hosts/index.tsx | 295 ++++++++ packages/app/src/connected-hosts/model.ts | 192 +++++ .../app/src/screens/settings/host-page.tsx | 2 + packages/cli/src/cli.ts | 3 + packages/cli/src/commands/host/index.ts | 116 +++ packages/cli/src/ssh/ssh-tunnel.ts | 92 +-- packages/cli/src/utils/client.ssh.test.ts | 61 +- packages/cli/src/utils/client.ts | 198 +---- packages/client/package.json | 5 + packages/client/src/daemon-client.test.ts | 5 +- packages/client/src/daemon-client.ts | 163 ++++- packages/client/src/hosts/index.ts | 204 ++++++ packages/client/src/index.test.ts | 1 + packages/client/src/index.ts | 6 + packages/client/src/node/buffering.ts | 22 + packages/client/src/node/connector.test.ts | 53 ++ packages/client/src/node/index.ts | 190 +++++ packages/client/src/node/ssh-tunnel.ts | 90 +++ packages/protocol/src/host-target.test.ts | 36 + packages/protocol/src/host-target.ts | 123 ++++ packages/protocol/src/hosts.ts | 112 +++ packages/protocol/src/messages.ts | 89 ++- packages/server/package.json | 2 +- .../authorization/operation-permissions.ts | 15 + .../server/connected-hosts/hosts.e2e.test.ts | 688 ++++++++++++++++++ .../src/server/connected-hosts/index.ts | 531 ++++++++++++++ .../src/server/connected-hosts/session.ts | 234 ++++++ packages/server/src/server/session.ts | 62 +- .../server/src/server/websocket-server.ts | 64 +- public-docs/cli.md | 25 + public-docs/plugins/reference.md | 20 + public-docs/sdk/reference.md | 51 ++ 39 files changed, 3623 insertions(+), 370 deletions(-) create mode 100644 docs/connected-hosts.md create mode 100644 packages/app/e2e/browser/connected-hosts.spec.ts create mode 100644 packages/app/e2e/support/helpers/connected-hosts.ts create mode 100644 packages/app/src/connected-hosts/index.tsx create mode 100644 packages/app/src/connected-hosts/model.ts create mode 100644 packages/cli/src/commands/host/index.ts create mode 100644 packages/client/src/hosts/index.ts create mode 100644 packages/client/src/node/buffering.ts create mode 100644 packages/client/src/node/connector.test.ts create mode 100644 packages/client/src/node/index.ts create mode 100644 packages/client/src/node/ssh-tunnel.ts create mode 100644 packages/protocol/src/host-target.test.ts create mode 100644 packages/protocol/src/host-target.ts create mode 100644 packages/protocol/src/hosts.ts create mode 100644 packages/server/src/server/connected-hosts/hosts.e2e.test.ts create mode 100644 packages/server/src/server/connected-hosts/index.ts create mode 100644 packages/server/src/server/connected-hosts/session.ts diff --git a/CLAUDE.md b/CLAUDE.md index fc431da2c5..ae1e78de2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | | [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules | +| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries | | [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | diff --git a/docs/architecture.md b/docs/architecture.md index 4ed25ba1c4..c98f3825af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a Your code never leaves your machine. Paseo is local-first. +Daemon-owned outbound connections and routed API scope ownership are described in +[Connected hosts](connected-hosts.md). + ## System overview ``` diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md new file mode 100644 index 0000000000..f30e81dca4 --- /dev/null +++ b/docs/connected-hosts.md @@ -0,0 +1,54 @@ +# Connected hosts + +A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list. +Connect A to B in A's settings under **Hosts connected to this daemon**, through `paseo --host A host`, +or through `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable +`registrationId` created before B is reachable. + +## Ownership and authority + +Credentials live in A's `hosts.json`, written atomically with mode `0600`. Discovery returns a safe +target description. Replacement inputs never come back through a read API. Keep this file private +when collecting diagnostics or backing up daemon state. + +The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry, +including a user's disabled state. Changed input requires an explicit update. Display labels and +access can be changed by users with `tunnel.manage`; connection and metadata changes require the +producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again. +Disabling retains the opt-out. + +Each routed API owns a separate session. A derives its identity from the admitted origin session, +producer, and logical SDK scope. A replaces forwarded identity and grants, and B intersects those +grants with the authenticated link's authority. Reconnect reads current authority. Changing caller +permissions closes existing routes. A routed API cannot select a second hop. + +A producer that registers a host through a routed API is qualified on B as +`route:::`, with each component percent-encoded. This namespace belongs to the authenticated link +principal. An ordinary direct B consumer does not become that producer by supplying a source key. +Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature. + +## Connections and recovery + +The shared Node connector owns the target grammar and each connection attempt, including a fresh +SSH process. SSH reaches an already running daemon. The daemon keeps unreachable registrations and +retries them. Identity is pinned when discovered. Self and duplicate registrations remain inspectable; +a changed destination requires explicit identity replacement. + +Both daemons must advertise `connectedHosts`. A checks B's capability and identity before forwarding +session operations. There is no local fallback. An older daemon continues to serve existing local +operations; requesting this feature gives update guidance. + +Disposing a routed API releases its observations and session. It does not remove the saved host or +affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore +through the usual owned-subscription contract; requests made while offline fail. Losing a response +returns `outcome_unknown` with the destination ID. Inspect the destination before retrying a mutation. +No mutation is automatically replayed. + +Host links cap saved registrations, simultaneous routes, frames, per-socket output, and shared queued +bytes. Slow consumers lose their route with a resource-limit error instead of retaining unbounded +output. Routed sessions release server resources on disconnect; subscription demand is rebuilt by +its owning client on reconnect. + +See the [SDK reference](../public-docs/sdk/reference.md#clienthosts), +[CLI reference](../public-docs/cli.md#connected-hosts), and +[plugin reference](../public-docs/plugins/reference.md#connected-hosts). diff --git a/docs/permissions.md b/docs/permissions.md index 93a70d6fb4..e2bf7c971f 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce. +Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement +requires the producer or `access.manage`. Routed operations retain both caller and link authority, +see [Connected hosts](connected-hosts.md#ownership-and-authority). + Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal. Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence. diff --git a/package-lock.json b/package-lock.json index 98566a2ec0..0d5e0297c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38178,6 +38178,7 @@ "dependencies": { "@getpaseo/protocol": "0.12.0", "@getpaseo/relay": "0.12.0", + "ws": "^8.20.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/app/e2e/browser/connected-hosts.spec.ts b/packages/app/e2e/browser/connected-hosts.spec.ts new file mode 100644 index 0000000000..98a25e150a --- /dev/null +++ b/packages/app/e2e/browser/connected-hosts.spec.ts @@ -0,0 +1,66 @@ +import { test } from "../support/fixtures"; +import { + openRestartableConnectedHosts, + beginConnectedHostDraft, + reconnectManagingHost, + expectConnectedHostDraftRetained, + saveConnectedHost, + openConnectedHosts, + openCompactConnectedHosts, + addConnectedHost, + expectConnectionError, + correctConnection, + inspectConnectedHost, + expectSavedHostDetails, + disableConnectedHost, + enableConnectedHost, + editConnectedHost, + removeConnectedHost, +} from "../support/helpers/connected-hosts"; + +test("invalid input stays editable; unreachable hosts remain manageable", async ({ + page, +}, testInfo) => { + await openConnectedHosts(page); + await addConnectedHost(page, "ssh://-invalid", "Build box"); + await expectConnectionError(page); + await correctConnection(page, "tcp://127.0.0.1:1?password=private-secret"); + await inspectConnectedHost(page, "Build box"); + await expectSavedHostDetails(page); + await page.screenshot({ path: testInfo.outputPath("connected-host-details.png") }); + await editConnectedHost(page); + await disableConnectedHost(page); + await enableConnectedHost(page); + await removeConnectedHost(page, "Renamed box"); +}); + +test.describe("compact connected hosts", () => { + test.use({ viewport: { width: 390, height: 844 } }); + test("saved hosts can be inspected and disabled on a phone layout", async ({ + page, + }, testInfo) => { + await openCompactConnectedHosts(page); + await addConnectedHost(page, "tcp://127.0.0.1:1", "Compact host"); + await inspectConnectedHost(page, "Compact host"); + await expectSavedHostDetails(page); + await page.screenshot({ + path: testInfo.outputPath("connected-host-compact.png"), + fullPage: true, + }); + await disableConnectedHost(page); + await removeConnectedHost(page, "Compact host"); + }); +}); + +test("connection loss retains the unsaved name and credential input", async ({ page }) => { + const host = await openRestartableConnectedHosts(page); + try { + await beginConnectedHostDraft(page); + await reconnectManagingHost(host); + await expectConnectedHostDraftRetained(page); + await saveConnectedHost(page); + await inspectConnectedHost(page, "Retained draft"); + } finally { + await host.close(); + } +}); diff --git a/packages/app/e2e/support/helpers/connected-hosts.ts b/packages/app/e2e/support/helpers/connected-hosts.ts new file mode 100644 index 0000000000..d57a5a0498 --- /dev/null +++ b/packages/app/e2e/support/helpers/connected-hosts.ts @@ -0,0 +1,114 @@ +import { expect, type Page } from "@playwright/test"; +import { gotoAppShell, openSettings } from "./app"; +import { openCompactSettings, openHostSection } from "./settings"; +import { getServerId } from "./server-id"; + +export async function openConnectedHosts(page: Page) { + await gotoAppShell(page); + await openSettings(page); + await openHostSection(page, getServerId(), "host"); + await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible(); +} +export async function addConnectedHost(page: Page, connection: string, label: string) { + await page.getByRole("button", { name: "Add host", exact: true }).click(); + await page.getByRole("textbox", { name: "Connection string", exact: true }).fill(connection); + await page.getByRole("textbox", { name: "Host name", exact: true }).fill(label); + await page.getByRole("textbox", { name: "Metadata", exact: true }).fill('{"team":"infra"}'); + await saveConnectedHost(page); +} +export async function saveConnectedHost(page: Page) { + await page.getByRole("button", { name: "Save host", exact: true }).click(); +} +export async function expectConnectionError(page: Page) { + await expect(page.getByText("Invalid host connection.", { exact: false })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Connection string", exact: true })).toBeVisible(); +} +export async function correctConnection(page: Page, value: string) { + await page.getByRole("textbox", { name: "Connection string", exact: true }).fill(value); + await saveConnectedHost(page); +} +export async function inspectConnectedHost(page: Page, label: string) { + await page.getByRole("button", { name: `Details for ${label}`, exact: true }).click(); + await expect(page.getByText("Connected host details", { exact: true })).toBeVisible(); +} +export async function expectSavedHostDetails(page: Page) { + await expect(page.getByText('{"team":"infra"}', { exact: true })).toBeVisible(); + await expect(page.getByText("Never", { exact: true })).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Replacement connection", exact: true }), + ).toHaveValue(""); +} +export async function disableConnectedHost(page: Page) { + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.getByRole("button", { name: "Disable", exact: true }).click(); + await expect(page.getByText(/disabled · Disabled/)).toBeVisible(); +} +export async function enableConnectedHost(page: Page) { + await page.getByRole("button", { name: "Enable", exact: true }).click(); + await expect(page.getByRole("button", { name: "Disable", exact: true })).toBeVisible(); +} +export async function removeConnectedHost(page: Page, label: string) { + await inspectConnectedHost(page, label); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Remove host", exact: true }).click(); + await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible(); +} + +export async function openCompactConnectedHosts(page: Page) { + await gotoAppShell(page); + await openCompactSettings(page, new URL(page.url()).pathname); + await openHostSection(page, getServerId(), "host"); + await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible(); +} + +import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./isolated-host-daemon"; +import { addDirectHostFromSettings, selectSettingsHost } from "./settings"; + +export async function openRestartableConnectedHosts(page: Page): Promise { + const host = await startIsolatedHostDaemon("srv_connected_host_draft"); + await gotoAppShell(page); + await openSettings(page); + await addDirectHostFromSettings(page, { host: "127.0.0.1", port: host.port }); + await selectSettingsHost(page, host.serverId); + await openHostSection(page, host.serverId, "host"); + await expect(page.getByText("No hosts connected to this daemon", { exact: true })).toBeVisible(); + return host; +} +export async function beginConnectedHostDraft(page: Page) { + await page.getByRole("button", { name: "Add host", exact: true }).click(); + await page.getByRole("textbox", { name: "Host name", exact: true }).fill("Retained draft"); + await page + .getByRole("textbox", { name: "Connection string", exact: true }) + .fill("tcp://127.0.0.1:1?password=draft-secret"); +} +export async function reconnectManagingHost(host: IsolatedHostDaemon) { + await host.restart(); +} +export async function expectConnectedHostDraftRetained(page: Page) { + await expect(page.getByRole("textbox", { name: "Host name", exact: true })).toHaveValue( + "Retained draft", + { timeout: 30000 }, + ); + await expect(page.getByRole("textbox", { name: "Connection string", exact: true })).toHaveValue( + "tcp://127.0.0.1:1?password=draft-secret", + ); +} + +export async function editConnectedHost(page: Page) { + await page.getByRole("switch", { name: "Replace destination identity", exact: true }).click(); + await saveConnectedHost(page); + await expect( + page.getByText("Identity replacement requires a replacement connection", { exact: true }), + ).toBeVisible(); + await page + .getByRole("textbox", { name: "Replacement connection", exact: true }) + .fill("tcp://127.0.0.1:1?password=replacement-secret"); + await page.getByRole("textbox", { name: "Host name", exact: true }).fill("Renamed box"); + await page.getByRole("textbox", { name: "Metadata", exact: true }).fill('{"team":"tools"}'); + await saveConnectedHost(page); + await inspectConnectedHost(page, "Renamed box"); + await expect(page.getByText('{"team":"tools"}', { exact: true })).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Replacement connection", exact: true }), + ).toHaveValue(""); +} diff --git a/packages/app/src/connected-hosts/index.tsx b/packages/app/src/connected-hosts/index.tsx new file mode 100644 index 0000000000..2dbdff5e8d --- /dev/null +++ b/packages/app/src/connected-hosts/index.tsx @@ -0,0 +1,295 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { createPaseoApi, type HostSummary, type HostDirectory } from "@getpaseo/client"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + SettingsCard, + SettingsRow, + SettingsInput, + SettingsSwitch, + SettingsSection, +} from "@/components/settings"; +import { Button } from "@/components/ui/button"; +import { Field, FormTextInput } from "@/components/ui/form-field"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { settingsStyles } from "@/styles/settings"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { ConnectedHostsModel, type Editor } from "./model"; + +export function ConnectedHostsSection({ serverId }: { serverId: string }) { + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + return ( + + {client ? ( + + ) : ( + + + + )} + + ); +} +function useModel(client: DaemonClient, connected: boolean) { + const [model] = useState(() => new ConnectedHostsModel(createPaseoApi(client))); + useEffect(() => { + model.setConnected(connected); + }, [model, connected]); + useEffect(() => model.close, [model]); + return model; +} +function ConnectedHosts({ client, connected }: { client: DaemonClient; connected: boolean }) { + const model = useModel(client, connected); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + const info = client.getLastServerInfoMessage(); + const canManage = info?.permissions?.includes("tunnel.manage") === true; + const directory = state.load.status === "loaded" ? state.load.directory : null; + const editor = state.editor; + return ( + + {state.load.status === "loading" ? ( + + + + ) : null} + {state.load.status === "error" ? ( + + + + + + ) : null} + {directory ? ( + + ) : null} + {directory && canManage && !editor ? ( + + ) : null} + {editor ? ( + + ) : null} + {state.error ? ( + + + + + + ) : null} + {state.pending ? ( + + Saving host change… + + ) : null} + + ); +} +function HostList({ + directory, + model, + canManage, + pending, +}: { + directory: HostDirectory; + model: ConnectedHostsModel; + canManage: boolean; + pending: boolean; +}) { + return ( + + {directory.hosts.length === 0 ? ( + + ) : ( + directory.hosts.map((host) => ( + + )) + )} + + ); +} +function HostRow({ + host, + model, + canManage, + pending, +}: { + host: HostSummary; + model: ConnectedHostsModel; + canManage: boolean; + pending: boolean; +}) { + const edit = useCallback(() => model.edit(host), [host, model]); + const toggle = useCallback(() => void model.setEnabled(host), [host, model]); + return ( + + + + {canManage ? ( + + ) : null} + + + ); +} +function HostDetails({ host }: { host: HostSummary }) { + return ( + + + + + + + + + + {host.error ? : null} + + ); +} +function HostEditor({ + editor, + directory, + model, + canManage, + manageSources, + sourceId, + pending, +}: { + editor: Editor; + directory: HostDirectory | null; + model: ConnectedHostsModel; + canManage: boolean; + manageSources: boolean; + sourceId: string | undefined; + pending: boolean; +}) { + const compact = useIsCompactFormFactor(); + const detail = + directory?.hosts.find((host) => host.registrationId === editor.host?.registrationId) ?? + editor.host; + const editSource = !detail || manageSources || detail.source.id === sourceId; + const remove = useCallback(async () => { + if (!detail) return; + const confirmed = await confirmDialog({ + title: "Remove connected host?", + message: + detail.source.id === "owner" + ? "This removes the saved connection. The host's agents and workspaces keep running." + : `Managed by ${detail.source.id}. Its source can add it again. Disable the host to keep access off.`, + confirmLabel: "Remove", + destructive: true, + }); + if (confirmed) await model.remove(detail); + }, [detail, model]); + return ( + + {detail ? "Connected host details" : "Add host"} + {detail ? : null} + {canManage ? ( + + + {editSource ? ( + + ) : null} + {detail && editSource ? ( + + ) : null} + + ) : null} + {canManage && editSource ? ( + + + + ) : null} + + {canManage ? ( + + ) : null} + + {canManage && detail && editSource ? ( + + ) : null} + + + ); +} +const styles = StyleSheet.create((theme) => ({ + section: { gap: theme.spacing[3] }, + actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] }, +})); diff --git a/packages/app/src/connected-hosts/model.ts b/packages/app/src/connected-hosts/model.ts new file mode 100644 index 0000000000..24fd41211f --- /dev/null +++ b/packages/app/src/connected-hosts/model.ts @@ -0,0 +1,192 @@ +import type { + PaseoApi, + HostSummary, + HostUpdate, + HostDirectory, + OwnedSubscription, +} from "@getpaseo/client"; + +export interface Editor { + key: number; + host: HostSummary | null; + connection: string; + label: string; + metadata: string; + replaceIdentity: boolean; +} +type Load = + | { status: "loading" } + | { status: "error"; error: string } + | { status: "loaded"; directory: HostDirectory }; +interface State { + load: Load; + editor: Editor | null; + pending: string | null; + error: string | null; +} + +/** Owns fetch/observation, form sequencing and mutation state outside React. */ +export class ConnectedHostsModel { + private state: State = { load: { status: "loading" }, editor: null, pending: null, error: null }; + private readonly listeners = new Set<() => void>(); + private observation?: OwnedSubscription; + private generation = 0; + private editorKey = 0; + private connected = false; + setConnected = (connected: boolean) => { + this.connected = connected; + if (connected && !this.observation) this.start(); + else if (!connected) + this.publish({ + load: { + status: "error", + error: + "Connection to this daemon was lost. Reconnect to continue; your edits are retained.", + }, + }); + }; + constructor(private readonly api: PaseoApi) {} + getState = () => this.state; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + private publish(patch: Partial) { + this.state = { ...this.state, ...patch }; + for (const listener of this.listeners) listener(); + } + start = () => { + if (!this.connected) { + this.publish({ + load: { status: "error", error: "Connect to this daemon to load its hosts." }, + }); + return; + } + const generation = ++this.generation; + void this.observation?.release(); + this.publish({ load: { status: "loading" } }); + try { + const observation = this.api.hosts.observe(); + this.observation = observation; + observation.subscribe({ + snapshot: (directory) => { + if (generation === this.generation) + this.publish({ load: { status: "loaded", directory } }); + }, + update: (message) => { + if (generation === this.generation && message.type === "hosts.changed") + this.publish({ load: { status: "loaded", directory: message.payload.directory } }); + }, + error: (error) => { + if (generation === this.generation) + this.publish({ load: { status: "error", error: describe(error) } }); + }, + }); + void observation.ready.catch((error) => { + if (generation === this.generation) + this.publish({ load: { status: "error", error: describe(error) } }); + }); + } catch (error) { + this.publish({ load: { status: "error", error: describe(error) } }); + } + }; + close = () => { + this.generation++; + void this.observation?.release(); + void this.api.dispose(); + this.listeners.clear(); + }; + edit = (host: HostSummary | null) => { + if (this.state.pending) return; + this.publish({ + error: null, + editor: { + key: ++this.editorKey, + host, + connection: "", + label: host?.label ?? "", + metadata: JSON.stringify(host?.metadata ?? {}, null, 2), + replaceIdentity: false, + }, + }); + }; + add = () => this.edit(null); + setLabel = (label: string) => this.set({ label }); + setConnection = (connection: string) => this.set({ connection }); + setMetadata = (metadata: string) => this.set({ metadata }); + setReplaceIdentity = (replaceIdentity: boolean) => this.set({ replaceIdentity }); + set = (patch: Partial>) => { + if (this.state.editor) this.publish({ editor: { ...this.state.editor, ...patch } }); + }; + cancel = () => { + if (!this.state.pending) this.publish({ editor: null, error: null }); + }; + dismissError = () => this.publish({ error: null }); + private async mutate(id: string, operation: () => Promise): Promise { + if (this.state.pending) return; + this.publish({ pending: id, error: null }); + try { + await operation(); + this.publish({ + load: { status: "loaded", directory: await this.api.hosts.list() }, + editor: null, + }); + } catch (error) { + this.publish({ error: describe(error) }); + } finally { + this.publish({ pending: null }); + } + } + save = () => + this.mutate("save", async () => { + const editor = this.state.editor; + if (!editor) return; + let metadata: Record; + try { + const value: unknown = JSON.parse(editor.metadata); + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.values(value).some((entry) => typeof entry !== "string") + ) + throw new Error("invalid"); + metadata = value as Record; + } catch { + throw new Error("Metadata must be a JSON object with string values."); + } + if (editor.host) { + const patch: HostUpdate = { + label: editor.label.trim() || null, + replaceIdentity: editor.replaceIdentity, + }; + if (JSON.stringify(metadata) !== JSON.stringify(editor.host.metadata)) + patch.metadata = metadata; + if (editor.connection.trim()) { + patch.connection = editor.connection.trim(); + } + await this.api.hosts.update(editor.host.registrationId, patch); + } else + await this.api.hosts.add({ + connection: editor.connection.trim(), + label: editor.label.trim() || undefined, + metadata, + }); + }); + setEnabled = (host: HostSummary) => + this.mutate(host.registrationId, () => + this.api.hosts.update(host.registrationId, { enabled: !host.enabled }), + ); + remove = (host: HostSummary) => + this.mutate(host.registrationId, () => this.api.hosts.remove(host.registrationId)); +} +function describe(error: unknown): string { + if ( + error && + typeof error === "object" && + "userMessage" in error && + typeof error.userMessage === "string" + ) + return error.userMessage; + return error instanceof Error ? error.message : "Host operation failed. Retry when connected."; +} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 6b308547a1..958c90f0d0 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -1,3 +1,4 @@ +import { ConnectedHostsSection } from "@/connected-hosts"; import { ArrowDown, ArrowUp, @@ -374,6 +375,7 @@ export function HostSettingsPage({ + {isLocalDaemon ? : null} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index dceb3f372d..1385ebb2d5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,3 +1,4 @@ +import { createHostCommand } from "./commands/host/index.js"; import { pairCommand } from "./commands/daemon/pair.js"; import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; @@ -58,6 +59,8 @@ export function createCli(): Command { .option("--no-color", "disable colored output"); addDaemonHostOption(program); + program.addCommand(createHostCommand()); + // Primary agent commands (top-level) addJsonAndDaemonHostOptions(addLsOptions(program.command("ls"))).action(withOutput(runLsCommand)); diff --git a/packages/cli/src/commands/host/index.ts b/packages/cli/src/commands/host/index.ts new file mode 100644 index 0000000000..ddd6a0e21e --- /dev/null +++ b/packages/cli/src/commands/host/index.ts @@ -0,0 +1,116 @@ +import { Command } from "commander"; +import type { HostSummary, HostUpdate } from "@getpaseo/client"; +import { + withOutput, + type CommandOptions, + type OutputSchema, + type CommandResult, +} from "../../output/index.js"; +import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js"; +import { connectToDaemon } from "../../utils/client.js"; + +const schema: OutputSchema = { + idField: "registrationId", + columns: [ + { header: "ID", field: "registrationId" }, + { header: "LABEL", field: "label" }, + { header: "SERVER", field: "serverId" }, + { header: "ENABLED", field: "enabled" }, + { header: "STATUS", field: "status" }, + { header: "SOURCE", field: (row) => `${row.source.id}/${row.source.key}` }, + ], +}; +function metadata(values: string[] = []): Record { + return Object.fromEntries( + values.map((entry) => { + const split = entry.indexOf("="); + if (split < 1) throw { code: "invalid_metadata", message: "Metadata must use key=value" }; + return [entry.slice(0, split), entry.slice(split + 1)]; + }), + ); +} +interface Options extends CommandOptions { + label?: string; + meta?: string[]; + removeMeta?: string[]; + connection?: string; + replaceIdentity?: boolean; +} +async function run( + action: string, + id: string | undefined, + options: Options, +): Promise> { + const client = await connectToDaemon({ target: options.daemonTarget }); + try { + if (action === "add") + return { + type: "single", + data: await client.addHost({ + connection: id!, + label: options.label, + metadata: metadata(options.meta), + }), + schema, + }; + if (action === "ls") return { type: "list", data: (await client.listHosts()).hosts, schema }; + const host = (await client.listHosts()).hosts.find((entry) => entry.registrationId === id); + if (!host) throw { code: "host_unknown", message: `Unknown registration ${id}` }; + if (action === "inspect") + return { + type: "single", + data: host, + schema: { ...schema, renderHuman: () => JSON.stringify(host, null, 2) }, + }; + if (action === "rm") { + await client.removeHost(id!); + return { type: "single", data: host, schema }; + } + const patch: HostUpdate = {}; + if (action === "enable" || action === "disable") patch.enabled = action === "enable"; + if (options.label !== undefined) patch.label = options.label || null; + if (options.connection !== undefined) patch.connection = options.connection; + if (options.replaceIdentity) patch.replaceIdentity = true; + if (options.meta?.length || options.removeMeta?.length) { + patch.metadata = { ...host.metadata, ...metadata(options.meta) }; + for (const key of options.removeMeta ?? []) delete patch.metadata[key]; + } + return { type: "single", data: await client.updateHost(id!, patch), schema }; + } finally { + await client.close(); + } +} +export function createHostCommand(): Command { + const host = new Command("host").description("Manage hosts connected to the selected daemon"); + addJsonAndDaemonHostOptions( + host.command("ls").description("List saved hosts, including disabled and failed entries"), + ).action(withOutput((options: Options, _command: Command) => run("ls", undefined, options))); + addJsonAndDaemonHostOptions( + host + .command("add") + .argument("") + .option("--label ") + .option("--meta ", "Set metadata", collectMultiple, []), + ).action( + withOutput((connection: string, options: Options, _command: Command) => + run("add", connection, options), + ), + ); + addJsonAndDaemonHostOptions( + host + .command("update") + .argument("") + .option("--label ") + .option("--connection ") + .option("--replace-identity", "Accept a new destination identity") + .option("--meta ", "Replace supplied metadata keys", collectMultiple, []) + .option("--remove-meta ", "Remove a metadata key", collectMultiple, []), + ).action( + withOutput((id: string, options: Options, _command: Command) => run("update", id, options)), + ); + for (const action of ["inspect", "enable", "disable", "rm"]) + addJsonAndDaemonHostOptions(host.command(action).argument("")).action( + withOutput((id: string, options: Options, _command: Command) => run(action, id, options)), + ); + return host; +} diff --git a/packages/cli/src/ssh/ssh-tunnel.ts b/packages/cli/src/ssh/ssh-tunnel.ts index 33900d0baa..0d25c2e9b8 100644 --- a/packages/cli/src/ssh/ssh-tunnel.ts +++ b/packages/cli/src/ssh/ssh-tunnel.ts @@ -1,90 +1,2 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { createServer, type Server, type Socket } from "node:net"; -import { buildSshTunnelArgs, type SshTransportTarget } from "@getpaseo/protocol/ssh-transport"; - -const SSH_STDERR_LIMIT = 8192; - -export interface SshTunnel { - endpoint: string; - close(): void; - failureDetail(): string | null; -} - -function formatSshFailure( - stderr: string, - code: number | null, - signal: NodeJS.Signals | null, -): string { - const detail = stderr.trim(); - if (detail) return detail; - if (signal) return `ssh exited with signal ${signal}`; - return `ssh exited with code ${code ?? "unknown"}`; -} - -export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null { - return failure ?? (stderr.trim() || null); -} - -export function createSshTunnel(target: SshTransportTarget): Promise { - let server: Server | null = null; - let socket: Socket | null = null; - let child: ChildProcessWithoutNullStreams | null = null; - let stderr = ""; - let failure: string | null = null; - - function close(): void { - server?.close(); - server = null; - socket?.destroy(); - socket = null; - if (child && !child.killed) child.kill(); - child = null; - } - - return new Promise((resolve, reject) => { - server = createServer((acceptedSocket) => { - socket = acceptedSocket; - server?.close(); - server = null; - - child = spawn("ssh", buildSshTunnelArgs(target), { - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }); - child.stderr.on("data", (chunk: Buffer | string) => { - stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); - }); - child.on("error", (error) => { - failure = error.message; - acceptedSocket.destroy(error); - }); - child.on("exit", (code, signal) => { - if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal); - acceptedSocket.destroy(failure ? new Error(failure) : undefined); - }); - acceptedSocket.on("error", () => undefined); - acceptedSocket.on("close", () => { - if (child && !child.killed) child.kill(); - }); - acceptedSocket.pipe(child.stdin); - child.stdout.pipe(acceptedSocket); - }); - server.once("error", (error) => { - close(); - reject(error); - }); - server.listen(0, "127.0.0.1", () => { - const address = server?.address(); - if (!address || typeof address === "string") { - close(); - reject(new Error("Failed to allocate the SSH tunnel port")); - return; - } - resolve({ - endpoint: `127.0.0.1:${address.port}`, - close, - failureDetail: () => resolveSshFailureDetail(failure, stderr), - }); - }); - }); -} +export { createSshTunnel, resolveSshFailureDetail } from "@getpaseo/client/node"; +export type { SshTunnel } from "@getpaseo/client/node"; diff --git a/packages/cli/src/utils/client.ssh.test.ts b/packages/cli/src/utils/client.ssh.test.ts index 19b720a893..6ae86a98e6 100644 --- a/packages/cli/src/utils/client.ssh.test.ts +++ b/packages/cli/src/utils/client.ssh.test.ts @@ -1,42 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { resolveSshFailureDetail } from "../ssh/ssh-tunnel.js"; -import { connectToDaemon } from "./client.js"; - -const mocks = vi.hoisted(() => ({ - configs: [] as Array>, - createSshTunnel: vi.fn(), -})); - -vi.mock("@getpaseo/client/internal/daemon-client", () => ({ - DaemonClient: class { - lastError = null; - - constructor(config: Record) { - mocks.configs.push(config); - } - - async connect() {} - async close() {} - }, -})); - -vi.mock("../ssh/ssh-tunnel.js", async (importOriginal) => ({ - ...(await importOriginal()), - createSshTunnel: mocks.createSshTunnel, -})); -vi.mock("./client-id.js", () => ({ getOrCreateCliClientId: async () => "cli-test-id" })); +import { parseHostTarget } from "@getpaseo/client/node"; describe("CLI SSH transport", () => { - beforeEach(() => { - mocks.configs.length = 0; - mocks.createSshTunnel.mockReset(); - mocks.createSshTunnel.mockResolvedValue({ - endpoint: "127.0.0.1:4567", - close: vi.fn(), - failureDetail: () => null, - }); - }); - it("surfaces SSH stderr before the child exit event settles", () => { expect(resolveSshFailureDetail(null, "Host key verification failed.\n")).toBe( "Host key verification failed.", @@ -45,21 +11,14 @@ describe("CLI SSH transport", () => { "ssh exited with code 255", ); }); - - it("routes an SSH host through a local tunnel", async () => { - await connectToDaemon({ - target: { kind: "endpoint", host: "ssh://deploy@build-box:2222?daemonPort=7777" }, - }); - - expect(mocks.createSshTunnel).toHaveBeenCalledWith({ - host: "deploy@build-box", - sshPort: 2222, - daemonPort: 7777, - }); - expect(mocks.configs[0]).toMatchObject({ - url: "ws://127.0.0.1:4567/ws", - clientId: "cli-test-id", - clientType: "cli", + it("uses the shared SSH target grammar and excludes reusable credentials from discovery", () => { + expect(parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777")).toEqual({ + kind: "ssh", + ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 }, + description: "ssh://deploy@build-box:2222 (daemon port 7777)", }); + expect(() => parseHostTarget("ssh://deploy:secret@build-box")).toThrow( + "Invalid host connection", + ); }); }); diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 92d91ea944..30714e0a31 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,3 +1,4 @@ +import { createHostClient, parseHostTarget } from "@getpaseo/client/node"; import { waitForDaemonReady, resolvePaseoHome, @@ -8,21 +9,12 @@ import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js"; export type { DaemonTarget } from "./daemon-target.js"; import { buildDaemonWebSocketUrl, - buildRelayWebSocketUrl, normalizeHostPort, parseConnectionUri, - shouldUseTlsForDefaultHostedRelay, } from "@getpaseo/protocol/daemon-endpoints"; -import { - parseConnectionOfferFromUrl, - type ConnectionOffer, -} from "@getpaseo/protocol/connection-offer"; -import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport"; -import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; -import { WebSocket } from "ws"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { getOrCreateCliClientId } from "./client-id.js"; import { resolveCliVersion } from "../version.js"; -import { createSshTunnel } from "../ssh/ssh-tunnel.js"; export interface ConnectOptions { target: DaemonTarget; @@ -170,11 +162,8 @@ export function resolveDaemonTarget(host: string): TransportTarget { } export function resolveDaemonPassword(host: string): string | undefined { - const trimmed = host.trim(); - if (trimmed.startsWith("tcp://")) { - const fromUri = parseConnectionUri(trimmed).password; - if (fromUri) return fromUri; - } + const fromUri = parseHostTarget(host).password; + if (fromUri) return fromUri; const fromEnv = process.env.PASEO_PASSWORD; return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; } @@ -189,117 +178,9 @@ export function resolveDaemonCredential( return token ? { kind: "localCredential", token } : null; } -/** - * Create a WebSocket factory that works in Node.js - */ -function createNodeWebSocketFactory() { - return ( - url: string, - options?: { headers?: Record; protocols?: string[]; socketPath?: string }, - ): WebSocketLike => { - return new WebSocket(url, options?.protocols, { - headers: options?.headers, - ...(options?.socketPath ? { socketPath: options.socketPath } : {}), - }) as unknown as WebSocketLike; - }; -} - -/** - * Create and connect a daemon client - * Returns the connected client or throws if connection fails - */ -async function tryConnectHost( - host: string, - credential: ReturnType, - home: string, - clientId: string, - timeout: number, - nodeWebSocketFactory: ReturnType, -): Promise<{ client: DaemonClient } | { error: unknown }> { - const target = resolveDaemonTarget(host); - const client = new DaemonClient({ - url: target.url, - clientId, - clientType: "cli", - appVersion: resolveCliVersion(), - ...(credential?.kind === "password" ? { password: credential.password } : {}), - ...(credential?.kind === "localCredential" - ? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined } - : {}), - connectTimeoutMs: timeout, - webSocketFactory: ( - url: string, - config?: { headers?: Record; protocols?: string[] }, - ) => - nodeWebSocketFactory(url, { - headers: config?.headers, - protocols: config?.protocols, - ...(target.type === "ipc" ? { socketPath: target.socketPath } : {}), - }), - reconnect: { enabled: false }, - }); - - try { - await client.connect(); - return { client }; - } catch (error) { - await client.close().catch(() => {}); - return { error }; - } -} - -async function connectViaRelayOffer( - offer: ConnectionOffer, - clientId: string, - timeout: number, - nodeWebSocketFactory: ReturnType, -): Promise { - const url = buildRelayWebSocketUrl({ - endpoint: offer.relay.endpoint, - serverId: offer.serverId, - role: "client", - useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), - }); - - const client = new DaemonClient({ - url, - clientId, - clientType: "cli", - appVersion: resolveCliVersion(), - connectTimeoutMs: timeout, - webSocketFactory: ( - target: string, - config?: { headers?: Record; protocols?: string[] }, - ) => nodeWebSocketFactory(target, { headers: config?.headers, protocols: config?.protocols }), - e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 }, - reconnect: { enabled: false }, - }); - - try { - await client.connect(); - return client; - } catch (error) { - await client.close().catch(() => {}); - const message = error instanceof Error ? error.message : String(error); - const lastError = client.lastError ? ` (${client.lastError})` : ""; - throw new Error(`Failed to connect via relay offer: ${message}${lastError}`, { cause: error }); - } -} - -function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null { - if (!host) return null; - try { - return parseConnectionOfferFromUrl(host); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid pairing offer URL: ${message}`, { cause: error }); - } -} - async function connectSelectedDaemon(options: ConnectOptions): Promise { const timeout = options.timeout ?? DEFAULT_TIMEOUT; - const deadline = Date.now() + timeout; - const explicitHost = + const host = options.target.kind === "endpoint" ? options.target.host : ( @@ -309,58 +190,25 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise { - try { - await close(); - } finally { - tunnel.close(); - } - }; - return result.client; - } - - const failure = tunnel.failureDetail(); - tunnel.close(); - if (failure) throw new Error(`SSH connection failed: ${failure}`, { cause: result.error }); - throw result.error; + const credential = resolveDaemonCredential(host, home); + const client = createHostClient(host, { + clientId: await getOrCreateCliClientId(home), + clientType: "cli", + appVersion: resolveCliVersion(), + ...(credential?.kind === "password" ? { password: credential.password } : {}), + ...(credential?.kind === "localCredential" + ? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined } + : {}), + connectTimeoutMs: timeout, + reconnect: { enabled: false }, + }); + try { + await client.connect(); + return client; + } catch (error) { + await client.close(); + throw error; } - const offer = parseHostOfferOrNull(explicitHost); - if (offer) { - return connectViaRelayOffer( - offer, - clientId, - Math.max(1, deadline - Date.now()), - nodeWebSocketFactory, - ); - } - - const result = await tryConnectHost( - explicitHost, - resolveDaemonCredential(explicitHost, home), - home, - clientId, - Math.max(1, deadline - Date.now()), - nodeWebSocketFactory, - ); - if ("client" in result) return result.client; - throw result.error; } export async function connectToDaemon(options: ConnectOptions): Promise { diff --git a/packages/client/package.json b/packages/client/package.json index 07ba43b332..57c937656d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -24,6 +24,10 @@ "./internal/daemon-client-websocket-transport": { "types": "./dist/daemon-client-websocket-transport.d.ts", "default": "./dist/daemon-client-websocket-transport.js" + }, + "./node": { + "types": "./dist/node/index.d.ts", + "default": "./dist/node/index.js" } }, "publishConfig": { @@ -41,6 +45,7 @@ "dependencies": { "@getpaseo/protocol": "0.12.0", "@getpaseo/relay": "0.12.0", + "ws": "^8.20.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 7469470ad1..960b19f4d7 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -873,9 +873,12 @@ test("keeps relay upgrade credentials out of the socket request", async () => { }, }); clients.push(client); - void client.connect(); + const connecting = client.connect(); + void connecting.catch(() => {}); await vi.waitFor(() => expect(requests).toHaveLength(1)); expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" }); + await client.close(); + await expect(connecting).rejects.toThrow("Daemon client closed"); }); test("refuses relay password auth without an encrypted hello", async () => { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 647e1fddf6..138b19cec6 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,4 +1,5 @@ import { messageTooLargeError } from "./daemon-client-transport-utils.js"; +import type { HostAdd, HostUpdate, HostDirectory } from "@getpaseo/protocol/hosts"; import { legacyUsageIcon } from "./legacy-usage-icons.js"; import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; @@ -391,6 +392,10 @@ export interface DaemonClientConfig { /** Deliver compact bodies/hash references to a caller-owned snapshot cache. * The default keeps public SDK snapshot entries expanded. */ providerSnapshots?: "wire"; + routedClient?: boolean; + routedServerId?: string; + routedFailure?: () => Error | null; + sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[]; url: string; clientId: string; clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub"; @@ -1008,8 +1013,16 @@ class DaemonRpcError extends Error { readonly requestId: string; readonly requestType?: string; readonly code?: string; + readonly serverId?: string; + readonly userMessage: string; - constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) { + constructor(params: { + requestId: string; + error: string; + requestType?: string; + code?: string; + serverId?: string; + }) { const parts = [params.error]; if (params.requestType) parts.push(`requestType=${params.requestType}`); if (params.code) parts.push(`code=${params.code}`); @@ -1018,6 +1031,8 @@ class DaemonRpcError extends Error { this.requestId = params.requestId; this.requestType = params.requestType; this.code = params.code; + this.serverId = params.serverId; + this.userMessage = params.error; } } @@ -1502,6 +1517,7 @@ export class DaemonClient { this.lastErrorValue = reason; this.scheduleReconnect({ reason, + failure: event instanceof Error ? event : undefined, event: "TRANSPORT_ERROR", reasonCode: "transport_error", }); @@ -1518,6 +1534,7 @@ export class DaemonClient { this.lastErrorValue = reason; this.scheduleReconnect({ reason, + failure: event instanceof Error ? event : undefined, event: "TRANSPORT_ERROR", reasonCode: "transport_error", }); @@ -1568,9 +1585,7 @@ export class DaemonClient { return; } this.shouldReconnect = false; - this.connectPromise = null; - this.connectResolve = null; - this.connectReject = null; + this.rejectConnect(new Error("Daemon client closed")); if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; @@ -1598,6 +1613,7 @@ export class DaemonClient { } ensureConnected(options?: { verify?: boolean }): void { + this.requireRoutedConnection(); if (this.connectionState.status === "disposed") { return; } @@ -1613,7 +1629,9 @@ export class DaemonClient { this.attemptConnect(); return; } - void this.connect(); + void this.connect().catch((error: unknown) => + this.logger.debug({ err: error }, "Connection attempt ended"), + ); } private verifyConnection(): void { @@ -1631,13 +1649,98 @@ export class DaemonClient { event: "CONNECTION_VERIFICATION_FAILED", reasonCode: "liveness_timeout", }); - this.ensureConnected(); + if (!this.config.routedClient) this.ensureConnected(); }) .finally(() => { if (this.connectionVerification === transport) this.connectionVerification = null; }); } + requireConnectedHosts(): void { + if (!this.isConnected) + throw Object.assign(new Error("The managing daemon is offline"), { + code: "host_offline", + serverId: this.config.routedServerId, + }); + if (this.lastServerInfoMessage?.features?.connectedHosts !== true) + throw Object.assign(new Error("Update the managing daemon to use connected hosts"), { + code: "unsupported_capability", + }); + } + async listHosts(): Promise { + this.requireConnectedHosts(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.list.request" }, + responseType: "hosts.list.response", + }) + ).directory; + } + observeHosts(): OwnedSubscription { + this.requireConnectedHosts(); + return this.owned.observe(async (accept) => { + const result = await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.list.request", observe: true }, + responseType: "hosts.list.response", + }); + if (!result.subscriptionId) throw new Error("Host omitted subscription identity"); + const snapshot = { ...result.directory, subscriptionId: result.subscriptionId }; + accept(snapshot); + return snapshot; + }); + } + async addHost(input: HostAdd) { + this.requireConnectedHosts(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.add.request", input }, + responseType: "hosts.add.response", + }) + ).host; + } + async updateHost(registrationId: string, patch: HostUpdate) { + this.requireConnectedHosts(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.update.request", registrationId, patch }, + responseType: "hosts.update.response", + }) + ).host; + } + async removeHost(registrationId: string): Promise { + this.requireConnectedHosts(); + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.remove.request", registrationId }, + responseType: "hosts.remove.response", + }); + } + async openHostRoute(serverId: string, scopeId: string): Promise { + this.requireConnectedHosts(); + try { + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.route.open.request", serverId, scopeId }, + responseType: "hosts.route.open.response", + }) + ).routeId; + } catch (error) { + throw Object.assign(error instanceof Error ? error : new Error("Host route failed"), { + serverId, + }); + } + } + sendHostFrame(routeId: string, data: string, binary: boolean): void { + this.sendSessionMessageStrict({ type: "hosts.route.frame.request", routeId, data, binary }); + } + cancelHostRoute(scopeId: string): void { + if (this.isConnected) + this.sendSessionMessageStrict({ type: "hosts.route.close.request", scopeId }); + } + closeHostRoute(routeId: string): void { + if (this.isConnected) + this.sendSessionMessageStrict({ type: "hosts.route.close.request", routeId }); + } + getConnectionState(): ConnectionState { return this.connectionState; } @@ -1780,7 +1883,7 @@ export class DaemonClient { private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void { if (!this.transport) { - throw new DaemonConnectionError("Transport not connected"); + throw this.unavailableError(); } // The daemon closes the connection on an oversized message, and creations resubmit on reconnect. assertTransportFrameFits(frame); @@ -1867,7 +1970,7 @@ export class DaemonClient { } // If connecting, queue the message to be sent once connected - if (status === "connecting") { + if (status === "connecting" && !this.config.routedClient) { return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { // Remove from queue @@ -1888,7 +1991,7 @@ export class DaemonClient { } // Not connected and not connecting - fail immediately - return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`)); + return Promise.reject(this.unavailableError()); } /** @@ -1942,9 +2045,13 @@ export class DaemonClient { kind: "error", error: new DaemonRpcError({ requestId: msg.payload.requestId, + serverId: this.config.routedServerId, error: msg.payload.error, requestType: msg.payload.requestType, - code: msg.payload.code, + code: + this.config.routedClient && msg.payload.code === "access_denied" + ? "permission_denied" + : msg.payload.code, }), }; } @@ -2053,9 +2160,23 @@ export class DaemonClient { }); } + private requireRoutedConnection(): void { + if (this.config.routedClient && !this.isConnected) throw this.unavailableError(); + } + + private unavailableError(): Error { + if (this.config.routedFailure?.()) return this.config.routedFailure()!; + if (this.config.routedClient) + return Object.assign(new Error(`Host ${this.config.routedServerId} is offline`), { + code: "host_offline", + serverId: this.config.routedServerId, + }); + return new DaemonConnectionError("Transport not connected"); + } + private sendSessionMessageStrict(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { - throw new DaemonConnectionError("Transport not connected"); + throw this.unavailableError(); } const payload = SessionInboundMessageSchema.parse(message); try { @@ -2874,7 +2995,10 @@ export class DaemonClient { // ============================================================================ private readonly creations = new CreationClient({ - supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true, + supports: (feature) => { + this.requireRoutedConnection(); + return this.lastServerInfoMessage?.features?.[feature] === true; + }, requestId: () => this.createRequestId(), request: (kind, input) => kind === "workspace" @@ -6310,6 +6434,9 @@ export class DaemonClient { const auth = this.helloAuth; this.sendJsonMessage("hello", "hello", { type: "hello", + ...(this.config.sessionPermissions + ? { sessionPermissions: this.config.sessionPermissions } + : {}), clientId: this.config.clientId, clientType: this.config.clientType ?? "cli", protocolVersion: 1, @@ -6655,6 +6782,7 @@ export class DaemonClient { } private scheduleReconnect(input?: { + failure?: Error; reason?: string; event?: string; reasonCode?: string; @@ -6675,7 +6803,14 @@ export class DaemonClient { // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. - this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost")); + this.clearWaiters( + this.config.routedClient + ? Object.assign( + new Error("Connection lost before result; inspect the destination before retrying"), + { code: "outcome_unknown", serverId: this.config.routedServerId }, + ) + : new DaemonConnectionError(reason ?? "Connection lost"), + ); this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost")); this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost")); this.terminalStreams.clearSlots(); @@ -6692,7 +6827,7 @@ export class DaemonClient { this.rejectConnect( this.authFailureReasonValue ? new DaemonAuthenticationError(this.authFailureReasonValue) - : new Error(reason ?? "Transport disconnected before connect"), + : (input?.failure ?? new Error(reason ?? "Transport disconnected before connect")), ); return; } diff --git a/packages/client/src/hosts/index.ts b/packages/client/src/hosts/index.ts new file mode 100644 index 0000000000..cb2419e156 --- /dev/null +++ b/packages/client/src/hosts/index.ts @@ -0,0 +1,204 @@ +import { safeRandomId } from "../daemon-client-transport-utils.js"; +import { DaemonClient } from "../daemon-client.js"; +import type { DaemonTransportFactory } from "../daemon-client-transport-types.js"; +import type { OwnedSubscription } from "../connection/index.js"; +import type { PaseoApi, createPaseoApi } from "../index.js"; +import type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts"; + +export interface PaseoHostActions { + list(): Promise; + observe(): OwnedSubscription; + add(input: HostAdd): Promise; + update(registrationId: string, patch: HostUpdate): Promise; + remove(registrationId: string): Promise; + client(serverId: string): Promise; +} + +function encode(data: Uint8Array | ArrayBuffer): string { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + let result = ""; + for (let start = 0; start < bytes.length; start += 8192) + result += String.fromCharCode(...bytes.subarray(start, start + 8192)); + return globalThis.btoa(result); +} +function decode(data: string): Uint8Array { + return Uint8Array.from(globalThis.atob(data), (char) => char.charCodeAt(0)); +} + +function routeTransport( + origin: DaemonClient, + serverId: string, + scopeId: string, + initialRouteId: string, + state: { failure: Error | null }, +): DaemonTransportFactory { + let first: string | null = initialRouteId; + return () => { + const opens = new Set<() => void>(); + const closes = new Set<(event?: unknown) => void>(); + const messages = new Set<(data: unknown, binary: boolean) => void>(); + const errors = new Set<(error: unknown) => void>(); + let routeId: string | null = null; + let closed = false; + const cleanups: (() => void)[] = []; + const close = (error?: unknown) => { + if (closed) return; + closed = true; + if (error instanceof Error) state.failure = Object.assign(error, { serverId }); + for (const cleanup of cleanups) cleanup(); + if (routeId) origin.closeHostRoute(routeId); + if (error) for (const handler of errors) handler(error); + for (const handler of closes) handler(error); + }; + cleanups.push( + origin.subscribeRawMessages((message) => { + if (message.type === "hosts.route.frame" && message.payload.subscriptionId === routeId) { + for (const handler of messages) + handler( + message.payload.binary ? decode(message.payload.data) : message.payload.data, + message.payload.binary, + ); + } + if (message.type === "hosts.route.closed" && message.payload.subscriptionId === routeId) + close( + Object.assign(new Error(message.payload.message), { + code: message.payload.code, + serverId, + }), + ); + }), + ); + cleanups.push( + origin.subscribeConnectionStatus((connectionState) => { + if (connectionState.status !== "connected") close(); + }), + ); + const opening = first ? Promise.resolve(first) : origin.openHostRoute(serverId, scopeId); + first = null; + void opening + .then((id) => { + if (closed) { + origin.closeHostRoute(id); + return; + } + routeId = id; + state.failure = null; + for (const handler of opens) handler(); + return undefined; + }) + .catch(close); + return { + send(data) { + if (closed || !routeId) + throw Object.assign(new Error(`Host ${serverId} is offline`), { + code: "host_offline", + serverId, + }); + origin.sendHostFrame( + routeId, + typeof data === "string" ? data : encode(data), + typeof data !== "string", + ); + }, + close: () => close(), + onOpen(handler) { + opens.add(handler); + return () => opens.delete(handler); + }, + onClose(handler) { + closes.add(handler); + return () => closes.delete(handler); + }, + onError(handler) { + errors.add(handler); + return () => errors.delete(handler); + }, + onMessage(handler) { + messages.add(handler); + return () => messages.delete(handler); + }, + }; + }; +} + +export function createHostActions( + origin: DaemonClient, + owner: { + own }>(create: () => T): T; + signal: AbortSignal; + api: typeof createPaseoApi; + }, +): PaseoHostActions { + const assertActive = () => { + if (owner.signal.aborted) throw new Error("Paseo API is disposed"); + }; + return { + list: () => { + assertActive(); + return origin.listHosts(); + }, + observe: () => owner.own(() => origin.observeHosts()), + add: (input) => { + assertActive(); + return origin.addHost(input); + }, + update: (id, patch) => { + assertActive(); + return origin.updateHost(id, patch); + }, + remove: (id) => { + assertActive(); + return origin.removeHost(id); + }, + async client(serverId) { + assertActive(); + origin.requireConnectedHosts(); + if (origin.getLastServerInfoMessage()?.serverId === serverId) { + const api = owner.api(origin, { signal: owner.signal }); + const scope = owner.own(() => ({ release: () => api.dispose() })); + return { ...api, dispose: () => scope.release() }; + } + const scopeId = safeRandomId(); + const cancel = () => origin.cancelHostRoute(scopeId); + owner.signal.addEventListener("abort", cancel, { once: true }); + let routeId: string; + try { + routeId = await origin.openHostRoute(serverId, scopeId); + } finally { + owner.signal.removeEventListener("abort", cancel); + } + if (owner.signal.aborted) { + origin.closeHostRoute(routeId); + assertActive(); + } + const state: { failure: Error | null } = { failure: null }; + const client = new DaemonClient({ + url: "ws://routed-host/ws", + clientId: scopeId, + transportFactory: routeTransport(origin, serverId, scopeId, routeId, state), + routedClient: true, + routedServerId: serverId, + routedFailure: () => state.failure, + }); + const api = owner.api(client, { signal: owner.signal }); + const scope = owner.own(() => ({ + release: async () => { + try { + await api.dispose(); + } finally { + await client.close(); + } + }, + })); + try { + await client.connect(); + assertActive(); + return { ...api, dispose: () => scope.release() }; + } catch (error) { + const failure = state.failure ?? error; + await scope.release(); + throw failure; + } + }, + }; +} diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index ce6d4a5156..e6b15d06bf 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -267,6 +267,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own "agents", "config", "dispose", + "hosts", "observeEvents", "projects", "providers", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 058ec158ef..c9f9c3fa9d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,6 @@ +import { createHostActions, type PaseoHostActions } from "./hosts/index.js"; +export type { PaseoHostActions } from "./hosts/index.js"; +export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts"; import type { OwnedSubscription } from "./connection/index.js"; export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js"; import type { DaemonClientConfig } from "./daemon-client.js"; @@ -484,6 +487,7 @@ export interface PaseoConfigActions { } export interface PaseoApi { + readonly hosts: PaseoHostActions; dispose(): Promise; observeEvents: DaemonClient["observeEvents"]; readonly terminals: PaseoTerminalActions; @@ -695,6 +699,7 @@ export function createPaseoApi( return { dispose, + hosts: createHostActions(daemonClient, { own, signal: lifetime.signal, api: createPaseoApi }), observeEvents, terminals, projects: { @@ -1034,6 +1039,7 @@ function listProviderUsage( daemonClient: DaemonClient, options?: PaseoProviderUsageOptions, ): Promise { + daemonClient.ensureConnected(); // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-02-28 once daemon floor >= v0.1.98. if (daemonClient.getLastServerInfoMessage()?.features?.providerUsageList !== true) { return Promise.reject(new Error("Update the host to list provider usage.")); diff --git a/packages/client/src/node/buffering.ts b/packages/client/src/node/buffering.ts new file mode 100644 index 0000000000..1a06a973a0 --- /dev/null +++ b/packages/client/src/node/buffering.ts @@ -0,0 +1,22 @@ +/** Counts bytes until the socket's send callback, shared by all links owned by a daemon. */ +export class HostTransportBudget { + private used = 0; + constructor(readonly limit = 64 * 1024 * 1024) {} + get bufferedBytes(): number { + return this.used; + } + reserve(bytes: number): () => void { + if (bytes > this.limit - this.used) + throw Object.assign(new Error("Connected host buffer limit reached"), { + code: "resource_limit", + }); + this.used += bytes; + let released = false; + return () => { + if (!released) { + released = true; + this.used -= bytes; + } + }; + } +} diff --git a/packages/client/src/node/connector.test.ts b/packages/client/src/node/connector.test.ts new file mode 100644 index 0000000000..5a0ffb68cf --- /dev/null +++ b/packages/client/src/node/connector.test.ts @@ -0,0 +1,53 @@ +import { once } from "node:events"; +import { createServer } from "node:http"; +import { afterEach, expect, test } from "vitest"; +import { WebSocketServer, WebSocket } from "ws"; +import { createHostTransportFactory, HostTransportBudget } from "./index.js"; +const cleanup: (() => Promise)[] = []; +afterEach(async () => { + for (const close of cleanup.splice(0).toReversed()) await close(); +}); +async function endpoint() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + cleanup.push(async () => { + for (const socket of wss.clients) socket.terminate(); + await new Promise((resolve) => wss.close(() => server.close(() => resolve()))); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing port"); + return { wss, url: `ws://127.0.0.1:${address.port}/ws` }; +} +test("connector retains per-attempt headers and WebSocket subprotocols", async () => { + const server = await endpoint(); + const connected = once(server.wss, "connection"); + const transport = createHostTransportFactory(server.url)({ + url: server.url, + headers: { Authorization: "Bearer example" }, + protocols: ["paseo-test"], + }); + cleanup.push(async () => transport.close()); + const [socket, request] = (await connected) as [WebSocket, import("node:http").IncomingMessage]; + expect(request.headers.authorization).toBe("Bearer example"); + expect(socket.protocol).toBe("paseo-test"); +}); +test("a slow consumer cannot exceed the shared byte budget and close releases reserved bytes", async () => { + const server = await endpoint(); + const budget = new HostTransportBudget(4 * 1024 * 1024); + const connected = once(server.wss, "connection"); + const transport = createHostTransportFactory(server.url, budget)({ url: server.url }); + cleanup.push(async () => transport.close()); + const ready = new Promise((resolve) => transport.onOpen(resolve)); + const [socket] = (await connected) as [WebSocket]; + socket.pause(); + await ready; + const frame = new Uint8Array(1024 * 1024); + for (let index = 0; index < 4; index++) transport.send(frame); + expect(budget.bufferedBytes).toBe(4 * 1024 * 1024); + expect(() => transport.send(frame)).toThrow("Connected host buffer limit reached"); + expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit); + transport.close(); + await expect.poll(() => budget.bufferedBytes).toBe(0); +}); diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts new file mode 100644 index 0000000000..41372b738b --- /dev/null +++ b/packages/client/src/node/index.ts @@ -0,0 +1,190 @@ +import { HostTransportBudget } from "./buffering.js"; +export { HostTransportBudget } from "./buffering.js"; +import { homedir } from "node:os"; +import { WebSocket } from "ws"; +import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target"; +import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js"; +import { createWebSocketTransportFactory } from "../daemon-client-websocket-transport.js"; +import { createRelayE2eeTransportFactory } from "../daemon-client-relay-e2ee-transport.js"; +import type { + DaemonTransport, + DaemonTransportFactory, + WebSocketLike, +} from "../daemon-client-transport-types.js"; +import { DaemonClient, type DaemonClientConfig } from "../daemon-client.js"; + +export { parseHostTarget }; +export type { SshTunnel } from "./ssh-tunnel.js"; +export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js"; + +/** A factory owns one attempt, including a fresh SSH process and its teardown. */ +export function createHostTransportFactory( + connection: string, + budget = new HostTransportBudget(), +): DaemonTransportFactory { + const target = parseHostTarget(connection); + return (attemptOptions) => { + const opens = new Set<() => void>(); + const closes = new Set<(event?: unknown) => void>(); + const errors = new Set<(event?: unknown) => void>(); + const messages = new Set<(data: unknown, binary: boolean) => void>(); + let closed = false; + let closeEvent: unknown; + let transport: DaemonTransport | undefined; + let teardown = () => {}; + let sshTunnel: SshTunnel | null = null; + const close = (code?: number, reason?: string) => { + if (closed) return; + closed = true; + try { + transport?.close(code, reason); + } finally { + try { + teardown(); + } finally { + for (const handler of closes) handler(closeEvent); + } + } + }; + void (async () => { + let socketTarget: Extract; + if (target.kind === "ssh") { + const tunnel = await createSshTunnel(target.ssh); + sshTunnel = tunnel; + teardown = () => tunnel.close(); + socketTarget = parseHostTarget(tunnel.endpoint) as Extract; + } else socketTarget = target; + if (closed) { + teardown(); + return; + } + const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir()); + let factory = createWebSocketTransportFactory((url, options) => { + const socket = new WebSocket( + socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url, + options?.protocols, + { + headers: options?.headers, + ...(socketPath ? { socketPath } : {}), + maxPayload: 24 * 1024 * 1024, + }, + ); + const pending = new Set<() => void>(); + const send = socket.send.bind(socket); + socket.send = (data, ...args: unknown[]) => { + const bytes = + typeof data === "string" + ? Buffer.byteLength(data) + : Buffer.byteLength(data as Uint8Array); + if (socket.bufferedAmount + bytes > 24 * 1024 * 1024) { + socket.terminate(); + throw Object.assign(new Error("Host socket buffer limit reached"), { + code: "resource_limit", + }); + } + let release: () => void; + try { + release = budget.reserve(bytes); + } catch (error) { + socket.terminate(); + throw error; + } + pending.add(release); + const callback = args.find( + (value): value is (error?: Error) => void => typeof value === "function", + ); + const sendOptions = args.find((value) => value !== null && typeof value === "object") as + | { binary?: boolean } + | undefined; + try { + send(data, sendOptions ?? {}, (error) => { + release(); + pending.delete(release); + callback?.(error); + }); + } catch (error) { + release(); + pending.delete(release); + throw error; + } + }; + socket.once("close", () => { + for (const release of pending) release(); + pending.clear(); + }); + return socket as unknown as WebSocketLike; + }); + if (socketTarget.offer) + factory = createRelayE2eeTransportFactory({ + baseFactory: factory, + daemonPublicKeyB64: socketTarget.offer.daemonPublicKeyB64, + logger: { warn() {} }, + }); + transport = factory({ ...attemptOptions, url: socketTarget.url }); + transport.onOpen(() => { + if (!closed) for (const handler of opens) handler(); + }); + transport.onClose((event) => { + closeEvent = event; + close(); + }); + transport.onError((error) => { + const detail = sshTunnel?.failureDetail() ?? ""; + const failure = + /permission denied|authentication failed|host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test( + detail, + ) + ? Object.assign( + new Error( + "SSH authentication or host verification failed. Check the SSH configuration on this daemon.", + ), + { code: "authentication_failed" }, + ) + : error; + for (const handler of errors) handler(failure); + close(); + }); + transport.onMessage((data, binary) => { + if (!closed) for (const handler of messages) handler(data, binary); + }); + })().catch(() => { + for (const handler of errors) handler(new Error("Host transport failed")); + close(); + }); + return { + send(data) { + if (closed || !transport) throw new Error("Host transport is unavailable"); + transport.send(data); + }, + close, + onOpen(handler) { + opens.add(handler); + return () => opens.delete(handler); + }, + onClose(handler) { + closes.add(handler); + return () => closes.delete(handler); + }, + onError(handler) { + errors.add(handler); + return () => errors.delete(handler); + }, + onMessage(handler) { + messages.add(handler); + return () => messages.delete(handler); + }, + }; + }; +} +export function createHostClient( + connection: string, + config: Omit, +): DaemonClient { + const target = parseHostTarget(connection); + return new DaemonClient({ + ...config, + url: "ws://host-connector/ws", + ...(target.password ? { password: target.password } : {}), + transportFactory: createHostTransportFactory(connection), + }); +} diff --git a/packages/client/src/node/ssh-tunnel.ts b/packages/client/src/node/ssh-tunnel.ts new file mode 100644 index 0000000000..33900d0baa --- /dev/null +++ b/packages/client/src/node/ssh-tunnel.ts @@ -0,0 +1,90 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createServer, type Server, type Socket } from "node:net"; +import { buildSshTunnelArgs, type SshTransportTarget } from "@getpaseo/protocol/ssh-transport"; + +const SSH_STDERR_LIMIT = 8192; + +export interface SshTunnel { + endpoint: string; + close(): void; + failureDetail(): string | null; +} + +function formatSshFailure( + stderr: string, + code: number | null, + signal: NodeJS.Signals | null, +): string { + const detail = stderr.trim(); + if (detail) return detail; + if (signal) return `ssh exited with signal ${signal}`; + return `ssh exited with code ${code ?? "unknown"}`; +} + +export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null { + return failure ?? (stderr.trim() || null); +} + +export function createSshTunnel(target: SshTransportTarget): Promise { + let server: Server | null = null; + let socket: Socket | null = null; + let child: ChildProcessWithoutNullStreams | null = null; + let stderr = ""; + let failure: string | null = null; + + function close(): void { + server?.close(); + server = null; + socket?.destroy(); + socket = null; + if (child && !child.killed) child.kill(); + child = null; + } + + return new Promise((resolve, reject) => { + server = createServer((acceptedSocket) => { + socket = acceptedSocket; + server?.close(); + server = null; + + child = spawn("ssh", buildSshTunnelArgs(target), { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); + }); + child.on("error", (error) => { + failure = error.message; + acceptedSocket.destroy(error); + }); + child.on("exit", (code, signal) => { + if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal); + acceptedSocket.destroy(failure ? new Error(failure) : undefined); + }); + acceptedSocket.on("error", () => undefined); + acceptedSocket.on("close", () => { + if (child && !child.killed) child.kill(); + }); + acceptedSocket.pipe(child.stdin); + child.stdout.pipe(acceptedSocket); + }); + server.once("error", (error) => { + close(); + reject(error); + }); + server.listen(0, "127.0.0.1", () => { + const address = server?.address(); + if (!address || typeof address === "string") { + close(); + reject(new Error("Failed to allocate the SSH tunnel port")); + return; + } + resolve({ + endpoint: `127.0.0.1:${address.port}`, + close, + failureDetail: () => resolveSshFailureDetail(failure, stderr), + }); + }); + }); +} diff --git a/packages/protocol/src/host-target.test.ts b/packages/protocol/src/host-target.test.ts new file mode 100644 index 0000000000..27544248a4 --- /dev/null +++ b/packages/protocol/src/host-target.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "vitest"; +import { parseHostTarget } from "./host-target.js"; + +test.each([ + ["127.0.0.1:6767", "tcp://127.0.0.1:6767"], + ["4321", "tcp://127.0.0.1:4321"], + ["[::1]:4321", "tcp://[::1]:4321"], + ["unix:///tmp/paseo.sock", "unix:///tmp/paseo.sock"], + ["pipe://\\\\.\\pipe\\paseo", "pipe://\\\\.\\pipe\\paseo"], + ["wss://example.com/custom?token=secret", "wss://example.com"], + ["tcp://example.com:6767?password=secret", "tcp://example.com:6767"], +])( + "the shared connector accepts %s and exposes only safe display text", + (connection, description) => { + expect(parseHostTarget(connection).description).toBe(description); + }, +); +test("SSH daemon credentials are separate from SSH authentication and excluded from display text", () => { + expect(parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777&password=secret")).toEqual({ + kind: "ssh", + ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 }, + password: "secret", + description: "ssh://deploy@build-box:2222 (daemon port 7777)", + }); +}); +test.each([ + "", + "ssh://-bad", + "ssh://user:secret@host", + "ssh://host?unknown=secret", + "unix://", + "tcp://host:0", + "C:\\Users\\socket", +])("invalid target syntax is rejected without echoing %s", (connection) => { + expect(() => parseHostTarget(connection)).toThrow("Invalid host connection"); +}); diff --git a/packages/protocol/src/host-target.ts b/packages/protocol/src/host-target.ts new file mode 100644 index 0000000000..b3566ab583 --- /dev/null +++ b/packages/protocol/src/host-target.ts @@ -0,0 +1,123 @@ +import { parseConnectionOfferFromUrl, type ConnectionOffer } from "./connection-offer.js"; +import { + buildDaemonWebSocketUrl, + buildRelayWebSocketUrl, + normalizeHostPort, + parseConnectionUri, + parseRelayConnectionUri, + shouldUseTlsForDefaultHostedRelay, +} from "./daemon-endpoints.js"; +import { parseSshTransportUri, type SshTransportTarget } from "./ssh-transport.js"; + +export type HostTarget = + | { kind: "ssh"; ssh: SshTransportTarget; password?: string; description: string } + | { + kind: "socket"; + url: string; + socketPath?: string; + password?: string; + offer?: ConnectionOffer; + description: string; + }; + +/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */ +export function parseHostTarget(input: string): HostTarget { + try { + return parse(input.trim()); + } catch { + throw Object.assign( + new Error( + "Invalid host connection. Use host:port, ws(s)://, tcp://, unix://, pipe://, ssh:// or a pairing URL.", + ), + { code: "invalid_connection" }, + ); + } +} +function parse(input: string): HostTarget { + if (!input) throw new Error("empty"); + if (input.startsWith("ssh://")) { + return sshTarget(input); + } + + if (input.startsWith("relay://") || input.includes("#connect=")) { + const relay = parseRelayConnectionUri(input); + return relayTarget(relay.offer, relay.password); + } + const offer = parseConnectionOfferFromUrl(input); + if (offer) return relayTarget(offer); + if ( + input.startsWith("unix://") || + input.startsWith("pipe://") || + input.startsWith("/") || + input.startsWith("~") || + input.startsWith("\\\\.\\pipe\\") + ) { + const socketPath = input.replace(/^(unix|pipe):\/\//, ""); + if (!socketPath || socketPath === "~") throw new Error("empty socket"); + const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\"); + return { + kind: "socket", + socketPath, + url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`, + description: `${pipe ? "pipe" : "unix"}://${socketPath}`, + }; + } + if (input.startsWith("tcp://")) return tcpTarget(input); + if (/^wss?:\/\//.test(input)) return websocketTarget(input); + const endpoint = normalizeHostPort(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input); + return { + kind: "socket", + url: buildDaemonWebSocketUrl(endpoint, { useTls: false }), + description: `tcp://${endpoint}`, + }; +} +function relayTarget(offer: ConnectionOffer, password?: string): HostTarget { + return { + kind: "socket", + url: buildRelayWebSocketUrl({ + endpoint: offer.relay.endpoint, + serverId: offer.serverId, + role: "client", + useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), + }), + offer, + password, + description: `relay://${offer.relay.endpoint}/${offer.serverId}`, + }; +} + +function tcpTarget(input: string): HostTarget { + const parts = parseConnectionUri(input); + const endpoint = normalizeHostPort( + `${parts.isIpv6 ? `[${parts.host}]` : parts.host}:${parts.port}`, + ); + return { + kind: "socket", + url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }), + password: parts.password, + description: `tcp://${endpoint}`, + }; +} +function websocketTarget(input: string): HostTarget { + const url = new URL(input); + if (!url.hostname || url.username || url.password || url.hash) throw new Error("invalid url"); + const password = url.searchParams.get("password") ?? undefined; + // Unknown query parameters may themselves contain credentials; discovery shows only the origin. + if (url.pathname === "/") url.pathname = "/ws"; + url.searchParams.delete("password"); + return { kind: "socket", url: url.toString(), password, description: url.origin }; +} + +function sshTarget(input: string): HostTarget { + const uri = new URL(input); + if (uri.searchParams.getAll("password").length > 1) throw new Error("Duplicate daemon password"); + const password = uri.searchParams.get("password") || undefined; + uri.searchParams.delete("password"); + const ssh = parseSshTransportUri(uri.toString()); + return { + kind: "ssh", + ssh, + ...(password ? { password } : {}), + description: `ssh://${ssh.host}${ssh.sshPort ? `:${ssh.sshPort}` : ""} (daemon port ${ssh.daemonPort})`, + }; +} diff --git a/packages/protocol/src/hosts.ts b/packages/protocol/src/hosts.ts new file mode 100644 index 0000000000..7f4edaa0dd --- /dev/null +++ b/packages/protocol/src/hosts.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; + +export const HostMetadataSchema = z.record(z.string().max(256), z.string().max(4096)); +export const HostAddSchema = z.object({ + connection: z.string().min(1).max(16384), + label: z.string().max(256).optional(), + metadata: HostMetadataSchema.optional(), + sourceKey: z.string().min(1).max(256).optional(), +}); +export const HostUpdateSchema = z.object({ + label: z.string().max(256).nullable().optional(), + metadata: HostMetadataSchema.optional(), + enabled: z.boolean().optional(), + connection: z.string().min(1).max(16384).optional(), + replaceIdentity: z.boolean().optional(), +}); +export const HostSummarySchema = z.object({ + registrationId: z.string(), + serverId: z.string().nullable(), + label: z.string().nullable(), + source: z.object({ id: z.string(), key: z.string() }), + metadata: HostMetadataSchema, + enabled: z.boolean(), + status: z.enum(["connecting", "online", "offline", "error", "disabled"]), + targetDescription: z.string(), + lastOnlineAt: z.string().nullable(), + nextRetryAt: z.string().nullable(), + error: z.object({ code: z.string(), message: z.string() }).nullable(), +}); +export const HostDirectorySchema = z.object({ + self: z.object({ serverId: z.string(), label: z.string() }), + hosts: z.array(HostSummarySchema), +}); +export type HostAdd = z.infer; +export type HostUpdate = z.infer; +export type HostSummary = z.infer; +export type HostDirectory = z.infer; + +export const HostsListRequestSchema = z.object({ + type: z.literal("hosts.list.request"), + requestId: z.string(), + observe: z.boolean().optional(), +}); +export const HostsAddRequestSchema = z.object({ + type: z.literal("hosts.add.request"), + requestId: z.string(), + input: HostAddSchema, +}); +export const HostsUpdateRequestSchema = z.object({ + type: z.literal("hosts.update.request"), + requestId: z.string(), + registrationId: z.string(), + patch: HostUpdateSchema, +}); +export const HostsRemoveRequestSchema = z.object({ + type: z.literal("hosts.remove.request"), + requestId: z.string(), + registrationId: z.string(), +}); +export const HostsRouteOpenRequestSchema = z.object({ + type: z.literal("hosts.route.open.request"), + requestId: z.string(), + serverId: z.string(), + scopeId: z.string().min(1).max(256), +}); +export const HostsRouteFrameRequestSchema = z.object({ + type: z.literal("hosts.route.frame.request"), + routeId: z.string(), + data: z.string().max(24 * 1024 * 1024), + binary: z.boolean(), +}); +export const HostsRouteCloseRequestSchema = z.object({ + type: z.literal("hosts.route.close.request"), + routeId: z.string().optional(), + scopeId: z.string().optional(), +}); +export const HostsListResponseSchema = z.object({ + type: z.literal("hosts.list.response"), + payload: z.object({ + requestId: z.string(), + directory: HostDirectorySchema, + subscriptionId: z.string().optional(), + }), +}); +export const HostsChangedSchema = z.object({ + type: z.literal("hosts.changed"), + payload: z.object({ subscriptionId: z.string(), directory: HostDirectorySchema }), +}); +export const HostsAddResponseSchema = z.object({ + type: z.literal("hosts.add.response"), + payload: z.object({ requestId: z.string(), host: HostSummarySchema }), +}); +export const HostsUpdateResponseSchema = z.object({ + type: z.literal("hosts.update.response"), + payload: z.object({ requestId: z.string(), host: HostSummarySchema }), +}); +export const HostsRemoveResponseSchema = z.object({ + type: z.literal("hosts.remove.response"), + payload: z.object({ requestId: z.string() }), +}); +export const HostsRouteOpenResponseSchema = z.object({ + type: z.literal("hosts.route.open.response"), + payload: z.object({ requestId: z.string(), routeId: z.string() }), +}); +export const HostsRouteFrameSchema = z.object({ + type: z.literal("hosts.route.frame"), + payload: z.object({ subscriptionId: z.string(), data: z.string(), binary: z.boolean() }), +}); +export const HostsRouteClosedSchema = z.object({ + type: z.literal("hosts.route.closed"), + payload: z.object({ subscriptionId: z.string(), code: z.string(), message: z.string() }), +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f7c43f224a..a1c0d65f30 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,4 +1,21 @@ import { AgentMessageSchema } from "./agent-message.js"; +import { + HostsListRequestSchema, + HostsAddRequestSchema, + HostsUpdateRequestSchema, + HostsRemoveRequestSchema, + HostsRouteOpenRequestSchema, + HostsRouteFrameRequestSchema, + HostsRouteCloseRequestSchema, + HostsListResponseSchema, + HostsChangedSchema, + HostsAddResponseSchema, + HostsUpdateResponseSchema, + HostsRemoveResponseSchema, + HostsRouteOpenResponseSchema, + HostsRouteFrameSchema, + HostsRouteClosedSchema, +} from "./hosts.js"; import { PluginRegistryIdentitySchema } from "./plugin-registry.js"; import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js"; export { @@ -885,10 +902,8 @@ export type RecentProviderSessionDescriptorPayload = z.infer< typeof RecentProviderSessionDescriptorPayloadSchema >; -// ============================================================================ -// Session Inbound Messages (Session receives these) -// ============================================================================ - +// =====================================================================// Session Inbound Messages (Session receives these) +// ===================================================================== export const VoiceAudioChunkMessageSchema = z.object({ type: z.literal("voice_audio_chunk"), audio: z.string(), // base64 encoded @@ -1214,10 +1229,8 @@ export const SendAgentMessageSchema = z.object({ attachments: AgentAttachmentsSchema, }); -// ============================================================================ -// Agent RPCs (requestId-correlated) -// ============================================================================ - +// =====================================================================// Agent RPCs (requestId-correlated) +// ===================================================================== const DirectorySyncRequestSchema = z.object({ generation: z.string().optional(), afterSeq: z.number().int().nonnegative().optional(), @@ -1641,10 +1654,8 @@ export const WriteProjectConfigRequestMessageSchema = z.object({ expectedRevision: PaseoConfigRevisionSchema.nullable(), }); -// ============================================================================ -// Dictation Streaming (lossless, resumable) -// ============================================================================ - +// =====================================================================// Dictation Streaming (lossless, resumable) +// ===================================================================== export const DictationStreamStartMessageSchema = z.object({ type: z.literal("dictation_stream_start"), dictationId: z.string(), @@ -2935,10 +2946,8 @@ export const PushUnregisterResponseSchema = z.object({ }), }); -// ============================================================================ -// Terminal Messages -// ============================================================================ - +// =====================================================================// Terminal Messages +// ===================================================================== export const ListTerminalsRequestSchema = z.object({ type: z.literal("list_terminals_request"), cwd: z.string().optional(), @@ -3202,6 +3211,14 @@ export const SubscriptionReleaseResponseSchema = z.object({ }); export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ + HostsListRequestSchema, + HostsAddRequestSchema, + HostsUpdateRequestSchema, + HostsRemoveRequestSchema, + HostsRouteOpenRequestSchema, + HostsRouteFrameRequestSchema, + HostsRouteCloseRequestSchema, + BrowserHostRegisterRequestSchema, SubscriptionReleaseRequestSchema, SessionEventsSetSubscriptionRequestSchema, @@ -3413,10 +3430,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ export type SessionInboundMessage = z.infer; -// ============================================================================ -// Session Outbound Messages (Session emits these) -// ============================================================================ - +// =====================================================================// Session Outbound Messages (Session emits these) +// ===================================================================== export const ActivityLogPayloadSchema = z.object({ subscriptionId: z.string().optional(), id: z.string(), @@ -3572,6 +3587,7 @@ export const ServerInfoStatusPayloadSchema = z hostname: ServerInfoHostnameSchema.optional(), version: ServerInfoVersionSchema.optional(), // COMPAT(sessionPermissions): optional while clients support older daemons. + principalId: z.string().optional(), permissions: z.array(DaemonPermissionSchema).optional(), // COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16. desktopManaged: z.boolean().optional(), @@ -3592,6 +3608,7 @@ export const ServerInfoStatusPayloadSchema = z internalWorkspaces: z.boolean().optional(), backgroundWorkspaces: z.boolean().optional(), providersSnapshot: z.boolean().optional(), + connectedHosts: z.boolean().optional(), usageSources: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: z.boolean().optional(), @@ -6389,10 +6406,8 @@ export const ListCommandsResponseSchema = z.object({ }), }); -// ============================================================================ -// Terminal Outbound Messages -// ============================================================================ - +// =====================================================================// Terminal Outbound Messages +// ===================================================================== const TerminalInfoSchema = z.object({ id: z.string(), name: z.string(), @@ -6871,6 +6886,15 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ }); export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ + HostsListResponseSchema, + HostsChangedSchema, + HostsAddResponseSchema, + HostsUpdateResponseSchema, + HostsRemoveResponseSchema, + HostsRouteOpenResponseSchema, + HostsRouteFrameSchema, + HostsRouteClosedSchema, + BrowserHostRegisterResponseSchema, SubscriptionReleaseResponseSchema, SessionEventsSetSubscriptionResponseSchema, @@ -7548,10 +7572,8 @@ export type CaptureTerminalRequest = z.infer; export type TerminalStreamExit = z.infer; -// ============================================================================ -// WebSocket Level Messages (wraps session messages) -// ============================================================================ - +// =====================================================================// WebSocket Level Messages (wraps session messages) +// ===================================================================== // WebSocket-only messages (not session messages) export const WSPingMessageSchema = z.object({ type: z.literal("ping"), @@ -7563,6 +7585,11 @@ export const WSPongMessageSchema = z.object({ export const WSHelloMessageSchema = z.object({ type: z.literal("hello"), + sessionPermissions: z.array(DaemonPermissionSchema).optional(), + routed: z.boolean().optional(), + producer: z + .object({ serverId: z.string().min(1).max(256), sourceId: z.string().min(1).max(1024) }) + .optional(), clientId: z.string().min(1), clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]), protocolVersion: z.number().int(), @@ -7636,10 +7663,8 @@ export type WSInboundMessage = z.infer; export type WSOutboundMessage = z.infer; export type WSHelloMessage = z.infer; -// ============================================================================ -// Helper functions for message conversion -// ============================================================================ - +// =====================================================================// Helper functions for message conversion +// ===================================================================== /** * Extract session message from WebSocket message * Returns null if message should be handled at WS level only diff --git a/packages/server/package.json b/packages/server/package.json index afc1c4a399..7f00ae3e99 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 0893abe1a3..aee93b5cbe 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -6,6 +6,13 @@ type OutboundOperation = SessionOutboundMessage["type"]; export type PermissionRequirement = DaemonPermission | readonly DaemonPermission[] | null; const INBOUND_PERMISSION = { + "hosts.list.request": "daemon.read", + "hosts.add.request": "tunnel.manage", + "hosts.update.request": "tunnel.manage", + "hosts.remove.request": "tunnel.manage", + "hosts.route.open.request": null, + "hosts.route.frame.request": null, + "hosts.route.close.request": null, abort_request: "workspace.write", "agent.config.apply.request": ["workspace.write", "hub.execute"], "agent.detach.request": "workspace.write", @@ -216,6 +223,14 @@ const INBOUND_PERMISSION = { } as const satisfies Record; const OUTBOUND_PERMISSION = { + "hosts.list.response": "daemon.read", + "hosts.changed": "daemon.read", + "hosts.add.response": "tunnel.manage", + "hosts.update.response": "tunnel.manage", + "hosts.remove.response": "tunnel.manage", + "hosts.route.open.response": null, + "hosts.route.frame": null, + "hosts.route.closed": null, "agent.create.response": ["workspace.write", "hub.execute"], "agent.create.update": ["workspace.write", "hub.execute"], "workspace.create.update": "workspace.manage", diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts new file mode 100644 index 0000000000..0c91b42506 --- /dev/null +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -0,0 +1,688 @@ +import { afterEach, expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { createHostClient } from "@getpaseo/client/node"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import type { HostDirectory } from "@getpaseo/protocol/hosts"; + +const cleanup: (() => Promise)[] = []; +afterEach(async () => { + for (const close of cleanup.splice(0).toReversed()) await close(); +}); +async function daemon(): Promise { + const instance = await createTestPaseoDaemon(); + cleanup.push(() => instance.close()); + return instance; +} +async function api(instance: TestPaseoDaemon) { + const client = createHostClient(`127.0.0.1:${instance.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + await client.connect(); + const paseo = createPaseoApi(client); + cleanup.push(async () => { + await paseo.dispose(); + await client.close(); + }); + return { paseo, client }; +} +async function online(paseo: ReturnType): Promise { + let directory!: HostDirectory; + await expect + .poll( + async () => { + directory = await paseo.hosts.list(); + return directory.hosts[0]?.status; + }, + { timeout: 15000 }, + ) + .toBe("online"); + return directory; +} + +test("SDK saves unreachable hosts, rejects syntax and preserves source identity without exposing secrets", async () => { + const a = await daemon(); + const { paseo } = await api(a); + await expect(paseo.hosts.add({ connection: "ssh://-bad" })).rejects.toMatchObject({ + code: "invalid_connection", + }); + const saved = await paseo.hosts.add({ + connection: "tcp://127.0.0.1:1?password=private-secret", + label: "Unavailable", + metadata: { team: "infra" }, + sourceKey: "stable", + }); + expect(saved).toMatchObject({ + registrationId: expect.stringMatching(/^hst_/), + serverId: null, + label: "Unavailable", + source: { id: "owner", key: "stable" }, + metadata: { team: "infra" }, + enabled: true, + }); + expect(JSON.stringify(await paseo.hosts.list())).not.toContain("private-secret"); + await paseo.hosts.update(saved.registrationId, { enabled: false }); + expect( + await paseo.hosts.add({ + connection: "tcp://127.0.0.1:1?password=private-secret", + label: "Unavailable", + metadata: { team: "infra" }, + sourceKey: "stable", + }), + ).toMatchObject({ registrationId: saved.registrationId, enabled: false, status: "disabled" }); + await expect( + paseo.hosts.add({ connection: "127.0.0.1:2", sourceKey: "stable" }), + ).rejects.toMatchObject({ code: "source_conflict" }); + await paseo.hosts.remove(saved.registrationId); + expect((await paseo.hosts.list()).hosts).toEqual([]); +}); + +test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origin disposal", async () => { + const b = await daemon(); + const a = await daemon(); + const { paseo } = await api(a); + const observation = paseo.hosts.observe(); + expect((await observation.ready).hosts).toEqual([]); + const saved = await paseo.hosts.add({ connection: `127.0.0.1:${b.port}`, label: "B" }); + const directory = await online(paseo); + const serverId = directory.hosts[0]!.serverId!; + const first = await paseo.hosts.client(serverId); + const second = await paseo.hosts.client(serverId); + const workspaces = await second.workspaces.list({ subscribe: {} }); + expect(workspaces.entries).toEqual([]); + await first.dispose(); + expect((await second.workspaces.list()).entries).toEqual([]); + await expect(second.hosts.client(directory.self.serverId)).rejects.toMatchObject({ + code: "unsupported_capability", + }); + await paseo.hosts.update(saved.registrationId, { enabled: false }); + await expect(paseo.hosts.client(serverId)).rejects.toMatchObject({ code: "host_disabled" }); + await paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(paseo); + await expect + .poll( + async () => { + try { + return (await second.workspaces.list()).entries.length; + } catch { + return -1; + } + }, + { timeout: 20000 }, + ) + .toBe(0); + await second.dispose(); + await observation.release(); + await paseo.dispose(); +}); + +test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => { + const b = await daemon(); + const a = await daemon(); + const owner = await api(a); + await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(owner.paseo); + const serverId = directory.hosts[0]!.serverId!; + const viewerClient = createHostClient(`127.0.0.1:${a.port}`, { + clientId: "overlapping", + sessionPermissions: ["daemon.read", "workspace.read"], + reconnect: { enabled: false }, + }); + await viewerClient.connect(); + const viewer = createPaseoApi(viewerClient); + cleanup.push(async () => { + await viewer.dispose(); + await viewerClient.close(); + }); + const remote = await viewer.hosts.client(serverId); + expect((await remote.workspaces.list()).entries).toEqual([]); + await expect( + remote.workspaces.create({ source: { kind: "directory", path: b.paseoHome } }), + ).rejects.toMatchObject({ code: "permission_denied" }); + await expect(viewer.hosts.add({ connection: "127.0.0.1:1" })).rejects.toMatchObject({ + code: "permission_denied", + }); + expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1); + await remote.dispose(); +}); + +test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => { + const b = await daemon(); + const a = await daemon(); + const admin = await api(a); + await admin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(admin.paseo)).hosts[0]!.serverId!; + const remote = await admin.paseo.hosts.client(serverId); + const workspace = await remote.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const one = await workspace.terminals.create({ + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], + name: "First", + }); + const two = await workspace.terminals.create({ + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], + name: "Second", + }); + const firstOrigin = await api(a); + const secondOrigin = await api(a); + const first = await RoutePeer.open(firstOrigin.client, serverId); + const second = await RoutePeer.open(secondOrigin.client, serverId); + await first.subscribeTerminal(one.id); + await second.subscribeTerminal(two.id); + first.input("FIRST_ONLY\n"); + second.input("SECOND_ONLY\n"); + await expect.poll(() => first.output()).toContain("FIRST_ONLY"); + await expect.poll(() => second.output()).toContain("SECOND_ONLY"); + expect(first.output()).not.toContain("SECOND_ONLY"); + expect(second.output()).not.toContain("FIRST_ONLY"); + await first.close(); + second.input("STILL_SECOND\n"); + await expect.poll(() => second.output()).toContain("STILL_SECOND"); + await second.close(); + await one.kill(); + await two.kill(); + await remote.dispose(); +}); + +import { + decodeTerminalStreamFrame, + encodeTerminalStreamFrame, + TerminalStreamOpcode, +} from "@getpaseo/protocol/binary-frames/index"; +import { + WSOutboundMessageSchema, + type SessionOutboundMessage, + type SessionInboundMessage, +} from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +class RoutePeer { + readonly messages: SessionOutboundMessage[] = []; + readonly bytes: Uint8Array[] = []; + private slot = -1; + private detach = () => {}; + private constructor( + private readonly origin: DaemonClient, + private readonly routeId: string, + ) { + this.detach = origin.subscribeRawMessages((message) => { + if (message.type !== "hosts.route.frame" || message.payload.subscriptionId !== routeId) + return; + if (message.payload.binary) this.bytes.push(Buffer.from(message.payload.data, "base64")); + else { + const frame = WSOutboundMessageSchema.parse(JSON.parse(message.payload.data)); + if (frame.type === "session") this.messages.push(frame.message); + } + }); + } + static async open(origin: DaemonClient, serverId: string) { + const peer = new RoutePeer( + origin, + await origin.openHostRoute(serverId, "same-untrusted-scope"), + ); + origin.sendHostFrame( + peer.routeId, + JSON.stringify({ + type: "hello", + clientId: "same-untrusted-client", + clientType: "cli", + protocolVersion: 1, + capabilities: { owned_subscriptions: true }, + }), + false, + ); + await expect + .poll( + () => + peer.messages.filter( + (message) => message.type === "status" && message.payload.status === "server_info", + ).length, + ) + .toBe(1); + return peer; + } + send(message: SessionInboundMessage) { + this.origin.sendHostFrame(this.routeId, JSON.stringify({ type: "session", message }), false); + } + async subscribeTerminal(terminalId: string) { + this.send({ type: "subscribe_terminal_request", terminalId, requestId: "same-request" }); + await expect + .poll( + () => + this.messages.filter((message) => message.type === "subscribe_terminal_response").length, + ) + .toBe(1); + const response = this.messages.find( + (message) => message.type === "subscribe_terminal_response", + ); + if (response?.type !== "subscribe_terminal_response" || response.payload.slot === null) + throw new Error("Missing terminal response"); + this.slot = response.payload.slot!; + } + input(text: string) { + this.origin.sendHostFrame( + this.routeId, + Buffer.from( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + slot: this.slot, + payload: text, + }), + ).toString("base64"), + true, + ); + } + output() { + return this.bytes + .flatMap((bytes) => { + const frame = decodeTerminalStreamFrame(bytes); + return frame?.opcode === TerminalStreamOpcode.Output + ? [new TextDecoder().decode(frame.payload)] + : []; + }) + .join(""); + } + async close() { + this.detach(); + this.origin.closeHostRoute(this.routeId); + } +} + +import { mkdir, writeFile, stat, mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { resolveDaemonVersion } from "../daemon-version.js"; + +test("identity discovery failures remain inspectable and changing a pinned destination requires explicit replacement", async () => { + const a = await daemon(); + const b = await daemon(); + const c = await daemon(); + const { paseo } = await api(a); + const self = await paseo.hosts.add({ connection: `127.0.0.1:${a.port}` }); + await expect.poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code).toBe("self_host"); + expect((await paseo.hosts.list()).hosts[0]!.serverId).toBe(a.daemon.getServerId()); + await paseo.hosts.remove(self.registrationId); + const original = await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const bId = (await online(paseo)).hosts[0]!.serverId; + const duplicate = await paseo.hosts.add({ connection: `ws://127.0.0.1:${b.port}/ws` }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[1]!.error?.code) + .toBe("duplicate_host"); + expect((await paseo.hosts.list()).hosts[1]!.serverId).toBe(bId); + await paseo.hosts.remove(duplicate.registrationId); + await paseo.hosts.update(original.registrationId, { connection: `127.0.0.1:${c.port}` }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code) + .toBe("server_id_mismatch"); + expect((await paseo.hosts.list()).hosts[0]!.serverId).toBe(bId); + await paseo.hosts.update(original.registrationId, { + connection: `127.0.0.1:${c.port}`, + replaceIdentity: true, + }); + expect((await online(paseo)).hosts[0]!.serverId).toBe(c.daemon.getServerId()); +}); + +test("disabled host configuration and source survive a fresh daemon instance", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-host-restart-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => a.close()); + const { paseo } = await api(a); + const host = await paseo.hosts.add({ + connection: "tcp://127.0.0.1:1?password=secret", + label: "Persistent", + metadata: { team: "infra" }, + sourceKey: "stable", + }); + await paseo.hosts.update(host.registrationId, { enabled: false }); + expect((await stat(join(a.paseoHome, "hosts.json"))).mode & 0o777).toBe(0o600); + await a.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + expect((await next.paseo.hosts.list()).hosts).toEqual([ + expect.objectContaining({ + registrationId: host.registrationId, + label: "Persistent", + metadata: { team: "infra" }, + source: { id: "owner", key: "stable" }, + enabled: false, + status: "disabled", + }), + ]); +}); + +test("server plugins use ordinary remote SDK operations and retain qualified source ownership on B", async () => { + const b = await daemon(); + const a = await daemon(); + const { paseo, client } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(paseo)).hosts[0]!.serverId!; + const directory = join(a.staticDir, "host-plugin"); + await mkdir(directory); + await writeFile( + join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "connected-host-test", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), + ); + await writeFile( + join(directory, "index.server.ts"), + ` +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; +const operate = defineRpc({ name: "operate", input: z.object({ serverId: z.string(), path: z.string() }), output: z.object({ workspaceId: z.string(), registrationId: z.string(), sourceId: z.string() }) }); +export default function contribute(server) { + server.handle(operate, async (input, context) => { + const remote = await context.paseo.hosts.client(input.serverId); + try { + const workspace = await remote.workspaces.create({ source: { kind: "directory", path: input.path }, title: "Plugin remote workspace" }); + const host = await remote.hosts.add({ connection: "127.0.0.1:1", sourceKey: "plugin-stable", metadata: { team: "plugin" } }); + return { workspaceId: workspace.id, registrationId: host.registrationId, sourceId: host.source.id }; + } finally { await remote.dispose(); } + }); + return () => {}; +}`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const result = await client.invokePluginRpc("connected-host-test", "operate", { + serverId, + path: b.staticDir, + }); + expect(result).toEqual({ + workspaceId: expect.stringMatching(/^wks_/), + registrationId: expect.stringMatching(/^hst_/), + sourceId: `route:owner:${a.daemon.getServerId()}:plugin%3Aconnected-host-test`, + }); + const direct = await api(b); + expect((await direct.paseo.workspaces.list()).entries).toHaveLength(1); + expect((await direct.paseo.hosts.list()).hosts[0]).toMatchObject({ + source: { + id: `route:owner:${a.daemon.getServerId()}:plugin%3Aconnected-host-test`, + key: "plugin-stable", + }, + metadata: { team: "plugin" }, + }); + await client.removePlugin("connected-host-test"); + expect((await direct.paseo.workspaces.list()).entries).toHaveLength(1); +}); + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +test("CLI host commands manage the selected daemon and emit secret-free structured output", async () => { + const a = await daemon(); + const cli = async (...args: string[]) => + JSON.parse( + ( + await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${a.port}`, + "host", + ...args, + "--json", + ], + { timeout: 20000 }, + ) + ).stdout, + ); + const saved = await cli( + "add", + "tcp://127.0.0.1:1?password=cli-secret", + "--label", + "CLI host", + "--meta", + "team=infra", + ); + expect(saved).toMatchObject({ + label: "CLI host", + metadata: { team: "infra" }, + registrationId: expect.stringMatching(/^hst_/), + }); + expect(JSON.stringify(await cli("ls"))).not.toContain("cli-secret"); + expect( + await cli( + "update", + saved.registrationId, + "--meta", + "team=tools", + "--meta", + "other=value", + "--remove-meta", + "team", + ), + ).toMatchObject({ metadata: { other: "value" } }); + expect(await cli("disable", saved.registrationId)).toMatchObject({ + status: "disabled", + enabled: false, + }); + expect(await cli("inspect", saved.registrationId)).toMatchObject({ + enabled: false, + targetDescription: "tcp://127.0.0.1:1", + }); + expect(await cli("enable", saved.registrationId)).toMatchObject({ enabled: true }); + await cli("rm", saved.registrationId); + expect(await cli("ls")).toEqual([]); +}, 60000); + +test("resuming an A session with narrower grants terminates existing routes and restores observations with current authority", async () => { + const b = await daemon(); + const a = await daemon(); + const admin = await api(a); + await admin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(admin.paseo)).hosts[0]!.serverId!; + const origin = createHostClient(`127.0.0.1:${a.port}`, { + clientId: "resumed-authority", + reconnect: { enabled: false }, + }); + await origin.connect(); + const paseo = createPaseoApi(origin); + cleanup.push(async () => { + await paseo.dispose(); + await origin.close(); + }); + const remote = await paseo.hosts.client(serverId); + const observed = await remote.workspaces.list({ subscribe: {} }); + const snapshots: unknown[] = []; + observed.subscription.subscribe({ + snapshot: (snapshot) => snapshots.push(snapshot), + update() {}, + }); + const restricted = createHostClient(`127.0.0.1:${a.port}`, { + clientId: "resumed-authority", + sessionPermissions: ["daemon.read", "workspace.read"], + reconnect: { enabled: false }, + }); + await restricted.connect(); + cleanup.push(() => restricted.close()); + await expect.poll(() => snapshots.length, { timeout: 15000 }).toBe(2); + await expect( + remote.workspaces.create({ source: { kind: "directory", path: b.staticDir } }), + ).rejects.toMatchObject({ code: "permission_denied" }); + expect((await remote.workspaces.list()).entries).toEqual([]); + await remote.dispose(); +}); + +import { WebSocketServer } from "ws"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { hashSync } from "bcryptjs"; + +test("old capabilities fail before execution and authentication errors recover through credential replacement", async () => { + const http = createServer(); + const ws = new WebSocketServer({ server: http }); + const operations: string[] = []; + ws.on("connection", (socket) => + socket.on("message", (data) => { + const frame = JSON.parse(data.toString()); + if (frame.type === "hello") + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "srv_old", + hostname: "old", + version: "0.1.0", + }, + }, + }), + ); + else if (frame.type === "session") operations.push(frame.message.type); + }), + ); + http.listen(0, "127.0.0.1"); + await once(http, "listening"); + cleanup.push(async () => { + for (const socket of ws.clients) socket.terminate(); + await new Promise((resolve) => ws.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing old daemon port"); + const old = createHostClient(`127.0.0.1:${address.port}`, { + clientId: "old-capability", + reconnect: { enabled: false }, + }); + await old.connect(); + cleanup.push(() => old.close()); + await expect(createPaseoApi(old).hosts.add({ connection: "127.0.0.1:1" })).rejects.toMatchObject({ + code: "unsupported_capability", + }); + expect(operations).toEqual([]); + const a = await daemon(); + const { paseo } = await api(a); + const registration = await paseo.hosts.add({ connection: `127.0.0.1:${address.port}` }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code) + .toBe("unsupported_capability"); + await expect(paseo.hosts.client("srv_old")).rejects.toMatchObject({ + code: "unsupported_capability", + }); + expect(operations).toEqual([]); + await paseo.hosts.remove(registration.registrationId); + const b = await createTestPaseoDaemon({ auth: { password: hashSync("right-password", 4) } }); + cleanup.push(() => b.close()); + const saved = await paseo.hosts.add({ + connection: `tcp://127.0.0.1:${b.port}?password=wrong-password`, + }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code) + .toBe("authentication_failed"); + expect(JSON.stringify(await paseo.hosts.list())).not.toContain("wrong-password"); + await paseo.hosts.update(saved.registrationId, { + connection: `tcp://127.0.0.1:${b.port}?password=right-password`, + }); + expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId()); +}); + +test("an unreachable saved target becomes online after its daemon starts without being added again", async () => { + const reservation = createServer(); + reservation.listen(0, "127.0.0.1"); + await once(reservation, "listening"); + const address = reservation.address(); + if (!address || typeof address === "string") throw new Error("Missing reserved port"); + await new Promise((resolve) => reservation.close(() => resolve())); + const a = await daemon(); + const { paseo } = await api(a); + const host = await paseo.hosts.add({ connection: `127.0.0.1:${address.port}` }); + await expect.poll(async () => (await paseo.hosts.list()).hosts[0]!.status).toBe("offline"); + const b = await createTestPaseoDaemon({ listenPort: address.port }); + cleanup.push(() => b.close()); + expect((await online(paseo)).hosts[0]).toMatchObject({ + registrationId: host.registrationId, + serverId: b.daemon.getServerId(), + status: "online", + nextRetryAt: null, + error: null, + }); +}); + +test("losing a sent mutation result identifies the destination and never queues unavailable operations", async () => { + const b = await daemon(); + const a = await daemon(); + const { paseo, client } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(paseo)).hosts[0]!.serverId!; + const remote = await paseo.hosts.client(serverId); + const pending = remote.config.patch({ pluginsEnabled: false }); + const result = pending.catch((error: unknown) => error); + await client.close(); + expect(await result).toMatchObject({ code: "outcome_unknown", serverId }); + await expect(remote.workspaces.list()).rejects.toMatchObject({ code: "host_offline", serverId }); + await expect(remote.terminals.list()).rejects.toMatchObject({ code: "host_offline", serverId }); + await expect( + remote.workspaces.create({ + source: { kind: "directory", path: "/tmp" }, + workspaceId: "offline-never-created", + }), + ).rejects.toMatchObject({ code: "host_offline", serverId }); + await remote.dispose(); +}); + +test("disposing an origin during routed handshake settles acquisition and closes only its pending session", async () => { + const http = createServer(); + const ws = new WebSocketServer({ server: http }); + let connections = 0; + let routeHello = false; + let routeClosed = false; + ws.on("connection", (socket) => { + const monitor = ++connections === 1; + socket.on("close", () => { + if (!monitor) routeClosed = true; + }); + socket.on("message", (data) => { + const frame = JSON.parse(data.toString()); + if (frame.type !== "hello") return; + if (monitor) + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "srv_pending", + hostname: "pending", + version: "0.11.0", + features: { connectedHosts: true, ownedSubscriptions: true }, + }, + }, + }), + ); + else routeHello = true; + }); + }); + http.listen(0, "127.0.0.1"); + await once(http, "listening"); + cleanup.push(async () => { + for (const socket of ws.clients) socket.terminate(); + await new Promise((resolve) => ws.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing pending endpoint port"); + const a = await daemon(); + const { paseo } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${address.port}` }); + await online(paseo); + const pending = paseo.hosts.client("srv_pending"); + const result = pending.catch((error: unknown) => error); + await expect.poll(() => routeHello).toBe(true); + await paseo.dispose(); + expect(await result).toBeInstanceOf(Error); + await expect.poll(() => routeClosed).toBe(true); + expect(ws.clients.size).toBe(1); +}); diff --git a/packages/server/src/server/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts new file mode 100644 index 0000000000..83a160119b --- /dev/null +++ b/packages/server/src/server/connected-hosts/index.ts @@ -0,0 +1,531 @@ +export { ConnectedHostSession } from "./session.js"; +import { randomUUID, createHash } from "node:crypto"; +import { mkdir, readFile, rename, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { + createHostClient, + createHostTransportFactory, + parseHostTarget, + HostTransportBudget, +} from "@getpaseo/client/node"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + HostSummarySchema, + HostAddSchema, + HostUpdateSchema, + type HostDirectory, + type HostSummary, + type HostAdd, + type HostUpdate, +} from "@getpaseo/protocol/hosts"; +import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages"; + +export class HostError extends Error { + constructor( + readonly code: string, + message: string, + readonly serverId?: string, + ) { + super(message); + } +} +const SavedSchema = HostSummarySchema.extend({ connection: z.string() }); +type Saved = z.infer; +export interface HostActor { + sourceId: string; + manageSources: boolean; +} +interface Live { + stop(): void; +} +interface Route { + registrationId: string; + close(code?: string, message?: string): void; +} + +/** Daemon-owned configuration, identity pinning, retry and routed session lifetime. */ +export class ConnectedHosts { + private readonly budget = new HostTransportBudget(); + private rows: Saved[] = []; + private readonly live = new Map(); + private readonly routes = new Set(); + private readonly listeners = new Set<() => void>(); + private queue: Promise = Promise.resolve(); + private readonly ready: Promise; + private closed = false; + constructor( + private readonly home: string, + private readonly self: HostDirectory["self"], + ) { + this.ready = this.load(); + void this.ready.catch(() => {}); + } + private async load(): Promise { + try { + this.rows = z + .array(SavedSchema) + .parse(JSON.parse(await readFile(join(this.home, "hosts.json"), "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") + throw new Error("Cannot read saved connected hosts", { cause: error }); + } + if (this.closed) return; + for (const row of this.rows) { + row.status = row.enabled ? "connecting" : "disabled"; + row.nextRetryAt = null; + this.start(row); + } + } + async list(): Promise { + await this.ready; + return { + self: { ...this.self }, + hosts: this.rows.map(({ connection: _secret, ...row }) => structuredClone(row)), + }; + } + observe(changed: () => void): () => void { + this.listeners.add(changed); + return () => this.listeners.delete(changed); + } + private changed(): void { + for (const listener of this.listeners) listener(); + } + private async persist(rows: Saved[]): Promise { + await mkdir(this.home, { recursive: true }); + const temp = join(this.home, `.hosts-${randomUUID()}.tmp`); + try { + await writeFile(temp, JSON.stringify(rows), { mode: 0o600 }); + await rename(temp, join(this.home, "hosts.json")); + } finally { + await rm(temp, { force: true }); + } + } + private mutate(run: () => Promise): Promise { + const result = this.queue.then(async () => { + await this.ready; + if (this.closed) throw new HostError("host_offline", "Daemon is stopping"); + return run(); + }); + this.queue = result.catch(() => {}); + return result; + } + private summary(row: Saved): HostSummary { + const { connection: _secret, ...summary } = row; + return structuredClone(summary); + } + add(input: HostAdd, actor: HostActor): Promise { + return this.mutate(async () => { + input = HostAddSchema.parse(input); + const target = parseHostTarget(input.connection); + if (this.rows.length >= 128) + throw new HostError("resource_limit", "Remove a host before adding another (limit 128)"); + const source = { id: actor.sourceId, key: input.sourceKey ?? randomUUID() }; + const existing = this.rows.find( + (row) => row.source.id === source.id && row.source.key === source.key, + ); + if (existing) { + if ( + existing.connection === input.connection && + existing.label === (input.label ?? null) && + JSON.stringify(Object.entries(existing.metadata).sort()) === + JSON.stringify(Object.entries(input.metadata ?? {}).sort()) + ) + return this.summary(existing); + throw new HostError( + "source_conflict", + "This source key already exists. Update its registration explicitly.", + ); + } + const row: Saved = { + registrationId: `hst_${randomUUID()}`, + serverId: null, + connection: input.connection, + label: input.label ?? null, + metadata: input.metadata ?? {}, + source, + enabled: true, + status: "connecting", + targetDescription: target.description, + lastOnlineAt: null, + nextRetryAt: null, + error: null, + }; + await this.persist([...this.rows, row]); + this.rows.push(row); + this.start(row); + this.changed(); + return this.summary(row); + }); + } + update(id: string, patch: HostUpdate, actor: HostActor): Promise { + return this.mutate(async () => { + patch = HostUpdateSchema.parse(patch); + const previous = this.require(id); + if ( + (patch.connection !== undefined || patch.metadata !== undefined || patch.replaceIdentity) && + actor.sourceId !== previous.source.id && + !actor.manageSources + ) + throw new HostError( + "permission_denied", + "Only the source or access manager can replace its connection and metadata", + ); + if (patch.replaceIdentity && patch.connection === undefined) + throw new HostError( + "invalid_connection", + "Identity replacement requires a replacement connection", + ); + const row: Saved = { + ...previous, + ...(patch.label !== undefined ? { label: patch.label } : {}), + ...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}), + ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}), + }; + if (patch.connection !== undefined) { + row.connection = patch.connection; + row.targetDescription = parseHostTarget(patch.connection).description; + } + if (patch.replaceIdentity) row.serverId = null; + const restart = + patch.connection !== undefined || patch.enabled !== undefined || patch.replaceIdentity; + if (restart) { + row.status = row.enabled ? "connecting" : "disabled"; + row.error = null; + row.nextRetryAt = null; + } + const rows = this.rows.map((entry) => (entry === previous ? row : entry)); + await this.persist(rows); + if (restart) this.stop(id, row.enabled ? "host_offline" : "host_disabled"); + Object.assign(previous, row); + this.rows = rows.map((entry) => (entry === row ? previous : entry)); + if (restart) this.start(previous); + this.changed(); + return this.summary(previous); + }); + } + remove(id: string, actor: HostActor): Promise { + return this.mutate(async () => { + const row = this.require(id); + if (actor.sourceId !== row.source.id && !actor.manageSources) + throw new HostError( + "permission_denied", + "Only the source or access manager can remove this host. Disable it to stop access.", + ); + const rows = this.rows.filter((entry) => entry !== row); + await this.persist(rows); + this.stop(id, "host_unknown"); + this.rows = rows; + this.changed(); + }); + } + private require(id: string): Saved { + const row = this.rows.find((entry) => entry.registrationId === id); + if (!row) throw new HostError("host_unknown", "Unknown host registration"); + return row; + } + private stop(id: string, code = "host_offline"): void { + this.live.get(id)?.stop(); + this.live.delete(id); + for (const route of this.routes) + if (route.registrationId === id) + route.close(code, "Host access was disabled, replaced or removed"); + } + private start(row: Saved): void { + if (!row.enabled || this.closed) return; + let client: DaemonClient | null = null; + let timer: ReturnType | undefined; + let stopped = false; + let connecting = false; + const live: Live = { + stop: () => { + stopped = true; + clearTimeout(timer); + void client?.close(); + }, + }; + this.live.set(row.registrationId, live); + const retry = () => { + if (stopped || connecting || timer || row.status === "error") return; + row.status = "offline"; + row.error = { + code: "host_offline", + message: "Cannot reach host. Check the connection; retrying automatically.", + }; + row.nextRetryAt = new Date(Date.now() + 5000).toISOString(); + this.changed(); + timer = setTimeout(() => { + timer = undefined; + void attempt(); + }, 5000); + timer.unref(); + }; + const attempt = async () => { + if (stopped) return; + await client?.close(); + client = createHostClient(row.connection, { + clientId: `host-monitor-${this.self.serverId}-${row.registrationId}`, + reconnect: { enabled: false }, + connectTimeoutMs: 10000, + }); + client.subscribeConnectionStatus((state) => { + if (!stopped && state.status === "disconnected") { + for (const route of this.routes) + if (route.registrationId === row.registrationId) route.close(); + retry(); + } + }); + connecting = true; + row.status = "connecting"; + row.nextRetryAt = null; + this.changed(); + try { + await client.connect(); + if (stopped) return; + const info = client.getLastServerInfoMessage(); + if (!info) throw new HostError("host_offline", "No daemon identity received"); + if (row.serverId && info.serverId !== row.serverId) + throw new HostError( + "server_id_mismatch", + "Daemon identity changed. Verify the destination and explicitly replace its identity.", + ); + if (!row.serverId) + await this.mutate(async () => { + if (stopped) return; + const discovered = { ...row, serverId: info.serverId }; + await this.persist(this.rows.map((entry) => (entry === row ? discovered : entry))); + row.serverId = info.serverId; + }); + if (info.serverId === this.self.serverId) + throw new HostError( + "self_host", + "This connection reaches this daemon. Remove it or replace the connection.", + ); + if (this.rows.find((entry) => entry.serverId === info.serverId) !== row) + throw new HostError( + "duplicate_host", + "Another registration already owns this daemon. Remove the duplicate registration.", + ); + if (info.features?.connectedHosts !== true) + throw new HostError( + "unsupported_capability", + "Update the destination daemon to use connected hosts.", + ); + // Pin and persist under the same writer as management to prevent discovery races. + await this.mutate(async () => { + if (stopped) return; + if (this.rows.find((entry) => entry.serverId === info.serverId) !== row) + throw new HostError("duplicate_host", "Another registration already owns this daemon"); + const next = { + ...row, + serverId: info.serverId, + status: "online" as const, + lastOnlineAt: new Date().toISOString(), + error: null, + nextRetryAt: null, + }; + await this.persist(this.rows.map((entry) => (entry === row ? next : entry))); + Object.assign(row, next); + this.changed(); + }); + } catch (error) { + if (stopped) return; + const failure = connectionFailure(error, client.authFailureReason !== null); + if (failure) { + row.status = "error"; + row.error = failure; + row.nextRetryAt = null; + this.changed(); + } + await client.close(); + } finally { + connecting = false; + if (!stopped && !client.isConnected) retry(); + } + }; + void attempt(); + } + async openRoute(input: { + serverId: string; + scopeId: string; + sourceId: string; + originSessionId: string; + permissions: readonly DaemonPermission[]; + signal: AbortSignal; + frame(data: string, binary: boolean): void; + ended(code: string, message: string): void; + }): Promise<{ send(data: string, binary: boolean): void; close(): void }> { + await this.ready; + const row = this.rows.find((entry) => entry.serverId === input.serverId); + if (!row) throw new HostError("host_unknown", `Unknown host ${input.serverId}`, input.serverId); + if (!row.enabled) + throw new HostError( + "host_disabled", + `Enable host ${input.serverId} before using it`, + input.serverId, + ); + if (row.status !== "online") + throw new HostError( + row.error?.code ?? "host_offline", + row.error?.message ?? `Host ${input.serverId} is offline`, + input.serverId, + ); + if (this.routes.size >= 128) throw new HostError("resource_limit", "Too many routed sessions"); + const transport = createHostTransportFactory( + row.connection, + this.budget, + )({ url: "ws://host-route/ws" }); + let closed = false; + let verified = false; + let helloSent = false; + const timer = setTimeout(() => route.close("host_offline", "Host handshake timed out"), 15000); + timer.unref(); + const route: Route = { + registrationId: row.registrationId, + close: (code = "host_offline", message = `Host ${input.serverId} disconnected`) => { + if (closed) return; + closed = true; + input.signal.removeEventListener("abort", abort); + clearTimeout(timer); + this.routes.delete(route); + try { + transport.close(); + } finally { + failed(new HostError(code, message, input.serverId)); + input.ended(code, message); + } + }, + }; + this.routes.add(route); + const abort = () => + route.close("host_offline", "Origin session closed or its authority changed"); + input.signal.addEventListener("abort", abort, { once: true }); + let opened!: () => void; + let failed!: (error: unknown) => void; + const ready = new Promise((resolve, reject) => { + opened = resolve; + failed = reject; + }); + if (input.signal.aborted) { + route.close(); + } + transport.onOpen(opened); + transport.onError((error) => { + const authenticationFailed = + error instanceof Error && "code" in error && error.code === "authentication_failed"; + const code = authenticationFailed ? "authentication_failed" : "host_offline"; + const message = authenticationFailed + ? "Host authentication failed. Check the SSH configuration and daemon credentials." + : "Host transport failed"; + failed(new HostError(code, message, input.serverId)); + route.close(code, message); + }); + transport.onClose(() => { + failed(new HostError("host_offline", "Host transport closed")); + route.close(); + }); + transport.onMessage((data, binary) => { + if (closed) return; + if (!verified) { + if (binary) { + route.close(); + return; + } + try { + const envelope = JSON.parse(String(data)); + if (envelope.type === "hello.rejected") { + route.close("authentication_failed", "Replace the host credentials"); + return; + } + const info = + envelope.type === "session" && envelope.message.type === "status" + ? envelope.message.payload + : null; + if (info?.status !== "server_info") return; + if (info.serverId !== row.serverId) { + route.close( + "server_id_mismatch", + "Host identity changed; explicit replacement required", + ); + return; + } + if (info.features?.connectedHosts !== true) { + route.close("unsupported_capability", "Update the destination daemon"); + return; + } + verified = true; + clearTimeout(timer); + } catch { + route.close(); + return; + } + } + input.frame( + binary ? Buffer.from(data as Uint8Array).toString("base64") : String(data), + binary, + ); + }); + await ready; + if (closed) throw new HostError("host_offline", "Host closed during connection"); + return { + close: () => route.close(), + send: (data, binary) => { + if (closed) throw new HostError("host_offline", "Host is offline"); + if (!helloSent) { + if (binary) throw new HostError("invalid_message", "Expected hello"); + const hello = JSON.parse(data) as WSHelloMessage; + if (hello.type !== "hello") throw new HostError("invalid_message", "Expected hello"); + const target = parseHostTarget(row.connection); + const clientId = `route-${createHash("sha256") + .update( + JSON.stringify([ + this.self.serverId, + input.sourceId, + input.originSessionId, + input.scopeId, + ]), + ) + .digest("hex")}`; + transport.send( + JSON.stringify({ + ...hello, + clientId, + routed: true, + producer: { serverId: this.self.serverId, sourceId: input.sourceId }, + sessionPermissions: input.permissions, + auth: target.password ? { kind: "password", password: target.password } : undefined, + }), + ); + helloSent = true; + return; + } + if (!verified) throw new HostError("host_offline", "Host identity has not been verified"); + try { + transport.send(binary ? Buffer.from(data, "base64") : data); + } catch { + route.close("resource_limit", "Host buffer limit reached or transport unavailable"); + } + }, + }; + } + async close(): Promise { + this.closed = true; + await this.ready; + await this.queue; + for (const id of this.live.keys()) this.stop(id); + for (const route of this.routes) route.close(); + this.listeners.clear(); + } +} + +function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] { + if (error instanceof HostError) return { code: error.code, message: error.message }; + if (error instanceof Error && "code" in error && error.code === "authentication_failed") + return { code: "authentication_failed", message: error.message }; + if (authenticationFailed) + return { + code: "authentication_failed", + message: "Host rejected the credentials. Replace the connection credentials.", + }; + return null; +} diff --git a/packages/server/src/server/connected-hosts/session.ts b/packages/server/src/server/connected-hosts/session.ts new file mode 100644 index 0000000000..1c8fd5036d --- /dev/null +++ b/packages/server/src/server/connected-hosts/session.ts @@ -0,0 +1,234 @@ +import type { + SessionInboundMessage, + SessionOutboundMessage, + DaemonPermission, +} from "@getpaseo/protocol/messages"; +import { ConnectedHosts, HostError, type HostActor } from "./index.js"; +import type { SessionDelivery } from "../session/owned-subscriptions/index.js"; + +type HostRequest = Extract< + SessionInboundMessage, + { type: T } +>; +interface SessionRoute { + scopeId: string; + source: object; + send(data: string, binary: boolean): void; + release(): Promise; + fail(code: string, message: string): void; +} + +/** Adapts host capabilities to source-owned session delivery. */ +export class ConnectedHostSession { + private readonly routes = new Map(); + constructor( + private readonly hosts: ConnectedHosts, + private readonly delivery: SessionDelivery, + private readonly actor: HostActor, + private readonly permissions: () => DaemonPermission[], + private readonly emit: (message: SessionOutboundMessage) => void, + private readonly routed: () => boolean, + private readonly originSessionId: string, + private readonly bufferedAmount: (source: object) => number | null, + ) {} + setSource(sourceId: string): void { + this.actor.sourceId = sourceId; + } + dispatch(message: SessionInboundMessage): Promise | undefined { + if (!message.type.startsWith("hosts.")) return; + return this.handle(message); + } + revoke(): void { + for (const route of this.routes.values()) + route.fail( + "permission_denied", + "Caller authority changed. Reconnecting with current grants.", + ); + void this.delivery.releaseFamily("host-route"); + } + private async handle(message: SessionInboundMessage): Promise { + try { + if (this.routed() && message.type.startsWith("hosts.route.")) + throw new HostError( + "unsupported_capability", + "Connected hosts support one hop. Connect to the managing daemon to select a host.", + ); + await this.execute(message); + } catch (error) { + this.fail(message, error); + } + } + private fail(message: SessionInboundMessage, error: unknown): void { + let failure = new HostError( + "host_error", + "Host operation failed. Check the connection and retry.", + ); + if (error instanceof HostError) failure = error; + else if (error instanceof Error && "code" in error && error.code === "invalid_connection") + failure = new HostError("invalid_connection", error.message); + if (message.type === "hosts.route.frame.request") { + const route = this.routes.get(message.routeId); + if (route && route.source === this.delivery.currentSource) + route.fail(failure.code, failure.message); + return; + } + if (!("requestId" in message) || !message.requestId) return; + this.emit({ + type: "rpc_error", + payload: { + requestId: message.requestId, + requestType: message.type, + code: failure.code, + error: failure.message, + }, + }); + } + private async execute(message: SessionInboundMessage): Promise { + const actor = { ...this.actor, manageSources: this.permissions().includes("access.manage") }; + switch (message.type) { + case "hosts.list.request": + return this.list(message); + case "hosts.add.request": + this.emit({ + type: "hosts.add.response", + payload: { + requestId: message.requestId, + host: await this.hosts.add(message.input, actor), + }, + }); + return; + case "hosts.update.request": + this.emit({ + type: "hosts.update.response", + payload: { + requestId: message.requestId, + host: await this.hosts.update(message.registrationId, message.patch, actor), + }, + }); + return; + case "hosts.remove.request": + await this.hosts.remove(message.registrationId, actor); + this.emit({ type: "hosts.remove.response", payload: { requestId: message.requestId } }); + return; + case "hosts.route.open.request": + return this.open(message); + case "hosts.route.frame.request": { + const route = this.routes.get(message.routeId); + if (!route || route.source !== this.delivery.currentSource) + throw new HostError("host_unknown", "Unknown routed session"); + route.send(message.data, message.binary); + return; + } + case "hosts.route.close.request": { + const route = message.routeId + ? this.routes.get(message.routeId) + : [...this.routes.values()].find( + (entry) => + entry.scopeId === message.scopeId && entry.source === this.delivery.currentSource, + ); + if (route && route.source === this.delivery.currentSource) await route.release(); + return; + } + } + } + private async list(message: HostRequest<"hosts.list.request">): Promise { + if (!message.observe) { + this.emit({ + type: "hosts.list.response", + payload: { requestId: message.requestId, directory: await this.hosts.list() }, + }); + return; + } + let stop = () => {}; + const owner = this.delivery.begin("hosts", undefined, () => stop()); + try { + stop = this.hosts.observe(() => { + void this.hosts + .list() + .then((directory) => { + owner.emit({ type: "hosts.changed", payload: { subscriptionId: owner.id, directory } }); + return undefined; + }) + .catch(() => owner.release()); + }); + this.emit({ + type: "hosts.list.response", + payload: { + requestId: message.requestId, + subscriptionId: owner.id, + directory: await this.hosts.list(), + }, + }); + } catch (error) { + await owner.release(); + throw error; + } + } + private async open(message: HostRequest<"hosts.route.open.request">): Promise { + if (this.delivery.subscriptionIds(this.delivery.currentSource!, "host-route").length >= 32) + throw new HostError("resource_limit", "Too many routed sessions for this caller"); + let close = () => {}; + const owner = this.delivery.begin("host-route", undefined, () => { + this.routes.delete(owner.id); + close(); + }); + const fail = (code: string, text: string) => { + owner.emit({ + type: "hosts.route.closed", + payload: { subscriptionId: owner.id, code, message: text }, + }); + void owner.release(); + }; + this.routes.set(owner.id, { + scopeId: message.scopeId, + source: owner.source, + send: () => { + throw new HostError("host_offline", "Host is connecting"); + }, + release: owner.release, + fail, + }); + try { + const route = await this.hosts.openRoute({ + serverId: message.serverId, + scopeId: message.scopeId, + sourceId: this.actor.sourceId, + originSessionId: this.originSessionId, + permissions: this.permissions(), + signal: owner.signal, + frame: (data, binary) => { + const size = Buffer.byteLength(data) + 512; + const buffered = this.bufferedAmount(owner.source); + if (buffered === null || buffered + size > 32 * 1024 * 1024) { + fail("resource_limit", "The receiving client cannot keep up with host output"); + return; + } + owner.emit({ + type: "hosts.route.frame", + payload: { subscriptionId: owner.id, data, binary }, + }); + }, + ended: fail, + }); + close = route.close; + if (owner.signal.aborted) { + route.close(); + return; + } + this.routes.set(owner.id, { + scopeId: message.scopeId, + source: owner.source, + send: route.send, + release: owner.release, + fail, + }); + this.emit({ + type: "hosts.route.open.response", + payload: { requestId: message.requestId, routeId: owner.id }, + }); + } catch (error) { + await owner.release(); + throw error; + } + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 87949594aa..2495c98f82 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,4 +1,6 @@ import { AgentDirectory } from "./agent/directory.js"; +import { ConnectedHostSession } from "./connected-hosts/index.js"; +import type { ConnectedHosts } from "./connected-hosts/index.js"; import { searchTimeline } from "./agent/chat-search/index.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; @@ -442,6 +444,9 @@ const nodeSessionFileSystem: SessionFileSystem = { type AgentMcpTransportFactory = () => Promise; export interface SessionOptions { + connectedHosts?: ConnectedHosts; + sourceId?: string; + routed?: boolean; browserToolsBroker?: BrowserToolsBroker | null; clientId: string; permissions: readonly DaemonPermission[]; @@ -452,6 +457,7 @@ export interface SessionOptions { onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; getTransportBufferedAmount?: (source?: object) => number | null; + getConnectedHostBufferedAmount?: () => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise; publishScriptStatusUpdate?: (message: ScriptStatusUpdateMessage) => void; @@ -674,6 +680,22 @@ interface ClientActivity { } export class Session { + private readonly connectedHostSession: ConnectedHostSession | null; + private routed = false; + isRouted(): boolean { + return this.routed; + } + getPrincipalId(): string { + return this.hostPrincipalId; + } + private hostPrincipalId: string; + setHostSource(sourceId: string): void { + this.hostPrincipalId = sourceId; + this.connectedHostSession?.setSource(sourceId); + } + setRouted(routed: boolean): void { + this.routed = routed; + } readonly delivery = new SessionDelivery( (source, message) => { if (!this.authorization.allowsOutbound(message)) return; @@ -864,9 +886,12 @@ export class Session { this.browserToolsBroker = options.browserToolsBroker; this.clientId = clientId; this.authorization = new SessionAuthorization(permissions); + this.appVersion = appVersion ?? null; this.clientCapabilities = parseClientCapabilities(clientCapabilities); this.sessionId = uuidv4(); + this.hostPrincipalId = hostSource(options); + this.connectedHostSession = createHostSession(options, this); this.onMessage = onMessage; this.onMessageToSource = onMessageToSource ?? null; this.onBinaryMessage = onBinaryMessage ?? null; @@ -2173,7 +2198,7 @@ export class Session { requestId, requestType: msg.type, error: `Session is not authorized for ${msg.type}`, - code: "access_denied", + code: msg.type.startsWith("hosts.") ? "permission_denied" : "access_denied", }, }); } @@ -2220,6 +2245,7 @@ export class Session { public setPermissions(permissions: readonly DaemonPermission[]): void { this.authorization.replacePermissions(permissions); + this.connectedHostSession?.revoke(); if (!this.authorization.allowsPermission("workspace.write")) { void this.delivery .releaseFamily("browser-host") @@ -2374,11 +2400,10 @@ export class Session { private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise { const promise = + this.connectedHostSession?.dispatch(msg) ?? this.dispatchSubscriptionMessage(msg, source) ?? this.dispatchVoiceAndControlMessage(msg) ?? - this.dispatchAgentRewindMessage(msg, source) ?? - this.dispatchAgentRelationshipMessage(msg) ?? - this.dispatchAgentTimelineMessage(msg, source) ?? + this.dispatchAgentObservationMessage(msg, source) ?? this.dispatchHubExecutionMessage(msg) ?? this.dispatchCreationMessage(msg, source) ?? this.dispatchAgentLifecycleMessage(msg) ?? @@ -2396,6 +2421,17 @@ export class Session { if (promise) await promise; } + private dispatchAgentObservationMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { + return ( + this.dispatchAgentRewindMessage(msg, source) ?? + this.dispatchAgentRelationshipMessage(msg) ?? + this.dispatchAgentTimelineMessage(msg, source) + ); + } + private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { return ( this.dispatchWorkspaceStateMessage(msg) ?? @@ -8765,3 +8801,21 @@ function legacyWantsEvent( return true; } } + +function createHostSession(options: SessionOptions, session: Session): ConnectedHostSession | null { + if (!options.connectedHosts) return null; + return new ConnectedHostSession( + options.connectedHosts, + session.delivery, + { sourceId: options.sourceId ?? "owner", manageSources: false }, + () => session.getPermissions(), + (message) => session.publish(message), + () => session.isRouted(), + session.getSessionId(), + () => options.getConnectedHostBufferedAmount?.() ?? 0, + ); +} + +function hostSource(options: SessionOptions): string { + return options.sourceId ?? "owner"; +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b95e7696e2..694887b5f1 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,4 @@ +import { ConnectedHosts } from "./connected-hosts/index.js"; import { stat } from "node:fs/promises"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import { CreationService } from "./creation/index.js"; @@ -437,6 +438,7 @@ export interface WebSocketLike { } interface SessionConnectionBase { + permissionCeiling?: readonly DaemonPermission[]; session: Session; principalId: string; sessionKey: string; @@ -460,6 +462,7 @@ interface PluginSessionConnection extends SessionConnectionBase { type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection; interface SocketSessionOptions { + sourceId?: string; clientId: string; appVersion: string | null; clientCapabilities: Record | null; @@ -550,6 +553,7 @@ export class VoiceAssistantWebSocketServer { private readonly workspaceAutoName: WorkspaceAutoName; private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; + private readonly connectedHosts: ConnectedHosts; private readonly passwordHash: string | undefined; private readonly credentialSource: DaemonAuthConfig | undefined; private readonly worktreesRoot: string | undefined; @@ -701,6 +705,7 @@ export class VoiceAssistantWebSocketServer { this.workspaceAutoName = workspaceAutoName; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; + this.connectedHosts = new ConnectedHosts(paseoHome, { serverId, label: getHostName() }); this.passwordHash = auth?.password; this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; @@ -1054,7 +1059,11 @@ export class VoiceAssistantWebSocketServer { } for (const connection of new Set(this.externalSessionsByKey.values())) { if (connection.principalId === principalId) { - connection.session.setPermissions(permissions); + connection.session.setPermissions( + connection.permissionCeiling + ? permissions.filter((permission) => connection.permissionCeiling!.includes(permission)) + : permissions, + ); } } } @@ -1071,6 +1080,7 @@ export class VoiceAssistantWebSocketServer { public async close(): Promise { this.prepareForShutdown(); + await this.connectedHosts.close(); this.unsubscribeSpeechReadiness?.(); this.unsubscribeSpeechReadiness = null; this.unsubscribeDaemonConfigChange?.(); @@ -1394,6 +1404,10 @@ export class VoiceAssistantWebSocketServer { appVersion, clientCapabilities, permissions: admission.permissions, + sourceId: + lifecycle.kind === "ephemeral-plugin" + ? `plugin:${lifecycle.pluginId}` + : admission.principalId, connectionLogger, onMessage: (msg) => { if (!connection) { @@ -1463,6 +1477,16 @@ export class VoiceAssistantWebSocketServer { private createSocketSession(options: SocketSessionOptions): Session { return new Session({ + connectedHosts: this.connectedHosts, + getConnectedHostBufferedAmount: () => { + let total = 0; + for (const socket of this.sessions.keys()) { + if (typeof socket.bufferedAmount !== "number") return null; + total += socket.bufferedAmount; + } + return total; + }, + sourceId: options.sourceId, browserToolsBroker: this.browserToolsBroker, clientId: options.clientId, appVersion: options.appVersion, @@ -1616,8 +1640,16 @@ export class VoiceAssistantWebSocketServer { } this.clearPendingConnection(ws); - const admitted = pending.admission; + let admitted = pending.admission; if (!admitted) throw new Error("Admitted hello has no session admission"); + if (message.sessionPermissions) + admitted = { + ...admitted, + permissions: admitted.permissions.filter((permission) => + message.sessionPermissions!.includes(permission), + ), + }; + pending.admission = admitted; pending.identity.clientId = clientId; if (message.appVersion) { pending.identity.appVersion = message.appVersion; @@ -1640,6 +1672,9 @@ export class VoiceAssistantWebSocketServer { lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" }, admission: admitted, }); + connection.permissionCeiling = message.sessionPermissions; + connection.session.setRouted(message.routed === true); + this.assignHostSource(connection, message, admitted.principalId, pluginId); this.sessions.set(ws, connection); if (connection.lifecycle === "reconnectable") { this.externalSessionsByKey.set(sessionKey, connection); @@ -1755,6 +1790,10 @@ export class VoiceAssistantWebSocketServer { clearTimeout(existing.externalDisconnectCleanupTimeout); existing.externalDisconnectCleanupTimeout = null; } + existing.permissionCeiling = message.sessionPermissions; + existing.session.setPermissions(pending.admission!.permissions); + existing.session.setRouted(message.routed === true); + this.assignHostSource(existing, message, pending.admission!.principalId); const newAppVersion = message.appVersion ?? null; if (newAppVersion && newAppVersion !== existing.appVersion) { existing.appVersion = newAppVersion; @@ -1785,6 +1824,21 @@ export class VoiceAssistantWebSocketServer { ); } + private assignHostSource( + connection: SessionConnection, + hello: WSHelloMessage, + principalId: string, + pluginId?: string, + ): void { + if (pluginId) return; + const producer = hello.producer; + const sourceId = + hello.routed && producer + ? `route:${[principalId, producer.serverId, producer.sourceId].map(encodeURIComponent).join(":")}` + : principalId; + connection.session.setHostSource(sourceId); + } + private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload { return { status: "server_info", @@ -1792,11 +1846,13 @@ export class VoiceAssistantWebSocketServer { serverId: this.serverId, hostname: getHostName(), version: this.daemonVersion, + principalId: session.getPrincipalId(), permissions: session.getPermissions(), // COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16. desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { + connectedHosts: true, usageSources: true, ownedSubscriptions: true, agentRequestReceipts: true, @@ -2086,6 +2142,10 @@ export class VoiceAssistantWebSocketServer { this.finishPluginSocketCleanup(ws); return; } + if (connection.session.isRouted()) { + await this.cleanupConnection(connection, "Routed session disconnected"); + return; + } this.incrementRuntimeCounter("sessionDisconnectedWaitingReconnect"); if (connection.externalDisconnectCleanupTimeout) { clearTimeout(connection.externalDisconnectCleanupTimeout); diff --git a/public-docs/cli.md b/public-docs/cli.md index f118d35894..dd61ef60ae 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -64,6 +64,31 @@ Entries may also include `loginErrors`. Structured output preserves numeric valu A completed query exits with status `0`, including when individual reports are unavailable or failed. Connection or request failures, unsupported hosts, unknown report IDs, and conflicting selectors exit with status `1` and write the error to stderr. Human output says **No usage reports returned** for an empty result; this does not establish whether account discovery succeeded for every source. +## Connected hosts + +`--host` selects the daemon whose saved connections you manage. Registrations use their +`registrationId`, not their label. + +```sh +paseo --host localhost:6767 host add 'ssh://deploy@build-box' --label build-box --meta team=infra +paseo --host localhost:6767 host ls --json +paseo --host localhost:6767 host inspect hst_123 +paseo --host localhost:6767 host update hst_123 --meta team=tools --remove-meta obsolete +paseo --host localhost:6767 host update hst_123 --connection 'ssh://deploy@new-address' +paseo --host localhost:6767 host disable hst_123 +paseo --host localhost:6767 host enable hst_123 +paseo --host localhost:6767 host rm hst_123 +``` + +Add accepts TCP/host-and-port, `ws://`/`wss://`, local socket/pipe, SSH, and relay/pairing URLs. +SSH connects to an already running daemon. Add `?password=...` to an SSH URI when that daemon requires a password (`&password=...` after `daemonPort`). Unreachable targets remain saved and retry automatically. +`ls` includes disabled and failed hosts. `inspect` includes safe connection details and recovery guidance. +JSON output never includes connection credentials. + +`update --meta` replaces supplied keys; `--remove-meta` removes keys. Replacing a connection retains +the pinned daemon identity. Add `--replace-identity` with `--connection` only after verifying a changed +destination. `rm` removes the saved connection; agents and workspaces on that host keep running. + ## Provider diagnostics Ask the daemon to inspect the provider environment it actually uses: diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 7a05329116..676e724e48 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -26,6 +26,26 @@ Local plugins are directory sources installed into one Paseo daemon. A plugin ca Plugin code is trusted and unsandboxed. Client surfaces run in the Paseo app. Backend contributions run in a subprocess with access to the daemon machine, including its files, processes, credentials, and network. +## Connected hosts + +`usePaseo().hosts` in a client surface and `context.paseo.hosts` in a server handler or lifecycle hook +use the same [SDK host API](/sdk/reference#clienthosts). They describe the selected daemon's saved +connections. Existing `useHosts()` and `getPaseoClient(serverId)` keep describing app-known hosts. + +```ts +const remote = await context.paseo.hosts.client("srv_build_box"); +try { + return await remote.workspaces.list(); +} finally { + await remote.dispose(); +} +``` + +A returned API owns its observations. Teardown of the plugin API also disposes derived scopes. +Keep remote calls inside the handler or lifecycle scope that owns them. Calling B does not forward +B's plugin lifecycle hooks to A. Registrations created by a routed plugin retain a source qualified +by the authenticated link and originating plugin. + ## Project files `paseo plugin init /absolute/path/to/my-plugin` creates: diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 76490974fe..5f140d2aa1 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -203,6 +203,57 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options `config.patch(patch, requestId?)` validates, persists, and returns an updated configuration. Use this administrative surface for host configuration, not per-agent choices. A patch affects every client and future agent using that daemon. +## `client.hosts` + +Manage hosts connected to the selected daemon. Both the managing daemon and destination must support connected hosts. + +```ts +const directory = await client.hosts.list(); +const registration = await client.hosts.add({ + connection: "ssh://deploy@build-box", + label: "Build box", + metadata: { team: "infra" }, + sourceKey: "build-box", +}); +await client.hosts.update(registration.registrationId, { enabled: false }); +await client.hosts.remove(registration.registrationId); +``` + +`list()` returns `{ self: { serverId, label }, hosts }`. Each host includes its registration and server +IDs, label, authenticated source, string metadata, enabled state, connection status, safe target +description, last-online/retry timestamps, and an actionable error. Credentials are never returned. +A valid but unreachable connection is saved and retried. Invalid syntax fails without saving. + +`update(registrationId, patch)` accepts `label`, `metadata`, `enabled`, `connection`, and +`replaceIdentity`. Metadata replaces the whole map. Replacing a connection preserves the expected +server ID unless `replaceIdentity: true` explicitly accepts a new destination. + +`observe()` returns an owned subscription. Await `ready`, subscribe for snapshots and `hosts.changed` +updates, then call `release()`. Observations restore a current snapshot after reconnect. + +```ts +const remote = await client.hosts.client("srv_build_box"); +try { + const workspaces = await remote.workspaces.list(); + const providers = await remote.providers.listAvailable(); + await remote.agents.ref("agent_on_build_box").send("Continue the investigation"); +} finally { + await remote.dispose(); +} +``` + +`client(serverId)` returns a caller-owned `PaseoApi` bound to that destination through the selected +daemon. The ordinary APIs keep their signatures. Paths and IDs belong to the destination. Selecting +the local server ID returns a local scope. Routing supports one hop. + +Disposing one scope releases its observations without closing siblings or deleting the saved host. +Origin disposal cascades. Offline operations fail; observations recover. A lost result reports +`outcome_unknown` and the destination server ID. Inspect before retrying a mutation. + +Host failures expose a stable `code`: `host_unknown`, `host_disabled`, `host_offline`, +`permission_denied`, `unsupported_capability`, `authentication_failed`, `server_id_mismatch`, +`self_host`, or `duplicate_host`. Discovery does not grant execution authority. + ## Errors and cleanup Connection, validation, rejection, and timeout failures reject their promise. Turn outcomes are returned through `PaseoAgentRunResult.status` because permission and provider errors are expected agent states. From 630e8301adc4afaf96fe6b17df78cf30e4a1d4c4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 19 Oct 2026 08:37:15 +0200 Subject: [PATCH 02/12] fix(hosts): validate safe targets and verify encrypted relay routes --- docs/connected-hosts.md | 1 + .../e2e/support/helpers/connected-hosts.ts | 3 + packages/app/src/connected-hosts/index.tsx | 26 ++- packages/cli/src/utils/client.ts | 6 +- packages/cli/src/utils/daemon-target.test.ts | 18 +- packages/cli/src/utils/daemon-target.ts | 10 +- packages/protocol/src/host-target.test.ts | 42 +++- packages/protocol/src/host-target.ts | 24 +- packages/protocol/src/messages.ts | 51 ++-- packages/server/package.json | 2 +- .../server/connected-hosts/hosts.e2e.test.ts | 27 +++ .../daemon-e2e/relay-transport.e2e.test.ts | 219 +++++++++++++++++- packages/server/src/server/session.ts | 10 +- .../websocket-server.relay-reconnect.test.ts | 28 ++- .../server/src/server/websocket-server.ts | 2 +- 15 files changed, 412 insertions(+), 57 deletions(-) diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index f30e81dca4..b4b99f6616 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -25,6 +25,7 @@ permissions closes existing routes. A routed API cannot select a second hop. A producer that registers a host through a routed API is qualified on B as `route:::`, with each component percent-encoded. This namespace belongs to the authenticated link principal. An ordinary direct B consumer does not become that producer by supplying a source key. +The optional `server_info.sourceId` reports this producer identity for source-owner controls; it does not describe the authenticated admission principal. Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature. ## Connections and recovery diff --git a/packages/app/e2e/support/helpers/connected-hosts.ts b/packages/app/e2e/support/helpers/connected-hosts.ts index d57a5a0498..70c288e4c4 100644 --- a/packages/app/e2e/support/helpers/connected-hosts.ts +++ b/packages/app/e2e/support/helpers/connected-hosts.ts @@ -28,6 +28,9 @@ export async function correctConnection(page: Page, value: string) { await saveConnectedHost(page); } export async function inspectConnectedHost(page: Page, label: string) { + await expect( + page.getByText(/^(connecting|offline|error|online|disabled) · (Enabled|Disabled) · User$/), + ).toBeVisible(); await page.getByRole("button", { name: `Details for ${label}`, exact: true }).click(); await expect(page.getByText("Connected host details", { exact: true })).toBeVisible(); } diff --git a/packages/app/src/connected-hosts/index.tsx b/packages/app/src/connected-hosts/index.tsx index 2dbdff5e8d..e1966f1508 100644 --- a/packages/app/src/connected-hosts/index.tsx +++ b/packages/app/src/connected-hosts/index.tsx @@ -85,7 +85,7 @@ function ConnectedHosts({ client, connected }: { client: DaemonClient; connected model={model} canManage={canManage} manageSources={info?.permissions?.includes("access.manage") === true} - sourceId={info?.principalId} + sourceId={info?.sourceId} pending={state.pending !== null} /> ) : null} @@ -151,7 +151,7 @@ function HostRow({ return ( - - - ) : null} - {directory ? ( - - ) : null} - {directory && canManage && !editor ? ( - - ) : null} - {editor ? ( - - ) : null} - {state.error ? ( - - - - - - ) : null} - {state.pending ? ( - - Saving host change… - - ) : null} - - ); -} -function HostList({ - directory, - model, - canManage, - pending, -}: { - directory: HostDirectory; - model: ConnectedHostsModel; - canManage: boolean; - pending: boolean; -}) { - return ( - - {directory.hosts.length === 0 ? ( - - ) : ( - directory.hosts.map((host) => ( - - )) - )} - - ); -} -function HostRow({ - host, - model, - canManage, - pending, -}: { - host: HostSummary; - model: ConnectedHostsModel; - canManage: boolean; - pending: boolean; -}) { - const compact = useIsCompactFormFactor(); - const edit = useCallback(() => model.edit(host), [host, model]); - const toggle = useCallback(() => void model.setEnabled(host), [host, model]); - return ( - - - - {canManage ? ( - - ) : null} - - - ); -} -function HostDetails({ host }: { host: HostSummary }) { - return ( - - - - - - - - - - - - {host.error ? : null} - - ); -} -function HostEditor({ - editor, - directory, - model, - canManage, - manageSources, - sourceId, - pending, -}: { - editor: Editor; - directory: HostDirectory | null; - model: ConnectedHostsModel; - canManage: boolean; - manageSources: boolean; - sourceId: string | undefined; - pending: boolean; -}) { - const compact = useIsCompactFormFactor(); - const detail = - directory?.hosts.find((host) => host.registrationId === editor.host?.registrationId) ?? - editor.host; - const editSource = !detail || manageSources || detail.source.id === sourceId; - const remove = useCallback(async () => { - if (!detail) return; - const confirmed = await confirmDialog({ - title: "Remove connected host?", - message: - detail.source.id === "owner" - ? "This removes the saved connection. The host's agents and workspaces keep running." - : `Managed by ${detail.source.id}. Its source can add it again. Disable the host to keep access off.`, - confirmLabel: "Remove", - destructive: true, - }); - if (confirmed) await model.remove(detail); - }, [detail, model]); - return ( - - {detail ? "Connected host details" : "Add host"} - {detail ? : null} - {canManage ? ( - - - {editSource ? ( - - ) : null} - {detail && editSource ? ( - - ) : null} - - ) : null} - {canManage && editSource ? ( - - - - ) : null} - - {canManage ? ( - - ) : null} - - {canManage && detail && editSource ? ( - - ) : null} - - - ); -} -const styles = StyleSheet.create((theme) => ({ - section: { gap: theme.spacing[3] }, - actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] }, -})); - -function sourceLabel(sourceId: string): string { - if (sourceId === "owner") return "User"; - if (sourceId.startsWith("plugin:")) return `Plugin: ${sourceId.slice("plugin:".length)}`; - if (sourceId.startsWith("route:")) { - const components = sourceId.split(":"); - let producer: string; - try { - producer = decodeURIComponent(components[3] ?? ""); - } catch { - return "User (via host)"; - } - return producer.startsWith("plugin:") - ? `Plugin: ${producer.slice("plugin:".length)} (via host)` - : "User (via host)"; - } - return "User"; -} diff --git a/packages/app/src/connected-hosts/model.ts b/packages/app/src/connected-hosts/model.ts deleted file mode 100644 index 24fd41211f..0000000000 --- a/packages/app/src/connected-hosts/model.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { - PaseoApi, - HostSummary, - HostUpdate, - HostDirectory, - OwnedSubscription, -} from "@getpaseo/client"; - -export interface Editor { - key: number; - host: HostSummary | null; - connection: string; - label: string; - metadata: string; - replaceIdentity: boolean; -} -type Load = - | { status: "loading" } - | { status: "error"; error: string } - | { status: "loaded"; directory: HostDirectory }; -interface State { - load: Load; - editor: Editor | null; - pending: string | null; - error: string | null; -} - -/** Owns fetch/observation, form sequencing and mutation state outside React. */ -export class ConnectedHostsModel { - private state: State = { load: { status: "loading" }, editor: null, pending: null, error: null }; - private readonly listeners = new Set<() => void>(); - private observation?: OwnedSubscription; - private generation = 0; - private editorKey = 0; - private connected = false; - setConnected = (connected: boolean) => { - this.connected = connected; - if (connected && !this.observation) this.start(); - else if (!connected) - this.publish({ - load: { - status: "error", - error: - "Connection to this daemon was lost. Reconnect to continue; your edits are retained.", - }, - }); - }; - constructor(private readonly api: PaseoApi) {} - getState = () => this.state; - subscribe = (listener: () => void) => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - private publish(patch: Partial) { - this.state = { ...this.state, ...patch }; - for (const listener of this.listeners) listener(); - } - start = () => { - if (!this.connected) { - this.publish({ - load: { status: "error", error: "Connect to this daemon to load its hosts." }, - }); - return; - } - const generation = ++this.generation; - void this.observation?.release(); - this.publish({ load: { status: "loading" } }); - try { - const observation = this.api.hosts.observe(); - this.observation = observation; - observation.subscribe({ - snapshot: (directory) => { - if (generation === this.generation) - this.publish({ load: { status: "loaded", directory } }); - }, - update: (message) => { - if (generation === this.generation && message.type === "hosts.changed") - this.publish({ load: { status: "loaded", directory: message.payload.directory } }); - }, - error: (error) => { - if (generation === this.generation) - this.publish({ load: { status: "error", error: describe(error) } }); - }, - }); - void observation.ready.catch((error) => { - if (generation === this.generation) - this.publish({ load: { status: "error", error: describe(error) } }); - }); - } catch (error) { - this.publish({ load: { status: "error", error: describe(error) } }); - } - }; - close = () => { - this.generation++; - void this.observation?.release(); - void this.api.dispose(); - this.listeners.clear(); - }; - edit = (host: HostSummary | null) => { - if (this.state.pending) return; - this.publish({ - error: null, - editor: { - key: ++this.editorKey, - host, - connection: "", - label: host?.label ?? "", - metadata: JSON.stringify(host?.metadata ?? {}, null, 2), - replaceIdentity: false, - }, - }); - }; - add = () => this.edit(null); - setLabel = (label: string) => this.set({ label }); - setConnection = (connection: string) => this.set({ connection }); - setMetadata = (metadata: string) => this.set({ metadata }); - setReplaceIdentity = (replaceIdentity: boolean) => this.set({ replaceIdentity }); - set = (patch: Partial>) => { - if (this.state.editor) this.publish({ editor: { ...this.state.editor, ...patch } }); - }; - cancel = () => { - if (!this.state.pending) this.publish({ editor: null, error: null }); - }; - dismissError = () => this.publish({ error: null }); - private async mutate(id: string, operation: () => Promise): Promise { - if (this.state.pending) return; - this.publish({ pending: id, error: null }); - try { - await operation(); - this.publish({ - load: { status: "loaded", directory: await this.api.hosts.list() }, - editor: null, - }); - } catch (error) { - this.publish({ error: describe(error) }); - } finally { - this.publish({ pending: null }); - } - } - save = () => - this.mutate("save", async () => { - const editor = this.state.editor; - if (!editor) return; - let metadata: Record; - try { - const value: unknown = JSON.parse(editor.metadata); - if ( - !value || - typeof value !== "object" || - Array.isArray(value) || - Object.values(value).some((entry) => typeof entry !== "string") - ) - throw new Error("invalid"); - metadata = value as Record; - } catch { - throw new Error("Metadata must be a JSON object with string values."); - } - if (editor.host) { - const patch: HostUpdate = { - label: editor.label.trim() || null, - replaceIdentity: editor.replaceIdentity, - }; - if (JSON.stringify(metadata) !== JSON.stringify(editor.host.metadata)) - patch.metadata = metadata; - if (editor.connection.trim()) { - patch.connection = editor.connection.trim(); - } - await this.api.hosts.update(editor.host.registrationId, patch); - } else - await this.api.hosts.add({ - connection: editor.connection.trim(), - label: editor.label.trim() || undefined, - metadata, - }); - }); - setEnabled = (host: HostSummary) => - this.mutate(host.registrationId, () => - this.api.hosts.update(host.registrationId, { enabled: !host.enabled }), - ); - remove = (host: HostSummary) => - this.mutate(host.registrationId, () => this.api.hosts.remove(host.registrationId)); -} -function describe(error: unknown): string { - if ( - error && - typeof error === "object" && - "userMessage" in error && - typeof error.userMessage === "string" - ) - return error.userMessage; - return error instanceof Error ? error.message : "Host operation failed. Retry when connected."; -} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 958c90f0d0..6b308547a1 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -1,4 +1,3 @@ -import { ConnectedHostsSection } from "@/connected-hosts"; import { ArrowDown, ArrowUp, @@ -375,7 +374,6 @@ export function HostSettingsPage({ - {isLocalDaemon ? : null} From 71cc853f7bfac87a310d5f3006521bf307b19d9f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 20 Oct 2026 07:32:37 +0200 Subject: [PATCH 09/12] test(hosts): control physical write backpressure across platforms Hold real TCP writes until assertions finish instead of assuming a paused receiver exhausts every platform receive buffer. Preserve physical send settlement, byte identity, capacity and sibling checks. --- .../server/src/server/connected-hosts/hosts.e2e.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 1cf36a3bfd..a3f3e3fd22 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -626,6 +626,7 @@ test("resuming an A session with narrower grants terminates existing routes and import { WebSocketServer, WebSocket } from "ws"; import { createServer } from "node:http"; +import type { Socket } from "node:net"; import { once } from "node:events"; import { hashSync } from "bcryptjs"; @@ -978,12 +979,14 @@ test("channel output accounts for escaped text, awaits physical drain, and prese const address = http.address(); if (!address || typeof address === "string") throw new Error("Missing port"); const accepted = once(wss, "connection"); + const tcpAccepted = once(http, "connection"); const receiver = new WebSocket(`ws://127.0.0.1:${address.port}`); const received: { binary: boolean; bytes: Buffer }[] = []; receiver.on("message", (bytes, binary) => received.push({ bytes: Buffer.from(bytes as Buffer), binary }), ); const [physical] = (await accepted) as [WebSocket]; + const [tcp] = (await tcpAccepted) as [Socket]; await once(receiver, "open"); cleanup.push(async () => { receiver.terminate(); @@ -1043,6 +1046,9 @@ test("channel output accounts for escaped text, awaits physical drain, and prese .poll(() => received.some((frame) => !frame.binary && frame.bytes.includes("sibling-before"))) .toBe(true); receiver.pause(); + // Hold real physical writes independently of the OS receive-buffer size. + // A paused receiver alone can still accept the entire frame on Windows. + tcp.cork(); let completed = false; const bytes = new Uint8Array(8 * 1024 * 1024).fill(173); const pending = channels[1]!.send(bytes).then(() => { @@ -1053,6 +1059,7 @@ test("channel output accounts for escaped text, awaits physical drain, and prese expect(completed).toBe(false); expect(physical.bufferedAmount).toBeLessThan(64 * 1024 * 1024); receiver.resume(); + tcp.uncork(); await pending; await channels[1]!.send("sibling-after"); await expect From 569b34263d356d31fe84c592cf013fd716e736be Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 20 Oct 2026 07:54:42 +0200 Subject: [PATCH 10/12] refactor(hosts): use the ordinary client contract over channel transports Keep queuing, permission codes and reconnection independent of transport selection. Track submitted request outcomes generically while retaining connection error codes and structured causes. --- docs/connected-hosts.md | 11 +- packages/client/src/daemon-client.test.ts | 128 ++++++++++++++++++ packages/client/src/daemon-client.ts | 111 +++++++++------ packages/client/src/hosts/index.ts | 23 ++-- .../server/connected-hosts/hosts.e2e.test.ts | 101 ++++++++++---- .../src/server/connected-hosts/index.ts | 1 - .../daemon-e2e/relay-transport.e2e.test.ts | 3 +- public-docs/sdk/reference.md | 14 +- 8 files changed, 304 insertions(+), 88 deletions(-) diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index 22ba2443f9..088a5218a7 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -43,9 +43,14 @@ operations; requesting this feature gives update guidance. Disposing a routed API releases its observations and session. It does not remove the saved host or affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore -through the usual owned-subscription contract; requests made while offline fail. Losing a response -returns `outcome_unknown` with the destination ID. Inspect the destination before retrying a mutation. -No mutation is automatically replayed. +through the usual owned-subscription contract. The ordinary client queues new requests while connecting +within its existing deadline and rejects requests while disconnected. Connection loss clears old +waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError` +(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination +before retrying a mutation. +Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent +creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission +RPC codes pass through unchanged, and connection/recovery behavior matches direct clients. Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8 channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 960b19f4d7..d30a2f4aad 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3,6 +3,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; import { DaemonClient, + DaemonConnectionError, supportsUsageReports, type DaemonClientTrace, type CreateAgentRequestOptions, @@ -7847,3 +7848,130 @@ test("measures an outbound message in UTF-8 bytes against the daemon's message l expect(mock.sent).toHaveLength(1); expect(client.getConnectionState().status).toBe("connected"); }); + +test("ordinary lifecycle queues new calls, distinguishes submitted outcomes, and never resends lost requests", async () => { + const transports = [createMockTransport(), createMockTransport(), createMockTransport()]; + let attempt = 0; + const client = new DaemonClient({ + url: "ws://transport", + clientId: "ordinary-lifecycle", + reconnect: { enabled: false }, + transportFactory: () => transports[attempt++]!.transport, + }); + clients.push(client); + const first = transports[0]!; + const connecting = client.connect(); + const initial = client.getDaemonConfig("initial").catch((error: unknown) => error); + expect(first.sent).toEqual([]); + first.triggerOpen(); + await connecting; + expect(parseSentFrame(first.sent[0]).requestId).toBe("initial"); + first.triggerMessage( + wrapSessionMessage({ + type: "rpc_error", + payload: { requestId: "initial", error: "Denied", code: "access_denied" }, + }), + ); + expect(await initial).toMatchObject({ code: "access_denied", serverId: "srv_test_1" }); + const submitted = client + .patchDaemonConfig({ pluginsEnabled: false }, "submitted") + .catch((error: unknown) => error); + first.triggerClose({ code: 1001, reason: "physical loss" }); + const unknown = await submitted; + expect(unknown).toBeInstanceOf(DaemonConnectionError); + expect(unknown).toMatchObject({ + code: "DAEMON_CONNECTION_LOST", + outcome: "unknown", + serverId: "srv_test_1", + }); + + expect(() => client.ensureConnected()).not.toThrow(); + const second = transports[1]!; + const neverSent = client + .patchDaemonConfig({ pluginsEnabled: true }, "never-sent") + .catch((error: unknown) => error); + expect(second.sent).toEqual([]); + second.triggerClose({ code: 1001, reason: "handshake lost" }); + const unavailable = await neverSent; + expect(unavailable).toBeInstanceOf(DaemonConnectionError); + expect(unavailable).toMatchObject({ code: "DAEMON_CONNECTION_LOST", serverId: "srv_test_1" }); + expect((unavailable as DaemonConnectionError).outcome).toBeUndefined(); + + client.ensureConnected(); + const third = transports[2]!; + const recovery = client.getDaemonConfig("recovery").catch((error: unknown) => error); + expect(third.sent).toEqual([]); + third.triggerOpen(); + expect(third.sent.map(parseSentFrame).map((frame) => frame.requestId)).toEqual(["recovery"]); + third.triggerMessage( + wrapSessionMessage({ + type: "rpc_error", + payload: { requestId: "recovery", error: "Denied", code: "access_denied" }, + }), + ); + expect(await recovery).toMatchObject({ code: "access_denied" }); +}); + +test.each([false, true])( + "synchronous send rejection leaves no unknown outcome after close (close during handoff: %s)", + async (closeDuringSend) => { + const first = createMockTransport(); + const second = createMockTransport(); + const rejected = Object.assign(new Error("Transport rejected this send"), { + code: "resource_limit", + }); + const originalSend = first.transport.send; + first.transport.send = (data) => { + if (typeof data === "string" && data.includes("set_daemon_config_request")) { + if (closeDuringSend) first.triggerClose({ code: 1001, reason: "closed during send" }); + throw rejected; + } + originalSend(data); + }; + let attempt = 0; + const client = new DaemonClient({ + url: "ws://transport", + clientId: "rejected-send", + reconnect: { enabled: false }, + transportFactory: () => [first, second][attempt++]!.transport, + }); + clients.push(client); + const connected = client.connect(); + first.triggerOpen(); + await connected; + await expect(client.patchDaemonConfig({ pluginsEnabled: false }, "rejected")).rejects.toBe( + rejected, + ); + first.triggerClose({ code: 1001, reason: "lost connection" }); + client.ensureConnected(); + const queued = client.getDaemonConfig("not-submitted").catch((error: unknown) => error); + second.triggerClose({ code: 1001, reason: "lost handshake" }); + const failure = (await queued) as DaemonConnectionError; + expect(failure).toBeInstanceOf(DaemonConnectionError); + expect(failure.outcome).toBeUndefined(); + expect((rejected as Error & { outcome?: string }).outcome).toBeUndefined(); + }, +); + +test("initial acquisition preserves structured transport failures and ordinary offline errors retain their cause", async () => { + const transport = createMockTransport(); + const failure = Object.assign(new Error("Destination identity changed"), { + code: "server_id_mismatch", + serverId: "srv_expected", + }); + const client = new DaemonClient({ + url: "ws://transport", + clientId: "structured-acquisition", + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connecting = client.connect(); + const rejected = expect(connecting).rejects.toBe(failure); + transport.triggerError(failure); + await rejected; + await expect(client.getDaemonConfig()).rejects.toMatchObject({ + code: "DAEMON_CONNECTION_LOST", + cause: failure, + }); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 254c9845bf..262cca26f0 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -398,9 +398,6 @@ export interface DaemonClientConfig { /** Deliver compact bodies/hash references to a caller-owned snapshot cache. * The default keeps public SDK snapshot entries expanded. */ providerSnapshots?: "wire"; - routedClient?: boolean; - routedServerId?: string; - routedFailure?: () => Error | null; sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[]; url: string; clientId: string; @@ -949,6 +946,7 @@ interface Waiter { reject(error: Error): void; timeoutHandle: ReturnType | null; requestId?: string; + submitted: boolean; } interface WaitHandle { @@ -1006,11 +1004,16 @@ type CorrelatedResponsePayload = CorrelatedResponsePayloads[TType]; export class DaemonConnectionError extends Error { + readonly serverId?: string; + readonly outcome?: "unknown"; constructor( message: string, readonly code: "DAEMON_CONNECTION_LOST" | "DAEMON_REQUEST_TIMEOUT" = "DAEMON_CONNECTION_LOST", + context?: { serverId?: string; outcome?: "unknown"; cause?: Error }, ) { - super(message); + super(message, context?.cause ? { cause: context.cause } : undefined); + this.serverId = context?.serverId; + this.outcome = context?.outcome; this.name = "DaemonConnectionError"; } } @@ -1303,6 +1306,8 @@ export class DaemonClient { private connectResolve: (() => void) | null = null; private connectReject: ((error: Error) => void) | null = null; private lastErrorValue: string | null = null; + private destinationServerId: string | undefined; + private connectionFailure: Error | undefined; private authFailureReasonValue: DaemonAuthFailureReason | null = null; private connectionState: ConnectionState = { status: "idle" }; private readonly terminalStreams = new TerminalStreamRouter(); @@ -1500,6 +1505,7 @@ export class DaemonClient { } this.scheduleReconnect({ reason, + failure: event instanceof Error ? event : undefined, event: "TRANSPORT_CLOSE", reasonCode: "transport_closed", }); @@ -1619,7 +1625,6 @@ export class DaemonClient { } ensureConnected(options?: { verify?: boolean }): void { - this.requireRoutedConnection(); if (this.connectionState.status === "disposed") { return; } @@ -1655,7 +1660,7 @@ export class DaemonClient { event: "CONNECTION_VERIFICATION_FAILED", reasonCode: "liveness_timeout", }); - if (!this.config.routedClient) this.ensureConnected(); + this.ensureConnected(); }) .finally(() => { if (this.connectionVerification === transport) this.connectionVerification = null; @@ -1666,7 +1671,7 @@ export class DaemonClient { if (!this.isConnected) throw Object.assign(new Error("The managing daemon is offline"), { code: "host_offline", - serverId: this.config.routedServerId, + serverId: this.destinationServerId, }); if (this.lastServerInfoMessage?.features?.connectedHosts !== true) throw Object.assign(new Error("Update the managing daemon to use connected hosts"), { @@ -1907,15 +1912,32 @@ export class DaemonClient { this.endTraceSection(isOpen); } - private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void { + private sendJsonMessage( + envelopeType: string, + messageType: string, + message: unknown, + submitted?: () => void, + ): void { this.traceInstant("paseo.ws.message.outbound", { envelopeType, messageType, }); - this.sendTransportFrame(JSON.stringify(message)); + this.sendTransportFrame(JSON.stringify(message), submitted); } - private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void { + private sendSessionPayload(payload: SessionInboundMessage): void { + this.sendJsonMessage("session", payload.type, { type: "session", message: payload }, () => { + if (!("requestId" in payload) || typeof payload.requestId !== "string") return; + for (const waiter of this.waiters) { + if (waiter.requestId === payload.requestId) waiter.submitted = true; + } + }); + } + + private sendTransportFrame( + frame: string | Uint8Array | ArrayBuffer, + submitted?: () => void, + ): void { if (!this.transport) { throw this.unavailableError(); } @@ -1926,6 +1948,7 @@ export class DaemonClient { size: String(getTransportFrameSize(frame)), }); try { + submitted?.(); this.transport.send(frame); } finally { this.endTraceSection(isOpen); @@ -1946,7 +1969,7 @@ export class DaemonClient { } const payload = SessionInboundMessageSchema.parse(message); try { - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); + this.sendSessionPayload(payload); } catch (error) { if (this.config.suppressSendErrors) { return; @@ -1985,7 +2008,7 @@ export class DaemonClient { private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise { return this.sendWhenConnected(() => { const payload = SessionInboundMessageSchema.parse(message); - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); + this.sendSessionPayload(payload); }); } @@ -2004,7 +2027,7 @@ export class DaemonClient { } // If connecting, queue the message to be sent once connected - if (status === "connecting" && !this.config.routedClient) { + if (status === "connecting") { return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { // Remove from queue @@ -2079,13 +2102,10 @@ export class DaemonClient { kind: "error", error: new DaemonRpcError({ requestId: msg.payload.requestId, - serverId: this.config.routedServerId, + serverId: this.destinationServerId, error: msg.payload.error, requestType: msg.payload.requestType, - code: - this.config.routedClient && msg.payload.code === "access_denied" - ? "permission_denied" - : msg.payload.code, + code: msg.payload.code, }), }; } @@ -2194,18 +2214,11 @@ export class DaemonClient { }); } - private requireRoutedConnection(): void { - if (this.config.routedClient && !this.isConnected) throw this.unavailableError(); - } - - private unavailableError(): Error { - if (this.config.routedFailure?.()) return this.config.routedFailure()!; - if (this.config.routedClient) - return Object.assign(new Error(`Host ${this.config.routedServerId} is offline`), { - code: "host_offline", - serverId: this.config.routedServerId, - }); - return new DaemonConnectionError("Transport not connected"); + private unavailableError(): DaemonConnectionError { + return new DaemonConnectionError("Transport not connected", "DAEMON_CONNECTION_LOST", { + serverId: this.destinationServerId, + cause: this.connectionFailure, + }); } private sendSessionMessageStrict(message: SessionInboundMessage): void { @@ -2214,7 +2227,7 @@ export class DaemonClient { } const payload = SessionInboundMessageSchema.parse(message); try { - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); + this.sendSessionPayload(payload); } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } @@ -3030,7 +3043,6 @@ export class DaemonClient { private readonly creations = new CreationClient({ supports: (feature) => { - this.requireRoutedConnection(); return this.lastServerInfoMessage?.features?.[feature] === true; }, requestId: () => this.createRequestId(), @@ -6847,16 +6859,18 @@ export class DaemonClient { // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. - this.clearWaiters( - this.config.routedClient - ? Object.assign( - new Error("Connection lost before result; inspect the destination before retrying"), - { code: "outcome_unknown", serverId: this.config.routedServerId }, - ) - : new DaemonConnectionError(reason ?? "Connection lost"), + const connectionError = new DaemonConnectionError( + reason ?? "Connection lost", + "DAEMON_CONNECTION_LOST", + { + serverId: this.destinationServerId, + cause: input?.failure, + }, ); - this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost")); - this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost")); + this.connectionFailure = input?.failure; + this.clearWaiters(connectionError, true); + this.rejectPendingSendQueue(connectionError); + this.rejectPingProbe(connectionError); this.terminalStreams.clearSlots(); this.lastServerInfoMessage = null; @@ -6994,6 +7008,8 @@ export class DaemonClient { const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload); if (serverInfo) { this.lastServerInfoMessage = serverInfo; + this.destinationServerId = serverInfo.serverId; + this.connectionFailure = undefined; if (this.connectionState.status === "connecting") { this.resetConnectTimeout(); this.reconnectAttempt = 0; @@ -7077,12 +7093,20 @@ export class DaemonClient { } } - private clearWaiters(error: Error): void { + private clearWaiters(error: Error, connectionLost = false): void { for (const waiter of Array.from(this.waiters)) { if (waiter.timeoutHandle) { clearTimeout(waiter.timeoutHandle); } - waiter.reject(error); + waiter.reject( + connectionLost && waiter.submitted && error instanceof DaemonConnectionError + ? new DaemonConnectionError(error.message, error.code, { + serverId: error.serverId, + outcome: "unknown", + cause: error.cause instanceof Error ? error.cause : undefined, + }) + : error, + ); } this.waiters.clear(); } @@ -7189,6 +7213,7 @@ export class DaemonClient { reject: wrappedReject, timeoutHandle, requestId: options?.requestId, + submitted: false, }; this.waiters.add(waiter); }); diff --git a/packages/client/src/hosts/index.ts b/packages/client/src/hosts/index.ts index 94a7b9b8af..1baa9ebe43 100644 --- a/packages/client/src/hosts/index.ts +++ b/packages/client/src/hosts/index.ts @@ -19,7 +19,6 @@ export function createHostChannelTransport( serverId: string, scopeId: string, initialRouteId: string | null, - state: { failure: Error | null }, link = false, ): DaemonTransportFactory { let first: string | null = initialRouteId; @@ -34,7 +33,7 @@ export function createHostChannelTransport( const close = (error?: unknown) => { if (closed) return; closed = true; - if (error instanceof Error) state.failure = Object.assign(error, { serverId }); + if (error instanceof Error) Object.assign(error, { serverId }); for (const cleanup of cleanups) cleanup(); try { if (routeId) origin.closeHostRoute(routeId); @@ -80,7 +79,6 @@ export function createHostChannelTransport( return; } routeId = id; - state.failure = null; for (const handler of opens) handler(); return undefined; }) @@ -161,6 +159,10 @@ export function createHostActions( let routeId: string; try { routeId = await origin.openHostRoute(serverId, scopeId); + } catch (error) { + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { + serverId, + }); } finally { owner.signal.removeEventListener("abort", cancel); } @@ -168,16 +170,12 @@ export function createHostActions( origin.closeHostRoute(routeId); assertActive(); } - const state: { failure: Error | null } = { failure: null }; const client = new DaemonClient({ url: "ws://routed-host/ws", clientId: scopeId, - transportFactory: createHostChannelTransport(origin, serverId, scopeId, routeId, state), + transportFactory: createHostChannelTransport(origin, serverId, scopeId, routeId), // Initial acquisition has a deadline; established scopes restore demand on loss. reconnect: { enabled: false }, - routedClient: true, - routedServerId: serverId, - routedFailure: () => state.failure, }); const api = owner.api(client, { signal: owner.signal }); const scope = owner.own(() => ({ @@ -195,12 +193,13 @@ export function createHostActions( assertActive(); return { ...api, dispose: () => scope.release() }; } catch (error) { - const failure = - state.failure ?? - Object.assign(error instanceof Error ? error : new Error("Host handshake failed"), { + const failure = Object.assign( + error instanceof Error ? error : new Error("Host handshake failed"), + { code: error instanceof Error && "code" in error ? error.code : "host_offline", serverId, - }); + }, + ); await scope.release(); throw failure; } diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index a3f3e3fd22..0005946316 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -157,7 +157,7 @@ test("routed authority is bounded by the caller, with isolated simultaneous sess expect((await remote.workspaces.list()).entries).toEqual([]); await expect( remote.workspaces.create({ source: { kind: "directory", path: b.paseoHome } }), - ).rejects.toMatchObject({ code: "permission_denied" }); + ).rejects.toMatchObject({ code: "access_denied" }); await expect(viewer.hosts.add({ connection: "127.0.0.1:1" })).rejects.toMatchObject({ code: "permission_denied", }); @@ -165,6 +165,42 @@ test("routed authority is bounded by the caller, with isolated simultaneous sess await remote.dispose(); }); +test("SDK host clients preserve ordinary permission RPC errors", async () => { + const b = await daemon(); + const a = await daemon(); + const owner = await api(a); + await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(owner.paseo)).hosts[0]!.serverId!; + const clients = [a, b].map((host) => + createHostClient(`127.0.0.1:${host.port}`, { + clientId: crypto.randomUUID(), + sessionPermissions: ["daemon.read", "workspace.read"], + reconnect: { enabled: false }, + }), + ); + for (const client of clients) { + cleanup.push(() => client.close()); + await client.connect(); + } + const origin = createPaseoApi(clients[0]!); + const direct = createPaseoApi(clients[1]!); + const remote = await origin.hosts.client(serverId); + cleanup.push(async () => { + await remote.dispose(); + await direct.dispose(); + await origin.dispose(); + }); + const input = { source: { kind: "directory" as const, path: b.staticDir } }; + const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as { + code: string; + }; + const remoteError = (await remote.workspaces.create(input).catch((error: unknown) => error)) as { + code: string; + }; + expect(directError.code).toBe("access_denied"); + expect(remoteError.code).toBe(directError.code); +}); + test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => { const b = await daemon(); const wire = await hostWire(b); @@ -619,7 +655,7 @@ test("resuming an A session with narrower grants terminates existing routes and await expect.poll(() => snapshots.length, { timeout: 15000 }).toBe(2); await expect( remote.workspaces.create({ source: { kind: "directory", path: b.staticDir } }), - ).rejects.toMatchObject({ code: "permission_denied" }); + ).rejects.toMatchObject({ code: "access_denied" }); expect((await remote.workspaces.list()).entries).toEqual([]); await remote.dispose(); }); @@ -722,7 +758,7 @@ test("an unreachable saved target becomes online after its daemon starts without }); }); -test("losing a sent mutation result identifies the destination and never queues unavailable operations", async () => { +test("losing a sent mutation result retains ordinary errors, identifies the destination and never replays it", async () => { const b = await daemon(); const a = await daemon(); const { paseo, client } = await api(a); @@ -732,15 +768,29 @@ test("losing a sent mutation result identifies the destination and never queues const pending = remote.config.patch({ pluginsEnabled: false }); const result = pending.catch((error: unknown) => error); await client.close(); - expect(await result).toMatchObject({ code: "outcome_unknown", serverId }); - await expect(remote.workspaces.list()).rejects.toMatchObject({ code: "host_offline", serverId }); - await expect(remote.terminals.list()).rejects.toMatchObject({ code: "host_offline", serverId }); - await expect( - remote.workspaces.create({ - source: { kind: "directory", path: "/tmp" }, - workspaceId: "offline-never-created", - }), - ).rejects.toMatchObject({ code: "host_offline", serverId }); + expect(await result).toMatchObject({ + code: "DAEMON_CONNECTION_LOST", + outcome: "unknown", + serverId, + }); + await expect(remote.workspaces.list()).rejects.toMatchObject({ + code: "DAEMON_CONNECTION_LOST", + serverId, + }); + const direct = await api(b); + await direct.client.close(); + const terminalFailure = (await direct.paseo.terminals + .list() + .catch((error: unknown) => error)) as Error; + await expect(remote.terminals.list()).rejects.toThrow(terminalFailure.message); + const creation = { + source: { kind: "directory" as const, path: "/tmp" }, + workspaceId: "offline-never-created", + }; + const creationFailure = (await direct.paseo.workspaces + .create(creation) + .catch((error: unknown) => error)) as Error; + await expect(remote.workspaces.create(creation)).rejects.toThrow(creationFailure.message); await remote.dispose(); }); @@ -910,21 +960,22 @@ test("a resumed physical link re-admits every logical session under its current const logical = new DaemonClient({ url: "ws://logical/ws", clientId: "logical", - transportFactory: createHostChannelTransport( - parent, - serverId, - "logical", - null, - { failure: null }, - true, - ), - routedClient: true, - routedServerId: serverId, + transportFactory: createHostChannelTransport(parent, serverId, "logical", null, true), }); cleanup.push(() => logical.close()); - await logical.connect(); + const connecting = logical.connect(); + expect(() => logical.ensureConnected()).not.toThrow(); const scoped = createPaseoApi(logical); cleanup.push(() => scoped.dispose()); + const initialCall = scoped.workspaces.list(); + await connecting; + expect((await initialCall).entries).toEqual([]); + const recoveryCalls: Promise[] = []; + const stopRecovery = logical.subscribeConnectionStatus((state) => { + if (state.status === "connecting") + recoveryCalls.push(scoped.workspaces.list().catch((error: unknown) => error)); + }); + cleanup.push(async () => stopRecovery()); const bytes = Uint8Array.from({ length: 256 * 1024 + 123 }, (_, index) => index % 251); const upload = await logical.uploadFile({ fileName: "routed.bin", @@ -955,7 +1006,7 @@ test("a resumed physical link re-admits every logical session under its current ]); await expect( scoped.workspaces.create({ source: { kind: "directory", path: b.staticDir } }), - ).rejects.toMatchObject({ code: "permission_denied" }); + ).rejects.toMatchObject({ code: "access_denied" }); const restored = createHostClient(`127.0.0.1:${b.port}`, { clientId: "physical-grants", reconnect: { enabled: false }, @@ -964,6 +1015,8 @@ test("a resumed physical link re-admits every logical session under its current await restored.connect(); await expect.poll(() => snapshots.length, { timeout: 15000 }).toBe(3); expect(logical.getLastServerInfoMessage()?.permissions).toContain("workspace.write"); + expect(recoveryCalls.length).toBeGreaterThanOrEqual(2); + for (const call of recoveryCalls) expect(await call).toMatchObject({ entries: [] }); await scoped.workspaces.create({ source: { kind: "directory", path: b.staticDir } }); }); diff --git a/packages/server/src/server/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts index 83f5173fab..1a8f7c2731 100644 --- a/packages/server/src/server/connected-hosts/index.ts +++ b/packages/server/src/server/connected-hosts/index.ts @@ -384,7 +384,6 @@ export class ConnectedHosts { input.serverId, randomUUID(), null, - { failure: null }, true, )({ url: "ws://host-link/ws" }); let closed = false; diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 85030a4151..facf6dffd7 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -329,6 +329,7 @@ async function waitForCapturedLog( snapshot: () => { snapshots++; }, + update() {}, }); const workspace = await remote.workspaces.create({ source: { kind: "directory", path: b.staticDir }, @@ -358,7 +359,7 @@ async function waitForCapturedLog( }, { timeout: 15000 }, ) - .toBe("host_offline"); + .toBe("DAEMON_CONNECTION_LOST"); await startRelay({ useLocalRelay: true, port }); await expect .poll( diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 5f140d2aa1..6e606e35ba 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -247,12 +247,18 @@ daemon. The ordinary APIs keep their signatures. Paths and IDs belong to the des the local server ID returns a local scope. Routing supports one hop. Disposing one scope releases its observations without closing siblings or deleting the saved host. -Origin disposal cascades. Offline operations fail; observations recover. A lost result reports -`outcome_unknown` and the destination server ID. Inspect before retrying a mutation. +Origin disposal cascades. Observations recover through the ordinary client lifecycle. While connecting, +new requests use the existing bounded send queue; disconnected requests fail. Connection loss clears +old waiters and queued sends. Ordinary non-recoverable requests are not replayed; existing +acknowledged/idempotent creation recovery retains its request identity. A submitted request whose response +is lost rejects with `DaemonConnectionError` (`code: "DAEMON_CONNECTION_LOST"`), optional +`outcome: "unknown"` and known `serverId`. Inspect before retrying a mutation. -Host failures expose a stable `code`: `host_unknown`, `host_disabled`, `host_offline`, +Registration and host acquisition failures expose a stable `code`: `host_unknown`, `host_disabled`, `host_offline`, `permission_denied`, `unsupported_capability`, `authentication_failed`, `server_id_mismatch`, -`self_host`, or `duplicate_host`. Discovery does not grant execution authority. +`self_host`, or `duplicate_host`. Ordinary RPC permission codes, including `access_denied`, pass through +unchanged. Multiplexing is a transport; consumers use the same client/API operations and lifecycle. +Discovery does not grant execution authority. ## Errors and cleanup From 63c3bb8c42257587747d421472ca8428681a1c4d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 20 Oct 2026 08:06:15 +0200 Subject: [PATCH 11/12] test(e2e): keep transient developer toasts from stalling checks Concurrent locator handlers can wait for a dismiss button another handler removed. Dismiss existing toast DOM at document lifetime, including its open shadow root, without intercepting app actions. --- packages/app/e2e/browser/developer-ui.spec.ts | 58 +++++++++++++++++++ packages/app/e2e/support/developer-ui.mjs | 31 +++++++--- 2 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 packages/app/e2e/browser/developer-ui.spec.ts diff --git a/packages/app/e2e/browser/developer-ui.spec.ts b/packages/app/e2e/browser/developer-ui.spec.ts new file mode 100644 index 0000000000..7ea2fc2c95 --- /dev/null +++ b/packages/app/e2e/browser/developer-ui.spec.ts @@ -0,0 +1,58 @@ +import { metroTest as test, expect } from "../support/fixtures"; +import { installDeveloperToastDismissal } from "../support/developer-ui.mjs"; +import type { Page } from "@playwright/test"; + +async function showTransientDeveloperToast(page: Page, shadow: boolean) { + await page.evaluate((useShadow) => { + const toast = document.createElement("div"); + toast.id = "error-toast"; + document.body.append(toast); + const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast; + const button = document.createElement("button"); + button.style.cssText = "width:32px;height:32px"; + button.addEventListener("click", () => button.remove()); + root.append(button); + }, shadow); +} + +async function openTransientDeveloperToast(page: Page) { + await page.setContent(""); + await showTransientDeveloperToast(page, false); + await installDeveloperToastDismissal(page); +} + +async function navigateWithDeveloperToastDismissal(page: Page) { + await installDeveloperToastDismissal(page); + await page.goto("data:text/html,"); + await showTransientDeveloperToast(page, true); +} + +async function inspectLayoutWhileToastDisappears(page: Page) { + const action = page.getByRole("button", { name: "Continue", exact: true }); + const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox())); + for (const box of boxes) expect(box).not.toBeNull(); + await expect(page.locator("#error-toast").getByRole("button")).toHaveCount(0); + await action.click(); +} + +test("a disappearing developer toast does not stall concurrent layout checks", async ({ page }) => { + test.setTimeout(10000); + await openTransientDeveloperToast(page); + await inspectLayoutWhileToastDisappears(page); +}); + +test("developer toast dismissal survives navigation and observes Expo shadow-root updates", async ({ + page, +}) => { + test.setTimeout(10000); + await navigateWithDeveloperToastDismissal(page); + await inspectLayoutWhileToastDisappears(page); + await page.evaluate(() => { + const root = document.getElementById("error-toast")!.shadowRoot!; + const replacement = document.createElement("button"); + replacement.style.cssText = "width:32px;height:32px"; + replacement.addEventListener("click", () => replacement.remove()); + root.append(replacement); + }); + await inspectLayoutWhileToastDisappears(page); +}); diff --git a/packages/app/e2e/support/developer-ui.mjs b/packages/app/e2e/support/developer-ui.mjs index 36d5752688..456f0d7088 100644 --- a/packages/app/e2e/support/developer-ui.mjs +++ b/packages/app/e2e/support/developer-ui.mjs @@ -1,10 +1,27 @@ /** @param {import('@playwright/test').Page} page */ export async function installDeveloperToastDismissal(page) { - // Developer chrome must not cover app actions or move the test's pointer/focus. - // Console errors remain in the runner's logs. - const dismiss = page.locator("#error-toast").getByRole("button", { name: "", exact: true }); - await page.addLocatorHandler(dismiss, () => dismiss.dispatchEvent("click"), { - // A new logged error can replace the toast before the old one disappears. - noWaitAfter: true, - }); + // Keep developer chrome out of app actions without pointer/focus changes. + // Locator handlers can deadlock when concurrent checks dismiss the same toast. + const install = () => { + let observedRoot; + const options = { childList: true, subtree: true }; + const dismiss = () => { + const toast = document.getElementById("error-toast"); + const root = toast?.shadowRoot ?? toast; + if (root && root !== observedRoot) { + observer.disconnect(); + observer.observe(document, options); + observer.observe(root, options); + observedRoot = root; + } + // Expo's toast root contains dismiss buttons; its overlay has a different ID. + for (const button of root?.querySelectorAll("button") ?? []) + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }; + const observer = new MutationObserver(dismiss); + observer.observe(document, options); + dismiss(); + }; + await page.addInitScript(install); + await page.evaluate(install); } From 25e0bd3a352ea26393175eea98c9871af23e8f1e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 13:52:10 +0700 Subject: [PATCH 12/12] Create, prompt, and monitor agents across connected hosts (#7507) * feat(hosts): delegate agents across connected hosts Resolve MCP host operations through in-process commands for self and the existing routed client transport for other destinations. Keep ordinary parent labels, app sessions and idle finish notifications. * test(app): normalize subagent fixtures through snapshots * docs(protocol): clarify same-host parent labels * fix(hosts): resolve subagents through destination sessions A child address identifies its destination regardless of the connection used. Reuse the existing host candidate selection for discovered routes and direct connections, and keep navigation and row actions independent of the parent transport. * fix(hosts): keep routed destinations distinct during discovery A routed candidate names only the origin daemon. Match it within the named destination profile so two destinations discovered through the same origin retain separate identities and connections, including existing saved routes. * feat(hosts): prompt and query agents on selected hosts Extend the existing MCP host operation boundary with prompt, status, activity and list operations. Local calls retain their handlers and notification owner; selected destinations use ordinary client RPCs and the existing idle finish wait. --- docs/connected-hosts.md | 20 + .../e2e/browser/subagent-origin-pane.spec.ts | 197 ++++++++- packages/app/e2e/support/helpers/subagents.ts | 155 ++++++- .../src/desktop/daemon/local-credential.ts | 1 + packages/app/src/hooks/use-archive-agent.ts | 5 + packages/app/src/panels/agent-tracks.tsx | 16 +- packages/app/src/runtime/host-runtime.test.ts | 117 +++++- packages/app/src/runtime/host-runtime.ts | 117 +++++- .../app/src/screens/settings/host-page.tsx | 1 + .../workspace-subagents-integration.test.ts | 18 +- .../src/subagents/archive-finished.test.ts | 27 +- .../app/src/subagents/archive-finished.ts | 24 +- packages/app/src/subagents/index.ts | 4 +- .../app/src/subagents/opens-in-sheet.test.tsx | 12 +- packages/app/src/subagents/select.test.ts | 54 ++- packages/app/src/subagents/select.ts | 47 ++- .../src/subagents/track-presentation.test.ts | 1 + packages/app/src/subagents/track.tsx | 37 +- .../app/src/subagents/use-archive-finished.ts | 13 +- .../app/src/subagents/use-archive-subagent.ts | 12 +- .../app/src/subagents/use-detach-subagent.ts | 12 +- .../app/src/types/host-connection.test.ts | 51 +++ packages/app/src/types/host-connection.ts | 19 +- packages/app/src/utils/agent-snapshots.ts | 5 +- .../app/src/utils/test-daemon-connection.ts | 2 + packages/client/package.json | 4 + packages/client/src/index.test.ts | 35 ++ packages/client/src/index.ts | 54 ++- packages/protocol/package.json | 4 + packages/protocol/src/addresses.ts | 5 + packages/protocol/src/agent-labels.ts | 22 + .../src/paseo-tool-presentation/catalog.ts | 1 + .../src/server/agent/activity-curator.ts | 20 +- .../src/server/agent/agent-projections.ts | 61 +++ .../server/src/server/agent/agent-prompt.ts | 109 ++++- .../src/server/agent/host-operations/index.ts | 393 ++++++++++++++++++ .../src/server/agent/tools/paseo-tools.ts | 241 +++++++---- packages/server/src/server/bootstrap.ts | 5 + .../server/connected-hosts/hosts.e2e.test.ts | 181 +++++++- .../server/src/server/websocket-server.ts | 5 + public-docs/sdk/reference.md | 3 + 41 files changed, 1919 insertions(+), 191 deletions(-) create mode 100644 packages/protocol/src/addresses.ts create mode 100644 packages/server/src/server/agent/host-operations/index.ts diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index 088a5218a7..1844456007 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -29,6 +29,26 @@ principal. An ordinary direct B consumer does not become that producer by supply The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal. Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature. +## Delegating agents + +Agent MCP discovers destination IDs with `list_hosts`. `create_workspace`, `list_workspaces`, +`create_agent`, `send_agent_prompt`, `get_agent_status`, `get_agent_activity`, and `list_agents` +accept `serverId`; omission selects the calling daemon. Remote creation requires a +destination workspace and explicit provider/model. Caller filesystem defaults apply only on the +calling daemon. Prompting a remote child acknowledges acceptance and uses the existing idle wait for +finish notifications. Remote agent lists have no caller-directory default; pass `cwd` to filter them. + +The parent’s Subagents track shows qualified children across connected hosts. Opening a child uses +the ordinary conversation on the destination's session. The runtime selects among that host's +connection candidates, including direct connections and routes discovered through another daemon. +Adding a direct connection to a discovered host adds another candidate. If no candidate connects, +the destination workspace keeps its existing disconnected or reconnecting behavior. + +Finish notifications use the existing idle wait, including a pending initial run or an already idle +child. They name the server and agent. A daemon restart or route loss ends an outstanding notification +wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain +no additional turn attribution or delivery guarantee. + ## Connections and recovery The shared Node connector owns the target grammar and each connection attempt, including a fresh diff --git a/packages/app/e2e/browser/subagent-origin-pane.spec.ts b/packages/app/e2e/browser/subagent-origin-pane.spec.ts index 47a0120da6..30b19e5099 100644 --- a/packages/app/e2e/browser/subagent-origin-pane.spec.ts +++ b/packages/app/e2e/browser/subagent-origin-pane.spec.ts @@ -1,10 +1,19 @@ +import { buildSeededHost } from "../support/helpers/daemon-registry"; +import { submitMessage } from "../support/helpers/composer"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents"; +import { + delegateFleetThroughMcp, + expectManagedSubagentArchived, + openSubagentsTrack, + seedParentWithSubagent, +} from "../support/helpers/subagents"; + +test.use({ e2eInjectPaseoTools: true }); const SETTINGS_KEY = "@paseo:app-settings"; @@ -147,3 +156,189 @@ test("a preferred chat file link still opens beside its right-pane agent", async await workspace.cleanup(); } }); + +test("MCP delegates to self and B and opens both qualified children in ordinary conversations", async ({ + page, +}) => { + const fleet = await delegateFleetThroughMcp(); + let appSockets = 0; + let routeOpens = 0; + const activeDaemonSockets = new Set(); + const observationRequests: Record = {}; + page.on("websocket", (socket) => { + appSockets++; + if (new URL(socket.url()).pathname === "/ws") { + activeDaemonSockets.add(socket); + socket.on("close", () => activeDaemonSockets.delete(socket)); + } + socket.on("framesent", ({ payload }) => { + if (typeof payload !== "string" || !payload.startsWith("{")) return; + if (String(payload).includes('"hosts.route.open.request"')) routeOpens++; + const outer = JSON.parse(String(payload)); + const request = outer.type === "session" ? outer.message : null; + if ( + request?.type && + (request.subscribe !== undefined || + request.observe === true || + request.type.endsWith("set_subscription.request")) + ) + observationRequests[request.type] = (observationRequests[request.type] ?? 0) + 1; + }); + }); + try { + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await openSubagentsTrack(page); + await expect(page.getByTestId(`subagents-track-row-${fleet.local.agentId}`)).toContainText( + "MCP child on A", + ); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText( + "MCP child on B", + ); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText( + "Fleet B", + ); + await page.getByTestId(`subagents-track-row-${fleet.local.agentId}`).click(); + await expect(page.getByTestId(`workspace-tab-agent_${fleet.local.agentId}`)).toBeVisible(); + await submitMessage(page, "LOCAL_UI_TARGET"); + await expect(page.getByText("LOCAL_UI_TARGET", { exact: true })).toBeVisible(); + expect((await fleet.b.fetchAgent(fleet.local.agentId))?.agent.lastUserMessageAt).toBeNull(); + await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click(); + await openSubagentsTrack(page); + await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click(); + await expect(page).toHaveURL((url) => + url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`), + ); + await submitMessage(page, "REMOTE_UI_TARGET"); + await expect(page.getByText("REMOTE_UI_TARGET", { exact: true })).toBeVisible(); + expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull(); + await fleet.expectCompletion(fleet.local); + await fleet.expectCompletion(fleet.remote); + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await expect( + page.getByRole("button", { name: /Agent finished.*MCP child on A/ }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /Agent finished.*MCP child on B/ }), + ).toBeVisible(); + const owners = await page.evaluate(() => { + const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as { + getHosts(): Array<{ serverId: string }>; + getSnapshot( + serverId: string, + ): { connectionStatus: string; clientGeneration: number } | null; + }; + return runtime.getHosts().map((host) => { + const snapshot = runtime.getSnapshot(host.serverId); + return { + serverId: host.serverId, + connectionStatus: snapshot?.connectionStatus, + clientGeneration: snapshot?.clientGeneration, + }; + }); + }); + console.log("Fleet ordinary route counts", { + appSockets, + activeDaemonSockets: activeDaemonSockets.size, + routeOpens, + owners, + observationRequests, + }); + } finally { + await fleet.close(); + } +}); + +async function openFleetParentWithDirectB( + page: Page, + fleet: Awaited>, +) { + await page.goto("/", { waitUntil: "commit" }); + const host = buildSeededHost({ + serverId: fleet.remote.serverId, + endpoint: `127.0.0.1:${fleet.destination.port}`, + label: "Fleet B", + nowIso: new Date().toISOString(), + }); + await page.evaluate((extraHost) => { + localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost])); + }, host); + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await openSubagentsTrack(page); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText( + "MCP child on B", + ); +} + +async function openFleetChild( + page: Page, + fleet: Awaited>, +) { + await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click(); + await expect(page).toHaveURL( + (url) => url.pathname === `/h/${fleet.remote.serverId}/workspace/${fleet.remote.workspaceId}`, + ); +} + +test("a directly connected B opens and archives its MCP child from A's track", async ({ page }) => { + const fleet = await delegateFleetThroughMcp(); + try { + await openFleetParentWithDirectB(page, fleet); + await openFleetChild(page, fleet); + await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible(); + await submitMessage(page, "DIRECT_B_TARGET"); + await expect(page.getByText("DIRECT_B_TARGET", { exact: true })).toBeVisible(); + expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull(); + await openFleetParentWithDirectB(page, fleet); + await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).hover(); + page.once("dialog", (dialog) => { + void dialog.accept(); + }); + await page.getByRole("button", { name: "Archive MCP child on B", exact: true }).click(); + await expectManagedSubagentArchived({ client: fleet.b }, fleet.remote.agentId); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toHaveCount(0); + expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.archivedAt).toBeNull(); + await expect(page.getByText(/already connected through another route/)).toHaveCount(0); + } finally { + await fleet.close(); + } +}); + +test("an unavailable B child opens its cached workspace with existing reconnecting behavior", async ({ + page, +}) => { + const fleet = await delegateFleetThroughMcp(); + try { + await openFleetParentWithDirectB(page, fleet); + await fleet.destination.close(); + await expect + .poll(() => + page.evaluate((serverId) => { + const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as { + getSnapshot(id: string): { serverId: string; connectionStatus: string } | null; + }; + return runtime.getSnapshot(serverId); + }, fleet.remote.serverId), + ) + .toMatchObject({ + serverId: fleet.remote.serverId, + connectionStatus: expect.stringMatching(/^(offline|connecting|error)$/), + }); + await openFleetChild(page, fleet); + await expect(page.getByTestId("workspace-header-title").filter({ visible: true })).toHaveText( + "B workspace", + ); + await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible(); + await expect(page.getByText(/already connected through another route/)).toHaveCount(0); + } finally { + await fleet.close(); + } +}); diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts index f3a5ca7c71..3354bf3dd8 100644 --- a/packages/app/e2e/support/helpers/subagents.ts +++ b/packages/app/e2e/support/helpers/subagents.ts @@ -1,3 +1,7 @@ +import { connectDaemonClient } from "./daemon-client-loader"; +import { startIsolatedHostDaemon } from "./isolated-host-daemon"; +import { seedWorkspace } from "./seed-client"; +import { getE2EDaemonPort } from "./daemon-port"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { expect, type Page } from "@playwright/test"; import { daemonWsRoutePattern } from "./daemon-port"; @@ -166,7 +170,7 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis } export async function expectManagedSubagentUnarchived( - workspace: Pick, + workspace: { client: Pick }, childId: string, ): Promise { await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({ @@ -175,7 +179,7 @@ export async function expectManagedSubagentUnarchived( } export async function expectManagedSubagentArchived( - workspace: Pick, + workspace: { client: Pick }, childId: string, ): Promise { await expect @@ -320,3 +324,150 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom await expect(detachButton).toBeVisible({ timeout: 30_000 }); await detachButton.click(); } + +/** The primary children come from the real agent MCP endpoint on A. */ +export async function delegateFleetThroughMcp() { + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + const { StreamableHTTPClientTransport } = + await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); + const { z } = await import("zod"); + type DaemonClient = import("@getpaseo/client/internal/daemon-client").DaemonClient; + const workspace = await seedWorkspace({ repoPrefix: "fleet-mcp-" }); + const destination = await startIsolatedHostDaemon(`fleet_${crypto.randomUUID()}`); + const a = await connectDaemonClient({ clientIdPrefix: "fleet-seed-A" }); + const b = await connectDaemonClient({ + clientIdPrefix: "fleet-observe-B", + port: destination.port, + }); + const serverId = a.getLastServerInfoMessage()!.serverId; + const mcp = new Client({ name: "fleet-parent", version: "1" }); + const close = async () => { + await mcp.close(); + await Promise.allSettled([a.close(), b.close()]); + await destination.close(); + await workspace.cleanup(); + }; + const call = async (name: string, args: Record = {}) => { + const response = await mcp.callTool({ name, arguments: args }); + expect(response, name).not.toMatchObject({ isError: true }); + return z.record(z.string(), z.unknown()).parse(response.structuredContent); + }; + try { + const parent = await a.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Fleet parent", + modeId: "load-test", + }); + const registration = await a.addHost({ + connection: `127.0.0.1:${destination.port}`, + label: "Fleet B", + }); + await expect + .poll( + async () => + (await a.listHosts()).hosts.find( + (row) => row.registrationId === registration.registrationId, + )?.status, + ) + .toBe("online"); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + expect(await call("list_hosts")).toMatchObject({ + hosts: expect.arrayContaining([ + expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }), + ]), + }); + const target = await call("create_workspace", { + serverId: destination.serverId, + isolation: "local", + path: workspace.repoPath, + title: "B workspace", + }); + expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({ + workspaces: expect.arrayContaining([ + expect.objectContaining({ workspaceId: target.workspaceId }), + ]), + }); + const createChild = async (targetServerId: string, workspaceId: string, title: string) => { + const result = await call("create_agent", { + serverId: targetServerId, + workspaceId, + title, + provider: "mock/e2e-fast-stream", + settings: { modeId: "load-test" }, + initialPrompt: `Finish ${title}`, + }); + return { + serverId: targetServerId, + agentId: z.string().parse(result.agentId), + workspaceId, + title, + }; + }; + const local = await createChild(serverId, workspace.workspaceId, "MCP child on A"); + const remote = await createChild( + destination.serverId, + z.string().parse(target.workspaceId), + "MCP child on B", + ); + // Existing SDK reservation creates unrelated equal-ID fixtures; primary children remain MCP-created. + await b.createAgent({ + agentId: local.agentId, + provider: "mock", + model: "e2e-fast-stream", + cwd: workspace.repoPath, + workspaceId: String(target.workspaceId), + title: "Unrelated on B", + modeId: "load-test", + }); + await a.createAgent({ + agentId: remote.agentId, + provider: "mock", + model: "e2e-fast-stream", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Unrelated on A", + modeId: "load-test", + }); + return { + workspace, + destination, + parent, + serverId, + local, + remote, + a, + b, + close, + async expectCompletion(child: typeof local) { + await expect + .poll(async () => { + const timeline = await a.fetchAgentTimeline(parent.id, { + direction: "tail", + limit: 100, + }); + return timeline.entries + .flatMap(({ item }) => + item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [], + ) + .filter( + (message) => + message.event === "finished" && + message.sender.id === child.agentId && + message.text.includes(`Server: ${child.serverId}`), + ).length; + }) + .toBe(1); + }, + }; + } catch (error) { + await close(); + throw error; + } +} diff --git a/packages/app/src/desktop/daemon/local-credential.ts b/packages/app/src/desktop/daemon/local-credential.ts index 005abdc21f..78093c8e63 100644 --- a/packages/app/src/desktop/daemon/local-credential.ts +++ b/packages/app/src/desktop/daemon/local-credential.ts @@ -10,6 +10,7 @@ function connectionListen(connection: HostConnection): string | null { case "directPipe": return `pipe://${connection.path}`; case "remoteSsh": + case "routed": case "relay": return null; } diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 92d8e4d2d6..e3ecf685b2 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -367,6 +367,11 @@ function useArchiveAgentPendingQuery() { }); } +export function usePendingArchiveAgentKeys(): ReadonlySet { + const pendingQuery = useArchiveAgentPendingQuery(); + return useMemo(() => new Set(Object.keys(pendingQuery.data ?? {})), [pendingQuery.data]); +} + export function usePendingArchiveAgentIds(serverId: string): ReadonlySet { const pendingQuery = useArchiveAgentPendingQuery(); return useMemo( diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index e67a004b45..3fc42030a7 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { memo, useCallback, type ReactElement } from "react"; import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; @@ -56,8 +57,8 @@ export const AgentTracks = memo(function AgentTracks({ const canDetachSubagents = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, ); - const archiveSubagent = useArchiveSubagent({ serverId }); - const detachSubagent = useDetachSubagent({ serverId }); + const archiveSubagent = useArchiveSubagent(); + const detachSubagent = useDetachSubagent(); const openSubagentTarget = useCallback( (target: WorkspaceTabTarget) => { openPreferredWorkspaceTarget({ @@ -71,12 +72,15 @@ export const AgentTracks = memo(function AgentTracks({ [openInSidePane, tabId, workspaceKey], ); const handleOpenSubagent = useCallback( - (subagentId: string) => { - const session = useSessionStore.getState().sessions[serverId]; + ({ serverId: targetServerId, agentId: subagentId }: AgentAddress) => { + const session = useSessionStore.getState().sessions[targetServerId]; const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId); // A subagent in another workspace lives in that workspace's tabs. - if (agent?.workspaceId && agent.workspaceId !== workspaceId) { - navigateToAgent({ serverId, agentId: subagentId }); + if ( + targetServerId !== serverId || + (agent?.workspaceId && agent.workspaceId !== workspaceId) + ) { + navigateToAgent({ serverId: targetServerId, agentId: subagentId }); return; } openSubagentTarget({ kind: "agent", agentId: subagentId }); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 61285eb2c6..1425dc3560 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -11,6 +11,7 @@ import type { FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client"; +import type { HostDirectory } from "@getpaseo/protocol/hosts"; import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; @@ -157,10 +158,24 @@ class FakeDaemonClient { } } + get isConnected(): boolean { + return this.state.status === "connected"; + } + getConnectionState(): ConnectionState { return this.state; } + public connectedHosts = false; + public hostDirectory: Promise = Promise.resolve({ + self: { serverId: "srv_test", label: "test" }, + hosts: [], + }); + + observeHosts() { + return subscriptionFixture(this.hostDirectory, () => () => {}); + } + public ownedSubscriptions = true; private daemonVersion: string | null = "0.8.0"; @@ -171,7 +186,10 @@ class FakeDaemonClient { serverId: "srv_test", hostname: "test", version: this.daemonVersion, - features: { ownedSubscriptions: this.ownedSubscriptions }, + features: { + ownedSubscriptions: this.ownedSubscriptions, + connectedHosts: this.connectedHosts, + }, }; } @@ -1658,6 +1676,103 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { + it.each([false, true])( + "keeps B and C distinct with routed and direct candidates (B direct first: %s)", + async (directFirst) => { + const directory = new Deferred(); + const origin = new FakeDaemonClient(); + origin.setConnectionState({ status: "connected" }); + origin.connectedHosts = true; + origin.hostDirectory = directory.promise; + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: ({ host }) => + (host.serverId === "srv_a" + ? origin + : new FakeDaemonClient()) as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: (host.serverId === "srv_a" + ? origin + : makeConnectedProbeClient(5)) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label, + }), + getClientId: async () => "cid_fleet_candidates", + }, + }); + const addDirectB = () => + store.upsertDirectConnection({ + serverId: "srv_b", + endpoint: "b:6767", + label: "My B", + }); + try { + if (directFirst) await addDirectB(); + await store.upsertDirectConnection({ + serverId: "srv_c", + endpoint: "c:6767", + label: "My C", + }); + await store.upsertDirectConnection({ serverId: "srv_a", endpoint: "a:6767" }); + await waitForHostOnline(store, "srv_a"); + directory.resolve({ + self: { serverId: "srv_a", label: "A" }, + hosts: ["b", "c"].map((target) => ({ + registrationId: `reg_${target}`, + serverId: `srv_${target}`, + label: `Fleet ${target.toUpperCase()}`, + source: { id: "user", key: target }, + metadata: {}, + enabled: true, + status: "online" as const, + targetDescription: `${target}:6767`, + lastOnlineAt: null, + nextRetryAt: null, + error: null, + })), + }); + await vi.waitFor(() => + expect(store.getHosts()).toContainEqual( + expect.objectContaining({ + serverId: "srv_b", + connections: expect.arrayContaining([ + { id: "via:srv_a", type: "routed", originServerId: "srv_a" }, + ]), + }), + ), + ); + await waitForHostOnline(store, "srv_b"); + if (!directFirst) await addDirectB(); + const targets = store.getHosts().filter((host) => host.serverId === "srv_b"); + expect(targets).toHaveLength(1); + expect(targets[0]?.connections).toEqual( + expect.arrayContaining([ + { id: "via:srv_a", type: "routed", originServerId: "srv_a" }, + { id: "direct:b:6767", type: "directTcp", endpoint: "b:6767", useTls: false }, + ]), + ); + expect(targets[0]?.connections).toHaveLength(2); + expect(targets[0]?.label).toBe(directFirst ? "My B" : "Fleet B"); + expect(store.getSnapshot("srv_b")?.connectionStatus).toBe("online"); + expect(store.getHosts()).toHaveLength(3); + const targetC = store.getHosts().find((host) => host.serverId === "srv_c"); + expect(targetC).toMatchObject({ + serverId: "srv_c", + label: "My C", + connections: [ + { id: "direct:c:6767", type: "directTcp", endpoint: "c:6767", useTls: false }, + { id: "via:srv_a", type: "routed", originServerId: "srv_a" }, + ], + }); + await waitForHostOnline(store, "srv_c"); + expect(store.getSnapshot("srv_c")?.connectionStatus).toBe("online"); + } finally { + store.syncHosts([]); + } + }, + ); + it.each(["active", "inactive", "background"] as const)( "keeps reconnect enabled through inactive/background and resumes immediately (mounted %s)", async (currentState) => { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 55298ce5a1..812e88aea3 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,3 +1,6 @@ +import { createHostChannelTransport } from "@getpaseo/client/internal/hosts"; +import type { OwnedSubscription } from "@getpaseo/client"; +import type { HostDirectory } from "@getpaseo/protocol/hosts"; import { useSyncExternalStore, useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; @@ -113,6 +116,7 @@ export type ConnectionLinkImport = | { status: "cancelled" }; export type ActiveConnection = + | { type: "routed"; endpoint: string; display: string } | { type: "directTcp"; endpoint: string; display: string } | { type: "directSocket"; endpoint: string; display: "socket" } | { type: "directPipe"; endpoint: string; display: "pipe" } @@ -234,6 +238,12 @@ const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000; function toActiveConnection(connection: HostConnection): ActiveConnection { + if (connection.type === "routed") + return { + type: "routed", + endpoint: connection.originServerId, + display: `via ${connection.originServerId}`, + }; if (connection.type === "directSocket") { return { type: "directSocket", @@ -518,7 +528,9 @@ function probeIntervalForConnection( return PROBE_MAX_BACKOFF_MS; } -function createDefaultDeps(): HostRuntimeControllerDeps { +function createDefaultDeps( + resolveOrigin: (serverId: string) => DaemonClient | null = () => null, +): HostRuntimeControllerDeps { const browserHostAvailable = typeof getDesktopHost()?.browser?.executeAutomationCommand === "function"; const browserAutomationCapabilities = browserHostAvailable @@ -549,6 +561,15 @@ function createDefaultDeps(): HostRuntimeControllerDeps { ...(host.password ? { password: host.password } : {}), localCredential: () => readDesktopManagedLocalCredential(connection), } satisfies Omit; + if (connection.type === "routed") { + const origin = resolveOrigin(connection.originServerId); + if (!origin) throw new Error(`Origin host ${connection.originServerId} is unavailable`); + return new DaemonClient({ + ...base, + url: "ws://routed-host/ws", + transportFactory: createHostChannelTransport(origin, host.serverId, clientId, null), + }); + } if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ ...base, @@ -597,15 +618,40 @@ function createDefaultDeps(): HostRuntimeControllerDeps { }, }); }, - connectToDaemon: ({ host, connection, timeoutMs }) => - connectToDaemon(connection, { + connectToDaemon: async ({ host, connection, timeoutMs }) => { + if (connection.type === "routed") { + const origin = resolveOrigin(connection.originServerId); + if (!origin?.isConnected) + throw new Error(`Origin host ${connection.originServerId} is unavailable`); + const client = new DaemonClient({ + url: "ws://routed-host/ws", + clientId: crypto.randomUUID(), + transportFactory: createHostChannelTransport( + origin, + host.serverId, + crypto.randomUUID(), + null, + ), + reconnect: { enabled: false }, + appVersion: resolveAppVersion() ?? undefined, + }); + try { + await client.connect(); + return { client, serverId: host.serverId, hostname: null }; + } catch (error) { + await client.close(); + throw error; + } + } + return connectToDaemon(connection, { ...(host.serverId ? { serverId: host.serverId } : {}), ...(host.password ? { password: host.password } : {}), localCredential: () => readDesktopManagedLocalCredential(connection), ...(timeoutMs !== undefined ? { timeoutMs } : {}), capabilities: appCapabilities, trace: nativePerformanceTrace, - }), + }); + }, getClientId: () => getOrCreateClientId(), mountClientHandlers: ({ client, host }) => { const unmountServerData = mountServerDataPushRouter({ @@ -1488,6 +1534,11 @@ interface AgentDirectoryRefreshInput { export class HostRuntimeStore { private controllers = new Map(); + private hostObservations = new Map< + string, + { client: DaemonClient; observation: OwnedSubscription; unsubscribe: () => void } + >(); + private fleetDirectoryDemand = new Map void>(); private serverListeners = new Map void>>(); private globalListeners = new Set<() => void>(); private hostListListeners = new Set<() => void>(); @@ -1518,7 +1569,7 @@ export class HostRuntimeStore { replicaRowStore?: ReplicaRowStore; revokePushNotifications?: typeof revokePushNotifications; }) { - this.deps = input?.deps ?? createDefaultDeps(); + this.deps = input?.deps ?? createDefaultDeps((serverId) => this.getClient(serverId)); this.storage = input?.storage ?? AsyncStorage; this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore()); this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications; @@ -2258,6 +2309,11 @@ export class HostRuntimeStore { if (nextIds.has(serverId)) { continue; } + this.hostObservations.get(serverId)?.unsubscribe(); + void this.hostObservations.get(serverId)?.observation.release(); + this.hostObservations.delete(serverId); + this.fleetDirectoryDemand.get(serverId)?.(); + this.fleetDirectoryDemand.delete(serverId); this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.connectionStatusStartedAtByServer.delete(serverId); @@ -2314,6 +2370,7 @@ export class HostRuntimeStore { const snapshot = controller.getSnapshot(); this.syncSessionReplica(snapshot.serverId, snapshot); this.syncDirectoryConnection(snapshot.serverId); + this.syncHostDiscovery(snapshot.serverId); this.emit(snapshot.serverId); }); void controller @@ -2332,6 +2389,56 @@ export class HostRuntimeStore { } } + private syncHostDiscovery(serverId: string): void { + const client = this.getClient(serverId); + const previous = this.hostObservations.get(serverId); + if (previous?.client === client) return; + previous?.unsubscribe(); + void previous?.observation.release(); + this.hostObservations.delete(serverId); + const host = this.hosts.find((candidate) => candidate.serverId === serverId); + if ( + !client?.isConnected || + !client.getLastServerInfoMessage()?.features?.connectedHosts || + host?.connections.some((connection) => connection.type === "routed") + ) + return; + const observation = client.observeHosts(); + const apply = (directory: HostDirectory) => { + if (this.hostObservations.get(serverId)?.client !== client) return; + for (const target of directory.hosts) { + if (!target.serverId || target.serverId === serverId) continue; + const next = upsertHostConnectionInProfiles({ + profiles: this.hosts, + serverId: target.serverId, + label: target.label ?? undefined, + connection: { + id: `via:${serverId}`, + type: "routed", + originServerId: serverId, + }, + }); + if (next !== this.hosts) { + this.setHostsAndSync(next); + void this.persistHosts(); + } + if (!this.fleetDirectoryDemand.has(target.serverId)) + this.fleetDirectoryDemand.set( + target.serverId, + this.acquireDirectoryDemand(target.serverId), + ); + } + }; + const unsubscribe = observation.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "hosts.changed") apply(message.payload.directory); + }, + }); + this.hostObservations.set(serverId, { client, observation, unsubscribe }); + void observation.ready.then(apply).catch(() => undefined); + } + private syncSessionReplica(serverId: string, snapshot: HostRuntimeSnapshot): void { if (!snapshot.client) { return; diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 6b308547a1..c70c4941cb 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -106,6 +106,7 @@ const removeProfileIcon = ; function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string { + if (connection.type === "routed") return `Via ${connection.originServerId}`; if (connection.type === "relay") { return `${t("settings.host.badges.relay")} (${connection.relayEndpoint})`; } diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 5f04a39bd9..ff2c8b2f4b 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -1,4 +1,5 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildWorkspaceTabSnapshot, @@ -10,6 +11,7 @@ import { selectSubagentsForParent } from "@/subagents/select"; import { findPaneById, findPaneContainingTab } from "./workspace-layout-actions"; import { useWorkspaceLayoutStore } from "./workspace-layout-store"; import { useSessionStore, type Agent } from "./session-store"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; vi.mock("@react-native-async-storage/async-storage", () => { const storage = new Map(); @@ -73,7 +75,11 @@ const AGENT_DEFAULTS: Agent = { }; function makeAgent(input: Partial & Pick): Agent { - return { ...AGENT_DEFAULTS, ...input }; + const agent = { ...AGENT_DEFAULTS, ...input }; + return { + ...normalizeAgentSnapshot(projectAgentSnapshot(agent), SERVER_ID), + projectPlacement: agent.projectPlacement, + }; } function initializeAgents(agents: Agent[]): void { @@ -142,7 +148,7 @@ describe("workspace subagents integration", () => { const child = makeAgent({ id: "child-agent", - parentAgentId: "parent-agent", + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: "parent-agent" }), title: "Child agent", }); const parent = makeAgent({ @@ -186,7 +192,7 @@ describe("workspace subagents integration", () => { }); const child = makeAgent({ id: "child-agent", - parentAgentId: "parent-agent", + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: "parent-agent" }), title: "Child agent", }); @@ -205,7 +211,7 @@ describe("workspace subagents integration", () => { ).map((row) => row.id), ).toEqual(["child-agent"]); - appendAgent({ ...child, parentAgentId: null, labels: {} }); + appendAgent(makeAgent({ ...child, labels: {} })); reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual([ @@ -238,7 +244,7 @@ describe("workspace subagents integration", () => { }); const child = makeAgent({ id: "child-agent", - parentAgentId: parent.id, + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }), title: "Cross-workspace child", }); @@ -271,7 +277,7 @@ describe("workspace subagents integration", () => { }); const child = makeAgent({ id: "child-agent", - parentAgentId: parent.id, + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }), title: "Child agent", }); diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts index 5c1615d96c..8ad93f990f 100644 --- a/packages/app/src/subagents/archive-finished.test.ts +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -5,6 +5,7 @@ import type { PaseoSubagentRow, ProviderSubagentRow } from "./select"; function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow { return { kind: "paseo", + address: { serverId: "server-1", agentId: id }, id, provider: "codex", title: id, @@ -76,8 +77,8 @@ describe("createArchiveFinishedSubagents", () => { [provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: ({ agentId: id }) => { events.push(`archive:${id}`); return id === "first" ? first.promise : second.promise; }, @@ -130,8 +131,8 @@ describe("createArchiveFinishedSubagents", () => { const calls: string[] = []; const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: ({ agentId: id }) => { calls.push(id); const response = responses.shift(); if (!response) throw new Error("Unexpected archive"); @@ -204,8 +205,8 @@ describe("createArchiveFinishedSubagents", () => { ]); const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: async (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: async ({ agentId: id }) => { throw id === "first" ? firstError : secondError; }, dismissProviderSubagents: () => undefined, @@ -230,7 +231,7 @@ describe("createArchiveFinishedSubagents", () => { const current = new Map([["failed", managed("failed")]]); const archive = createArchiveFinishedSubagents([paseo("failed")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), + getManagedSubagent: ({ agentId: id }) => current.get(id), archiveManagedSubagent: async () => { current.set("failed", managed("failed", "running")); throw new Error("archive failed"); @@ -256,7 +257,7 @@ describe("createArchiveFinishedSubagents", () => { const current = new Map([["failed", managed("failed")]]); const archive = createArchiveFinishedSubagents([paseo("failed")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), + getManagedSubagent: ({ agentId: id }) => current.get(id), archiveManagedSubagent: async () => { current.set("failed", managed("failed", "initializing")); throw new Error("archive failed"); @@ -297,7 +298,7 @@ describe("createArchiveFinishedSubagents", () => { const current = new Map([["failed", managed("failed")]]); const archive = createArchiveFinishedSubagents([paseo("failed")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), + getManagedSubagent: ({ agentId: id }) => current.get(id), archiveManagedSubagent: async () => { updateSnapshot(current); throw error; @@ -325,8 +326,8 @@ describe("createArchiveFinishedSubagents", () => { const calls: string[] = []; const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: async (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: async ({ agentId: id }) => { calls.push(id); if (calls.length === 1) throw firstError; if (calls.length === 2) { @@ -384,8 +385,8 @@ describe("createArchiveFinishedSubagents", () => { const archived: string[] = []; const archive = createArchiveFinishedSubagents(rows, { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: async (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: async ({ agentId: id }) => { archived.push(id); current.set("resumed", managed("resumed", "running")); }, diff --git a/packages/app/src/subagents/archive-finished.ts b/packages/app/src/subagents/archive-finished.ts index 0f3c685591..c79c3af6f9 100644 --- a/packages/app/src/subagents/archive-finished.ts +++ b/packages/app/src/subagents/archive-finished.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import type { Agent } from "@/stores/session-store"; import type { SubagentRow } from "./select"; @@ -22,8 +23,8 @@ export type ManagedSubagentSnapshot = Pick ManagedSubagentSnapshot | undefined; - archiveManagedSubagent: (id: string) => Promise; + getManagedSubagent: (address: AgentAddress) => ManagedSubagentSnapshot | undefined; + archiveManagedSubagent: (address: AgentAddress) => Promise; dismissProviderSubagents: (ids: string[]) => void; } @@ -55,12 +56,12 @@ function eligibleSignature(rows: readonly SubagentRow[]): string { return identitySignature(rowIdentities(rows)); } -function managedRowIdentity(id: string): string { - return `paseo:${id}`; +function managedRowIdentity(address: AgentAddress): string { + return `paseo:${address.serverId}:${address.agentId}`; } function rowIdentity(row: SubagentRow): string { - return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`; + return row.kind === "paseo" ? managedRowIdentity(row.address) : `provider:${row.id}`; } function rowIdentities(rows: readonly SubagentRow[]): Set { @@ -169,7 +170,7 @@ async function runArchiveFinished( reportProgress: (completedCount: number) => void, ): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set }> { const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id); - const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id); + const paseoAddresses = rows.filter((row) => row.kind === "paseo").map((row) => row.address); let completedCount = 0; const outcome = emptyOutcome(); const retryableFailureIds = new Set(); @@ -181,15 +182,16 @@ async function runArchiveFinished( reportProgress(completedCount); } - for (const id of paseoIds) { - if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) { + for (const address of paseoAddresses) { + const id = address.agentId; + if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parentAgentId)) { try { - await deps.archiveManagedSubagent(id); + await deps.archiveManagedSubagent(address); outcome.archivedPaseoIds.push(id); } catch (error) { outcome.failures.push({ id, error }); - if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) { - retryableFailureIds.add(managedRowIdentity(id)); + if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parentAgentId)) { + retryableFailureIds.add(managedRowIdentity(address)); } } } else { diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 9c6227a34f..6c539931e1 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -1,7 +1,7 @@ export type { SubagentRow } from "./select"; export { selectSubagentsForParent, useSubagentsForParent } from "./select"; -export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; -export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent"; +export { useArchiveSubagent } from "./use-archive-subagent"; +export { useDetachSubagent } from "./use-detach-subagent"; export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy"; export { isWorkspaceRootAgent } from "./workspace-root-policy"; export { diff --git a/packages/app/src/subagents/opens-in-sheet.test.tsx b/packages/app/src/subagents/opens-in-sheet.test.tsx index 0792091135..c9b915f787 100644 --- a/packages/app/src/subagents/opens-in-sheet.test.tsx +++ b/packages/app/src/subagents/opens-in-sheet.test.tsx @@ -2,9 +2,11 @@ * @vitest-environment jsdom */ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; import { act, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { useSubagentOpensInSheet } from "./opens-in-sheet"; @@ -77,7 +79,15 @@ if (!workspaceKey) throw new Error("workspace key"); function seedParentWithChild(): SubagentRow { const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" }; - const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" }; + const child = normalizeAgentSnapshot( + projectAgentSnapshot({ + ...AGENT_DEFAULTS, + id: "child", + title: "Child", + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }), + }), + SERVER_ID, + ); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); useSessionStore.getState().setAgents( SERVER_ID, diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 38a905183d..c9d7ca4096 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -53,7 +53,14 @@ const AGENT_DEFAULTS: Agent = { }; function makeAgent(input: Partial & Pick): Agent { - return { ...AGENT_DEFAULTS, ...input }; + return { + ...AGENT_DEFAULTS, + ...input, + labels: { + ...(input.parentAgentId ? { "paseo.parent-agent-id": input.parentAgentId } : {}), + ...input.labels, + }, + }; } function setAgents(agents: Agent[]): void { @@ -73,6 +80,41 @@ afterEach(() => { }); describe("selectSubagentsForParent", () => { + it("keeps equal child IDs on different hosts qualified to the complete parent address", () => { + const parentId = "shared-parent"; + const childId = "shared-child"; + const local = makeAgent({ + id: childId, + parentAgentId: parentId, + labels: { "paseo.parent-agent-id": parentId, "paseo.parent-server-id": SERVER_ID }, + }); + const remote = makeAgent({ + id: childId, + serverId: "server-2", + parentAgentId: null, + labels: { "paseo.parent-agent-id": parentId, "paseo.parent-server-id": SERVER_ID }, + }); + setAgents([local]); + useSessionStore.getState().initializeSession("server-2", null as unknown as DaemonClient); + useSessionStore.getState().setAgents("server-2", new Map([[childId, remote]])); + try { + const rows = selectSubagentsForParent( + useSessionStore.getState(), + { serverId: SERVER_ID, parentAgentId: parentId }, + EMPTY_PENDING_ARCHIVE_IDS, + ); + expect(rows).toHaveLength(2); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ address: { serverId: SERVER_ID, agentId: childId } }), + expect.objectContaining({ address: { serverId: "server-2", agentId: childId } }), + ]), + ); + } finally { + useSessionStore.getState().clearSession("server-2"); + } + }); + it("hides cached provider children when the host does not support them", () => { useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { kind: "upsert", @@ -315,6 +357,7 @@ describe("selectSubagentsForParent", () => { expect(rows).toEqual([ { kind: "paseo", + address: { serverId: SERVER_ID, agentId: "child" }, id: "child", provider: "claude", title: "Review child", @@ -327,6 +370,7 @@ describe("selectSubagentsForParent", () => { }, ]); expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ + "address", "createdAt", "description", "id", @@ -371,7 +415,7 @@ describe("selectSubagentsForParent", () => { setAgents([ makeAgent({ id: "parent-a" }), makeAgent({ id: "parent-b" }), - { ...child, parentAgentId: "parent-b" }, + { ...child, parentAgentId: "parent-b", labels: { "paseo.parent-agent-id": "parent-b" } }, ]); expect( @@ -409,7 +453,7 @@ describe("selectSubagentsForParent", () => { serverId: SERVER_ID, parentAgentId: "parent", }, - new Set(["child-b"]), + new Set([`${SERVER_ID}:child-b`]), ); expect(rows.map((row) => row.id)).toEqual(["child-a"]); @@ -424,7 +468,7 @@ describe("selectSubagentsForParent", () => { serverId: SERVER_ID, parentAgentId: "parent", }, - new Set(["child"]), + new Set([`${SERVER_ID}:child`]), ); expect(rows).toEqual([]); @@ -541,7 +585,7 @@ describe("createSubagentsForParentSelector", () => { const rows = createSubagentsForParentSelector( params, - new Set(["child-b"]), + new Set([`${SERVER_ID}:child-b`]), )(useSessionStore.getState()); expect(rows.map((row) => row.id)).toEqual(["child-a"]); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 164a4d1280..29f76e34d4 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -1,5 +1,7 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; import { useEffect, useMemo } from "react"; -import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent"; +import { usePendingArchiveAgentKeys } from "@/hooks/use-archive-agent"; import equal from "fast-deep-equal"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSessionStore, type Agent } from "@/stores/session-store"; @@ -8,6 +10,7 @@ import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messa export interface PaseoSubagentRow { kind: "paseo"; + address: AgentAddress; id: Agent["id"]; provider: Agent["provider"]; title: Agent["title"]; @@ -55,6 +58,7 @@ const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = []; function toSubagentRow(agent: Agent): SubagentRow { return { kind: "paseo", + address: { serverId: agent.serverId, agentId: agent.id }, id: agent.id, provider: agent.provider, title: agent.title, @@ -72,21 +76,19 @@ export function selectSubagentsForParent( params: SelectSubagentsParams, pendingArchiveIds: ReadonlySet, ): SubagentRow[] { - const agents = state.sessions[params.serverId]?.agents; - if (!agents || agents.size === 0) { - return EMPTY_SUBAGENT_ROWS; - } - const rows: SubagentRow[] = []; - for (const agent of agents.values()) { - if ( - agent.archivedAt || - pendingArchiveIds.has(agent.id) || - agent.parentAgentId !== params.parentAgentId - ) { - continue; + for (const [serverId, session] of Object.entries(state.sessions)) { + for (const agent of session.agents.values()) { + const parent = getParentAddressFromLabels(agent.labels, serverId); + if ( + agent.archivedAt || + pendingArchiveIds.has(`${serverId}:${agent.id}`) || + parent?.serverId !== params.serverId || + parent.agentId !== params.parentAgentId + ) + continue; + rows.push(toSubagentRow(agent)); } - rows.push(toSubagentRow(agent)); } if (rows.length === 0) { @@ -104,12 +106,21 @@ export function createSubagentsForParentSelector( params: SelectSubagentsParams, pendingArchiveIds: ReadonlySet, ): (state: SessionStoreSnapshot) => SubagentRow[] { - let previousAgents: Map | undefined; + let previousAgents: Array<[string, Map]> = []; let previousRows: SubagentRow[] | null = null; return (state) => { - const agents = state.sessions[params.serverId]?.agents; - if (previousRows && agents === previousAgents) { + const agents = Object.entries(state.sessions).map( + ([serverId, session]) => [serverId, session.agents] as [string, Map], + ); + if ( + previousRows && + agents.length === previousAgents.length && + agents.every( + ([serverId, map], index) => + previousAgents[index]?.[0] === serverId && previousAgents[index]?.[1] === map, + ) + ) { return previousRows; } @@ -159,7 +170,7 @@ export function selectProviderSubagentsForParent( export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] { const { serverId, parentAgentId } = params; - const pendingArchiveIds = usePendingArchiveAgentIds(serverId); + const pendingArchiveIds = usePendingArchiveAgentKeys(); const selectPaseoRows = useMemo( () => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds), [serverId, parentAgentId, pendingArchiveIds], diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index df009583be..0397795541 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -13,6 +13,7 @@ function row( ): PaseoSubagentRow { return { kind: "paseo", + address: overrides.address ?? { serverId: "server-1", agentId: overrides.id }, id: overrides.id, provider: overrides.provider ?? "codex", title: overrides.title ?? `Agent ${overrides.id}`, diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 43f659c534..d759da431e 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,3 +1,5 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import { useHosts } from "@/runtime/host-runtime"; import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -35,17 +37,17 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ export interface SubagentsTrackProps { serverId: string; rows: SubagentRow[]; - onOpenSubagent: (id: string) => void; + onOpenSubagent: (address: AgentAddress) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; /** * Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on * close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list. */ opensInSheet: (row: SubagentRow) => boolean; - onArchiveSubagent: (id: string) => void; + onArchiveSubagent: (address: AgentAddress) => void; onArchiveFinished?: () => void; archiveFinishedStatus?: ArchiveFinishedStatus; - onDetachSubagent?: (id: string) => void; + onDetachSubagent?: (address: AgentAddress) => void; } const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" }; @@ -105,7 +107,7 @@ export function SubagentsTrack({ ) : null} {rows.map((row) => ( void; + onOpenSubagent: (address: AgentAddress) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; opensInSheet: boolean; - onArchiveSubagent: (id: string) => void; - onDetachSubagent?: (id: string) => void; + onArchiveSubagent: (address: AgentAddress) => void; + onDetachSubagent?: (address: AgentAddress) => void; } function SubagentsTrackRow({ @@ -198,22 +200,24 @@ function SubagentsTrackRow({ }: SubagentsTrackRowProps): ReactElement { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); - const presentation = useRowPresentation(row, serverId); + const rowServerId = row.kind === "paseo" ? row.address.serverId : serverId; + const hostLabel = useHosts().find((host) => host.serverId === rowServerId)?.label ?? rowServerId; + const presentation = useRowPresentation(row, rowServerId); const displayLabel = presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; const handlePress = useCallback(() => { if (row.kind === "provider") { onOpenProviderSubagent(row.parentAgentId, row.id); } else { - onOpenSubagent(row.id); + onOpenSubagent(row.address); } }, [onOpenProviderSubagent, onOpenSubagent, row]); const handleArchivePress = useCallback(() => { - onArchiveSubagent(row.id); - }, [onArchiveSubagent, row.id]); + if (row.kind === "paseo") onArchiveSubagent(row.address); + }, [onArchiveSubagent, row]); const handleDetachPress = useCallback(() => { - onDetachSubagent?.(row.id); - }, [onDetachSubagent, row.id]); + if (row.kind === "paseo") onDetachSubagent?.(row.address); + }, [onDetachSubagent, row]); const actionsAlwaysVisible = isNative || isCompact; const renderRow = useCallback( @@ -225,7 +229,10 @@ function SubagentsTrackRow({ {displayLabel} {row.kind === "paseo" ? ( - + <> + + + ) : ( )} @@ -253,6 +260,8 @@ function SubagentsTrackRow({ row.kind, row.id, serverId, + rowServerId, + hostLabel, ], ); diff --git a/packages/app/src/subagents/use-archive-finished.ts b/packages/app/src/subagents/use-archive-finished.ts index e7653f054e..0152dc9ba3 100644 --- a/packages/app/src/subagents/use-archive-finished.ts +++ b/packages/app/src/subagents/use-archive-finished.ts @@ -1,3 +1,4 @@ +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; import { useEffect, useMemo, useSyncExternalStore } from "react"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useSessionStore } from "@/stores/session-store"; @@ -32,8 +33,16 @@ export function useArchiveFinishedSubagents({ () => createArchiveFinishedSubagents([], { parentAgentId, - getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id), - archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }), + getManagedSubagent: (address) => { + const agent = useSessionStore + .getState() + .sessions[address.serverId]?.agents.get(address.agentId); + if (!agent) return undefined; + const parent = getParentAddressFromLabels(agent.labels, address.serverId); + if (parent?.serverId !== serverId || parent.agentId !== parentAgentId) return undefined; + return { ...agent, parentAgentId: parent.agentId }; + }, + archiveManagedSubagent: archiveAgent, dismissProviderSubagents: (ids) => { useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids); }, diff --git a/packages/app/src/subagents/use-archive-subagent.ts b/packages/app/src/subagents/use-archive-subagent.ts index b00f9bb871..e8189c1830 100644 --- a/packages/app/src/subagents/use-archive-subagent.ts +++ b/packages/app/src/subagents/use-archive-subagent.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; @@ -13,17 +14,12 @@ export type { ResolveArchiveSubagentDialogInput, } from "./archive-subagent"; -export interface UseArchiveSubagentInput { - serverId: string; -} - -export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void { +export function useArchiveSubagent(): (address: AgentAddress) => void { const { archiveAgent } = useArchiveAgent(); - const { serverId } = input; const toast = useToast(); return useCallback( - (subagentId: string) => { + ({ serverId, agentId: subagentId }: AgentAddress) => { void requestArchiveSubagent( { serverId, subagentId }, { @@ -37,6 +33,6 @@ export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: }, ); }, - [archiveAgent, serverId, toast], + [archiveAgent, toast], ); } diff --git a/packages/app/src/subagents/use-detach-subagent.ts b/packages/app/src/subagents/use-detach-subagent.ts index 36b2f2a560..120aedb872 100644 --- a/packages/app/src/subagents/use-detach-subagent.ts +++ b/packages/app/src/subagents/use-detach-subagent.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; import { i18n } from "@/i18n/i18next"; @@ -14,16 +15,11 @@ export type { ResolveDetachSubagentDialogInput, } from "./detach-subagent"; -export interface UseDetachSubagentInput { - serverId: string; -} - -export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: string) => void { - const { serverId } = input; +export function useDetachSubagent(): (address: AgentAddress) => void { const toast = useToast(); return useCallback( - (subagentId: string) => { + ({ serverId, agentId: subagentId }: AgentAddress) => { void requestDetachSubagent( { serverId, subagentId }, { @@ -46,6 +42,6 @@ export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: s }, ); }, - [serverId, toast], + [toast], ); } diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index a476c32089..0f0590bb9e 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -205,6 +205,57 @@ describe("upsertHostConnectionInProfiles", () => { path: "/tmp/paseo.sock", }; + it.each([false, true])( + "keeps destinations distinct when a persisted route shares an origin (C already saved: %s)", + (savedC) => { + const route: HostConnection = { id: "via:srv_a", type: "routed", originServerId: "srv_a" }; + const directB: HostConnection = { + id: "direct:b:6767", + type: "directTcp", + endpoint: "b:6767", + }; + const directC: HostConnection = { + id: "direct:c:6767", + type: "directTcp", + endpoint: "c:6767", + }; + const b: HostProfile = { + ...makeHost("srv_b"), + label: "My B", + connections: [route, directB], + preferredConnectionId: route.id, + }; + const c: HostProfile = { + ...makeHost("srv_c"), + label: "My C", + connections: [directC], + preferredConnectionId: directC.id, + }; + const profiles = upsertHostConnectionInProfiles({ + profiles: savedC ? [b, c] : [b], + serverId: "srv_c", + label: "Fleet C", + connection: route, + }); + expect(profiles).toHaveLength(2); + expect(profiles[0]).toEqual(b); + expect(profiles[1]).toMatchObject({ + serverId: "srv_c", + label: savedC ? "My C" : "Fleet C", + connections: savedC ? [directC, route] : [route], + preferredConnectionId: savedC ? directC.id : route.id, + }); + expect( + upsertHostConnectionInProfiles({ + profiles, + serverId: "srv_b", + label: "Fleet B", + connection: route, + }), + ).toBe(profiles); + }, + ); + it("gives a newly discovered host the default appearance", () => { const [profile] = upsertHostConnectionInProfiles({ profiles: [], diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 0962c02c7c..fb3150a168 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -51,7 +51,14 @@ export interface RelayHostConnection { daemonPublicKeyB64: string; } +export interface RoutedHostConnection { + id: string; + type: "routed"; + originServerId: string; +} + export type HostConnection = + | RoutedHostConnection | DirectTcpHostConnection | DirectSocketHostConnection | DirectPipeHostConnection @@ -128,6 +135,8 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool return false; } + if (left.type === "routed" && right.type === "routed") + return left.originServerId === right.originServerId; if (left.type === "directTcp" && right.type === "directTcp") { return left.endpoint === right.endpoint && (left.useTls ?? false) === (right.useTls ?? false); } @@ -236,9 +245,11 @@ export function upsertHostConnectionInProfiles(input: { const matchingIndexes = existing.reduce((matches, daemon, index) => { if ( daemon.serverId === serverId || - daemon.connections.some((existingConnection) => - hostConnectionEquals(existingConnection, normalizedConnection), - ) + // A routed candidate names its origin; its destination belongs to the host profile. + (normalizedConnection.type !== "routed" && + daemon.connections.some((existingConnection) => + hostConnectionEquals(existingConnection, normalizedConnection), + )) ) { matches.push(index); } @@ -399,6 +410,7 @@ export function createRemoteSshHostConnection(input: { } const StoredHostConnectionSchema = z.discriminatedUnion("type", [ + z.strictObject({ id: z.string(), type: z.literal("routed"), originServerId: z.string().min(1) }), z.strictObject({ id: z.string().optional(), type: z.literal("directTcp"), @@ -446,6 +458,7 @@ export const StoredHostRegistrySchema = z.array(StoredHostProfileSchema); type StoredHostConnection = z.infer; function normalizeStoredConnection(connection: StoredHostConnection): HostConnection | null { + if (connection.type === "routed") return connection; if (connection.type === "directTcp") { try { const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint)); diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index c1c70c640e..13a7bcd14b 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,6 +1,6 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; -import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; import { TURN_LIVENESS_IDLE, type ActiveTurnIdentity, @@ -98,7 +98,8 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: ? new Date(snapshot.attentionTimestamp) : null; const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null; - const parentAgentId = getParentAgentIdFromLabels(snapshot.labels); + const parent = getParentAddressFromLabels(snapshot.labels, serverId); + const parentAgentId = parent?.serverId === serverId ? parent.agentId : null; // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. // Old daemons expose only status. Normalize that legacy signal once so the rest // of the app consumes one activity shape. diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index b882b1f30a..5942b45621 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -146,6 +146,8 @@ export async function buildClientConfig( | "buildDesktopTransportUrl" > = defaultDaemonConnectionDependencies, ): Promise { + if (connection.type === "routed") + throw new Error("Routed host connections are resolved by HostRuntime"); const clientId = await deps.getClientId(); const desktopTransportFactory = deps.createDesktopTransportFactory(); const base = { diff --git a/packages/client/package.json b/packages/client/package.json index 57c937656d..d3f5cabb63 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -28,6 +28,10 @@ "./node": { "types": "./dist/node/index.d.ts", "default": "./dist/node/index.js" + }, + "./internal/hosts": { + "types": "./dist/hosts/index.d.ts", + "default": "./dist/hosts/index.js" } }, "publishConfig": { diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index e6b15d06bf..a7bb17121c 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1735,3 +1735,38 @@ test("canceled timeline handles and captured state are collectible while their A ); expect(result.stdout).toContain('"phase":"API alive"'); }, 20000); + +test("qualified parents preserve foreign identity without resolving an equal local agent ID", async () => { + const { client, ws } = await connectClient(); + const localParent = client.agents.ref("same-parent"); + expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" }); + const creating = client.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/repo/sdk", + parent: { serverId: "srv_parent_elsewhere", agentId: "same-parent" }, + labels: { task: "preserved" }, + }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).not.toHaveProperty("callerAgentId"); + expect(request).toMatchObject({ + labels: { + task: "preserved", + "paseo.parent-agent-id": "same-parent", + "paseo.parent-server-id": "srv_parent_elsewhere", + }, + }); + ws.message( + sessionMessage({ + type: "status", + payload: { + status: "agent_created", + requestId: request.requestId, + agentId: "agent_sdk", + agent: createAgent(), + }, + }), + ); + await creating; + await client.close(); + expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c9f9c3fa9d..455d377634 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,6 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; +export type { AgentAddress } from "@getpaseo/protocol/addresses"; import { createHostActions, type PaseoHostActions } from "./hosts/index.js"; export type { PaseoHostActions } from "./hosts/index.js"; export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts"; @@ -249,7 +252,7 @@ export interface PaseoAgentCreateOptions { onEvent?: (snapshot: CreationSnapshot) => void; config: PaseoAgentConfig; cwd: string; - parent?: string | PaseoAgentHandle; + parent?: string | AgentAddress | PaseoAgentHandle; title?: PaseoAgentSessionConfig["title"]; env?: CreateAgentRequestMessage["env"]; prompt?: string; @@ -343,6 +346,7 @@ export interface PaseoAgentTimelineHandle { export interface PaseoAgentHandle { readonly id: string; + readonly address: AgentAddress; /** * `workspaceId` through `archivedAt` mirror the last snapshot this handle * observed. A handle from `ref()` reads `null` for all of them until @@ -530,10 +534,12 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient { function toDaemonAgentCreateOptions( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, + serverId?: string, ): CreateAgentRequestOptions { const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; const { provider, model } = parseProviderModel(providerModel); + const relationship = parent ? encodeParent(parent, serverId) : {}; return { ...requestOptions, config: { @@ -545,7 +551,12 @@ function toDaemonAgentCreateOptions( ...(providerOptions !== undefined ? { providerOptions } : {}), }, ...(placement ? { workspaceId: placement.workspaceId } : {}), - ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), + ...(parent + ? { + ...relationship, + labels: { ...requestOptions.labels, ...relationship.labels }, + } + : {}), ...(prompt !== undefined ? { initialPrompt: prompt } : {}), }; } @@ -592,7 +603,13 @@ export function createPaseoApi( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, ) => { - const agent = await daemonClient.createAgent(toDaemonAgentCreateOptions(options, placement)); + const agent = await daemonClient.createAgent( + toDaemonAgentCreateOptions( + options, + placement, + daemonClient.getLastServerInfoMessage()?.serverId, + ), + ); return createAgentHandle(agent); }; const terminals = createTerminalActions(daemonClient, async (workspaceId) => { @@ -717,7 +734,18 @@ export function createPaseoApi( openWorkspace(daemonClient, createWorkspaceHandle, input, requestId), create: async ({ requestId, agent, ...options }) => { const result = await daemonClient.createWorkspace( - { ...options, ...(agent ? { agent: toDaemonAgentCreateOptions(agent) } : {}) }, + { + ...options, + ...(agent + ? { + agent: toDaemonAgentCreateOptions( + agent, + undefined, + daemonClient.getLastServerInfoMessage()?.serverId, + ), + } + : {}), + }, requestId, ); if (result.error || !result.workspace) { @@ -870,8 +898,14 @@ function createAgentHandleFactory( const id = typeof agent === "string" ? agent : agent.id; let current = typeof agent === "string" ? null : agent; + let serverId = daemonClient.getLastServerInfoMessage()?.serverId; const handle: PaseoAgentHandle = { id, + get address() { + serverId ??= daemonClient.getLastServerInfoMessage()?.serverId; + if (!serverId) throw new Error("Connect to a host before obtaining an agent address"); + return { serverId, agentId: id }; + }, timeline: { append: (item) => daemonClient.appendAgentTimelineItem(id, item), refetch: async (options) => { @@ -1020,8 +1054,16 @@ function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string { return typeof workspace === "string" ? workspace : workspace.id; } -function resolveAgentId(agent: string | PaseoAgentHandle): string { - return typeof agent === "string" ? agent : agent.id; +function encodeParent( + parent: string | AgentAddress | PaseoAgentHandle, + serverId?: string, +): Pick { + if (typeof parent === "string") return { callerAgentId: parent }; + const address = "address" in parent ? parent.address : parent; + if (!serverId) throw new Error("Connect to a host before creating a child"); + return address.serverId === serverId + ? { callerAgentId: address.agentId, labels: parentAddressLabels(address) } + : { labels: parentAddressLabels(address) }; } function parseProviderModel(selection: string): { provider: string; model: string } { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 770ae5f50f..1dd6665592 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -12,6 +12,10 @@ "./*": { "types": "./dist/*.d.ts", "default": "./dist/*.js" + }, + "./addresses": { + "types": "./dist/addresses.d.ts", + "default": "./dist/addresses.js" } }, "publishConfig": { diff --git a/packages/protocol/src/addresses.ts b/packages/protocol/src/addresses.ts new file mode 100644 index 0000000000..6a8328b93c --- /dev/null +++ b/packages/protocol/src/addresses.ts @@ -0,0 +1,5 @@ +/** Agent identity is qualified by its daemon; transport and reachability are separate. */ +export interface AgentAddress { + serverId: string; + agentId: string; +} diff --git a/packages/protocol/src/agent-labels.ts b/packages/protocol/src/agent-labels.ts index 2bf05950dd..e2eb008093 100644 --- a/packages/protocol/src/agent-labels.ts +++ b/packages/protocol/src/agent-labels.ts @@ -1,3 +1,5 @@ +import type { AgentAddress } from "./addresses.js"; +export const PARENT_SERVER_ID_LABEL = "paseo.parent-server-id"; export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id"; const OPEN_AGENT_TAB_LABEL_PREFIX = "paseo.open-agent-tab."; @@ -29,3 +31,23 @@ export function hasOpenAgentTab(labels: Record | null | undefin ([label, value]) => isOpenAgentTabLabel(label) && value === "true", ); } + +// Absent parent server label means the same host. +export function getParentAddressFromLabels( + labels: Record | null | undefined, + serverId: string, +): AgentAddress | null { + const agentId = getParentAgentIdFromLabels(labels); + if (!agentId) return null; + const parentServerId = labels?.[PARENT_SERVER_ID_LABEL]; + return { + serverId: + typeof parentServerId === "string" && parentServerId.trim() + ? parentServerId.trim() + : serverId, + agentId, + }; +} +export function parentAddressLabels(parent: AgentAddress): Record { + return { [PARENT_AGENT_ID_LABEL]: parent.agentId, [PARENT_SERVER_ID_LABEL]: parent.serverId }; +} diff --git a/packages/protocol/src/paseo-tool-presentation/catalog.ts b/packages/protocol/src/paseo-tool-presentation/catalog.ts index 1706134523..0c35b128b7 100644 --- a/packages/protocol/src/paseo-tool-presentation/catalog.ts +++ b/packages/protocol/src/paseo-tool-presentation/catalog.ts @@ -195,6 +195,7 @@ export const PASEO_TOOL_CATALOG = { context: workspaceContext(), detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] }, }, + list_hosts: { label: "List hosts" }, list_workspaces: { label: "List workspaces" }, rename_workspace: { label: "Rename workspace", diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index ec5bceb1cb..488867c657 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -5,7 +5,7 @@ import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization"; import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; -import { projectTimelineRows } from "./timeline-projection.js"; +import { projectTimelineRows, selectItemsByProjectedLimit } from "./timeline-projection.js"; const DEFAULT_MAX_ITEMS = 0; const MAX_TOOL_INPUT_CHARS = 400; @@ -210,6 +210,24 @@ export function curateAgentActivity( : "No activity to display."; } +export function summarizeAgentActivity(timeline: AgentTimelineItem[], limit?: number) { + const selection = selectItemsByProjectedLimit({ + items: timeline, + direction: "tail", + limit: limit ?? 0, + }); + const { totalProjected, shownProjected } = selection; + const noun = totalProjected === 1 ? "activity" : "activities"; + const countHeader = + limit && shownProjected < totalProjected + ? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})` + : `Showing all ${totalProjected} ${noun}`; + return { + updateCount: timeline.length, + content: `${countHeader}\n\n${curateAgentActivity(selection.items)}`, + }; +} + interface ForkCursorBoundary { timelineEpoch: string; cursor: { epoch: string; seq: number }; diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 3f2a9b7775..c7cf537ef9 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -1,3 +1,4 @@ +import { isSameOrDescendantPath } from "../path-utils.js"; import type { AgentListItemPayload, AgentSnapshotPayload, @@ -278,6 +279,66 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt }; } +function parseTimestamp(value: string | null | undefined): number { + if (!value) { + return 0; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function resolveAgentListActivityTime(agent: AgentListItemPayload): number { + return Math.max( + parseTimestamp(agent.updatedAt), + parseTimestamp(agent.lastUserMessageAt), + parseTimestamp(agent.attentionTimestamp), + parseTimestamp(agent.archivedAt), + parseTimestamp(agent.createdAt), + ); +} + +function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number { + const attentionDelta = + Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false); + if (attentionDelta !== 0) { + return attentionDelta; + } + + const statusOrder = { + running: 0, + initializing: 1, + idle: 2, + error: 3, + closed: 4, + } as Record; + const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999); + if (statusDelta !== 0) { + return statusDelta; + } + + return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a); +} + +export function selectAgentListItems( + agents: readonly AgentSnapshotPayload[], + options: { + cwd?: string; + statuses?: AgentSnapshotPayload["status"][]; + sinceHours?: number; + limit?: number; + } = {}, +): AgentListItemPayload[] { + const statusFilter = options.statuses?.length ? new Set(options.statuses) : null; + const sinceMs = Date.now() - (options.sinceHours ?? 48) * 60 * 60 * 1000; + return agents + .map(toAgentListItemPayload) + .filter((agent) => !options.cwd || isSameOrDescendantPath(options.cwd, agent.cwd)) + .filter((agent) => !statusFilter || statusFilter.has(agent.status)) + .filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs) + .sort(compareAgentListItems) + .slice(0, options.limit ?? 50); +} + export function toRecentProviderSessionDescriptorPayload( session: ImportableProviderSession & { provider: string }, options: RecentProviderSessionProjectionOptions, diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index bf6d50480b..6b120abb1c 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,3 +1,4 @@ +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js"; import type { Logger } from "pino"; @@ -10,7 +11,10 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; -import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; +import { + getParentAgentIdFromLabels, + getParentAddressFromLabels, +} from "@getpaseo/protocol/agent-labels"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; export type AgentUnarchiveController = Pick; @@ -292,7 +296,7 @@ async function resolvePromptSource( manager: Pick, storage: AgentStorage, ): Promise { - if (!source) return undefined; + if (!source || source.title) return source; const title = ( manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title )?.trim(); @@ -401,6 +405,7 @@ export interface SetupFinishNotificationParams { agentStorage: AgentStorage; childAgentId: string; callerAgentId: string; + childServerId?: string; requireParentOwnership?: boolean; /** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */ dispatchResult?: Promise; @@ -429,6 +434,7 @@ const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; interface FinishNotificationBodyInput { childAgentId: string; + serverId?: string; title: string; reason: FinishNotificationReason; lastAssistantMessage: string | null; @@ -438,12 +444,14 @@ interface FinishNotificationBodyInput { function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; const sections = [statusLine]; + if (params.serverId) sections.push(`Server: ${params.serverId}`); if (params.reason === "needs permission" && params.permissionRequest) { sections.push( "Respond with `respond_to_permission` using the `agentId` and `requestId` below.", `\n${JSON.stringify( { agentId: params.childAgentId, + ...(params.serverId ? { serverId: params.serverId } : {}), requestId: params.permissionRequest.id, request: params.permissionRequest, }, @@ -480,6 +488,101 @@ const armedFinishNotifications = new WeakMap< Map> >(); +/** Creation waits and accepted follow-ups share notification ownership. */ +export function setupFinishWait(params: { + agentManager: AgentManager; + agentStorage: AgentStorage; + originServerId?: string; + childServerId?: string; + childAgentId: string; + callerAgentId: string; + wait(signal: AbortSignal): Promise; + afterPermission(requestId: string, signal: AbortSignal): Promise; + logger: Logger; +}): () => void { + const controller = new AbortController(); + const byManager = + armedFinishNotifications.get(params.agentManager) ?? + new Map>(); + armedFinishNotifications.set(params.agentManager, byManager); + const key = JSON.stringify([ + params.childServerId ?? null, + params.childAgentId, + params.callerAgentId, + ]); + const registrations = byManager.get(key) ?? new Set(); + byManager.set(key, registrations); + for (const previous of registrations) previous.stop(); + byManager.set(key, registrations); + let superseded = Promise.resolve(false); + const stop = () => { + controller.abort(); + registrations.delete(registration); + if (registrations.size === 0 && byManager.get(key) === registrations) byManager.delete(key); + }; + const registration: ArmedFinishNotification = { + stop, + supersedeWhen(accepted) { + superseded = superseded.then(async (prior) => prior || (await accepted)); + }, + }; + registrations.add(registration); + async function watch(): Promise { + while (!controller.signal.aborted) { + const result = await params.wait(controller.signal); + if (controller.signal.aborted || (await superseded) || result.status === "timeout") return; + const parent = getParentAddressFromLabels( + result.final?.labels, + params.childServerId ?? "self", + ); + if ( + parent?.agentId !== params.callerAgentId || + parent.serverId !== (params.originServerId ?? "self") + ) + return; + if ((await params.agentStorage.get(params.callerAgentId))?.archivedAt) return; + const reason = { idle: "finished", error: "errored", permission: "needs permission" }[ + result.status + ] as FinishNotificationReason; + const permissionRequest = result.final?.pendingPermissions[0]; + await sendPromptToAgent({ + agentManager: params.agentManager, + agentStorage: params.agentStorage, + agentId: params.callerAgentId, + prompt: formatFinishNotificationBody({ + childAgentId: params.childAgentId, + serverId: params.childServerId, + title: result.final?.title ?? params.childAgentId, + reason, + lastAssistantMessage: result.lastMessage, + permissionRequest, + }), + source: { + kind: "agent-notification", + agentId: params.childAgentId, + title: result.final?.title ?? params.childAgentId, + event: finishNotificationEvents[reason], + }, + activeTurnBehavior: "steer", + unarchive: false, + logger: params.logger, + }); + if (result.status !== "permission" || !permissionRequest) return; + await params.afterPermission(permissionRequest.id, controller.signal); + } + } + void watch() + .catch((error) => { + if (!controller.signal.aborted) + params.logger.warn( + { err: error, agentId: params.childAgentId }, + "Child finish wait stopped", + ); + }) + .finally(stop); + return stop; +} + export function setupFinishNotification(params: SetupFinishNotificationParams): () => void { const { agentManager, @@ -499,7 +602,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map(); armedFinishNotifications.set(agentManager, armedByManager); - const armedKey = JSON.stringify([childAgentId, callerAgentId]); + const armedKey = JSON.stringify([params.childServerId ?? null, childAgentId, callerAgentId]); const registrations = armedByManager.get(armedKey) ?? new Set(); armedByManager.set(armedKey, registrations); const previous = [...registrations]; diff --git a/packages/server/src/server/agent/host-operations/index.ts b/packages/server/src/server/agent/host-operations/index.ts new file mode 100644 index 0000000000..b69f0775a9 --- /dev/null +++ b/packages/server/src/server/agent/host-operations/index.ts @@ -0,0 +1,393 @@ +import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; +import type { ActiveTurnBehavior, AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { + DaemonClient, + type FetchWorkspacesEntry, + type WaitForFinishResult, +} from "@getpaseo/client/internal/daemon-client"; +import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types"; +import type { HostDirectory } from "@getpaseo/protocol/hosts"; +import { OWNER_PERMISSIONS } from "../../authorization/index.js"; +import type { ConnectedHosts } from "../../connected-hosts/index.js"; +import { resolveRequiredProviderModel } from "../mcp-shared.js"; +import { selectAgentListItems } from "../agent-projections.js"; +import { summarizeAgentActivity } from "../activity-curator.js"; +import { expandUserPath } from "../../path-utils.js"; +import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js"; + +export type HostOperation = ( + input: Record, + context: PaseoToolExecutionContext, +) => Promise; +export interface HostOperations { + createAgent: HostOperation; + createWorkspace: HostOperation; + listWorkspaces: HostOperation; + sendPrompt: HostOperation; + getStatus: HostOperation; + getActivity: HostOperation; + listAgents: HostOperation; + wait(agentId: string, signal: AbortSignal): Promise; + observe( + agentId: string, + handler: (snapshot: AgentSnapshotPayload | null) => void, + signal: AbortSignal, + ): Promise<() => Promise>; +} +export interface ToolHosts { + directory(): Promise; + use(serverId: string, action: (client: DaemonClient) => Promise): Promise; +} + +/** Daemon MCP has the daemon's authority, bounded by the destination's admission of its link. */ +export function createToolHosts(hosts: ConnectedHosts): ToolHosts { + return { + directory: () => hosts.list(), + async use(serverId, action) { + const lifetime = new AbortController(); + const transportFactory: DaemonTransportFactory = () => { + const opens = new Set<() => void>(); + const closes = new Set<(event?: unknown) => void>(); + const errors = new Set<(event: unknown) => void>(); + const messages = new Set<(data: unknown, binary: boolean) => void>(); + let closed = false; + let route: Awaited> | undefined; + void hosts + .openRoute({ + serverId, + scopeId: randomUUID(), + sourceId: "mcp", + originSessionId: "mcp", + permissions: OWNER_PERMISSIONS, + signal: lifetime.signal, + frame: (data) => { + for (const listener of messages) listener(data, typeof data !== "string"); + }, + ended: (code, message) => { + for (const listener of errors) + listener(Object.assign(new Error(message), { code, serverId })); + for (const listener of closes) listener(); + }, + }) + .then((opened) => { + route = opened; + if (closed) opened.close(); + else for (const listener of opens) listener(); + return undefined; + }) + .catch((error) => { + for (const listener of errors) listener(error); + for (const listener of closes) listener(error); + }); + return { + send: (data) => { + if (!route) throw new Error("Host route is not open"); + route.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data); + }, + close: () => { + closed = true; + route?.close(); + }, + onOpen: (listener) => { + opens.add(listener); + return () => opens.delete(listener); + }, + onClose: (listener) => { + closes.add(listener); + return () => closes.delete(listener); + }, + onError: (listener) => { + errors.add(listener); + return () => errors.delete(listener); + }, + onMessage: (listener) => { + messages.add(listener); + return () => messages.delete(listener); + }, + }; + }; + const client = new DaemonClient({ + url: "ws://host-route/ws", + clientId: randomUUID(), + transportFactory, + reconnect: { enabled: false }, + }); + try { + await client.connect(); + return await action(client); + } finally { + lifetime.abort(); + await client.close(); + } + }, + }; +} + +const remoteCreate = z + .object({ + provider: z.string().min(1), + title: z.string().trim().min(1).max(60), + initialPrompt: z.string().min(1), + workspaceId: z.string().min(1), + labels: z.record(z.string(), z.string()).optional(), + notifyOnFinish: z.boolean().optional(), + idempotencyKey: z.string().optional(), + settings: z + .object({ + modeId: z.string().optional(), + thinkingOptionId: z.string().optional(), + features: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + }) + .strict(); +const result = (structuredContent: unknown): PaseoToolResult => ({ + content: [], + structuredContent, +}); + +export function createHostResolver(input: { + serverId: string; + local: HostOperations; + hosts: () => ToolHosts; + callerAgentId?: string; + lockedCwd?: string; +}) { + return { + directory: () => input.hosts().directory(), + async use( + serverId: string | undefined, + action: (host: HostOperations) => Promise, + ): Promise { + const destination = serverId ?? input.serverId; + if (destination === input.serverId) return action(input.local); + if (input.lockedCwd) + throw new Error("This caller is restricted to its local working directory"); + return input.hosts().use(destination, async (client) => + action({ + async createAgent(args) { + const parsed = remoteCreate.parse(args); + const { provider, model } = resolveRequiredProviderModel(parsed.provider); + const workspace = (await client.fetchWorkspaces({})).entries.find( + (row) => row.id === parsed.workspaceId, + ); + if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`); + const labels = { + ...parsed.labels, + ...(input.callerAgentId + ? parentAddressLabels({ agentId: input.callerAgentId, serverId: input.serverId }) + : {}), + }; + const snapshot = await client.createAgent({ + config: { + provider, + model, + cwd: workspace.workspaceDirectory, + title: parsed.title, + modeId: parsed.settings?.modeId, + thinkingOptionId: parsed.settings?.thinkingOptionId, + featureValues: parsed.settings?.features, + }, + workspaceId: parsed.workspaceId, + labels, + initialPrompt: parsed.initialPrompt, + idempotencyKey: parsed.idempotencyKey, + }); + return result({ + agentId: snapshot.id, + type: snapshot.provider, + status: snapshot.status, + cwd: snapshot.cwd, + workspaceId: snapshot.workspaceId, + currentModeId: snapshot.currentModeId, + availableModes: snapshot.availableModes, + }); + }, + async sendPrompt(args) { + const agentId = args.agentId as string; + if (args.sessionMode !== undefined) + await client.setAgentMode(agentId, args.sessionMode as string); + await client.sendMessage(agentId, args.prompt as string, { + sourceAgentId: input.callerAgentId, + activeTurnBehavior: + (args.activeTurnBehavior as ActiveTurnBehavior | undefined) ?? + (input.callerAgentId ? "steer" : "interrupt"), + }); + const agent = (await client.fetchAgent(agentId))?.agent; + if (!agent) throw new Error(`Agent ${agentId} not found`); + return result({ + success: true, + status: agent.status, + guidance: + input.callerAgentId && args.notifyOnFinish !== false + ? "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives." + : "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.", + }); + }, + async getStatus(args) { + const agent = (await client.fetchAgent(args.agentId as string))?.agent; + if (!agent) throw new Error(`Agent ${args.agentId} not found`); + return result({ status: agent.status, snapshot: agent }); + }, + async getActivity(args) { + const agentId = args.agentId as string; + const limit = args.limit as number | undefined; + const timeline = await client.fetchAgentTimeline(agentId, { + direction: "tail", + limit: limit ?? 0, + }); + const agent = (await client.fetchAgent(agentId))?.agent; + if (!agent) throw new Error(`Agent ${agentId} not found`); + return result({ + agentId, + ...summarizeAgentActivity( + timeline.entries.map((entry) => entry.item), + limit, + ), + currentModeId: agent.currentModeId, + }); + }, + async listAgents(args) { + const { entries } = await client.fetchAgents({ + includeBackground: args.includeBackground === true, + filter: { includeArchived: args.includeArchived === true }, + }); + return result({ + agents: selectAgentListItems( + entries.map((entry) => entry.agent), + { + cwd: (args.cwd as string | undefined)?.trim() + ? expandUserPath(args.cwd as string) + : undefined, + statuses: args.statuses as AgentSnapshotPayload["status"][] | undefined, + sinceHours: args.sinceHours as number | undefined, + limit: args.limit as number | undefined, + }, + ), + }); + }, + async listWorkspaces(args) { + const { entries } = await client.fetchWorkspaces({ + includeBackground: args.includeBackground === true, + }); + return result({ workspaces: entries.map(workspaceSummary) }); + }, + async createWorkspace(args) { + const isolation = z.enum(["local", "worktree"]).parse(args.isolation); + const path = z.string().min(1).parse(args.path); + const response = await client.createWorkspace({ + source: + isolation === "local" + ? { kind: "directory", path, projectId: args.projectId as string | undefined } + : { + kind: "worktree", + cwd: path, + projectId: args.projectId as string | undefined, + action: + args.mode === "checkout-branch" || args.mode === "checkout-pr" + ? "checkout" + : "branch-off", + baseBranch: args.baseBranch as string | undefined, + branchName: args.branchName as string | undefined, + refName: args.branch as string | undefined, + githubPrNumber: args.prNumber as number | undefined, + worktreeSlug: args.worktreeSlug as string | undefined, + }, + title: args.title as string | undefined, + background: args.background as boolean | undefined, + }); + if (response.error || !response.workspace) + throw new Error(response.error ?? "Workspace creation failed"); + return result(workspaceSummary(response.workspace)); + }, + async observe(agentId, handler, signal) { + const observation = client.observeTimeline([agentId], { signal }); + const refresh = async () => handler((await client.fetchAgent(agentId))?.agent ?? null); + const unsubscribe = observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "agent_stream" || message.payload.agentId !== agentId) return; + const event = message.payload.event; + if ( + event.type === "permission_resolved" || + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_canceled" || + event.type === "turn_failed" + ) + void refresh().catch(() => handler(null)); + }, + error: () => handler(null), + }); + try { + await observation.ready; + await refresh(); + } catch (error) { + unsubscribe(); + await observation.release(); + throw error; + } + return async () => { + unsubscribe(); + await observation.release(); + }; + }, + wait: (agentId, signal) => { + const close = () => void client.close(); + signal.addEventListener("abort", close, { once: true }); + return client + .waitForFinish(agentId, 0) + .finally(() => signal.removeEventListener("abort", close)); + }, + }), + ); + }, + }; +} +function workspaceSummary(workspace: FetchWorkspacesEntry) { + return { + workspaceId: workspace.id, + projectId: workspace.projectId, + cwd: workspace.workspaceDirectory, + isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local", + kind: workspace.workspaceKind === "checkout" ? "local_checkout" : workspace.workspaceKind, + title: workspace.title, + background: workspace.background, + }; +} + +/** Wait pauses at permissions; observe only until that request clears before re-arming it. */ +export async function waitForPermissionChange( + host: HostOperations, + agentId: string, + requestId: string, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + let changed!: () => void; + let failed!: (error: unknown) => void; + const ready = new Promise((resolve, reject) => { + changed = resolve; + failed = reject; + }); + const abort = () => failed(signal.reason ?? new Error("Finish wait stopped")); + signal.addEventListener("abort", abort, { once: true }); + let release: (() => Promise) | undefined; + try { + release = await host.observe( + agentId, + (snapshot) => { + if (!snapshot) failed(new Error(`Agent ${agentId} is unavailable`)); + else if (!snapshot.pendingPermissions.some((request) => request.id === requestId)) + changed(); + }, + signal, + ); + await ready; + } finally { + signal.removeEventListener("abort", abort); + await release?.(); + } +} diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 30f28426b1..f7d4288e87 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,10 @@ +import { + createHostResolver, + waitForPermissionChange, + type HostOperation, + type HostOperations, + type ToolHosts, +} from "../host-operations/index.js"; import { AgentDirectory } from "../directory.js"; import { stat } from "node:fs/promises"; import { z } from "zod"; @@ -16,14 +23,12 @@ import { AgentSnapshotPayloadSchema, WorkspaceScriptPayloadSchema, } from "../../messages.js"; -import type { AgentListItemPayload } from "../../messages.js"; import { buildStoredAgentPayload, - toAgentListItemPayload, + selectAgentListItems, toAgentPayload, } from "../agent-projections.js"; -import { curateAgentActivity } from "../activity-curator.js"; -import { selectItemsByProjectedLimit } from "../timeline-projection.js"; +import { summarizeAgentActivity } from "../activity-curator.js"; import type { AgentStorage } from "../agent-storage.js"; import { AgentNotFoundError, ensureAgentLoaded } from "../agent-loading.js"; import { isStoredAgentProviderAvailable } from "../../persistence-hooks.js"; @@ -72,6 +77,7 @@ import { type PromptDispatchDisposition, sendPromptToAgent, setupFinishNotification, + setupFinishWait, waitForAgentRunStartWithTimeout, } from "../agent-prompt.js"; import { respondToAgentPermission } from "../permission-response.js"; @@ -110,6 +116,8 @@ import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-confi import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; export interface PaseoToolHostDependencies { + serverId?: string; + hosts?: () => ToolHosts; agentManager: AgentManager; agentStorage: AgentStorage; terminalManager?: TerminalManager | null; @@ -165,22 +173,8 @@ export interface PaseoToolHostDependencies { logger: Logger; } -function parseTimestamp(value: string | null | undefined): number { - if (!value) { - return 0; - } - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? 0 : parsed; -} - -function resolveAgentListActivityTime(agent: AgentListItemPayload): number { - return Math.max( - parseTimestamp(agent.updatedAt), - parseTimestamp(agent.lastUserMessageAt), - parseTimestamp(agent.attentionTimestamp), - parseTimestamp(agent.archivedAt), - parseTimestamp(agent.createdAt), - ); +function settledStatus(status: string): "error" | "idle" { + return status === "error" ? "error" : "idle"; } interface ProviderSummary { @@ -328,28 +322,6 @@ function toProviderSummary(entry: { }; } -function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number { - const attentionDelta = - Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false); - if (attentionDelta !== 0) { - return attentionDelta; - } - - const statusOrder = { - running: 0, - initializing: 1, - idle: 2, - error: 3, - closed: 4, - } as Record; - const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999); - if (statusDelta !== 0) { - return statusDelta; - } - - return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a); -} - function resolveScheduleProviderAndModel(params: { provider?: string; defaultProvider: AgentProvider; @@ -685,7 +657,66 @@ function createToolCatalog( return result.data; }; + const localHostOperations = new Map(); const tools = new Map(); + const hostTools = new Map([ + ["create_agent", "createAgent"], + ["create_workspace", "createWorkspace"], + ["list_workspaces", "listWorkspaces"], + ["send_agent_prompt", "sendPrompt"], + ["get_agent_status", "getStatus"], + ["get_agent_activity", "getActivity"], + ["list_agents", "listAgents"], + ] as const satisfies ReadonlyArray); + const hostResolver = createHostResolver({ + serverId: options.serverId ?? "self", + callerAgentId, + lockedCwd: callerContext?.lockedCwd, + hosts: () => { + if (!options.hosts) throw new Error("Connected hosts are not configured"); + return options.hosts(); + }, + local: { + createAgent: (args, context) => localHostOperations.get("create_agent")!(args, context), + createWorkspace: (args, context) => + localHostOperations.get("create_workspace")!(args, context), + listWorkspaces: (args, context) => localHostOperations.get("list_workspaces")!(args, context), + sendPrompt: (args, context) => localHostOperations.get("send_agent_prompt")!(args, context), + getStatus: (args, context) => localHostOperations.get("get_agent_status")!(args, context), + getActivity: (args, context) => localHostOperations.get("get_agent_activity")!(args, context), + listAgents: (args, context) => localHostOperations.get("list_agents")!(args, context), + async observe(agentId, handler, signal) { + const unsubscribe = agentManager.subscribe( + (event) => { + if (event.type === "agent_state") handler(toAgentPayload(event.agent)); + }, + { agentId, replayState: true }, + ); + const stop = () => unsubscribe(); + signal.addEventListener("abort", stop, { once: true }); + return async () => { + signal.removeEventListener("abort", stop); + unsubscribe(); + }; + }, + async wait(agentId, signal) { + const settled = await agentManager.waitForAgentEvent(agentId, { + signal, + waitForActive: true, + }); + const snapshot = agentManager.getAgent(agentId); + const final = snapshot + ? await serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger) + : null; + return { + status: settled.permission ? "permission" : settledStatus(settled.status), + final, + lastMessage: settled.lastMessage, + error: final?.lastError ?? null, + }; + }, + }, + }); const registerTool = ( name: string, config: PaseoToolConfig, @@ -695,6 +726,76 @@ function createToolCatalog( if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) { return; } + const operation = hostTools.get(name as Parameters[0]); + if (operation) { + localHostOperations.set(name, handler); + const original = config.inputSchema; + const selector = { + serverId: z + .string() + .min(1) + .optional() + .describe("Destination server ID from list_hosts; omit for this host."), + }; + config = { + ...config, + inputSchema: + original instanceof z.ZodObject + ? original.extend(selector) + : { ...(original as z.ZodRawShape), ...selector }, + }; + handler = async ({ serverId, ...input }, context) => { + const destination = serverId ?? options.serverId; + try { + const response = await hostResolver.use(serverId, (host) => + host[operation](input, context), + ); + let childAgentId: string | undefined; + if (name === "create_agent") { + childAgentId = (response.structuredContent as { agentId: string }).agentId; + } else if (name === "send_agent_prompt" && destination !== options.serverId) { + childAgentId = input.agentId as string; + } + if (childAgentId && callerAgentId && input.notifyOnFinish !== false) { + setupFinishWait({ + agentManager, + agentStorage, + originServerId: options.serverId, + childServerId: destination, + childAgentId, + callerAgentId, + logger: childLogger, + wait: (signal) => + hostResolver.use(destination, (host) => host.wait(childAgentId, signal)), + afterPermission: (requestId, signal) => + hostResolver.use(destination, (host) => + waitForPermissionChange(host, childAgentId, requestId, signal), + ), + }); + } + return { + ...response, + structuredContent: { + ...(response.structuredContent as object), + ...(destination ? { serverId: destination } : {}), + }, + }; + } catch (error) { + if (!options.serverId) throw error; + const failure = { + serverId: destination, + error: error instanceof Error ? error.message : String(error), + ...(error instanceof Error && "code" in error ? { code: error.code } : {}), + ...(error instanceof Error && "outcome" in error ? { outcome: error.outcome } : {}), + }; + return { + isError: true, + content: [{ type: "text", text: JSON.stringify(failure) }], + structuredContent: failure, + }; + } + }; + } tools.set(name, { name, title: config.title, @@ -1332,6 +1433,19 @@ function createToolCatalog( }); } + registerTool( + "list_hosts", + { + title: "List hosts", + description: "Discover this host and registered destination hosts for fleet tools.", + inputSchema: {}, + }, + async () => ({ + content: [], + structuredContent: ensureValidJson(await hostResolver.directory()), + }), + ); + registerTool( "create_workspace", { @@ -1593,10 +1707,15 @@ function createToolCatalog( workspaceId: resolvedArgs.workspaceId, thinking: parsedArgs.settings?.thinkingOptionId, features: parsedArgs.settings?.features, - labels: parsedArgs.labels, + labels: { + ...parsedArgs.labels, + ...(callerAgentId && !resolvedArgs.detached && options.serverId + ? { "paseo.parent-server-id": options.serverId } + : {}), + }, mode: parsedArgs.settings?.modeId, promptFailure: "throw", - notifyOnFinish, + notifyOnFinish: false, detached: resolvedArgs.detached, callerAgentId, callerContext, @@ -2003,6 +2122,7 @@ function createToolCatalog( agentManager, agentStorage, childAgentId: agentId, + childServerId: options.serverId, callerAgentId, waitForTurnStart: false, dispatchResult, @@ -2023,6 +2143,7 @@ function createToolCatalog( agentManager, agentStorage, childAgentId: agentId, + childServerId: options.serverId, callerAgentId, waitForTurnStart: true, logger: childLogger, @@ -2158,8 +2279,6 @@ function createToolCatalog( }) => { const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined; const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd; - const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; - const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000; if (!options.workspaceRegistry) throw new Error("Workspace registry is not configured"); const directory = new AgentDirectory({ manager: agentManager, @@ -2179,13 +2298,10 @@ function createToolCatalog( new Set(providerSnapshotManager.listRegisteredProviderIds()), ), }); - const agents = (await directory.list({ includeBackground, includeArchived })) - .map(toAgentListItemPayload) - .filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd)) - .filter((agent) => !statusFilter || statusFilter.has(agent.status)) - .filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs) - .sort(compareAgentListItems) - .slice(0, limit); + const agents = selectAgentListItems( + await directory.list({ includeBackground, includeArchived }), + { cwd: requestedCwd, statuses, sinceHours, limit }, + ); return { content: [], @@ -3182,29 +3298,12 @@ function createToolCatalog( const timeline = agentManager.getTimeline(agentId); const snapshot = agentManager.getAgent(agentId); - const selection = selectItemsByProjectedLimit({ - items: timeline, - direction: "tail", - limit: limit ?? 0, - }); - const curatedContent = curateAgentActivity(selection.items); - const { totalProjected, shownProjected } = selection; - - const noun = totalProjected === 1 ? "activity" : "activities"; - const countHeader = - limit && shownProjected < totalProjected - ? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})` - : `Showing all ${totalProjected} ${noun}`; - - const contentWithCount = `${countHeader}\n\n${curatedContent}`; - return { content: [], structuredContent: ensureValidJson({ agentId, - updateCount: timeline.length, + ...summarizeAgentActivity(timeline, limit), currentModeId: snapshot?.currentModeId ?? null, - content: contentWithCount, }), }; }, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 73e668735f..5125a0b923 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1393,6 +1393,11 @@ export async function createPaseoDaemon( const createAgentToolHostDependencies = ( runtime: PaseoToolRuntimeContext, ): PaseoToolHostDependencies => ({ + serverId, + hosts: () => { + if (!wsServer) throw new Error("Daemon is not accepting host operations"); + return wsServer.toolHosts(); + }, agentManager, agentStorage, terminalManager, diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 0005946316..0efd8d86d6 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1,3 +1,7 @@ +import { z } from "zod"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { afterEach, expect, test } from "vitest"; import { createPaseoApi } from "@getpaseo/client"; import { createHostClient, createHostChannelTransport } from "@getpaseo/client/node"; @@ -8,8 +12,10 @@ const cleanup: (() => Promise)[] = []; afterEach(async () => { for (const close of cleanup.splice(0).toReversed()) await close(); }); -async function daemon(): Promise { - const instance = await createTestPaseoDaemon(); +async function daemon( + options: Parameters[0] = {}, +): Promise { + const instance = await createTestPaseoDaemon(options); cleanup.push(() => instance.close()); return instance; } @@ -1303,3 +1309,174 @@ test("cancelling a logical scope during admission settles it and releases its ow endpoint.close(); await delivery.close(); }); + +test("agent MCP discovers hosts, creates children on self and B, and notifies their parent", async () => { + const parentPrompts: string[] = []; + const a = await daemon({ + agentClients: createTestAgentClients({ + onStartTurn: (prompt, config) => { + if (config.title === "Fleet parent") parentPrompts.push(JSON.stringify(prompt)); + }, + }), + }); + const b = await daemon(); + const { paseo, client: aClient } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${b.port}`, label: "Fleet B" }); + const directory = await online(paseo); + const workspace = await paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test" }, + title: "Fleet parent", + }); + const mcp = new Client({ name: "fleet-delegation", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const result = await mcp.callTool({ name: "list_hosts", arguments: {} }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + self: { serverId: directory.self.serverId }, + hosts: [expect.objectContaining({ serverId: directory.hosts[0]!.serverId, label: "Fleet B" })], + }); + const call = async (name: string, args: Record) => { + const response = await mcp.callTool({ name, arguments: args }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return z.record(z.string(), z.unknown()).parse(response.structuredContent); + }; + const serverId = directory.hosts[0]!.serverId!; + const destination = await call("create_workspace", { + serverId, + isolation: "local", + path: b.staticDir, + title: "Destination workspace", + }); + expect(await call("list_workspaces", { serverId })).toMatchObject({ + serverId, + workspaces: [expect.objectContaining({ workspaceId: destination.workspaceId })], + }); + const self = await call("create_agent", { + title: "Self child", + provider: "claude/claude-test", + initialPrompt: "Finish local delegation", + }); + const remote = await call("create_agent", { + serverId, + workspaceId: destination.workspaceId, + title: "B child", + provider: "claude/claude-test", + initialPrompt: "Finish destination delegation", + }); + expect(self.serverId).toBe(directory.self.serverId); + expect(remote.serverId).toBe(serverId); + const bApi = await api(b); + expect( + (await bApi.paseo.agents.ref(String(remote.agentId)).refresh())?.agent.labels, + ).toMatchObject({ + "paseo.parent-agent-id": parent.id, + "paseo.parent-server-id": directory.self.serverId, + }); + await expect.poll(() => parentPrompts.join("\n")).toContain(`Agent ${self.agentId}`); + await expect.poll(() => parentPrompts.join("\n")).toContain(`Agent ${remote.agentId}`); + expect(parentPrompts.join("\n")).toContain(`Server: ${serverId}`); + expect(parentPrompts.join("\n")).toContain(`Server: ${directory.self.serverId}`); + + await aClient.createAgent({ + agentId: String(remote.agentId), + provider: "claude", + model: "claude-test", + cwd: a.staticDir, + workspaceId: workspace.id, + title: "Unrelated equal ID on A", + }); + expect(await call("get_agent_status", { serverId, agentId: remote.agentId })).toMatchObject({ + serverId, + snapshot: { id: remote.agentId, title: "B child", cwd: b.staticDir }, + }); + expect(await call("get_agent_status", { agentId: remote.agentId })).toMatchObject({ + serverId: directory.self.serverId, + snapshot: { title: "Unrelated equal ID on A" }, + }); + const beforeFollowup = parentPrompts.filter((prompt) => + prompt.includes(`Agent ${remote.agentId}`), + ).length; + expect(beforeFollowup).toBe(1); + expect( + await call("send_agent_prompt", { + serverId, + agentId: remote.agentId, + prompt: "REMOTE_FOLLOWUP_MARKER", + sessionMode: "full-access", + }), + ).toMatchObject({ success: true, serverId, guidance: expect.stringContaining("notified") }); + expect(await call("get_agent_activity", { serverId, agentId: remote.agentId })).toMatchObject({ + serverId, + agentId: remote.agentId, + currentModeId: "full-access", + content: expect.stringContaining("REMOTE_FOLLOWUP_MARKER"), + }); + expect(await call("list_agents", { serverId })).toMatchObject({ + serverId, + agents: expect.arrayContaining([ + expect.objectContaining({ id: remote.agentId, title: "B child", cwd: b.staticDir }), + ]), + }); + expect(await call("list_agents", {})).toMatchObject({ + serverId: directory.self.serverId, + agents: expect.arrayContaining([ + expect.objectContaining({ + id: remote.agentId, + title: "Unrelated equal ID on A", + cwd: a.staticDir, + }), + ]), + }); + await expect + .poll(() => parentPrompts.filter((prompt) => prompt.includes(`Agent ${remote.agentId}`)).length) + .toBe(2); + expect(parentPrompts.findLast((prompt) => prompt.includes(`Agent ${remote.agentId}`))).toContain( + `Server: ${serverId}`, + ); + expect((await aClient.fetchAgent(String(remote.agentId)))?.agent.lastUserMessageAt).toBeNull(); + expect(JSON.stringify(await aClient.fetchAgentTimeline(String(remote.agentId)))).not.toContain( + "REMOTE_FOLLOWUP_MARKER", + ); + + for (const selector of [{}, { serverId: directory.self.serverId }]) { + expect(await call("get_agent_status", { ...selector, agentId: self.agentId })).toMatchObject({ + serverId: directory.self.serverId, + snapshot: { title: "Self child" }, + }); + const previous = parentPrompts.filter((prompt) => + prompt.includes(`Agent ${self.agentId}`), + ).length; + expect( + await call("send_agent_prompt", { + ...selector, + agentId: self.agentId, + prompt: "SELF_FOLLOWUP_MARKER", + }), + ).toMatchObject({ + success: true, + serverId: directory.self.serverId, + disposition: "turn_started", + }); + expect(await call("get_agent_activity", { ...selector, agentId: self.agentId })).toMatchObject({ + serverId: directory.self.serverId, + content: expect.stringContaining("SELF_FOLLOWUP_MARKER"), + }); + expect(await call("list_agents", selector)).toMatchObject({ + serverId: directory.self.serverId, + agents: expect.arrayContaining([ + expect.objectContaining({ id: self.agentId, title: "Self child" }), + ]), + }); + await expect + .poll(() => parentPrompts.filter((prompt) => prompt.includes(`Agent ${self.agentId}`)).length) + .toBe(previous + 1); + } +}); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 2c8da4ed42..b985503fd7 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,4 @@ +import { createToolHosts } from "./agent/host-operations/index.js"; import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index"; import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js"; import { ConnectedHosts } from "./connected-hosts/index.js"; @@ -558,6 +559,10 @@ export class VoiceAssistantWebSocketServer { private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; private readonly connectedHosts: ConnectedHosts; + + toolHosts() { + return createToolHosts(this.connectedHosts); + } private readonly passwordHash: string | undefined; private readonly credentialSource: DaemonAuthConfig | undefined; private readonly worktreesRoot: string | undefined; diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 6e606e35ba..2867261c32 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -59,6 +59,8 @@ Create a new client after `close()`. Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, `background`, and `labels`. +`parent` accepts a host-local agent ID, an `AgentAddress` (`{ serverId, agentId }`), or an agent handle. A handle’s `address` keeps its host identity after disconnect. When creating through `hosts.client(serverId)`, pass the parent handle or complete address; the SDK carries parent metadata to the destination. + `background` is workspace creation intent. It cannot accompany an existing `workspaceId`. See [background workspaces](./workspaces.md) for caller inheritance and inclusive discovery. @@ -80,6 +82,7 @@ See [background workspaces](./workspaces.md) for caller inheritance and inclusiv | Member | Result | Behavior | | ------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `string` | Stable daemon agent ID. | +| `address` | `AgentAddress` | Complete `{ serverId, agentId }` identity; requires a connected host to establish it. | | `workspaceId` | `string \| null` | Current workspace placement. | | `cwd` | `string \| null` | Current working directory. | | `status` | Agent status or `null` | Current lifecycle status. |