mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-19 07:42:52 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c5a3a349 | ||
|
|
12baedf656 | ||
|
|
b81f6c1350 | ||
|
|
a954ded4be | ||
|
|
b8bcd204a9 |
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
|
||||
@@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
|
||||
|
||||
Your code never leaves your machine. Paseo is local-first.
|
||||
|
||||
Daemon-owned outbound connections and routed API scope ownership are described in
|
||||
[Connected hosts](connected-hosts.md).
|
||||
|
||||
## System overview
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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).
|
||||
@@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
|
||||
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
|
||||
see [Connected hosts](connected-hosts.md#ownership-and-authority).
|
||||
|
||||
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
||||
|
||||
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
||||
|
||||
Generated
+1
@@ -37725,6 +37725,7 @@
|
||||
"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,6 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
@@ -17,7 +18,6 @@ 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,19 +205,21 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
title: "Second touch chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
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");
|
||||
// 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`);
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
openRestartableConnectedHosts,
|
||||
beginConnectedHostDraft,
|
||||
reconnectManagingHost,
|
||||
expectConnectedHostDraftRetained,
|
||||
saveConnectedHost,
|
||||
openConnectedHosts,
|
||||
openCompactConnectedHosts,
|
||||
addConnectedHost,
|
||||
expectConnectionError,
|
||||
correctConnection,
|
||||
inspectConnectedHost,
|
||||
expectSavedHostDetails,
|
||||
disableConnectedHost,
|
||||
enableConnectedHost,
|
||||
editConnectedHost,
|
||||
removeConnectedHost,
|
||||
} from "../support/helpers/connected-hosts";
|
||||
|
||||
test("invalid input stays editable; unreachable hosts remain manageable", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openConnectedHosts(page);
|
||||
await addConnectedHost(page, "ssh://-invalid", "Build box");
|
||||
await expectConnectionError(page);
|
||||
await correctConnection(page, "tcp://127.0.0.1:1?password=private-secret");
|
||||
await inspectConnectedHost(page, "Build box");
|
||||
await expectSavedHostDetails(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("connected-host-details.png") });
|
||||
await editConnectedHost(page);
|
||||
await disableConnectedHost(page);
|
||||
await enableConnectedHost(page);
|
||||
await removeConnectedHost(page, "Renamed box");
|
||||
});
|
||||
|
||||
test.describe("compact connected hosts", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test("saved hosts can be inspected and disabled on a phone layout", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openCompactConnectedHosts(page);
|
||||
await addConnectedHost(page, "tcp://127.0.0.1:1", "Compact host");
|
||||
await inspectConnectedHost(page, "Compact host");
|
||||
await expectSavedHostDetails(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("connected-host-compact.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await disableConnectedHost(page);
|
||||
await removeConnectedHost(page, "Compact host");
|
||||
});
|
||||
});
|
||||
|
||||
test("connection loss retains the unsaved name and credential input", async ({ page }) => {
|
||||
const host = await openRestartableConnectedHosts(page);
|
||||
try {
|
||||
await beginConnectedHostDraft(page);
|
||||
await reconnectManagingHost(host);
|
||||
await expectConnectedHostDraftRetained(page);
|
||||
await saveConnectedHost(page);
|
||||
await inspectConnectedHost(page, "Retained draft");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
@@ -501,3 +501,59 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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("");
|
||||
}
|
||||
@@ -1295,7 +1295,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
// Read pointer capability when restoring, without making a pointer change itself
|
||||
// a focus trigger.
|
||||
useLayoutEffect(() => {
|
||||
if (isPaneFocused && focusIntentRef.current && hasFinePointer()) {
|
||||
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) {
|
||||
textInputRef.current?.focus();
|
||||
}
|
||||
}, [isPaneFocused]);
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
PaseoApi,
|
||||
HostSummary,
|
||||
HostUpdate,
|
||||
HostDirectory,
|
||||
OwnedSubscription,
|
||||
} from "@getpaseo/client";
|
||||
|
||||
export interface Editor {
|
||||
key: number;
|
||||
host: HostSummary | null;
|
||||
connection: string;
|
||||
label: string;
|
||||
metadata: string;
|
||||
replaceIdentity: boolean;
|
||||
}
|
||||
type Load =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; directory: HostDirectory };
|
||||
interface State {
|
||||
load: Load;
|
||||
editor: Editor | null;
|
||||
pending: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Owns fetch/observation, form sequencing and mutation state outside React. */
|
||||
export class ConnectedHostsModel {
|
||||
private state: State = { load: { status: "loading" }, editor: null, pending: null, error: null };
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private observation?: OwnedSubscription<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,3 +1,4 @@
|
||||
import { ConnectedHostsSection } from "@/connected-hosts";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
@@ -374,6 +375,7 @@ export function HostSettingsPage({
|
||||
<HostConnectionError serverId={serverId} />
|
||||
|
||||
<HostAppearanceSection host={host} />
|
||||
<ConnectedHostsSection serverId={serverId} />
|
||||
|
||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHostCommand } from "./commands/host/index.js";
|
||||
import { pairCommand } from "./commands/daemon/pair.js";
|
||||
import { Command, Option } from "commander";
|
||||
import { createAgentCommand } from "./commands/agent/index.js";
|
||||
@@ -58,6 +59,8 @@ export function createCli(): Command {
|
||||
.option("--no-color", "disable colored output");
|
||||
addDaemonHostOption(program);
|
||||
|
||||
program.addCommand(createHostCommand());
|
||||
|
||||
// Primary agent commands (top-level)
|
||||
addJsonAndDaemonHostOptions(addLsOptions(program.command("ls"))).action(withOutput(runLsCommand));
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Command } from "commander";
|
||||
import type { HostSummary, HostUpdate } from "@getpaseo/client";
|
||||
import {
|
||||
withOutput,
|
||||
type CommandOptions,
|
||||
type OutputSchema,
|
||||
type CommandResult,
|
||||
} from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
|
||||
const schema: OutputSchema<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;
|
||||
}
|
||||
@@ -1,90 +1,2 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createServer, type Server, type Socket } from "node:net";
|
||||
import { buildSshTunnelArgs, type SshTransportTarget } from "@getpaseo/protocol/ssh-transport";
|
||||
|
||||
const SSH_STDERR_LIMIT = 8192;
|
||||
|
||||
export interface SshTunnel {
|
||||
endpoint: string;
|
||||
close(): void;
|
||||
failureDetail(): string | null;
|
||||
}
|
||||
|
||||
function formatSshFailure(
|
||||
stderr: string,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): string {
|
||||
const detail = stderr.trim();
|
||||
if (detail) return detail;
|
||||
if (signal) return `ssh exited with signal ${signal}`;
|
||||
return `ssh exited with code ${code ?? "unknown"}`;
|
||||
}
|
||||
|
||||
export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null {
|
||||
return failure ?? (stderr.trim() || null);
|
||||
}
|
||||
|
||||
export function createSshTunnel(target: SshTransportTarget): Promise<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),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
export { createSshTunnel, resolveSshFailureDetail } from "@getpaseo/client/node";
|
||||
export type { SshTunnel } from "@getpaseo/client/node";
|
||||
|
||||
@@ -1,42 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSshFailureDetail } from "../ssh/ssh-tunnel.js";
|
||||
import { connectToDaemon } from "./client.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
configs: [] as Array<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" }));
|
||||
import { parseHostTarget } from "@getpaseo/client/node";
|
||||
|
||||
describe("CLI SSH transport", () => {
|
||||
beforeEach(() => {
|
||||
mocks.configs.length = 0;
|
||||
mocks.createSshTunnel.mockReset();
|
||||
mocks.createSshTunnel.mockResolvedValue({
|
||||
endpoint: "127.0.0.1:4567",
|
||||
close: vi.fn(),
|
||||
failureDetail: () => null,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces SSH stderr before the child exit event settles", () => {
|
||||
expect(resolveSshFailureDetail(null, "Host key verification failed.\n")).toBe(
|
||||
"Host key verification failed.",
|
||||
@@ -45,21 +11,14 @@ describe("CLI SSH transport", () => {
|
||||
"ssh exited with code 255",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes an SSH host through a local tunnel", async () => {
|
||||
await connectToDaemon({
|
||||
target: { kind: "endpoint", host: "ssh://deploy@build-box:2222?daemonPort=7777" },
|
||||
});
|
||||
|
||||
expect(mocks.createSshTunnel).toHaveBeenCalledWith({
|
||||
host: "deploy@build-box",
|
||||
sshPort: 2222,
|
||||
daemonPort: 7777,
|
||||
});
|
||||
expect(mocks.configs[0]).toMatchObject({
|
||||
url: "ws://127.0.0.1:4567/ws",
|
||||
clientId: "cli-test-id",
|
||||
clientType: "cli",
|
||||
it("uses the shared SSH target grammar and excludes reusable credentials from discovery", () => {
|
||||
expect(parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777")).toEqual({
|
||||
kind: "ssh",
|
||||
ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 },
|
||||
description: "ssh://build-box:2222 (daemon port 7777)",
|
||||
});
|
||||
expect(() => parseHostTarget("ssh://deploy:secret@build-box")).toThrow(
|
||||
"Invalid host connection",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHostClient, parseHostTarget } from "@getpaseo/client/node";
|
||||
import {
|
||||
waitForDaemonReady,
|
||||
resolvePaseoHome,
|
||||
@@ -8,21 +9,12 @@ import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
|
||||
export type { DaemonTarget } from "./daemon-target.js";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
parseConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
import {
|
||||
parseConnectionOfferFromUrl,
|
||||
type ConnectionOffer,
|
||||
} from "@getpaseo/protocol/connection-offer";
|
||||
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
||||
import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client";
|
||||
import { WebSocket } from "ws";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { getOrCreateCliClientId } from "./client-id.js";
|
||||
import { resolveCliVersion } from "../version.js";
|
||||
import { createSshTunnel } from "../ssh/ssh-tunnel.js";
|
||||
|
||||
export interface ConnectOptions {
|
||||
target: DaemonTarget;
|
||||
@@ -50,15 +42,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: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
|
||||
message: message.startsWith("Cannot connect to daemon at ")
|
||||
? message
|
||||
: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
|
||||
details: describeConnectionRemedy(code, options.target),
|
||||
};
|
||||
}
|
||||
@@ -170,11 +162,8 @@ export function resolveDaemonTarget(host: string): TransportTarget {
|
||||
}
|
||||
|
||||
export function resolveDaemonPassword(host: string): string | undefined {
|
||||
const trimmed = host.trim();
|
||||
if (trimmed.startsWith("tcp://")) {
|
||||
const fromUri = parseConnectionUri(trimmed).password;
|
||||
if (fromUri) return fromUri;
|
||||
}
|
||||
const fromUri = parseHostTarget(host).password;
|
||||
if (fromUri) return fromUri;
|
||||
const fromEnv = process.env.PASEO_PASSWORD;
|
||||
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
|
||||
}
|
||||
@@ -189,117 +178,9 @@ export function resolveDaemonCredential(
|
||||
return token ? { kind: "localCredential", token } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebSocket factory that works in Node.js
|
||||
*/
|
||||
function createNodeWebSocketFactory() {
|
||||
return (
|
||||
url: string,
|
||||
options?: { headers?: Record<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 deadline = Date.now() + timeout;
|
||||
const explicitHost =
|
||||
const host =
|
||||
options.target.kind === "endpoint"
|
||||
? options.target.host
|
||||
: (
|
||||
@@ -309,58 +190,25 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
|
||||
})
|
||||
).listen;
|
||||
const home = resolveClientPaseoHome(options.target);
|
||||
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 credential = resolveDaemonCredential(host, home);
|
||||
const client = createHostClient(host, {
|
||||
clientId: await getOrCreateCliClientId(home),
|
||||
clientType: "cli",
|
||||
appVersion: resolveCliVersion(),
|
||||
...(credential?.kind === "password" ? { password: credential.password } : {}),
|
||||
...(credential?.kind === "localCredential"
|
||||
? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined }
|
||||
: {}),
|
||||
connectTimeoutMs: timeout,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.close();
|
||||
throw error;
|
||||
}
|
||||
const offer = parseHostOfferOrNull(explicitHost);
|
||||
if (offer) {
|
||||
return connectViaRelayOffer(
|
||||
offer,
|
||||
clientId,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await tryConnectHost(
|
||||
explicitHost,
|
||||
resolveDaemonCredential(explicitHost, home),
|
||||
home,
|
||||
clientId,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
);
|
||||
if ("client" in result) return result.client;
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
export async function connectToDaemon(options: ConnectOptions): Promise<DaemonClient> {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { parseHostTarget } from "@getpaseo/protocol/host-target";
|
||||
import { test, expect } from "vitest";
|
||||
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
|
||||
import { resolveDaemonCredential, resolveClientPaseoHome } from "./client.js";
|
||||
import {
|
||||
resolveDaemonCredential,
|
||||
resolveClientPaseoHome,
|
||||
buildDaemonConnectionCommandError,
|
||||
} from "./client.js";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -44,6 +49,31 @@ 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;
|
||||
@@ -67,3 +97,34 @@ 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");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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 };
|
||||
@@ -39,13 +40,26 @@ export function selectDaemonTarget(
|
||||
export function describeDaemonTarget(target: DaemonTarget): string {
|
||||
if (target.kind === "instance") return `home ${target.home}`;
|
||||
try {
|
||||
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();
|
||||
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;
|
||||
} catch {
|
||||
return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED");
|
||||
return "<invalid endpoint>";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"./internal/daemon-client-websocket-transport": {
|
||||
"types": "./dist/daemon-client-websocket-transport.d.ts",
|
||||
"default": "./dist/daemon-client-websocket-transport.js"
|
||||
},
|
||||
"./node": {
|
||||
"types": "./dist/node/index.d.ts",
|
||||
"default": "./dist/node/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
@@ -41,6 +45,7 @@
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.3",
|
||||
"@getpaseo/relay": "0.12.0-beta.3",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -873,9 +873,12 @@ test("keeps relay upgrade credentials out of the socket request", async () => {
|
||||
},
|
||||
});
|
||||
clients.push(client);
|
||||
void client.connect();
|
||||
const connecting = client.connect();
|
||||
void connecting.catch(() => {});
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
||||
expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" });
|
||||
await client.close();
|
||||
await expect(connecting).rejects.toThrow("Daemon client closed");
|
||||
});
|
||||
|
||||
test("refuses relay password auth without an encrypted hello", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { messageTooLargeError } from "./daemon-client-transport-utils.js";
|
||||
import type { HostAdd, HostUpdate, HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import { legacyUsageIcon } from "./legacy-usage-icons.js";
|
||||
import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js";
|
||||
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
|
||||
@@ -391,6 +392,10 @@ export interface DaemonClientConfig {
|
||||
/** Deliver compact bodies/hash references to a caller-owned snapshot cache.
|
||||
* The default keeps public SDK snapshot entries expanded. */
|
||||
providerSnapshots?: "wire";
|
||||
routedClient?: boolean;
|
||||
routedServerId?: string;
|
||||
routedFailure?: () => Error | null;
|
||||
sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[];
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
|
||||
@@ -1008,8 +1013,16 @@ class DaemonRpcError extends Error {
|
||||
readonly requestId: string;
|
||||
readonly requestType?: string;
|
||||
readonly code?: string;
|
||||
readonly serverId?: string;
|
||||
readonly userMessage: string;
|
||||
|
||||
constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) {
|
||||
constructor(params: {
|
||||
requestId: string;
|
||||
error: string;
|
||||
requestType?: string;
|
||||
code?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const parts = [params.error];
|
||||
if (params.requestType) parts.push(`requestType=${params.requestType}`);
|
||||
if (params.code) parts.push(`code=${params.code}`);
|
||||
@@ -1018,6 +1031,8 @@ class DaemonRpcError extends Error {
|
||||
this.requestId = params.requestId;
|
||||
this.requestType = params.requestType;
|
||||
this.code = params.code;
|
||||
this.serverId = params.serverId;
|
||||
this.userMessage = params.error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1502,6 +1517,7 @@ export class DaemonClient {
|
||||
this.lastErrorValue = reason;
|
||||
this.scheduleReconnect({
|
||||
reason,
|
||||
failure: event instanceof Error ? event : undefined,
|
||||
event: "TRANSPORT_ERROR",
|
||||
reasonCode: "transport_error",
|
||||
});
|
||||
@@ -1518,6 +1534,7 @@ export class DaemonClient {
|
||||
this.lastErrorValue = reason;
|
||||
this.scheduleReconnect({
|
||||
reason,
|
||||
failure: event instanceof Error ? event : undefined,
|
||||
event: "TRANSPORT_ERROR",
|
||||
reasonCode: "transport_error",
|
||||
});
|
||||
@@ -1568,9 +1585,7 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
this.shouldReconnect = false;
|
||||
this.connectPromise = null;
|
||||
this.connectResolve = null;
|
||||
this.connectReject = null;
|
||||
this.rejectConnect(new Error("Daemon client closed"));
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
@@ -1598,6 +1613,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
ensureConnected(options?: { verify?: boolean }): void {
|
||||
this.requireRoutedConnection();
|
||||
if (this.connectionState.status === "disposed") {
|
||||
return;
|
||||
}
|
||||
@@ -1613,7 +1629,9 @@ export class DaemonClient {
|
||||
this.attemptConnect();
|
||||
return;
|
||||
}
|
||||
void this.connect();
|
||||
void this.connect().catch((error: unknown) =>
|
||||
this.logger.debug({ err: error }, "Connection attempt ended"),
|
||||
);
|
||||
}
|
||||
|
||||
private verifyConnection(): void {
|
||||
@@ -1631,13 +1649,98 @@ export class DaemonClient {
|
||||
event: "CONNECTION_VERIFICATION_FAILED",
|
||||
reasonCode: "liveness_timeout",
|
||||
});
|
||||
this.ensureConnected();
|
||||
if (!this.config.routedClient) this.ensureConnected();
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.connectionVerification === transport) this.connectionVerification = null;
|
||||
});
|
||||
}
|
||||
|
||||
requireConnectedHosts(): void {
|
||||
if (!this.isConnected)
|
||||
throw Object.assign(new Error("The managing daemon is offline"), {
|
||||
code: "host_offline",
|
||||
serverId: this.config.routedServerId,
|
||||
});
|
||||
if (this.lastServerInfoMessage?.features?.connectedHosts !== true)
|
||||
throw Object.assign(new Error("Update the managing daemon to use connected hosts"), {
|
||||
code: "unsupported_capability",
|
||||
});
|
||||
}
|
||||
async listHosts(): Promise<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;
|
||||
}
|
||||
@@ -1780,7 +1883,7 @@ export class DaemonClient {
|
||||
|
||||
private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void {
|
||||
if (!this.transport) {
|
||||
throw new DaemonConnectionError("Transport not connected");
|
||||
throw this.unavailableError();
|
||||
}
|
||||
// The daemon closes the connection on an oversized message, and creations resubmit on reconnect.
|
||||
assertTransportFrameFits(frame);
|
||||
@@ -1867,7 +1970,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
// If connecting, queue the message to be sent once connected
|
||||
if (status === "connecting") {
|
||||
if (status === "connecting" && !this.config.routedClient) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
// Remove from queue
|
||||
@@ -1888,7 +1991,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
// Not connected and not connecting - fail immediately
|
||||
return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`));
|
||||
return Promise.reject(this.unavailableError());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1942,9 +2045,13 @@ export class DaemonClient {
|
||||
kind: "error",
|
||||
error: new DaemonRpcError({
|
||||
requestId: msg.payload.requestId,
|
||||
serverId: this.config.routedServerId,
|
||||
error: msg.payload.error,
|
||||
requestType: msg.payload.requestType,
|
||||
code: msg.payload.code,
|
||||
code:
|
||||
this.config.routedClient && msg.payload.code === "access_denied"
|
||||
? "permission_denied"
|
||||
: msg.payload.code,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -2053,9 +2160,23 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
private requireRoutedConnection(): void {
|
||||
if (this.config.routedClient && !this.isConnected) throw this.unavailableError();
|
||||
}
|
||||
|
||||
private unavailableError(): Error {
|
||||
if (this.config.routedFailure?.()) return this.config.routedFailure()!;
|
||||
if (this.config.routedClient)
|
||||
return Object.assign(new Error(`Host ${this.config.routedServerId} is offline`), {
|
||||
code: "host_offline",
|
||||
serverId: this.config.routedServerId,
|
||||
});
|
||||
return new DaemonConnectionError("Transport not connected");
|
||||
}
|
||||
|
||||
private sendSessionMessageStrict(message: SessionInboundMessage): void {
|
||||
if (!this.transport || this.connectionState.status !== "connected") {
|
||||
throw new DaemonConnectionError("Transport not connected");
|
||||
throw this.unavailableError();
|
||||
}
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
try {
|
||||
@@ -2851,7 +2972,10 @@ export class DaemonClient {
|
||||
// ============================================================================
|
||||
|
||||
private readonly creations = new CreationClient({
|
||||
supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true,
|
||||
supports: (feature) => {
|
||||
this.requireRoutedConnection();
|
||||
return this.lastServerInfoMessage?.features?.[feature] === true;
|
||||
},
|
||||
requestId: () => this.createRequestId(),
|
||||
request: (kind, input) =>
|
||||
kind === "workspace"
|
||||
@@ -6287,6 +6411,9 @@ export class DaemonClient {
|
||||
const auth = this.helloAuth;
|
||||
this.sendJsonMessage("hello", "hello", {
|
||||
type: "hello",
|
||||
...(this.config.sessionPermissions
|
||||
? { sessionPermissions: this.config.sessionPermissions }
|
||||
: {}),
|
||||
clientId: this.config.clientId,
|
||||
clientType: this.config.clientType ?? "cli",
|
||||
protocolVersion: 1,
|
||||
@@ -6632,6 +6759,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private scheduleReconnect(input?: {
|
||||
failure?: Error;
|
||||
reason?: string;
|
||||
event?: string;
|
||||
reasonCode?: string;
|
||||
@@ -6652,7 +6780,14 @@ export class DaemonClient {
|
||||
|
||||
// Clear all pending waiters and queued sends since the connection was lost
|
||||
// and responses from the previous connection will never arrive.
|
||||
this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost"));
|
||||
this.clearWaiters(
|
||||
this.config.routedClient
|
||||
? Object.assign(
|
||||
new Error("Connection lost before result; inspect the destination before retrying"),
|
||||
{ code: "outcome_unknown", serverId: this.config.routedServerId },
|
||||
)
|
||||
: new DaemonConnectionError(reason ?? "Connection lost"),
|
||||
);
|
||||
this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost"));
|
||||
this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost"));
|
||||
this.terminalStreams.clearSlots();
|
||||
@@ -6669,7 +6804,7 @@ export class DaemonClient {
|
||||
this.rejectConnect(
|
||||
this.authFailureReasonValue
|
||||
? new DaemonAuthenticationError(this.authFailureReasonValue)
|
||||
: new Error(reason ?? "Transport disconnected before connect"),
|
||||
: (input?.failure ?? new Error(reason ?? "Transport disconnected before connect")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { safeRandomId } from "../daemon-client-transport-utils.js";
|
||||
import { DaemonClient } from "../daemon-client.js";
|
||||
import type { DaemonTransportFactory } from "../daemon-client-transport-types.js";
|
||||
import type { OwnedSubscription } from "../connection/index.js";
|
||||
import type { PaseoApi, createPaseoApi } from "../index.js";
|
||||
import type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
|
||||
export interface PaseoHostActions {
|
||||
list(): Promise<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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -267,6 +267,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own
|
||||
"agents",
|
||||
"config",
|
||||
"dispose",
|
||||
"hosts",
|
||||
"observeEvents",
|
||||
"projects",
|
||||
"providers",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { createHostActions, type PaseoHostActions } from "./hosts/index.js";
|
||||
export type { PaseoHostActions } from "./hosts/index.js";
|
||||
export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import type { OwnedSubscription } from "./connection/index.js";
|
||||
export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js";
|
||||
import type { DaemonClientConfig } from "./daemon-client.js";
|
||||
@@ -484,6 +487,7 @@ export interface PaseoConfigActions {
|
||||
}
|
||||
|
||||
export interface PaseoApi {
|
||||
readonly hosts: PaseoHostActions;
|
||||
dispose(): Promise<void>;
|
||||
observeEvents: DaemonClient["observeEvents"];
|
||||
readonly terminals: PaseoTerminalActions;
|
||||
@@ -695,6 +699,7 @@ export function createPaseoApi(
|
||||
|
||||
return {
|
||||
dispose,
|
||||
hosts: createHostActions(daemonClient, { own, signal: lifetime.signal, api: createPaseoApi }),
|
||||
observeEvents,
|
||||
terminals,
|
||||
projects: {
|
||||
@@ -1034,6 +1039,7 @@ function listProviderUsage(
|
||||
daemonClient: DaemonClient,
|
||||
options?: PaseoProviderUsageOptions,
|
||||
): Promise<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."));
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Counts bytes until the socket's send callback, shared by all links owned by a daemon. */
|
||||
export class HostTransportBudget {
|
||||
private used = 0;
|
||||
constructor(readonly limit = 64 * 1024 * 1024) {}
|
||||
get bufferedBytes(): number {
|
||||
return this.used;
|
||||
}
|
||||
reserve(bytes: number): () => void {
|
||||
if (bytes > this.limit - this.used)
|
||||
throw Object.assign(new Error("Connected host buffer limit reached"), {
|
||||
code: "resource_limit",
|
||||
});
|
||||
this.used += bytes;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (!released) {
|
||||
released = true;
|
||||
this.used -= bytes;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { createHostTransportFactory, HostTransportBudget } from "./index.js";
|
||||
const cleanup: (() => Promise<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);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { HostTransportBudget } from "./buffering.js";
|
||||
export { HostTransportBudget } from "./buffering.js";
|
||||
import { homedir } from "node:os";
|
||||
import { WebSocket } from "ws";
|
||||
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
|
||||
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
|
||||
import { createWebSocketTransportFactory } from "../daemon-client-websocket-transport.js";
|
||||
import { createRelayE2eeTransportFactory } from "../daemon-client-relay-e2ee-transport.js";
|
||||
import type {
|
||||
DaemonTransport,
|
||||
DaemonTransportFactory,
|
||||
WebSocketLike,
|
||||
} from "../daemon-client-transport-types.js";
|
||||
import { DaemonClient, type DaemonClientConfig } from "../daemon-client.js";
|
||||
|
||||
export { parseHostTarget };
|
||||
export type { SshTunnel } from "./ssh-tunnel.js";
|
||||
export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js";
|
||||
|
||||
/** A factory owns one attempt, including a fresh SSH process and its teardown. */
|
||||
export function createHostTransportFactory(
|
||||
connection: string,
|
||||
budget = new HostTransportBudget(),
|
||||
): DaemonTransportFactory {
|
||||
const target = parseHostTarget(connection);
|
||||
return (attemptOptions) => {
|
||||
const opens = new Set<() => void>();
|
||||
const closes = new Set<(event?: unknown) => void>();
|
||||
const errors = new Set<(event?: unknown) => void>();
|
||||
const messages = new Set<(data: unknown, binary: boolean) => void>();
|
||||
let closed = false;
|
||||
let closeEvent: unknown;
|
||||
let transport: DaemonTransport | undefined;
|
||||
let teardown = () => {};
|
||||
let sshTunnel: SshTunnel | null = null;
|
||||
const close = (code?: number, reason?: string) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
transport?.close(code, reason);
|
||||
} finally {
|
||||
try {
|
||||
teardown();
|
||||
} finally {
|
||||
for (const handler of closes) handler(closeEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
void (async () => {
|
||||
let socketTarget: Extract<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),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createServer, type Server, type Socket } from "node:net";
|
||||
import { buildSshTunnelArgs, type SshTransportTarget } from "@getpaseo/protocol/ssh-transport";
|
||||
|
||||
const SSH_STDERR_LIMIT = 8192;
|
||||
|
||||
export interface SshTunnel {
|
||||
endpoint: string;
|
||||
close(): void;
|
||||
failureDetail(): string | null;
|
||||
}
|
||||
|
||||
function formatSshFailure(
|
||||
stderr: string,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): string {
|
||||
const detail = stderr.trim();
|
||||
if (detail) return detail;
|
||||
if (signal) return `ssh exited with signal ${signal}`;
|
||||
return `ssh exited with code ${code ?? "unknown"}`;
|
||||
}
|
||||
|
||||
export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null {
|
||||
return failure ?? (stderr.trim() || null);
|
||||
}
|
||||
|
||||
export function createSshTunnel(target: SshTransportTarget): Promise<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),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const HostMetadataSchema = z.record(z.string().max(256), z.string().max(4096));
|
||||
export const HostAddSchema = z.object({
|
||||
connection: z.string().min(1).max(16384),
|
||||
label: z.string().max(256).optional(),
|
||||
metadata: HostMetadataSchema.optional(),
|
||||
sourceKey: z.string().min(1).max(256).optional(),
|
||||
});
|
||||
export const HostUpdateSchema = z.object({
|
||||
label: z.string().max(256).nullable().optional(),
|
||||
metadata: HostMetadataSchema.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
connection: z.string().min(1).max(16384).optional(),
|
||||
replaceIdentity: z.boolean().optional(),
|
||||
});
|
||||
export const HostSummarySchema = z.object({
|
||||
registrationId: z.string(),
|
||||
serverId: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
source: z.object({ id: z.string(), key: z.string() }),
|
||||
metadata: HostMetadataSchema,
|
||||
enabled: z.boolean(),
|
||||
status: z.enum(["connecting", "online", "offline", "error", "disabled"]),
|
||||
targetDescription: z.string(),
|
||||
lastOnlineAt: z.string().nullable(),
|
||||
nextRetryAt: z.string().nullable(),
|
||||
error: z.object({ code: z.string(), message: z.string() }).nullable(),
|
||||
});
|
||||
export const HostDirectorySchema = z.object({
|
||||
self: z.object({ serverId: z.string(), label: z.string() }),
|
||||
hosts: z.array(HostSummarySchema),
|
||||
});
|
||||
export type HostAdd = z.infer<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() }),
|
||||
});
|
||||
@@ -1,4 +1,21 @@
|
||||
import { AgentMessageSchema } from "./agent-message.js";
|
||||
import {
|
||||
HostsListRequestSchema,
|
||||
HostsAddRequestSchema,
|
||||
HostsUpdateRequestSchema,
|
||||
HostsRemoveRequestSchema,
|
||||
HostsRouteOpenRequestSchema,
|
||||
HostsRouteFrameRequestSchema,
|
||||
HostsRouteCloseRequestSchema,
|
||||
HostsListResponseSchema,
|
||||
HostsChangedSchema,
|
||||
HostsAddResponseSchema,
|
||||
HostsUpdateResponseSchema,
|
||||
HostsRemoveResponseSchema,
|
||||
HostsRouteOpenResponseSchema,
|
||||
HostsRouteFrameSchema,
|
||||
HostsRouteClosedSchema,
|
||||
} from "./hosts.js";
|
||||
import { PluginRegistryIdentitySchema } from "./plugin-registry.js";
|
||||
import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js";
|
||||
export {
|
||||
@@ -3196,6 +3213,14 @@ export const SubscriptionReleaseResponseSchema = z.object({
|
||||
});
|
||||
|
||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
HostsListRequestSchema,
|
||||
HostsAddRequestSchema,
|
||||
HostsUpdateRequestSchema,
|
||||
HostsRemoveRequestSchema,
|
||||
HostsRouteOpenRequestSchema,
|
||||
HostsRouteFrameRequestSchema,
|
||||
HostsRouteCloseRequestSchema,
|
||||
|
||||
BrowserHostRegisterRequestSchema,
|
||||
SubscriptionReleaseRequestSchema,
|
||||
SessionEventsSetSubscriptionRequestSchema,
|
||||
@@ -3564,6 +3589,8 @@ 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.
|
||||
@@ -3585,6 +3612,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
internalWorkspaces: z.boolean().optional(),
|
||||
backgroundWorkspaces: z.boolean().optional(),
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
connectedHosts: z.boolean().optional(),
|
||||
usageSources: z.boolean().optional(),
|
||||
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
||||
providersSnapshotCwd: z.boolean().optional(),
|
||||
@@ -6851,6 +6879,15 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({
|
||||
});
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
HostsListResponseSchema,
|
||||
HostsChangedSchema,
|
||||
HostsAddResponseSchema,
|
||||
HostsUpdateResponseSchema,
|
||||
HostsRemoveResponseSchema,
|
||||
HostsRouteOpenResponseSchema,
|
||||
HostsRouteFrameSchema,
|
||||
HostsRouteClosedSchema,
|
||||
|
||||
BrowserHostRegisterResponseSchema,
|
||||
SubscriptionReleaseResponseSchema,
|
||||
SessionEventsSetSubscriptionResponseSchema,
|
||||
@@ -7542,6 +7579,11 @@ export const WSPongMessageSchema = z.object({
|
||||
|
||||
export const WSHelloMessageSchema = z.object({
|
||||
type: z.literal("hello"),
|
||||
sessionPermissions: z.array(DaemonPermissionSchema).optional(),
|
||||
routed: z.boolean().optional(),
|
||||
producer: z
|
||||
.object({ serverId: z.string().min(1).max(256), sourceId: z.string().min(1).max(1024) })
|
||||
.optional(),
|
||||
clientId: z.string().min(1),
|
||||
clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]),
|
||||
protocolVersion: z.number().int(),
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts\"",
|
||||
"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,6 +6,13 @@ type OutboundOperation = SessionOutboundMessage["type"];
|
||||
export type PermissionRequirement = DaemonPermission | readonly DaemonPermission[] | null;
|
||||
|
||||
const INBOUND_PERMISSION = {
|
||||
"hosts.list.request": "daemon.read",
|
||||
"hosts.add.request": "tunnel.manage",
|
||||
"hosts.update.request": "tunnel.manage",
|
||||
"hosts.remove.request": "tunnel.manage",
|
||||
"hosts.route.open.request": null,
|
||||
"hosts.route.frame.request": null,
|
||||
"hosts.route.close.request": null,
|
||||
abort_request: "workspace.write",
|
||||
"agent.config.apply.request": ["workspace.write", "hub.execute"],
|
||||
"agent.detach.request": "workspace.write",
|
||||
@@ -215,6 +222,14 @@ 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",
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,531 @@
|
||||
export { ConnectedHostSession } from "./session.js";
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createHostClient,
|
||||
createHostTransportFactory,
|
||||
parseHostTarget,
|
||||
HostTransportBudget,
|
||||
} from "@getpaseo/client/node";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
HostSummarySchema,
|
||||
HostAddSchema,
|
||||
HostUpdateSchema,
|
||||
type HostDirectory,
|
||||
type HostSummary,
|
||||
type HostAdd,
|
||||
type HostUpdate,
|
||||
} from "@getpaseo/protocol/hosts";
|
||||
import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages";
|
||||
|
||||
export class HostError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly serverId?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
const SavedSchema = HostSummarySchema.extend({ connection: z.string() });
|
||||
type Saved = z.infer<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;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type {
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
DaemonPermission,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { ConnectedHosts, HostError, type HostActor } from "./index.js";
|
||||
import type { SessionDelivery } from "../session/owned-subscriptions/index.js";
|
||||
|
||||
type HostRequest<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,3 +1,14 @@
|
||||
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";
|
||||
@@ -194,9 +205,9 @@ async function waitForCapturedLog(
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
let relayStdoutLines: string[] = [];
|
||||
|
||||
const startRelay = async (options: { useLocalRelay?: boolean } = {}) => {
|
||||
const startRelay = async (options: { useLocalRelay?: boolean; port?: number } = {}) => {
|
||||
relayStdoutLines = [];
|
||||
relayPort = await getAvailablePort();
|
||||
relayPort = options.port ?? (await getAvailablePort());
|
||||
const relayDir = path.resolve(process.cwd(), "../relay");
|
||||
const relayArgs = [
|
||||
"wrangler",
|
||||
@@ -255,10 +266,142 @@ async function waitForCapturedLog(
|
||||
|
||||
const stopRelay = async () => {
|
||||
if (!relayProcess) return;
|
||||
relayProcess.kill("SIGTERM");
|
||||
const process = relayProcess;
|
||||
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({
|
||||
@@ -932,3 +1075,73 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AgentDirectory } from "./agent/directory.js";
|
||||
import { ConnectedHostSession } from "./connected-hosts/index.js";
|
||||
import type { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import { searchTimeline } from "./agent/chat-search/index.js";
|
||||
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities";
|
||||
@@ -441,6 +443,9 @@ const nodeSessionFileSystem: SessionFileSystem = {
|
||||
type AgentMcpTransportFactory = () => Promise<unknown>;
|
||||
|
||||
export interface SessionOptions {
|
||||
connectedHosts?: ConnectedHosts;
|
||||
sourceId?: string;
|
||||
routed?: boolean;
|
||||
browserToolsBroker?: BrowserToolsBroker | null;
|
||||
clientId: string;
|
||||
permissions: readonly DaemonPermission[];
|
||||
@@ -451,6 +456,7 @@ 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;
|
||||
@@ -673,6 +679,22 @@ 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;
|
||||
@@ -862,9 +884,12 @@ export class Session {
|
||||
this.browserToolsBroker = options.browserToolsBroker;
|
||||
this.clientId = clientId;
|
||||
this.authorization = new SessionAuthorization(permissions);
|
||||
|
||||
this.appVersion = appVersion ?? null;
|
||||
this.clientCapabilities = parseClientCapabilities(clientCapabilities);
|
||||
this.sessionId = uuidv4();
|
||||
this.hostSourceId = hostSource(options);
|
||||
this.connectedHostSession = createHostSession(options, this);
|
||||
this.onMessage = onMessage;
|
||||
this.onMessageToSource = onMessageToSource ?? null;
|
||||
this.onBinaryMessage = onBinaryMessage ?? null;
|
||||
@@ -2159,7 +2184,7 @@ export class Session {
|
||||
requestId,
|
||||
requestType: msg.type,
|
||||
error: `Session is not authorized for ${msg.type}`,
|
||||
code: "access_denied",
|
||||
code: msg.type.startsWith("hosts.") ? "permission_denied" : "access_denied",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2206,6 +2231,7 @@ export class Session {
|
||||
|
||||
public setPermissions(permissions: readonly DaemonPermission[]): void {
|
||||
this.authorization.replacePermissions(permissions);
|
||||
this.connectedHostSession?.revoke();
|
||||
if (!this.authorization.allowsPermission("workspace.write")) {
|
||||
void this.delivery
|
||||
.releaseFamily("browser-host")
|
||||
@@ -2320,11 +2346,10 @@ 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.dispatchAgentRewindMessage(msg, source) ??
|
||||
this.dispatchAgentRelationshipMessage(msg) ??
|
||||
this.dispatchAgentTimelineMessage(msg, source) ??
|
||||
this.dispatchAgentObservationMessage(msg, source) ??
|
||||
this.dispatchHubExecutionMessage(msg) ??
|
||||
this.dispatchCreationMessage(msg, source) ??
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
@@ -2342,6 +2367,17 @@ 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) ??
|
||||
@@ -8684,3 +8720,21 @@ function legacyWantsEvent(
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function createHostSession(options: SessionOptions, session: Session): ConnectedHostSession | null {
|
||||
if (!options.connectedHosts) return null;
|
||||
return new ConnectedHostSession(
|
||||
options.connectedHosts,
|
||||
session.delivery,
|
||||
{ sourceId: options.sourceId ?? "owner", manageSources: false },
|
||||
() => session.getPermissions(),
|
||||
(message) => session.publish(message),
|
||||
() => session.isRouted(),
|
||||
session.getSessionId(),
|
||||
() => options.getConnectedHostBufferedAmount?.() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function hostSource(options: SessionOptions): string {
|
||||
return options.sourceId ?? "owner";
|
||||
}
|
||||
|
||||
@@ -71,6 +71,18 @@ 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) => {
|
||||
@@ -473,16 +485,12 @@ function holdNextSessionMessage(session: (typeof sessionMock.instances)[number])
|
||||
function holdSessionCleanup(session: (typeof sessionMock.instances)[number]): {
|
||||
finish: () => void;
|
||||
} {
|
||||
let finish = () => {};
|
||||
session.cleanup.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
finish: () => finish(),
|
||||
};
|
||||
let finish!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
session.cleanup.mockImplementationOnce(() => pending);
|
||||
return { finish };
|
||||
}
|
||||
|
||||
describe("relay external socket reconnect behavior", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import { CreationService } from "./creation/index.js";
|
||||
@@ -437,6 +438,7 @@ export interface WebSocketLike {
|
||||
}
|
||||
|
||||
interface SessionConnectionBase {
|
||||
permissionCeiling?: readonly DaemonPermission[];
|
||||
session: Session;
|
||||
principalId: string;
|
||||
sessionKey: string;
|
||||
@@ -460,6 +462,7 @@ interface PluginSessionConnection extends SessionConnectionBase {
|
||||
type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
|
||||
|
||||
interface SocketSessionOptions {
|
||||
sourceId?: string;
|
||||
clientId: string;
|
||||
appVersion: string | null;
|
||||
clientCapabilities: Record<string, unknown> | null;
|
||||
@@ -550,6 +553,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly workspaceAutoName: WorkspaceAutoName;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly paseoHome: string;
|
||||
private readonly connectedHosts: ConnectedHosts;
|
||||
private readonly passwordHash: string | undefined;
|
||||
private readonly credentialSource: DaemonAuthConfig | undefined;
|
||||
private readonly worktreesRoot: string | undefined;
|
||||
@@ -701,6 +705,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.workspaceAutoName = workspaceAutoName;
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.connectedHosts = new ConnectedHosts(paseoHome, { serverId, label: getHostName() });
|
||||
this.passwordHash = auth?.password;
|
||||
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
|
||||
this.daemonConfigStore = daemonConfigStore;
|
||||
@@ -1047,7 +1052,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
for (const connection of new Set(this.externalSessionsByKey.values())) {
|
||||
if (connection.principalId === principalId) {
|
||||
connection.session.setPermissions(permissions);
|
||||
connection.session.setPermissions(
|
||||
connection.permissionCeiling
|
||||
? permissions.filter((permission) => connection.permissionCeiling!.includes(permission))
|
||||
: permissions,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1064,6 +1073,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.prepareForShutdown();
|
||||
await this.connectedHosts.close();
|
||||
this.unsubscribeSpeechReadiness?.();
|
||||
this.unsubscribeSpeechReadiness = null;
|
||||
this.unsubscribeDaemonConfigChange?.();
|
||||
@@ -1387,6 +1397,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
appVersion,
|
||||
clientCapabilities,
|
||||
permissions: admission.permissions,
|
||||
sourceId:
|
||||
lifecycle.kind === "ephemeral-plugin"
|
||||
? `plugin:${lifecycle.pluginId}`
|
||||
: admission.principalId,
|
||||
connectionLogger,
|
||||
onMessage: (msg) => {
|
||||
if (!connection) {
|
||||
@@ -1456,6 +1470,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
private createSocketSession(options: SocketSessionOptions): Session {
|
||||
return new Session({
|
||||
connectedHosts: this.connectedHosts,
|
||||
getConnectedHostBufferedAmount: () => {
|
||||
let total = 0;
|
||||
for (const socket of this.sessions.keys()) {
|
||||
if (typeof socket.bufferedAmount !== "number") return null;
|
||||
total += socket.bufferedAmount;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
sourceId: options.sourceId,
|
||||
browserToolsBroker: this.browserToolsBroker,
|
||||
clientId: options.clientId,
|
||||
appVersion: options.appVersion,
|
||||
@@ -1609,8 +1633,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
this.clearPendingConnection(ws);
|
||||
const admitted = pending.admission;
|
||||
let admitted = pending.admission;
|
||||
if (!admitted) throw new Error("Admitted hello has no session admission");
|
||||
if (message.sessionPermissions)
|
||||
admitted = {
|
||||
...admitted,
|
||||
permissions: admitted.permissions.filter((permission) =>
|
||||
message.sessionPermissions!.includes(permission),
|
||||
),
|
||||
};
|
||||
pending.admission = admitted;
|
||||
pending.identity.clientId = clientId;
|
||||
if (message.appVersion) {
|
||||
pending.identity.appVersion = message.appVersion;
|
||||
@@ -1633,6 +1665,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" },
|
||||
admission: admitted,
|
||||
});
|
||||
connection.permissionCeiling = message.sessionPermissions;
|
||||
connection.session.setRouted(message.routed === true);
|
||||
this.assignHostSource(connection, message, admitted.principalId, pluginId);
|
||||
this.sessions.set(ws, connection);
|
||||
if (connection.lifecycle === "reconnectable") {
|
||||
this.externalSessionsByKey.set(sessionKey, connection);
|
||||
@@ -1748,6 +1783,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
clearTimeout(existing.externalDisconnectCleanupTimeout);
|
||||
existing.externalDisconnectCleanupTimeout = null;
|
||||
}
|
||||
existing.permissionCeiling = message.sessionPermissions;
|
||||
existing.session.setPermissions(pending.admission!.permissions);
|
||||
existing.session.setRouted(message.routed === true);
|
||||
this.assignHostSource(existing, message, pending.admission!.principalId);
|
||||
const newAppVersion = message.appVersion ?? null;
|
||||
if (newAppVersion && newAppVersion !== existing.appVersion) {
|
||||
existing.appVersion = newAppVersion;
|
||||
@@ -1778,6 +1817,21 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
|
||||
private assignHostSource(
|
||||
connection: SessionConnection,
|
||||
hello: WSHelloMessage,
|
||||
principalId: string,
|
||||
pluginId?: string,
|
||||
): void {
|
||||
if (pluginId) return;
|
||||
const producer = hello.producer;
|
||||
const sourceId =
|
||||
hello.routed && producer
|
||||
? `route:${[principalId, producer.serverId, producer.sourceId].map(encodeURIComponent).join(":")}`
|
||||
: principalId;
|
||||
connection.session.setHostSource(sourceId);
|
||||
}
|
||||
|
||||
private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload {
|
||||
return {
|
||||
status: "server_info",
|
||||
@@ -1785,11 +1839,13 @@ 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,
|
||||
@@ -2078,6 +2134,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.finishPluginSocketCleanup(ws);
|
||||
return;
|
||||
}
|
||||
if (connection.session.isRouted()) {
|
||||
await this.cleanupConnection(connection, "Routed session disconnected");
|
||||
return;
|
||||
}
|
||||
this.incrementRuntimeCounter("sessionDisconnectedWaitingReconnect");
|
||||
if (connection.externalDisconnectCleanupTimeout) {
|
||||
clearTimeout(connection.externalDisconnectCleanupTimeout);
|
||||
|
||||
@@ -64,6 +64,31 @@ Entries may also include `loginErrors`. Structured output preserves numeric valu
|
||||
|
||||
A completed query exits with status `0`, including when individual reports are unavailable or failed. Connection or request failures, unsupported hosts, unknown report IDs, and conflicting selectors exit with status `1` and write the error to stderr. Human output says **No usage reports returned** for an empty result; this does not establish whether account discovery succeeded for every source.
|
||||
|
||||
## Connected hosts
|
||||
|
||||
`--host` selects the daemon whose saved connections you manage. Registrations use their
|
||||
`registrationId`, not their label.
|
||||
|
||||
```sh
|
||||
paseo --host localhost:6767 host add 'ssh://deploy@build-box' --label build-box --meta team=infra
|
||||
paseo --host localhost:6767 host ls --json
|
||||
paseo --host localhost:6767 host inspect hst_123
|
||||
paseo --host localhost:6767 host update hst_123 --meta team=tools --remove-meta obsolete
|
||||
paseo --host localhost:6767 host update hst_123 --connection 'ssh://deploy@new-address'
|
||||
paseo --host localhost:6767 host disable hst_123
|
||||
paseo --host localhost:6767 host enable hst_123
|
||||
paseo --host localhost:6767 host rm hst_123
|
||||
```
|
||||
|
||||
Add accepts TCP/host-and-port, `ws://`/`wss://`, local socket/pipe, SSH, and relay/pairing URLs.
|
||||
SSH connects to an already running daemon. Add `?password=...` to an SSH URI when that daemon requires a password (`&password=...` after `daemonPort`). Unreachable targets remain saved and retry automatically.
|
||||
`ls` includes disabled and failed hosts. `inspect` includes safe connection details and recovery guidance.
|
||||
JSON output never includes connection credentials.
|
||||
|
||||
`update --meta` replaces supplied keys; `--remove-meta` removes keys. Replacing a connection retains
|
||||
the pinned daemon identity. Add `--replace-identity` with `--connection` only after verifying a changed
|
||||
destination. `rm` removes the saved connection; agents and workspaces on that host keep running.
|
||||
|
||||
## Provider diagnostics
|
||||
|
||||
Ask the daemon to inspect the provider environment it actually uses:
|
||||
|
||||
@@ -26,6 +26,26 @@ Local plugins are directory sources installed into one Paseo daemon. A plugin ca
|
||||
|
||||
Plugin code is trusted and unsandboxed. Client surfaces run in the Paseo app. Backend contributions run in a subprocess with access to the daemon machine, including its files, processes, credentials, and network.
|
||||
|
||||
## Connected hosts
|
||||
|
||||
`usePaseo().hosts` in a client surface and `context.paseo.hosts` in a server handler or lifecycle hook
|
||||
use the same [SDK host API](/sdk/reference#clienthosts). They describe the selected daemon's saved
|
||||
connections. Existing `useHosts()` and `getPaseoClient(serverId)` keep describing app-known hosts.
|
||||
|
||||
```ts
|
||||
const remote = await context.paseo.hosts.client("srv_build_box");
|
||||
try {
|
||||
return await remote.workspaces.list();
|
||||
} finally {
|
||||
await remote.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
A returned API owns its observations. Teardown of the plugin API also disposes derived scopes.
|
||||
Keep remote calls inside the handler or lifecycle scope that owns them. Calling B does not forward
|
||||
B's plugin lifecycle hooks to A. Registrations created by a routed plugin retain a source qualified
|
||||
by the authenticated link and originating plugin.
|
||||
|
||||
## Project files
|
||||
|
||||
`paseo plugin init /absolute/path/to/my-plugin` creates:
|
||||
|
||||
@@ -203,6 +203,57 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options
|
||||
|
||||
`config.patch(patch, requestId?)` validates, persists, and returns an updated configuration. Use this administrative surface for host configuration, not per-agent choices. A patch affects every client and future agent using that daemon.
|
||||
|
||||
## `client.hosts`
|
||||
|
||||
Manage hosts connected to the selected daemon. Both the managing daemon and destination must support connected hosts.
|
||||
|
||||
```ts
|
||||
const directory = await client.hosts.list();
|
||||
const registration = await client.hosts.add({
|
||||
connection: "ssh://deploy@build-box",
|
||||
label: "Build box",
|
||||
metadata: { team: "infra" },
|
||||
sourceKey: "build-box",
|
||||
});
|
||||
await client.hosts.update(registration.registrationId, { enabled: false });
|
||||
await client.hosts.remove(registration.registrationId);
|
||||
```
|
||||
|
||||
`list()` returns `{ self: { serverId, label }, hosts }`. Each host includes its registration and server
|
||||
IDs, label, authenticated source, string metadata, enabled state, connection status, safe target
|
||||
description, last-online/retry timestamps, and an actionable error. Credentials are never returned.
|
||||
A valid but unreachable connection is saved and retried. Invalid syntax fails without saving.
|
||||
|
||||
`update(registrationId, patch)` accepts `label`, `metadata`, `enabled`, `connection`, and
|
||||
`replaceIdentity`. Metadata replaces the whole map. Replacing a connection preserves the expected
|
||||
server ID unless `replaceIdentity: true` explicitly accepts a new destination.
|
||||
|
||||
`observe()` returns an owned subscription. Await `ready`, subscribe for snapshots and `hosts.changed`
|
||||
updates, then call `release()`. Observations restore a current snapshot after reconnect.
|
||||
|
||||
```ts
|
||||
const remote = await client.hosts.client("srv_build_box");
|
||||
try {
|
||||
const workspaces = await remote.workspaces.list();
|
||||
const providers = await remote.providers.listAvailable();
|
||||
await remote.agents.ref("agent_on_build_box").send("Continue the investigation");
|
||||
} finally {
|
||||
await remote.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
`client(serverId)` returns a caller-owned `PaseoApi` bound to that destination through the selected
|
||||
daemon. The ordinary APIs keep their signatures. Paths and IDs belong to the destination. Selecting
|
||||
the local server ID returns a local scope. Routing supports one hop.
|
||||
|
||||
Disposing one scope releases its observations without closing siblings or deleting the saved host.
|
||||
Origin disposal cascades. Offline operations fail; observations recover. A lost result reports
|
||||
`outcome_unknown` and the destination server ID. Inspect before retrying a mutation.
|
||||
|
||||
Host failures expose a stable `code`: `host_unknown`, `host_disabled`, `host_offline`,
|
||||
`permission_denied`, `unsupported_capability`, `authentication_failed`, `server_id_mismatch`,
|
||||
`self_host`, or `duplicate_host`. Discovery does not grant execution authority.
|
||||
|
||||
## Errors and cleanup
|
||||
|
||||
Connection, validation, rejection, and timeout failures reject their promise. Turn outcomes are returned through `PaseoAgentRunResult.status` because permission and provider errors are expected agent states.
|
||||
|
||||
@@ -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"',
|
||||
'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"',
|
||||
);
|
||||
const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8");
|
||||
assert.doesNotMatch(serverConfig, /maxWorkers/);
|
||||
|
||||
Reference in New Issue
Block a user