Compare commits

..
Author SHA1 Message Date
Mohamed Boudra b29bd98b5f feat(plugins): accept inline SVG and component icons
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.

The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
2026-10-19 07:51:46 +02:00
87 changed files with 1204 additions and 4352 deletions
-1
View File
@@ -46,7 +46,6 @@ 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 |
-3
View File
@@ -4,9 +4,6 @@ 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
```
-55
View File
@@ -1,55 +0,0 @@
# 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` on POSIX systems. 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:<B principal>:<A serverId>:<A producer>`, 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
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).
-4
View File
@@ -31,10 +31,6 @@ 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.
+27 -9
View File
@@ -240,8 +240,9 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/reference.md#host-ui).
@@ -332,7 +333,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
@@ -400,6 +401,25 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
### Plugin icons
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
`icon` is a Lucide name, `{ svg }`, or a component.
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
The daemon applies it to provider and usage-source icon files; the app applies it to inline
icons. Change the rules there, never at a call site.
- The app validates each icon once, at registration, through `parsePluginIcon` in
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
- That module is the only code that branches on the icon form. Surfaces draw with
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
component icon never reach an SVG element.
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
each attachment, and a component cannot be stored.
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
markup string.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
@@ -491,15 +511,13 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
string is never inline SVG or a URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
-1
View File
@@ -37725,7 +37725,6 @@
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
+14 -16
View File
@@ -1,7 +1,6 @@
import { expect, test, type Page } from "../support/fixtures";
import {
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
@@ -18,6 +17,7 @@ import {
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
import {
closeMobileAgentSidebar,
closeMobileExplorer,
@@ -205,21 +205,19 @@ test.describe("touch screen wide enough for the desktop layout", () => {
title: "Second touch chat",
});
await openWorkspaceWithAgents(page, [first, second]);
// Repeat after an explicit edit so retained focus from an earlier visit is
// also dismissed when the same mounted editor becomes inactive again.
for (const draft of ["Keep this touch draft", "Edit this touch draft again"]) {
await composerLocator(page).fill(draft);
await expectComposerFocused(page);
// An external keyboard changes tabs without a pointer click blurring the
// editor first. The device still has a touch-primary focus policy.
await roundTripComposerTabBeforePaint(page, "Second touch chat");
await expectComposerDraft(page, draft);
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, " after return");
await expectComposerDraft(page, `${draft} after return`);
}
await composerLocator(page).fill("Keep this touch draft");
await expectComposerFocused(page);
// An external keyboard changes tabs without a pointer click blurring the
// editor first. The device still has a touch-primary focus policy.
await switchComposerTabWithKeyboard(page, "previous");
await expectComposerDraft(page, "");
await switchComposerTabWithKeyboard(page, "next");
await expectComposerDraft(page, "Keep this touch draft");
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, " after return");
await expectComposerDraft(page, "Keep this touch draft after return");
} finally {
await workspace.cleanup();
}
@@ -1,66 +0,0 @@
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();
}
});
@@ -0,0 +1,138 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "plugin-icons-e2e";
const SVG_PATH = "M4 12l5 5L20 6";
const pluginClientSource = `import React from "react";
import { Text, View } from "react-native";
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
function BadgeIcon({ size, color }) {
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function Panel() {
return <View><Text>Plugin icon panel</Text></View>;
}
const panels = [
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
];
export default function contribute(client) {
for (const panel of panels) {
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
client.addCommandCenterItem({
id: panel.id,
title: "Open " + panel.title,
icon: panel.icon,
context: "workspace",
onSelect({ openPanel }) { openPanel(panel.id); },
});
}
return () => {};
}`;
function pluginTab(page: Page, panelId: string): Locator {
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin icons", () => {
test.describe.configure({ timeout: 180_000 });
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await test.step("Command Center rows draw each icon form", async () => {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("icon panel");
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
await expect(
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
).toBeVisible();
}
await expect(
panel
.getByRole("button", { name: "Open SVG icon panel", exact: true })
.locator(`path[d="${SVG_PATH}"]`),
).toBeVisible();
await expect(
panel
.getByRole("button", { name: "Open Component icon panel", exact: true })
.getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "command-center-icons");
await page.keyboard.press("Escape");
});
await test.step("workspace panel tabs draw each icon form", async () => {
await runCommand(page, "Open Lucide icon panel");
await runCommand(page, "Open SVG icon panel");
await runCommand(page, "Open Component icon panel");
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
await expect(
pluginTab(page, "component").getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "workspace-tab-icons");
});
} finally {
try {
await client.removePlugin(PLUGIN_ID);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await rm(directory, { recursive: true, force: true });
}
}
});
});
@@ -501,59 +501,3 @@ export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
}
/** Exercise the quick-return race before a hidden pane gets a rendering frame. */
export async function roundTripComposerTabBeforePaint(
page: Page,
activeChatTitle: string,
): Promise<void> {
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
await expect(activeTab).toHaveAttribute("aria-selected", "true");
const result = await activeTab.evaluate((tab) => {
const editor = document.activeElement;
return new Promise<{ returnedBeforePaint: boolean; focused: boolean }>((resolve) => {
let switchedAway = false;
let painted = false;
let frame = 0;
const shortcut = (code: "BracketLeft" | "BracketRight") => {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: code === "BracketLeft" ? "{" : "}",
code,
altKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
}),
);
};
// Watch selection commits without reading layout: a layout read could make
// Chromium blur the hidden editor and erase the condition under test.
const observer = new MutationObserver(() => {
if (!switchedAway && tab.getAttribute("aria-selected") === "false") {
switchedAway = true;
frame = requestAnimationFrame(() => {
painted = true;
});
// Deliver the return shortcut before rendering, without a Playwright
// round trip allowing the browser to paint the inactive tab first.
shortcut("BracketRight");
} else if (switchedAway && tab.getAttribute("aria-selected") === "true") {
observer.disconnect();
cancelAnimationFrame(frame);
return resolve({
returnedBeforePaint: !painted,
focused: document.activeElement === editor,
});
}
});
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
shortcut("BracketLeft");
});
});
expect(
result.returnedBeforePaint,
"tab round trip must finish before rendering the hidden pane",
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
@@ -1,117 +0,0 @@
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 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();
}
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<IsolatedHostDaemon> {
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("");
}
+1 -6
View File
@@ -1295,12 +1295,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// Read pointer capability when restoring, without making a pointer change itself
// a focus trigger.
useLayoutEffect(() => {
const finePointer = hasFinePointer();
if (!isPaneFocused && isWeb && !finePointer) {
// Hiding a retained tab only blurs its editor after the browser renders.
// A quick return before that frame would otherwise keep touch focus.
textInputRef.current?.blur();
} else if (isPaneFocused && focusIntentRef.current && finePointer) {
if (isPaneFocused && focusIntentRef.current && hasFinePointer()) {
textInputRef.current?.focus();
}
}, [isPaneFocused]);
-315
View File
@@ -1,315 +0,0 @@
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 (
<SettingsSection title="Hosts connected to this daemon">
{client ? (
<ConnectedHosts key={serverId} client={client} connected={connected} />
) : (
<SettingsCard>
<SettingsRow label="Connect to this daemon to manage its hosts" />
</SettingsCard>
)}
</SettingsSection>
);
}
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 (
<View style={styles.section}>
{state.load.status === "loading" ? (
<SettingsCard>
<SettingsRow label="Loading connected hosts…" />
</SettingsCard>
) : null}
{state.load.status === "error" ? (
<SettingsCard>
<SettingsRow label="Could not load connected hosts" error={state.load.error}>
<Button variant="outline" size="sm" onPress={model.start}>
Retry
</Button>
</SettingsRow>
</SettingsCard>
) : null}
{directory ? (
<HostList
directory={directory}
model={model}
canManage={canManage}
pending={state.pending !== null}
/>
) : null}
{directory && canManage && !editor ? (
<Button variant="outline" size="sm" disabled={state.pending !== null} onPress={model.add}>
Add host
</Button>
) : null}
{editor ? (
<HostEditor
key={editor.key}
editor={editor}
directory={directory}
model={model}
canManage={canManage}
manageSources={info?.permissions?.includes("access.manage") === true}
sourceId={info?.sourceId}
pending={state.pending !== null}
/>
) : null}
{state.error ? (
<SettingsCard>
<SettingsRow label="Host change failed" error={state.error}>
<Button variant="ghost" size="sm" onPress={model.dismissError}>
Dismiss
</Button>
</SettingsRow>
</SettingsCard>
) : null}
{state.pending ? (
<Text accessibilityLiveRegion="polite" style={settingsStyles.rowHint}>
Saving host change…
</Text>
) : null}
</View>
);
}
function HostList({
directory,
model,
canManage,
pending,
}: {
directory: HostDirectory;
model: ConnectedHostsModel;
canManage: boolean;
pending: boolean;
}) {
return (
<SettingsCard>
{directory.hosts.length === 0 ? (
<SettingsRow label="No hosts connected to this daemon" />
) : (
directory.hosts.map((host) => (
<HostRow
key={host.registrationId}
host={host}
model={model}
canManage={canManage}
pending={pending}
/>
))
)}
</SettingsCard>
);
}
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 (
<SettingsRow
label={host.label ?? host.serverId ?? host.registrationId}
hint={`${host.status} · ${host.enabled ? "Enabled" : "Disabled"} · ${sourceLabel(host.source.id)}`}
>
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
disabled={pending}
onPress={edit}
accessibilityLabel={`Details for ${host.label ?? host.registrationId}`}
>
Details
</Button>
{canManage ? (
<Button variant="outline" size="sm" disabled={pending} onPress={toggle}>
{host.enabled ? "Disable" : "Enable"}
</Button>
) : null}
</View>
</SettingsRow>
);
}
function HostDetails({ host }: { host: HostSummary }) {
return (
<SettingsCard>
<SettingsRow label="Registration" hint={host.registrationId} />
<SettingsRow label="Server ID" hint={host.serverId ?? "Not discovered"} />
<SettingsRow label="Connection" hint={host.targetDescription} />
<SettingsRow label="Metadata" hint={JSON.stringify(host.metadata)} />
<SettingsRow label="Status" hint={host.status} />
<SettingsRow label="Source" hint={sourceLabel(host.source.id)} />
<SettingsRow label="Source identity" hint={host.source.id} />
<SettingsRow label="Source key" hint={host.source.key} />
<SettingsRow label="Last online" hint={host.lastOnlineAt ?? "Never"} />
<SettingsRow label="Next retry" hint={host.nextRetryAt ?? "None scheduled"} />
{host.error ? <SettingsRow label={host.error.code} error={host.error.message} /> : null}
</SettingsCard>
);
}
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 (
<View style={styles.section}>
<Text style={settingsStyles.rowTitle}>{detail ? "Connected host details" : "Add host"}</Text>
{detail ? <HostDetails host={detail} /> : null}
{canManage ? (
<SettingsCard>
<SettingsInput
label="Host name"
initialValue={editor.label}
onChangeText={model.setLabel}
disabled={pending}
/>
{editSource ? (
<SettingsInput
label={detail ? "Replacement connection" : "Connection string"}
initialValue={editor.connection}
onChangeText={model.setConnection}
secureTextEntry
disabled={pending}
/>
) : null}
{detail && editSource ? (
<SettingsSwitch
label="Replace destination identity"
value={editor.replaceIdentity}
onValueChange={model.setReplaceIdentity}
disabled={pending}
/>
) : null}
</SettingsCard>
) : null}
{canManage && editSource ? (
<Field label="Metadata">
<FormTextInput
accessibilityLabel="Metadata"
initialValue={editor.metadata}
onChangeText={model.setMetadata}
multiline
editable={!pending}
size={compact ? "md" : "sm"}
/>
</Field>
) : null}
<View style={styles.actions}>
{canManage ? (
<Button
variant="default"
size="sm"
loading={pending}
disabled={pending}
onPress={model.save}
>
Save host
</Button>
) : null}
<Button variant="ghost" size="sm" disabled={pending} onPress={model.cancel}>
Cancel
</Button>
{canManage && detail && editSource ? (
<Button variant="outline" size="sm" disabled={pending} onPress={remove}>
Remove host
</Button>
) : null}
</View>
</View>
);
}
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";
}
-192
View File
@@ -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<HostDirectory>;
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<State>) {
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<Omit<Editor, "host" | "key">>) => {
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<unknown>): Promise<void> {
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<string, string>;
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<string, string>;
} 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.";
}
@@ -1,6 +1,10 @@
import { z } from "zod";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@getpaseo/plugin";
import {
PluginAttachmentItemSchema,
type PluginAttachmentItem,
type PluginSerializableIcon,
} from "@getpaseo/plugin";
import type { UserComposerAttachment } from "@/attachments/types";
export const PluginResourceComposerAttachmentSchema = z.object({
@@ -8,7 +12,8 @@ export const PluginResourceComposerAttachmentSchema = z.object({
pluginId: z.string().min(1),
sourceId: z.string().min(1),
sourceTitle: z.string().min(1),
sourceIcon: z.string().min(1),
// Validated when the source registered; a draft keeps the icon its source had then.
sourceIcon: z.union([z.string().min(1), z.object({ svg: z.string().min(1) })]),
item: PluginAttachmentItemSchema,
});
@@ -20,7 +25,7 @@ export interface PluginResourceSourceIdentity {
pluginId: string;
sourceId: string;
sourceTitle: string;
sourceIcon: string;
sourceIcon: PluginSerializableIcon;
}
export function createPluginResourceAttachment(
+14 -14
View File
@@ -2,15 +2,18 @@ import { useCallback, useMemo, useState, type ReactElement, type RefObject } fro
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin";
import type {
PluginAttachmentItem,
PluginAttachmentSourceContribution,
PluginSerializableIcon,
} from "@getpaseo/plugin";
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
import type { LucideIcon } from "lucide-react-native";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AttachmentMenuItem } from "@/composer/input/input";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useFetchQuery } from "@/data/query";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import { PluginIconView } from "../icons";
import { useInstalledPlugins } from "../registry";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
@@ -19,8 +22,8 @@ const SEARCH_STALE_TIME_MS = 30_000;
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
function SourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
return <PluginIconView icon={icon} size={ICON_SIZE.md} color={color} />;
}
const ThemedSourceIcon = withUnistyles(SourceIcon);
@@ -145,15 +148,12 @@ export function usePluginAttachmentPicker(
);
const menuItems = useMemo(
() =>
sources.map(({ key, source }) => {
const Icon = resolvePluginIcon(source.icon);
return {
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
};
}),
sources.map(({ key, source }) => ({
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon icon={source.icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
})),
[sources],
);
if (!active) return { menuItems, picker: null };
+6 -12
View File
@@ -3,19 +3,14 @@ import { withUnistyles } from "react-native-unistyles";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import type { ComposerAttachment } from "@/attachments/types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import type { PluginSerializableIcon } from "@getpaseo/plugin";
import { PluginIconView } from "../icons";
import type { PluginResourceComposerAttachment } from "./model";
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ResourceIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={ICON_SIZE.sm} color={color} />;
function ResourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
return <PluginIconView icon={icon} size={ICON_SIZE.sm} color={color} />;
}
const ThemedResourceIcon = withUnistyles(ResourceIcon);
@@ -39,12 +34,11 @@ export function PluginResourceAttachmentPill({
openLabel,
removeLabel,
}: PluginResourceAttachmentPillProps) {
const Icon = resolvePluginIcon(attachment.sourceIcon);
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
const icon = useMemo(
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
[Icon],
() => <ThemedResourceIcon icon={attachment.sourceIcon} uniProps={iconColorMapping} />,
[attachment.sourceIcon],
);
return (
<AttachmentPill
@@ -30,8 +30,9 @@ function installation(): InstalledPlugin {
function store() {
return new PluginButtonStore({
validateIconName(name) {
if (name !== "Scan") throw new Error("Unknown icon");
parseIcon(icon) {
if (icon !== "Scan") throw new Error("Unknown icon");
return icon;
},
});
}
+4 -2
View File
@@ -1,4 +1,6 @@
import { resolvePluginIcon } from "../icons";
import { parsePluginIcon } from "../icons";
import { PluginButtonStore } from "./model";
export const pluginButtonStore = new PluginButtonStore({ validateIconName: resolvePluginIcon });
export const pluginButtonStore = new PluginButtonStore({
parseIcon: (icon) => parsePluginIcon("Plugin button", icon),
});
@@ -1,7 +1,8 @@
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
export interface ButtonValidation {
validateIconName(name: string): void;
/** Returns the normalized icon, or throws naming why the icon is unusable. */
parseIcon(icon: PluginButtonIcon): PluginButtonIcon;
}
export function requireButtonId(value: string): string {
@@ -29,12 +30,6 @@ function isComponent(value: unknown): boolean {
);
}
function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): void {
if (typeof icon === "string") validation.validateIconName(icon);
else if (!isComponent(icon))
throw new Error("Plugin button icon must be a Lucide name or component");
}
function validateBehavior(
behavior: PluginButtonBehavior,
validation: ButtonValidation,
@@ -57,9 +52,9 @@ function validateBehavior(
ids.add(item.id);
if (item.kind === "separator") return { ...item };
if (item.kind !== "item") throw new Error("Invalid plugin button menu entry");
if (item.icon !== undefined) validateIcon(item.icon, validation);
return {
...item,
icon: item.icon === undefined ? undefined : validation.parseIcon(item.icon),
title: requireText(item.title, "menu item title"),
visible: optionalBoolean(item.visible, true),
disabled: optionalBoolean(item.disabled, false),
@@ -83,9 +78,9 @@ export function validateButton(
button: PluginButton,
validation: ButtonValidation,
): ResolvedPluginButton {
validateIcon(button.icon, validation);
return {
...button,
icon: validation.parseIcon(button.icon),
title: requireText(button.title, "title"),
label: button.label === undefined ? undefined : requireText(button.label, "label"),
visible: optionalBoolean(button.visible, true),
+3 -15
View File
@@ -32,7 +32,7 @@ import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { Icon } from "../icons";
import { pluginIconComponent } from "../icons";
import {
PluginEnvironmentProvider,
PluginPopoverContent,
@@ -79,6 +79,7 @@ function ButtonEnvironment({ view, children }: { view: ButtonView; children: Rea
function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }) {
const color = view.props.theme.colors.foregroundMuted;
const size = view.entry.placement === "composer" ? 14 : 16;
const Icon = pluginIconComponent(icon);
return (
<View
style={styles.icon}
@@ -87,24 +88,11 @@ function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{typeof icon === "string" ? (
<Icon name={icon} size={size} color={color} />
) : (
renderCustomIcon(icon, view, size, color)
)}
<Icon {...view.props} size={size} color={color} />
</View>
);
}
function renderCustomIcon(
CustomIcon: Exclude<PluginButtonIcon, string>,
view: ButtonView,
size: number,
color: string,
) {
return <CustomIcon {...view.props} size={size} color={color} />;
}
function pressButton(view: ButtonView, path: readonly string[]) {
void pluginButtonStore.run(view.entry.key, path).catch((error: unknown) => {
view.toast.error(error instanceof Error ? error.message : String(error));
@@ -1,7 +1,7 @@
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import type { CommandCenterContribution } from "@/command-center/contributions";
import { getCommandCenterIcon } from "@/command-center/icon";
import { resolvePluginIcon } from "../icons";
import { pluginIconComponent } from "../icons";
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
import type { InstalledPlugin } from "../types";
import { createPluginCapabilities, type PluginNavigation } from "../actions";
@@ -83,7 +83,7 @@ export function buildPluginCommandCenterContributions(
kind: "action",
title: item.title,
sectionTitle: plugin.id,
icon: getCommandCenterIcon(resolvePluginIcon(item.icon)),
icon: getCommandCenterIcon(pluginIconComponent(item.icon)),
},
run,
});
+66
View File
@@ -381,6 +381,72 @@ describe("evaluatePluginClientBundle", () => {
).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]);
});
it("registers inline SVG and component icons", () => {
const svg = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function ReviewPanel() { return null; }
function BadgeIcon() { return null; }
plugin.addWorkspacePanel({
id: "review",
title: "Review",
icon: { svg: ${JSON.stringify(` ${svg}\n`)} },
context: "workspace",
Component: ReviewPanel,
});
plugin.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: BadgeIcon,
context: "global",
onSelect() {},
});
plugin.addAttachmentSource({
id: "issues",
title: "Issues",
icon: { svg: ${JSON.stringify(svg)} },
pickerTitle: "Attach issue",
searchPlaceholder: "Search",
search: { name: "issues.search", input: {}, output: {} },
});
`),
);
expect(plugin.workspacePanels[0]?.icon).toEqual({ svg });
expect(plugin.commandCenterItems[0]?.icon).toHaveProperty("name", "BadgeIcon");
expect(plugin.attachmentSources[0]?.icon).toEqual({ svg });
});
it.each([
[
"workspace panel",
`plugin.addWorkspacePanel({ id: "review", title: "Review", icon: ICON, context: "workspace", Component() { return null; } })`,
"Workspace panel review has an invalid icon: script elements are not allowed",
],
[
"Command Center item",
`plugin.addCommandCenterItem({ id: "review", title: "Review", icon: ICON, context: "global", onSelect() {} })`,
"Command Center item review has an invalid icon: script elements are not allowed",
],
[
"settings screen",
`plugin.addSettingsScreen({ id: "review", title: "Review", icon: ICON, Component() { return null; } })`,
"Settings screen review has an invalid icon: script elements are not allowed",
],
[
"attachment source",
`plugin.addAttachmentSource({ id: "review", title: "Review", icon: ICON, pickerTitle: "Pick", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } })`,
"Attachment source review has an invalid icon: script elements are not allowed",
],
])("fails the plugin when a %s registers an unsafe SVG", (_kind, registration, message) => {
const icon = JSON.stringify({ svg: "<svg><script>run()</script></svg>" });
expect(() =>
evaluatePluginClientBundle("review", bundle(registration.replace("ICON", icon))),
).toThrow(message);
});
it("normalizes and validates workspace panel locations", () => {
const plugin = evaluatePluginClientBundle(
"review",
+10 -15
View File
@@ -33,7 +33,7 @@ import {
} from "@getpaseo/plugin/client";
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
import type { ComponentType } from "react";
import { resolvePluginIcon } from "./icons";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
import { pluginReactNativeRuntime } from "./react-native/runtime";
import { parsePluginThemeContribution } from "./themes";
@@ -199,9 +199,9 @@ export function runPluginClientBundle(
if (settingsScreenIds.has(screenId))
throw new Error(`Duplicate settings screen: ${screenId}`);
if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`);
resolvePluginIcon(contribution.icon);
const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon);
settingsScreenIds.add(screenId);
return register(collector.settingsScreens, { ...contribution, id: screenId }, () =>
return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () =>
settingsScreenIds.delete(screenId),
);
},
@@ -241,12 +241,10 @@ export function runPluginClientBundle(
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
resolvePluginIcon(contribution.icon.trim());
const legacy = {
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon),
surface: requireId(contribution.surface, "sidebar surface id"),
};
// Shares the header's ids: the sidebar orders both kinds of item under one key.
@@ -262,16 +260,14 @@ export function runPluginClientBundle(
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
if (!icon) throw new Error(`Workspace panel ${normalizedId} has no icon`);
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
if (contribution.context !== "workspace" && contribution.context !== "agent") {
throw new Error(`Workspace panel ${normalizedId} has invalid context`);
}
if (typeof contribution.Component !== "function") {
throw new Error(`Workspace panel ${normalizedId} is not a component`);
}
resolvePluginIcon(icon);
const locations = normalizePanelLocations(normalizedId, contribution.locations);
workspacePanelIds.add(normalizedId);
return register(
@@ -292,9 +288,8 @@ export function runPluginClientBundle(
throw new Error(`Duplicate Command Center item: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
if (!icon) throw new Error(`Command Center item ${normalizedId} has no icon`);
const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon);
if (
contribution.context !== "global" &&
contribution.context !== "workspace" &&
@@ -305,7 +300,6 @@ export function runPluginClientBundle(
if (typeof contribution.onSelect !== "function") {
throw new Error(`Command Center item ${normalizedId} has no callback`);
}
resolvePluginIcon(icon);
commandCenterItemIds.add(normalizedId);
return register(
collector.commandCenterItems,
@@ -350,18 +344,19 @@ export function runPluginClientBundle(
throw new Error(`Duplicate attachment source: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
const pickerTitle = contribution.pickerTitle.trim();
const searchPlaceholder = contribution.searchPlaceholder.trim();
const method = contribution.search.name.trim();
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
const icon = parseSerializablePluginIcon(
`Attachment source ${normalizedId}`,
contribution.icon,
);
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
if (!searchPlaceholder) {
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
}
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
resolvePluginIcon(icon);
attachmentSourceIds.add(normalizedId);
return register(
collector.attachmentSources,
-18
View File
@@ -1,18 +0,0 @@
import { Settings } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { Icon } from "./icons";
describe("Icon", () => {
it("renders a host Lucide icon with the requested presentation", () => {
expect(Icon({ name: "Settings", size: 18, color: "#123456" })).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("renders nothing for an unknown icon name", () => {
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
expect(Icon({ name: "Icon" })).toBeNull();
expect(Icon({ name: "createLucideIcon" })).toBeNull();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { Settings } from "lucide-react-native";
import React, { isValidElement, type ReactElement, type ReactNode } from "react";
import { SvgXml } from "react-native-svg";
import { describe, expect, it } from "vitest";
import type { PluginIconProps } from "@getpaseo/plugin";
import { Icon, parsePluginIcon, parseSerializablePluginIcon, PluginIconView } from "./icons";
const SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
const SVG_ICON = { svg: SVG };
const GLYPHS = new Set<unknown>([Settings, SvgXml]);
/** Expands function components down to what reaches the screen: a Lucide glyph, SvgXml, or DOM. */
function draw(node: ReactNode): ReactElement | null {
if (!isValidElement(node)) return null;
const { type } = node;
if (typeof type !== "function" || GLYPHS.has(type)) return node;
return draw((type as (props: unknown) => ReactNode)(node.props));
}
function BadgeIcon({ size, color }: PluginIconProps) {
return <span data-size={size} data-color={color} />;
}
describe("PluginIconView", () => {
it("draws a Lucide name as that Lucide icon", () => {
expect(draw(<PluginIconView icon="Settings" size={18} color="#123456" />)).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("draws inline SVG sized to the surface, with color reaching currentColor", () => {
expect(draw(<PluginIconView icon={SVG_ICON} size={18} color="#123456" />)).toMatchObject({
type: SvgXml,
props: { xml: SVG, width: 18, height: 18, color: "#123456" },
});
});
it("draws a component with the surface's size and color", () => {
expect(draw(<PluginIconView icon={BadgeIcon} size={18} color="#123456" />)).toMatchObject({
type: "span",
props: { "data-size": 18, "data-color": "#123456" },
});
});
});
describe("parsePluginIcon", () => {
it("normalizes each icon form", () => {
expect(parsePluginIcon("Workspace panel files", " Settings ")).toBe("Settings");
expect(parsePluginIcon("Workspace panel files", { svg: `\n${SVG}\n` })).toEqual({ svg: SVG });
expect(parsePluginIcon("Workspace panel files", BadgeIcon)).toBe(BadgeIcon);
});
it.each([
["", "Workspace panel files has no icon"],
[
"NotALucideIcon",
'Workspace panel files has an invalid icon: unknown Lucide icon "NotALucideIcon"',
],
[
{ svg: "<svg><script>run()</script></svg>" },
"Workspace panel files has an invalid icon: script elements are not allowed",
],
[{ svg: "hello" }, "Workspace panel files has an invalid icon: not an SVG document"],
[
{ path: "icon.svg" },
"Workspace panel files has an invalid icon: expected a Lucide icon name, { svg }, or a component",
],
])("rejects %j", (icon, message) => {
expect(() => parsePluginIcon("Workspace panel files", icon as never)).toThrow(message);
});
it("refuses a component where the icon is stored as data", () => {
expect(() =>
parseSerializablePluginIcon("Attachment source issues", BadgeIcon as never),
).toThrow(
"Attachment source issues has an invalid icon: expected a Lucide icon name or { svg }",
);
});
});
describe("Icon", () => {
it("renders a host Lucide icon by name with the requested presentation", () => {
expect(draw(Icon({ name: "Settings", size: 18, color: "#123456" }))).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("renders a plugin's own SVG through the icon prop", () => {
expect(draw(Icon({ icon: { svg: SVG }, size: 18, color: "#123456" }))).toMatchObject({
type: SvgXml,
props: { xml: SVG, width: 18, color: "#123456" },
});
});
it("renders nothing for an unusable icon", () => {
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
expect(Icon({ name: "Icon" })).toBeNull();
expect(Icon({ name: "createLucideIcon" })).toBeNull();
expect(Icon({ icon: { svg: '<svg onload="run()"></svg>' } })).toBeNull();
});
});
+139 -13
View File
@@ -1,24 +1,150 @@
import { createElement, type ReactElement } from "react";
import { createElement, type ComponentType, type ReactElement } from "react";
import * as LucideIcons from "lucide-react-native";
import type { PluginIconProps } from "@getpaseo/plugin/client";
import type { LucideIcon } from "lucide-react-native";
import {
findSvgIconProblem,
type PluginIconProps,
type PluginSerializableIcon,
type PluginSvgIcon,
} from "@getpaseo/plugin";
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { IconProps } from "@getpaseo/plugin/client/react-native";
import { SvgIcon } from "@/components/provider-icons";
function findPluginIcon(name: string): LucideIcon | null {
/** Host surfaces may thin a Lucide stroke; SVG and plugin components ignore it. */
type HostIconProps = PluginIconProps & { strokeWidth?: number };
/** A host-drawn icon. A plain function, so it stands in for any plugin icon component type. */
type HostIcon = (props: HostIconProps) => ReactElement | null;
const lucideIconComponents = new Map<string, HostIcon>();
const svgIconComponents = new Map<string, HostIcon>();
function findLucideIcon(name: string): LucideIcon | null {
const candidate = Reflect.get(LucideIcons, name);
if (candidate === LucideIcons.Icon || candidate === LucideIcons.createLucideIcon) return null;
const isComponent =
typeof candidate === "function" ||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
return isComponent ? (candidate as LucideIcon) : null;
return isComponent(candidate) ? (candidate as LucideIcon) : null;
}
export function resolvePluginIcon(name: string): LucideIcon {
const icon = findPluginIcon(name);
if (!icon) throw new Error(`Unknown Lucide icon: ${name}`);
function isComponent(value: unknown): boolean {
return (
typeof value === "function" ||
(typeof value === "object" && value !== null && "$$typeof" in value)
);
}
function isSvgIcon(icon: unknown): icon is PluginSvgIcon {
return (
typeof icon === "object" &&
icon !== null &&
!("$$typeof" in icon) &&
typeof Reflect.get(icon, "svg") === "string"
);
}
function isComponentIcon<Props extends PluginIconProps>(
icon: PluginIcon<Props>,
): icon is ComponentType<Props> {
return isComponent(icon);
}
function invalidIcon(owner: string, reason: string): Error {
return new Error(`${owner} has an invalid icon: ${reason}`);
}
/** Validates an icon that is stored with the data it labels. Returns the normalized icon. */
export function parseSerializablePluginIcon(
owner: string,
icon: PluginSerializableIcon,
): PluginSerializableIcon {
if (typeof icon === "string") {
const name = icon.trim();
if (!name) throw new Error(`${owner} has no icon`);
if (!findLucideIcon(name)) throw invalidIcon(owner, `unknown Lucide icon "${name}"`);
return name;
}
if (!isSvgIcon(icon)) throw invalidIcon(owner, "expected a Lucide icon name or { svg }");
const svg = icon.svg.trim();
const problem = findSvgIconProblem(svg);
if (problem) throw invalidIcon(owner, problem);
return { svg };
}
/** Validates a contribution's icon once, at registration. Rendering trusts the result. */
export function parsePluginIcon<Props extends PluginIconProps>(
owner: string,
icon: PluginIcon<Props>,
): PluginIcon<Props> {
if (isComponentIcon(icon)) return icon;
if (typeof icon !== "string" && !isSvgIcon(icon)) {
throw invalidIcon(owner, "expected a Lucide icon name, { svg }, or a component");
}
return parseSerializablePluginIcon(owner, icon);
}
function MissingIcon(): null {
return null;
}
function createLucideIcon(name: string, Lucide: LucideIcon): HostIcon {
function PluginLucideIcon({ size, color, strokeWidth }: HostIconProps) {
return createElement(Lucide, { size, color, strokeWidth });
}
PluginLucideIcon.displayName = `PluginLucideIcon(${name})`;
return PluginLucideIcon;
}
function lucideIconComponent(name: string): HostIcon {
const cached = lucideIconComponents.get(name);
if (cached) return cached;
const Lucide = findLucideIcon(name);
if (!Lucide) return MissingIcon;
const icon = createLucideIcon(name, Lucide);
lucideIconComponents.set(name, icon);
return icon;
}
export function Icon({ name, size, color }: PluginIconProps): ReactElement | null {
const icon = findPluginIcon(name);
return icon ? createElement(icon, { size, color }) : null;
function svgIconComponent(svg: string): HostIcon {
const cached = svgIconComponents.get(svg);
if (cached) return cached;
function PluginSvgIcon({ size, color }: PluginIconProps) {
return createElement(SvgIcon, { svg, size, color });
}
svgIconComponents.set(svg, PluginSvgIcon);
return PluginSvgIcon;
}
/**
* A stable component for a registered icon, for surfaces that take an icon component. Lucide and
* SVG icons draw from `size` and `color` only; a plugin component receives every prop.
*/
export function pluginIconComponent<Props extends PluginIconProps>(
icon: PluginIcon<Props>,
): ComponentType<Props> {
if (typeof icon === "string") return lucideIconComponent(icon);
if (isSvgIcon(icon)) return svgIconComponent(icon.svg);
return icon;
}
export interface PluginIconViewProps extends PluginIconProps {
icon: PluginIcon;
}
/** Draws a registered plugin icon. `color` tints Lucide icons and `currentColor` in SVG. */
export function PluginIconView({ icon, size, color }: PluginIconViewProps): ReactElement {
return createElement(pluginIconComponent(icon), { size, color });
}
function isRenderable(icon: PluginIcon): boolean {
if (typeof icon === "string") return findLucideIcon(icon) !== null;
if (isSvgIcon(icon)) return findSvgIconProblem(icon.svg) === null;
return isComponent(icon);
}
/** The kit `Icon`. Plugin code passes it anything, so an unusable icon renders nothing. */
export function Icon(props: IconProps): ReactElement | null {
const icon = props.icon === undefined ? props.name : props.icon;
if (!isRenderable(icon)) return null;
const size = props.size ?? 24;
const color = props.color ?? "currentColor";
return createElement(PluginIconView, { icon, size, color });
}
+2 -1
View File
@@ -9,6 +9,7 @@ import type {
PluginSidebarItemContribution,
PluginSidebarSection,
} from "./types";
import type { PluginIcon } from "@getpaseo/plugin/client";
export interface PluginSidebarTarget {
plugin: InstalledPlugin;
@@ -36,7 +37,7 @@ export type PluginSidebarGroup =
| (PluginSidebarGroupBase & { kind: "item"; targets: PluginSidebarTarget[] })
| (PluginSidebarGroupBase & {
kind: "legacy";
icon: string;
icon: PluginIcon;
targets: PluginLegacySidebarTarget[];
});
@@ -1,16 +1,12 @@
import type { SidebarIcon, SidebarRowProps } from "@getpaseo/plugin/client/ui";
import { useCallback, useEffect, useRef } from "react";
import type { SidebarRowProps } from "@getpaseo/plugin/client/ui";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator as AppSidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { resolvePluginIcon } from "../icons";
import { parsePluginIcon, pluginIconComponent } from "../icons";
import { useSidebarItemFrame } from "./frame";
function resolveIcon(icon: SidebarIcon) {
return typeof icon === "string" ? resolvePluginIcon(icon) : icon;
}
/**
* An item may render several rows. Any press inside a row, on the row or on its trailing content,
* makes that row the popover anchor: the capture-phase responder check runs before the pressed
@@ -29,6 +25,11 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
anchorTo(rowRef.current);
return false;
}, [anchorTo]);
// Rows get their icon at render, not registration, so an invalid icon throws to the item's boundary.
const iconComponent = useMemo(
() => (icon ? pluginIconComponent(parsePluginIcon("Sidebar row", icon)) : null),
[icon],
);
// Keyboard activation skips the responder system, so the row's own press anchors as well.
const handlePress = useCallback(() => {
anchorTo(rowRef.current);
@@ -37,7 +38,7 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
return (
<View ref={rowRef} collapsable={false} onStartShouldSetResponderCapture={anchorHere}>
<SidebarHeaderRow
icon={icon ? resolveIcon(icon) : null}
icon={iconComponent}
label={label ?? frame.title}
onPress={handlePress}
isActive={active}
@@ -2,7 +2,7 @@ import { router, usePathname } from "expo-router";
import { useCallback } from "react";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { rememberPluginContributionHost } from "../contribution-host";
import { resolvePluginIcon } from "../icons";
import { pluginIconComponent } from "../icons";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
import { selectPluginSidebarTarget, type PluginSidebarGroup } from "../sidebar-groups";
@@ -33,7 +33,7 @@ export function LegacyPluginSidebarRow({
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
return (
<SidebarHeaderRow
icon={resolvePluginIcon(group.icon)}
icon={pluginIconComponent(group.icon)}
label={group.title}
onPress={navigate}
isActive={isActive}
+8 -14
View File
@@ -1,5 +1,5 @@
import { router, useLocalSearchParams } from "expo-router";
import type { PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
import type { PluginIcon, PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { X } from "lucide-react-native";
import { useCallback, useMemo, type ComponentType } from "react";
@@ -17,7 +17,7 @@ import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { usePluginHostNavigation } from "./host-navigation";
import { resolvePluginIcon } from "./icons";
import { PluginIconView } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes";
@@ -47,14 +47,8 @@ function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
}
function PluginHeaderIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={16} color={color} />;
function PluginHeaderIcon({ icon, color = "" }: { icon: PluginIcon; color?: string }) {
return <PluginIconView icon={icon} size={16} color={color} />;
}
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
@@ -162,7 +156,7 @@ export function PluginSurfaceScreen() {
: (sidebarItem?.title ?? (pluginId || "Plugin")),
[params, pluginId, sidebarItem, surface],
);
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
const icon = sidebarItem?.icon ?? null;
const close = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace(`/h/${encodeURIComponent(serverId)}`);
@@ -172,15 +166,15 @@ export function PluginSurfaceScreen() {
const headerLeft = useMemo(
() => (
<>
{Icon ? (
{icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
<ThemedPluginHeaderIcon icon={icon} uniProps={mutedColorMapping} />
</HeaderIconBadge>
) : null}
<ScreenTitle testID="plugin-surface-title">{title}</ScreenTitle>
</>
),
[Icon, title],
[icon, title],
);
const hostFilter = useMemo(
() =>
@@ -33,7 +33,7 @@ vi.mock("../client-runtime", () => ({
}));
vi.mock("../icons", () => ({
Icon: () => null,
resolvePluginIcon: () => () => null,
pluginIconComponent: () => () => null,
}));
import { pluginRegistry } from "../registry";
@@ -17,7 +17,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { usePluginHostNavigation } from "../host-navigation";
import { createPluginClientStateSource } from "../client-state/source";
import { toPluginTheme } from "../theme";
import { resolvePluginIcon } from "../icons";
import { pluginIconComponent } from "../icons";
import { useInstalledPlugin } from "../registry";
import { PluginInstallationProvider } from "../installation-provider";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
@@ -144,7 +144,7 @@ function usePluginPanelDescriptor(
subtitle: target.pluginId,
tooltip: panel.title,
titleState: "ready",
icon: resolvePluginIcon(panel.icon),
icon: pluginIconComponent(panel.icon),
statusBucket: null,
};
}
@@ -1,4 +1,3 @@
import { ConnectedHostsSection } from "@/connected-hosts";
import {
ArrowDown,
ArrowUp,
@@ -375,7 +374,6 @@ export function HostSettingsPage({
<HostConnectionError serverId={serverId} />
<HostAppearanceSection host={host} />
<ConnectedHostsSection serverId={serverId} />
{isLocalDaemon ? <LocalDaemonSection /> : null}
@@ -1,4 +1,4 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { useCallback, useMemo, type ComponentType, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -26,7 +26,7 @@ import {
type SidebarNavItem,
type SidebarSection,
} from "@/sidebar-nav/model";
import { resolvePluginIcon } from "@/plugins/icons";
import { pluginIconComponent } from "@/plugins/icons";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -50,15 +50,17 @@ const BUILTIN_ICONS: Record<BuiltinSidebarItemId, LucideIcon> = {
/** Plugin items register no icon, so they share this one; a legacy `addSidebarItem` keeps its own. */
const PLUGIN_ICON = Blocks;
function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
type NavIconComponent = ComponentType<{ size: number; color: string }>;
function NavIcon({ Icon, color = "" }: { Icon: NavIconComponent; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
}
const ThemedNavIcon = withUnistyles(NavIcon);
function navItemIcon(item: SidebarNavItem): LucideIcon {
function navItemIcon(item: SidebarNavItem): NavIconComponent {
if (item.kind === "builtin") return BUILTIN_ICONS[item.id];
return item.group.kind === "legacy" ? resolvePluginIcon(item.group.icon) : PLUGIN_ICON;
return item.group.kind === "legacy" ? pluginIconComponent(item.group.icon) : PLUGIN_ICON;
}
function navItemLabel(t: TFunction, item: SidebarNavItem): string {
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { Globe, SquarePen, SquareTerminal } from "lucide-react-native";
import invariant from "tiny-invariant";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { resolvePluginIcon } from "@/plugins/icons";
import { pluginIconComponent } from "@/plugins/icons";
import { useInstalledPlugins } from "@/plugins/registry";
import { pluginPanelSupportsLocation } from "@/plugins/workspace-panels/locations";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
@@ -212,7 +212,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
pluginItems.push({
id: `plugin:${plugin.id}:${panel.id}`,
label: panel.title,
Icon: resolvePluginIcon(panel.icon),
Icon: pluginIconComponent(panel.icon),
disabled: false,
panelKind: "plugin",
launch: launchSelection(selection),
-3
View File
@@ -1,4 +1,3 @@
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";
@@ -59,8 +58,6 @@ 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));
-116
View File
@@ -1,116 +0,0 @@
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<HostSummary> = {
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<string, string> {
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<CommandResult<HostSummary>> {
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("<connection>")
.option("--label <name>")
.option("--meta <key=value>", "Set metadata", collectMultiple, []),
).action(
withOutput((connection: string, options: Options, _command: Command) =>
run("add", connection, options),
),
);
addJsonAndDaemonHostOptions(
host
.command("update")
.argument("<registration-id>")
.option("--label <name>")
.option("--connection <connection>")
.option("--replace-identity", "Accept a new destination identity")
.option("--meta <key=value>", "Replace supplied metadata keys", collectMultiple, [])
.option("--remove-meta <key>", "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("<registration-id>")).action(
withOutput((id: string, options: Options, _command: Command) => run(action, id, options)),
);
return host;
}
+90 -2
View File
@@ -1,2 +1,90 @@
export { createSshTunnel, resolveSshFailureDetail } from "@getpaseo/client/node";
export type { SshTunnel } from "@getpaseo/client/node";
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<SshTunnel> {
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),
});
});
});
}
+51 -10
View File
@@ -1,8 +1,42 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSshFailureDetail } from "../ssh/ssh-tunnel.js";
import { parseHostTarget } from "@getpaseo/client/node";
import { connectToDaemon } from "./client.js";
const mocks = vi.hoisted(() => ({
configs: [] as Array<Record<string, unknown>>,
createSshTunnel: vi.fn(),
}));
vi.mock("@getpaseo/client/internal/daemon-client", () => ({
DaemonClient: class {
lastError = null;
constructor(config: Record<string, unknown>) {
mocks.configs.push(config);
}
async connect() {}
async close() {}
},
}));
vi.mock("../ssh/ssh-tunnel.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../ssh/ssh-tunnel.js")>()),
createSshTunnel: mocks.createSshTunnel,
}));
vi.mock("./client-id.js", () => ({ getOrCreateCliClientId: async () => "cli-test-id" }));
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.",
@@ -11,14 +45,21 @@ describe("CLI SSH transport", () => {
"ssh exited with code 255",
);
});
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://build-box:2222 (daemon port 7777)",
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",
});
expect(() => parseHostTarget("ssh://deploy:secret@build-box")).toThrow(
"Invalid host connection",
);
});
});
+178 -26
View File
@@ -1,4 +1,3 @@
import { createHostClient, parseHostTarget } from "@getpaseo/client/node";
import {
waitForDaemonReady,
resolvePaseoHome,
@@ -9,12 +8,21 @@ 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 { DaemonClient } from "@getpaseo/client/internal/daemon-client";
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 { getOrCreateCliClientId } from "./client-id.js";
import { resolveCliVersion } from "../version.js";
import { createSshTunnel } from "../ssh/ssh-tunnel.js";
export interface ConnectOptions {
target: DaemonTarget;
@@ -42,15 +50,15 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
if (error && typeof error === "object" && "message" in error) message = String(error.message);
if (options.target.kind === "endpoint")
message = message.replaceAll(options.target.host, describeDaemonTarget(options.target));
if (message.startsWith("Cannot connect to daemon at "))
return error as { code: string; message: string; details: string };
let code = "DAEMON_UNREACHABLE";
if (typeof error === "object" && error !== null && "code" in error) code = String(error.code);
else if (message === "Password required") code = "AUTH_REQUIRED";
else if (message === "Incorrect password") code = "AUTH_FAILED";
return {
code,
message: message.startsWith("Cannot connect to daemon at ")
? message
: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
details: describeConnectionRemedy(code, options.target),
};
}
@@ -162,8 +170,11 @@ export function resolveDaemonTarget(host: string): TransportTarget {
}
export function resolveDaemonPassword(host: string): string | undefined {
const fromUri = parseHostTarget(host).password;
if (fromUri) return fromUri;
const trimmed = host.trim();
if (trimmed.startsWith("tcp://")) {
const fromUri = parseConnectionUri(trimmed).password;
if (fromUri) return fromUri;
}
const fromEnv = process.env.PASEO_PASSWORD;
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
}
@@ -178,9 +189,117 @@ 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<string, string>; 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<typeof resolveDaemonCredential>,
home: string,
clientId: string,
timeout: number,
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
): 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<string, string>; 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<typeof createNodeWebSocketFactory>,
): Promise<DaemonClient> {
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<string, string>; 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<DaemonClient> {
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
const host =
const deadline = Date.now() + timeout;
const explicitHost =
options.target.kind === "endpoint"
? options.target.host
: (
@@ -190,25 +309,58 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
})
).listen;
const home = resolveClientPaseoHome(options.target);
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 clientId = await getOrCreateCliClientId(home);
const nodeWebSocketFactory = createNodeWebSocketFactory();
if (explicitHost?.trim().startsWith("ssh://")) {
const target = parseSshTransportUri(explicitHost.trim());
const tunnel = await createSshTunnel(target);
const password = resolveDaemonPassword(explicitHost);
const result = await tryConnectHost(
tunnel.endpoint,
password ? { kind: "password", password } : null,
home,
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
if ("client" in result) {
const close = result.client.close.bind(result.client);
result.client.close = async () => {
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 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<DaemonClient> {
+1 -62
View File
@@ -1,11 +1,6 @@
import { parseHostTarget } from "@getpaseo/protocol/host-target";
import { test, expect } from "vitest";
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
import {
resolveDaemonCredential,
resolveClientPaseoHome,
buildDaemonConnectionCommandError,
} from "./client.js";
import { resolveDaemonCredential, resolveClientPaseoHome } from "./client.js";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -49,31 +44,6 @@ test("endpoint descriptions redact pairing material and credentials", () => {
).not.toContain("private");
});
test("a pairing offer without a URL prefix still redacts its key", () => {
const offer = {
v: 2,
serverId: "srv_example",
daemonPublicKeyB64: "example-secret",
relay: { endpoint: "localhost:6767", useTls: false },
};
const host = `#offer=${Buffer.from(JSON.stringify(offer)).toString("base64url")}`;
expect(describeDaemonTarget({ kind: "endpoint", host })).toBe(
"relay://localhost:6767/srv_example",
);
});
test("malformed endpoint diagnostics never expose the supplied credential", () => {
const target = { kind: "endpoint" as const, host: "alice:example-secret@localhost:6767" };
expect(describeDaemonTarget(target)).not.toContain("example-secret");
for (const message of [
`Invalid connection ${target.host}`,
`Cannot connect to daemon at ${target.host}`,
]) {
const diagnostic = buildDaemonConnectionCommandError({ target, error: new Error(message) });
expect(JSON.stringify(diagnostic)).not.toContain("example-secret");
}
});
test("CLI selects an explicit password before a matching local credential and never sends the latter remotely", async () => {
const home = await mkdtemp(join(tmpdir(), "paseo-cli-credential-"));
const previousPassword = process.env.PASEO_PASSWORD;
@@ -97,34 +67,3 @@ test("CLI selects an explicit password before a matching local credential and ne
await rm(home, { recursive: true, force: true });
}
});
test.each(["/tmp/paseo.sock", "\\\\.\\pipe\\paseo", "localhost:6767", "[::1]:6767", "6767"])(
"validated bare target %s keeps its spelling in command hints",
(host) => {
expect(describeDaemonTarget({ kind: "endpoint", host })).toBe(host);
},
);
test("validated URI command hints preserve their transport address without credentials", () => {
const ssh = describeDaemonTarget({
kind: "endpoint",
host: "ssh://deploy@build-box:2222?daemonPort=7777&password=example-secret",
});
expect(ssh).not.toContain("example-secret");
expect(parseHostTarget(ssh)).toMatchObject({
kind: "ssh",
ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 },
});
const ws = describeDaemonTarget({
kind: "endpoint",
host: "wss://example.test/custom-ws?authorization=example-secret",
});
expect(ws).not.toContain("example-secret");
expect(new URL(ws).pathname).toBe("/custom-ws");
expect(
describeDaemonTarget({
kind: "endpoint",
host: "tcp://localhost:6767?daemonPort=example-secret&ssl=example-secret",
}),
).not.toContain("example-secret");
});
+7 -21
View File
@@ -1,4 +1,3 @@
import { parseHostTarget } from "@getpaseo/protocol/host-target";
import { resolvePaseoHome } from "@getpaseo/server/daemon-control";
export type DaemonTarget = { kind: "instance"; home: string } | { kind: "endpoint"; host: string };
@@ -40,26 +39,13 @@ export function selectDaemonTarget(
export function describeDaemonTarget(target: DaemonTarget): string {
if (target.kind === "instance") return `home ${target.home}`;
try {
const input = target.host.trim();
const parsed = parseHostTarget(input);
// Discovery descriptions are display text; command hints must preserve the address syntax.
if (parsed.kind === "ssh" || (!parsed.offer && !parsed.socketPath && input.includes("://"))) {
const url = new URL(input);
if (decodeURIComponent(url.username).includes(":")) url.username = "REDACTED";
for (const key of new Set(url.searchParams.keys())) {
const routingOption =
(parsed.kind === "ssh" && key === "daemonPort") ||
(key === "ssl" &&
url.searchParams.getAll(key).every((value) => value === "true" || value === "false"));
if (!routingOption) url.searchParams.set(key, "REDACTED");
}
return url.toString();
}
// Pairing offers must always use safe display text, including prefix-free fragments.
return parsed.kind === "socket" && !parsed.offer && !input.includes("://")
? input
: parsed.description;
const url = new URL(target.host);
if (url.password) url.password = "REDACTED";
for (const key of url.searchParams.keys())
if (/password|token|secret/i.test(key)) url.searchParams.set(key, "REDACTED");
if (url.hash) url.hash = "REDACTED";
return url.toString();
} catch {
return "<invalid endpoint>";
return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED");
}
}
-5
View File
@@ -24,10 +24,6 @@
"./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": {
@@ -45,7 +41,6 @@
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -4
View File
@@ -873,12 +873,9 @@ test("keeps relay upgrade credentials out of the socket request", async () => {
},
});
clients.push(client);
const connecting = client.connect();
void connecting.catch(() => {});
void client.connect();
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 () => {
+14 -149
View File
@@ -1,5 +1,4 @@
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";
@@ -392,10 +391,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;
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
@@ -1013,16 +1008,8 @@ 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;
serverId?: string;
}) {
constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) {
const parts = [params.error];
if (params.requestType) parts.push(`requestType=${params.requestType}`);
if (params.code) parts.push(`code=${params.code}`);
@@ -1031,8 +1018,6 @@ class DaemonRpcError extends Error {
this.requestId = params.requestId;
this.requestType = params.requestType;
this.code = params.code;
this.serverId = params.serverId;
this.userMessage = params.error;
}
}
@@ -1517,7 +1502,6 @@ export class DaemonClient {
this.lastErrorValue = reason;
this.scheduleReconnect({
reason,
failure: event instanceof Error ? event : undefined,
event: "TRANSPORT_ERROR",
reasonCode: "transport_error",
});
@@ -1534,7 +1518,6 @@ export class DaemonClient {
this.lastErrorValue = reason;
this.scheduleReconnect({
reason,
failure: event instanceof Error ? event : undefined,
event: "TRANSPORT_ERROR",
reasonCode: "transport_error",
});
@@ -1585,7 +1568,9 @@ export class DaemonClient {
return;
}
this.shouldReconnect = false;
this.rejectConnect(new Error("Daemon client closed"));
this.connectPromise = null;
this.connectResolve = null;
this.connectReject = null;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
@@ -1613,7 +1598,6 @@ export class DaemonClient {
}
ensureConnected(options?: { verify?: boolean }): void {
this.requireRoutedConnection();
if (this.connectionState.status === "disposed") {
return;
}
@@ -1629,9 +1613,7 @@ export class DaemonClient {
this.attemptConnect();
return;
}
void this.connect().catch((error: unknown) =>
this.logger.debug({ err: error }, "Connection attempt ended"),
);
void this.connect();
}
private verifyConnection(): void {
@@ -1649,98 +1631,13 @@ 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;
});
}
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<HostDirectory> {
this.requireConnectedHosts();
return (
await this.sendCorrelatedSessionRequest({
message: { type: "hosts.list.request" },
responseType: "hosts.list.response",
})
).directory;
}
observeHosts(): OwnedSubscription<HostDirectory> {
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<void> {
this.requireConnectedHosts();
await this.sendCorrelatedSessionRequest({
message: { type: "hosts.remove.request", registrationId },
responseType: "hosts.remove.response",
});
}
async openHostRoute(serverId: string, scopeId: string): Promise<string> {
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;
}
@@ -1883,7 +1780,7 @@ export class DaemonClient {
private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void {
if (!this.transport) {
throw this.unavailableError();
throw new DaemonConnectionError("Transport not connected");
}
// The daemon closes the connection on an oversized message, and creations resubmit on reconnect.
assertTransportFrameFits(frame);
@@ -1970,7 +1867,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
@@ -1991,7 +1888,7 @@ export class DaemonClient {
}
// Not connected and not connecting - fail immediately
return Promise.reject(this.unavailableError());
return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`));
}
/**
@@ -2045,13 +1942,9 @@ 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:
this.config.routedClient && msg.payload.code === "access_denied"
? "permission_denied"
: msg.payload.code,
code: msg.payload.code,
}),
};
}
@@ -2160,23 +2053,9 @@ 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 this.unavailableError();
throw new DaemonConnectionError("Transport not connected");
}
const payload = SessionInboundMessageSchema.parse(message);
try {
@@ -2972,10 +2851,7 @@ export class DaemonClient {
// ============================================================================
private readonly creations = new CreationClient({
supports: (feature) => {
this.requireRoutedConnection();
return this.lastServerInfoMessage?.features?.[feature] === true;
},
supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true,
requestId: () => this.createRequestId(),
request: (kind, input) =>
kind === "workspace"
@@ -6411,9 +6287,6 @@ 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,
@@ -6759,7 +6632,6 @@ export class DaemonClient {
}
private scheduleReconnect(input?: {
failure?: Error;
reason?: string;
event?: string;
reasonCode?: string;
@@ -6780,14 +6652,7 @@ 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"),
);
this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost"));
this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost"));
this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost"));
this.terminalStreams.clearSlots();
@@ -6804,7 +6669,7 @@ export class DaemonClient {
this.rejectConnect(
this.authFailureReasonValue
? new DaemonAuthenticationError(this.authFailureReasonValue)
: (input?.failure ?? new Error(reason ?? "Transport disconnected before connect")),
: new Error(reason ?? "Transport disconnected before connect"),
);
return;
}
-204
View File
@@ -1,204 +0,0 @@
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<HostDirectory>;
observe(): OwnedSubscription<HostDirectory>;
add(input: HostAdd): Promise<HostSummary>;
update(registrationId: string, patch: HostUpdate): Promise<HostSummary>;
remove(registrationId: string): Promise<void>;
client(serverId: string): Promise<PaseoApi>;
}
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<T extends { release(): Promise<void> }>(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;
}
},
};
}
-1
View File
@@ -267,7 +267,6 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own
"agents",
"config",
"dispose",
"hosts",
"observeEvents",
"projects",
"providers",
-6
View File
@@ -1,6 +1,3 @@
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";
@@ -487,7 +484,6 @@ export interface PaseoConfigActions {
}
export interface PaseoApi {
readonly hosts: PaseoHostActions;
dispose(): Promise<void>;
observeEvents: DaemonClient["observeEvents"];
readonly terminals: PaseoTerminalActions;
@@ -699,7 +695,6 @@ export function createPaseoApi(
return {
dispose,
hosts: createHostActions(daemonClient, { own, signal: lifetime.signal, api: createPaseoApi }),
observeEvents,
terminals,
projects: {
@@ -1039,7 +1034,6 @@ function listProviderUsage(
daemonClient: DaemonClient,
options?: PaseoProviderUsageOptions,
): Promise<PaseoProviderUsageResult> {
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."));
-22
View File
@@ -1,22 +0,0 @@
/** 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;
}
};
}
}
@@ -1,53 +0,0 @@
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<unknown>)[] = [];
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<void>((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<void>((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);
});
-190
View File
@@ -1,190 +0,0 @@
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<HostTarget, { kind: "socket" }>;
if (target.kind === "ssh") {
const tunnel = await createSshTunnel(target.ssh);
sshTunnel = tunnel;
teardown = () => tunnel.close();
socketTarget = parseHostTarget(tunnel.endpoint) as Extract<HostTarget, { kind: "socket" }>;
} 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<DaemonClientConfig, "url" | "transportFactory">,
): DaemonClient {
const target = parseHostTarget(connection);
return new DaemonClient({
...config,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection),
});
}
-90
View File
@@ -1,90 +0,0 @@
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<SshTunnel> {
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),
});
});
});
}
+4 -4
View File
@@ -1,16 +1,16 @@
import type { ComponentType } from "react";
import type { PluginHostProps } from "./contracts.js";
import type { PluginIconProps } from "../icons.js";
import type { PluginHostProps, PluginIcon } from "./contracts.js";
export type PluginButtonContext =
| { context: "workspace"; workspaceId: string }
| { context: "agent"; workspaceId: string; agentId: string };
export type PluginButtonIconProps = PluginHostProps &
PluginButtonContext & { size: number; color: string };
export type PluginButtonIconProps = PluginHostProps & PluginButtonContext & PluginIconProps;
export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void };
export type PluginButtonIcon = string | ComponentType<PluginButtonIconProps>;
export type PluginButtonIcon = PluginIcon<PluginButtonIconProps>;
export type PluginButtonBehavior =
| { kind: "action"; onPress(): void | Promise<void> }
+12 -9
View File
@@ -17,6 +17,7 @@ import type {
PluginTimelineTransformResult,
PluginCleanup,
} from "../contracts.js";
import type { PluginIconProps, PluginSerializableIcon } from "../icons.js";
export interface PluginHostProps {
theme: PluginTheme;
@@ -87,11 +88,13 @@ export interface PluginSidebarItemContribution {
Component: ComponentType<PluginSidebarItemProps>;
}
export interface PluginIconProps {
name: string;
size?: number;
color?: string;
}
/**
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
* and color; buttons widen the props with their host and target context.
*/
export type PluginIcon<Props extends PluginIconProps = PluginIconProps> =
| PluginSerializableIcon
| ComponentType<Props>;
export type PluginPanelLocation = "workspace" | "explorer";
@@ -102,7 +105,7 @@ export interface PluginOpenPanelOptions {
interface PluginWorkspacePanelBase {
id: string;
title: string;
icon: string;
icon: PluginIcon;
locations?: readonly PluginPanelLocation[];
}
@@ -168,7 +171,7 @@ export type PluginWorkspacePanelContribution =
export interface PluginSettingsScreenContribution {
id: string;
title: string;
icon: string;
icon: PluginIcon;
Component: ComponentType<PluginSurfaceProps>;
}
@@ -190,7 +193,7 @@ export interface PluginSurfaceContribution {
export interface PluginSidebarContribution {
id: string;
title: string;
icon: string;
icon: PluginIcon;
surface: string;
}
@@ -259,7 +262,7 @@ export interface PluginAgentCommandContext extends PluginCommandCapabilities {
interface PluginCommandCenterItemBase {
id: string;
title: string;
icon: string;
icon: PluginIcon;
keywords?: readonly string[];
}
+2 -1
View File
@@ -8,7 +8,7 @@ export type {
PluginScreenLocation,
PluginSidebarItemProps,
PluginSidebarItemContribution,
PluginIconProps,
PluginIcon,
PluginPanelLocation,
PluginOpenPanelOptions,
PluginWorkspacePanelProps,
@@ -45,6 +45,7 @@ export type {
PluginComposerPillContribution,
PluginHeaderButtonContribution,
} from "./buttons.js";
export type { PluginIconProps, PluginSerializableIcon, PluginSvgIcon } from "../icons.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
+8 -3
View File
@@ -17,7 +17,8 @@ import type {
TextInput as NativeTextInput,
TextInputProps,
} from "react-native";
import type { PluginIconProps } from "./contracts.js";
import type { PluginIconProps } from "../icons.js";
import type { PluginIcon } from "./contracts.js";
export interface ModalProps {
title: string;
@@ -53,12 +54,16 @@ export interface ToastApi {
error(message: string): void;
}
export declare const Icon: ComponentType<PluginIconProps>;
/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */
export type IconProps = Partial<PluginIconProps> &
({ name: string; icon?: never } | { icon: PluginIcon; name?: never });
export declare const Icon: ComponentType<IconProps>;
export declare const Modal: ModalComponent;
export declare function useToast(): ToastApi;
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
export type { PluginIconProps } from "./contracts.js";
export type { PluginIconProps } from "../icons.js";
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
export declare const ScrollView: ForwardRefExoticComponent<
+3 -2
View File
@@ -1,4 +1,5 @@
import type { ComponentType, ReactNode, Ref } from "react";
import type { PluginIcon } from "./contracts.js";
export interface SettingsSectionProps {
title: string;
@@ -64,14 +65,14 @@ export interface ExternalLinkProps {
}
export declare const ExternalLink: ComponentType<ExternalLinkProps>;
export type SidebarIcon = string | ComponentType<{ size: number; color: string }>;
export type SidebarIcon = PluginIcon;
export interface SidebarRowProps {
/**
* Tells rows of one item apart when the item renders several: "bot-2". Unique within the item.
* Omit it when the item renders one row.
*/
id?: string;
/** A Lucide icon name or a component. */
/** A Lucide icon name, inline SVG, or a component. */
icon?: SidebarIcon;
/** Defaults to the item's registered title. */
label?: string;
+2 -1
View File
@@ -1,4 +1,5 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginSerializableIcon } from "./icons.js";
import type { PluginRpcContract } from "./rpc.js";
export interface PluginTheme {
@@ -73,7 +74,7 @@ export interface PluginThemeContribution {
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
icon: PluginSerializableIcon;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { findSvgIconProblem } from "./icons.js";
describe("findSvgIconProblem", () => {
it.each([
["a plain SVG", '<svg viewBox="0 0 24 24"><path d="M0 0h24v24z" /></svg>'],
["leading whitespace", '\n <svg viewBox="0 0 24 24"></svg>'],
[
"a fragment reference",
'<svg><defs><path id="mark" d="M0 0h1v1z" /></defs><use href="#mark" /></svg>',
],
["currentColor fills", '<svg fill="currentColor"><circle r="4" /></svg>'],
])("accepts %s", (_name, svg) => {
expect(findSvgIconProblem(svg)).toBeNull();
});
it.each([
["a non-SVG document", "hello", "not an SVG document"],
["an <svgx> element", "<svgx></svgx>", "not an SVG document"],
["a script", "<svg><script /></svg>", "script elements are not allowed"],
["a foreign object", "<svg><foreignObject /></svg>", "foreignObject elements are not allowed"],
["a style", "<svg><style /></svg>", "style elements are not allowed"],
[
"an event handler",
'<svg><path onclick="run()" /></svg>',
"event-handler attributes are not allowed",
],
[
"a javascript URL",
'<svg><a href="javascript:run()" /></svg>',
"javascript URLs are not allowed",
],
[
"an external href",
'<svg><use href="https://example.com/icon.svg" /></svg>',
"external href references are not allowed",
],
[
"an external xlink:href",
"<svg><image xlink:href=icon.png /></svg>",
"external href references are not allowed",
],
["markup over 64 KiB", `<svg>${" ".repeat(64 * 1024)}</svg>`, "exceeds 64 KiB"],
])("rejects %s", (_name, svg, reason) => {
expect(findSvgIconProblem(svg)).toBe(reason);
});
it("measures the size in UTF-8 bytes", () => {
const multibyte = "é".repeat(32 * 1024);
expect(findSvgIconProblem(`<svg>${multibyte}</svg>`)).toBe("exceeds 64 KiB");
});
});
+38
View File
@@ -0,0 +1,38 @@
/** What a component icon receives from the surface that draws it. */
export interface PluginIconProps {
size: number;
color: string;
}
/** Inline SVG markup. Paseo validates it when the contribution registers. */
export interface PluginSvgIcon {
svg: string;
}
/** An icon that can be stored or sent: a Lucide icon name, or inline SVG. */
export type PluginSerializableIcon = string | PluginSvgIcon;
export const PLUGIN_SVG_ICON_MAX_BYTES = 64 * 1024;
const EXTERNAL_HREF = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
/**
* Why Paseo refuses to render this SVG, or null when it is a self-contained document.
* The daemon applies it to provider and usage-source icon files; the app to inline plugin icons.
*/
export function findSvgIconProblem(svg: string): string | null {
if (new TextEncoder().encode(svg).byteLength > PLUGIN_SVG_ICON_MAX_BYTES) {
return "exceeds 64 KiB";
}
if (!/^\s*<svg(?:\s|>)/i.test(svg)) return "not an SVG document";
if (/<script(?:\s|>)/i.test(svg)) return "script elements are not allowed";
if (/<foreignObject(?:\s|>)/i.test(svg)) return "foreignObject elements are not allowed";
if (/<style(?:\s|>)/i.test(svg)) return "style elements are not allowed";
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) return "event-handler attributes are not allowed";
if (/javascript\s*:/i.test(svg)) return "javascript URLs are not allowed";
for (const match of svg.matchAll(EXTERNAL_HREF)) {
const href = match[2] ?? match[3] ?? "";
if (!href.startsWith("#")) return "external href references are not allowed";
}
return null;
}
+7
View File
@@ -20,3 +20,10 @@ export {
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
export {
findSvgIconProblem,
PLUGIN_SVG_ICON_MAX_BYTES,
type PluginIconProps,
type PluginSerializableIcon,
type PluginSvgIcon,
} from "./icons.js";
-76
View File
@@ -1,76 +0,0 @@
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://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",
"alice:example-secret@localhost:6767",
"not-a-scheme://localhost:6767",
"localhost/path:6767",
"localhost?password=example-secret:6767",
"localhost#example-secret:6767",
"tcp://localhost/path:6767",
"relay://alice:example-secret@localhost:6767/server?key=private",
])("invalid target syntax is rejected without echoing %s", (connection) => {
expect(() => parseHostTarget(connection)).toThrow("Invalid host connection");
});
test("SSH aliases and DNS names retain their connection semantics", () => {
expect(parseHostTarget("build-box.example.:6767").description).toBe(
"tcp://build-box.example.:6767",
);
expect(parseHostTarget("ssh://deploy@my_ssh-alias")).toMatchObject({
kind: "ssh",
ssh: { host: "deploy@my_ssh-alias" },
});
});
test("encoded SSH usernames cannot leak into the target description", () => {
expect(parseHostTarget("ssh://alice%3Aexample-secret@my-alias").description).toBe(
"ssh://my-alias (daemon port 6767)",
);
});
test("pairing offers cannot publish a credential-bearing or malformed relay authority", () => {
for (const endpoint of [
"alice:example-secret@localhost:6767",
"localhost/path:6767",
"localhost?token=example-secret:6767",
]) {
const offer = {
v: 2,
serverId: "srv_test",
daemonPublicKeyB64: "private-key",
relay: { endpoint, useTls: false },
};
const url = `https://app.paseo.sh/#offer=${Buffer.from(JSON.stringify(offer)).toString("base64url")}`;
expect(() => parseHostTarget(url)).toThrow("Invalid host connection");
}
});
-137
View File
@@ -1,137 +0,0 @@
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 = validatedEndpoint(/^\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 {
const endpoint = validatedEndpoint(offer.relay.endpoint);
return {
kind: "socket",
url: buildRelayWebSocketUrl({
endpoint,
serverId: offer.serverId,
role: "client",
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
}),
offer,
password,
description: `relay://${endpoint}/${encodeURIComponent(offer.serverId)}`,
};
}
function tcpTarget(input: string): HostTarget {
const uri = new URL(input);
if (uri.pathname && uri.pathname !== "/") throw new Error("Unexpected TCP path");
if (uri.hash) throw new Error("Unexpected TCP fragment");
const parts = parseConnectionUri(input);
const endpoint = validatedEndpoint(
`${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://${uri.hostname}${ssh.sshPort ? `:${ssh.sshPort}` : ""} (daemon port ${ssh.daemonPort})`,
};
}
/** A bare endpoint has exactly one authority; URL syntax must use an explicit supported scheme. */
function validatedEndpoint(input: string): string {
if (!/^(?:[a-zA-Z0-9_][a-zA-Z0-9_.-]*|\[[0-9a-fA-F:.]+\]):[0-9]{1,5}$/.test(input))
throw new Error("Invalid endpoint");
// URL parsing validates IPv6 literals and rejects invalid authority/port encodings.
const url = new URL(`http://${input}`);
if (!url.hostname) throw new Error("Missing endpoint host");
return normalizeHostPort(input);
}
-112
View File
@@ -1,112 +0,0 @@
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<typeof HostAddSchema>;
export type HostUpdate = z.infer<typeof HostUpdateSchema>;
export type HostSummary = z.infer<typeof HostSummarySchema>;
export type HostDirectory = z.infer<typeof HostDirectorySchema>;
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() }),
});
-42
View File
@@ -1,21 +1,4 @@
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 {
@@ -3213,14 +3196,6 @@ export const SubscriptionReleaseResponseSchema = z.object({
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HostsListRequestSchema,
HostsAddRequestSchema,
HostsUpdateRequestSchema,
HostsRemoveRequestSchema,
HostsRouteOpenRequestSchema,
HostsRouteFrameRequestSchema,
HostsRouteCloseRequestSchema,
BrowserHostRegisterRequestSchema,
SubscriptionReleaseRequestSchema,
SessionEventsSetSubscriptionRequestSchema,
@@ -3589,8 +3564,6 @@ export const ServerInfoStatusPayloadSchema = z
serverId: z.string().trim().min(1),
hostname: ServerInfoHostnameSchema.optional(),
version: ServerInfoVersionSchema.optional(),
// Authenticated producer for host registration ownership, separate from admission authority.
sourceId: z.string().optional(),
// COMPAT(sessionPermissions): optional while clients support older daemons.
permissions: z.array(DaemonPermissionSchema).optional(),
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
@@ -3612,7 +3585,6 @@ 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(),
@@ -6879,15 +6851,6 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HostsListResponseSchema,
HostsChangedSchema,
HostsAddResponseSchema,
HostsUpdateResponseSchema,
HostsRemoveResponseSchema,
HostsRouteOpenResponseSchema,
HostsRouteFrameSchema,
HostsRouteClosedSchema,
BrowserHostRegisterResponseSchema,
SubscriptionReleaseResponseSchema,
SessionEventsSetSubscriptionResponseSchema,
@@ -7579,11 +7542,6 @@ 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(),
+1 -1
View File
@@ -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 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|connected hosts\"",
"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: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",
@@ -6,13 +6,6 @@ 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",
@@ -222,14 +215,6 @@ const INBOUND_PERMISSION = {
} as const satisfies Record<InboundOperation, PermissionRequirement>;
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",
@@ -1,718 +0,0 @@
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<unknown>)[] = [];
afterEach(async () => {
for (const close of cleanup.splice(0).toReversed()) await close();
});
async function daemon(): Promise<TestPaseoDaemon> {
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();
expect(client.getLastServerInfoMessage()).toMatchObject({ sourceId: "owner" });
expect(client.getLastServerInfoMessage()).not.toHaveProperty("principalId");
const paseo = createPaseoApi(client);
cleanup.push(async () => {
await paseo.dispose();
await client.close();
});
return { paseo, client };
}
async function online(paseo: ReturnType<typeof createPaseoApi>): Promise<HostDirectory> {
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",
});
for (const connection of [
"alice:example-secret@localhost:6767",
"not-a-scheme://localhost:6767",
"localhost/path:6767",
]) {
const failure = await paseo.hosts.add({ connection }).catch((error: unknown) => error);
expect(failure).toMatchObject({ code: "invalid_connection" });
expect(String(failure)).not.toContain("example-secret");
expect((await paseo.hosts.list()).hosts).toEqual([]);
}
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);
const info = peer.messages.find(
(message) => message.type === "status" && message.payload.status === "server_info",
);
if (info?.type !== "status") throw new Error("Missing routed server info");
expect(info.payload).toMatchObject({
sourceId: `route:owner:${origin.getLastServerInfoMessage()!.serverId}:owner`,
});
expect(info.payload).not.toHaveProperty("principalId");
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 });
// Windows exposes inherited ACLs, not POSIX owner/group/other mode bits.
if (process.platform !== "win32")
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,
[
"--conditions=source",
"--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 malformed = await cli("add", "alice:example-secret@localhost:6767").catch(
(error: unknown) => error,
);
expect(malformed).toHaveProperty("stderr");
expect((malformed as { stderr: string }).stderr).toContain("invalid_connection");
expect((malformed as { stderr: string }).stderr).not.toContain("example-secret");
expect(await cli("ls")).toEqual([]);
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<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((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<void>((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<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((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);
});
@@ -1,531 +0,0 @@
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<typeof SavedSchema>;
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<string, Live>();
private readonly routes = new Set<Route>();
private readonly listeners = new Set<() => void>();
private queue: Promise<unknown> = Promise.resolve();
private readonly ready: Promise<void>;
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<void> {
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<HostDirectory> {
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<void> {
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<T>(run: () => Promise<T>): Promise<T> {
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<HostSummary> {
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<HostSummary> {
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<void> {
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<typeof setTimeout> | 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<void>((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<void> {
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;
}
@@ -1,234 +0,0 @@
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<T extends SessionInboundMessage["type"]> = Extract<
SessionInboundMessage,
{ type: T }
>;
interface SessionRoute {
scopeId: string;
source: object;
send(data: string, binary: boolean): void;
release(): Promise<void>;
fail(code: string, message: string): void;
}
/** Adapts host capabilities to source-owned session delivery. */
export class ConnectedHostSession {
private readonly routes = new Map<string, SessionRoute>();
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<void> | 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<void> {
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<void> {
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<void> {
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<void> {
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;
}
}
}
@@ -1,14 +1,3 @@
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { createHostClient } from "@getpaseo/client/node";
import { hashSync } from "bcryptjs";
import { once } from "node:events";
import {
decodeTerminalStreamFrame,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
} from "@getpaseo/protocol/binary-frames/index";
import type { DaemonClient as HostClient } from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { describe, expect, test } from "vitest";
import { withTimeout } from "../../utils/promise-timeout.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
@@ -205,9 +194,9 @@ async function waitForCapturedLog(
let relayProcess: ChildProcess | null = null;
let relayStdoutLines: string[] = [];
const startRelay = async (options: { useLocalRelay?: boolean; port?: number } = {}) => {
const startRelay = async (options: { useLocalRelay?: boolean } = {}) => {
relayStdoutLines = [];
relayPort = options.port ?? (await getAvailablePort());
relayPort = await getAvailablePort();
const relayDir = path.resolve(process.cwd(), "../relay");
const relayArgs = [
"wrangler",
@@ -266,142 +255,10 @@ async function waitForCapturedLog(
const stopRelay = async () => {
if (!relayProcess) return;
const process = relayProcess;
relayProcess.kill("SIGTERM");
relayProcess = null;
const exited = once(process, "exit");
process.kill("SIGTERM");
await withTimeout(exited, 10000, "Local relay did not stop");
};
test("connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", async () => {
let a: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let b: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let origin: ReturnType<typeof createHostClient> | undefined;
let api: PaseoApi | undefined;
let remote: PaseoApi | undefined;
let sibling: PaseoApi | undefined;
let terminalRoute: Awaited<ReturnType<typeof openConnectedHostTerminal>> | undefined;
try {
await startRelay({ useLocalRelay: true });
const port = relayPort;
b = await createTestPaseoDaemon({
relayEnabled: true,
relayEndpoint: `127.0.0.1:${port}`,
relayUseTls: false,
relayPublicUseTls: false,
auth: { password: hashSync("relay-password", 4) },
});
a = await createTestPaseoDaemon();
origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() });
await origin.connect();
api = createPaseoApi(origin);
const offerUrl = await getPairingOfferUrl({
paseoHome: b.paseoHome,
relayEnabled: true,
relayEndpoint: b.config.relayEndpoint,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const connection = new URL(`relay://127.0.0.1:${port}/${serverId}`);
connection.searchParams.set("key", daemonPublicKeyB64);
connection.searchParams.set("password", "wrong-password");
const saved = await api.hosts.add({ connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 })
.toBe("authentication_failed");
expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/);
connection.searchParams.set("password", "relay-password");
await api.hosts.update(saved.registrationId, { connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 })
.toBe("online");
remote = await api.hosts.client(serverId);
sibling = await api.hosts.client(serverId);
expect((await remote.workspaces.list()).entries).toEqual([]);
const observed = await remote.workspaces.list({ subscribe: {} });
let snapshots = 0;
observed.subscription.subscribe({
snapshot: () => {
snapshots++;
},
});
const workspace = await remote.workspaces.create({
source: { kind: "directory", path: b.staticDir },
title: "Relayed workspace",
});
const terminal = await workspace.terminals.create({
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE");
terminalRoute.close();
terminalRoute = undefined;
const before = snapshots;
await stopRelay();
await expect
.poll(
async () => {
try {
await remote!.workspaces.list();
return "online";
} catch (error) {
return (error as { code: string }).code;
}
},
{ timeout: 15000 },
)
.toBe("host_offline");
await startRelay({ useLocalRelay: true, port });
await expect
.poll(
async () => {
try {
return (await remote!.workspaces.list()).entries.length;
} catch {
return -1;
}
},
{ timeout: 30000 },
)
.toBe(1);
await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before);
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
terminalRoute.write("ENCRYPTED_BINARY_AFTER\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER");
terminalRoute.close();
terminalRoute = undefined;
await remote.dispose();
remote = undefined;
expect((await sibling.workspaces.list()).entries).toHaveLength(1);
await sibling.terminals.ref(terminal.id).kill();
await api.dispose();
await expect(sibling.workspaces.list()).rejects.toThrow();
const direct = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
password: "relay-password",
reconnect: { enabled: false },
});
try {
await direct.connect();
const directApi = createPaseoApi(direct);
expect((await directApi.workspaces.list()).entries).toHaveLength(1);
await directApi.dispose();
} finally {
await direct.close();
}
} finally {
terminalRoute?.close();
await remote?.dispose();
await sibling?.dispose();
await api?.dispose();
await origin?.close();
await a?.close();
await b?.close();
await stopRelay();
}
}, 120000);
test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => {
await startRelay({ useLocalRelay: true });
const daemon = await createTestPaseoDaemon({
@@ -1075,73 +932,3 @@ async function waitForCapturedLog(
}
}, 90000);
});
async function openConnectedHostTerminal(origin: HostClient, serverId: string, terminalId: string) {
const routeId = await origin.openHostRoute(serverId, crypto.randomUUID());
const messages: SessionOutboundMessage[] = [];
let output = "";
const detach = origin.subscribeRawMessages((message) => {
if (message.type !== "hosts.route.frame" || message.payload.subscriptionId !== routeId) return;
if (message.payload.binary) {
const frame = decodeTerminalStreamFrame(Buffer.from(message.payload.data, "base64"));
if (frame?.opcode === TerminalStreamOpcode.Output)
output += new TextDecoder().decode(frame.payload);
} else {
const frame = WSOutboundMessageSchema.parse(JSON.parse(message.payload.data));
if (frame.type === "session") messages.push(frame.message);
}
});
const close = () => {
detach();
origin.closeHostRoute(routeId);
};
try {
origin.sendHostFrame(
routeId,
JSON.stringify({
type: "hello",
clientId: "relay-binary",
clientType: "cli",
protocolVersion: 1,
}),
false,
);
await expect
.poll(() =>
messages.some(
(message) => message.type === "status" && message.payload.status === "server_info",
),
)
.toBe(true);
origin.sendHostFrame(
routeId,
JSON.stringify({
type: "session",
message: { type: "subscribe_terminal_request", terminalId, requestId: "binary" },
}),
false,
);
await expect
.poll(() => messages.some((message) => message.type === "subscribe_terminal_response"))
.toBe(true);
const response = messages.find((message) => message.type === "subscribe_terminal_response");
if (response?.type !== "subscribe_terminal_response" || response.payload.slot == null)
throw new Error("Missing binary stream slot");
const slot = response.payload.slot;
return {
close,
output: () => output,
write: (payload: string) =>
origin.sendHostFrame(
routeId,
Buffer.from(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Input, slot, payload }),
).toString("base64"),
true,
),
};
} catch (error) {
close();
throw error;
}
}
@@ -534,6 +534,25 @@ export type Value = string;`,
expect(clientBundle).not.toContain("React.createElement");
});
it("imports an SVG file from a runtime directory as its markup", async () => {
const entries = await createSplitPlugin();
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
await writeFile(path.join(entries.directory, "client", "icon.svg"), svg);
await writeFile(
entries.client,
`import icon from "./client/icon.svg";
export default function contribute(client) {
client.addCommandCenterItem({ id: "open", title: "Open", icon: { svg: icon }, context: "global", onSelect() {} });
return () => undefined;
}`,
);
const { clientBundle } = await compilePlugin(entries);
const registered: unknown[] = [];
const exports = new Function(`return ${clientBundle}`)()(() => undefined);
exports.default({ addCommandCenterItem: (item: unknown) => registered.push(item) });
expect(registered).toMatchObject([{ icon: { svg } }]);
});
it("lowers async callbacks before Hermes evaluates the client bundle", async () => {
const entries = await createSplitPlugin();
await writeFile(
@@ -393,6 +393,8 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
bundle: true,
format: "cjs",
jsx: "automatic",
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
loader: { ".svg": "text" },
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// The neutral platform reads no package.json entry fields, so packages without
@@ -29,18 +29,14 @@ describe("readPluginProviderIcon", () => {
});
test.each([
["non-SVG document", "hello", "file is not an SVG document"],
["script", "<svg><script /></svg>", "script elements"],
["foreign object", "<svg><foreignObject /></svg>", "foreignObject elements"],
["style", "<svg><style /></svg>", "style elements"],
["event handler", '<svg><path onclick="run()" /></svg>', "event-handler attributes"],
["javascript URL", '<svg><a href="javascript:run()" /></svg>', "javascript URLs"],
["non-SVG document", "hello", "not an SVG document"],
["script", "<svg><script /></svg>", "script elements are not allowed"],
["external href", '<svg><use href="https://example.com/icon.svg" /></svg>', "external href"],
])("rejects %s", async (_name, svg, reason) => {
])("rejects %s with the shared SVG rules", async (_name, svg, reason) => {
const directory = await writeIcon(svg);
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
`icon.svg": ${reason}`,
`Invalid plugin provider icon "icon.svg": ${reason}`,
);
});
@@ -78,7 +74,7 @@ describe("readPluginProviderIcon", () => {
const directory = await writeIcon(`<svg>${" ".repeat(64 * 1024)}</svg>`);
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
"file exceeds 64 KiB",
`Invalid plugin provider icon "icon.svg": exceeds 64 KiB`,
);
});
});
@@ -1,7 +1,6 @@
import { readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
const MAX_ICON_BYTES = 64 * 1024;
import { findSvgIconProblem } from "@getpaseo/plugin";
export async function readPluginProviderIcon(
pluginDirectory: string,
@@ -20,10 +19,10 @@ export async function readPluginProviderIcon(
if (!iconStat.isFile()) {
throw iconError(iconPath, "file does not exist or is not a regular file");
}
if (iconStat.size > MAX_ICON_BYTES) throw iconError(iconPath, "file exceeds 64 KiB");
const svg = await readFile(resolvedIcon, "utf8");
validateSvg(iconPath, svg);
const problem = findSvgIconProblem(svg);
if (problem) throw iconError(iconPath, problem);
return svg;
}
@@ -38,27 +37,6 @@ function assertInsidePluginDirectory(
if (escapesDirectory) throw iconError(iconPath, "path leaves the plugin directory");
}
function validateSvg(iconPath: string, svg: string): void {
if (!/^\s*<svg(?:\s|>)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document");
if (/<script(?:\s|>)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed");
if (/<foreignObject(?:\s|>)/i.test(svg)) {
throw iconError(iconPath, "foreignObject elements are not allowed");
}
if (/<style(?:\s|>)/i.test(svg)) throw iconError(iconPath, "style elements are not allowed");
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) {
throw iconError(iconPath, "event-handler attributes are not allowed");
}
if (/javascript\s*:/i.test(svg)) throw iconError(iconPath, "javascript URLs are not allowed");
const hrefPattern = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
for (const match of svg.matchAll(hrefPattern)) {
const href = match[2] ?? match[3] ?? "";
if (!href.startsWith("#")) {
throw iconError(iconPath, "external href references are not allowed");
}
}
}
function iconError(iconPath: string, reason: string): Error {
return new Error(`Invalid plugin provider icon "${iconPath}": ${reason}`);
}
+4 -58
View File
@@ -1,6 +1,4 @@
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";
@@ -443,9 +441,6 @@ const nodeSessionFileSystem: SessionFileSystem = {
type AgentMcpTransportFactory = () => Promise<unknown>;
export interface SessionOptions {
connectedHosts?: ConnectedHosts;
sourceId?: string;
routed?: boolean;
browserToolsBroker?: BrowserToolsBroker | null;
clientId: string;
permissions: readonly DaemonPermission[];
@@ -456,7 +451,6 @@ export interface SessionOptions {
onBinaryMessage?: (frame: Uint8Array) => void;
onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise<void>;
getTransportBufferedAmount?: (source?: object) => number | null;
getConnectedHostBufferedAmount?: () => number | null;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
publishScriptStatusUpdate?: (message: ScriptStatusUpdateMessage) => void;
@@ -679,22 +673,6 @@ interface ClientActivity {
}
export class Session {
private readonly connectedHostSession: ConnectedHostSession | null;
private routed = false;
isRouted(): boolean {
return this.routed;
}
getHostSourceId(): string {
return this.hostSourceId;
}
private hostSourceId: string;
setHostSource(sourceId: string): void {
this.hostSourceId = sourceId;
this.connectedHostSession?.setSource(sourceId);
}
setRouted(routed: boolean): void {
this.routed = routed;
}
readonly delivery = new SessionDelivery(
(source, message) => {
if (!this.authorization.allowsOutbound(message)) return;
@@ -884,12 +862,9 @@ 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.hostSourceId = hostSource(options);
this.connectedHostSession = createHostSession(options, this);
this.onMessage = onMessage;
this.onMessageToSource = onMessageToSource ?? null;
this.onBinaryMessage = onBinaryMessage ?? null;
@@ -2184,7 +2159,7 @@ export class Session {
requestId,
requestType: msg.type,
error: `Session is not authorized for ${msg.type}`,
code: msg.type.startsWith("hosts.") ? "permission_denied" : "access_denied",
code: "access_denied",
},
});
}
@@ -2231,7 +2206,6 @@ 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")
@@ -2346,10 +2320,11 @@ export class Session {
private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise<void> {
const promise =
this.connectedHostSession?.dispatch(msg) ??
this.dispatchSubscriptionMessage(msg, source) ??
this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentObservationMessage(msg, source) ??
this.dispatchAgentRewindMessage(msg, source) ??
this.dispatchAgentRelationshipMessage(msg) ??
this.dispatchAgentTimelineMessage(msg, source) ??
this.dispatchHubExecutionMessage(msg) ??
this.dispatchCreationMessage(msg, source) ??
this.dispatchAgentLifecycleMessage(msg) ??
@@ -2367,17 +2342,6 @@ export class Session {
if (promise) await promise;
}
private dispatchAgentObservationMessage(
msg: SessionInboundMessage,
source?: object,
): Promise<void> | undefined {
return (
this.dispatchAgentRewindMessage(msg, source) ??
this.dispatchAgentRelationshipMessage(msg) ??
this.dispatchAgentTimelineMessage(msg, source)
);
}
private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
return (
this.dispatchWorkspaceStateMessage(msg) ??
@@ -8720,21 +8684,3 @@ 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";
}
@@ -71,18 +71,6 @@ const sessionMock = vi.hoisted(() => {
wantsSourceEvent = (source: object) => !this.delivery.isModern(source);
getSessionId = vi.fn(() => "mock-session-id");
getPermissions = vi.fn(() => this.args.permissions as string[]);
setPermissions = (permissions: string[]) => {
this.args.permissions = permissions;
};
private routed = false;
setRouted = (routed: boolean) => {
this.routed = routed;
};
isRouted = () => this.routed;
setHostSource = (sourceId: string) => {
this.args.sourceId = sourceId;
};
getHostSourceId = () => this.args.sourceId as string;
allowsInbound = vi.fn(() => true);
allowsPermission = vi.fn(() => true);
publish = vi.fn((message: unknown) => {
@@ -485,12 +473,16 @@ function holdNextSessionMessage(session: (typeof sessionMock.instances)[number])
function holdSessionCleanup(session: (typeof sessionMock.instances)[number]): {
finish: () => void;
} {
let finish!: () => void;
const pending = new Promise<void>((resolve) => {
finish = resolve;
});
session.cleanup.mockImplementationOnce(() => pending);
return { finish };
let finish = () => {};
session.cleanup.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
return {
finish: () => finish(),
};
}
describe("relay external socket reconnect behavior", () => {
+2 -62
View File
@@ -1,4 +1,3 @@
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";
@@ -438,7 +437,6 @@ export interface WebSocketLike {
}
interface SessionConnectionBase {
permissionCeiling?: readonly DaemonPermission[];
session: Session;
principalId: string;
sessionKey: string;
@@ -462,7 +460,6 @@ interface PluginSessionConnection extends SessionConnectionBase {
type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
interface SocketSessionOptions {
sourceId?: string;
clientId: string;
appVersion: string | null;
clientCapabilities: Record<string, unknown> | null;
@@ -553,7 +550,6 @@ 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;
@@ -705,7 +701,6 @@ 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;
@@ -1052,11 +1047,7 @@ export class VoiceAssistantWebSocketServer {
}
for (const connection of new Set(this.externalSessionsByKey.values())) {
if (connection.principalId === principalId) {
connection.session.setPermissions(
connection.permissionCeiling
? permissions.filter((permission) => connection.permissionCeiling!.includes(permission))
: permissions,
);
connection.session.setPermissions(permissions);
}
}
}
@@ -1073,7 +1064,6 @@ export class VoiceAssistantWebSocketServer {
public async close(): Promise<void> {
this.prepareForShutdown();
await this.connectedHosts.close();
this.unsubscribeSpeechReadiness?.();
this.unsubscribeSpeechReadiness = null;
this.unsubscribeDaemonConfigChange?.();
@@ -1397,10 +1387,6 @@ export class VoiceAssistantWebSocketServer {
appVersion,
clientCapabilities,
permissions: admission.permissions,
sourceId:
lifecycle.kind === "ephemeral-plugin"
? `plugin:${lifecycle.pluginId}`
: admission.principalId,
connectionLogger,
onMessage: (msg) => {
if (!connection) {
@@ -1470,16 +1456,6 @@ 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,
@@ -1633,16 +1609,8 @@ export class VoiceAssistantWebSocketServer {
}
this.clearPendingConnection(ws);
let admitted = pending.admission;
const 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;
@@ -1665,9 +1633,6 @@ 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);
@@ -1783,10 +1748,6 @@ 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;
@@ -1817,21 +1778,6 @@ 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",
@@ -1839,13 +1785,11 @@ export class VoiceAssistantWebSocketServer {
serverId: this.serverId,
hostname: getHostName(),
version: this.daemonVersion,
sourceId: session.getHostSourceId(),
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,
@@ -2134,10 +2078,6 @@ 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);
@@ -0,0 +1,5 @@
/** Inline SVG icon. `currentColor` follows the surface's icon color. */
export const counterIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="4" />
<path d="M7.5 8v8M10.5 8v8M13.5 8v8M16.5 8v8M6 15l12-6" />
</svg>`;
@@ -1,11 +1,12 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { counterIcon } from "./client/icons";
import { contributeClient, ExamplePanel } from "./client/main";
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({
id: "counter",
title: "Plugin counter",
icon: "Blocks",
icon: { svg: counterIcon },
context: "workspace",
locations: ["workspace", "explorer"],
Component: ExamplePanel,
-25
View File
@@ -64,31 +64,6 @@ 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:
+2 -1
View File
@@ -222,7 +222,8 @@ client.addCommandCenterItem({
});
```
`icon` is a [Lucide](https://lucide.dev/icons/) icon name.
`icon` is a [Lucide](https://lucide.dev/icons/) icon name, inline SVG, or a component. See
[Icons](/docs/plugins/reference#icons).
## Edit and reload
+96 -50
View File
@@ -26,26 +26,6 @@ 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:
@@ -182,7 +162,7 @@ guarantee compatibility with another host's renderer.
These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "<name>" is not available in plugin client code`.
Do not import `lucide-react-native`, `react-native-svg`, or DOM libraries. Set contribution `icon` fields to a [Lucide icon name](https://lucide.dev/icons/); Paseo validates the name and renders the icon.
Do not import `lucide-react-native`, `react-native-svg`, or DOM libraries. Set contribution `icon` fields to a [Lucide icon name](https://lucide.dev/icons/), inline SVG, or a component; Paseo validates the icon and renders it. See [Icons](#icons).
### Cross-platform rules
@@ -358,11 +338,9 @@ Use `runAcpProvider()` from `@getpaseo/plugin/server/acp` to adapt a command-bac
hooks only for a vendor's discovery, configuration, notification, or tool-call differences.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
It must resolve inside that directory to a regular file that follows the
[SVG icon rules](#svg-rules). Paseo reads and validates the file when the plugin starts; the string
is never inline SVG or a URL.
### Usage sources
@@ -536,7 +514,7 @@ bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Faile
on `server_info.features.usageSources`. The older `provider.usage.list` RPC maps the same reports
for 0.10 clients and renders problems into its `error` string.
The source icon follows the provider SVG restrictions above.
The source icon is a file path with the same rules as a provider icon.
## Entry point and cleanup
@@ -988,10 +966,10 @@ closes the popover.
Render the item with the sidebar kit from `@getpaseo/plugin/client/ui`:
| Component | Props and behavior |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SidebarRow` | Required `onPress`; optional `icon` (Lucide name or `{ size, color }` component), `label`, `active`, `trailing`, `id`. A full-width row. `trailing` renders beside the row's pressable, so a button in it presses on its own; a press elsewhere in it presses the row. `id` tells rows of one item apart in test IDs. |
| `SidebarSeparator` | No props. The sidebar's separator line, between groups of rows. |
| Component | Props and behavior |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SidebarRow` | Required `onPress`; optional `icon` (any [icon](#icons) form), `label`, `active`, `trailing`, `id`. A full-width row. `trailing` renders beside the row's pressable, so a button in it presses on its own; a press elsewhere in it presses the row. `id` tells rows of one item apart in test IDs. |
| `SidebarSeparator` | No props. The sidebar's separator line, between groups of rows. |
A popover anchors to the row that was pressed, whether the press landed on the row or on its
`trailing` content, so a per-row "More" button opens its popover next to its own row.
@@ -1266,14 +1244,82 @@ Showing another toast replaces the currently visible toast. An empty message is
### Icons
`Icon` renders a [Lucide icon](https://lucide.dev/icons/) from Paseo's installed icon set. Plugin bundles do not import
`lucide-react-native` or `react-native-svg`.
Every contribution `icon` field takes one of three forms, typed `PluginIcon` in
`@getpaseo/plugin/client`:
| Prop | Type | Required | Behavior |
| ------- | -------- | -------- | ----------------------------------------------- |
| `name` | `string` | Yes | Lucide icon name. Unknown names render nothing. |
| `size` | `number` | No | Icon width and height. |
| `color` | `string` | No | Icon color. Use a plugin theme token. |
| Form | Example | Use it for |
| ----------- | --------------------------- | --------------------------------------------------- |
| Lucide name | `icon: "Scan"` | Any [Lucide icon](https://lucide.dev/icons/). |
| Inline SVG | `icon: { svg: reviewIcon }` | A brand mark or an icon Lucide does not have. |
| Component | `icon: ReviewBadge` | An icon that changes with state, such as a counter. |
Attachment sources take a Lucide name or inline SVG (`PluginSerializableIcon`), because each
attached item stores its source's icon.
Paseo validates the icon when the contribution registers. An unknown Lucide name or an SVG that
breaks a rule fails the plugin with the reason:
```text
Workspace panel review has an invalid icon: script elements are not allowed
```
Paseo draws inline SVG at the surface's icon size and passes the surface's icon color as
`currentColor`. Paint with `currentColor` to follow the theme; fixed fills keep their colors.
A component receives `PluginIconProps`, `{ size, color }`, and renders within that size. Header
buttons and composer pills pass [`PluginButtonIconProps`](#custom-icons-and-popover-content).
#### SVG rules
Inline SVG, provider icons, and usage-source icons follow the same rules:
- One `<svg>` document, no larger than 64 KiB.
- No `<script>`, `<style>`, or `<foreignObject>` elements.
- No event-handler attributes such as `onclick`, and no `javascript:` URLs.
- `href` and `xlink:href` point only at fragments in the same document, such as `#mark`.
#### Ship SVG markup
Keep the markup in a client module:
```ts
// client/icons.ts
export const reviewIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16v16H4z" />
</svg>`;
```
Or put an `.svg` file in `client/` and import it. The plugin compiler bundles the file's markup as a
string. Declare the module once so TypeScript accepts the import:
```ts
// client/svg.d.ts
declare module "*.svg" {
const markup: string;
export default markup;
}
```
```ts
// index.client.tsx
import reviewIcon from "./client/review.svg";
client.addWorkspacePanel({ id: "review", title: "Review", icon: { svg: reviewIcon } /* … */ });
```
#### Render an icon
`Icon` from `@getpaseo/plugin/client/react-native` draws an icon in your own UI. Plugin bundles do
not import `lucide-react-native` or `react-native-svg`.
| Prop | Type | Required | Behavior |
| ------- | ------------ | ---------------- | ------------------------------------------------------------ |
| `name` | `string` | `name` or `icon` | Lucide icon name. |
| `icon` | `PluginIcon` | `name` or `icon` | Any icon form above. |
| `size` | `number` | No | Icon width and height. Defaults to 24. |
| `color` | `string` | No | Icon color and SVG `currentColor`. Use a plugin theme token. |
An unknown name or an SVG that breaks a rule renders nothing.
## Timeline items
@@ -1460,7 +1506,7 @@ Themes need a host that supports them. A client released before `addTheme` canno
Register a component with `client.addSettingsScreen({ id, title, icon, Component })` in
`index.client.tsx`. It appears under **Settings → Plugins → your plugin** on that host.
`id` is unique within the installation; `icon` is a Lucide name. Registration returns an
`id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an
idempotent remover, and plugin teardown removes remaining screens.
Call `client.openSettings(id)` or a Command Center callback's `openSettings(id)` to open one
@@ -1657,7 +1703,7 @@ export default function contribute(client: PluginClientContext) {
| ----------- | -------- | ------------------------------------------------------------- |
| `id` | Yes | Plugin-local panel ID. |
| `title` | Yes | Workspace-tab title. |
| `icon` | Yes | Lucide icon name. |
| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). |
| `context` | Yes | `workspace` or `agent`. |
| `locations` | No | `workspace` and/or `explorer`. Defaults to `workspace`. |
| `Component` | Yes | React Native component matching the selected context's props. |
@@ -1741,14 +1787,14 @@ client.addCommandCenterItem({
`addCommandCenterItem` fields:
| Field | Required | Meaning |
| ---------- | -------- | ---------------------------------------------- |
| `id` | Yes | Plugin-local item ID. |
| `title` | Yes | Search result title. |
| `icon` | Yes | Lucide icon name. |
| `keywords` | No | Additional Command Center search terms. |
| `context` | Yes | `global`, `workspace`, or `agent`. |
| `onSelect` | Yes | Client-side callback for the matching context. |
| Field | Required | Meaning |
| ---------- | -------- | ---------------------------------------------------------- |
| `id` | Yes | Plugin-local item ID. |
| `title` | Yes | Search result title. |
| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). |
| `keywords` | No | Additional Command Center search terms. |
| `context` | Yes | `global`, `workspace`, or `agent`. |
| `onSelect` | Yes | Client-side callback for the matching context. |
Global items appear on the installation's selected host. Workspace items appear only when that host has an active cached workspace. Agent items appear only when the focused workspace tab is an agent or an agent-context plugin panel whose cached record belongs to that workspace. Missing context removes the item rather than calling the plugin to discover it.
@@ -1874,7 +1920,7 @@ These contracts are exported from `@getpaseo/plugin/client`.
| Field | Required | Meaning |
| ---------- | -------- | ----------------------------------------------------------------------- |
| `title` | Yes | Non-empty accessible label, tooltip, and sheet title. |
| `icon` | Yes | Lucide name or `ComponentType<PluginButtonIconProps>`. |
| `icon` | Yes | Lucide name, `{ svg }`, or `ComponentType<PluginButtonIconProps>`. |
| `label` | No | Non-empty display text. Omit to use the placement's default. |
| `visible` | No | Defaults to `true`. False removes the trigger and its layout space. |
| `disabled` | No | Defaults to `false`. Keeps the button visible and prevents interaction. |
@@ -2282,7 +2328,7 @@ export default function contribute(server: PluginServerContext) {
}
```
Paseo owns the composer menu, search picker, selected pill, draft state, and submission. The `text` value is the complete snapshot sent to the agent.
Paseo owns the composer menu, search picker, selected pill, draft state, and submission. The `text` value is the complete snapshot sent to the agent. `icon` is a Lucide name or `{ svg }`; see [Icons](#icons).
## Hosts and lifecycle
-51
View File
@@ -203,57 +203,6 @@ 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.
+1 -1
View File
@@ -475,7 +475,7 @@ test("Windows units and integration run independently with complete routed cover
assert.doesNotMatch(invocations[0], / -t /);
assert.equal(
invocations[1],
'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|connected hosts"',
'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"',
);
const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8");
assert.doesNotMatch(serverConfig, /maxWorkers/);
+2 -2
View File
@@ -255,7 +255,7 @@ export default function contribute(client: PluginClientContext) {
}
```
Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every screen and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client.
Icons are Lucide icon names, inline SVG (`{ svg }`), or components; see [Icons](https://paseo.sh/docs/plugins/reference.md#icons). `theme` is a typed `PluginTheme` on every screen and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client.
Before writing imports, classify each module as shared, client, or server. Follow the
[SDK import boundaries](https://paseo.sh/docs/plugins/reference.md#runtime-modules), including
@@ -724,7 +724,7 @@ After a change:
Common failures:
- Missing sidebar item: wrong host, plugin not `running`, the item is hidden in Settings > Sidebar, or its component throws (an invalid Lucide icon name throws).
- Missing sidebar item: wrong host, plugin not `running`, the item is hidden in Settings > Sidebar, or its component throws (a `SidebarRow` with an unknown Lucide name or unsafe SVG throws).
- Unavailable client module: client bundles can use only the host-provided modules listed above.
- RPC rejection: input or output failed its Zod schema, or the handler threw. Inspect `paseo plugin logs <id>` for handler output.
- Plugin exits or reload fails: inspect `paseo plugin ls` for status and `paseo plugin logs <id>` for initialization, cleanup, or crash output.